|
| 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 | +==== |
0 commit comments