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
+
+ -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
+
+
+
+
+
+
+
+
+