Skip to content

Commit 03b6c8f

Browse files
authored
Merge pull request #3557 from kliushnichenko/feat/problem-details-in-modules
problem details in modules
2 parents e71754f + 102fc58 commit 03b6c8f

File tree

18 files changed

+448
-115
lines changed

18 files changed

+448
-115
lines changed

docs/asciidoc/modules/avaje-validator.adoc

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,6 @@ import io.jooby.validation.BeanValidator
167167
}
168168
----
169169

170-
[IMPORTANT]
171-
====
172-
Please note, if you are mixing both approaches (MVC/scripts), it's better to avoid using a filter,
173-
as it may lead to double validation on MVC routes. In this case,
174-
it's recommended to use the handler version (see below).
175-
====
176-
177170
`BeanValidator.validate()` behaves identically to validation in MVC routes.
178171
It also supports validating list, array, and map of beans.
179172

@@ -223,6 +216,12 @@ catches `ConstraintViolationException` and transforms it into the following resp
223216
}
224217
----
225218

219+
[NOTE]
220+
====
221+
`AvajeValidatorModule` is compliant with `ProblemDetails`. Therefore, if you enable
222+
the Problem Details feature, the response above will be transformed into an `RFC 7807` compliant format
223+
====
224+
226225
It is possible to override the `title` and `status` code of the response above:
227226

228227
[source, java]
@@ -330,4 +329,4 @@ import io.jooby.avaje.validator.AvajeValidatorModule;
330329
cfg.failFast(true);
331330
}));
332331
}
333-
----
332+
----

docs/asciidoc/modules/hibernate-validator.adoc

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,6 @@ import io.jooby.validation.BeanValidator
130130
}
131131
----
132132

133-
[IMPORTANT]
134-
====
135-
Please note, if you are mixing both approaches (MVC/scripts), it's better to avoid using a filter,
136-
as it may lead to double validation on MVC routes. In this case,
137-
it's recommended to use the handler version (see below).
138-
====
139-
140133
`BeanValidator.validate()` behaves identically to validation in MVC routes.
141134
It also supports validating list, array, and map of beans
142135

@@ -186,6 +179,12 @@ catches `ConstraintViolationException` and transforms it into the following resp
186179
}
187180
----
188181

182+
[NOTE]
183+
====
184+
`HibernateValidatorModule` is compliant with `ProblemDetails`. Therefore, if you enable the Problem Details feature,
185+
the response above will be transformed into an `RFC 7807` compliant format
186+
====
187+
189188
It is possible to override the `title` and `status` code of the response above:
190189

191190
[source, java]
@@ -386,4 +385,4 @@ import io.jooby.hibernate.validator.HibernateValidatorModule;
386385
cfg.failFast(true);
387386
}));
388387
}
389-
----
388+
----

docs/asciidoc/problem-details.adoc

Lines changed: 35 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,52 +10,44 @@ Thankfully, there’s a standard called https://www.rfc-editor.org/rfc/rfc7807[I
1010
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.
1111
If it suits the API’s needs, using this standard benefits both designers and users alike.
1212

13-
`Jooby` provides built-in support for `Problem Details`. There are two main entities to work with:
13+
`Jooby` provides built-in support for `Problem Details`.
1414

15-
1. `HttpProblem` - representation of the `RFC 7807` model and the way to instantiate the problem.
15+
=== Set up ProblemDetails
1616

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)
17+
To enable the `ProblemDetails`, simply add the following line to your configuration:
1918

20-
=== Set up handler
19+
.application.conf
20+
[source, properties]
21+
----
22+
problem.details.enabled = true
23+
----
2124

22-
The bare minimal requirement is to set up an error handler:
25+
This is the bare minimal configuration you need.
26+
It enables a global error handler that catches all exceptions, transforms them into Problem Details compliant format and renders the response based on the Accept header value. It also sets the appropriate content-type in response (e.g. application/problem+json, application/problem+xml)
2327

24-
.Java
25-
[source,java,role="primary"]
26-
----
27-
import io.jooby.problem.ProblemDetailsHandler;
28+
All supported settings include:
2829

