Skip to content

Commit

Permalink
Add HttpServiceError plus update Problem
Browse files Browse the repository at this point in the history
  • Loading branch information
mdedetrich committed May 22, 2020
1 parent 203ba43 commit d874830
Show file tree
Hide file tree
Showing 3 changed files with 233 additions and 2 deletions.
151 changes: 151 additions & 0 deletions shared/src/main/scala/org/mdedetrich/webmodels/HttpServiceError.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package org.mdedetrich.webmodels

import circe._
import io.circe._
import io.circe.syntax._

/**
* `ResponseContent` provides a convenient abstraction for working with REST'ful HTTP
* API's that return RFC3986 Problem in error cases. `ResponseContent` makes the
* assumption that the web services that you work with do mainly return RFC3986
* Problem however `ResponseContent` also provides fallback data types
* (`ResponseContent.JSON`/`ResponseContent.String`) that lets you easily handle
* cases where the response of a request isn't a valid Problem JSON (such cases
* are not uncommon when you have load balancer's/reverse proxies sitting infront of
* webserver's).
*/
sealed abstract class ResponseContent extends Product with Serializable {

/**
* Checks to see if the [[ResponseContent]] is JSON and contains a JSON field that satisfies a predicate.
*
* @param field The JSON field to check
* @param predicate The predicate
* @return Whether the predicate was satisfied. Always returns `false` if the this is a [[ResponseContent.String]]
*/
def checkJsonField(field: String, predicate: Json => Boolean): Boolean =
this match {
case ResponseContent.Problem(problem) =>
problem.asJson.findAllByKey(field).exists(predicate)
case ResponseContent.Json(json) =>
json
.findAllByKey(field)
.exists(predicate)
case ResponseContent.String(_) => false
}

/**
* A combination of [[checkJsonField]] that also checks if the resulting
* JSON field is a String that satisfies a predicate.
* @param field The JSON field to look for
* @param predicate The predicate to satisfy
*/
def checkJsonFieldAsString(field: String, predicate: String => Boolean): Boolean =
checkJsonField(field, _.asString.exists(predicate))

/**
* Checks to see if the [[ResponseContent]] contains a specific String, regardless
* in what format its stored
*/
def checkString(predicate: String => Boolean): Boolean =
this match {
case ResponseContent.Problem(problem) =>
predicate(problem.asJson.noSpaces)
case ResponseContent.Json(json) =>
predicate(json.noSpaces)
case ResponseContent.String(string) =>
predicate(string)
}
}

object ResponseContent {

/**
* This case happens if the response of the Http request is a valid Problem according to
* RFC7807. This means that the JSON response content is a JSON object that contains the field named `type`
* and all other fields (if they exist) satisfy the RFC3986 specification (i.e. the `type` field is
* valid URI)
* @see https://tools.ietf.org/html/rfc7807
* @param problem
*/
final case class Problem(problem: org.mdedetrich.webmodels.Problem) extends ResponseContent

/**
* This case happens if the response of the HTTP request is JSON but it sn't a valid RFC3986 Problem.
* This means that either the mandatory `type` field isn't in the JSON response and/or the other fields
* specific to Problem don't follow all of the RFC3986 specification (i.e. the `type` field is
* not a valid URI)
* @see https://tools.ietf.org/html/rfc7159
*/
final case class Json(json: io.circe.Json) extends ResponseContent

/**
* This case happens if the body content is not valid JSON according to RFC7159
*/
final case class String(string: java.lang.String) extends ResponseContent
}

