diff --git a/proposals/problem-details/README.asciidoc b/proposals/problem-details/README.asciidoc new file mode 100644 index 00000000..1cb72efa --- /dev/null +++ b/proposals/problem-details/README.asciidoc @@ -0,0 +1,48 @@ +// +// Copyright (c) 2019 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + += Problem Details + +//The specification is here +//TODO provide link the the spec html + +== About + +The proposed standard https://tools.ietf.org/html/rfc7807[RFC-7807] specifies an http response message entity (body) to convey the machine- and human-readable details about a problem with a request. In order to apply this standard to the Java world, exceptions on the server side have to be mapped to such a problem details; and/or problem detail responses have to trigger an exception on the client side containing the information provided in the message. + +The MicroProfile Problem Detail specification defines that compliant implementations: + +* Map exceptions thrown while processing an http request to valid problem detail documents. +* What defaults to use for the various specified fields. +* Consider the annotations defined in the API to override those details or add extension fields. +* On the client side map problem detail http message entities back to appropriate standard or a (compatible) custom exceptions containing the fields and extensions. + + +== Building + +Just enter `mvn` at the command line and maven will generate the following artifacts: + +API:: +A jar containing the api annotations, etc. in `/api/target` + +Specification:: +A PDF and HTML version of the specification document in `/spec/target/generated-docs/` + +TCK:: +A artifacts to test an implementation in `/tck/target` diff --git a/proposals/problem-details/api/pom.xml b/proposals/problem-details/api/pom.xml new file mode 100644 index 00000000..34f7e97c --- /dev/null +++ b/proposals/problem-details/api/pom.xml @@ -0,0 +1,42 @@ + + + + 4.0.0 + + + io.microprofile.sandbox + problem-details-parent + 1.0.0-SNAPSHOT + .. + + + problem-details-api + MicroProfile Problem Details :: API + Problem Details for MicroProfile :: API + + + + javax.ws.rs + javax.ws.rs-api + 2.1.1 + provided + + + diff --git a/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Constants.java b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Constants.java new file mode 100644 index 00000000..09ba1d65 --- /dev/null +++ b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Constants.java @@ -0,0 +1,31 @@ +package org.eclipse.microprofile.problemdetails; + +import javax.ws.rs.core.MediaType; + +public class Constants { + /** + * The JSON formatted details body of a failing http request. + * + * @see RFC-7807 + */ + public static final String PROBLEM_DETAIL_JSON = "application/problem+json"; + /** + * The JSON formatted details body of a failing http request. + * + * @see RFC-7807 + */ + public static final MediaType PROBLEM_DETAIL_JSON_TYPE = MediaType.valueOf(PROBLEM_DETAIL_JSON); + + /** + * The XML formatted details body of a failing http request. + * + * @see RFC-7807 + */ + public static final String PROBLEM_DETAIL_XML = "application/problem+xml"; + /** + * The XML formatted details body of a failing http request. + * + * @see RFC-7807 + */ + public static final MediaType PROBLEM_DETAIL_XML_TYPE = MediaType.valueOf(PROBLEM_DETAIL_XML); +} diff --git a/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Detail.java b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Detail.java new file mode 100644 index 00000000..814cc874 --- /dev/null +++ b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Detail.java @@ -0,0 +1,19 @@ +package org.eclipse.microprofile.problemdetails; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * The annotated methods or fields are used to build the detail + * field of the problem detail. Multiple details are joined to a single string + * delimited by `. `: a period and a space character. + *

+ * Defaults to the message of the exception. + */ +@Retention(RUNTIME) +@Target({METHOD, FIELD}) +public @interface Detail {} diff --git a/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Extension.java b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Extension.java new file mode 100644 index 00000000..e558f9cc --- /dev/null +++ b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Extension.java @@ -0,0 +1,20 @@ +package org.eclipse.microprofile.problemdetails; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * The annotated methods or fields are used to build additional properties of the problem detail. + */ +@Retention(RUNTIME) +@Target({METHOD, FIELD}) +public @interface Extension { + /** + * Defaults to the field/method name + */ + String value() default ""; +} diff --git a/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Instance.java b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Instance.java new file mode 100644 index 00000000..3f1cd5de --- /dev/null +++ b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Instance.java @@ -0,0 +1,20 @@ +package org.eclipse.microprofile.problemdetails; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * The annotated method or field is used for the instance field of the problem detail. + * The behavior is undefined, if there are multiple fields/methods. + * Note that this value should be different for every occurrence. + *

