Skip to content

Commit 5bc80b7

Browse files
documentation, minor improvements (#3555)
1 parent b321c72 commit 5bc80b7

File tree

11 files changed

+335
-11
lines changed

11 files changed

+335
-11
lines changed

docs/asciidoc/index.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,8 @@ include::responses.adoc[]
238238

239239
include::error-handler.adoc[]
240240

241+
include::problem-details.adoc[]
242+
241243
include::handlers.adoc[]
242244

243245
include::configuration.adoc[]

docs/asciidoc/problem-details.adoc

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
== Problem Details
2+
3+
Most APIs have a way to report problems and errors, helping the user understand when something went wrong and what the issue is.
4+
The method used depends on the API’s style, technology, and design.
5+
Handling error reporting is an important part of the overall API design process.
6+
7+
You could create your own error-reporting system, but that takes time and effort, both for the designer and for users who need to learn the custom approach.
8+
Thankfully, there’s a standard called https://www.rfc-editor.org/rfc/rfc7807[IETF RFC 7807] (later refined in https://www.rfc-editor.org/rfc/rfc9457[RFC 9457]) that can help.
9+
10+
By adopting `RFC 7807`, API designers don’t have to spend time creating a custom solution, and users benefit by recognizing a familiar format across different APIs.
11+
If it suits the API’s needs, using this standard benefits both designers and users alike.
12+
13+
`Jooby` provides built-in support for `Problem Details`. There are two main entities to work with:
14+
15+
1. `HttpProblem` - representation of the `RFC 7807` model and the way to instantiate the problem.
16+
17+
2. `ProblemDetailsHandler` - global error handler that catches all exceptions, transforms them into `Problem Details` compliant format and renders the response based on the `Accept` header value.
18+
It also sets the appropriate content-type in response (e.g. application/problem+json, application/problem+xml)
19+
20+
=== Set up handler
21+
22+
The bare minimal requirement is to set up an error handler:
23+
24+
.Java
25+
[source,java,role="primary"]
26+
----
27+
import io.jooby.problem.ProblemDetailsHandler;
28+
29+
{
30+
...
31+
error(new ProblemDetailsHandler() // <1>
32+
.log4xxErrors() // <2>
33+
.mute(StatusCode.UNAUTHORIZED) // <3>
34+
);
35+
}
36+
----
37+
38+
.Kotlin
39+
[source,kt,role="secondary"]
40+
----
41+
import io.jooby.problem.ProblemDetailsHandler
42+
43+
{
44+
...
45+
error(new ProblemDetailsHandler() // <1>
46+
.log4xxErrors() // <2>
47+
.mute(StatusCode.UNAUTHORIZED) // <3>
48+
)
49+
}
50+
----
51+
52+
<1> Enable `Problem Details` handler.
53+
Keep in mind, order matters (WYSIWYG), so if you have some specific exception handlers, they should be placed above this line.
54+
<2> By default, only server errors (5xx) will be logged. You can optionally enable the logging of client errors (4xx). If `DEBUG` logging level is enabled, the log will contain a stacktrace as well.
55+
<3> You can optionally mute some status codes or exceptions logging completely.
56+
57+
=== Creating problems
58+
59+
==== Static helpers
60+
61+
There are several handy static methods to produce a simple `HttpProblem`:
62+
63+
- `HttpProblem.valueOf(StatusCode status)` - will pick the title by status code.
64+
Don't overuse it, the problem should have meaningful `title` and `detail` when possible.
65+
- `HttpProblem.valueOf(StatusCode status, String title)` - with custom `title`
66+
- `HttpProblem.valueOf(StatusCode status, String title, String detail)` - with `title` and `detail`
67+
68+
`HttpProblem` extends `RuntimeException` so you can naturally throw it (as you do with exceptions):
69+
70+
.Java
71+
[source,java,role="primary"]
72+
----
73+
get("/users/{userId}", ctx -> {
74+
var userId = ctx.path("userId").value();
75+
User user = userRepository.findUser(userId);
76+
77+
if (user == null) {
78+
throw HttpProblem.valueOf(StatusCode.NOT_FOUND,
79+
"User Not Found",
80+
"User with ID %s was not found in the system.".formatted(userId)
81+
);
82+
}
83+
...
84+
});
85+
----
86+
87+
.Kotlin
88+
[source,kt,role="secondary"]
89+
----
90+
get("/users/{userId}") { ctx ->
91+
val userId = ctx.path("userId").value()
92+
val user = userRepository.findUser(userId)
93+
94+
if (user == null) {
95+
throw HttpProblem.valueOf(StatusCode.NOT_FOUND,
96+
"User Not Found",
97+
"User with ID $userId was not found in the system."
98+
)
99+
}
100+
...
101+
})
102+
----
103+
104+
Resulting response:
105+
106+
[source,json]
107+
----
108+
{
109+
"timestamp": "2024-10-05T14:10:41.648933100Z",
110+
"type": "about:blank",
111+
"title": "User Not Found",
112+
"status": 404,
113+
"detail": "User with ID 123 was not found in the system.",
114+
"instance": null
115+
}
116+
----
117+
118+
==== Builder
119+
120+
Use builder to create rich problem instance with all properties:
121+
122+
[source,java]
123+
----
124+
throw HttpProblem.builder()
125+
.type(URI.create("http://example.com/invalid-params"))
126+
.title("Invalid input parameters")
127+
.status(StatusCode.UNPROCESSABLE_ENTITY)
128+
.detail("'Name' may not be empty")
129+
.instance(URI.create("http://example.com/invalid-params/3325"))
130+
.build();
131+
----
132+
133+
=== Adding extra parameters
134+
135+
`RFC 7807` has a simple extension model: APIs are free to add any other properties to the problem details object, so all properties other than the five ones listed above are extensions.
136+
137+
However, variadic root level fields are usually not very convenient for (de)serialization (especially in statically typed languages). That's why `HttpProblem` implementation grabs all extensions under a single root field `parameters`. You can add parameters using builder like this:
138+
139+
[source,java]
140+
----
141+
throw HttpProblem.builder()
142+
.title("Order not found")
143+
.status(StatusCode.NOT_FOUND)
144+
.detail("Order with ID $orderId could not be processed because it is missing or invalid.")
145+
.param("reason", "Order ID format incorrect or order does not exist.")
146+
.param("suggestion", "Please check the order ID and try again")
147+
.param("supportReference", "/support")
148+
.build();
149+
----
150+
151+
Resulting response:
152+
153+
[source,json]
154+
----
155+
{
156+
"timestamp": "2024-10-06T07:34:06.643235500Z",
157+
"type": "about:blank",
158+
"title": "Order not found",
159+
"status": 404,
160+
"detail": "Order with ID $orderId could not be processed because it is missing or invalid.",
161+
"instance": null,
162+
"parameters": {
163+
"reason": "Order ID format incorrect or order does not exist.",
164+
"suggestion": "Please check the order ID and try again",
165+
"supportReference": "/support"
166+
}
167+
}
168+
----
169+
170+
=== Adding headers
171+
172+
Some `HTTP` codes (like `413` or `426`) require additional response headers, or it may be required by third-party system/integration. `HttpProblem` support additional headers in response:
173+
174+
[source,java]
175+
----
176+
throw HttpProblem.builder()
177+
.title("Invalid input parameters")
178+
.status(StatusCode.UNPROCESSABLE_ENTITY)
179+
.header("my-string-header", "string")
180+
.header("my-int-header", 100)
181+
.build();
182+
----
183+
184+
=== Respond with errors details
185+
186+
`RFC 9457` finally described how errors should be delivered in HTTP APIs.
187+
It is basically another extension `errors` on a root level. Adding errors is straight-forward using `error()` or `errors()` for bulk addition in builder:
188+
189+
[source,java]
190+
----
191+
throw HttpProblem.builder()
192+
...
193+
.error(new HttpProblem.Error("First name cannot be blank", "#/firstName"))
194+
.error(new HttpProblem.Error("Last name is required", "#/lastName"))
195+
.build();
196+
----
197+
198+
In response:
199+
[source,json]
200+
----
201+
{
202+
...
203+
"errors": [
204+
{
205+
"detail": "First name cannot be blank",
206+
"pointer": "#/firstName"
207+
},
208+
{
209+
"detail": "Last name is required",
210+
"pointer": "#/lastName"
211+
}
212+
]
213+
}
214+
----
215+
216+
[TIP]
217+
====
218+
If you need to enrich errors with more information feel free to extend `HttpProblem.Error` and make your custom errors model.
219+
====
220+
221+
=== Custom `Exception` to `HttpProblem`
222+
223+
Apparently, you may already have many custom `Exception` classes in the codebase, and you want to make them `Problem Details` compliant without complete re-write. You can achieve this by implementing `HttpProblemMappable` interface. It allows you to control how exceptions should be transformed into `HttpProblem` if default behaviour doesn't suite your needs:
224+
225+
[source,java]
226+
----
227+
import io.jooby.problem.HttpProblemMappable;
228+
229+
public class MyException implements HttpProblemMappable {
230+
231+
public HttpProblem toHttpProblem() {
232+
return HttpProblem.builder()
233+
...
234+
build();
235+
}
236+
237+
}
238+
----
239+
240+
=== Custom Problems
241+
242+
Extending `HttpProblem` and utilizing builder functionality makes it really easy:
243+
244+
[source,java]
245+
----
246+
public class OutOfStockProblem extends HttpProblem {
247+
248+
private static final URI TYPE = URI.create("https://example.org/out-of-stock");
249+
250+
public OutOfStockProblem(final String product) {
251+
super(builder()
252+
.type(TYPE)
253+
.title("Out of Stock")
254+
.status(StatusCode.BAD_REQUEST)
255+
.detail(String.format("'%s' is no longer available", product))
256+
.param("suggestions", List.of("Coffee Grinder MX-17", "Coffee Grinder MX-25"))
257+
);
258+
}
259+
}
260+
----
261+
262+
=== Custom Exception Handlers
263+
264+
All the features described above should give you ability to rely solely on `ProblemDetailsHandler`.
265+
But, in case you still need custom exception handler for some reason, you still can do it:
266+
267+
[source,java]
268+
----
269+
{
270+
...
271+
error(MyCustomException.class, (ctx, cause, code) -> {
272+
MyCustomException ex = (MyCustomException) cause;
273+
274+
HttpProblem problem = ... ; // <1>
275+
276+
ctx.getRouter().getErrorHandler().apply(ctx, problem, code); // <2>
277+
});
278+
279+
// should always go below (WYSIWYG)
280+
error(new ProblemDetailsHandler()); // <3>
281+
}
282+
----
283+
284+
<1> Transform exception to `HttpProblem`
285+
<2> Propagate the problem to `ProblemDetailsHandler`. It will handle the rest.
286+
<3> `ProblemDetailsHandler` should always go below your custom exception handlers
287+
288+
[IMPORTANT]
289+
====
290+
Do not attempt to render `HttpProblem` manually, it is strongly discouraged.
291+
`HttpProblem` is derived from the `RuntimeException` to enable ease of `HttpProblem` throwing.
292+
Thus, thrown `HttpProblem` will also contain a stacktrace, if you render `HttpProblem` as is -
293+
it will be rendered together with stacktrace. It is strongly advised not to expose the stacktrace to the client. Propagate the problem to `ProblemDetailsHandler` and let him take care of the rest.
294+
====

