Skip to content

Commit 78c0175

Browse files
committed
Add HttpServiceError plus update Problem
1 parent 203ba43 commit 78c0175

File tree

6 files changed

+286
-3
lines changed

6 files changed

+286
-3
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.mdedetrich.webmodels
2+
3+
private[webmodels] object Platform {
4+
5+
/**
6+
* Note that this only works for non negative int's but since we are using it for HTTP codes it should
7+
* be fine
8+
* @param digit
9+
* @param value
10+
* @return
11+
*/
12+
def checkFirstDigitOfInt(digit: Int, value: Int): Boolean = {
13+
var x = value
14+
15+
while ({
16+
x > 9
17+
}) x /= 10
18+
x == digit
19+
}
20+
21+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package org.mdedetrich.webmodels
2+
3+
private[webmodels] object Platform {
4+
/**
5+
* Logic taken for figuring out fast way to calculate first digit
6+
* of Int taken using OldCurmudgeon's method is
7+
* taken from https://stackoverflow.com/a/18054242. This uses
8+
*/
9+
10+
private val limits: Array[Int] = Array[Int](
11+
2000000000, Integer.MAX_VALUE,
12+
200000000,
13+
300000000 - 1, 20000000,
14+
30000000 - 1,
15+
2000000,
16+
3000000 - 1,
17+
200000,
18+
300000 - 1,
19+
20000,
20+
30000 - 1,
21+
2000,
22+
3000 - 1,
23+
200,
24+
300 - 1,
25+
20,
26+
30 - 1,
27+
2,
28+
3 - 1
29+
)
30+
31+
def checkFirstDigitOfInt(digit: Int, value: Int): Boolean = {
32+
var i = 0
33+
while ( {
34+
i < limits.length
35+
}) {
36+
if (value > limits(i + 1)) return false
37+
if (value >= limits(i)) return true
38+
39+
i += digit
40+
}
41+
false
42+
}
43+
44+
45+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package org.mdedetrich.webmodels
2+
3+
import circe._
4+
import io.circe._
5+
import io.circe.syntax._
6+
7+
/**
8+
* `ResponseContent` provides a convenient abstraction for working with REST'ful HTTP
9+
* API's that return RFC3986 Problem in error cases. `ResponseContent` makes the
10+
* assumption that the web services that you work with do mainly return RFC3986
11+
* Problem however `ResponseContent` also provides fallback data types
12+
* (`ResponseContent.JSON`/`ResponseContent.String`) that lets you easily handle
13+
* cases where the response of a request isn't a valid Problem JSON (such cases
14+
* are not uncommon when you have load balancer's/reverse proxies sitting infront of
15+
* webserver's).
16+
*/
17+
sealed abstract class ResponseContent extends Product with Serializable {
18+
19+
/**
20+
* Checks to see if the [[ResponseContent]] is JSON and contains a JSON field that satisfies a predicate.
21+
*
22+
* @param field The JSON field to check
23+
* @param predicate The predicate
24+
* @return Whether the predicate was satisfied. Always returns `false` if the this is a [[ResponseContent.String]]
25+
*/
26+
def checkJsonField(field: String, predicate: Json => Boolean): Boolean =
27+
this match {
28+
case ResponseContent.Problem(problem) =>
29+
problem.asJson.findAllByKey(field).exists(predicate)
30+
case ResponseContent.Json(json) =>
31+
json
32+
.findAllByKey(field)
33+
.exists(predicate)
34+
case ResponseContent.String(_) => false
35+
}
36+
37+
/**
38+
* A combination of [[checkJsonField]] that also checks if the resulting
39+
* JSON field is a String that satisfies a predicate.
40+
* @param field The JSON field to look for
41+
* @param predicate The predicate to satisfy
42+
*/
43+
def checkJsonFieldAsString(field: String, predicate: String => Boolean): Boolean =
44+
checkJsonField(field, _.asString.exists(predicate))
45+
46+
/**
47+
* Checks to see if the [[ResponseContent]] contains a specific String, regardless
48+
* in what format its stored
49+
*/
50+
def checkString(predicate: String => Boolean): Boolean =
51+
this match {
52+
case ResponseContent.Problem(problem) =>
53+
predicate(problem.asJson.noSpaces)
54+
case ResponseContent.Json(json) =>
55+
predicate(json.noSpaces)
56+
case ResponseContent.String(string) =>
57+
predicate(string)
58+
}
59+
}
60+
61+
object ResponseContent {
62+
63+
/**
64+
* This case happens if the response of the Http request is a valid Problem according to
65+
* RFC7807. This means that the JSON response content is a JSON object that contains the field named `type`
66+
* and all other fields (if they exist) satisfy the RFC3986 specification (i.e. the `type` field is
67+
* valid URI)
68+
* @see https://tools.ietf.org/html/rfc7807
69+
*/
70+
final case class Problem(problem: org.mdedetrich.webmodels.Problem) extends ResponseContent
71+
72+
/**
73+
* This case happens if the response of the HTTP request is JSON but it sn't a valid RFC3986 Problem.
74+
* This means that either the mandatory `type` field isn't in the JSON response and/or the other fields
75+
* specific to Problem don't follow all of the RFC3986 specification (i.e. the `type` field is
76+
* not a valid URI)
77+
* @see https://tools.ietf.org/html/rfc7159
78+
*/
79+
final case class Json(json: io.circe.Json) extends ResponseContent
80+
81+
/**
82+
* This case happens if the body content is not valid JSON according to RFC7159
83+
*/
84+
final case class String(string: java.lang.String) extends ResponseContent
85+
}
86+
87+
/**
88+
* The purpose of this data type is to provide a common way of dealing
89+
* with errors from REST'ful HTTP APi's making it particularly useful
90+
* for strongly typed clients to web services.
91+
*
92+
* `HttpServiceError` makes no assumptions about what HTTP client you
93+
* happen to be using which makes it a great candidate for having a
94+
* common error type in projects that have to juggle with
95+
* multiple HTTP clients. Since `HttpServiceError` is a trait, it can easily be
96+
* extended with existing error types that your library/application may happen
97+
* to have.
98+
*
99+
* Due to the fact that `HttpServiceError` is meant abstract over different HTTP
100+
* clients, it exposes methods that provides the minimum necessary data commonly
101+
* needed to properly identify errors without exposing too much about the HTTP
102+
* client itself. Examples of such methods are `statusCode`, `responseContent`
103+
* and `responseHeaders`.
104+
*/
105+
trait HttpServiceError {
106+
107+
/**
108+
* Type Type of the HttpRequest object from the original Http Client
109+
*/
110+
type HttpRequest
111+
112+
/**
113+
* The type of the HttpResponse object from the original Http Client
114+
*/
115+
type HttpResponse
116+
117+
/**
118+
* The original request that gave this response
119+
*/
120+
def request: HttpRequest
121+
122+
/**
123+
* The original response
124+
*/
125+
def response: HttpResponse
126+
127+
/**
128+
* The content of the response represented as a convenient
129+
* data type
130+
*/
131+
def responseContent: ResponseContent
132+
133+
/**
134+
* The status code of the response
135+
*/
136+
def statusCode: Int
137+
138+
/**
139+
* Indicates whether this error is due to a missing resource, i.e. 404 case
140+
*/
141+
def resourceMissingError: Boolean = statusCode.toString.startsWith("404")
142+
143+
/**
144+
* Indicates whether this error was caused due to a client error (i.e.
145+
* the client is somehow sending a bad request). Retrying such requests
146+
* are often pointless.
147+
*/
148+
def clientError: Boolean = Platform.checkFirstDigitOfInt(4, statusCode)
149+
150+
/**
151+
* Indicates whether this error was caused due to a server problem.
152+
* Such requests are often safe to retry (ideally with an exponential delay)
153+
* as long as the request is idempotent.
154+
*/
155+
def serverError: Boolean = Platform.checkFirstDigitOfInt(5, statusCode)
156+
157+
/**
158+
* The headers of the response without any alterations made
159+
* (i.e. any duplicate fields/ordering should remained untouched
160+
* from the original response).
161+
*/
162+
def responseHeaders: IndexedSeq[(String, String)]
163+
}

shared/src/main/scala/org/mdedetrich/webmodels/Problem.scala

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package org.mdedetrich.webmodels
22

3+
import java.net.{URI, URISyntaxException}
4+
35
import io.circe.JsonObject
46

57
/**
@@ -26,9 +28,45 @@ import io.circe.JsonObject
2628
* @param extraFields Any extra fields placed into the problem object that
2729
* aren't part of the standard
2830
*/
29-
final case class Problem(`type`: String,
31+
final case class Problem(`type`: URI,
3032
title: Option[String] = None,
3133
status: Option[Int] = None,
3234
detail: Option[String] = None,
3335
instance: Option[String] = None,
3436
extraFields: JsonObject = JsonObject.empty)
37+
38+
object Problem {
39+
final val DefaultType: URI = new URI("about:blank")
40+
41+
/**
42+
* Problem Details for HTTP APIs. Constructs a problem with the default `type`
43+
* value of about:blank
44+
*
45+
* @see https://tools.ietf.org/html/rfc7807
46+
* @param title A short, human-readable summary of the problem
47+
*type. It SHOULD NOT change from occurrence to occurrence of the
48+
*problem, except for purposes of localization (e.g., using
49+
*proactive content negotiation; se
50+
* @param status The HTTP status code ([RFC7231], Section 6)
51+
*generated by the origin server for this occurrence of the problem.
52+
* @param detail A human-readable explanation specific to this
53+
*occurrence of the problem.
54+
* @param instance A URI reference that identifies the specific
55+
*occurrence of the problem. It may or may not yield further
56+
*information if dereferenced.
57+
* @param extraFields Any extra fields placed into the problem object that
58+
* aren't part of the standard
59+
*/
60+
final def withDefault(title: Option[String] = None,
61+
status: Option[Int] = None,
62+
detail: Option[String] = None,
63+
instance: Option[String] = None,
64+
extraFields: JsonObject = JsonObject.empty): Problem = Problem(
65+
DefaultType,
66+
title,
67+
status,
68+
detail,
69+
instance,
70+
extraFields
71+
)
72+
}

shared/src/main/scala/org/mdedetrich/webmodels/circe.scala

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package org.mdedetrich.webmodels
22

3+
import java.net.{URI, URISyntaxException}
4+
35
import io.circe._
46
import io.circe.syntax._
57
import cats.syntax.either._
8+
import io.circe.Decoder.Result
69

710
object circe {
811
implicit val correlationIdDecoder: Decoder[CorrelationId] = Decoder[String].map(CorrelationId)
@@ -14,10 +17,23 @@ object circe {
1417
implicit val oAuth2TokenDecoder: Decoder[OAuth2Token] = Decoder[String].map(OAuth2Token)
1518
implicit val oAuth2TokenEncoder: Encoder[OAuth2Token] = Encoder.instance[OAuth2Token](_.value.asJson)
1619

20+
private implicit val uriDecoder: Decoder[URI] = new Decoder[URI] {
21+
override def apply(c: HCursor): Result[URI] = c.as[String].flatMap { string =>
22+
try {
23+
Right(new URI(string))
24+
} catch {
25+
case e: URISyntaxException =>
26+
Left(DecodingFailure(s"Invalid URI ${e.getMessage}", c.history))
27+
}
28+
}
29+
}
30+
31+
private implicit val uriEncoder: Encoder[URI] = Encoder.encodeString.contramap(_.toString)
32+
1733
implicit val problemDecoder: Decoder[Problem] = Decoder.instance[Problem] { c =>
1834
for {
1935
jsonObject <- c.as[JsonObject]
20-
problemType <- c.downField("type").as[String]
36+
problemType <- c.downField("type").as[URI]
2137
problemTitle <- c.downField("title").as[Option[String]]
2238
problemStatus <- c.downField("status").as[Option[Int]]
2339
problemDetail <- c.downField("detail").as[Option[String]]

version.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version in ThisBuild := "0.8.1"
1+
version in ThisBuild := "0.9.0"

0 commit comments

Comments
 (0)