/**
* The purpose of this data type is to provide a common way of dealing
* with errors from REST'ful HTTP APi's making it particularly useful
* for strongly typed clients to web services.
*
* `HttpServiceError` makes no assumptions about what HTTP client you
* happen to be using which makes it a great candidate for having a
* common error type in projects that have to juggle with
* multiple HTTP clients. Since `HttpServiceError` is a trait, it can easily be
* extended with existing error types that your library/application may happen
* to have.
*
* Due to the fact that `HttpServiceError` is meant abstract over different HTTP
* clients, it exposes methods that provides the minimum necessary data commonly
* needed to properly identify errors without exposing too much about the HTTP
* client itself. Examples of such methods are `statusCode`, `responseContent`
* and `responseHeaders`.
*/
trait HttpServiceError {

/**
* Type Type of the HttpRequest object from the original Http Client
*/
type HttpRequest

/**
* The type of the HttpResponse object from the original Http Client
*/
type HttpResponse

/**
* The original request that gave this response
*/
def request: HttpRequest

/**
* The original response
*/
def response: HttpResponse

/**
* The content of the response represented as a convenient
* data type
*/
def responseContent: ResponseContent

/**
* The status code of the response
*/
def statusCode: Int

/**
* Helper method which indicates whether this error is due to a missing
* resource
*/
def resourceMissingError: Boolean = statusCode.toString.startsWith("404")

/**
* The headers of the response without any alterations made
* (i.e. any duplicate fields/ordering should remained untouched
* from the original response).
*/
def responseHeaders: IndexedSeq[(String, String)]
}
82 changes: 81 additions & 1 deletion shared/src/main/scala/org/mdedetrich/webmodels/Problem.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.mdedetrich.webmodels

import java.net.{URI, URISyntaxException}

import io.circe.JsonObject

/**
Expand All @@ -26,9 +28,87 @@ import io.circe.JsonObject
* @param extraFields Any extra fields placed into the problem object that
* aren't part of the standard
*/
final case class Problem(`type`: String,
final case class Problem(`type`: URI,
title: Option[String] = None,
status: Option[Int] = None,
detail: Option[String] = None,
instance: Option[String] = None,
extraFields: JsonObject = JsonObject.empty)

object Problem {

/**
* Problem Details for HTTP APIs
*
* @see https://tools.ietf.org/html/rfc7807
* @param title A short, human-readable summary of the problem
*type. It SHOULD NOT change from occurrence to occurrence of the
*problem, except for purposes of localization (e.g., using
*proactive content negotiation; se
* @param status The HTTP status code ([RFC7231], Section 6)
*generated by the origin server for this occurrence of the problem.
* @param detail A human-readable explanation specific to this
*occurrence of the problem.
* @param instance A URI reference that identifies the specific
*occurrence of the problem. It may or may not yield further
*information if dereferenced.
* @param extraFields Any extra fields placed into the problem object that
* aren't part of the standard
*/
def apply(title: Option[String] = None,
status: Option[Int] = None,
detail: Option[String] = None,
instance: Option[String] = None,
extraFields: JsonObject = JsonObject.empty): Problem = Problem(
new URI("about:blank"),
title,
status,
detail,
instance,
extraFields
)

/**
* Problem Details for HTTP APIs
*
* @see https://tools.ietf.org/html/rfc7807
* @param `type` A URI reference [RFC3986] that identifies the
*problem type. This specification encourages that, when
*dereferenced, it provide human-readable documentation for the
*problem type (e.g., using HTML [W3C.REC-html5-20141028]). When
*this member is not present, its value is assumed to be
*"about:blank".
* @param title A short, human-readable summary of the problem
*type. It SHOULD NOT change from occurrence to occurrence of the
*problem, except for purposes of localization (e.g., using
*proactive content negotiation; se
* @param status The HTTP status code ([RFC7231], Section 6)
*generated by the origin server for this occurrence of the problem.
* @param detail A human-readable explanation specific to this
*occurrence of the problem.
* @param instance A URI reference that identifies the specific
*occurrence of the problem. It may or may not yield further
*information if dereferenced.
* @param extraFields Any extra fields placed into the problem object that
* aren't part of the standard
*/
def apply(`type`: String,
title: Option[String] = None,
status: Option[Int] = None,
detail: Option[String] = None,
instance: Option[String] = None,
extraFields: JsonObject = JsonObject.empty): Option[Problem] =
try {
Some(
Problem(
new URI(`type`),
title,
status,
detail,
instance,
extraFields
))
} catch {
case _: URISyntaxException => None
}
}
2 changes: 1 addition & 1 deletion version.sbt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
version in ThisBuild := "0.8.1"
version in ThisBuild := "0.9.0"

0 comments on commit d874830

Please sign in to comment.