+ * By default, an URN with urn:uuid: and a random {@link java.util.UUID} + * is generated. + */ +@Retention(RUNTIME) +@Target({METHOD, FIELD}) +public @interface Instance {} diff --git a/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/LogLevel.java b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/LogLevel.java new file mode 100644 index 00000000..b618cfe5 --- /dev/null +++ b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/LogLevel.java @@ -0,0 +1,10 @@ +package org.eclipse.microprofile.problemdetails; + +public enum LogLevel { + /** + * DEBUG for 4xx and ERROR for 5xx and anything else. + */ + AUTO, + + ERROR, WARNING, INFO, DEBUG, OFF +} diff --git a/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Logging.java b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Logging.java new file mode 100644 index 00000000..0993285a --- /dev/null +++ b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Logging.java @@ -0,0 +1,30 @@ +package org.eclipse.microprofile.problemdetails; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.PACKAGE; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static org.eclipse.microprofile.problemdetails.LogLevel.AUTO; + +/** + * Defines how problem details should be logged. + *

+ * Can be applied to the package level, so all exceptions in the package are configured by default. + */ +@Retention(RUNTIME) +@Target({TYPE, PACKAGE}) +public @interface Logging { + + /** + * The category to log to. Defaults to the fully qualified class name of the exception. + */ + String to() default ""; + + /** + * The level to log at. Defaults to AUTO, i.e. DEBUG for 4xx + * and ERROR for 5xx. + */ + LogLevel at() default AUTO; +} diff --git a/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Status.java b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Status.java new file mode 100644 index 00000000..3fea0ed3 --- /dev/null +++ b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Status.java @@ -0,0 +1,19 @@ +package org.eclipse.microprofile.problemdetails; + +import javax.ws.rs.core.Response; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Defines the http status code to be used for the annotated exception. + * This will also be included as the status field of the + * problem detail. + */ +@Retention(RUNTIME) +@Target(TYPE) +public @interface Status { + Response.Status value(); +} diff --git a/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Title.java b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Title.java new file mode 100644 index 00000000..caf3f460 --- /dev/null +++ b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Title.java @@ -0,0 +1,18 @@ +package org.eclipse.microprofile.problemdetails; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Defines the title string to be used for the annotated exception. + * The default is derived from the simple class name by splitting the + * camel case name into words. + */ +@Retention(RUNTIME) +@Target(TYPE) +public @interface Title { + String value(); +} diff --git a/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Type.java b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Type.java new file mode 100644 index 00000000..3c096250 --- /dev/null +++ b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/Type.java @@ -0,0 +1,17 @@ +package org.eclipse.microprofile.problemdetails; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Defines the type url to be used for the annotated exception. + * The default is a URN urn:problem-type:[simple-class-name-with-dashes] + */ +@Retention(RUNTIME) +@Target(TYPE) +public @interface Type { + String value(); +} diff --git a/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/package-info.java b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/package-info.java new file mode 100644 index 00000000..aa5462cb --- /dev/null +++ b/proposals/problem-details/api/src/main/java/org/eclipse/microprofile/problemdetails/package-info.java @@ -0,0 +1,43 @@ +/* + ******************************************************************************* + * Copyright (c) 2019 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *******************************************************************************/ + +/** + * Annotations to override the default values for RFC-7807 problem detail fields, for example: + *

+ *{@literal @}Type("https://example.com/probs/out-of-credit")
+ *{@literal @}Title("You do not have enough credit.")
+ *{@literal @}Status(FORBIDDEN)
+ * public class OutOfCreditException extends RuntimeException {
+ *    {@literal @}Instance private URI instance;
+ *    {@literal @}Extension private int balance;
+ *     private int cost;
+ *    {@literal @}Extension private List accounts;
+ *
+ *    {@literal @}Detail public String getDetail() {
+ *         return "Your current balance is " + balance + ", but that costs " + cost + ".";
+ *     }
+ *
+ *     // ... constructors & getters
+ * }
+ * 
+ * + * @since 1.0 + */ +package org.eclipse.microprofile.problemdetails; diff --git a/proposals/problem-details/pom.xml b/proposals/problem-details/pom.xml new file mode 100644 index 00000000..153c7b13 --- /dev/null +++ b/proposals/problem-details/pom.xml @@ -0,0 +1,93 @@ + + + + 4.0.0 + + io.microprofile.sandbox + problem-details-parent + 1.0.0-SNAPSHOT + pom + + MicroProfile Problem Details + RFC-7807 compliant Problem Detail handling for Java on the http server as well as on the client side + http://microprofile.io + + + UTF-8 + + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + A business-friendly OSS license + + + + + Eclipse Foundation + http://www.eclipse.org/ + + + + GitHub + https://github.com/eclipse/microprofile-sandbox/issues + + + + scm:git:https://github.com/eclipse/microprofile-sandbox.git + scm:git:git@github.com:eclipse/microprofile-sandbox.git + https://github.com/eclipse/microprofile-sandbox/proposals/problem-details + HEAD + + + + + MicroProfile Community + http://microprofile.io/ + Eclipse Foundation + + + + + spec + api + tck + + + + install + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 1.8 + 1.8 + + -parameters + + + + + + diff --git a/proposals/problem-details/spec/pom.xml b/proposals/problem-details/spec/pom.xml new file mode 100644 index 00000000..20037ce6 --- /dev/null +++ b/proposals/problem-details/spec/pom.xml @@ -0,0 +1,88 @@ + + + + 4.0.0 + + + io.microprofile.sandbox + problem-details-parent + 1.0.0-SNAPSHOT + .. + + + problem-details-spec + pom + MicroProfile Problem Details :: Spec + Problem Details for MicroProfile :: Specification + + + ${maven.build.timestamp} + Draft + + + + + + org.asciidoctor + asciidoctor-maven-plugin + 1.5.7.1 + + + org.asciidoctor + asciidoctorj-pdf + 1.5.0-alpha.16 + + + + + generate-pdf-doc + generate-resources + + process-asciidoc + + + pdf + + ${project.version} + ${asciidoctor-revision-remark} + ${asciidoctor-revision-date} + + + + + output-html + generate-resources + + process-asciidoc + + + html5 + + ${project.version} + ${asciidoctor-revision-remark} + ${asciidoctor-revision-date} + + + + + + + + diff --git a/proposals/problem-details/spec/src/main/asciidoc/microprofile-problemdetails.asciidoc b/proposals/problem-details/spec/src/main/asciidoc/microprofile-problemdetails.asciidoc new file mode 100644 index 00000000..4f7a3e0d --- /dev/null +++ b/proposals/problem-details/spec/src/main/asciidoc/microprofile-problemdetails.asciidoc @@ -0,0 +1,65 @@ +// +// Copyright (c) 2019 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + += MicroProfile Problem Details +:authors: RĂ¼diger zu Dohna +:email: ruediger.dohna@codecentric.de +:version-label!: +:sectanchors: +:doctype: book +:license: Apache License v2.0 +:source-highlighter: coderay +:icons: font +:numbered: +:toc: left +:toclevels: 4 +:sectnumlevels: 4 +ifdef::backend-pdf[] +:pagenums: +endif::[] + +== Introduction + +* MUST `application/problem+json`, `application/problem+xml`; SHOULD any, e.g. `+yaml` +* SHOULD render `text/html` +* map also `@Valid` REST params +* logging: 4xx = DEBUG, 5xx = ERROR; configurable? +* order of extensions is alphabetic (which is better for tests than random) +* multiple extensions with the same name: undefined behavior +* JAXB can't unmarshal a subclass with the same type and namespace + +== Fields + +=== Type + +=== Title + +=== Detail + +=== Status + +=== Instance + +=== Extension + +== Logging + +== Security Considerations + +* Security considerations: nothing dangerous in problem details (i.e. exception message); stack-trace in logs diff --git a/proposals/problem-details/tck/README.asciidoc b/proposals/problem-details/tck/README.asciidoc new file mode 100644 index 00000000..95ff5d18 --- /dev/null +++ b/proposals/problem-details/tck/README.asciidoc @@ -0,0 +1,21 @@ +// +// Copyright (c) 2019 Contributors to the Eclipse Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + += Running the MicroProfile Problem Detail TCK + +//TODO: document how to run the TCK + +`-Dtestcontainer-running=http://localhost:8080/problem-details-tck` diff --git a/proposals/problem-details/tck/pom.xml b/proposals/problem-details/tck/pom.xml new file mode 100644 index 00000000..6e51c5fc --- /dev/null +++ b/proposals/problem-details/tck/pom.xml @@ -0,0 +1,196 @@ + + + + 4.0.0 + + + io.microprofile.sandbox + problem-details-parent + 1.0.0-SNAPSHOT + .. + + + problem-details-tck + war + MicroProfile Problem Details :: TCK + Problem Details for MicroProfile :: TCK + + + false + + 4.4.2.Final + 1.7.30 + + + + ${project.artifactId} + + + + + io.microprofile.sandbox + problem-details-api + ${project.version} + provided + + + + org.projectlombok + lombok + 1.18.10 + provided + + + javax.ws.rs + javax.ws.rs-api + 2.1.1 + provided + + + org.eclipse.microprofile.rest.client + microprofile-rest-client-api + 1.3.4 + provided + + + jakarta.validation + jakarta.validation-api + 2.0.2 + provided + + + jakarta.json.bind + jakarta.json.bind-api + 1.0.2 + provided + + + org.slf4j + slf4j-api + ${slf4j.version} + provided + + + + org.junit.jupiter + junit-jupiter + 5.5.2 + test + + + org.assertj + assertj-core + 3.14.0 + test + + + com.github.t1 + jee-testcontainers + 1.3.0 + test + + + org.jboss.resteasy + resteasy-core-spi + ${resteasy.version} + test + + + org.jboss.resteasy + resteasy-undertow + ${resteasy.version} + test + + + org.jboss.resteasy + resteasy-json-binding-provider + ${resteasy.version} + test + + + org.jboss.resteasy + resteasy-client-microprofile + ${resteasy.version} + test + + + org.glassfish.jaxb + jaxb-runtime + 2.3.2 + test + + + org.hibernate.validator + hibernate-validator + 6.1.0.Final + test + + + org.glassfish.web + javax.el + 2.2.6 + test + + + ch.qos.logback + logback-classic + 1.2.3 + test + + + + + + problem-details-ri + + true + + + + io.microprofile.sandbox + problem-details-api + ${project.version} + + + + com.github.t1 + problem-details-ri + 1.1.0-SNAPSHOT + + + + + with-slf4j + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + org.slf4j + slf4j-jdk14 + ${slf4j.version} + + + + + diff --git a/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/BridgeBoundary.java b/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/BridgeBoundary.java new file mode 100644 index 00000000..38f842ea --- /dev/null +++ b/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/BridgeBoundary.java @@ -0,0 +1,111 @@ +package org.eclipse.microprofile.problemdetails.tckapp; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.inject.RestClient; + +import javax.inject.Inject; +import javax.validation.constraints.NotNull; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.QueryParam; +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.Entity; +import java.net.URI; + +import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; + +@Slf4j +@Path("/bridge") +public class BridgeBoundary { + private static final String BASE_URI = "http://localhost:8080/problem-details-tck"; + private static final Entity EMPTY = Entity.entity("{}", APPLICATION_JSON_TYPE); + + /** how to call the target */ + public enum Mode { + /** JAX-RS WebTarget */ + jaxRs, + + /** Manually build a Microprofile Rest Client */ + mMpRest, + + /** Injected Microprofile Rest Client */ + iMpRest + } + + /** how should the target behave */ + public enum State {ok, fails} + + @Data @NoArgsConstructor @AllArgsConstructor + public static class Reply { + private String value; + } + + public static class ApiException extends IllegalArgumentException {} + + @Inject @RestClient API target; + + private final Client rest = ClientBuilder.newClient(); + + @RegisterRestClient(baseUri = BASE_URI) + public interface API { + @Path("/bridge/target/{state}") + @GET Reply request(@PathParam("state") State state) throws ApiException; + } + + @Path("/indirect/{state}") + @GET public Reply indirect(@PathParam("state") State state, @NotNull @QueryParam("mode") Mode mode) { + log.debug("call indirect {} :: {}", state, mode); + + API target = target(mode); + + try { + Reply reply = target.request(state); + log.debug("indirect call reply {}", reply); + return reply; + } catch (RuntimeException e) { + log.debug("indirect call exception", e); + throw e; + } + } + + private API target(@QueryParam("mode") Mode mode) { + switch (mode) { + case jaxRs: + return this::jaxRsCall; + case mMpRest: + return RestClientBuilder.newBuilder().baseUri(URI.create(BASE_URI)).build(API.class); + case iMpRest: + return this.target; + } + throw new UnsupportedOperationException(); + } + + private Reply jaxRsCall(State state) { + // ProblemDetailExceptionRegistry.register(ApiException.class); + return rest.target(BASE_URI) + // .register(ProblemDetailClientResponseFilter.class) + .path("/bridge/target") + .path(state.toString()) + .request(APPLICATION_JSON_TYPE) + .get(Reply.class); + } + + @Path("/target/{state}") + @GET public Reply target(@PathParam("state") State state) { + log.debug("target {}", state); + switch (state) { + case ok: + return new Reply("okay"); + case fails: + throw new ApiException(); + } + throw new UnsupportedOperationException(); + } +} diff --git a/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/Config.java b/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/Config.java new file mode 100644 index 00000000..4a684cca --- /dev/null +++ b/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/Config.java @@ -0,0 +1,7 @@ +package org.eclipse.microprofile.problemdetails.tckapp; + +import javax.ws.rs.ApplicationPath; +import javax.ws.rs.core.Application; + +@ApplicationPath("/") +public class Config extends Application {} diff --git a/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/CustomExceptionBoundary.java b/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/CustomExceptionBoundary.java new file mode 100644 index 00000000..1f1a53f1 --- /dev/null +++ b/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/CustomExceptionBoundary.java @@ -0,0 +1,183 @@ +package org.eclipse.microprofile.problemdetails.tckapp; + +import org.eclipse.microprofile.problemdetails.Detail; +import org.eclipse.microprofile.problemdetails.Extension; +import org.eclipse.microprofile.problemdetails.Instance; +import org.eclipse.microprofile.problemdetails.Status; +import org.eclipse.microprofile.problemdetails.Title; +import org.eclipse.microprofile.problemdetails.Type; + +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import java.net.URI; + +import static javax.ws.rs.core.Response.Status.FORBIDDEN; + +@Path("/custom") +public class CustomExceptionBoundary { + @Path("/runtime-exception") + @POST public void customRuntimeException() { + class CustomException extends RuntimeException {} + throw new CustomException(); + } + + @Path("/illegal-argument-exception") + @POST public void customIllegalArgumentException() { + class CustomException extends IllegalArgumentException {} + throw new CustomException(); + } + + @Path("/explicit-type") + @POST public void customTypeException() { + @Type("http://error-codes.org/out-of-memory") + class SomeException extends RuntimeException {} + throw new SomeException(); + } + + @Path("/explicit-title") + @POST public void customTitleException() { + @Title("Some Title") + class SomeException extends RuntimeException {} + throw new SomeException(); + } + + @Path("/explicit-status") + @POST public void customExplicitStatus() { + @Status(FORBIDDEN) + class SomethingForbiddenException extends RuntimeException {} + throw new SomethingForbiddenException(); + } + + @Path("/public-detail-method") + @POST public void publicDetailMethod() { + class SomeMessageException extends RuntimeException { + @Detail public String detail() { return "some detail"; } + } + throw new SomeMessageException(); + } + + @Path("/private-detail-method") + @POST public void privateDetailMethod() { + class SomeMessageException extends RuntimeException { + @Detail private String detail() { return "some detail"; } + } + throw new SomeMessageException(); + } + + @Path("/failing-detail-method") + @POST public void failingDetailMethod() { + class FailingDetailException extends RuntimeException { + public FailingDetailException() { super("some message"); } + + @Detail public String failingDetail() { + throw new RuntimeException("inner"); + } + } + throw new FailingDetailException(); + } + + @Path("/public-detail-field") + @POST public void publicDetailField() { + class SomeMessageException extends RuntimeException { + @Detail public String detail = "some detail"; + + public SomeMessageException(String message) { + super(message); + } + } + throw new SomeMessageException("overwritten"); + } + + @Path("/private-detail-field") + @POST public void privateDetailField() { + class SomeMessageException extends RuntimeException { + @Detail private String detail = "some detail"; + + public SomeMessageException(String message) { + super(message); + } + } + throw new SomeMessageException("overwritten"); + } + + @Path("/multi-detail-fields") + @POST public void multiDetailField() { + class SomeMessageException extends RuntimeException { + @Detail public String detail1 = "detail a"; + @Detail public String detail2 = "detail b"; + } + throw new SomeMessageException(); + } + + @Path("/mixed-details") + @POST public void multiDetails() { + class SomeMessageException extends RuntimeException { + @Detail public String detail0() { return "detail a"; } + + @Detail public String detail1 = "detail b"; + @Detail public String detail2 = "detail c"; + } + throw new SomeMessageException(); + } + + @Path("/detail-method-arg") + @POST public void detailMethodArg() { + class SomeMessageException extends RuntimeException { + @Detail public String detail(String foo) { return "some " + foo; } + } + throw new SomeMessageException(); + } + + @Path("/explicit-uri-instance") + @POST public void customInstanceException() { + class SomeException extends RuntimeException { + @Instance URI instance() { return URI.create("foobar"); } + } + throw new SomeException(); + } + + @Path("/extension-method") + @POST public void customExtensionMethod() { + class SomeException extends RuntimeException { + @Extension public String ex() { return "some extension"; } + } + throw new SomeException(); + } + + @Path("/extension-method-with-name") + @POST public void customExtensionMethodWithExplicitName() { + class SomeMessageException extends RuntimeException { + @Extension("foo") public String ex() { return "some extension"; } + } + throw new SomeMessageException(); + } + + @Path("/extension-field") + @POST public void customExtensionField() { + class SomeMessageException extends RuntimeException { + @Extension public String ex = "some extension"; + } + throw new SomeMessageException(); + } + + @Path("/extension-field-with-name") + @POST public void customExtensionFieldWithName() { + class SomeMessageException extends RuntimeException { + @Extension("foo") public String ex = "some extension"; + } + throw new SomeMessageException(); + } + + @Path("/multi-extension") + @POST public void multiExtension() { + class SomeMessageException extends RuntimeException { + @Extension String m1() { return "method 1"; } + + @Extension("m2") String method() { return "method 2"; } + + @Extension String f1 = "field 1"; + @Extension("f2") String field = "field 2"; + } + throw new SomeMessageException(); + } +} diff --git a/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/StandardExceptionBoundary.java b/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/StandardExceptionBoundary.java new file mode 100644 index 00000000..98280841 --- /dev/null +++ b/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/StandardExceptionBoundary.java @@ -0,0 +1,54 @@ +package org.eclipse.microprofile.problemdetails.tckapp; + +import javax.ws.rs.BadRequestException; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.ServiceUnavailableException; +import javax.ws.rs.core.Response; + +import static javax.ws.rs.core.MediaType.TEXT_PLAIN_TYPE; +import static javax.ws.rs.core.Response.Status.BAD_REQUEST; + +@Path("/standard") +public class StandardExceptionBoundary { + @Path("/plain-bad-request") + @POST public void plainBadRequest() { + throw new BadRequestException(); + } + + @Path("/bad-request-with-message") + @POST public void badRequestWithMessage() { + throw new BadRequestException("some message"); + } + + @Path("/bad-request-with-text-response") + @POST public void badRequestWithResponse() { + throw new BadRequestException(Response.status(BAD_REQUEST) + .type(TEXT_PLAIN_TYPE).entity("the body").build()); + } + + @Path("/plain-service-unavailable") + @POST public void plainServiceUnavailable() { + throw new ServiceUnavailableException(); + } + + @Path("/illegal-argument-without-message") + @POST public void illegalArgumentWithoutMessage() { + throw new IllegalArgumentException(); + } + + @Path("/illegal-argument-with-message") + @POST public void illegalArgumentWithMessage() { + throw new IllegalArgumentException("some message"); + } + + @Path("/npe-without-message") + @POST public void npeWithoutMessage() { + throw new NullPointerException(); + } + + @Path("/npe-with-message") + @POST public void npeWithMessage() { + throw new NullPointerException("some message"); + } +} diff --git a/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/ValidationBoundary.java b/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/ValidationBoundary.java new file mode 100644 index 00000000..da4e935c --- /dev/null +++ b/proposals/problem-details/tck/src/main/java/org/eclipse/microprofile/problemdetails/tckapp/ValidationBoundary.java @@ -0,0 +1,39 @@ +package org.eclipse.microprofile.problemdetails.tckapp; + +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.Value; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Past; +import javax.validation.constraints.Positive; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import java.time.LocalDate; +import java.util.List; + +@Path("/validation") +public class ValidationBoundary { + @Value + @NoArgsConstructor(force = true) @AllArgsConstructor + public static class Address { + @NotNull String street; + @Positive int zipCode; + @NotNull String city; + } + + @Value + @NoArgsConstructor(force = true) @AllArgsConstructor + public static class Person { + @NotNull String firstName; + @NotEmpty String lastName; + @Past LocalDate born; + @Valid List
address; + } + + @POST public String post(@Valid Person person) { + return "valid:" + person; + } +} diff --git a/proposals/problem-details/tck/src/main/webapp/WEB-INF/beans.xml b/proposals/problem-details/tck/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 00000000..e69de29b diff --git a/proposals/problem-details/tck/src/test/java/test/ContainerLaunchingExtension.java b/proposals/problem-details/tck/src/test/java/test/ContainerLaunchingExtension.java new file mode 100644 index 00000000..ad4cd28c --- /dev/null +++ b/proposals/problem-details/tck/src/test/java/test/ContainerLaunchingExtension.java @@ -0,0 +1,167 @@ +package test; + +import com.github.t1.testcontainers.jee.JeeContainer; +import org.assertj.core.api.Condition; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; + +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import java.net.URI; +import java.util.function.Consumer; + +import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerLaunchingExtension implements Extension, BeforeAllCallback { + private static URI BASE_URI = null; + + /** + * Stopping is done by the ryuk container... see + * https://www.testcontainers.org/test_framework_integration/manual_lifecycle_control/#singleton-containers + */ + @Override public void beforeAll(ExtensionContext context) { + if (System.getProperty("testcontainer-running") != null) { + BASE_URI = URI.create(System.getProperty("testcontainer-running")); + } else if (BASE_URI == null) { + JeeContainer container = JeeContainer.create() + .withDeployment("target/problem-details-test.war"); + container.start(); + BASE_URI = container.baseUri(); + } + } + + public static ProblemDetailAssert testPost(String path) { + return thenProblemDetail(post(path)); + } + + public static ProblemDetailAssert testPost(String path, Class type) { + return thenProblemDetail(post(path), type); + } + + public static ProblemDetailAssert testPost(String path, String accept) { + return thenProblemDetail(target(path).request(MediaType.valueOf(accept)).post(null)); + } + + public static ProblemDetailAssert testPost(String path, String accept1, String accept2) { + return thenProblemDetail(target(path).request(MediaType.valueOf(accept1), MediaType.valueOf(accept2)).post(null)); + } + + public static ResponseAssert testPost(String path, String accept, Class type) { + return new ResponseAssert<>(target(path).request(MediaType.valueOf(accept)).post(null), type); + } + + public static Response post(String path) { + return target(path).request(APPLICATION_JSON_TYPE).post(null); + } + + public static WebTarget target(String path) { + return target().path(path); + } + + private static final Client CLIENT = ClientBuilder.newClient(); + + public static WebTarget target() { + return CLIENT.target(BASE_URI); + } + + public static ProblemDetailAssert thenProblemDetail(Response response) { + return thenProblemDetail(response, ProblemDetail.class); + } + + public static ProblemDetailAssert thenProblemDetail(Response response, Class type) { + return new ProblemDetailAssert<>(response, type); + } + + public static class ProblemDetailAssert extends ResponseAssert { + public ProblemDetailAssert(Response response, Class type) { super(response, type); } + + @Override public ProblemDetailAssert hasStatus(Status status) { + super.hasStatus(status); + assertThat(entity.getStatus()).describedAs("problem-detail.status") + .isEqualTo(status.getStatusCode()); + return this; + } + + @Override public ProblemDetailAssert hasContentType(String contentType) { + super.hasContentType(contentType); + return this; + } + + @Override public ProblemDetailAssert hasContentType(MediaType contentType) { + super.hasContentType(contentType); + return this; + } + + + public ProblemDetailAssert hasType(String type) { + assertThat(entity.getType()).describedAs("problem-detail.type") + .isEqualTo(URI.create(type)); + return this; + } + + public ProblemDetailAssert hasTitle(String title) { + assertThat(entity.getTitle()).describedAs("problem-detail.title") + .isEqualTo(title); + return this; + } + + public ProblemDetailAssert hasDetail(String detail) { + assertThat(getDetail()).describedAs("problem-detail.detail") + .isEqualTo(detail); + return this; + } + + public String getDetail() { + return entity.getDetail(); + } + + public ProblemDetailAssert hasUuidInstance() { + assertThat(entity.getInstance()).describedAs("problem-detail.instance") + .has(new Condition<>(instance -> instance.toString().startsWith("urn:uuid:"), "some uuid urn")); + return this; + } + + public void check(Consumer consumer) { + consumer.accept(entity); + } + } + + public static class ResponseAssert { + protected final Response response; + protected final T entity; + + public ResponseAssert(Response response, Class type) { + this.response = response; + assertThat(this.response.hasEntity()).describedAs("response has entity").isTrue(); + this.entity = this.response.readEntity(type); + } + + public ResponseAssert hasStatus(Status status) { + assertThat(response.getStatusInfo()).describedAs("response status") + .isEqualTo(status); + return this; + } + + public ResponseAssert hasContentType(String contentType) { + return hasContentType(MediaType.valueOf(contentType)); + } + + public ResponseAssert hasContentType(MediaType contentType) { + assertThat(response.getMediaType().isCompatible(contentType)) + .describedAs("response content type [" + response.getMediaType() + "] " + + "is not compatible with [" + contentType + "]").isTrue(); + return this; + } + + @SuppressWarnings("UnusedReturnValue") public ResponseAssert hasBody(T entity) { + assertThat(this.entity).isEqualTo(entity); + return this; + } + } +} diff --git a/proposals/problem-details/tck/src/test/java/test/CustomExceptionIT.java b/proposals/problem-details/tck/src/test/java/test/CustomExceptionIT.java new file mode 100644 index 00000000..4b28deff --- /dev/null +++ b/proposals/problem-details/tck/src/test/java/test/CustomExceptionIT.java @@ -0,0 +1,145 @@ +package test; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static javax.ws.rs.core.Response.Status.BAD_REQUEST; +import static javax.ws.rs.core.Response.Status.FORBIDDEN; +import static javax.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; +import static org.eclipse.microprofile.problemdetails.Constants.PROBLEM_DETAIL_JSON; +import static test.ContainerLaunchingExtension.testPost; + +@ExtendWith(ContainerLaunchingExtension.class) +class CustomExceptionIT { + + @Test void shouldMapCustomRuntimeException() { + testPost("/custom/runtime-exception") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:custom") + .hasTitle("Custom") + .hasDetail(null) + .hasUuidInstance(); + } + + @Test void shouldMapCustomIllegalArgumentException() { + testPost("/custom/illegal-argument-exception") + .hasStatus(BAD_REQUEST) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:custom") + .hasTitle("Custom") + .hasDetail(null) + .hasUuidInstance(); + } + + @Test void shouldMapExplicitType() { + testPost("/custom/explicit-type") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("http://error-codes.org/out-of-memory") + .hasTitle("Some") + .hasDetail(null) + .hasUuidInstance(); + } + + @Test void shouldMapExplicitTitle() { + testPost("/custom/explicit-title") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some") + .hasTitle("Some Title") + .hasDetail(null) + .hasUuidInstance(); + } + + @Test void shouldMapExplicitStatus() { + testPost("/custom/explicit-status") + .hasStatus(FORBIDDEN) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:something-forbidden") + .hasTitle("Something Forbidden") + .hasDetail(null) + .hasUuidInstance(); + } + + + @Test void shouldMapDetailMethod() { + testPost("/custom/public-detail-method") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some-message") + .hasTitle("Some Message") + .hasDetail("some detail") + .hasUuidInstance(); + } + + @Test void shouldMapPrivateDetailMethod() { + testPost("/custom/private-detail-method") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some-message") + .hasTitle("Some Message") + .hasDetail("some detail") + .hasUuidInstance(); + } + + @Test void shouldMapFailingDetailMethod() { + testPost("/custom/failing-detail-method") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:failing-detail") + .hasTitle("Failing Detail") + .hasDetail("could not invoke FailingDetailException.failingDetail: java.lang.RuntimeException: inner") + .hasUuidInstance(); + } + + @Test void shouldMapPublicDetailFieldOverridingMessage() { + testPost("/custom/public-detail-field") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some-message") + .hasTitle("Some Message") + .hasDetail("some detail") + .hasUuidInstance(); + } + + @Test void shouldMapPrivateDetailField() { + testPost("/custom/private-detail-field") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some-message") + .hasTitle("Some Message") + .hasDetail("some detail") + .hasUuidInstance(); + } + + @Test void shouldMapMultipleDetailFields() { + testPost("/custom/multi-detail-fields") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some-message") + .hasTitle("Some Message") + .hasDetail("detail a. detail b") + .hasUuidInstance(); + } + + @Test void shouldMapDetailMethodAndTwoFields() { + testPost("/custom/mixed-details") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some-message") + .hasTitle("Some Message") + .hasDetail("detail a. detail b. detail c") + .hasUuidInstance(); + } + + @Test void shouldFailToMapDetailMethodTakingAnArgument() { + testPost("/custom/detail-method-arg") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some-message") + .hasTitle("Some Message") + .hasDetail("could not invoke SomeMessageException.detail: expected no args but got 1") + .hasUuidInstance(); + } +} diff --git a/proposals/problem-details/tck/src/test/java/test/ExtensionMappingIT.java b/proposals/problem-details/tck/src/test/java/test/ExtensionMappingIT.java new file mode 100644 index 00000000..1fe4d838 --- /dev/null +++ b/proposals/problem-details/tck/src/test/java/test/ExtensionMappingIT.java @@ -0,0 +1,93 @@ +package test; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static javax.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; +import static org.assertj.core.api.BDDAssertions.then; +import static org.eclipse.microprofile.problemdetails.Constants.PROBLEM_DETAIL_JSON; +import static test.ContainerLaunchingExtension.testPost; + +@ExtendWith(ContainerLaunchingExtension.class) +class ExtensionMappingIT { + + @Test void shouldMapExtensionStringMethod() { + testPost("/custom/extension-method", ProblemDetailWithExtensionString.class) + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some") + .hasTitle("Some") + .hasDetail(null) + .hasUuidInstance() + .check(detail -> then(detail.ex).isEqualTo("some extension")); + } + + @Test void shouldMapExtensionStringMethodWithAnnotatedName() { + testPost("/custom/extension-method-with-name", ProblemDetailWithExtensionStringFoo.class) + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some-message") + .hasTitle("Some Message") + .hasDetail(null) + .hasUuidInstance() + .check(detail -> then(detail.foo).isEqualTo("some extension")); + } + + @Test void shouldMapExtensionStringField() { + testPost("/custom/extension-field", ProblemDetailWithExtensionString.class) + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some-message") + .hasTitle("Some Message") + .hasDetail(null) + .hasUuidInstance() + .check(detail -> then(detail.ex).isEqualTo("some extension")); + } + + @Test void shouldMapExtensionStringFieldWithAnnotatedName() { + testPost("/custom/extension-field-with-name", ProblemDetailWithExtensionStringFoo.class) + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some-message") + .hasTitle("Some Message") + .hasDetail(null) + .hasUuidInstance() + .check(detail -> then(detail.foo).isEqualTo("some extension")); + } + + @Test void shouldMapMultiplePackagePrivateExtensions() { + testPost("/custom/multi-extension", ProblemDetailWithMultipleExtensions.class) + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:some-message") + .hasTitle("Some Message") + .hasDetail(null) + .hasUuidInstance() + .check(detail -> { + then(detail.m1).isEqualTo("method 1"); + then(detail.m2).isEqualTo("method 2"); + then(detail.f1).isEqualTo("field 1"); + then(detail.f2).isEqualTo("field 2"); + }); + } + + @Data @EqualsAndHashCode(callSuper = true) + public static class ProblemDetailWithExtensionString extends ProblemDetail { + private String ex; + } + + @Data @EqualsAndHashCode(callSuper = true) + public static class ProblemDetailWithExtensionStringFoo extends ProblemDetail { + private String foo; + } + + @Data @EqualsAndHashCode(callSuper = true) + public static class ProblemDetailWithMultipleExtensions extends ProblemDetail { + private String m1; + private String m2; + private String f1; + private String f2; + } +} diff --git a/proposals/problem-details/tck/src/test/java/test/MicroprofileRestClientBridgeIT.java b/proposals/problem-details/tck/src/test/java/test/MicroprofileRestClientBridgeIT.java new file mode 100644 index 00000000..0fff82d3 --- /dev/null +++ b/proposals/problem-details/tck/src/test/java/test/MicroprofileRestClientBridgeIT.java @@ -0,0 +1,63 @@ +package test; + +import org.eclipse.microprofile.problemdetails.tckapp.BridgeBoundary; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import javax.ws.rs.core.Response; + +import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; +import static javax.ws.rs.core.Response.Status.BAD_REQUEST; +import static javax.ws.rs.core.Response.Status.NOT_FOUND; +import static javax.ws.rs.core.Response.Status.OK; +import static org.assertj.core.api.BDDAssertions.then; +import static org.eclipse.microprofile.problemdetails.Constants.PROBLEM_DETAIL_JSON_TYPE; +import static test.ContainerLaunchingExtension.target; +import static test.ContainerLaunchingExtension.thenProblemDetail; + +@ExtendWith(ContainerLaunchingExtension.class) +class MicroprofileRestClientBridgeIT { + + @Test void shouldFailValidationWithoutMode() { + Response response = get("/bridge/indirect/ok", null); + + thenProblemDetail(response).hasType("urn:problem-type:validation-failed"); + } + + @EnumSource(BridgeBoundary.Mode.class) + @ParameterizedTest void shouldFailWithUnknownState(BridgeBoundary.Mode mode) { + Response response = get("/bridge/indirect/unknown", mode); + + thenProblemDetail(response).hasStatus(NOT_FOUND).hasType("urn:problem-type:not-found"); + } + + @EnumSource(BridgeBoundary.Mode.class) + @ParameterizedTest void shouldMapBridgedOkay(BridgeBoundary.Mode mode) { + Response response = get("/bridge/indirect/ok", mode); + + then(response.getStatusInfo()).isEqualTo(OK); + then(response.getMediaType()).isEqualTo(APPLICATION_JSON_TYPE); + then(response.readEntity(String.class)).isEqualTo("{\"value\":\"okay\"}"); + } + + @EnumSource(BridgeBoundary.Mode.class) + @ParameterizedTest void shouldMapBridgedFail(BridgeBoundary.Mode mode) { + Response response = get("/bridge/indirect/fails", mode); + + thenProblemDetail(response) + .hasStatus(BAD_REQUEST) + .hasContentType(PROBLEM_DETAIL_JSON_TYPE) + .hasTitle("Api") + .hasType("urn:problem-type:api") + .hasUuidInstance(); + } + + private Response get(String path, BridgeBoundary.Mode mode) { + return target(path) + .queryParam("mode", mode) + .request(APPLICATION_JSON_TYPE) + .get(); + } +} diff --git a/proposals/problem-details/tck/src/test/java/test/ProblemDetail.java b/proposals/problem-details/tck/src/test/java/test/ProblemDetail.java new file mode 100644 index 00000000..c135c8ea --- /dev/null +++ b/proposals/problem-details/tck/src/test/java/test/ProblemDetail.java @@ -0,0 +1,17 @@ +package test; + +import lombok.Data; + +import javax.ws.rs.core.MediaType; +import java.net.URI; + +@Data +public class ProblemDetail { + public static final MediaType JSON_MEDIA_TYPE = MediaType.valueOf("application/problem+json"); + + private URI type; + private String title; + private String detail; + private Integer status; + private URI instance; +} diff --git a/proposals/problem-details/tck/src/test/java/test/StandardExceptionMappingIT.java b/proposals/problem-details/tck/src/test/java/test/StandardExceptionMappingIT.java new file mode 100644 index 00000000..cfb495e7 --- /dev/null +++ b/proposals/problem-details/tck/src/test/java/test/StandardExceptionMappingIT.java @@ -0,0 +1,123 @@ +package test; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; + +import static javax.ws.rs.core.MediaType.APPLICATION_XML; +import static javax.ws.rs.core.MediaType.TEXT_PLAIN; +import static javax.ws.rs.core.Response.Status.BAD_REQUEST; +import static javax.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR; +import static javax.ws.rs.core.Response.Status.SERVICE_UNAVAILABLE; +import static org.eclipse.microprofile.problemdetails.Constants.PROBLEM_DETAIL_JSON; +import static org.eclipse.microprofile.problemdetails.Constants.PROBLEM_DETAIL_XML; +import static test.ContainerLaunchingExtension.testPost; + +@ExtendWith(ContainerLaunchingExtension.class) +class StandardExceptionMappingIT { + // TODO TomEE doesn't write some problem detail entities https://github.com/t1/problem-details/issues/17 + @DisabledIfSystemProperty(named = "jee-testcontainer", matches = "tomee") + @Test void shouldMapClientWebApplicationExceptionWithoutEntityOrMessage() { + testPost("standard/plain-bad-request") + .hasStatus(BAD_REQUEST) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:bad-request") + .hasTitle("Bad Request") + .hasDetail(null) + .hasUuidInstance(); + } + + // TODO TomEE doesn't write some problem detail entities https://github.com/t1/problem-details/issues/17 + @DisabledIfSystemProperty(named = "jee-testcontainer", matches = "tomee") + @Test void shouldMapClientWebApplicationExceptionWithoutEntityButMessage() { + testPost("/standard/bad-request-with-message") + .hasStatus(BAD_REQUEST) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:bad-request") + .hasTitle("Bad Request") + .hasDetail("some message") + .hasUuidInstance(); + } + + @Test void shouldUseEntityFromWebApplicationException() { + testPost("/standard/bad-request-with-text-response", TEXT_PLAIN, String.class) + .hasStatus(BAD_REQUEST) + .hasContentType(TEXT_PLAIN) + .hasBody("the body"); + } + + // TODO TomEE doesn't write some problem detail entities https://github.com/t1/problem-details/issues/17 + @DisabledIfSystemProperty(named = "jee-testcontainer", matches = "tomee") + @Test void shouldMapServerWebApplicationExceptionWithoutEntityOrMessage() { + testPost("/standard/plain-service-unavailable") + .hasStatus(SERVICE_UNAVAILABLE) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:service-unavailable") + .hasTitle("Service Unavailable") + .hasDetail(null) + .hasUuidInstance(); + } + + @Test void shouldMapIllegalArgumentExceptionWithoutMessage() { + testPost("/standard/illegal-argument-without-message") + .hasStatus(BAD_REQUEST) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:illegal-argument") + .hasTitle("Illegal Argument") + .hasDetail(null) + .hasUuidInstance(); + } + + @Test void shouldMapIllegalArgumentExceptionWithMessage() { + testPost("/standard/illegal-argument-with-message") + .hasStatus(BAD_REQUEST) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:illegal-argument") + .hasTitle("Illegal Argument") + .hasDetail("some message") + .hasUuidInstance(); + } + + @Test void shouldMapNullPointerExceptionWithoutMessage() { + testPost("/standard/npe-without-message") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:null-pointer") + .hasTitle("Null Pointer") + .hasDetail(null) + .hasUuidInstance(); + } + + @Test void shouldMapNullPointerExceptionWithMessage() { + testPost("/standard/npe-with-message") + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:null-pointer") + .hasTitle("Null Pointer") + .hasDetail("some message") + .hasUuidInstance(); + } + + @Disabled + @Test void shouldMapToXml() { + testPost("/standard/npe-with-message", APPLICATION_XML) + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_XML) + .hasType("urn:problem-type:null-pointer") + .hasTitle("Null Pointer") + .hasDetail("some message") + .hasUuidInstance(); + } + + @Disabled + @Test void shouldMapToSecondAcceptXml() { + testPost("/standard/npe-with-message", TEXT_PLAIN, APPLICATION_XML) + .hasStatus(INTERNAL_SERVER_ERROR) + .hasContentType(PROBLEM_DETAIL_XML) + .hasType("urn:problem-type:null-pointer") + .hasTitle("Null Pointer") + .hasDetail("some message") + .hasUuidInstance(); + } +} diff --git a/proposals/problem-details/tck/src/test/java/test/ValidationFailedExceptionMappingIT.java b/proposals/problem-details/tck/src/test/java/test/ValidationFailedExceptionMappingIT.java new file mode 100644 index 00000000..d6ca5230 --- /dev/null +++ b/proposals/problem-details/tck/src/test/java/test/ValidationFailedExceptionMappingIT.java @@ -0,0 +1,86 @@ +package test; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.assertj.core.api.BDDAssertions; +import org.eclipse.microprofile.problemdetails.tckapp.ValidationBoundary.Address; +import org.eclipse.microprofile.problemdetails.tckapp.ValidationBoundary.Person; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import test.ContainerLaunchingExtension.ProblemDetailAssert; + +import javax.ws.rs.core.Response; +import java.time.LocalDate; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import static java.util.Collections.singletonList; +import static javax.ws.rs.client.Entity.entity; +import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; +import static javax.ws.rs.core.Response.Status.BAD_REQUEST; +import static org.assertj.core.api.Assertions.entry; +import static org.assertj.core.api.Assertions.fail; +import static org.eclipse.microprofile.problemdetails.Constants.PROBLEM_DETAIL_JSON; +import static test.ContainerLaunchingExtension.target; +import static test.ContainerLaunchingExtension.thenProblemDetail; + +@ExtendWith(ContainerLaunchingExtension.class) +class ValidationFailedExceptionMappingIT { + @Test void shouldMapAnnotatedValidationFailedException() { + Person person = new Person(null, "", LocalDate.now().plusDays(3), + singletonList(new Address(null, -1, null))); + + Response response = target("/validation").request(APPLICATION_JSON_TYPE) + .post(entity(person, APPLICATION_JSON_TYPE)); + + thenValidationFailed(response); + } + + private void thenValidationFailed(Response response) { + ProblemDetailAssert it = thenProblemDetail(response, ValidationProblemDetail.class) + .hasStatus(BAD_REQUEST) + .hasContentType(PROBLEM_DETAIL_JSON) + .hasType("urn:problem-type:validation-failed") + .hasTitle("Validation Failed") + .hasUuidInstance(); + + String mustNotBeNull = mustOrMay(it) + " not be null"; + + switch (it.getDetail()) { + case "2 violations failed": // rest-easy has a different mode to validate fields + it.check(detail -> BDDAssertions.then(detail.violations).containsOnly( + entry("post.person.firstName", mustNotBeNull), + entry("post.person.lastName", "must not be empty") + )); + break; + case "6 violations failed": + it.check(detail -> BDDAssertions.then(detail.violations).containsOnly( + entry("firstName", mustNotBeNull), + entry("lastName", "must not be empty"), + entry("born", "must be a past date"), + entry("address[0].street", mustNotBeNull), + entry("address[0].zipCode", "must be greater than 0"), + entry("address[0].city", mustNotBeNull) + )); + break; + default: + fail("unexpected detail: [" + it.getDetail() + "]"); + } + } + + /** some validators say 'may' others say 'must' :( */ + private String mustOrMay(ProblemDetailAssert it) { + AtomicBoolean containsMust = new AtomicBoolean(true); + it.check(detail -> { + if (detail.violations != null) { + containsMust.set(detail.violations.containsValue("must not be null")); + } + }); + return containsMust.get() ? "must" : "may"; + } + + @Data @EqualsAndHashCode(callSuper = true) + public static class ValidationProblemDetail extends ProblemDetail { + private Map violations; + } +} diff --git a/proposals/problem-details/tck/src/test/resources/logback-test.xml b/proposals/problem-details/tck/src/test/resources/logback-test.xml new file mode 100644 index 00000000..32d0f35d --- /dev/null +++ b/proposals/problem-details/tck/src/test/resources/logback-test.xml @@ -0,0 +1,13 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + +