Skip to content

Commit

Permalink
Take multiple Accept headers case into account in RESTEasy Reactive
Browse files Browse the repository at this point in the history
  • Loading branch information
geoand committed Nov 26, 2021
1 parent 477b090 commit 3f1a599
Show file tree
Hide file tree
Showing 5 changed files with 168 additions and 76 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -124,66 +124,21 @@ public void handle(ResteasyReactiveRequestContext requestContext) throws Excepti
}
// according to the spec we need to return HTTP 406 when Accept header doesn't match what is specified in @Produces
if (target.value.getProduces() != null) {
String accepts = serverRequest.getRequestHeader(HttpHeaders.ACCEPT);
if ((accepts != null) && !accepts.equals(MediaType.WILDCARD)) {
int commaIndex = accepts.indexOf(',');
boolean multipleAcceptsValues = commaIndex >= 0;
MediaType[] producesMediaTypes = target.value.getProduces().getSortedOriginalMediaTypes();
if (!multipleAcceptsValues && (producesMediaTypes.length == 1)) {
// the point of this branch is to eliminate any list creation or string indexing as none is needed
MediaType acceptsMediaType = MediaType.valueOf(accepts.trim());
MediaType providedMediaType = producesMediaTypes[0];
if (!providedMediaType.isCompatible(acceptsMediaType)) {
throw new NotAcceptableException(INVALID_ACCEPT_HEADER_MESSAGE);
}
} else if (multipleAcceptsValues && (producesMediaTypes.length == 1)) {
// this is fairly common case, so we want it to be as fast as possible
// we do that by manually splitting the accepts header and immediately checking
// if the value is compatible with the produces media type
boolean compatible = false;
int begin = 0;

do {
String acceptPart;
if (commaIndex == -1) { // this is the case where we are checking the remainder of the string
acceptPart = accepts.substring(begin);
} else {
acceptPart = accepts.substring(begin, commaIndex);
}
if (producesMediaTypes[0].isCompatible(toMediaType(acceptPart.trim()))) {
compatible = true;
break;
} else if (commaIndex == -1) { // we have reached the end and not found any compatible media types
break;
}
begin = commaIndex + 1; // the next part will start at the character after the comma
if (begin >= (accepts.length() - 1)) { // if we have reached this point, then are no compatible media types
break;
}
commaIndex = accepts.indexOf(',', begin);
} while (true);

if (!compatible) {
throw new NotAcceptableException(INVALID_ACCEPT_HEADER_MESSAGE);
}
} else {
// don't use any of the JAX-RS stuff from the various MediaType helper as we want to be as performant as possible
List<MediaType> acceptsMediaTypes;
if (accepts.contains(",")) {
String[] parts = accepts.split(",");
acceptsMediaTypes = new ArrayList<>(parts.length);
for (int i = 0; i < parts.length; i++) {
String part = parts[i];
acceptsMediaTypes.add(toMediaType(part.trim()));
}
} else {
acceptsMediaTypes = Collections.singletonList(toMediaType(accepts));
}
if (MediaTypeHelper.getFirstMatch(Arrays.asList(producesMediaTypes),
acceptsMediaTypes) == null) {
throw new NotAcceptableException(INVALID_ACCEPT_HEADER_MESSAGE);
// there could potentially be multiple Accept headers and we need to response with 406
// if none match the method's @Produces
List<String> accepts = serverRequest.getAllRequestHeaders(HttpHeaders.ACCEPT);
if (!accepts.isEmpty()) {
boolean hasAtLeastOneMatch = false;
for (int i = 0; i < accepts.size(); i++) {
boolean matches = acceptHeaderMatches(target, accepts.get(i));
if (matches) {
hasAtLeastOneMatch = true;
break;
}
}
if (!hasAtLeastOneMatch) {
throw new NotAcceptableException(INVALID_ACCEPT_HEADER_MESSAGE);
}
}
}

Expand All @@ -198,6 +153,65 @@ public void handle(ResteasyReactiveRequestContext requestContext) throws Excepti
}
}

private boolean acceptHeaderMatches(RequestMapper.RequestMatch<RuntimeResource> target, String accepts) {
if ((accepts != null) && !accepts.equals(MediaType.WILDCARD)) {
int commaIndex = accepts.indexOf(',');
boolean multipleAcceptsValues = commaIndex >= 0;
MediaType[] producesMediaTypes = target.value.getProduces().getSortedOriginalMediaTypes();
if (!multipleAcceptsValues && (producesMediaTypes.length == 1)) {
// the point of this branch is to eliminate any list creation or string indexing as none is needed
MediaType acceptsMediaType = MediaType.valueOf(accepts.trim());
MediaType providedMediaType = producesMediaTypes[0];
return providedMediaType.isCompatible(acceptsMediaType);
} else if (multipleAcceptsValues && (producesMediaTypes.length == 1)) {
// this is fairly common case, so we want it to be as fast as possible
// we do that by manually splitting the accepts header and immediately checking
// if the value is compatible with the produces media type
boolean compatible = false;
int begin = 0;

do {
String acceptPart;
if (commaIndex == -1) { // this is the case where we are checking the remainder of the string
acceptPart = accepts.substring(begin);
} else {
acceptPart = accepts.substring(begin, commaIndex);
}
if (producesMediaTypes[0].isCompatible(toMediaType(acceptPart.trim()))) {
compatible = true;
break;
} else if (commaIndex == -1) { // we have reached the end and not found any compatible media types
break;
}
begin = commaIndex + 1; // the next part will start at the character after the comma
if (begin >= (accepts.length() - 1)) { // if we have reached this point, then are no compatible media types
break;
}
commaIndex = accepts.indexOf(',', begin);
} while (true);

return compatible;
} else {
// don't use any of the JAX-RS stuff from the various MediaType helper as we want to be as performant as possible
List<MediaType> acceptsMediaTypes;
if (accepts.contains(",")) {
String[] parts = accepts.split(",");
acceptsMediaTypes = new ArrayList<>(parts.length);
for (int i = 0; i < parts.length; i++) {
String part = parts[i];
acceptsMediaTypes.add(toMediaType(part.trim()));
}
} else {
acceptsMediaTypes = Collections.singletonList(toMediaType(accepts));
}
return MediaTypeHelper.getFirstMatch(Arrays.asList(producesMediaTypes),
acceptsMediaTypes) != null;
}
}

return true;
}

private MediaType toMediaType(String mediaTypeStr) {
return MediaTypeHeaderDelegate.parse(mediaTypeStr);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.jboss.resteasy.reactive.server.handlers;

import java.util.List;
import java.util.Locale;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.HttpHeaders;
Expand Down Expand Up @@ -31,26 +32,35 @@ public FixedProducesHandler(MediaType mediaType, EntityWriter writer) {

@Override
public void handle(ResteasyReactiveRequestContext requestContext) throws Exception {
String accept = requestContext.serverRequest().getRequestHeader(HttpHeaders.ACCEPT);
if (accept == null) {
List<String> acceptValues = requestContext.serverRequest().getAllRequestHeaders(HttpHeaders.ACCEPT);
if (acceptValues.isEmpty()) {
requestContext.setResponseContentType(mediaType);
requestContext.setEntityWriter(writer);
} else {
//TODO: this needs to be optimised
if (accept.contains(mediaTypeString) || accept.contains("*/*") || accept.contains(mediaTypeSubstring)) {
requestContext.setResponseContentType(mediaType);
requestContext.setEntityWriter(writer);
} else {
// some clients might be sending the header with incorrect casing...
String lowercaseAccept = accept.toLowerCase(Locale.ROOT);
if (lowercaseAccept.contains(mediaTypeString) || lowercaseAccept.contains(mediaTypeSubstring)) {
boolean handled = false;
for (int i = 0; i < acceptValues.size(); i++) {
String accept = acceptValues.get(i);
//TODO: this needs to be optimised
if (accept.contains(mediaTypeString) || accept.contains("*/*") || accept.contains(mediaTypeSubstring)) {
requestContext.setResponseContentType(mediaType);
requestContext.setEntityWriter(writer);
handled = true;
break;
} else {
throw new WebApplicationException(
Response.notAcceptable(Variant.mediaTypes(mediaType.getMediaType()).build()).build());
// some clients might be sending the header with incorrect casing...
String lowercaseAccept = accept.toLowerCase(Locale.ROOT);
if (lowercaseAccept.contains(mediaTypeString) || lowercaseAccept.contains(mediaTypeSubstring)) {
requestContext.setResponseContentType(mediaType);
requestContext.setEntityWriter(writer);
handled = true;
break;
}
}
}
if (!handled) {
throw new WebApplicationException(
Response.notAcceptable(Variant.mediaTypes(mediaType.getMediaType()).build()).build());
}
}
}
}
5 changes: 5 additions & 0 deletions independent-projects/resteasy-reactive/server/vertx/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@
<artifactId>jakarta.validation-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web-client</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.jboss.logging</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,14 @@

public class ResteasyReactiveUnitTest implements BeforeAllCallback, AfterAllCallback {

public static final int SERVER_PORT = 8080;

public static final DotName HTTP_SERVER_REQUEST = DotName.createSimple(HttpServerRequest.class.getName());
public static final DotName HTTP_SERVER_RESPONSE = DotName.createSimple(HttpServerResponse.class.getName());
public static final DotName ROUTING_CONTEXT = DotName.createSimple(RoutingContext.class.getName());
private static final Logger rootLogger;
public static final String EXECUTOR_THREAD_NAME = "blocking executor thread";

private Handler[] originalHandlers;

static {
Expand Down Expand Up @@ -224,12 +227,13 @@ public void init(Vertx vertx, Context context) {
@Override
public void start(Promise<Void> startPromise) throws Exception {
server = vertx.createHttpServer();
server.requestHandler(router).listen(8080).onComplete(new io.vertx.core.Handler<AsyncResult<HttpServer>>() {
@Override
public void handle(AsyncResult<HttpServer> event) {
startPromise.complete();
}
});
server.requestHandler(router).listen(SERVER_PORT)
.onComplete(new io.vertx.core.Handler<AsyncResult<HttpServer>>() {
@Override
public void handle(AsyncResult<HttpServer> event) {
startPromise.complete();
}
});
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package org.jboss.resteasy.reactive.server.vertx.test.simple;

import static org.assertj.core.api.Assertions.assertThat;

import io.vertx.core.Vertx;
import io.vertx.ext.web.client.WebClient;
import java.util.List;
import java.util.concurrent.TimeUnit;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import org.jboss.resteasy.reactive.server.vertx.test.framework.ResteasyReactiveUnitTest;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.spec.JavaArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

public class MultipleAcceptHeadersTest {

private static final String BODY = "{\"message\": \"hello world\"}";

@RegisterExtension
static ResteasyReactiveUnitTest test = new ResteasyReactiveUnitTest()
.setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)
.addClasses(HelloResource.class));

@Test
public void matchingHeaderIsFirst() throws Exception {
// the WebClient is used because RestAssured can't seem to send the 'Accept' header multiple times...
WebClient client = WebClient.create(Vertx.vertx());

var response = client.get(ResteasyReactiveUnitTest.SERVER_PORT, "localhost", "/hello")
.putHeader("Accept", List.of("application/xml", "application/json")).send().toCompletionStage()
.toCompletableFuture().get(10, TimeUnit.SECONDS);
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.bodyAsString()).isEqualTo(BODY);
}

@Test
public void matchingHeaderIsLast() throws Exception {
WebClient client = WebClient.create(Vertx.vertx());

var response = client.get(ResteasyReactiveUnitTest.SERVER_PORT, "localhost", "/hello")
.putHeader("Accept", List.of("application/json", "application/xml")).send().toCompletionStage()
.toCompletableFuture().get(10, TimeUnit.SECONDS);
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.bodyAsString()).isEqualTo(BODY);
}

@Path("/hello")
public static class HelloResource {

@GET
@Produces("application/json")
public String hello() {
return BODY;
}
}
}

0 comments on commit 3f1a599

Please sign in to comment.