diff --git a/README.md b/README.md index f6e4e83044..666deb0510 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,7 @@ Additional coverage: - Advanced JSON serialization. - Gzip compression - REST Client reactive - support for POJO JSON serialization in multipart forms. +- Request matching - selecting from multiple media types ### `http/rest-client` Verifies Rest Client configuration using `quarkus-rest-client-jaxb` (XML support) and `quarkus-rest-client-jsonb` (JSON support). diff --git a/http/jaxrs-reactive/src/main/java/io/quarkus/ts/http/jaxrs/reactive/MediaTypeResource.java b/http/jaxrs-reactive/src/main/java/io/quarkus/ts/http/jaxrs/reactive/MediaTypeResource.java new file mode 100644 index 0000000000..45e4697146 --- /dev/null +++ b/http/jaxrs-reactive/src/main/java/io/quarkus/ts/http/jaxrs/reactive/MediaTypeResource.java @@ -0,0 +1,111 @@ +package io.quarkus.ts.http.jaxrs.reactive; + +import static io.quarkus.ts.http.jaxrs.reactive.MediaTypeResource.MEDIA_TYPE_PATH; +import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE; +import static javax.ws.rs.core.MediaType.APPLICATION_ATOM_XML; +import static javax.ws.rs.core.MediaType.APPLICATION_FORM_URLENCODED; +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; +import static javax.ws.rs.core.MediaType.APPLICATION_JSON_PATCH_JSON; +import static javax.ws.rs.core.MediaType.APPLICATION_OCTET_STREAM; +import static javax.ws.rs.core.MediaType.APPLICATION_SVG_XML; +import static javax.ws.rs.core.MediaType.APPLICATION_XHTML_XML; +import static javax.ws.rs.core.MediaType.APPLICATION_XML; +import static javax.ws.rs.core.MediaType.TEXT_XML; +import static javax.ws.rs.core.MediaType.WILDCARD; + +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.PATCH; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; + +@Path(MEDIA_TYPE_PATH) +public class MediaTypeResource { + + public static final String MEDIA_TYPE_PATH = "/media-type"; + public static final String POST_APP_JSON_AND_TEXT_XML_AND_TEXT_PLAIN = "postAppJsonTextXmlTextPlain"; + public static final String POST_APP_ATOM_XML_AND_TEXT_XML_EXT_AND_APP_JSON_PATCH_JSON = "postAppAtomXmlAndTextAnyXmlExtAndAppJsonPatch"; + public static final String GET_WITHOUT_CONSUMED_MEDIA_TYPES = "getWithoutConsumedMediaTypes"; + public static final String PATCH_WITHOUT_CONSUMED_MEDIA_TYPES = "patchWithoutConsumedMediaTypes"; + public static final String PATCH_APP_OCTET_STREAM = "patchAppOctetStream"; + + @Consumes({ "application/soap+xml" }) + @POST + public Response postApplicationSoapXml() { + return Response.ok("application/soap+xml").build(); + } + + @Consumes({ "custom/media-type" }) + @POST + public Response postCustomMediaType(@HeaderParam(CONTENT_TYPE) String contentType) { + return Response.ok(contentType).build(); + } + + @Consumes({ APPLICATION_XML, TEXT_XML, APPLICATION_JSON }) + @POST + public Response postAppJsonTextXmlTextPlain() { + return Response.ok(POST_APP_JSON_AND_TEXT_XML_AND_TEXT_PLAIN).build(); + } + + @Consumes({ APPLICATION_ATOM_XML, "text/xml-external-parsed-entity", APPLICATION_JSON_PATCH_JSON }) + @POST + public Response postAppAtomXmlAndTextAnyXmlExtAndAppJsonPatch() { + return Response.ok(POST_APP_ATOM_XML_AND_TEXT_XML_EXT_AND_APP_JSON_PATCH_JSON).build(); + } + + @GET + public Response getWithoutConsumedMediaTypes() { + return Response.ok(GET_WITHOUT_CONSUMED_MEDIA_TYPES).build(); + } + + @Consumes(WILDCARD) + @GET + public Response getWildcard() { + // this endpoint is never reached as getWithoutConsumedMediaTypes has priority over wildcard + return Response.ok(WILDCARD).build(); + } + + @Produces(APPLICATION_XHTML_XML) + @PATCH + public Response patchWithoutConsumedMediaTypes() { + return Response.ok(PATCH_WITHOUT_CONSUMED_MEDIA_TYPES).build(); + } + + @Produces(WILDCARD) + @Consumes(APPLICATION_SVG_XML) + @PATCH + public Response patchWildcard() { + return Response.ok(APPLICATION_SVG_XML).build(); + } + + @Produces(APPLICATION_OCTET_STREAM) + @Consumes(APPLICATION_SVG_XML) + @PATCH + public Response patchAppOctetStream() { + return Response.ok(PATCH_APP_OCTET_STREAM).build(); + } + + @Produces(APPLICATION_FORM_URLENCODED) + @Consumes(APPLICATION_SVG_XML) + @PATCH + public Response patchAppFormUrlEncoded() { + return Response.ok(APPLICATION_FORM_URLENCODED).build(); + } + + @Produces(APPLICATION_SVG_XML) + @Consumes(APPLICATION_SVG_XML) + @PATCH + public Response patchAppSvgXml() { + return Response.ok(APPLICATION_SVG_XML).build(); + } + + @Consumes(WILDCARD) + @PUT + public Response putWildcard() { + return Response.ok(WILDCARD).build(); + } +} diff --git a/http/jaxrs-reactive/src/test/java/io/quarkus/ts/http/jaxrs/reactive/MediaTypeSelectionIT.java b/http/jaxrs-reactive/src/test/java/io/quarkus/ts/http/jaxrs/reactive/MediaTypeSelectionIT.java new file mode 100644 index 0000000000..66cfe18602 --- /dev/null +++ b/http/jaxrs-reactive/src/test/java/io/quarkus/ts/http/jaxrs/reactive/MediaTypeSelectionIT.java @@ -0,0 +1,210 @@ +package io.quarkus.ts.http.jaxrs.reactive; + +import static io.quarkus.ts.http.jaxrs.reactive.MediaTypeResource.GET_WITHOUT_CONSUMED_MEDIA_TYPES; +import static io.quarkus.ts.http.jaxrs.reactive.MediaTypeResource.MEDIA_TYPE_PATH; +import static io.quarkus.ts.http.jaxrs.reactive.MediaTypeResource.PATCH_APP_OCTET_STREAM; +import static io.quarkus.ts.http.jaxrs.reactive.MediaTypeResource.PATCH_WITHOUT_CONSUMED_MEDIA_TYPES; +import static io.quarkus.ts.http.jaxrs.reactive.MediaTypeResource.POST_APP_ATOM_XML_AND_TEXT_XML_EXT_AND_APP_JSON_PATCH_JSON; +import static io.quarkus.ts.http.jaxrs.reactive.MediaTypeResource.POST_APP_JSON_AND_TEXT_XML_AND_TEXT_PLAIN; +import static io.restassured.http.ContentType.BINARY; +import static io.restassured.http.ContentType.JSON; +import static io.restassured.http.ContentType.MULTIPART; +import static io.restassured.http.ContentType.XML; +import static javax.ws.rs.core.MediaType.APPLICATION_ATOM_XML; +import static javax.ws.rs.core.MediaType.APPLICATION_FORM_URLENCODED; +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; +import static javax.ws.rs.core.MediaType.APPLICATION_JSON_PATCH_JSON; +import static javax.ws.rs.core.MediaType.APPLICATION_SVG_XML; +import static javax.ws.rs.core.MediaType.APPLICATION_XHTML_XML; +import static javax.ws.rs.core.MediaType.APPLICATION_XML; +import static javax.ws.rs.core.MediaType.TEXT_XML; +import static javax.ws.rs.core.MediaType.WILDCARD; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.scenarios.QuarkusScenario; +import io.restassured.RestAssured; + +@QuarkusScenario +public class MediaTypeSelectionIT { + + @Test + public void testUnsupportedMediaType() { + // no resource method consumes multipart/form-data nor wild card + RestAssured.given() + .contentType("multipart/form-data") + .post(MEDIA_TYPE_PATH) + .then() + .statusCode(415); + // no resource method consumes 'application/atom+*' and asterisk is treated as any other character after plus, + // therefore 'application/atom+xml' is not matched + RestAssured.given() + .contentType("application/atom+*") + .post(MEDIA_TYPE_PATH) + .then() + .statusCode(415); + // 'application/atom+xml' is not matched + RestAssured.given() + .contentType("application/atom") + .post(MEDIA_TYPE_PATH) + .then() + .statusCode(415); + // 'text/xml' is not matched + RestAssured.given() + .contentType("text/xml-foo") + .post(MEDIA_TYPE_PATH) + .then() + .statusCode(415); + } + + @Test + public void testExactMatchApplicationSoapXml() { + RestAssured.given() + .contentType("application/soap+xml") + .post(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo("application/soap+xml")); + } + + @Test + public void testMediaSubTypesRanges() { + // resource method 'postAppJsonTextXmlTextPlain' should match below calls as it + // consumes 'application/xml', 'text/xml' and 'application/json' + RestAssured.given() + .contentType(APPLICATION_XML) + .post(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(POST_APP_JSON_AND_TEXT_XML_AND_TEXT_PLAIN)); + RestAssured.given() + .contentType(TEXT_XML) + .post(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(POST_APP_JSON_AND_TEXT_XML_AND_TEXT_PLAIN)); + RestAssured.given() + .contentType(APPLICATION_JSON) + .post(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(POST_APP_JSON_AND_TEXT_XML_AND_TEXT_PLAIN)); + // resource method 'postAppAtomXmlAndTextAnyXmlExtAndAppJsonPatch' should match below calls as it + // consumes 'application/atom+xml', 'text/xml-external-parsed-entity' and 'application/json-patch+json'; + // media types consumed by resource method 'postAppAtomXmlAndTextAnyXmlExtAndAppJsonPatch' are more specific + // than media types consumed by 'postAppJsonTextXmlTextPlain' and the most specific reference is selected + RestAssured.given() + .contentType(APPLICATION_ATOM_XML) + .post(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(POST_APP_ATOM_XML_AND_TEXT_XML_EXT_AND_APP_JSON_PATCH_JSON)); + RestAssured.given() + .contentType("text/xml-external-parsed-entity") + .post(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(POST_APP_ATOM_XML_AND_TEXT_XML_EXT_AND_APP_JSON_PATCH_JSON)); + RestAssured.given() + .contentType(APPLICATION_JSON_PATCH_JSON) + .post(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(POST_APP_ATOM_XML_AND_TEXT_XML_EXT_AND_APP_JSON_PATCH_JSON)); + } + + @Test + public void testNoMediaType() { + // resource method without consumes has priority over wildcard + RestAssured.given() + .contentType(APPLICATION_JSON) + .get(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(GET_WITHOUT_CONSUMED_MEDIA_TYPES)); + RestAssured.given() + .contentType(APPLICATION_SVG_XML) + .get(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(GET_WITHOUT_CONSUMED_MEDIA_TYPES)); + } + + @Test + public void testAcceptHeaders() { + // here we accept one of xml types, therefore resource method producing application/xhtml+xml is matched + RestAssured.given() + .contentType(APPLICATION_JSON) + .accept(XML) + .patch(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(PATCH_WITHOUT_CONSUMED_MEDIA_TYPES)); + // here we accept one of json types, but no endpoint matches, therefore method producing wildcard is used + RestAssured.given() + .contentType(APPLICATION_SVG_XML) + .accept(JSON) + .patch(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(APPLICATION_SVG_XML)); + // here we accept multipart, but no endpoint matches, therefore method producing wildcard is used + RestAssured.given() + .contentType(APPLICATION_SVG_XML) + .accept(MULTIPART) + .patch(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(APPLICATION_SVG_XML)); + // exact match for accept header has priority over wildcard + RestAssured.given() + .contentType(APPLICATION_SVG_XML) + .accept(BINARY) + .patch(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(PATCH_APP_OCTET_STREAM)); + } + + @Test + public void testWildCardMediaType() { + // test wildcard is fallback choice when no other resource method matches + RestAssured.given() + .contentType(APPLICATION_XHTML_XML) + .put(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(WILDCARD)); + } + + @Test + public void testMediaSubTypeWildcard() { + // matches '*/*' as that's the only HTTP PUT method + RestAssured.given() + .contentType("application/*") + .put(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(WILDCARD)); + // matches resource method without defined content type as 'application/atom+*' do not match any type + RestAssured.given() + .contentType("application/atom+*") + .patch(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(PATCH_WITHOUT_CONSUMED_MEDIA_TYPES)); + } + + @Test + public void testQualityDeterminesFormUrlEncoded() { + RestAssured.given() + .contentType(APPLICATION_SVG_XML) + .accept(String.format("%s;q=0.4, %s;q=0.5", APPLICATION_SVG_XML, APPLICATION_FORM_URLENCODED)) + .patch(MEDIA_TYPE_PATH) + .then() + .statusCode(200) + .body(Matchers.equalTo(APPLICATION_FORM_URLENCODED)); + } + +}