├── .github └── workflows │ └── maven.yml ├── .gitignore ├── LICENSE ├── README.adoc ├── api ├── README.adoc ├── pom.xml └── src │ └── main │ └── java │ └── com │ └── github │ └── t1 │ └── problemdetail │ ├── Constants.java │ ├── Detail.java │ ├── Extension.java │ ├── Instance.java │ ├── LogLevel.java │ ├── Logging.java │ ├── ProblemDetail.java │ ├── Status.java │ ├── Title.java │ ├── Type.java │ └── package-info.java ├── pom.xml ├── renovate.json ├── ri-lib ├── README.adoc ├── pom.xml └── src │ ├── main │ └── java │ │ └── com │ │ └── github │ │ └── t1 │ │ ├── problemdetail │ │ └── ri │ │ │ └── lib │ │ │ ├── ProblemDetailJsonToExceptionBuilder.java │ │ │ ├── ProblemDetails.java │ │ │ └── ProblemXml.java │ │ └── validation │ │ └── ValidationFailedException.java │ └── test │ ├── java │ └── test │ │ ├── LoggingBehavior.java │ │ ├── MockLoggerFactory.java │ │ ├── ProblemDetailJsonToExceptionBuilderBehavior.java │ │ ├── StaticLoggerBinder.java │ │ ├── ViolationDetailBehavior.java │ │ └── sub │ │ ├── SubException.java │ │ ├── SubExceptionWithCategory.java │ │ ├── SubExceptionWithLevel.java │ │ └── package-info.java │ └── resources │ └── META-INF │ └── services │ └── org.slf4j.spi.SLF4JServiceProvider ├── ri ├── README.adoc ├── pom.xml └── src │ ├── main │ └── java │ │ └── com │ │ └── github │ │ └── t1 │ │ └── problemdetailmapper │ │ ├── ProblemDetailExceptionMapper.java │ │ ├── ProblemDetailHandler.java │ │ ├── ProblemDetailHtmlMessageBodyWriter.java │ │ ├── ProblemDetailJsonMessageBodyReader.java │ │ ├── ProblemDetailXmlMessageBodyReader.java │ │ └── ProblemDetailXmlMessageBodyWriter.java │ └── test │ └── java │ ├── com │ └── github │ │ └── t1 │ │ └── problemdetailmapper │ │ └── ProblemDetailExceptionMapperBehavior.java │ └── test │ ├── ProblemDetailDeserializationBehavior.java │ └── ProblemDetailSerializationBehavior.java └── test ├── README.adoc ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── github │ │ └── t1 │ │ └── problemdetaildemoapp │ │ ├── Config.java │ │ ├── CustomExceptionBoundary.java │ │ ├── DemoBoundary.java │ │ ├── LoggingFilter.java │ │ ├── OutOfCreditException.java │ │ ├── StandardExceptionBoundary.java │ │ └── ValidationBoundary.java └── webapp │ └── WEB-INF │ └── beans.xml └── test ├── java └── test │ ├── ContainerLaunchingExtension.java │ ├── CustomExceptionIT.java │ ├── DemoIT.java │ ├── ExtensionMappingIT.java │ ├── StandardExceptionMappingIT.java │ └── ValidationFailedExceptionMappingIT.java └── resources └── logback-test.xml /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | name: Java CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | jdk: [ 17, 21 ] 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-java@v4 15 | with: 16 | java-version: ${{matrix.jdk}} 17 | distribution: 'temurin' 18 | cache: 'maven' 19 | - run: mvn --batch-mode --show-version --no-transfer-progress -DCI=GitHub install 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | *.iml 3 | target/ 4 | /.links/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = Problem Detail image:https://maven-badges.herokuapp.com/maven-central/com.github.t1/problem-details/badge.svg[link=https://search.maven.org/artifact/com.github.t1/problem-details] image:https://github.com/t1/problem-details/actions/workflows/maven.yml/badge.svg[link=https://github.com/t1/problem-details/actions/workflows/maven.yml] 2 | 3 | == Abstract 4 | 5 | Map standard and custom exceptions to a http response body containing problem details as specified in https://datatracker.ietf.org/doc/html/rfc9457[RFC-9457] (formerly https://datatracker.ietf.org/doc/html/rfc7807[RFC-7807]). 6 | 7 | Most things work out of the box: the `type` and `title` fields are derived from the exception class name; the `detail` field is the message of the exception; the `instance` field is a random UUID URN that is also logged together with the complete stack trace. 8 | 9 | These defaults and the status code can be overridden with annotations. 10 | 11 | I wrote a https://www.codecentric.de/wissens-hub/blog/rfc-7807-problem-details-with-spring-boot-and-jax-rs[blog] about this. 12 | 13 | == Motivation 14 | 15 | Getting a consistent error response format for a REST API is a common problem and allows clients to handle specific business errors. Getting some generic HTML response is not helpful. There is a standard for this type of details about an error: RFC-9457. And this library provides a simple way to map exceptions to this format. 16 | 17 | == Spec & API 18 | 19 | This has been proposed to and rejected by several existing specs: 20 | 21 | * https://github.com/jakartaee/rest/issues/839[Jakarta REST (JAX-RS)]; a second discussion was also https://github.com/jakartaee/rest/issues/1150[rejected]. 22 | * https://github.com/eclipse/microprofile-rest-client/issues/248[MP REST Client]. 23 | 24 | The API in the `api` module looks quite stable. Some first ideas for a full spec follow below. It's yet far from complete, but it's a start: 25 | 26 | * MUST `application/problem+json`, `application/problem+xml`; SHOULD any, e.g. `+yaml` 27 | * SHOULD render `text/html` 28 | * map also `@Valid` REST params 29 | * logging: 4xx = DEBUG, 5xx = ERROR; configurable? 30 | * order of extensions is alphabetic (which is better for tests than random) 31 | * multiple extensions with the same name: undefined behavior 32 | * JAXB can't unmarshal a subclass with the same type and namespace 33 | * Security considerations: nothing dangerous in problem details (i.e. exception message); stack-trace in logs 34 | * TODO scan client classpath for @Type annotated exceptions (and document this in the spec and the annotation) 35 | * TODO inherited annotations 36 | * TODO cause annotations 37 | * TODO type factory, e.g. URL to OpenAPI 38 | * TODO instance factory, e.g. URL to the logging system filtering on an UUID 39 | 40 | == Dummy-Impl [ri] 41 | 42 | It's called `ri`, but it's actually only a POC, and it's incomplete. See the README for details. 43 | 44 | == Test 45 | 46 | The `test` module runs integration tests by using https://github.com/t1/jee-testcontainers[JEE Testcontainers], i.e. it can be configured to start different Docker containers with various JEE application servers. By default, it starts a Wildfly. 47 | 48 | `testcontainer-running` 49 | 50 | As the containers don't yet implement the API by themselves, the dummy implementation `ri` is hard-wired in the tests for now. 51 | 52 | === Wildfly 53 | 54 | Default `mvn` or explicitly `mvn -Djee-testcontainer=wildfly` 55 | 56 | === Open Liberty 57 | 58 | `mvn -Djee-testcontainer=open-liberty:19.0.0.9-javaee8-java11 -Pwith-slf4j` 59 | 60 | needs tag for jdk11 support 61 | needs dependencies on slf4j-api and slf4j-jdk14 62 | 63 | === TomEE 64 | 65 | `mvn -Djee-testcontainer=tomee` 66 | 67 | 3 tests fail, because this version of TomEE (9.0.20 / 8.0.0-M3) doesn't write the problem detail response entity in some cases for some reason: 68 | StandardExceptionMappingIT.shouldMapWebApplicationExceptionWithoutEntityButMessage 69 | StandardExceptionMappingIT.shouldMapWebApplicationExceptionWithoutEntityOrMessage 70 | ValidationFailedExceptionMappingIT.shouldMapValidationFailedException 71 | 72 | === Payara 73 | 74 | `mvn -Djee-testcontainer=payara -Pwith-slf4j` 75 | 76 | fails due to lack of jdk11 support of the https://hub.docker.com/r/payara/server-full[`payara`] image. 77 | needs dependencies on slf4j-api and slf4j-jdk14 78 | 79 | == Spring 80 | 81 | We build for JDK 11 and the Jakarta EE 10 APIs. The current versions of Spring Boot don't support this combination. But you can still use the older `com.github.t1:problem-details-api:1.0.10`, which was based on Jakarta EE 8. 82 | -------------------------------------------------------------------------------- /api/README.adoc: -------------------------------------------------------------------------------- 1 | = Problem Details API 2 | 3 | This is the API for the Problem Details. 4 | -------------------------------------------------------------------------------- /api/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | 6 | com.github.t1 7 | problem-details 8 | 3.0.2-SNAPSHOT 9 | ../pom.xml 10 | 11 | 12 | problem-details-api 13 | Problem Detail API 14 | 15 | 16 | 8 17 | 8 18 | utf-8 19 | 20 | 21 | 22 | install 23 | 24 | 25 | 26 | 27 | jakarta.ws.rs 28 | jakarta.ws.rs-api 29 | 4.0.0 30 | provided 31 | 32 | 33 | jakarta.xml.bind 34 | jakarta.xml.bind-api 35 | 4.0.2 36 | provided 37 | true 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/t1/problemdetail/Constants.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail; 2 | 3 | import jakarta.ws.rs.core.MediaType; 4 | 5 | public class Constants { 6 | /** 7 | * The JSON formatted details body of a failing http request. 8 | * 9 | * @see RFC-7807 10 | */ 11 | public static final String PROBLEM_DETAIL_JSON = "application/problem+json"; 12 | /** 13 | * The JSON formatted details body of a failing http request. 14 | * 15 | * @see RFC-7807 16 | */ 17 | public static final MediaType PROBLEM_DETAIL_JSON_TYPE = MediaType.valueOf(PROBLEM_DETAIL_JSON); 18 | 19 | /** 20 | * The XML formatted details body of a failing http request. 21 | * 22 | * @see RFC-7807 23 | */ 24 | public static final String PROBLEM_DETAIL_XML = "application/problem+xml"; 25 | /** 26 | * The XML formatted details body of a failing http request. 27 | * 28 | * @see RFC-7807 29 | */ 30 | public static final MediaType PROBLEM_DETAIL_XML_TYPE = MediaType.valueOf(PROBLEM_DETAIL_XML); 31 | } 32 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/t1/problemdetail/Detail.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.Target; 5 | 6 | import static java.lang.annotation.ElementType.FIELD; 7 | import static java.lang.annotation.ElementType.METHOD; 8 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 9 | 10 | /** 11 | * The annotated methods or fields are used to build the detail 12 | * field of the problem detail. Multiple details are joined to a single string 13 | * delimited by `. `: a period and a space character. 14 | *