jooby/src/main/java/io/jooby/exception/InvalidCsrfToken.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
package io.jooby.exception;
77

8+
import edu.umd.cs.findbugs.annotations.NonNull;
89
import edu.umd.cs.findbugs.annotations.Nullable;
910
import io.jooby.problem.HttpProblem;
1011

@@ -26,7 +27,7 @@ public InvalidCsrfToken(@Nullable String token) {
2627
}
2728

2829
@Override
29-
public HttpProblem toHttpProblem() {
30+
public @NonNull HttpProblem toHttpProblem() {
3031
return HttpProblem.valueOf(statusCode,
3132
"Invalid CSRF token",
3233
"CSRF token '" + getMessage() + "' is invalid");

jooby/src/main/java/io/jooby/exception/MethodNotAllowedException.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public List<String> getAllow() {
5151
}
5252

5353
@Override
54-
public HttpProblem toHttpProblem() {
54+
public @NonNull HttpProblem toHttpProblem() {
5555
return HttpProblem.valueOf(statusCode,
5656
statusCode.reason(),
5757
"HTTP method '" + getMethod() + "' is not allowed. Allowed methods are: " + allow

jooby/src/main/java/io/jooby/exception/NotAcceptableException.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
package io.jooby.exception;
77

8+
import edu.umd.cs.findbugs.annotations.NonNull;
89
import edu.umd.cs.findbugs.annotations.Nullable;
910
import io.jooby.StatusCode;
1011
import io.jooby.problem.HttpProblem;
@@ -35,7 +36,7 @@ public NotAcceptableException(@Nullable String contentType) {
3536
}
3637

3738
@Override
38-
public HttpProblem toHttpProblem() {
39+
public @NonNull HttpProblem toHttpProblem() {
3940
return HttpProblem.valueOf(statusCode,
4041
statusCode.reason(),
4142
"Server cannot produce a response matching the list of " +

jooby/src/main/java/io/jooby/exception/NotFoundException.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public NotFoundException(@NonNull String path) {
3636
}
3737

3838
@Override
39-
public HttpProblem toHttpProblem() {
39+
public @NonNull HttpProblem toHttpProblem() {
4040
return HttpProblem.valueOf(statusCode,
4141
statusCode.reason(),
4242
"Route '" + getRequestPath() + "' not found. Please verify request path");

jooby/src/main/java/io/jooby/exception/StatusCodeException.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public StatusCodeException(
6363
}
6464

6565
@Override
66-
public HttpProblem toHttpProblem() {
66+
public @NonNull HttpProblem toHttpProblem() {
6767
return HttpProblem.valueOf(statusCode, getMessage());
6868
}
6969
}

jooby/src/main/java/io/jooby/exception/TypeMismatchException.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public TypeMismatchException(@NonNull String name, @NonNull Type type) {
5252

5353

5454
@Override
55-
public HttpProblem toHttpProblem() {
55+
public @NonNull HttpProblem toHttpProblem() {
5656
return HttpProblem.valueOf(statusCode, "Type Mismatch", getMessage());
5757
}
5858
}

jooby/src/main/java/io/jooby/exception/UnsupportedMediaType.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
package io.jooby.exception;
77

8+
import edu.umd.cs.findbugs.annotations.NonNull;
89
import edu.umd.cs.findbugs.annotations.Nullable;
910
import io.jooby.StatusCode;
1011
import io.jooby.problem.HttpProblem;
@@ -35,7 +36,7 @@ public UnsupportedMediaType(@Nullable String type) {
3536
}
3637

3738
@Override
38-
public HttpProblem toHttpProblem() {
39+
public @NonNull HttpProblem toHttpProblem() {
3940
return HttpProblem.valueOf(statusCode,
4041
statusCode.reason(),
4142
"Media type '" + getContentType() + "' is not supported");

0 commit comments

Comments
 (0)