29-
{
30-
...
31-
error(new ProblemDetailsHandler() // <1>
32-
.log4xxErrors() // <2>
33-
.mute(StatusCode.UNAUTHORIZED) // <3>
34-
);
30+
.application.conf
31+
[source, properties]
32+
----
33+
problem.details {
34+
enabled = true
35+
log4xxErrors = true // <1>
36+
muteCodes = [401, 403] // <2>
37+
muteTypes = ["com.example.MyMutedException"] // <3>
3538
}
3639
----
3740

38-
.Kotlin
39-
[source,kt,role="secondary"]
40-
----
41-
import io.jooby.problem.ProblemDetailsHandler
4241

43-
{
44-
...
45-
error(new ProblemDetailsHandler() // <1>
46-
.log4xxErrors() // <2>
47-
.mute(StatusCode.UNAUTHORIZED) // <3>
48-
)
49-
}
50-
----
42+
<1> 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.
43+
<2> You can optionally mute some status codes completely.
44+
<3> You can optionally mute some exceptions logging completely.
5145

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.
5646

5747
=== Creating problems
5848

49+
`HttpProblem` class represents the `RFC 7807` model. It is the main entity you need to work with to produce the problem.
50+
5951
==== Static helpers
6052

6153
There are several handy static methods to produce a simple `HttpProblem`:
@@ -70,6 +62,8 @@ Don't overuse it, the problem should have meaningful `title` and `detail` when p
7062
.Java
7163
[source,java,role="primary"]
7264
----
65+
import io.jooby.problem.HttpProblem;
66+
7367
get("/users/{userId}", ctx -> {
7468
var userId = ctx.path("userId").value();
7569
User user = userRepository.findUser(userId);
@@ -87,6 +81,8 @@ get("/users/{userId}", ctx -> {
8781
.Kotlin
8882
[source,kt,role="secondary"]
8983
----
84+
import io.jooby.problem.HttpProblem
85+
9086
get("/users/{userId}") { ctx ->
9187
val userId = ctx.path("userId").value()
9288
val user = userRepository.findUser(userId)
@@ -117,7 +113,7 @@ Resulting response:
117113

118114
==== Builder
119115

120-
Use builder to create rich problem instance with all properties:
116+
Use builder to create a rich problem instance with all properties:
121117

122118
[source,java]
123119
----
@@ -190,8 +186,8 @@ It is basically another extension `errors` on a root level. Adding errors is str
190186
----
191187
throw HttpProblem.builder()
192188
...
193-
.error(new HttpProblem.Error("First name cannot be blank", "#/firstName"))
194-
.error(new HttpProblem.Error("Last name is required", "#/lastName"))
189+
.error(new HttpProblem.Error("First name cannot be blank", "/firstName"))
190+
.error(new HttpProblem.Error("Last name is required", "/lastName"))
195191
.build();
196192
----
197193

@@ -203,11 +199,11 @@ In response:
203199
"errors": [
204200
{
205201
"detail": "First name cannot be blank",
206-
"pointer": "#/firstName"
202+
"pointer": "/firstName"
207203
},
208204
{
209205
"detail": "Last name is required",
210-
"pointer": "#/lastName"
206+
"pointer": "/lastName"
211207
}
212208
]
213209
}
@@ -261,8 +257,7 @@ public class OutOfStockProblem extends HttpProblem {
261257

262258
=== Custom Exception Handlers
263259

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:
260+
All the features described above should give you ability to rely solely on built-in global error handler. But, in case you still need custom exception handler for some reason, you still can do it:
266261

267262
[source,java]
268263
----
@@ -275,20 +270,16 @@ But, in case you still need custom exception handler for some reason, you still
275270
276271
ctx.getRouter().getErrorHandler().apply(ctx, problem, code); // <2>
277272
});
278-
279-
// should always go below (WYSIWYG)
280-
error(new ProblemDetailsHandler()); // <3>
281273
}
282274
----
283275

284276
<1> Transform exception to `HttpProblem`
285277
<2> Propagate the problem to `ProblemDetailsHandler`. It will handle the rest.
286-
<3> `ProblemDetailsHandler` should always go below your custom exception handlers
287278

288279
[IMPORTANT]
289280
====
290281
Do not attempt to render `HttpProblem` manually, it is strongly discouraged.
291282
`HttpProblem` is derived from the `RuntimeException` to enable ease of `HttpProblem` throwing.
292283
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.
284+
it will be rendered together with stacktrace. It is strongly advised not to expose the stacktrace to the client system. Propagate the problem to global error handler and let him take care of the rest.
294285
====

jooby/src/main/java/io/jooby/Jooby.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import java.util.function.Supplier;
4242
import java.util.stream.Collectors;
4343

44+
import io.jooby.problem.ProblemDetailsHandler;
4445
import org.slf4j.Logger;
4546
import org.slf4j.LoggerFactory;
4647

@@ -1324,6 +1325,17 @@ public static Jooby createApp(
13241325
return app;
13251326
}
13261327

1328+
/**
1329+
* Check if {@link ProblemDetailsHandler} is enabled as a global error handler
1330+
*
1331+
* @return boolean flag
1332+
*/
1333+
public boolean problemDetailsIsEnabled() {
1334+
var config = getConfig();
1335+
return config.hasPath(ProblemDetailsHandler.ENABLED_KEY)
1336+
&& config.getBoolean(ProblemDetailsHandler.ENABLED_KEY);
1337+
}
1338+
13271339
private static void configurePackage(Package pkg) {
13281340
if (pkg != null) {
13291341
configurePackage(pkg.getName());

jooby/src/main/java/io/jooby/internal/RouterImpl.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import java.util.stream.IntStream;
3636
import java.util.stream.Stream;
3737

38+
import io.jooby.problem.ProblemDetailsHandler;
3839
import org.slf4j.Logger;
3940
import org.slf4j.LoggerFactory;
4041

@@ -594,10 +595,11 @@ private void pureAscii(String pattern, Consumer<String> consumer) {
594595

595596
@NonNull public Router start(@NonNull Jooby app, @NonNull Server server) {
596597
started = true;
598+
var globalErrHandler = defineGlobalErrorHandler(app);
597599
if (err == null) {
598-
err = ErrorHandler.create();
600+
err = globalErrHandler;
599601
} else {
600-
err = err.then(ErrorHandler.create());
602+
err = err.then(globalErrHandler);
601603
}
602604

603605
ExecutionMode mode = app.getExecutionMode();
@@ -665,6 +667,24 @@ private void pureAscii(String pattern, Consumer<String> consumer) {
665667
return this;
666668
}
667669

670+
/**
671+
* Define the global error handler.
672+
* If ProblemDetails is enabled the {@link ProblemDetailsHandler} instantiated
673+
* from configuration settings and returned. Otherwise, {@link DefaultErrorHandler} instance
674+
* returned.
675+
*
676+
* @param app - Jooby application instance
677+
* @return global error handler
678+
*/
679+
private ErrorHandler defineGlobalErrorHandler(Jooby app) {
680+
if (app.problemDetailsIsEnabled()) {
681+
var problemDetailsConfig = app.getConfig().getConfig(ProblemDetailsHandler.ROOT_CONFIG_PATH);
682+
return ProblemDetailsHandler.fromConfig(problemDetailsConfig);
683+
} else {
684+
return ErrorHandler.create();
685+
}
686+
}
687+
668688
private ExecutionMode forceMode(Route route, ExecutionMode mode) {
669689
if (route.getMethod().equals(Router.WS)) {
670690
// websocket always run in worker executor

jooby/src/main/java/io/jooby/problem/HttpProblem.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ public Builder error(final Error error) {
243243
return this;
244244
}
245245

246-
public Builder errors(final List<Error> errors) {
246+
public Builder errors(final List<? extends Error> errors) {
247247
this.errors.addAll(errors);
248248
return this;
249249
}

jooby/src/main/java/io/jooby/problem/ProblemDetailsHandler.java

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

8+
import com.typesafe.config.Config;
89
import edu.umd.cs.findbugs.annotations.NonNull;
910
import io.jooby.*;
1011
import io.jooby.exception.NotAcceptableException;
@@ -15,6 +16,7 @@
1516
import java.util.Map;
1617

1718
import static io.jooby.MediaType.*;
19+
import static io.jooby.SneakyThrows.throwingConsumer;
1820
import static io.jooby.StatusCode.SERVER_ERROR_CODE;
1921

2022
/**
@@ -29,6 +31,13 @@
2931
*/
3032
public class ProblemDetailsHandler extends DefaultErrorHandler {
3133

34+
private static final String MUTE_CODES_KEY = "muteCodes";
35+
private static final String MUTE_TYPES_KEY = "muteTypes";
36+
private static final String LOG_4XX_ERRORS_KEY = "log4xxErrors";
37+
38+
public static final String ROOT_CONFIG_PATH = "problem.details";
39+
public static final String ENABLED_KEY = ROOT_CONFIG_PATH + ".enabled";
40+
3241
private boolean log4xxErrors;
3342

3443
public ProblemDetailsHandler log4xxErrors() {
@@ -49,7 +58,7 @@ public void apply(@NonNull Context ctx, @NonNull Throwable cause, @NonNull Statu
4958
}
5059

5160
try {
52-
HttpProblem problem = evaluateTheProblem(ctx, cause, code);
61+
HttpProblem problem = evaluateTheProblem(cause, code);
5362

5463
logProblem(ctx, problem, cause);
5564

@@ -82,7 +91,7 @@ private void setResponseType(Context ctx, MediaType type) {
8291
}
8392
}
8493

85-
private HttpProblem evaluateTheProblem(Context ctx, Throwable cause, StatusCode statusCode) {
94+
private HttpProblem evaluateTheProblem(Throwable cause, StatusCode statusCode) {
8695
HttpProblem problem;
8796
if (cause instanceof HttpProblem httpProblem) {
8897
problem = httpProblem;
@@ -192,4 +201,25 @@ private void logProblem(Context ctx, HttpProblem problem, Throwable cause) {
192201
private String buildLogMsg(Context ctx, HttpProblem problem, StatusCode statusCode) {
193202
return "%s | %s".formatted(ErrorHandler.errorMessage(ctx, statusCode), problem.toString());
194203
}
204+
205+
public static ProblemDetailsHandler fromConfig(Config config) {
206+
var handler = new ProblemDetailsHandler();
207+
208+
if (config.hasPath(LOG_4XX_ERRORS_KEY) && config.getBoolean(LOG_4XX_ERRORS_KEY)) {
209+
handler.log4xxErrors();
210+
}
211+
212+
if (config.hasPath(MUTE_CODES_KEY)) {
213+
config.getIntList(MUTE_CODES_KEY)
214+
.forEach(code -> handler.mute(StatusCode.valueOf(code)));
215+
}
216+
217+
if (config.hasPath(MUTE_TYPES_KEY)) {
218+
config.getStringList(MUTE_TYPES_KEY)
219+
.forEach(throwingConsumer(
220+
className -> handler.mute((Class<? extends Exception>) Class.forName(className))));
221+
}
222+
223+
return handler;
224+
}
195225
}

0 commit comments

Comments
 (0)