15 | * Defaults to the message of the exception. 16 | */ 17 | @Retention(RUNTIME) 18 | @Target({METHOD, FIELD}) 19 | public @interface Detail {} 20 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/t1/problemdetail/Extension.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.Target; 5 | 6 | import static java.lang.annotation.ElementType.FIELD; 7 | import static java.lang.annotation.ElementType.METHOD; 8 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 9 | 10 | /** 11 | * The annotated methods or fields are used to build additional properties of the problem detail. 12 | */ 13 | @Retention(RUNTIME) 14 | @Target({METHOD, FIELD}) 15 | public @interface Extension { 16 | /** 17 | * Defaults to the field/method name 18 | */ 19 | String value() default ""; 20 | } 21 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/t1/problemdetail/Instance.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.Target; 5 | 6 | import static java.lang.annotation.ElementType.FIELD; 7 | import static java.lang.annotation.ElementType.METHOD; 8 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 9 | 10 | /** 11 | * The annotated method or field is used for the instance field of the problem detail. 12 | * Note that this value should be different for every occurrence. 13 | *

14 | * By default, an URN with urn:uuid: and a random {@link java.util.UUID} 15 | * is generated, that's also logged with the stack trace. 16 | */ 17 | @Retention(RUNTIME) 18 | @Target({METHOD, FIELD}) 19 | public @interface Instance {} 20 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/t1/problemdetail/LogLevel.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail; 2 | 3 | public enum LogLevel { 4 | /** 5 | * DEBUG for 4xx and ERROR for 5xx and anything else. 6 | */ 7 | AUTO, 8 | 9 | ERROR, WARNING, INFO, DEBUG, OFF 10 | } 11 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/t1/problemdetail/Logging.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.Target; 5 | 6 | import static com.github.t1.problemdetail.LogLevel.AUTO; 7 | import static java.lang.annotation.ElementType.PACKAGE; 8 | import static java.lang.annotation.ElementType.TYPE; 9 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 10 | 11 | /** 12 | * Defines how problem details should be logged. 13 | *

14 | * Can be applied to the package level, so all exceptions in the package are configured by default. 15 | */ 16 | @Retention(RUNTIME) 17 | @Target({TYPE,PACKAGE}) 18 | public @interface Logging { 19 | 20 | /** 21 | * The category to log to. Defaults to the fully qualified class name of the exception. 22 | */ 23 | String to() default ""; 24 | 25 | /** 26 | * The level to log at. Defaults to AUTO, i.e. DEBUG for 4xx 27 | * and ERROR for 5xx. 28 | */ 29 | LogLevel at() default AUTO; 30 | } 31 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/t1/problemdetail/ProblemDetail.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail; 2 | 3 | import jakarta.xml.bind.annotation.XmlType; 4 | import java.net.URI; 5 | import java.util.Objects; 6 | 7 | /** 8 | * Http response body containing problem details as specified in 9 | * RFC-7807 10 | *

11 | * This class can be used by clients of an API that produces problem detail 12 | * responses to deserialize the body (entity), e.g.: 13 | *


 14 |  * Response response = target.post("/orders", ...);
 15 |  *
 16 |  * if (response.getStatusInfo() == OK) {
 17 |  *     ...
 18 |  * } else {
 19 |  *     ProblemDetail detail = response.readEntity(ProblemDetail.class);
 20 |  *     if (OUT_OF_CREDIT.equals(detail.getType())) {
 21 |  *         ...
 22 |  *     }
 23 |  * }
 24 |  * 
25 | *

26 | * If the problem detail contains extensions (custom fields) you need to access, 27 | * you can simply create a subclass of ProblemDetail, e.g.: 28 | *


 29 |  * public static class ExtendedProblemDetail extends ProblemDetail {
 30 |  *     private int balance;
 31 |  *     private List<URI> accounts;
 32 |  *     ...
 33 |  * }
 34 |  *
 35 |  * ...
 36 |  * ProblemDetail detail = response.readEntity(ExtendedProblemDetail.class);
 37 |  * if (detail.getBalance() < 10) {
 38 |  *     ...
 39 |  * }
 40 |  * 
41 | * 42 | * Note: This doesn't work with JAXB, as there can't be two types 43 | * with the same name and namespace. Other options like @XmlAny 44 | * would loose the type safety. You can instead unmarshal the body to an 45 | * xml dom document. 46 | *

47 | * Extensions can also be complex, i.e. contain nested lists and mappings. 48 | *

49 | * Consumers MUST use the "type" string as the primary identifier for 50 | * the problem type; the "title" string is advisory and included only 51 | * for users who are not aware of the semantics of the URI and do not 52 | * have the ability to discover them (e.g., offline log analysis). 53 | * Consumers SHOULD NOT automatically dereference the type URI. 54 | *

55 | * The "status" member, if present, is only advisory; it conveys the 56 | * HTTP status code used for the convenience of the consumer. 57 | * Generators MUST use the same status code in the actual HTTP response, 58 | * to assure that generic HTTP software that does not understand this 59 | * format still behaves correctly. See Section 5 for further caveats 60 | * regarding its use. 61 | *

62 | * Consumers can use the status member to determine what the original 63 | * status code used by the generator was, in cases where it has been 64 | * changed (e.g., by an intermediary or cache), and when message bodies 65 | * persist without HTTP information. Generic HTTP software will still 66 | * use the HTTP status code. 67 | *

68 | * The "detail" member, if present, ought to focus on helping the client 69 | * correct the problem, rather than giving debugging information. 70 | * Consumers SHOULD NOT parse the "detail" member for information; 71 | * extensions are more suitable and less error-prone ways to obtain such 72 | * information. 73 | *

74 | * Note that both "type" and "instance" accept relative URIs; this means 75 | * that they must be resolved relative to the document's base URI, as 76 | * per [RFC3986], Section 5. 77 | */ 78 | @XmlType(name = "problem", propOrder = {"type", "title", "status", "detail", "instance"}) 79 | public class ProblemDetail { 80 | @Override public String toString() { 81 | return "ProblemDetail:" + type + ":" + title + ":" + status + ":" + detail + ":" + instance; 82 | } 83 | 84 | /** 85 | * A URI reference [RFC3986] that identifies the 86 | * problem type. This specification encourages that, when 87 | * dereferenced, it provide human-readable documentation for the 88 | * problem type (e.g., using HTML [W3C.REC-html5-20141028]). When 89 | * this member is not present, its value is assumed to be 90 | * "about:blank". 91 | */ 92 | private URI type; 93 | 94 | 95 | /** 96 | * A short, human-readable summary of the problem 97 | * type. It SHOULD NOT change from occurrence to occurrence of the 98 | * problem, except for purposes of localization (e.g., using 99 | * proactive content negotiation; see [RFC7231], Section 3.4). 100 | */ 101 | private String title; 102 | 103 | 104 | /** 105 | * The HTTP status code ([RFC7231], Section 6) 106 | * generated by the origin server for this occurrence of the problem. 107 | */ 108 | private Integer status; 109 | 110 | 111 | /** 112 | * A human-readable explanation specific to this 113 | * occurrence of the problem. 114 | */ 115 | private String detail; 116 | 117 | 118 | /** 119 | * A URI reference that identifies the specific 120 | * occurrence of the problem. It may or may not yield further 121 | * information if dereferenced. 122 | */ 123 | private URI instance; 124 | 125 | 126 | public URI getType() {return this.type;} 127 | 128 | public String getTitle() {return this.title;} 129 | 130 | public Integer getStatus() {return this.status;} 131 | 132 | public String getDetail() {return this.detail;} 133 | 134 | public URI getInstance() {return this.instance;} 135 | 136 | public void setType(URI type) {this.type = type; } 137 | 138 | public void setTitle(String title) {this.title = title; } 139 | 140 | public void setStatus(Integer status) {this.status = status; } 141 | 142 | public void setDetail(String detail) {this.detail = detail; } 143 | 144 | public void setInstance(URI instance) {this.instance = instance; } 145 | 146 | @Override public boolean equals(Object o) { 147 | if (this == o) 148 | return true; 149 | if (o == null || getClass() != o.getClass()) 150 | return false; 151 | ProblemDetail that = (ProblemDetail) o; 152 | return Objects.equals(type, that.type) && 153 | Objects.equals(title, that.title) && 154 | Objects.equals(status, that.status) && 155 | Objects.equals(detail, that.detail) && 156 | Objects.equals(instance, that.instance); 157 | } 158 | 159 | @Override public int hashCode() { 160 | return Objects.hash(type, title, status, detail, instance); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/t1/problemdetail/Status.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail; 2 | 3 | import jakarta.ws.rs.core.Response; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.Target; 6 | 7 | import static java.lang.annotation.ElementType.TYPE; 8 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 9 | 10 | /** 11 | * Defines the http status code to be used for the annotated exception. 12 | * This will also be included as the status field of the 13 | * problem detail. 14 | */ 15 | @Retention(RUNTIME) 16 | @Target(TYPE) 17 | public @interface Status { 18 | Response.Status value(); 19 | } 20 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/t1/problemdetail/Title.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.Target; 5 | 6 | import static java.lang.annotation.ElementType.TYPE; 7 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 8 | 9 | /** 10 | * Defines the title string to be used for the annotated exception. 11 | * The default is derived from the simple class name by splitting the 12 | * camel case name into words. 13 | */ 14 | @Retention(RUNTIME) 15 | @Target({TYPE}) 16 | public @interface Title { 17 | String value(); 18 | } 19 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/t1/problemdetail/Type.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.Target; 5 | 6 | import static java.lang.annotation.ElementType.TYPE; 7 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 8 | 9 | /** 10 | * Defines the type url to be used for the annotated exception. 11 | * The default is a URN urn:problem-type:[simple-class-name-with-dashes] 12 | */ 13 | @Retention(RUNTIME) 14 | @Target({TYPE}) 15 | public @interface Type { 16 | String value(); 17 | } 18 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/t1/problemdetail/package-info.java: -------------------------------------------------------------------------------- 1 | @XmlSchema( 2 | namespace = "urn:ietf:rfc:7807", 3 | elementFormDefault = XmlNsForm.QUALIFIED) 4 | package com.github.t1.problemdetail; 5 | 6 | import jakarta.xml.bind.annotation.XmlNsForm; 7 | import jakarta.xml.bind.annotation.XmlSchema; 8 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | com.github.t1 6 | problem-details 7 | 3.0.2-SNAPSHOT 8 | pom 9 | Problem Details 10 | Problem Details [RFC-7807] Java API, TCK, and implementations for JAX-RS 11 | https://github.com/t1/problem-details 12 | 13 | 14 | 17 15 | 17 16 | utf-8 17 | utf-8 18 | 19 | 1.18.38 20 | 6.0 21 | 2.0.17 22 | 6.2.12.Final 23 | 9.0.0.Final 24 | 25 | 3.27.3 26 | 5.13.0 27 | 5.18.0 28 | 2.1 29 | 1.5.18 30 | 31 | 32 | 33 | 34 | t1 35 | Rüdiger zu Dohna 36 | 37 | 38 | 39 | 40 | Apache License 2.0 41 | https://choosealicense.com/licenses/apache-2.0/ 42 | repo 43 | 44 | 45 | 46 | 47 | scm:git:https://github.com/t1/problem-details.git 48 | HEAD 49 | https://github.com/t1/problem-details.git 50 | 51 | 52 | 53 | ossrh 54 | t1-javaee-helpers 55 | https://oss.sonatype.org/service/local/staging/deploy/maven2/ 56 | 57 | 58 | ossrh 59 | https://oss.sonatype.org/content/repositories/snapshots 60 | 61 | 62 | 63 | 64 | install 65 | 66 | 67 | org.apache.maven.plugins 68 | maven-compiler-plugin 69 | 3.14.0 70 | 71 | true 72 | 73 | 74 | 75 | org.apache.maven.plugins 76 | maven-surefire-plugin 77 | 3.5.3 78 | 79 | 80 | *Behavior 81 | 82 | 83 | 84 | 85 | org.apache.maven.plugins 86 | maven-failsafe-plugin 87 | 3.5.3 88 | 89 | 90 | perform-it 91 | 92 | integration-test 93 | verify 94 | 95 | 96 | 97 | 98 | 99 | org.apache.maven.plugins 100 | maven-source-plugin 101 | 3.3.1 102 | 103 | 104 | attach-sources 105 | 106 | jar-no-fork 107 | 108 | 109 | 110 | 111 | 112 | org.apache.maven.plugins 113 | maven-javadoc-plugin 114 | 3.11.2 115 | 116 | 117 | attach-javadocs 118 | 119 | jar 120 | 121 | 122 | 123 | 124 | -missing 125 | 126 | 127 | 128 | org.apache.maven.plugins 129 | maven-release-plugin 130 | 3.1.1 131 | 132 | true 133 | v@{project.version} 134 | false 135 | release 136 | 137 | 138 | 139 | org.sonatype.plugins 140 | nexus-staging-maven-plugin 141 | 1.7.0 142 | 143 | ossrh 144 | https://oss.sonatype.org/ 145 | true 146 | 147 | 148 | 149 | 150 | 151 | 152 | api 153 | ri 154 | ri-lib 155 | test 156 | 157 | 158 | 159 | 160 | release 161 | 162 | 163 | 164 | 165 | 166 | org.apache.maven.plugins 167 | maven-gpg-plugin 168 | 3.2.7 169 | 170 | 171 | sign-artifacts 172 | verify 173 | 174 | sign 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "packageRules": [ 7 | { 8 | "description": "Automerge non-major updates", 9 | "matchUpdateTypes": ["minor", "patch"], 10 | "automerge": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /ri-lib/README.adoc: -------------------------------------------------------------------------------- 1 | = Problem Details Impl Lib 2 | 3 | This is a library of some classes that can be shared between the JAX-RS and the Spring Boot implementation. This is not part of any public API. 4 | -------------------------------------------------------------------------------- /ri-lib/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | 6 | com.github.t1 7 | problem-details 8 | 3.0.2-SNAPSHOT 9 | ../pom.xml 10 | 11 | 12 | problem-details-ri-lib 13 | Code to be reused by jax-rs as well as spring impls 14 | 15 | 16 | 11 17 | 11 18 | utf-8 19 | 20 | 21 | 22 | install 23 | 24 | 25 | 26 | 27 | org.projectlombok 28 | lombok 29 | ${lombok.version} 30 | provided 31 | 32 | 33 | org.slf4j 34 | slf4j-api 35 | ${slf4j.version} 36 | provided 37 | 38 | 39 | 40 | com.github.t1 41 | problem-details-api 42 | 3.0.2-SNAPSHOT 43 | 44 | 45 | jakarta.ws.rs 46 | jakarta.ws.rs-api 47 | 4.0.0 48 | provided 49 | 50 | 51 | jakarta.json.bind 52 | jakarta.json.bind-api 53 | 3.0.1 54 | provided 55 | 56 | 57 | jakarta.json 58 | jakarta.json-api 59 | 2.1.3 60 | provided 61 | 62 | 63 | jakarta.validation 64 | jakarta.validation-api 65 | 3.1.1 66 | provided 67 | 68 | 69 | 70 | org.junit.jupiter 71 | junit-jupiter 72 | ${junit.version} 73 | test 74 | 75 | 76 | org.assertj 77 | assertj-core 78 | ${assertj.version} 79 | test 80 | 81 | 82 | org.mockito 83 | mockito-junit-jupiter 84 | ${mockito.version} 85 | test 86 | 87 | 88 | org.eclipse 89 | yasson 90 | 3.0.4 91 | test 92 | 93 | 94 | org.hibernate.validator 95 | hibernate-validator 96 | ${hibernate.version} 97 | test 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /ri-lib/src/main/java/com/github/t1/problemdetail/ri/lib/ProblemDetailJsonToExceptionBuilder.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail.ri.lib; 2 | 3 | import com.github.t1.problemdetail.Extension; 4 | import com.github.t1.problemdetail.Instance; 5 | import jakarta.json.Json; 6 | import jakarta.json.JsonObject; 7 | import jakarta.json.JsonObjectBuilder; 8 | import jakarta.json.bind.Jsonb; 9 | import jakarta.json.bind.JsonbBuilder; 10 | import jakarta.json.bind.JsonbConfig; 11 | import jakarta.json.bind.config.PropertyVisibilityStrategy; 12 | import jakarta.ws.rs.NotFoundException; 13 | import lombok.extern.slf4j.Slf4j; 14 | 15 | import java.io.InputStream; 16 | import java.lang.reflect.Field; 17 | import java.lang.reflect.Method; 18 | import java.lang.reflect.Modifier; 19 | import java.util.HashMap; 20 | import java.util.Map; 21 | import java.util.stream.Stream; 22 | 23 | @Slf4j 24 | public class ProblemDetailJsonToExceptionBuilder extends Throwable { 25 | private static final Map> REGISTRY = new HashMap<>(); 26 | 27 | static { 28 | register(NullPointerException.class); 29 | register(RuntimeException.class); 30 | register(NotFoundException.class); 31 | } 32 | 33 | public static String register(Class exceptionType) { 34 | String typeUri = ProblemDetails.buildType(exceptionType).toString(); 35 | REGISTRY.put(typeUri, exceptionType); 36 | return typeUri; 37 | } 38 | 39 | public ProblemDetailJsonToExceptionBuilder(InputStream entityStream) { 40 | this.body = Json.createReader(entityStream).readObject(); 41 | String typeUri = body.getString("type", null); 42 | this.type = REGISTRY.getOrDefault(typeUri, null); 43 | } 44 | 45 | private final JsonObject body; 46 | private final Class type; 47 | 48 | private final JsonObjectBuilder output = Json.createObjectBuilder(); 49 | 50 | /** 51 | * Throws an exception; if the type wasn't {@link #register(Class) registered}, 52 | * a {@link IllegalArgumentException} is thrown. 53 | */ 54 | public void trigger() { 55 | if (type == null) 56 | throw new IllegalArgumentException("no registered exception found for `type` field in " + body); 57 | 58 | setInstance(); 59 | setExtensions(); 60 | 61 | String json = output.build().toString(); 62 | throw JSONB.fromJson(json, type); 63 | } 64 | 65 | private void setInstance() { 66 | if (!body.containsKey("instance")) 67 | return; 68 | String value = body.getString("instance"); 69 | Stream.of(type.getDeclaredFields()) 70 | .filter(field -> field.isAnnotationPresent(Instance.class)) 71 | .findAny().ifPresent(field -> output.add(field.getName(), value)); 72 | } 73 | 74 | private void setExtensions() { 75 | Stream.of(type.getDeclaredFields()) 76 | .filter(field -> field.isAnnotationPresent(Extension.class)) 77 | .forEach(field -> { 78 | String annotatedName = field.getAnnotation(Extension.class).value(); 79 | String name = annotatedName.isEmpty() ? field.getName() : annotatedName; 80 | if (body.containsKey(name)) { 81 | output.add(field.getName(), body.getValue("/" + name)); 82 | } 83 | }); 84 | } 85 | 86 | private static final PropertyVisibilityStrategy FIELD_ACCESS = new PropertyVisibilityStrategy() { 87 | @Override 88 | public boolean isVisible(Field field) { 89 | return Modifier.isPublic(field.getModifiers()) || isOpen(field.getDeclaringClass().getModule()); 90 | } 91 | 92 | private boolean isOpen(Module module) { 93 | return module.getDescriptor() == null || module.getDescriptor().isOpen(); 94 | } 95 | 96 | @Override 97 | public boolean isVisible(Method method) { 98 | return Modifier.isPublic(method.getModifiers()); 99 | } 100 | }; 101 | private static final Jsonb JSONB = JsonbBuilder.create(new JsonbConfig().withPropertyVisibilityStrategy(FIELD_ACCESS)); 102 | } 103 | -------------------------------------------------------------------------------- /ri-lib/src/main/java/com/github/t1/problemdetail/ri/lib/ProblemDetails.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail.ri.lib; 2 | 3 | import com.github.t1.problemdetail.Detail; 4 | import com.github.t1.problemdetail.Extension; 5 | import com.github.t1.problemdetail.Instance; 6 | import com.github.t1.problemdetail.LogLevel; 7 | import com.github.t1.problemdetail.Logging; 8 | import com.github.t1.problemdetail.Status; 9 | import com.github.t1.problemdetail.Title; 10 | import com.github.t1.problemdetail.Type; 11 | import lombok.Getter; 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | 15 | import jakarta.ws.rs.core.Response.StatusType; 16 | import jakarta.ws.rs.core.UriBuilder; 17 | import java.lang.annotation.Annotation; 18 | import java.lang.reflect.AnnotatedElement; 19 | import java.lang.reflect.Field; 20 | import java.lang.reflect.InvocationTargetException; 21 | import java.lang.reflect.Member; 22 | import java.lang.reflect.Method; 23 | import java.net.URI; 24 | import java.net.URISyntaxException; 25 | import java.util.ArrayList; 26 | import java.util.LinkedHashMap; 27 | import java.util.List; 28 | import java.util.Map; 29 | import java.util.TreeMap; 30 | import java.util.UUID; 31 | 32 | import static com.github.t1.problemdetail.LogLevel.AUTO; 33 | import static java.util.stream.Collectors.joining; 34 | import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; 35 | import static jakarta.ws.rs.core.Response.Status.Family.CLIENT_ERROR; 36 | import static jakarta.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; 37 | 38 | /** 39 | * Tech stack independent collector. Template methods can be overridden to provide tech stack specifics. 40 | */ 41 | public abstract class ProblemDetails { 42 | protected final Exception exception; 43 | protected final Class type; 44 | 45 | @Getter private final StatusType status; 46 | @Getter private final Object body; 47 | @Getter private final String mediaType; 48 | @Getter private final String logMessage; 49 | 50 | public ProblemDetails(Exception exception) { 51 | this.exception = exception; 52 | this.type = exception.getClass(); 53 | this.status = buildStatus(); 54 | this.body = buildBody(); 55 | this.mediaType = buildResponseMediaType(); 56 | this.logMessage = buildLogMessage(); 57 | 58 | log(logMessage); 59 | } 60 | 61 | protected Object buildBody() { 62 | Map body = new LinkedHashMap<>(); 63 | body.put("type", buildType()); 64 | 65 | body.put("title", buildTitle()); 66 | 67 | body.put("status", status.getStatusCode()); 68 | 69 | String detail = buildDetail(); 70 | if (detail != null) { 71 | body.put("detail", detail); 72 | } 73 | 74 | body.put("instance", buildInstance()); 75 | 76 | body.putAll(buildExtensions()); 77 | 78 | return body; 79 | } 80 | 81 | public int getStatusCode() { 82 | return buildStatus().getStatusCode(); 83 | } 84 | 85 | protected StatusType buildStatus() { 86 | if (type.isAnnotationPresent(Status.class)) { 87 | return type.getAnnotation(Status.class).value(); 88 | } else if (exception instanceof IllegalArgumentException) { 89 | return BAD_REQUEST; 90 | } else { 91 | return fallbackStatus(); 92 | } 93 | } 94 | 95 | protected StatusType fallbackStatus() { 96 | return INTERNAL_SERVER_ERROR; 97 | } 98 | 99 | protected URI buildType() { 100 | return buildType(type); 101 | } 102 | 103 | public static URI buildType(Class type) { 104 | return URI.create(type.isAnnotationPresent(Type.class) 105 | ? type.getAnnotation(Type.class).value() 106 | : "urn:problem-type:" + wordsFromTypeName(type, '-').toLowerCase()); 107 | } 108 | 109 | protected String buildTitle() { 110 | return type.isAnnotationPresent(Title.class) 111 | ? type.getAnnotation(Title.class).value() 112 | : wordsFromTypeName(type, ' '); 113 | } 114 | 115 | private static String wordsFromTypeName(Class type, char delimiter) { 116 | String message = camelToWords(type.getSimpleName(), delimiter); 117 | if (message.endsWith(delimiter + "Exception")) 118 | message = message.substring(0, message.length() - 10); 119 | return message; 120 | } 121 | 122 | private static String camelToWords(String input, char delimiter) { 123 | StringBuilder out = new StringBuilder(); 124 | input.codePoints().forEach(c -> { 125 | if (Character.isUpperCase(c) && out.length() > 0) { 126 | out.append(delimiter); 127 | } 128 | out.appendCodePoint(c); 129 | }); 130 | return out.toString(); 131 | } 132 | 133 | protected String buildDetail() { 134 | List details = new ArrayList<>(); 135 | for (Method method : type.getDeclaredMethods()) { 136 | if (method.isAnnotationPresent(Detail.class)) { 137 | details.add(invoke(method)); 138 | } 139 | } 140 | for (Field field : type.getDeclaredFields()) { 141 | if (field.isAnnotationPresent(Detail.class)) { 142 | details.add(get(field)); 143 | } 144 | } 145 | return (details.isEmpty()) 146 | ? hasDefaultMessage() ? null : exception.getMessage() 147 | : details.stream().map(Object::toString).collect(joining(". ")); 148 | } 149 | 150 | /** We don't want to repeat default messages like `400 Bad Request` */ 151 | protected abstract boolean hasDefaultMessage(); 152 | 153 | private Object invoke(Method method) { 154 | try { 155 | if (method.getParameterCount() != 0) 156 | return invocationFailed(method, "expected no args but got " + method.getParameterCount()); 157 | method.setAccessible(true); 158 | return method.invoke(exception); 159 | } catch (IllegalAccessException e) { 160 | return invocationFailed(method, e); 161 | } catch (InvocationTargetException e) { 162 | return invocationFailed(method, e.getTargetException()); 163 | } 164 | } 165 | 166 | private String invocationFailed(Method method, Object detail) { 167 | return "could not invoke " + method.getDeclaringClass().getSimpleName() 168 | + "." + method.getName() + ": " + detail; 169 | } 170 | 171 | private Object get(Field field) { 172 | try { 173 | field.setAccessible(true); 174 | return field.get(exception); 175 | } catch (IllegalAccessException e) { 176 | return "could not get " + field; 177 | } 178 | } 179 | 180 | protected URI buildInstance() { 181 | String instance = null; 182 | for (Method method : type.getDeclaredMethods()) { 183 | if (method.isAnnotationPresent(Instance.class)) { 184 | instance = invoke(method).toString(); 185 | } 186 | } 187 | for (Field field : type.getDeclaredFields()) { 188 | if (field.isAnnotationPresent(Instance.class)) { 189 | instance = get(field).toString(); 190 | } 191 | } 192 | if (instance == null) 193 | return URI.create("urn:uuid:" + UUID.randomUUID()); 194 | return createSafeUri(instance); 195 | } 196 | 197 | private URI createSafeUri(String string) { 198 | try { 199 | return new URI(string); 200 | } catch (URISyntaxException e) { 201 | return UriBuilder.fromUri("urn:invalid-uri-syntax") 202 | .queryParam("source", string) 203 | .queryParam("exception", e) 204 | .build(); 205 | } 206 | } 207 | 208 | protected Map buildExtensions() { 209 | Map extensions = new TreeMap<>(); 210 | for (Method method : type.getDeclaredMethods()) { 211 | if (method.isAnnotationPresent(Extension.class)) { 212 | extensions.put(extensionName(method), invoke(method)); 213 | } 214 | } 215 | for (Field field : type.getDeclaredFields()) { 216 | if (field.isAnnotationPresent(Extension.class)) { 217 | extensions.put(extensionName(field), get(field)); 218 | } 219 | } 220 | return extensions; 221 | } 222 | 223 | private String extensionName(Member member) { 224 | String annotatedName = ((AnnotatedElement) member).getAnnotation(Extension.class).value(); 225 | return annotatedName.isEmpty() ? member.getName() : annotatedName; 226 | } 227 | 228 | protected String buildResponseMediaType() { 229 | String format = findMediaTypeSubtype(); 230 | 231 | // browsers send, e.g., `text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8` 232 | // so the extra `problem+` is acceptable only by the wildcard and that starts a download 233 | return "xhtml+xml".equals(format) ? "text/html" : "application/problem+" + format; 234 | } 235 | 236 | protected abstract String findMediaTypeSubtype(); 237 | 238 | private String buildLogMessage() { 239 | return "ProblemDetail:\n" + formatBody() + "\n" 240 | + "Exception"; 241 | } 242 | 243 | private Object formatBody() { 244 | return (body instanceof Map) 245 | ? ((Map) body).entrySet().stream() 246 | .map(entry -> " " + entry.getKey() + ": " + entry.getValue()) 247 | .collect(joining("\n")) 248 | : String.valueOf(body); 249 | } 250 | 251 | 252 | private void log(String message) { 253 | Logger logger = buildLogger(); 254 | switch (buildLogLevel()) { 255 | case AUTO: 256 | if (CLIENT_ERROR.equals(status.getFamily())) { 257 | logger.debug(message); 258 | } else { 259 | logger.error(message); 260 | } 261 | break; 262 | case ERROR: 263 | logger.error(message); 264 | break; 265 | case WARNING: 266 | logger.warn(message); 267 | break; 268 | case INFO: 269 | logger.info(message); 270 | break; 271 | case DEBUG: 272 | logger.debug(message); 273 | break; 274 | case OFF: 275 | break; 276 | } 277 | } 278 | 279 | private Logger buildLogger() { 280 | Logging logging = findLoggingAnnotation(); 281 | return (logging == null || logging.to().isEmpty()) ? LoggerFactory.getLogger(type) 282 | : LoggerFactory.getLogger(logging.to()); 283 | } 284 | 285 | private LogLevel buildLogLevel() { 286 | Logging logging = findLoggingAnnotation(); 287 | return (logging == null) ? AUTO : logging.at(); 288 | } 289 | 290 | private Logging findLoggingAnnotation() { 291 | Logging onType = type.getAnnotation(Logging.class); 292 | Logging onPackage = type.getPackage().getAnnotation(Logging.class); 293 | if (onPackage == null) 294 | return onType; 295 | if (onType == null) 296 | return onPackage; 297 | return new Logging() { 298 | @Override public Class annotationType() { 299 | throw new UnsupportedOperationException(); 300 | } 301 | 302 | @Override public String to() { 303 | return (onType.to().isEmpty()) ? onPackage.to() : onType.to(); 304 | } 305 | 306 | @Override public LogLevel at() { 307 | return (onType.at() == AUTO) ? onPackage.at() : onType.at(); 308 | } 309 | }; 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /ri-lib/src/main/java/com/github/t1/problemdetail/ri/lib/ProblemXml.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetail.ri.lib; 2 | 3 | import lombok.SneakyThrows; 4 | import org.w3c.dom.Document; 5 | import org.w3c.dom.Element; 6 | 7 | import javax.xml.parsers.DocumentBuilderFactory; 8 | import javax.xml.parsers.ParserConfigurationException; 9 | import javax.xml.transform.Transformer; 10 | import javax.xml.transform.TransformerConfigurationException; 11 | import javax.xml.transform.TransformerException; 12 | import javax.xml.transform.TransformerFactory; 13 | import javax.xml.transform.dom.DOMSource; 14 | import javax.xml.transform.stream.StreamResult; 15 | import java.io.IOException; 16 | import java.io.OutputStream; 17 | import java.io.OutputStreamWriter; 18 | import java.io.Writer; 19 | import java.util.Map; 20 | 21 | import static javax.xml.transform.OutputKeys.ENCODING; 22 | import static javax.xml.transform.OutputKeys.INDENT; 23 | import static javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION; 24 | 25 | public class ProblemXml { 26 | public static final DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY = DocumentBuilderFactory.newInstance(); 27 | 28 | private final Document document; 29 | private Element current; 30 | 31 | @SneakyThrows(ParserConfigurationException.class) 32 | public ProblemXml(Object object) { 33 | document = DOCUMENT_BUILDER_FACTORY.newDocumentBuilder().newDocument(); 34 | current = document.createElementNS("urn:ietf:rfc:7807", "problem"); 35 | document.appendChild(current); 36 | 37 | append(object); 38 | } 39 | 40 | @SuppressWarnings("unchecked") private void append(Object object) { 41 | if (object instanceof Map) { 42 | append((Map) object); 43 | } else if (object instanceof Iterable) { 44 | append((Iterable) object); 45 | } else if (object != null) { 46 | append(object.toString()); 47 | } 48 | } 49 | 50 | private void append(Map map) { 51 | Element container = current; 52 | map.forEach((key, value) -> { 53 | current = document.createElement(key); 54 | append(value); 55 | container.appendChild(current); 56 | }); 57 | current = container; 58 | } 59 | 60 | private void append(Iterable iterable) { 61 | Element container = current; 62 | iterable.forEach(item -> { 63 | current = document.createElement("i"); 64 | append(item); 65 | container.appendChild(current); 66 | }); 67 | current = container; 68 | } 69 | 70 | private void append(String string) { 71 | current.appendChild(document.createTextNode(string)); 72 | } 73 | 74 | 75 | public void writeTo(OutputStream outputStream) throws IOException { 76 | OutputStreamWriter writer = new OutputStreamWriter(outputStream); 77 | writeTo(writer); 78 | } 79 | 80 | @SneakyThrows(TransformerException.class) 81 | public void writeTo(Writer writer) throws IOException { 82 | document.normalize(); 83 | // we write the `\n"); 86 | Transformer transformer = transformer(); 87 | transformer.setOutputProperty(ENCODING, "UTF-8"); 88 | transformer.setOutputProperty(OMIT_XML_DECLARATION, "yes"); 89 | transformer.setOutputProperty(INDENT, "yes"); 90 | transformer.transform(new DOMSource(document), new StreamResult(writer)); 91 | } 92 | 93 | private Transformer transformer() throws TransformerConfigurationException { 94 | TransformerFactory transformerFactory = TransformerFactory.newInstance(); 95 | transformerFactory.setAttribute("indent-number", 4); 96 | return transformerFactory.newTransformer(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /ri-lib/src/main/java/com/github/t1/validation/ValidationFailedException.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.validation; 2 | 3 | import com.github.t1.problemdetail.Detail; 4 | import com.github.t1.problemdetail.Extension; 5 | import com.github.t1.problemdetail.Status; 6 | import com.github.t1.problemdetail.Title; 7 | 8 | import jakarta.validation.ConstraintViolation; 9 | import jakarta.validation.Validation; 10 | import jakarta.validation.ValidatorFactory; 11 | import java.util.Map; 12 | import java.util.Set; 13 | 14 | import static java.util.stream.Collectors.groupingBy; 15 | import static java.util.stream.Collectors.mapping; 16 | import static java.util.stream.Collectors.toSet; 17 | import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; 18 | 19 | /** 20 | * Can be mapped as problem detail, exposing only the violations, but not the 21 | * actual data. You'll find the data together with the stack trace in the logs. 22 | */ 23 | @Title("Validation Failed") 24 | @Status(BAD_REQUEST) 25 | public class ValidationFailedException extends RuntimeException { 26 | 27 | private static ValidatorFactory VALIDATOR_FACTORY; 28 | 29 | public static void validate(Object object, Class... groups) { 30 | if (VALIDATOR_FACTORY == null) VALIDATOR_FACTORY = Validation.buildDefaultValidatorFactory(); 31 | validate(VALIDATOR_FACTORY, object, groups); 32 | } 33 | 34 | public static void validate(ValidatorFactory factory, Object object, Class... groups) { 35 | Set> violations = factory.getValidator().validate(object, groups); 36 | if (violations.isEmpty()) 37 | return; 38 | throw new ValidationFailedException(violations); 39 | } 40 | 41 | private final Set> violations; 42 | 43 | public ValidationFailedException(Set> violations) { 44 | super(violations.size() + " violations failed on " + rootBean(violations)); 45 | this.violations = violations; 46 | } 47 | 48 | // don't expose the message: 49 | @Detail String detail() { 50 | return violations.size() + " violations failed"; 51 | } 52 | 53 | @Extension 54 | public Map> violations() { 55 | return violations.stream().collect(groupingBy( 56 | (ConstraintViolation violation) -> violation.getPropertyPath().toString(), 57 | mapping(ConstraintViolation::getMessage, toSet()) 58 | )); 59 | } 60 | 61 | private static String rootBean(Set> violations) { 62 | return (violations.isEmpty()) ? null : violations.iterator().next().getRootBean().toString(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /ri-lib/src/test/java/test/LoggingBehavior.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import com.github.t1.problemdetail.Logging; 4 | import com.github.t1.problemdetail.Status; 5 | import com.github.t1.problemdetail.ri.lib.ProblemDetails; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.Test; 8 | import test.sub.SubException; 9 | import test.sub.SubExceptionWithCategory; 10 | import test.sub.SubExceptionWithLevel; 11 | 12 | import java.net.URI; 13 | 14 | import static com.github.t1.problemdetail.LogLevel.DEBUG; 15 | import static com.github.t1.problemdetail.LogLevel.ERROR; 16 | import static com.github.t1.problemdetail.LogLevel.INFO; 17 | import static com.github.t1.problemdetail.LogLevel.OFF; 18 | import static com.github.t1.problemdetail.LogLevel.WARNING; 19 | import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; 20 | import static jakarta.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; 21 | import static org.mockito.BDDMockito.then; 22 | import static test.MockLoggerFactory.onlyLogger; 23 | 24 | class LoggingBehavior { 25 | @BeforeEach void setUp() { MockLoggerFactory.reset(); } 26 | 27 | @Test void shouldLogAuto5xxAtError() { 28 | @Status(INTERNAL_SERVER_ERROR) class CustomException extends Exception {} 29 | 30 | ProblemDetails details = new MockProblemDetails(new CustomException()); 31 | 32 | then(onlyLogger(CustomException.class)).should().error(details.getLogMessage()); 33 | } 34 | 35 | @Test void shouldLogAuto4xxAtDebug() { 36 | @Status(BAD_REQUEST) class CustomException extends Exception {} 37 | 38 | ProblemDetails details = new MockProblemDetails(new CustomException()); 39 | 40 | then(onlyLogger(CustomException.class)).should().debug(details.getLogMessage()); 41 | } 42 | 43 | @Test void shouldLogExplicitlyAtError() { 44 | @Logging(at = ERROR) class CustomException extends Exception {} 45 | 46 | ProblemDetails details = new MockProblemDetails(new CustomException()); 47 | 48 | then(onlyLogger(CustomException.class)).should().error(details.getLogMessage()); 49 | } 50 | 51 | @Test void shouldLogExplicitlyAtWarning() { 52 | @Logging(at = WARNING) class CustomException extends Exception {} 53 | 54 | ProblemDetails details = new MockProblemDetails(new CustomException()); 55 | 56 | then(onlyLogger(CustomException.class)).should().warn(details.getLogMessage()); 57 | } 58 | 59 | @Test void shouldLogExplicitlyAtInfo() { 60 | @Logging(at = INFO) class CustomException extends Exception {} 61 | 62 | ProblemDetails details = new MockProblemDetails(new CustomException()); 63 | 64 | then(onlyLogger(CustomException.class)).should().info(details.getLogMessage()); 65 | } 66 | 67 | @Test void shouldLogExplicitlyAtDebug() { 68 | @Logging(at = DEBUG) class CustomException extends Exception {} 69 | 70 | ProblemDetails details = new MockProblemDetails(new CustomException()); 71 | 72 | then(onlyLogger(CustomException.class)).should().debug(details.getLogMessage()); 73 | } 74 | 75 | @Test void shouldLogExplicitlyAtOff() { 76 | @Logging(at = OFF) class CustomException extends Exception {} 77 | 78 | new MockProblemDetails(new CustomException()); 79 | 80 | then(onlyLogger(CustomException.class)).shouldHaveNoInteractions(); 81 | } 82 | 83 | 84 | @Test void shouldLogToExplicitCategory() { 85 | @Logging(to = "my-errors") class CustomException extends Exception {} 86 | 87 | ProblemDetails details = new MockProblemDetails(new CustomException()); 88 | 89 | then(onlyLogger("my-errors")).should().error(details.getLogMessage()); 90 | } 91 | 92 | 93 | @Test void shouldLogToPackageAnnotatedCategory() { 94 | ProblemDetails details = new MockProblemDetails(new SubException()); 95 | 96 | then(onlyLogger("warnings")).should().warn(details.getLogMessage()); 97 | } 98 | 99 | @Test void shouldOverridePackageAnnotatedLogLevel() { 100 | ProblemDetails details = new MockProblemDetails(new SubExceptionWithLevel()); 101 | 102 | then(onlyLogger("warnings")).should().info(details.getLogMessage()); 103 | } 104 | 105 | @Test void shouldOverridePackageAnnotatedLogCategory() { 106 | ProblemDetails details = new MockProblemDetails(new SubExceptionWithCategory()); 107 | 108 | then(onlyLogger("sub-cat")).should().warn(details.getLogMessage()); 109 | } 110 | 111 | private static class MockProblemDetails extends ProblemDetails { 112 | public MockProblemDetails(Exception exception) { super(exception); } 113 | 114 | @Override protected boolean hasDefaultMessage() { return false; } 115 | 116 | @Override protected String findMediaTypeSubtype() { return "json"; } 117 | 118 | @Override protected URI buildInstance() { return URI.create("urn:some-instance"); } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /ri-lib/src/test/java/test/MockLoggerFactory.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import org.slf4j.ILoggerFactory; 4 | import org.slf4j.Logger; 5 | 6 | import java.util.LinkedHashMap; 7 | import java.util.Map; 8 | 9 | import static org.assertj.core.api.BDDAssertions.then; 10 | import static org.mockito.Mockito.mock; 11 | 12 | public class MockLoggerFactory implements ILoggerFactory { 13 | private static final Map LOGGERS = new LinkedHashMap<>(); 14 | 15 | public static void reset() { LOGGERS.clear(); } 16 | 17 | @Override public Logger getLogger(String name) { 18 | return LOGGERS.computeIfAbsent(name, this::createLogger); 19 | } 20 | 21 | private Logger createLogger(String name) { 22 | return mock(Logger.class, "logger:" + name); 23 | } 24 | 25 | public static Logger onlyLogger(Class type) { 26 | return onlyLogger(type.getName()); 27 | } 28 | 29 | public static Logger onlyLogger(String name) { 30 | then(LOGGERS.keySet()).containsOnly(name); 31 | Logger logger = LOGGERS.remove(name); 32 | then(logger).isNotNull(); 33 | return logger; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ri-lib/src/test/java/test/ProblemDetailJsonToExceptionBuilderBehavior.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import com.github.t1.problemdetail.Extension; 4 | import com.github.t1.problemdetail.Instance; 5 | import com.github.t1.problemdetail.ri.lib.ProblemDetailJsonToExceptionBuilder; 6 | import jakarta.json.Json; 7 | import jakarta.json.JsonObjectBuilder; 8 | import org.junit.jupiter.api.Test; 9 | 10 | import java.io.ByteArrayInputStream; 11 | import java.io.InputStream; 12 | import java.net.URI; 13 | 14 | import static java.nio.charset.StandardCharsets.UTF_8; 15 | import static org.assertj.core.api.Assertions.*; 16 | import static org.assertj.core.api.BDDAssertions.then; 17 | 18 | class ProblemDetailJsonToExceptionBuilderBehavior { 19 | private final JsonObjectBuilder entity = Json.createObjectBuilder(); 20 | 21 | @Test void shouldTriggerNullType() { 22 | // entity = "{}" 23 | 24 | IllegalArgumentException thrown = catchThrowableOfType(this::trigger, IllegalArgumentException.class); 25 | 26 | then(thrown).hasMessage("no registered exception found for `type` field in {}"); 27 | } 28 | 29 | @Test void shouldNotFilterUnknownType() { 30 | entity.add("type", "unknown"); 31 | 32 | IllegalArgumentException thrown = catchThrowableOfType(this::trigger, IllegalArgumentException.class); 33 | 34 | then(thrown).hasMessage("no registered exception found for `type` field in {\"type\":\"unknown\"}"); 35 | } 36 | 37 | @Test void shouldBuildNullPointer() { 38 | entity.add("type", "urn:problem-type:null-pointer"); 39 | 40 | Exception thrown = catchException(this::trigger); 41 | 42 | assertThat(thrown) 43 | .isInstanceOf(NullPointerException.class) 44 | .hasMessage(null); 45 | } 46 | 47 | public static class CustomException extends RuntimeException {} 48 | 49 | @Test void shouldBuildCustomType() { 50 | givenRegisteredType(CustomException.class); 51 | 52 | Exception thrown = catchException(this::trigger); 53 | 54 | assertThat(thrown).isInstanceOf(CustomException.class).hasMessage(null); 55 | } 56 | 57 | public static class CustomWithStringInstanceException extends RuntimeException { 58 | @Instance String string = null; 59 | } 60 | 61 | @Test void shouldBuildCustomTypeWithStringInstanceFieldNotSet() { 62 | givenRegisteredType(CustomWithStringInstanceException.class); 63 | 64 | CustomWithStringInstanceException thrown = catchThrowableOfType(this::trigger, CustomWithStringInstanceException.class); 65 | 66 | assertThat(thrown).hasMessage(null); 67 | assertThat(thrown.string).isEqualTo(null); 68 | } 69 | 70 | @Test void shouldBuildCustomTypeWithStringInstanceFieldSet() { 71 | givenRegisteredType(CustomWithStringInstanceException.class); 72 | entity.add("instance", SOME_URI.toString()); 73 | 74 | CustomWithStringInstanceException thrown = catchThrowableOfType(this::trigger, CustomWithStringInstanceException.class); 75 | 76 | assertThat(thrown).hasMessage(null); 77 | assertThat(thrown.string).isEqualTo(SOME_URI.toString()); 78 | } 79 | 80 | public static class CustomWithUriInstanceException extends RuntimeException { 81 | @Instance URI uri = null; 82 | } 83 | 84 | @Test void shouldBuildCustomTypeWithUriInstanceFieldSet() { 85 | givenRegisteredType(CustomWithUriInstanceException.class); 86 | entity.add("instance", SOME_URI.toString()); 87 | 88 | CustomWithUriInstanceException thrown = catchThrowableOfType(this::trigger, CustomWithUriInstanceException.class); 89 | 90 | assertThat(thrown).hasMessage(null); 91 | assertThat(thrown.uri).isEqualTo(SOME_URI); 92 | } 93 | 94 | public static class CustomWithIntegerInstanceException extends RuntimeException { 95 | @Instance Integer i = null; 96 | } 97 | 98 | @Test void shouldBuildCustomTypeWithIntegerInstanceFieldSet() { 99 | givenRegisteredType(CustomWithIntegerInstanceException.class); 100 | entity.add("instance", "123"); 101 | 102 | CustomWithIntegerInstanceException thrown = catchThrowableOfType(this::trigger, CustomWithIntegerInstanceException.class); 103 | 104 | assertThat(thrown).hasMessage(null); 105 | assertThat(thrown.i).isEqualTo(123); 106 | } 107 | 108 | public static class CustomWithLongInstanceException extends RuntimeException { 109 | @Instance long l; 110 | } 111 | 112 | @Test void shouldBuildCustomTypeWithLongInstanceFieldSet() { 113 | givenRegisteredType(CustomWithLongInstanceException.class); 114 | entity.add("instance", "123"); 115 | 116 | CustomWithLongInstanceException thrown = catchThrowableOfType(this::trigger, CustomWithLongInstanceException.class); 117 | 118 | assertThat(thrown).hasMessage(null); 119 | assertThat(thrown.l).isEqualTo(123L); 120 | } 121 | 122 | public static class CustomWithUnnamedExtensionsException extends RuntimeException { 123 | @Extension boolean bo1; 124 | @Extension byte by1; 125 | @Extension short sh1; 126 | @Extension int in1; 127 | @Extension long lo1; 128 | @Extension float fl1; 129 | @Extension double du1; 130 | 131 | @Extension Boolean bo2; 132 | @Extension Byte by2; 133 | @Extension Short sh2; 134 | @Extension Integer in2; 135 | @Extension Long lo2; 136 | @Extension Float fl2; 137 | @Extension Double du2; 138 | 139 | @Extension String str; 140 | @Extension URI uri; 141 | } 142 | 143 | @Test void shouldBuildCustomTypeWithUnnamedExtensionsFieldSetConverting() { 144 | givenRegisteredType(CustomWithUnnamedExtensionsException.class); 145 | entity.add("bo1", "true"); 146 | entity.add("by1", "12"); 147 | entity.add("sh1", "123"); 148 | entity.add("in1", "1234"); 149 | entity.add("lo1", "12345"); 150 | entity.add("fl1", "1.12"); 151 | entity.add("du1", "1.23456"); 152 | 153 | entity.add("bo2", "true"); 154 | entity.add("by2", "123"); 155 | entity.add("sh2", "1234"); 156 | entity.add("in2", "12345"); 157 | entity.add("lo2", "123456"); 158 | entity.add("fl2", "1.123"); 159 | entity.add("du2", "1.234567"); 160 | 161 | entity.add("str", "dummy-string"); 162 | entity.add("uri", "dummy:uri"); 163 | 164 | CustomWithUnnamedExtensionsException thrown = catchThrowableOfType(this::trigger, CustomWithUnnamedExtensionsException.class); 165 | 166 | assertThat(thrown).hasMessage(null); 167 | 168 | assertThat(thrown.bo1).isEqualTo(true); 169 | assertThat(thrown.by1).isEqualTo((byte) 12); 170 | assertThat(thrown.sh1).isEqualTo((short) 123); 171 | assertThat(thrown.in1).isEqualTo(1234); 172 | assertThat(thrown.lo1).isEqualTo(12345); 173 | assertThat(thrown.fl1).isEqualTo(1.12f); 174 | assertThat(thrown.du1).isEqualTo(1.23456D); 175 | 176 | assertThat(thrown.bo2).isEqualTo(true); 177 | assertThat(thrown.by2).isEqualTo((byte) 123); 178 | assertThat(thrown.sh2).isEqualTo((short) 1234); 179 | assertThat(thrown.in2).isEqualTo(12345); 180 | assertThat(thrown.lo2).isEqualTo(123456); 181 | assertThat(thrown.fl2).isEqualTo(1.123f); 182 | assertThat(thrown.du2).isEqualTo(1.234567D); 183 | 184 | assertThat(thrown.str).isEqualTo("dummy-string"); 185 | assertThat(thrown.uri).isEqualTo(URI.create("dummy:uri")); 186 | } 187 | 188 | @Test void shouldBuildCustomTypeWithUnnamedExtensionsFieldSetNotConverting() { 189 | givenRegisteredType(CustomWithUnnamedExtensionsException.class); 190 | entity.add("bo1", true); 191 | entity.add("by1", 12); 192 | entity.add("sh1", 123); 193 | entity.add("in1", 1234); 194 | entity.add("lo1", 12345); 195 | entity.add("fl1", 1.12f); 196 | entity.add("du1", 1.23456d); 197 | 198 | entity.add("bo2", true); 199 | entity.add("by2", 123); 200 | entity.add("sh2", 1234); 201 | entity.add("in2", 12345); 202 | entity.add("lo2", 123456); 203 | entity.add("fl2", 1.123f); 204 | entity.add("du2", 1.234567d); 205 | 206 | CustomWithUnnamedExtensionsException thrown = catchThrowableOfType(this::trigger, CustomWithUnnamedExtensionsException.class); 207 | 208 | assertThat(thrown).hasMessage(null); 209 | 210 | assertThat(thrown.bo1).isEqualTo(true); 211 | assertThat(thrown.by1).isEqualTo((byte) 12); 212 | assertThat(thrown.sh1).isEqualTo((short) 123); 213 | assertThat(thrown.in1).isEqualTo(1234); 214 | assertThat(thrown.lo1).isEqualTo(12345); 215 | assertThat(thrown.fl1).isEqualTo(1.12f); 216 | assertThat(thrown.du1).isEqualTo(1.23456D); 217 | 218 | assertThat(thrown.bo2).isEqualTo(true); 219 | assertThat(thrown.by2).isEqualTo((byte) 123); 220 | assertThat(thrown.sh2).isEqualTo((short) 1234); 221 | assertThat(thrown.in2).isEqualTo(12345); 222 | assertThat(thrown.lo2).isEqualTo(123456); 223 | assertThat(thrown.fl2).isEqualTo(1.123f); 224 | assertThat(thrown.du2).isEqualTo(1.234567D); 225 | } 226 | 227 | 228 | public static class CustomWithNamedExtensionsException extends RuntimeException { 229 | @Extension("bo1") boolean bo1x; 230 | @Extension("by1") byte by1x; 231 | @Extension("sh1") short sh1x; 232 | @Extension("in1") int in1x; 233 | @Extension("lo1") long lo1x; 234 | @Extension("fl1") float fl1x; 235 | @Extension("du1") double du1x; 236 | 237 | @Extension("bo2") Boolean bo2x; 238 | @Extension("by2") Byte by2x; 239 | @Extension("sh2") Short sh2x; 240 | @Extension("in2") Integer in2x; 241 | @Extension("lo2") Long lo2x; 242 | @Extension("fl2") Float fl2x; 243 | @Extension("du2") Double du2x; 244 | 245 | @Extension("str") String strX; 246 | @Extension("uri") URI uriX; 247 | } 248 | 249 | @Test void shouldBuildCustomTypeWithNamedExtensionsFieldSet() { 250 | givenRegisteredType(CustomWithNamedExtensionsException.class); 251 | entity.add("bo1", "true"); 252 | entity.add("by1", "12"); 253 | entity.add("sh1", "123"); 254 | entity.add("in1", "1234"); 255 | entity.add("lo1", "12345"); 256 | entity.add("fl1", "1.12"); 257 | entity.add("du1", "1.23456"); 258 | 259 | entity.add("bo2", "true"); 260 | entity.add("by2", "123"); 261 | entity.add("sh2", "1234"); 262 | entity.add("in2", "12345"); 263 | entity.add("lo2", "123456"); 264 | entity.add("fl2", "1.123"); 265 | entity.add("du2", "1.234567"); 266 | 267 | entity.add("str", "dummy-string"); 268 | entity.add("uri", "dummy:uri"); 269 | 270 | CustomWithNamedExtensionsException thrown = catchThrowableOfType(this::trigger, CustomWithNamedExtensionsException.class); 271 | 272 | assertThat(thrown).hasMessage(null); 273 | 274 | assertThat(thrown.bo1x).isEqualTo(true); 275 | assertThat(thrown.by1x).isEqualTo((byte) 12); 276 | assertThat(thrown.sh1x).isEqualTo((short) 123); 277 | assertThat(thrown.in1x).isEqualTo(1234); 278 | assertThat(thrown.lo1x).isEqualTo(12345); 279 | assertThat(thrown.fl1x).isEqualTo(1.12f); 280 | assertThat(thrown.du1x).isEqualTo(1.23456D); 281 | 282 | assertThat(thrown.bo2x).isEqualTo(true); 283 | assertThat(thrown.by2x).isEqualTo((byte) 123); 284 | assertThat(thrown.sh2x).isEqualTo((short) 1234); 285 | assertThat(thrown.in2x).isEqualTo(12345); 286 | assertThat(thrown.lo2x).isEqualTo(123456); 287 | assertThat(thrown.fl2x).isEqualTo(1.123f); 288 | assertThat(thrown.du2x).isEqualTo(1.234567D); 289 | 290 | assertThat(thrown.strX).isEqualTo("dummy-string"); 291 | assertThat(thrown.uriX).isEqualTo(URI.create("dummy:uri")); 292 | } 293 | 294 | 295 | private void trigger() { 296 | String json = entity.build().toString(); 297 | InputStream inputStream = new ByteArrayInputStream(json.getBytes(UTF_8)); 298 | new ProblemDetailJsonToExceptionBuilder(inputStream).trigger(); 299 | } 300 | 301 | private void givenRegisteredType(Class type) { 302 | String typeUri = ProblemDetailJsonToExceptionBuilder.register(type); 303 | entity.add("type", typeUri); 304 | } 305 | 306 | private static final URI SOME_URI = URI.create("some:uri"); 307 | } 308 | -------------------------------------------------------------------------------- /ri-lib/src/test/java/test/StaticLoggerBinder.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import org.slf4j.ILoggerFactory; 4 | import org.slf4j.IMarkerFactory; 5 | import org.slf4j.spi.MDCAdapter; 6 | import org.slf4j.spi.SLF4JServiceProvider; 7 | 8 | public class StaticLoggerBinder implements SLF4JServiceProvider { 9 | private final MockLoggerFactory factory = new MockLoggerFactory(); 10 | 11 | @Override public String getRequestedApiVersion() {return "2.0";} 12 | 13 | @Override public ILoggerFactory getLoggerFactory() { return factory; } 14 | 15 | @Override public IMarkerFactory getMarkerFactory() {return null;} 16 | 17 | @Override public MDCAdapter getMDCAdapter() {return null;} 18 | 19 | @Override public void initialize() {} 20 | } 21 | -------------------------------------------------------------------------------- /ri-lib/src/test/java/test/ViolationDetailBehavior.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import com.github.t1.validation.ValidationFailedException; 4 | import lombok.AllArgsConstructor; 5 | import lombok.NoArgsConstructor; 6 | import lombok.Value; 7 | import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator; 8 | import org.junit.jupiter.api.Test; 9 | 10 | import jakarta.validation.Validation; 11 | import jakarta.validation.ValidatorFactory; 12 | import jakarta.validation.constraints.Future; 13 | import jakarta.validation.constraints.NotNull; 14 | import jakarta.validation.constraints.Past; 15 | import java.time.LocalDate; 16 | import java.util.HashSet; 17 | 18 | import static java.util.Arrays.asList; 19 | import static java.util.Collections.singleton; 20 | import static org.assertj.core.api.Assertions.catchThrowableOfType; 21 | import static org.assertj.core.api.BDDAssertions.entry; 22 | import static org.assertj.core.api.BDDAssertions.then; 23 | 24 | class ViolationDetailBehavior { 25 | @Value 26 | @NoArgsConstructor(force = true) @AllArgsConstructor 27 | public static class Person { 28 | @NotNull String firstName; 29 | @NotNull String lastName; 30 | // yes, these constraints are impossible to match, that's the point 31 | @Past @Future LocalDate born; 32 | } 33 | 34 | @Test void shouldMapSingleViolation() { 35 | Person person = new Person("Jane", null, null); 36 | 37 | ValidationFailedException throwable = catchThrowableOfType(() -> ValidationFailedException.validate(FACTORY, person), ValidationFailedException.class); 38 | 39 | then(throwable).hasMessage("1 violations failed on ViolationDetailBehavior.Person(firstName=Jane, lastName=null, born=null)"); 40 | then(throwable.violations()).containsExactly(entry("lastName", singleton("must not be null"))); 41 | } 42 | 43 | @Test void shouldMapTwoViolations() { 44 | Person person = new Person(null, null, null); 45 | 46 | ValidationFailedException throwable = catchThrowableOfType(() -> ValidationFailedException.validate(FACTORY, person), ValidationFailedException.class); 47 | 48 | then(throwable).hasMessage("2 violations failed on ViolationDetailBehavior.Person(firstName=null, lastName=null, born=null)"); 49 | then(throwable.violations()).containsOnly( 50 | entry("firstName", singleton("must not be null")), 51 | entry("lastName", singleton("must not be null"))); 52 | } 53 | 54 | @Test void shouldMapTwoViolationsOnTheSameField() { 55 | LocalDate now = LocalDate.now(); 56 | Person person = new Person("Jane", "Doe", now); 57 | 58 | ValidationFailedException throwable = catchThrowableOfType(() -> ValidationFailedException.validate(FACTORY, person), ValidationFailedException.class); 59 | 60 | then(throwable).hasMessage("2 violations failed on ViolationDetailBehavior.Person(firstName=Jane, lastName=Doe, born=" + now + ")"); 61 | then(throwable.violations()).containsExactly( 62 | entry("born", new HashSet<>(asList("must be a past date", "must be a future date")))); 63 | } 64 | 65 | private static final ValidatorFactory FACTORY = Validation.byDefaultProvider() 66 | .configure() 67 | .messageInterpolator(new ParameterMessageInterpolator()) 68 | .buildValidatorFactory(); 69 | } 70 | -------------------------------------------------------------------------------- /ri-lib/src/test/java/test/sub/SubException.java: -------------------------------------------------------------------------------- 1 | package test.sub; 2 | 3 | public class SubException extends Exception {} 4 | -------------------------------------------------------------------------------- /ri-lib/src/test/java/test/sub/SubExceptionWithCategory.java: -------------------------------------------------------------------------------- 1 | package test.sub; 2 | 3 | import com.github.t1.problemdetail.Logging; 4 | 5 | @Logging(to = "sub-cat") 6 | public class SubExceptionWithCategory extends Exception {} 7 | -------------------------------------------------------------------------------- /ri-lib/src/test/java/test/sub/SubExceptionWithLevel.java: -------------------------------------------------------------------------------- 1 | package test.sub; 2 | 3 | import com.github.t1.problemdetail.Logging; 4 | 5 | import static com.github.t1.problemdetail.LogLevel.INFO; 6 | 7 | @Logging(at = INFO) 8 | public class SubExceptionWithLevel extends Exception {} 9 | -------------------------------------------------------------------------------- /ri-lib/src/test/java/test/sub/package-info.java: -------------------------------------------------------------------------------- 1 | @Logging(at = WARNING, to = "warnings") 2 | package test.sub; 3 | 4 | import com.github.t1.problemdetail.Logging; 5 | 6 | import static com.github.t1.problemdetail.LogLevel.WARNING; 7 | -------------------------------------------------------------------------------- /ri-lib/src/test/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider: -------------------------------------------------------------------------------- 1 | test.StaticLoggerBinder 2 | -------------------------------------------------------------------------------- /ri/README.adoc: -------------------------------------------------------------------------------- 1 | = Problem Details Dummy Implementation for JAX-RS 2 | 3 | This is a JAX-RS dummy implementation for the Problem Details API. It's just a dummy, because: 4 | 5 | a. it's not built into the container, you'll have to package it into your applications, which means they are not a thin war any more. 6 | 7 | b. it doesn't handle `@Valid` annotated parameters and other exceptions that the container produces. 8 | 9 | c. the exceptions on the client side are wrapped in a ResponseProcessingException. 10 | 11 | It implements an `ExceptionMapper` for the server side and a `ClientResponseFilter` for the client. 12 | 13 | == Client 14 | 15 | `ProblemDetailJsonToExceptionBuilder.register(OutOfCreditException.class);` 16 | 17 | `...target().register(ProblemDetailHandler.class)` (this would be registered globally in a full implementation) 18 | -------------------------------------------------------------------------------- /ri/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | 6 | com.github.t1 7 | problem-details 8 | 3.0.2-SNAPSHOT 9 | ../pom.xml 10 | 11 | 12 | problem-details-ri 13 | A dummy implementation using an ExceptionMapper, i.e. an incomplete implementation 14 | 15 | 16 | 11 17 | 11 18 | utf-8 19 | 20 | 21 | 22 | verify 23 | ${project.artifactId} 24 | 25 | 26 | 27 | 28 | org.projectlombok 29 | lombok 30 | ${lombok.version} 31 | provided 32 | 33 | 34 | jakarta.ws.rs 35 | jakarta.ws.rs-api 36 | 4.0.0 37 | provided 38 | 39 | 40 | jakarta.json.bind 41 | jakarta.json.bind-api 42 | 3.0.1 43 | provided 44 | 45 | 46 | jakarta.json 47 | jakarta.json-api 48 | 2.1.3 49 | provided 50 | 51 | 52 | jakarta.xml.bind 53 | jakarta.xml.bind-api 54 | 4.0.2 55 | provided 56 | true 57 | 58 | 59 | jakarta.validation 60 | jakarta.validation-api 61 | 3.1.1 62 | provided 63 | 64 | 65 | org.slf4j 66 | slf4j-api 67 | ${slf4j.version} 68 | provided 69 | 70 | 71 | 72 | com.github.t1 73 | problem-details-api 74 | 3.0.2-SNAPSHOT 75 | 76 | 77 | com.github.t1 78 | problem-details-ri-lib 79 | 3.0.2-SNAPSHOT 80 | 81 | 82 | 83 | org.junit.jupiter 84 | junit-jupiter 85 | ${junit.version} 86 | test 87 | 88 | 89 | org.assertj 90 | assertj-core 91 | ${assertj.version} 92 | test 93 | 94 | 95 | org.mockito 96 | mockito-junit-jupiter 97 | ${mockito.version} 98 | test 99 | 100 | 101 | org.jboss.resteasy 102 | resteasy-core 103 | ${resteasy.version} 104 | test 105 | 106 | 107 | ch.qos.logback 108 | logback-classic 109 | ${logback.version} 110 | test 111 | 112 | 113 | org.glassfish.jaxb 114 | jaxb-runtime 115 | 4.0.5 116 | test 117 | 118 | 119 | org.eclipse 120 | yasson 121 | 3.0.4 122 | test 123 | 124 | 125 | jakarta.ejb 126 | jakarta.ejb-api 127 | 4.0.1 128 | test 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /ri/src/main/java/com/github/t1/problemdetailmapper/ProblemDetailExceptionMapper.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetailmapper; 2 | 3 | import com.github.t1.problemdetail.ri.lib.ProblemDetails; 4 | import lombok.extern.slf4j.Slf4j; 5 | 6 | import jakarta.ws.rs.WebApplicationException; 7 | import jakarta.ws.rs.core.Context; 8 | import jakarta.ws.rs.core.HttpHeaders; 9 | import jakarta.ws.rs.core.MediaType; 10 | import jakarta.ws.rs.core.Response; 11 | import jakarta.ws.rs.core.Response.StatusType; 12 | import jakarta.ws.rs.ext.ExceptionMapper; 13 | import jakarta.ws.rs.ext.Provider; 14 | import java.util.List; 15 | 16 | import static java.util.Arrays.asList; 17 | 18 | /** 19 | * Maps exceptions to a response with a body containing problem details 20 | * as specified in rfc-7807 21 | */ 22 | @Slf4j 23 | @Provider 24 | public class ProblemDetailExceptionMapper implements ExceptionMapper { 25 | private static final List UNWRAP = asList("jakarta.ejb.EJBException", "java.lang.IllegalStateException", 26 | "java.util.concurrent.CompletionException"); 27 | 28 | @Context 29 | HttpHeaders requestHeaders; 30 | 31 | @Override public Response toResponse(Exception exception) { 32 | Response response = (exception instanceof WebApplicationException) 33 | ? ((WebApplicationException) exception).getResponse() : null; 34 | if (response != null && response.hasEntity()) { 35 | return response; 36 | } 37 | 38 | while (UNWRAP.contains(exception.getClass().getName()) && exception.getCause() instanceof Exception /* implies not null */) { 39 | exception = (Exception) exception.getCause(); 40 | } 41 | 42 | ProblemDetails problemDetail = new ProblemDetails(exception) { 43 | @Override protected StatusType fallbackStatus() { 44 | return (response != null) ? response.getStatusInfo() : super.fallbackStatus(); 45 | } 46 | 47 | @Override protected boolean hasDefaultMessage() { 48 | return exception.getMessage() != null 49 | && exception.getMessage().equals("HTTP " + getStatus().getStatusCode() 50 | + " " + getStatus().getReasonPhrase()); 51 | } 52 | 53 | @Override protected String findMediaTypeSubtype() { 54 | for (MediaType accept : requestHeaders.getAcceptableMediaTypes()) { 55 | if ("application".equals(accept.getType())) { 56 | return accept.getSubtype(); 57 | } 58 | } 59 | return "json"; 60 | } 61 | }; 62 | 63 | return Response 64 | .status(problemDetail.getStatus()) 65 | .entity(problemDetail.getBody()) 66 | .header("Content-Type", problemDetail.getMediaType()) 67 | .build(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /ri/src/main/java/com/github/t1/problemdetailmapper/ProblemDetailHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetailmapper; 2 | 3 | import com.github.t1.problemdetail.ri.lib.ProblemDetailJsonToExceptionBuilder; 4 | import lombok.extern.slf4j.Slf4j; 5 | 6 | import jakarta.ws.rs.client.ClientRequestContext; 7 | import jakarta.ws.rs.client.ClientResponseContext; 8 | import jakarta.ws.rs.client.ClientResponseFilter; 9 | import jakarta.ws.rs.ext.Provider; 10 | 11 | import static com.github.t1.problemdetail.Constants.PROBLEM_DETAIL_JSON_TYPE; 12 | 13 | @Slf4j 14 | @Provider 15 | public class ProblemDetailHandler implements ClientResponseFilter { 16 | @Override public void filter(ClientRequestContext requestContext, ClientResponseContext responseContext) { 17 | // TODO XML 18 | if (PROBLEM_DETAIL_JSON_TYPE.isCompatible(responseContext.getMediaType())) { 19 | new ProblemDetailJsonToExceptionBuilder(responseContext.getEntityStream()) 20 | .trigger(); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ri/src/main/java/com/github/t1/problemdetailmapper/ProblemDetailHtmlMessageBodyWriter.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetailmapper; 2 | 3 | import jakarta.ws.rs.core.MediaType; 4 | import jakarta.ws.rs.core.MultivaluedMap; 5 | import jakarta.ws.rs.ext.MessageBodyWriter; 6 | import jakarta.ws.rs.ext.Provider; 7 | import java.io.OutputStream; 8 | import java.io.OutputStreamWriter; 9 | import java.io.PrintWriter; 10 | import java.lang.annotation.Annotation; 11 | import java.lang.reflect.Type; 12 | import java.util.Map; 13 | 14 | import static jakarta.ws.rs.core.MediaType.TEXT_HTML_TYPE; 15 | 16 | @Provider 17 | public class ProblemDetailHtmlMessageBodyWriter implements MessageBodyWriter> { 18 | @Override public boolean isWriteable(Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { 19 | return Map.class.isAssignableFrom(type) && TEXT_HTML_TYPE.isCompatible(mediaType); 20 | } 21 | 22 | @Override public void writeTo(Map problem, Class type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap httpHeaders, OutputStream entityStream) { 23 | PrintWriter out = new PrintWriter(new OutputStreamWriter(entityStream)); 24 | 25 | out.print("" + 26 | "\n" + 27 | "\n" + 28 | " \n" + 53 | " Problem Detail: " + problem.get("title") + "\n" + 54 | "\n" + 55 | "\n" + 56 | "

" + problem.get("title") + "

\n" + 57 | "\n" + 58 | "\n"); 59 | problem.forEach((key, value) -> printColumn(out, key, value)); 60 | out.print("" + 61 | "
\n" + 62 | "\n" + 63 | "\n"); 64 | out.flush(); 65 | } 66 | 67 | private void printColumn(PrintWriter out, String title, Object value) { 68 | if (value != null) { 69 | out.print("" + 70 | " \n" + 71 | " " + title + "\n" + 72 | " " + value + "\n" + 73 | " \n"); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /ri/src/main/java/com/github/t1/problemdetailmapper/ProblemDetailJsonMessageBodyReader.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetailmapper; 2 | 3 | import com.github.t1.problemdetail.ProblemDetail; 4 | import jakarta.json.bind.Jsonb; 5 | import jakarta.json.bind.JsonbBuilder; 6 | import jakarta.ws.rs.core.MediaType; 7 | import jakarta.ws.rs.core.MultivaluedMap; 8 | import jakarta.ws.rs.ext.MessageBodyReader; 9 | import jakarta.ws.rs.ext.Provider; 10 | 11 | import java.io.InputStream; 12 | import java.lang.annotation.Annotation; 13 | import java.lang.reflect.Type; 14 | 15 | import static com.github.t1.problemdetail.Constants.PROBLEM_DETAIL_JSON_TYPE; 16 | import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; 17 | 18 | @Provider 19 | public class ProblemDetailJsonMessageBodyReader implements MessageBodyReader { 20 | private static final Jsonb JSONB = JsonbBuilder.create(); 21 | 22 | @Override public boolean isReadable(Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { 23 | return type.equals(ProblemDetail.class) 24 | && (APPLICATION_JSON_TYPE.isCompatible(mediaType) || PROBLEM_DETAIL_JSON_TYPE.isCompatible(mediaType)); 25 | } 26 | 27 | @Override public ProblemDetail readFrom(Class type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap httpHeaders, InputStream entityStream) { 28 | return JSONB.fromJson(entityStream, type); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ri/src/main/java/com/github/t1/problemdetailmapper/ProblemDetailXmlMessageBodyReader.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetailmapper; 2 | 3 | import com.github.t1.problemdetail.ProblemDetail; 4 | 5 | import jakarta.ws.rs.core.MediaType; 6 | import jakarta.ws.rs.core.MultivaluedMap; 7 | import jakarta.ws.rs.ext.MessageBodyReader; 8 | import jakarta.ws.rs.ext.Provider; 9 | import jakarta.xml.bind.JAXB; 10 | import java.io.InputStream; 11 | import java.lang.annotation.Annotation; 12 | import java.lang.reflect.Type; 13 | 14 | import static com.github.t1.problemdetail.Constants.PROBLEM_DETAIL_XML_TYPE; 15 | import static jakarta.ws.rs.core.MediaType.APPLICATION_XML_TYPE; 16 | 17 | @Provider 18 | public class ProblemDetailXmlMessageBodyReader implements MessageBodyReader { 19 | @Override public boolean isReadable(Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { 20 | return ProblemDetail.class.isAssignableFrom(type) 21 | && (APPLICATION_XML_TYPE.isCompatible(mediaType) || PROBLEM_DETAIL_XML_TYPE.isCompatible(mediaType)); 22 | } 23 | 24 | @Override public ProblemDetail readFrom(Class type, Type genericType, Annotation[] annotations, MediaType mediaType, 25 | MultivaluedMap httpHeaders, InputStream entityStream) { 26 | return JAXB.unmarshal(entityStream, type); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ri/src/main/java/com/github/t1/problemdetailmapper/ProblemDetailXmlMessageBodyWriter.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetailmapper; 2 | 3 | import com.github.t1.problemdetail.ri.lib.ProblemXml; 4 | 5 | import jakarta.ws.rs.core.MediaType; 6 | import jakarta.ws.rs.core.MultivaluedMap; 7 | import jakarta.ws.rs.ext.MessageBodyWriter; 8 | import jakarta.ws.rs.ext.Provider; 9 | import java.io.IOException; 10 | import java.io.OutputStream; 11 | import java.lang.annotation.Annotation; 12 | import java.lang.reflect.Type; 13 | import java.util.Map; 14 | 15 | import static com.github.t1.problemdetail.Constants.PROBLEM_DETAIL_XML_TYPE; 16 | 17 | @Provider 18 | public class ProblemDetailXmlMessageBodyWriter implements MessageBodyWriter> { 19 | @Override public boolean isWriteable(Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { 20 | return PROBLEM_DETAIL_XML_TYPE.isCompatible(mediaType); 21 | } 22 | 23 | @Override public void writeTo(Map map, Class type, Type genericType, Annotation[] annotations, MediaType mediaType, 24 | MultivaluedMap httpHeaders, OutputStream entityStream) throws IOException { 25 | new ProblemXml(map).writeTo(entityStream); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ri/src/test/java/com/github/t1/problemdetailmapper/ProblemDetailExceptionMapperBehavior.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetailmapper; 2 | 3 | import com.github.t1.problemdetail.Detail; 4 | import com.github.t1.problemdetail.Extension; 5 | import com.github.t1.problemdetail.Instance; 6 | import com.github.t1.problemdetail.Status; 7 | import com.github.t1.problemdetail.Title; 8 | import com.github.t1.problemdetail.Type; 9 | import org.jboss.resteasy.specimpl.ResteasyHttpHeaders; 10 | import org.junit.jupiter.api.BeforeEach; 11 | import org.junit.jupiter.api.Test; 12 | 13 | import jakarta.ejb.EJBException; 14 | import jakarta.ws.rs.ForbiddenException; 15 | import jakarta.ws.rs.InternalServerErrorException; 16 | import jakarta.ws.rs.core.MultivaluedHashMap; 17 | import jakarta.ws.rs.core.Response; 18 | import java.net.URI; 19 | import java.util.Map; 20 | import java.util.concurrent.CompletionException; 21 | 22 | import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; 23 | import static jakarta.ws.rs.core.Response.Status.FORBIDDEN; 24 | import static jakarta.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; 25 | import static org.assertj.core.api.Assertions.entry; 26 | import static org.assertj.core.api.BDDAssertions.then; 27 | 28 | @SuppressWarnings("unused") 29 | class ProblemDetailExceptionMapperBehavior { 30 | 31 | private final ProblemDetailExceptionMapper mapper = new ProblemDetailExceptionMapper(); 32 | 33 | @BeforeEach void setUp() { 34 | mapper.requestHeaders = new ResteasyHttpHeaders(new MultivaluedHashMap<>()); 35 | } 36 | 37 | @Test void shouldMapStandardRuntimeException() { 38 | Response problemDetail = mapper.toResponse(new NullPointerException("some message")); 39 | 40 | then(problemDetail.getStatusInfo()).isEqualTo(INTERNAL_SERVER_ERROR); 41 | then(problemDetailAsMap(problemDetail)) 42 | .contains( 43 | entry("type", URI.create("urn:problem-type:null-pointer")), 44 | entry("title", "Null Pointer"), 45 | entry("status", 500), 46 | entry("detail", "some message")) 47 | .hasSize(5) // the URN is random 48 | .containsKey("instance"); 49 | } 50 | 51 | @Test void shouldMapStandardIllegalArgumentException() { 52 | Response problemDetail = mapper.toResponse(new IllegalArgumentException("some message")); 53 | 54 | then(problemDetail.getStatusInfo()).isEqualTo(BAD_REQUEST); 55 | then(problemDetailAsMap(problemDetail)) 56 | .contains( 57 | entry("type", URI.create("urn:problem-type:illegal-argument")), 58 | entry("title", "Illegal Argument"), 59 | entry("status", 400), 60 | entry("detail", "some message")) 61 | .hasSize(5) // the URN is random 62 | .containsKey("instance"); 63 | } 64 | 65 | @Test void shouldMapWebApplicationException() { 66 | Response problemDetail = mapper.toResponse(new ForbiddenException("some message")); 67 | 68 | then(problemDetail.getStatusInfo()).isEqualTo(FORBIDDEN); 69 | then(problemDetailAsMap(problemDetail)) 70 | .contains( 71 | entry("type", URI.create("urn:problem-type:forbidden")), 72 | entry("title", "Forbidden"), 73 | entry("status", 403), 74 | entry("detail", "some message")) 75 | .hasSize(5) // the URN is random 76 | .containsKey("instance"); 77 | } 78 | 79 | @Test void shouldUnwrapEJBException() { 80 | Response problemDetail = mapper.toResponse(new EJBException(new RuntimeException("some message"))); 81 | 82 | then(problemDetail.getStatusInfo()).isEqualTo(INTERNAL_SERVER_ERROR); 83 | then(problemDetailAsMap(problemDetail)) 84 | .contains( 85 | entry("type", URI.create("urn:problem-type:runtime")), 86 | entry("title", "Runtime"), 87 | entry("status", 500), 88 | entry("detail", "some message")) 89 | .hasSize(5) // the URN is random 90 | .containsKey("instance"); 91 | } 92 | 93 | @Test void shouldUnwrapIllegalStateException() { 94 | Response problemDetail = mapper.toResponse(new IllegalStateException(new RuntimeException("some message"))); 95 | 96 | then(problemDetail.getStatusInfo()).isEqualTo(INTERNAL_SERVER_ERROR); 97 | then(problemDetailAsMap(problemDetail)) 98 | .contains( 99 | entry("type", URI.create("urn:problem-type:runtime")), 100 | entry("title", "Runtime"), 101 | entry("status", 500), 102 | entry("detail", "some message")) 103 | .hasSize(5) // the URN is random 104 | .containsKey("instance"); 105 | } 106 | 107 | @Test void shouldUnwrapCompletionException() { 108 | Response problemDetail = mapper.toResponse(new CompletionException(new RuntimeException("some message"))); 109 | 110 | then(problemDetail.getStatusInfo()).isEqualTo(INTERNAL_SERVER_ERROR); 111 | then(problemDetailAsMap(problemDetail)) 112 | .contains( 113 | entry("type", URI.create("urn:problem-type:runtime")), 114 | entry("title", "Runtime"), 115 | entry("status", 500), 116 | entry("detail", "some message")) 117 | .hasSize(5) // the URN is random 118 | .containsKey("instance"); 119 | } 120 | 121 | @Test void shouldMapWebApplicationExceptionWithResponse() { 122 | Response in = Response.serverError().entity("some entity").build(); 123 | 124 | try (Response out = mapper.toResponse(new InternalServerErrorException(in))) { 125 | 126 | then(out.getStatusInfo()).isEqualTo(INTERNAL_SERVER_ERROR); 127 | then(out.getEntity()).isSameAs(in.getEntity()); 128 | } 129 | } 130 | 131 | @Test void shouldMapCustomExceptionWithFields() { 132 | @Type("some-type") 133 | @Title("some-title") 134 | @Status(FORBIDDEN) 135 | class SomeException extends RuntimeException { 136 | @Extension private final int f1 = 123; 137 | @SuppressWarnings("unused") private final int unmapped = 456; 138 | @Instance private final URI instance = URI.create("https://some.domain/some/path"); 139 | @SuppressWarnings("FieldMayBeFinal") 140 | @Detail String detail = "some-detail"; 141 | } 142 | 143 | Response problemDetail = mapper.toResponse(new SomeException()); 144 | 145 | then(problemDetail.getStatusInfo()).isEqualTo(FORBIDDEN); 146 | then(problemDetailAsMap(problemDetail)).containsExactly( 147 | entry("type", URI.create("some-type")), 148 | entry("title", "some-title"), 149 | entry("status", 403), 150 | entry("detail", "some-detail"), 151 | entry("instance", URI.create("https://some.domain/some/path")), 152 | entry("f1", 123) 153 | ); 154 | } 155 | 156 | @Test void shouldMapCustomExceptionWithMethods() { 157 | @Type("some-type") 158 | @Title("some-title") 159 | @Status(FORBIDDEN) 160 | class SomeException extends RuntimeException { 161 | @Extension private int f1() { return 123; } 162 | 163 | @Instance private URI instance() { return URI.create("https://some.domain/some/path"); } 164 | 165 | @Detail String detail() { return "some-detail"; } 166 | } 167 | 168 | Response problemDetail = mapper.toResponse(new SomeException()); 169 | 170 | then(problemDetail.getStatusInfo()).isEqualTo(FORBIDDEN); 171 | then(problemDetailAsMap(problemDetail)).containsExactly( 172 | entry("type", URI.create("some-type")), 173 | entry("title", "some-title"), 174 | entry("status", 403), 175 | entry("detail", "some-detail"), 176 | entry("instance", URI.create("https://some.domain/some/path")), 177 | entry("f1", 123) 178 | ); 179 | } 180 | 181 | @Test void shouldMapCustomExceptionWithFailingMethods() { 182 | @Type("some-type") 183 | @Title("some-title") 184 | @Status(FORBIDDEN) 185 | class SomeException extends RuntimeException { 186 | @Extension private int f1() { throw new RuntimeException("no f1"); } 187 | 188 | @Instance private URI instance() { throw new IllegalArgumentException("no instance"); } 189 | 190 | @Detail String detail() { throw new NullPointerException(); } 191 | } 192 | 193 | Response problemDetail = mapper.toResponse(new SomeException()); 194 | 195 | then(problemDetail.getStatusInfo()).isEqualTo(FORBIDDEN); 196 | then(problemDetailAsMap(problemDetail)).containsExactly( 197 | entry("type", URI.create("some-type")), 198 | entry("title", "some-title"), 199 | entry("status", 403), 200 | entry("detail", "could not invoke SomeException.detail: java.lang.NullPointerException"), 201 | entry("instance", URI.create("urn:invalid-uri-syntax?" + 202 | "source=could+not+invoke+SomeException.instance%3A+java.lang.IllegalArgumentException%3A+no+instance&" + 203 | "exception=java.net.URISyntaxException%3A+Illegal+character+in+scheme+name+at+index+5%3A+could+not+invoke+SomeException.instance%3A+java.lang.IllegalArgumentException%3A+no+instance")), 204 | entry("f1", "could not invoke SomeException.f1: java.lang.RuntimeException: no f1") 205 | ); 206 | } 207 | 208 | @Test void shouldMapCustomExceptionWithInvalidInstanceMethod() { 209 | class SomeException extends RuntimeException { 210 | @Instance private String instance() { return "spaces are invalid"; } 211 | } 212 | 213 | Response problemDetail = mapper.toResponse(new SomeException()); 214 | 215 | then(problemDetail.getStatusInfo()).isEqualTo(INTERNAL_SERVER_ERROR); 216 | then(problemDetailAsMap(problemDetail)).containsExactly( 217 | entry("type", URI.create("urn:problem-type:some")), 218 | entry("title", "Some"), 219 | entry("status", 500), 220 | entry("instance", URI.create("urn:invalid-uri-syntax?" + 221 | "source=spaces+are+invalid&exception=java.net.URISyntaxException%3A+" + 222 | "Illegal+character+in+path+at+index+6%3A+spaces+are+invalid")) 223 | ); 224 | } 225 | 226 | @SuppressWarnings("unchecked") private Map problemDetailAsMap(Response problemDetail) { 227 | return (Map) problemDetail.getEntity(); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /ri/src/test/java/test/ProblemDetailDeserializationBehavior.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import com.github.t1.problemdetail.ProblemDetail; 4 | import com.github.t1.problemdetailmapper.ProblemDetailJsonMessageBodyReader; 5 | import com.github.t1.problemdetailmapper.ProblemDetailXmlMessageBodyReader; 6 | import lombok.SneakyThrows; 7 | import org.junit.jupiter.api.Test; 8 | 9 | import jakarta.ws.rs.ext.MessageBodyReader; 10 | import java.io.ByteArrayInputStream; 11 | import java.io.IOException; 12 | import java.net.URI; 13 | 14 | import static java.nio.charset.StandardCharsets.UTF_8; 15 | import static org.assertj.core.api.BDDAssertions.then; 16 | 17 | public class ProblemDetailDeserializationBehavior { 18 | @Test void shouldDeserializeJson() { 19 | ProblemDetailJsonMessageBodyReader reader = new ProblemDetailJsonMessageBodyReader(); 20 | 21 | ProblemDetail problemDetail = read(reader, "{" + 22 | " \"type\": \"urn:problem-type:java.lang.NullPointerException\",\n" + 23 | " \"title\": \"Null Pointer\",\n" + 24 | " \"status\": 500,\n" + 25 | " \"detail\": \"some message\",\n" + 26 | " \"instance\": \"urn:uuid:d294b32b-9dda-4292-b51f-35f65b4bf64d\"\n" + 27 | "}"); 28 | 29 | thenIsExpected(problemDetail); 30 | } 31 | 32 | @Test void shouldDeserializeXml() { 33 | ProblemDetailXmlMessageBodyReader reader = new ProblemDetailXmlMessageBodyReader(); 34 | 35 | ProblemDetail problemDetail = read(reader, "" + 36 | "\n" + 37 | "\n" + 38 | " urn:problem-type:java.lang.NullPointerException\n" + 39 | " Null Pointer\n" + 40 | " 500\n" + 41 | " some message\n" + 42 | " urn:uuid:d294b32b-9dda-4292-b51f-35f65b4bf64d\n" + 43 | ""); 44 | 45 | thenIsExpected(problemDetail); 46 | } 47 | 48 | @SneakyThrows(IOException.class) 49 | private ProblemDetail read(MessageBodyReader reader, String text) { 50 | return reader.readFrom(ProblemDetail.class, null, null, null, null, new ByteArrayInputStream(text.getBytes(UTF_8))); 51 | } 52 | 53 | private void thenIsExpected(ProblemDetail problemDetail) { 54 | then(problemDetail.getType()).isEqualTo(URI.create("urn:problem-type:java.lang.NullPointerException")); 55 | then(problemDetail.getTitle()).isEqualTo("Null Pointer"); 56 | then(problemDetail.getStatus()).isEqualTo(500); 57 | then(problemDetail.getDetail()).isEqualTo("some message"); 58 | then(problemDetail.getInstance()).isEqualTo(URI.create("urn:uuid:d294b32b-9dda-4292-b51f-35f65b4bf64d")); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ri/src/test/java/test/ProblemDetailSerializationBehavior.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import com.github.t1.problemdetailmapper.ProblemDetailHtmlMessageBodyWriter; 4 | import com.github.t1.problemdetailmapper.ProblemDetailXmlMessageBodyWriter; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import jakarta.json.bind.Jsonb; 8 | import jakarta.json.bind.JsonbBuilder; 9 | import jakarta.json.bind.JsonbConfig; 10 | 11 | import java.io.ByteArrayOutputStream; 12 | import java.io.IOException; 13 | import java.net.URI; 14 | import java.util.LinkedHashMap; 15 | import java.util.Map; 16 | 17 | import static java.util.Arrays.asList; 18 | import static java.util.Collections.emptyList; 19 | import static java.util.Collections.singletonList; 20 | import static org.assertj.core.api.BDDAssertions.then; 21 | 22 | class ProblemDetailSerializationBehavior { 23 | public static final Map SOME_PROBLEM_DETAIL = new LinkedHashMap<>(); 24 | 25 | static { 26 | SOME_PROBLEM_DETAIL.put("type", URI.create("urn:some-type")); 27 | SOME_PROBLEM_DETAIL.put("title", "some-title"); 28 | SOME_PROBLEM_DETAIL.put("status", 400); 29 | SOME_PROBLEM_DETAIL.put("detail", "some-detail"); 30 | SOME_PROBLEM_DETAIL.put("instance", URI.create("urn:some-instance")); 31 | SOME_PROBLEM_DETAIL.put("k1", "v1"); 32 | SOME_PROBLEM_DETAIL.put("k2", asList(URI.create("urn:1"), null, URI.create("urn:2"))); 33 | SOME_PROBLEM_DETAIL.put("k3", v3()); 34 | SOME_PROBLEM_DETAIL.put("k4", v4()); 35 | SOME_PROBLEM_DETAIL.put("k5", null); 36 | } 37 | 38 | private static Map v3() { 39 | Map v3 = new LinkedHashMap<>(); 40 | v3.put("k3.1", "v3.1"); 41 | v3.put("k3.2", "v3.2"); 42 | return v3; 43 | } 44 | 45 | private static Map v4() { 46 | Map v4 = new LinkedHashMap<>(); 47 | v4.put("k4.1", asList("v4.1.1", "v4.1.2", "v4.1.3")); 48 | v4.put("k4.2", singletonList("v4.2.1")); 49 | v4.put("k4.3", asList("v4.3.1", "v4.3.2")); 50 | v4.put("k4.4", emptyList()); 51 | return v4; 52 | } 53 | 54 | @Test void shouldSerializeAsJson() { 55 | String out = JSONB.toJson(SOME_PROBLEM_DETAIL); 56 | 57 | then(out).isEqualTo( 58 | "{\n" + 59 | " \"type\": \"urn:some-type\",\n" + 60 | " \"title\": \"some-title\",\n" + 61 | " \"status\": 400,\n" + 62 | " \"detail\": \"some-detail\",\n" + 63 | " \"instance\": \"urn:some-instance\",\n" + 64 | " \"k1\": \"v1\",\n" + 65 | " \"k2\": [\n" + 66 | " \"urn:1\",\n" + 67 | " null,\n" + 68 | " \"urn:2\"\n" + 69 | " ],\n" + 70 | " \"k3\": {\n" + 71 | " \"k3.1\": \"v3.1\",\n" + 72 | " \"k3.2\": \"v3.2\"\n" + 73 | " },\n" + 74 | " \"k4\": {\n" + 75 | " \"k4.1\": [\n" + 76 | " \"v4.1.1\",\n" + 77 | " \"v4.1.2\",\n" + 78 | " \"v4.1.3\"\n" + 79 | " ],\n" + 80 | " \"k4.2\": [\n" + 81 | " \"v4.2.1\"\n" + 82 | " ],\n" + 83 | " \"k4.3\": [\n" + 84 | " \"v4.3.1\",\n" + 85 | " \"v4.3.2\"\n" + 86 | " ],\n" + 87 | " \"k4.4\": [\n" + 88 | " ]\n" + 89 | " },\n" + 90 | " \"k5\": null\n" + 91 | "}"); 92 | } 93 | 94 | @Test void shouldSerializeAsXml() throws IOException { 95 | ByteArrayOutputStream out = new ByteArrayOutputStream(); 96 | 97 | // JAXB doesn't know out-of-the-box how to marshal a map; our writer does: 98 | new ProblemDetailXmlMessageBodyWriter().writeTo(SOME_PROBLEM_DETAIL, null, null, null, null, null, out); 99 | 100 | then(out.toString()).isEqualTo( 101 | "\n" + 102 | "\n" + 103 | " urn:some-type\n" + 104 | " some-title\n" + 105 | " 400\n" + 106 | " some-detail\n" + 107 | " urn:some-instance\n" + 108 | " v1\n" + 109 | " \n" + 110 | " urn:1\n" + 111 | " \n" + 112 | " urn:2\n" + 113 | " \n" + 114 | " \n" + 115 | " v3.1\n" + 116 | " v3.2\n" + 117 | " \n" + 118 | " \n" + 119 | " \n" + 120 | " v4.1.1\n" + 121 | " v4.1.2\n" + 122 | " v4.1.3\n" + 123 | " \n" + 124 | " \n" + 125 | " v4.2.1\n" + 126 | " \n" + 127 | " \n" + 128 | " v4.3.1\n" + 129 | " v4.3.2\n" + 130 | " \n" + 131 | " \n" + 132 | " \n" + 133 | " \n" + 134 | "\n"); 135 | } 136 | 137 | @Test void shouldSerializeAsHtml() { 138 | ByteArrayOutputStream out = new ByteArrayOutputStream(); 139 | 140 | new ProblemDetailHtmlMessageBodyWriter().writeTo(SOME_PROBLEM_DETAIL, null, null, null, null, null, out); 141 | 142 | then(out.toString()).isEqualTo("\n" + 143 | "\n" + 144 | " \n" + 169 | " Problem Detail: some-title\n" + 170 | "\n" + 171 | "\n" + 172 | "

some-title

\n" + 173 | "\n" + 174 | "\n" + 175 | " \n" + 176 | " \n" + 177 | " \n" + 178 | " \n" + 179 | " \n" + 180 | " \n" + 181 | " \n" + 182 | " \n" + 183 | " \n" + 184 | " \n" + 185 | " \n" + 186 | " \n" + 187 | " \n" + 188 | " \n" + 189 | " \n" + 190 | " \n" + 191 | " \n" + 192 | " \n" + 193 | " \n" + 194 | " \n" + 195 | " \n" + 196 | " \n" + 197 | " \n" + 198 | " \n" + 199 | " \n" + 200 | " \n" + 201 | " \n" + 202 | " \n" + 203 | " \n" + 204 | " \n" + 205 | " \n" + 206 | " \n" + 207 | " \n" + 208 | " \n" + 209 | " \n" + 210 | " \n" + 211 | "
typeurn:some-type
titlesome-title
status400
detailsome-detail
instanceurn:some-instance
k1v1
k2[urn:1, null, urn:2]
k3{k3.1=v3.1, k3.2=v3.2}
k4{k4.1=[v4.1.1, v4.1.2, v4.1.3], k4.2=[v4.2.1], k4.3=[v4.3.1, v4.3.2], k4.4=[]}
\n" + 212 | "\n" + 213 | "\n"); 214 | } 215 | 216 | private static final Jsonb JSONB = JsonbBuilder.create(new JsonbConfig().withFormatting(true)); 217 | } 218 | -------------------------------------------------------------------------------- /test/README.adoc: -------------------------------------------------------------------------------- 1 | = Problem Details JAX-RS Test Suite 2 | 3 | This is the test suite for JAX-RS, i.e. a JEE app exposing various problem detail errors and a set of integration tests using the client side api to check it. 4 | 5 | Note that the tests can check various Testcontainers or an already running service, which can also be the Spring Boot test app. 6 | -------------------------------------------------------------------------------- /test/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | 6 | com.github.t1 7 | problem-details 8 | 3.0.2-SNAPSHOT 9 | ../pom.xml 10 | 11 | 12 | problem-details-test 13 | war 14 | Problem Detail test suite for JAX-RS 15 | 16 | 17 | 11 18 | 11 19 | 20 | false 21 | 22 | 23 | 24 | verify 25 | ${project.artifactId} 26 | 27 | 28 | org.apache.maven.plugins 29 | maven-war-plugin 30 | 3.3.2 31 | 32 | 33 | 34 | 35 | 36 | 37 | org.projectlombok 38 | lombok 39 | ${lombok.version} 40 | provided 41 | 42 | 43 | jakarta.platform 44 | jakarta.jakartaee-api 45 | 10.0.0 46 | provided 47 | 48 | 49 | org.eclipse.microprofile 50 | microprofile 51 | pom 52 | ${microprofile.version} 53 | provided 54 | 55 | 56 | org.slf4j 57 | slf4j-api 58 | ${slf4j.version} 59 | provided 60 | 61 | 62 | jakarta.xml.bind 63 | jakarta.xml.bind-api 64 | 4.0.0 65 | provided 66 | true 67 | 68 | 69 | 70 | com.github.t1 71 | problem-details-api 72 | 3.0.2-SNAPSHOT 73 | 74 | 75 | com.github.t1 76 | problem-details-ri 77 | 3.0.2-SNAPSHOT 78 | 79 | 80 | 81 | org.junit.jupiter 82 | junit-jupiter 83 | ${junit.version} 84 | test 85 | 86 | 87 | org.assertj 88 | assertj-core 89 | ${assertj.version} 90 | test 91 | 92 | 93 | org.jboss.resteasy 94 | resteasy-core 95 | 6.2.4.Final 96 | test 97 | 98 | 99 | org.jboss.resteasy 100 | resteasy-client 101 | 6.2.4.Final 102 | test 103 | 104 | 105 | org.glassfish.jaxb 106 | jaxb-runtime 107 | 4.0.3 108 | test 109 | 110 | 111 | org.jboss.resteasy 112 | resteasy-json-binding-provider 113 | ${resteasy.version} 114 | test 115 | 116 | 117 | org.eclipse 118 | yasson 119 | 3.0.3 120 | test 121 | 122 | 123 | org.hibernate.validator 124 | hibernate-validator 125 | ${hibernate.version} 126 | test 127 | 128 | 129 | 130 | 131 | ch.qos.logback 132 | logback-classic 133 | ${logback.version} 134 | test 135 | 136 | 137 | com.github.t1 138 | jee-testcontainers 139 | ${jee-testcontainers.version} 140 | test 141 | 142 | 143 | 144 | 145 | 146 | 147 | with-slf4j 148 | 149 | 150 | org.slf4j 151 | slf4j-api 152 | ${slf4j.version} 153 | 154 | 155 | 156 | org.slf4j 157 | slf4j-jdk14 158 | ${slf4j.version} 159 | 160 | 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /test/src/main/java/com/github/t1/problemdetaildemoapp/Config.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetaildemoapp; 2 | 3 | import jakarta.ws.rs.ApplicationPath; 4 | import jakarta.ws.rs.core.Application; 5 | 6 | @ApplicationPath("/") 7 | public class Config extends Application {} 8 | -------------------------------------------------------------------------------- /test/src/main/java/com/github/t1/problemdetaildemoapp/CustomExceptionBoundary.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetaildemoapp; 2 | 3 | import com.github.t1.problemdetail.Detail; 4 | import com.github.t1.problemdetail.Extension; 5 | import com.github.t1.problemdetail.Instance; 6 | import com.github.t1.problemdetail.Status; 7 | import com.github.t1.problemdetail.Title; 8 | import com.github.t1.problemdetail.Type; 9 | 10 | import jakarta.ws.rs.POST; 11 | import jakarta.ws.rs.Path; 12 | import java.net.URI; 13 | 14 | import static jakarta.ws.rs.core.Response.Status.FORBIDDEN; 15 | 16 | @Path("/custom") 17 | public class CustomExceptionBoundary { 18 | @Path("/runtime-exception") 19 | @POST public void customRuntimeException() { 20 | class CustomException extends RuntimeException {} 21 | throw new CustomException(); 22 | } 23 | 24 | @Path("/illegal-argument-exception") 25 | @POST public void customIllegalArgumentException() { 26 | class CustomException extends IllegalArgumentException {} 27 | throw new CustomException(); 28 | } 29 | 30 | @Path("/explicit-type") 31 | @POST public void customTypeException() { 32 | @Type("https://error-codes.org/out-of-memory") 33 | class SomeException extends RuntimeException {} 34 | throw new SomeException(); 35 | } 36 | 37 | @Path("/explicit-title") 38 | @POST public void customTitleException() { 39 | @Title("Some Title") 40 | class SomeException extends RuntimeException {} 41 | throw new SomeException(); 42 | } 43 | 44 | @Path("/explicit-status") 45 | @POST public void customExplicitStatus() { 46 | @Status(FORBIDDEN) 47 | class SomethingForbiddenException extends RuntimeException {} 48 | throw new SomethingForbiddenException(); 49 | } 50 | 51 | @Path("/public-detail-method") 52 | @POST public void publicDetailMethod() { 53 | class SomeMessageException extends RuntimeException { 54 | @Detail public String detail() { return "some detail"; } 55 | } 56 | throw new SomeMessageException(); 57 | } 58 | 59 | @Path("/private-detail-method") 60 | @POST public void privateDetailMethod() { 61 | class SomeMessageException extends RuntimeException { 62 | @Detail private String detail() { return "some detail"; } 63 | } 64 | throw new SomeMessageException(); 65 | } 66 | 67 | @Path("/failing-detail-method") 68 | @POST public void failingDetailMethod() { 69 | class FailingDetailException extends RuntimeException { 70 | public FailingDetailException() { super("some message"); } 71 | 72 | @Detail public String failingDetail() { 73 | throw new RuntimeException("inner"); 74 | } 75 | } 76 | throw new FailingDetailException(); 77 | } 78 | 79 | @Path("/public-detail-field") 80 | @POST public void publicDetailField() { 81 | class SomeMessageException extends RuntimeException { 82 | @Detail public String detail = "some detail"; 83 | 84 | public SomeMessageException(String message) { 85 | super(message); 86 | } 87 | } 88 | throw new SomeMessageException("overwritten"); 89 | } 90 | 91 | @Path("/private-detail-field") 92 | @POST public void privateDetailField() { 93 | class SomeMessageException extends RuntimeException { 94 | @Detail private final String detail = "some detail"; 95 | 96 | public SomeMessageException(String message) { 97 | super(message); 98 | } 99 | } 100 | throw new SomeMessageException("overwritten"); 101 | } 102 | 103 | @Path("/multi-detail-fields") 104 | @POST public void multiDetailField() { 105 | class SomeMessageException extends RuntimeException { 106 | @Detail public final String detail1 = "detail a"; 107 | @Detail public final String detail2 = "detail b"; 108 | } 109 | throw new SomeMessageException(); 110 | } 111 | 112 | @Path("/mixed-details") 113 | @POST public void multiDetails() { 114 | class SomeMessageException extends RuntimeException { 115 | @Detail public String detail0() { return "detail a"; } 116 | 117 | @Detail public final String detail1 = "detail b"; 118 | @Detail public final String detail2 = "detail c"; 119 | } 120 | throw new SomeMessageException(); 121 | } 122 | 123 | @Path("/detail-method-arg") 124 | @POST public void detailMethodArg() { 125 | class SomeMessageException extends RuntimeException { 126 | @Detail public String detail(String foo) { return "some " + foo; } 127 | } 128 | throw new SomeMessageException(); 129 | } 130 | 131 | @Path("/explicit-uri-instance") 132 | @POST public void customInstanceException() { 133 | class SomeException extends RuntimeException { 134 | @Instance URI instance() { return URI.create("foobar"); } 135 | } 136 | throw new SomeException(); 137 | } 138 | 139 | @Path("/extension-method") 140 | @POST public void customExtensionMethod() { 141 | class SomeException extends RuntimeException { 142 | @Extension public String ex() { return "some extension"; } 143 | } 144 | throw new SomeException(); 145 | } 146 | 147 | @Path("/extension-method-with-name") 148 | @POST public void customExtensionMethodWithExplicitName() { 149 | class SomeMessageException extends RuntimeException { 150 | @Extension("foo") public String ex() { return "some extension"; } 151 | } 152 | throw new SomeMessageException(); 153 | } 154 | 155 | @Path("/extension-field") 156 | @POST public void customExtensionField() { 157 | class SomeMessageException extends RuntimeException { 158 | @Extension public final String ex = "some extension"; 159 | } 160 | throw new SomeMessageException(); 161 | } 162 | 163 | @Path("/extension-field-with-name") 164 | @POST public void customExtensionFieldWithName() { 165 | class SomeMessageException extends RuntimeException { 166 | @Extension("foo") public final String ex = "some extension"; 167 | } 168 | throw new SomeMessageException(); 169 | } 170 | 171 | @Path("/multi-extension") 172 | @POST public void multiExtension() { 173 | class SomeMessageException extends RuntimeException { 174 | @Extension String m1() { return "method 1"; } 175 | 176 | @Extension("m2") String method() { return "method 2"; } 177 | 178 | @Extension final String f1 = "field 1"; 179 | @Extension("f2") final String field = "field 2"; 180 | } 181 | throw new SomeMessageException(); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /test/src/main/java/com/github/t1/problemdetaildemoapp/DemoBoundary.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetaildemoapp; 2 | 3 | import com.github.t1.problemdetail.Detail; 4 | import com.github.t1.problemdetail.Extension; 5 | import com.github.t1.problemdetail.Status; 6 | import lombok.AllArgsConstructor; 7 | import lombok.NoArgsConstructor; 8 | import lombok.extern.slf4j.Slf4j; 9 | 10 | import jakarta.json.Json; 11 | import jakarta.json.JsonObject; 12 | import jakarta.validation.constraints.NotNull; 13 | import jakarta.ws.rs.DefaultValue; 14 | import jakarta.ws.rs.FormParam; 15 | import jakarta.ws.rs.POST; 16 | import jakarta.ws.rs.Path; 17 | import jakarta.ws.rs.Produces; 18 | import java.net.URI; 19 | import java.time.LocalDate; 20 | 21 | import static java.util.Arrays.asList; 22 | import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; 23 | import static jakarta.ws.rs.core.Response.Status.FORBIDDEN; 24 | 25 | @Slf4j 26 | @Path("/orders") 27 | public class DemoBoundary { 28 | @Produces(APPLICATION_JSON) 29 | @POST public JsonObject order( 30 | @FormParam("user") int userId, 31 | @FormParam("article") @NotNull String article, 32 | @FormParam(value = "payment-method") @DefaultValue("prepaid") PaymentMethod paymentMethod) { 33 | 34 | log.info("order {} for {} via {}", article, userId, paymentMethod); 35 | 36 | int cost = cost(article); 37 | 38 | checkPaymentMethod(userId, cost, paymentMethod); 39 | 40 | deduct(cost, userId); 41 | String shipmentId = ship(article, userId); 42 | log.info("ship {} id {} to {}", article, shipmentId, userId); 43 | 44 | return Json.createObjectBuilder() 45 | .add("shipment-id", shipmentId) 46 | .add("article", article) 47 | .add("user", userId) 48 | .build(); 49 | } 50 | 51 | public enum PaymentMethod { 52 | prepaid, credit_card, on_account 53 | } 54 | 55 | private void checkPaymentMethod(int userId, int cost, PaymentMethod paymentMethod) { 56 | switch (paymentMethod) { 57 | case prepaid: 58 | break; 59 | case credit_card: 60 | if (cost > 1000) 61 | throw new CreditCardLimitExceeded(); 62 | break; 63 | case on_account: 64 | if (userId == 2) 65 | throw new UserNotEntitledToOrderOnAccount(); 66 | break; 67 | } 68 | } 69 | 70 | private int cost(String article) { 71 | switch (article) { 72 | case "expensive gadget": 73 | return 50; 74 | case "cheap gadget": 75 | return 5; 76 | default: 77 | throw new ArticleNotFoundException(article); 78 | } 79 | } 80 | 81 | private void deduct(int cost, int userId) { 82 | int balance = balance(userId); 83 | if (balance < cost) { 84 | throw new OutOfCreditException(balance, cost, 85 | URI.create("/account/12345/msgs/abc"), 86 | asList(ACCOUNT_1, ACCOUNT_2) 87 | ); 88 | } 89 | } 90 | 91 | private int balance(int userId) { 92 | switch (userId) { 93 | case 1: 94 | return 30; 95 | case 2: 96 | return 10; 97 | default: 98 | throw new IllegalArgumentException("unknown user " + userId); 99 | } 100 | } 101 | 102 | private String ship(String article, int userId) { 103 | return userId + ":" + article + ":" + LocalDate.now(); 104 | } 105 | 106 | 107 | public static final URI ACCOUNT_1 = URI.create("/account/12345"); 108 | public static final URI ACCOUNT_2 = URI.create("/account/67890"); 109 | 110 | @Status(FORBIDDEN) private static class CreditCardLimitExceeded extends RuntimeException {} 111 | 112 | @Status(FORBIDDEN) private static class UserNotEntitledToOrderOnAccount extends RuntimeException {} 113 | 114 | @AllArgsConstructor @NoArgsConstructor 115 | private static class ArticleNotFoundException extends IllegalArgumentException { 116 | @Extension String article; 117 | 118 | @Detail String getDetail() { return "The article " + article + " is not in our catalog"; } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /test/src/main/java/com/github/t1/problemdetaildemoapp/LoggingFilter.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetaildemoapp; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | 5 | import jakarta.ws.rs.container.ContainerRequestContext; 6 | import jakarta.ws.rs.container.ContainerRequestFilter; 7 | import jakarta.ws.rs.container.ContainerResponseContext; 8 | import jakarta.ws.rs.container.ContainerResponseFilter; 9 | import jakarta.ws.rs.core.MediaType; 10 | import jakarta.ws.rs.ext.Provider; 11 | import java.io.ByteArrayInputStream; 12 | import java.io.IOException; 13 | import java.util.List; 14 | 15 | import static java.nio.charset.StandardCharsets.UTF_8; 16 | import static java.util.Arrays.asList; 17 | import static jakarta.ws.rs.core.MediaType.APPLICATION_FORM_URLENCODED_TYPE; 18 | import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; 19 | import static jakarta.ws.rs.core.MediaType.APPLICATION_XML_TYPE; 20 | import static jakarta.ws.rs.core.MediaType.CHARSET_PARAMETER; 21 | 22 | @Slf4j 23 | @Provider 24 | public class LoggingFilter implements ContainerRequestFilter, ContainerResponseFilter { 25 | public static boolean LOG_REQUEST_ENTITY = false; 26 | 27 | @Override 28 | public void filter(ContainerRequestContext request) throws IOException { 29 | log.info("{} request {}{}", request.getMethod(), request.getUriInfo().getPath(), entity(request)); 30 | } 31 | 32 | private String entity(ContainerRequestContext request) throws IOException { 33 | MediaType mediaType = request.getMediaType(); 34 | if (LOG_REQUEST_ENTITY && log.isDebugEnabled() && request.hasEntity() && TEXT_TYPES.stream().anyMatch(mediaType::isCompatible)) { 35 | byte[] bytes = request.getEntityStream().readAllBytes(); 36 | request.setEntityStream(new ByteArrayInputStream(bytes)); // we've just depleted the original stream 37 | String charset = mediaType.getParameters().getOrDefault(CHARSET_PARAMETER, UTF_8.name()); 38 | return " " + mediaType + ":\n" + new String(bytes, charset); 39 | } else { 40 | return ""; 41 | } 42 | } 43 | 44 | @Override public void filter(ContainerRequestContext request, ContainerResponseContext response) { 45 | log.info("{} response {} -> {}:{}{}", request.getMethod(), request.getUriInfo().getPath(), 46 | response.getStatusInfo(), response.getMediaType(), response.hasEntity() ? (":\n" + response.getEntity()) : ""); 47 | } 48 | 49 | private static final List TEXT_TYPES = asList( 50 | APPLICATION_FORM_URLENCODED_TYPE, 51 | APPLICATION_JSON_TYPE, 52 | APPLICATION_XML_TYPE 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /test/src/main/java/com/github/t1/problemdetaildemoapp/OutOfCreditException.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetaildemoapp; 2 | 3 | import com.github.t1.problemdetail.Detail; 4 | import com.github.t1.problemdetail.Extension; 5 | import com.github.t1.problemdetail.Instance; 6 | import com.github.t1.problemdetail.Status; 7 | import com.github.t1.problemdetail.Title; 8 | import com.github.t1.problemdetail.Type; 9 | import lombok.AllArgsConstructor; 10 | import lombok.Getter; 11 | import lombok.NoArgsConstructor; 12 | 13 | import java.net.URI; 14 | import java.util.List; 15 | 16 | import static jakarta.ws.rs.core.Response.Status.FORBIDDEN; 17 | 18 | @Type("https://example.com/probs/out-of-credit") 19 | @Title("You do not have enough credit.") 20 | @Status(FORBIDDEN) 21 | @Getter @AllArgsConstructor @NoArgsConstructor(force = true) 22 | public class OutOfCreditException extends RuntimeException { 23 | @Extension private int balance; 24 | private int cost; 25 | @Instance private URI instance; 26 | @Extension private List accounts; 27 | 28 | @Detail public String getDetail() { 29 | return "Your current balance is " + balance + ", but that costs " + cost + "."; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/src/main/java/com/github/t1/problemdetaildemoapp/StandardExceptionBoundary.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetaildemoapp; 2 | 3 | import jakarta.ws.rs.BadRequestException; 4 | import jakarta.ws.rs.POST; 5 | import jakarta.ws.rs.Path; 6 | import jakarta.ws.rs.ServiceUnavailableException; 7 | import jakarta.ws.rs.core.Response; 8 | 9 | import static jakarta.ws.rs.core.MediaType.TEXT_PLAIN_TYPE; 10 | import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; 11 | 12 | @Path("/standard") 13 | public class StandardExceptionBoundary { 14 | @Path("/plain-bad-request") 15 | @POST public void plainBadRequest() { 16 | throw new BadRequestException(); 17 | } 18 | 19 | @Path("/bad-request-with-message") 20 | @POST public void badRequestWithMessage() { 21 | throw new BadRequestException("some message"); 22 | } 23 | 24 | @Path("/bad-request-with-text-response") 25 | @POST public void badRequestWithResponse() { 26 | throw new BadRequestException(Response.status(BAD_REQUEST) 27 | .type(TEXT_PLAIN_TYPE).entity("the body").build()); 28 | } 29 | 30 | @Path("/plain-service-unavailable") 31 | @POST public void plainServiceUnavailable() { 32 | throw new ServiceUnavailableException(); 33 | } 34 | 35 | @Path("/illegal-argument-without-message") 36 | @POST public void illegalArgumentWithoutMessage() { 37 | throw new IllegalArgumentException(); 38 | } 39 | 40 | @Path("/illegal-argument-with-message") 41 | @POST public void illegalArgumentWithMessage() { 42 | throw new IllegalArgumentException("some message"); 43 | } 44 | 45 | @Path("/npe-without-message") 46 | @POST public void npeWithoutMessage() { 47 | throw new NullPointerException(); 48 | } 49 | 50 | @Path("/npe-with-message") 51 | @POST public void npeWithMessage() { 52 | throw new NullPointerException("some message"); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/src/main/java/com/github/t1/problemdetaildemoapp/ValidationBoundary.java: -------------------------------------------------------------------------------- 1 | package com.github.t1.problemdetaildemoapp; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.NoArgsConstructor; 5 | import lombok.Value; 6 | 7 | import jakarta.validation.Valid; 8 | import jakarta.validation.constraints.NotNull; 9 | import jakarta.validation.constraints.Past; 10 | import jakarta.validation.constraints.Positive; 11 | import jakarta.ws.rs.POST; 12 | import jakarta.ws.rs.Path; 13 | import java.time.LocalDate; 14 | 15 | import static com.github.t1.validation.ValidationFailedException.validate; 16 | 17 | @Path("/validation") 18 | public class ValidationBoundary { 19 | @Value 20 | @NoArgsConstructor(force = true) @AllArgsConstructor 21 | public static class Address { 22 | @NotNull String street; 23 | @Positive int zipCode; 24 | @NotNull String city; 25 | } 26 | 27 | @Value 28 | @NoArgsConstructor(force = true) @AllArgsConstructor 29 | public static class Person { 30 | @NotNull String firstName; 31 | @NotNull String lastName; 32 | @Past LocalDate born; 33 | @Valid Address[] address; 34 | } 35 | 36 | @POST public void post() { 37 | Person person = new Person(null, null, LocalDate.now().plusDays(3), 38 | new Address[]{new Address(null, -1, null)}); 39 | 40 | validate(person); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/src/main/webapp/WEB-INF/beans.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /test/src/test/java/test/ContainerLaunchingExtension.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import com.github.t1.problemdetail.ProblemDetail; 4 | import com.github.t1.problemdetailmapper.ProblemDetailJsonMessageBodyReader; 5 | import com.github.t1.problemdetailmapper.ProblemDetailXmlMessageBodyReader; 6 | import com.github.t1.testcontainers.jee.JeeContainer; 7 | import org.assertj.core.api.Condition; 8 | import org.junit.jupiter.api.extension.BeforeAllCallback; 9 | import org.junit.jupiter.api.extension.Extension; 10 | import org.junit.jupiter.api.extension.ExtensionContext; 11 | 12 | import jakarta.ws.rs.client.Client; 13 | import jakarta.ws.rs.client.ClientBuilder; 14 | import jakarta.ws.rs.client.WebTarget; 15 | import jakarta.ws.rs.core.MediaType; 16 | import jakarta.ws.rs.core.Response; 17 | import jakarta.ws.rs.core.Response.Status; 18 | import java.net.URI; 19 | import java.util.function.Consumer; 20 | 21 | import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; 22 | import static org.assertj.core.api.Assertions.assertThat; 23 | 24 | class ContainerLaunchingExtension implements Extension, BeforeAllCallback { 25 | private static URI BASE_URI = null; 26 | 27 | /** 28 | * Stopping is done by the ryuk container... 29 | * see here 30 | */ 31 | @Override public void beforeAll(ExtensionContext context) { 32 | if (System.getProperty("testcontainer-running") != null) { 33 | BASE_URI = URI.create(System.getProperty("testcontainer-running")); 34 | } else if (BASE_URI == null) { 35 | JeeContainer container = JeeContainer.create("rdohna/wildfly:27.0.1.Final-jdk17-graphql") 36 | .withDeployment("target/problem-details-test.war"); 37 | container.start(); 38 | BASE_URI = container.baseUri(); 39 | } 40 | } 41 | 42 | public static ProblemDetailAssert testPost(String path) { 43 | return then(post(path)); 44 | } 45 | 46 | public static ProblemDetailAssert testPost(String path, Class type) { 47 | return then(post(path), type); 48 | } 49 | 50 | public static ProblemDetailAssert testPost(String path, String accept) { 51 | return then(target(path).request(MediaType.valueOf(accept)).post(null)); 52 | } 53 | 54 | public static ProblemDetailAssert testPost(String path, String accept1, String accept2) { 55 | return then(target(path).request(MediaType.valueOf(accept1), MediaType.valueOf(accept2)).post(null)); 56 | } 57 | 58 | public static ResponseAssert testPost(String path, String accept, Class type) { 59 | return new ResponseAssert<>(target(path).request(MediaType.valueOf(accept)).post(null), type); 60 | } 61 | 62 | private static Response post(String path) { 63 | return target(path).request(APPLICATION_JSON_TYPE).post(null); 64 | } 65 | 66 | private static WebTarget target(String path) { 67 | return target().path(path); 68 | } 69 | 70 | private static final Client CLIENT = ClientBuilder.newClient() 71 | .register(ProblemDetailJsonMessageBodyReader.class) 72 | .register(ProblemDetailXmlMessageBodyReader.class); 73 | 74 | public static WebTarget target() { 75 | return CLIENT.target(BASE_URI); 76 | } 77 | 78 | public static ProblemDetailAssert then(Response response) { 79 | return then(response, ProblemDetail.class); 80 | } 81 | 82 | public static ProblemDetailAssert then(Response response, Class type) { 83 | return new ProblemDetailAssert<>(response, type); 84 | } 85 | 86 | public static class ProblemDetailAssert extends ResponseAssert { 87 | public ProblemDetailAssert(Response response, Class type) {super(response, type);} 88 | 89 | @Override public ProblemDetailAssert hasStatus(Status status) { 90 | super.hasStatus(status); 91 | assertThat(entity.getStatus()).describedAs("problem-detail.status") 92 | .isEqualTo(status.getStatusCode()); 93 | return this; 94 | } 95 | 96 | @Override public ProblemDetailAssert hasContentType(String contentType) { 97 | super.hasContentType(contentType); 98 | return this; 99 | } 100 | 101 | @Override public ProblemDetailAssert hasContentType(MediaType contentType) { 102 | super.hasContentType(contentType); 103 | return this; 104 | } 105 | 106 | 107 | public ProblemDetailAssert hasType(String type) { 108 | assertThat(entity.getType()).describedAs("problem-detail.type") 109 | .isEqualTo(URI.create(type)); 110 | return this; 111 | } 112 | 113 | public ProblemDetailAssert hasTitle(String title) { 114 | assertThat(entity.getTitle()).describedAs("problem-detail.title") 115 | .isEqualTo(title); 116 | return this; 117 | } 118 | 119 | public ProblemDetailAssert hasDetail(String detail) { 120 | assertThat(entity.getDetail()).describedAs("problem-detail.detail") 121 | .isEqualTo(detail); 122 | return this; 123 | } 124 | 125 | public ProblemDetailAssert hasUuidInstance() { 126 | assertThat(entity.getInstance()).describedAs("problem-detail.instance") 127 | .has(new Condition<>(instance -> instance.toString().startsWith("urn:uuid:"), "some uuid urn")); 128 | return this; 129 | } 130 | 131 | public ProblemDetailAssert hasInstance(URI instance) { 132 | assertThat(entity.getInstance()).describedAs("problem-detail.instance") 133 | .isEqualTo(instance); 134 | return this; 135 | } 136 | 137 | public void checkExtensions(Consumer consumer) { 138 | consumer.accept(entity); 139 | } 140 | } 141 | 142 | public static class ResponseAssert { 143 | protected final Response response; 144 | protected final T entity; 145 | 146 | public ResponseAssert(Response response, Class type) { 147 | this.response = response; 148 | assertThat(this.response.hasEntity()).describedAs("response has entity").isTrue(); 149 | this.entity = this.response.readEntity(type); 150 | } 151 | 152 | public ResponseAssert hasStatus(Status status) { 153 | assertThat(response.getStatusInfo()).describedAs("response status") 154 | .isEqualTo(status); 155 | return this; 156 | } 157 | 158 | public ResponseAssert hasContentType(String contentType) { 159 | return hasContentType(MediaType.valueOf(contentType)); 160 | } 161 | 162 | public ResponseAssert hasContentType(MediaType contentType) { 163 | assertThat(response.getMediaType().isCompatible(contentType)) 164 | .describedAs("response content type [" + response.getMediaType() + "] " 165 | + "is not compatible with [" + contentType + "]").isTrue(); 166 | return this; 167 | } 168 | 169 | @SuppressWarnings("UnusedReturnValue") public ResponseAssert hasBody(T entity) { 170 | assertThat(this.entity).isEqualTo(entity); 171 | return this; 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /test/src/test/java/test/CustomExceptionIT.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.extension.ExtendWith; 5 | 6 | import static com.github.t1.problemdetail.Constants.PROBLEM_DETAIL_JSON; 7 | import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; 8 | import static jakarta.ws.rs.core.Response.Status.FORBIDDEN; 9 | import static jakarta.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; 10 | import static test.ContainerLaunchingExtension.testPost; 11 | 12 | @ExtendWith(ContainerLaunchingExtension.class) 13 | class CustomExceptionIT { 14 | 15 | @Test void shouldMapCustomRuntimeException() { 16 | testPost("/custom/runtime-exception") 17 | .hasStatus(INTERNAL_SERVER_ERROR) 18 | .hasContentType(PROBLEM_DETAIL_JSON) 19 | .hasType("urn:problem-type:custom") 20 | .hasTitle("Custom") 21 | .hasDetail(null) 22 | .hasUuidInstance(); 23 | } 24 | 25 | @Test void shouldMapCustomIllegalArgumentException() { 26 | testPost("/custom/illegal-argument-exception") 27 | .hasStatus(BAD_REQUEST) 28 | .hasContentType(PROBLEM_DETAIL_JSON) 29 | .hasType("urn:problem-type:custom") 30 | .hasTitle("Custom") 31 | .hasDetail(null) 32 | .hasUuidInstance(); 33 | } 34 | 35 | @Test void shouldMapExplicitType() { 36 | testPost("/custom/explicit-type") 37 | .hasStatus(INTERNAL_SERVER_ERROR) 38 | .hasContentType(PROBLEM_DETAIL_JSON) 39 | .hasType("https://error-codes.org/out-of-memory") 40 | .hasTitle("Some") 41 | .hasDetail(null) 42 | .hasUuidInstance(); 43 | } 44 | 45 | @Test void shouldMapExplicitTitle() { 46 | testPost("/custom/explicit-title") 47 | .hasStatus(INTERNAL_SERVER_ERROR) 48 | .hasContentType(PROBLEM_DETAIL_JSON) 49 | .hasType("urn:problem-type:some") 50 | .hasTitle("Some Title") 51 | .hasDetail(null) 52 | .hasUuidInstance(); 53 | } 54 | 55 | @Test void shouldMapExplicitStatus() { 56 | testPost("/custom/explicit-status") 57 | .hasStatus(FORBIDDEN) 58 | .hasContentType(PROBLEM_DETAIL_JSON) 59 | .hasType("urn:problem-type:something-forbidden") 60 | .hasTitle("Something Forbidden") 61 | .hasDetail(null) 62 | .hasUuidInstance(); 63 | } 64 | 65 | 66 | @Test void shouldMapDetailMethod() { 67 | testPost("/custom/public-detail-method") 68 | .hasStatus(INTERNAL_SERVER_ERROR) 69 | .hasContentType(PROBLEM_DETAIL_JSON) 70 | .hasType("urn:problem-type:some-message") 71 | .hasTitle("Some Message") 72 | .hasDetail("some detail") 73 | .hasUuidInstance(); 74 | } 75 | 76 | @Test void shouldMapPrivateDetailMethod() { 77 | testPost("/custom/private-detail-method") 78 | .hasStatus(INTERNAL_SERVER_ERROR) 79 | .hasContentType(PROBLEM_DETAIL_JSON) 80 | .hasType("urn:problem-type:some-message") 81 | .hasTitle("Some Message") 82 | .hasDetail("some detail") 83 | .hasUuidInstance(); 84 | } 85 | 86 | @Test void shouldMapFailingDetailMethod() { 87 | testPost("/custom/failing-detail-method") 88 | .hasStatus(INTERNAL_SERVER_ERROR) 89 | .hasContentType(PROBLEM_DETAIL_JSON) 90 | .hasType("urn:problem-type:failing-detail") 91 | .hasTitle("Failing Detail") 92 | .hasDetail("could not invoke FailingDetailException.failingDetail: java.lang.RuntimeException: inner") 93 | .hasUuidInstance(); 94 | } 95 | 96 | @Test void shouldMapPublicDetailFieldOverridingMessage() { 97 | testPost("/custom/public-detail-field") 98 | .hasStatus(INTERNAL_SERVER_ERROR) 99 | .hasContentType(PROBLEM_DETAIL_JSON) 100 | .hasType("urn:problem-type:some-message") 101 | .hasTitle("Some Message") 102 | .hasDetail("some detail") 103 | .hasUuidInstance(); 104 | } 105 | 106 | @Test void shouldMapPrivateDetailField() { 107 | testPost("/custom/private-detail-field") 108 | .hasStatus(INTERNAL_SERVER_ERROR) 109 | .hasContentType(PROBLEM_DETAIL_JSON) 110 | .hasType("urn:problem-type:some-message") 111 | .hasTitle("Some Message") 112 | .hasDetail("some detail") 113 | .hasUuidInstance(); 114 | } 115 | 116 | @Test void shouldMapMultipleDetailFields() { 117 | testPost("/custom/multi-detail-fields") 118 | .hasStatus(INTERNAL_SERVER_ERROR) 119 | .hasContentType(PROBLEM_DETAIL_JSON) 120 | .hasType("urn:problem-type:some-message") 121 | .hasTitle("Some Message") 122 | .hasDetail("detail a. detail b") 123 | .hasUuidInstance(); 124 | } 125 | 126 | @Test void shouldMapDetailMethodAndTwoFields() { 127 | testPost("/custom/mixed-details") 128 | .hasStatus(INTERNAL_SERVER_ERROR) 129 | .hasContentType(PROBLEM_DETAIL_JSON) 130 | .hasType("urn:problem-type:some-message") 131 | .hasTitle("Some Message") 132 | .hasDetail("detail a. detail b. detail c") 133 | .hasUuidInstance(); 134 | } 135 | 136 | @Test void shouldFailToMapDetailMethodTakingAnArgument() { 137 | testPost("/custom/detail-method-arg") 138 | .hasStatus(INTERNAL_SERVER_ERROR) 139 | .hasContentType(PROBLEM_DETAIL_JSON) 140 | .hasType("urn:problem-type:some-message") 141 | .hasTitle("Some Message") 142 | .hasDetail("could not invoke SomeMessageException.detail: expected no args but got 1") 143 | .hasUuidInstance(); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /test/src/test/java/test/DemoIT.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import com.github.t1.problemdetail.ri.lib.ProblemDetailJsonToExceptionBuilder; 4 | import com.github.t1.problemdetaildemoapp.OutOfCreditException; 5 | import com.github.t1.problemdetailmapper.ProblemDetailHandler; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | import org.junit.jupiter.api.Test; 10 | import org.junit.jupiter.api.extension.ExtendWith; 11 | 12 | import jakarta.json.bind.annotation.JsonbProperty; 13 | import jakarta.ws.rs.client.Entity; 14 | import jakarta.ws.rs.client.ResponseProcessingException; 15 | import jakarta.ws.rs.core.Form; 16 | import jakarta.ws.rs.core.Response; 17 | import java.net.URI; 18 | import java.time.LocalDate; 19 | 20 | import static com.github.t1.problemdetaildemoapp.DemoBoundary.ACCOUNT_1; 21 | import static com.github.t1.problemdetaildemoapp.DemoBoundary.ACCOUNT_2; 22 | import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; 23 | import static jakarta.ws.rs.core.Response.Status.OK; 24 | import static org.assertj.core.api.Assertions.catchThrowableOfType; 25 | import static org.assertj.core.api.BDDAssertions.then; 26 | import static test.ContainerLaunchingExtension.target; 27 | 28 | /** 29 | * Demonstrate the client side when mapping exceptions to problem details 30 | * as presented in the rfc. 31 | */ 32 | @ExtendWith(ContainerLaunchingExtension.class) 33 | class DemoIT { 34 | static { 35 | ProblemDetailJsonToExceptionBuilder.register(OutOfCreditException.class); 36 | } 37 | 38 | @Test void shouldOrderCheapGadget() { 39 | Response response = postOrder("cheap gadget"); 40 | 41 | then(response.getStatusInfo()).isEqualTo(OK); 42 | then(response.readEntity(Shipment.class)).isEqualTo(new Shipment( 43 | "1:cheap gadget:" + LocalDate.now(), 44 | "cheap gadget", 45 | 1)); 46 | } 47 | 48 | @Test void shouldFailToOrderExpensiveGadgetWhenOutOfCredit() { 49 | OutOfCreditException throwable = catchThrowableOfType(() -> postOrder("expensive gadget"), 50 | OutOfCreditException.class); 51 | 52 | then(throwable).describedAs("nothing thrown").isNotNull(); 53 | then(throwable.getBalance()).isEqualTo(30); 54 | then(throwable.getCost()).isEqualTo(0); // not an extension, i.e. not in the body 55 | then(throwable.getInstance()).isEqualTo(URI.create("/account/12345/msgs/abc")); 56 | // detail is not settable, i.e. it's recreated in the method and the cost is 0 57 | then(throwable.getDetail()).isEqualTo("Your current balance is 30, but that costs 0."); 58 | then(throwable.getAccounts()).containsExactly(ACCOUNT_1, ACCOUNT_2); 59 | } 60 | 61 | private Response postOrder(String article) { 62 | try { 63 | return target() 64 | .register(ProblemDetailHandler.class) 65 | .path("/orders").request(APPLICATION_JSON_TYPE) 66 | .post(Entity.form(new Form() 67 | .param("user", "1") 68 | .param("article", article))); 69 | } catch (ResponseProcessingException e) { 70 | throw (RuntimeException) e.getCause(); 71 | } 72 | } 73 | 74 | @AllArgsConstructor @NoArgsConstructor 75 | public static @Data class Shipment { 76 | @JsonbProperty("shipment-id") String shipmentId; 77 | String article; 78 | Integer user; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/src/test/java/test/ExtensionMappingIT.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import com.github.t1.problemdetail.ProblemDetail; 4 | import lombok.Data; 5 | import lombok.EqualsAndHashCode; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | 9 | import static com.github.t1.problemdetail.Constants.PROBLEM_DETAIL_JSON; 10 | import static jakarta.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; 11 | import static org.assertj.core.api.BDDAssertions.then; 12 | import static test.ContainerLaunchingExtension.testPost; 13 | 14 | @ExtendWith(ContainerLaunchingExtension.class) 15 | class ExtensionMappingIT { 16 | 17 | @Test void shouldMapExtensionStringMethod() { 18 | testPost("/custom/extension-method", ProblemDetailWithExtensionString.class) 19 | .hasStatus(INTERNAL_SERVER_ERROR) 20 | .hasContentType(PROBLEM_DETAIL_JSON) 21 | .hasType("urn:problem-type:some") 22 | .hasTitle("Some") 23 | .hasDetail(null) 24 | .hasUuidInstance() 25 | .checkExtensions(detail -> then(detail.ex).isEqualTo("some extension")); 26 | } 27 | 28 | @Test void shouldMapExtensionStringMethodWithAnnotatedName() { 29 | testPost("/custom/extension-method-with-name", ProblemDetailWithExtensionStringFoo.class) 30 | .hasStatus(INTERNAL_SERVER_ERROR) 31 | .hasContentType(PROBLEM_DETAIL_JSON) 32 | .hasType("urn:problem-type:some-message") 33 | .hasTitle("Some Message") 34 | .hasDetail(null) 35 | .hasUuidInstance() 36 | .checkExtensions(detail -> then(detail.foo).isEqualTo("some extension")); 37 | } 38 | 39 | @Test void shouldMapExtensionStringField() { 40 | testPost("/custom/extension-field", ProblemDetailWithExtensionString.class) 41 | .hasStatus(INTERNAL_SERVER_ERROR) 42 | .hasContentType(PROBLEM_DETAIL_JSON) 43 | .hasType("urn:problem-type:some-message") 44 | .hasTitle("Some Message") 45 | .hasDetail(null) 46 | .hasUuidInstance() 47 | .checkExtensions(detail -> then(detail.ex).isEqualTo("some extension")); 48 | } 49 | 50 | @Test void shouldMapExtensionStringFieldWithAnnotatedName() { 51 | testPost("/custom/extension-field-with-name", ProblemDetailWithExtensionStringFoo.class) 52 | .hasStatus(INTERNAL_SERVER_ERROR) 53 | .hasContentType(PROBLEM_DETAIL_JSON) 54 | .hasType("urn:problem-type:some-message") 55 | .hasTitle("Some Message") 56 | .hasDetail(null) 57 | .hasUuidInstance() 58 | .checkExtensions(detail -> then(detail.foo).isEqualTo("some extension")); 59 | } 60 | 61 | @Test void shouldMapMultiplePackagePrivateExtensions() { 62 | testPost("/custom/multi-extension", ProblemDetailWithMultipleExtensions.class) 63 | .hasStatus(INTERNAL_SERVER_ERROR) 64 | .hasContentType(PROBLEM_DETAIL_JSON) 65 | .hasType("urn:problem-type:some-message") 66 | .hasTitle("Some Message") 67 | .hasDetail(null) 68 | .hasUuidInstance() 69 | .checkExtensions(detail -> { 70 | then(detail.m1).isEqualTo("method 1"); 71 | then(detail.m2).isEqualTo("method 2"); 72 | then(detail.f1).isEqualTo("field 1"); 73 | then(detail.f2).isEqualTo("field 2"); 74 | }); 75 | } 76 | 77 | @Data @EqualsAndHashCode(callSuper = true) 78 | public static class ProblemDetailWithExtensionString extends ProblemDetail { 79 | private String ex; 80 | } 81 | 82 | @Data @EqualsAndHashCode(callSuper = true) 83 | public static class ProblemDetailWithExtensionStringFoo extends ProblemDetail { 84 | private String foo; 85 | } 86 | 87 | @Data @EqualsAndHashCode(callSuper = true) 88 | public static class ProblemDetailWithMultipleExtensions extends ProblemDetail { 89 | private String m1; 90 | private String m2; 91 | private String f1; 92 | private String f2; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /test/src/test/java/test/StandardExceptionMappingIT.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.extension.ExtendWith; 5 | 6 | import static com.github.t1.problemdetail.Constants.PROBLEM_DETAIL_JSON; 7 | import static com.github.t1.problemdetail.Constants.PROBLEM_DETAIL_XML; 8 | import static jakarta.ws.rs.core.MediaType.APPLICATION_XML; 9 | import static jakarta.ws.rs.core.MediaType.TEXT_PLAIN; 10 | import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; 11 | import static jakarta.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; 12 | import static jakarta.ws.rs.core.Response.Status.SERVICE_UNAVAILABLE; 13 | import static test.ContainerLaunchingExtension.testPost; 14 | 15 | @ExtendWith(ContainerLaunchingExtension.class) 16 | class StandardExceptionMappingIT { 17 | @Test void shouldMapClientWebApplicationExceptionWithoutEntityOrMessage() { 18 | testPost("standard/plain-bad-request") 19 | .hasStatus(BAD_REQUEST) 20 | .hasContentType(PROBLEM_DETAIL_JSON) 21 | .hasType("urn:problem-type:bad-request") 22 | .hasTitle("Bad Request") 23 | .hasDetail(null) 24 | .hasUuidInstance(); 25 | } 26 | 27 | @Test void shouldMapClientWebApplicationExceptionWithoutEntityButMessage() { 28 | testPost("/standard/bad-request-with-message") 29 | .hasStatus(BAD_REQUEST) 30 | .hasContentType(PROBLEM_DETAIL_JSON) 31 | .hasType("urn:problem-type:bad-request") 32 | .hasTitle("Bad Request") 33 | .hasDetail("some message") 34 | .hasUuidInstance(); 35 | } 36 | 37 | @Test void shouldUseEntityFromWebApplicationException() { 38 | testPost("/standard/bad-request-with-text-response", TEXT_PLAIN, String.class) 39 | .hasStatus(BAD_REQUEST) 40 | .hasContentType(TEXT_PLAIN) 41 | .hasBody("the body"); 42 | } 43 | 44 | @Test void shouldMapServerWebApplicationExceptionWithoutEntityOrMessage() { 45 | testPost("/standard/plain-service-unavailable") 46 | .hasStatus(SERVICE_UNAVAILABLE) 47 | .hasContentType(PROBLEM_DETAIL_JSON) 48 | .hasType("urn:problem-type:service-unavailable") 49 | .hasTitle("Service Unavailable") 50 | .hasDetail(null) 51 | .hasUuidInstance(); 52 | } 53 | 54 | @Test void shouldMapIllegalArgumentExceptionWithoutMessage() { 55 | testPost("/standard/illegal-argument-without-message") 56 | .hasStatus(BAD_REQUEST) 57 | .hasContentType(PROBLEM_DETAIL_JSON) 58 | .hasType("urn:problem-type:illegal-argument") 59 | .hasTitle("Illegal Argument") 60 | .hasDetail(null) 61 | .hasUuidInstance(); 62 | } 63 | 64 | @Test void shouldMapIllegalArgumentExceptionWithMessage() { 65 | testPost("/standard/illegal-argument-with-message") 66 | .hasStatus(BAD_REQUEST) 67 | .hasContentType(PROBLEM_DETAIL_JSON) 68 | .hasType("urn:problem-type:illegal-argument") 69 | .hasTitle("Illegal Argument") 70 | .hasDetail("some message") 71 | .hasUuidInstance(); 72 | } 73 | 74 | @Test void shouldMapNullPointerExceptionWithoutMessage() { 75 | testPost("/standard/npe-without-message") 76 | .hasStatus(INTERNAL_SERVER_ERROR) 77 | .hasContentType(PROBLEM_DETAIL_JSON) 78 | .hasType("urn:problem-type:null-pointer") 79 | .hasTitle("Null Pointer") 80 | .hasDetail(null) 81 | .hasUuidInstance(); 82 | } 83 | 84 | @Test void shouldMapNullPointerExceptionWithMessage() { 85 | testPost("/standard/npe-with-message") 86 | .hasStatus(INTERNAL_SERVER_ERROR) 87 | .hasContentType(PROBLEM_DETAIL_JSON) 88 | .hasType("urn:problem-type:null-pointer") 89 | .hasTitle("Null Pointer") 90 | .hasDetail("some message") 91 | .hasUuidInstance(); 92 | } 93 | 94 | @Test void shouldMapToXml() { 95 | testPost("/standard/npe-with-message", APPLICATION_XML) 96 | .hasStatus(INTERNAL_SERVER_ERROR) 97 | .hasContentType(PROBLEM_DETAIL_XML) 98 | .hasType("urn:problem-type:null-pointer") 99 | .hasTitle("Null Pointer") 100 | .hasDetail("some message") 101 | .hasUuidInstance(); 102 | } 103 | 104 | @Test void shouldMapToSecondAcceptXml() { 105 | testPost("/standard/npe-with-message", TEXT_PLAIN, APPLICATION_XML) 106 | .hasStatus(INTERNAL_SERVER_ERROR) 107 | .hasContentType(PROBLEM_DETAIL_XML) 108 | .hasType("urn:problem-type:null-pointer") 109 | .hasTitle("Null Pointer") 110 | .hasDetail("some message") 111 | .hasUuidInstance(); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /test/src/test/java/test/ValidationFailedExceptionMappingIT.java: -------------------------------------------------------------------------------- 1 | package test; 2 | 3 | import com.github.t1.problemdetail.ProblemDetail; 4 | import lombok.Data; 5 | import lombok.EqualsAndHashCode; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | 9 | import java.util.Map; 10 | import java.util.Set; 11 | 12 | import static com.github.t1.problemdetail.Constants.PROBLEM_DETAIL_JSON; 13 | import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; 14 | import static org.assertj.core.api.Assertions.entry; 15 | import static org.assertj.core.api.BDDAssertions.then; 16 | import static test.ContainerLaunchingExtension.testPost; 17 | 18 | @ExtendWith(ContainerLaunchingExtension.class) 19 | class ValidationFailedExceptionMappingIT { 20 | @Test void shouldMapValidationFailedException() { 21 | testPost("/validation", ValidationProblemDetail.class) 22 | .hasStatus(BAD_REQUEST) 23 | .hasContentType(PROBLEM_DETAIL_JSON) 24 | .hasType("urn:problem-type:validation-failed") 25 | .hasTitle("Validation Failed") 26 | .hasDetail("6 violations failed") 27 | .hasUuidInstance() 28 | .checkExtensions(detail -> then(detail.violations).containsOnly( 29 | entry("lastName", Set.of("must not be null")), 30 | entry("address[0].city", Set.of("must not be null")), 31 | entry("address[0].street", Set.of("must not be null")), 32 | entry("address[0].zipCode", Set.of("must be greater than 0")), 33 | entry("firstName", Set.of("must not be null")), 34 | entry("born", Set.of("must be a past date")) 35 | )); 36 | } 37 | 38 | @Data @EqualsAndHashCode(callSuper = true) 39 | public static class ValidationProblemDetail extends ProblemDetail { 40 | private Map> violations; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | --------------------------------------------------------------------------------