From 78c01757b64975fd8b36d273ef832509b8b87c55 Mon Sep 17 00:00:00 2001 From: Matthew de Detrich Date: Wed, 20 May 2020 18:17:45 +0200 Subject: [PATCH] Add HttpServiceError plus update Problem --- .../org/mdedetrich/webmodels/Platform.scala | 21 +++ .../org/mdedetrich/webmodels/Platform.scala | 45 +++++ .../webmodels/HttpServiceError.scala | 163 ++++++++++++++++++ .../org/mdedetrich/webmodels/Problem.scala | 40 ++++- .../org/mdedetrich/webmodels/circe.scala | 18 +- version.sbt | 2 +- 6 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 js/src/main/scala/org/mdedetrich/webmodels/Platform.scala create mode 100644 jvm/src/main/scala/org/mdedetrich/webmodels/Platform.scala create mode 100644 shared/src/main/scala/org/mdedetrich/webmodels/HttpServiceError.scala diff --git a/js/src/main/scala/org/mdedetrich/webmodels/Platform.scala b/js/src/main/scala/org/mdedetrich/webmodels/Platform.scala new file mode 100644 index 0000000..c75166e --- /dev/null +++ b/js/src/main/scala/org/mdedetrich/webmodels/Platform.scala @@ -0,0 +1,21 @@ +package org.mdedetrich.webmodels + +private[webmodels] object Platform { + + /** + * Note that this only works for non negative int's but since we are using it for HTTP codes it should + * be fine + * @param digit + * @param value + * @return + */ + def checkFirstDigitOfInt(digit: Int, value: Int): Boolean = { + var x = value + + while ({ + x > 9 + }) x /= 10 + x == digit + } + +} diff --git a/jvm/src/main/scala/org/mdedetrich/webmodels/Platform.scala b/jvm/src/main/scala/org/mdedetrich/webmodels/Platform.scala new file mode 100644 index 0000000..50c3b00 --- /dev/null +++ b/jvm/src/main/scala/org/mdedetrich/webmodels/Platform.scala @@ -0,0 +1,45 @@ +package org.mdedetrich.webmodels + +private[webmodels] object Platform { + /** + * Logic taken for figuring out fast way to calculate first digit + * of Int taken using OldCurmudgeon's method is + * taken from https://stackoverflow.com/a/18054242. This uses + */ + + private val limits: Array[Int] = Array[Int]( + 2000000000, Integer.MAX_VALUE, + 200000000, + 300000000 - 1, 20000000, + 30000000 - 1, + 2000000, + 3000000 - 1, + 200000, + 300000 - 1, + 20000, + 30000 - 1, + 2000, + 3000 - 1, + 200, + 300 - 1, + 20, + 30 - 1, + 2, + 3 - 1 + ) + + def checkFirstDigitOfInt(digit: Int, value: Int): Boolean = { + var i = 0 + while ( { + i < limits.length + }) { + if (value > limits(i + 1)) return false + if (value >= limits(i)) return true + + i += digit + } + false + } + + +} 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..5736d01 --- /dev/null +++ b/shared/src/main/scala/org/mdedetrich/webmodels/HttpServiceError.scala @@ -0,0 +1,163 @@ +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 + */ + 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 + + /** + * Indicates whether this error is due to a missing resource, i.e. 404 case + */ + def resourceMissingError: Boolean = statusCode.toString.startsWith("404") + + /** + * Indicates whether this error was caused due to a client error (i.e. + * the client is somehow sending a bad request). Retrying such requests + * are often pointless. + */ + def clientError: Boolean = Platform.checkFirstDigitOfInt(4, statusCode) + + /** + * Indicates whether this error was caused due to a server problem. + * Such requests are often safe to retry (ideally with an exponential delay) + * as long as the request is idempotent. + */ + def serverError: Boolean = Platform.checkFirstDigitOfInt(5, statusCode) + + /** + * 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..d59418a 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,45 @@ 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 { + final val DefaultType: URI = new URI("about:blank") + + /** + * Problem Details for HTTP APIs. Constructs a problem with the default `type` + * value of about:blank + * + * @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 + */ + final def withDefault(title: Option[String] = None, + status: Option[Int] = None, + detail: Option[String] = None, + instance: Option[String] = None, + extraFields: JsonObject = JsonObject.empty): Problem = Problem( + DefaultType, + title, + status, + detail, + instance, + extraFields + ) +} diff --git a/shared/src/main/scala/org/mdedetrich/webmodels/circe.scala b/shared/src/main/scala/org/mdedetrich/webmodels/circe.scala index 0fab1c2..533530b 100644 --- a/shared/src/main/scala/org/mdedetrich/webmodels/circe.scala +++ b/shared/src/main/scala/org/mdedetrich/webmodels/circe.scala @@ -1,8 +1,11 @@ package org.mdedetrich.webmodels +import java.net.{URI, URISyntaxException} + import io.circe._ import io.circe.syntax._ import cats.syntax.either._ +import io.circe.Decoder.Result object circe { implicit val correlationIdDecoder: Decoder[CorrelationId] = Decoder[String].map(CorrelationId) @@ -14,10 +17,23 @@ object circe { implicit val oAuth2TokenDecoder: Decoder[OAuth2Token] = Decoder[String].map(OAuth2Token) implicit val oAuth2TokenEncoder: Encoder[OAuth2Token] = Encoder.instance[OAuth2Token](_.value.asJson) + private implicit val uriDecoder: Decoder[URI] = new Decoder[URI] { + override def apply(c: HCursor): Result[URI] = c.as[String].flatMap { string => + try { + Right(new URI(string)) + } catch { + case e: URISyntaxException => + Left(DecodingFailure(s"Invalid URI ${e.getMessage}", c.history)) + } + } + } + + private implicit val uriEncoder: Encoder[URI] = Encoder.encodeString.contramap(_.toString) + implicit val problemDecoder: Decoder[Problem] = Decoder.instance[Problem] { c => for { jsonObject <- c.as[JsonObject] - problemType <- c.downField("type").as[String] + problemType <- c.downField("type").as[URI] problemTitle <- c.downField("title").as[Option[String]] problemStatus <- c.downField("status").as[Option[Int]] problemDetail <- c.downField("detail").as[Option[String]] 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"