diff --git a/shared/src/main/scala/org/mdedetrich/webmodels/HttpServiceError.scala b/shared/src/main/scala/org/mdedetrich/webmodels/HttpServiceError.scala new file mode 100644 index 0000000..6765ab8 --- /dev/null +++ b/shared/src/main/scala/org/mdedetrich/webmodels/HttpServiceError.scala @@ -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, i.e. 404 case + */ + 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)] +} diff --git a/shared/src/main/scala/org/mdedetrich/webmodels/Problem.scala b/shared/src/main/scala/org/mdedetrich/webmodels/Problem.scala index 388a857..91e9377 100644 --- a/shared/src/main/scala/org/mdedetrich/webmodels/Problem.scala +++ b/shared/src/main/scala/org/mdedetrich/webmodels/Problem.scala @@ -1,5 +1,7 @@ package org.mdedetrich.webmodels +import java.net.{URI, URISyntaxException} + import io.circe.JsonObject /** @@ -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 + } +} diff --git a/version.sbt b/version.sbt index ccb5c05..1b5f9da 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -version in ThisBuild := "0.8.1" +version in ThisBuild := "0.9.0"