├── .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 extends RuntimeException> 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 extends RuntimeException> 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 extends Exception> 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 extends Exception> 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 extends Exception> 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 extends Annotation> 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 extends RuntimeException> 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 | " type \n" +
177 | " urn:some-type \n" +
178 | " \n" +
179 | " \n" +
180 | " title \n" +
181 | " some-title \n" +
182 | " \n" +
183 | " \n" +
184 | " status \n" +
185 | " 400 \n" +
186 | " \n" +
187 | " \n" +
188 | " detail \n" +
189 | " some-detail \n" +
190 | " \n" +
191 | " \n" +
192 | " instance \n" +
193 | " urn:some-instance \n" +
194 | " \n" +
195 | " \n" +
196 | " k1 \n" +
197 | " v1 \n" +
198 | " \n" +
199 | " \n" +
200 | " k2 \n" +
201 | " [urn:1, null, urn:2] \n" +
202 | " \n" +
203 | " \n" +
204 | " k3 \n" +
205 | " {k3.1=v3.1, k3.2=v3.2} \n" +
206 | " \n" +
207 | " \n" +
208 | " k4 \n" +
209 | " {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" +
210 | " \n" +
211 | "
\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 |
--------------------------------------------------------------------------------