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..4e4cc7e --- /dev/null +++ b/js/src/main/scala/org/mdedetrich/webmodels/Platform.scala @@ -0,0 +1,18 @@ +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..51986ee --- /dev/null +++ b/jvm/src/main/scala/org/mdedetrich/webmodels/Platform.scala @@ -0,0 +1,44 @@ +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..fa4e8ad --- /dev/null +++ b/shared/src/main/scala/org/mdedetrich/webmodels/HttpServiceError.scala @@ -0,0 +1,147 @@ +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 +} + +final case class Header(name: String, value: String) + +/** 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[Header] +} diff --git a/shared/src/main/scala/org/mdedetrich/webmodels/circe.scala b/shared/src/main/scala/org/mdedetrich/webmodels/circe.scala index 337dc1e..8b62a4c 100644 --- a/shared/src/main/scala/org/mdedetrich/webmodels/circe.scala +++ b/shared/src/main/scala/org/mdedetrich/webmodels/circe.scala @@ -1,5 +1,7 @@ package org.mdedetrich.webmodels +import java.net.{URI, URISyntaxException} + import io.circe._ import io.circe.syntax._