Skip to content

Commit

Permalink
Support "Accept-Patch" for OPTIONS requests
Browse files Browse the repository at this point in the history
This commit introduces support in both servlet and webflux for the
"Accept-Patch" header in OPTIONS requests, as defined in section 3.1 of
RFC 5789.

See spring-projectsgh-26759
  • Loading branch information
poutsma authored and lxbzmy committed Mar 26, 2022
1 parent 081a4be commit 1d1bd45
Show file tree
Hide file tree
Showing 5 changed files with 124 additions and 22 deletions.
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* Copyright 2002-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -99,6 +99,12 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
* @see <a href="https://tools.ietf.org/html/rfc7231#section-5.3.5">Section 5.3.5 of RFC 7231</a>
*/
public static final String ACCEPT_LANGUAGE = "Accept-Language";
/**
* The HTTP {@code Accept-Patch} header field name.
* @since 5.3.6
* @see <a href="https://tools.ietf.org/html/rfc5789#section-3.1">Section 3.1 of RFC 5789</a>
*/
public static final String ACCEPT_PATCH = "Accept-Patch";
/**
* The HTTP {@code Accept-Ranges} header field name.
* @see <a href="https://tools.ietf.org/html/rfc7233#section-2.3">Section 5.3.5 of RFC 7233</a>
Expand Down Expand Up @@ -525,6 +531,25 @@ public List<Locale> getAcceptLanguageAsLocales() {
.collect(Collectors.toList());
}

/**
* Set the list of acceptable {@linkplain MediaType media types} for
* {@code PATCH} methods, as specified by the {@code Accept-Patch} header.
* @since 5.3.6
*/
public void setAcceptPatch(List<MediaType> mediaTypes) {
set(ACCEPT_PATCH, MediaType.toString(mediaTypes));
}

/**
* Return the list of acceptable {@linkplain MediaType media types} for
* {@code PATCH} methods, as specified by the {@code Accept-Patch} header.
* <p>Returns an empty list when the acceptable media types are unspecified.
* @since 5.3.6
*/
public List<MediaType> getAcceptPatch() {
return MediaType.parseMediaTypes(get(ACCEPT_PATCH));
}

/**
* Set the (new) value of the {@code Access-Control-Allow-Credentials} response header.
*/
Expand Down
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2019 the original author or authors.
* Copyright 2002-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -37,6 +37,7 @@
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.Assert;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.result.condition.NameValueExpression;
Expand Down Expand Up @@ -173,7 +174,8 @@ protected HandlerMethod handleNoMatch(Set<RequestMappingInfo> infos,
String httpMethod = request.getMethodValue();
Set<HttpMethod> methods = helper.getAllowedMethods();
if (HttpMethod.OPTIONS.matches(httpMethod)) {
HttpOptionsHandler handler = new HttpOptionsHandler(methods);
Set<MediaType> mediaTypes = helper.getConsumablePatchMediaTypes();
HttpOptionsHandler handler = new HttpOptionsHandler(methods, mediaTypes);
return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD);
}
throw new MethodNotAllowedException(httpMethod, methods);
Expand Down Expand Up @@ -301,6 +303,22 @@ public List<Set<NameValueExpression<String>>> getParamConditions() {
collect(Collectors.toList());
}

/**
* Return declared "consumable" types but only among those that have
* PATCH specified, or that have no methods at all.
*/
public Set<MediaType> getConsumablePatchMediaTypes() {
Set<MediaType> result = new LinkedHashSet<>();
for (PartialMatch match : this.partialMatches) {
Set<RequestMethod> methods = match.getInfo().getMethodsCondition().getMethods();
if (methods.isEmpty() || methods.contains(RequestMethod.PATCH)) {
result.addAll(match.getInfo().getConsumesCondition().getConsumableMediaTypes());
}
}
return result;
}



/**
* Container for a RequestMappingInfo that matches the URL path at least.
Expand Down Expand Up @@ -367,8 +385,9 @@ private static class HttpOptionsHandler {
private final HttpHeaders headers = new HttpHeaders();


public HttpOptionsHandler(Set<HttpMethod> declaredMethods) {
public HttpOptionsHandler(Set<HttpMethod> declaredMethods, Set<MediaType> acceptPatch) {
this.headers.setAllow(initAllowedHttpMethods(declaredMethods));
this.headers.setAcceptPatch(new ArrayList<>(acceptPatch));
}

private static Set<HttpMethod> initAllowedHttpMethods(Set<HttpMethod> declaredMethods) {
Expand Down
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2019 the original author or authors.
* Copyright 2002-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -37,13 +37,15 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Controller;
import org.springframework.util.ClassUtils;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.reactive.BindingContext;
import org.springframework.web.reactive.HandlerMapping;
Expand Down Expand Up @@ -192,10 +194,12 @@ public void getHandlerHttpOptions() {
List<HttpMethod> allMethodExceptTrace = new ArrayList<>(Arrays.asList(HttpMethod.values()));
allMethodExceptTrace.remove(HttpMethod.TRACE);

testHttpOptions("/foo", EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS));
testHttpOptions("/person/1", EnumSet.of(HttpMethod.PUT, HttpMethod.OPTIONS));
testHttpOptions("/persons", EnumSet.copyOf(allMethodExceptTrace));
testHttpOptions("/something", EnumSet.of(HttpMethod.PUT, HttpMethod.POST));
testHttpOptions("/foo", EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS), null);
testHttpOptions("/person/1", EnumSet.of(HttpMethod.PUT, HttpMethod.OPTIONS), null);
testHttpOptions("/persons", EnumSet.copyOf(allMethodExceptTrace), null);
testHttpOptions("/something", EnumSet.of(HttpMethod.PUT, HttpMethod.POST), null);
testHttpOptions("/qux", EnumSet.of(HttpMethod.PATCH,HttpMethod.GET,HttpMethod.HEAD,HttpMethod.OPTIONS),
new MediaType("foo", "bar"));
}

@Test
Expand Down Expand Up @@ -332,7 +336,7 @@ private void testHttpMediaTypeNotSupportedException(String url) {
assertError(mono, UnsupportedMediaTypeStatusException.class, ex -> assertThat(ex.getSupportedMediaTypes()).as("Invalid supported consumable media types").isEqualTo(Collections.singletonList(new MediaType("application", "xml"))));
}

private void testHttpOptions(String requestURI, Set<HttpMethod> allowedMethods) {
private void testHttpOptions(String requestURI, Set<HttpMethod> allowedMethods, @Nullable MediaType acceptPatch) {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.options(requestURI));
HandlerMethod handlerMethod = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();

Expand All @@ -346,7 +350,13 @@ private void testHttpOptions(String requestURI, Set<HttpMethod> allowedMethods)
Object value = result.getReturnValue();
assertThat(value).isNotNull();
assertThat(value.getClass()).isEqualTo(HttpHeaders.class);
assertThat(((HttpHeaders) value).getAllow()).isEqualTo(allowedMethods);

HttpHeaders headers = (HttpHeaders) value;
assertThat(headers.getAllow()).hasSameElementsAs(allowedMethods);

if (acceptPatch != null && headers.getAllow().contains(HttpMethod.PATCH) ) {
assertThat(headers.getAcceptPatch()).containsExactly(acceptPatch);
}
}

private void testMediaTypeNotAcceptable(String url) {
Expand Down Expand Up @@ -430,6 +440,16 @@ public HttpHeaders fooOptions() {
return headers;
}

@RequestMapping(value = "/qux", method = RequestMethod.GET, produces = "application/xml")
public String getBaz() {
return "";
}

@RequestMapping(value = "/qux", method = RequestMethod.PATCH, consumes = "foo/bar")
public void patchBaz(String value) {
}


public void dummy() { }
}

Expand Down
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* Copyright 2002-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -244,7 +244,8 @@ protected HandlerMethod handleNoMatch(
if (helper.hasMethodsMismatch()) {
Set<String> methods = helper.getAllowedMethods();
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
HttpOptionsHandler handler = new HttpOptionsHandler(methods);
Set<MediaType> mediaTypes = helper.getConsumablePatchMediaTypes();
HttpOptionsHandler handler = new HttpOptionsHandler(methods, mediaTypes);
return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD);
}
throw new HttpRequestMethodNotSupportedException(request.getMethod(), methods);
Expand Down Expand Up @@ -411,6 +412,21 @@ public List<String[]> getParamConditions() {
return result;
}

/**
* Return declared "consumable" types but only among those that have
* PATCH specified, or that have no methods at all.
*/
public Set<MediaType> getConsumablePatchMediaTypes() {
Set<MediaType> result = new LinkedHashSet<>();
for (PartialMatch match : this.partialMatches) {
Set<RequestMethod> methods = match.getInfo().getMethodsCondition().getMethods();
if (methods.isEmpty() || methods.contains(RequestMethod.PATCH)) {
result.addAll(match.getInfo().getConsumesCondition().getConsumableMediaTypes());
}
}
return result;
}


/**
* Container for a RequestMappingInfo that matches the URL path at least.
Expand Down Expand Up @@ -475,8 +491,9 @@ private static class HttpOptionsHandler {

private final HttpHeaders headers = new HttpHeaders();

public HttpOptionsHandler(Set<String> declaredMethods) {
public HttpOptionsHandler(Set<String> declaredMethods, Set<MediaType> acceptPatch) {
this.headers.setAllow(initAllowedHttpMethods(declaredMethods));
this.headers.setAcceptPatch(new ArrayList<>(acceptPatch));
}

private static Set<HttpMethod> initAllowedHttpMethods(Set<String> declaredMethods) {
Expand Down
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* Copyright 2002-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -22,6 +22,7 @@
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.servlet.http.HttpServletRequest;
Expand All @@ -31,8 +32,10 @@

import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.server.RequestPath;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Controller;
import org.springframework.util.MultiValueMap;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
Expand Down Expand Up @@ -177,10 +180,11 @@ void getHandlerMediaTypeNotSupported(TestRequestMappingInfoHandlerMapping mappin

@PathPatternsParameterizedTest
void getHandlerHttpOptions(TestRequestMappingInfoHandlerMapping mapping) throws Exception {
testHttpOptions(mapping, "/foo", "GET,HEAD,OPTIONS");
testHttpOptions(mapping, "/person/1", "PUT,OPTIONS");
testHttpOptions(mapping, "/persons", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS");
testHttpOptions(mapping, "/something", "PUT,POST");
testHttpOptions(mapping, "/foo", "GET,HEAD,OPTIONS", null);
testHttpOptions(mapping, "/person/1", "PUT,OPTIONS", null);
testHttpOptions(mapping, "/persons", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS", null);
testHttpOptions(mapping, "/something", "PUT,POST", null);
testHttpOptions(mapping, "/qux", "PATCH,GET,HEAD,OPTIONS", new MediaType("foo", "bar"));
}

@PathPatternsParameterizedTest
Expand Down Expand Up @@ -401,8 +405,8 @@ private void testHttpMediaTypeNotSupportedException(TestRequestMappingInfoHandle
.satisfies(ex -> assertThat(ex.getSupportedMediaTypes()).containsExactly(MediaType.APPLICATION_XML));
}

private void testHttpOptions(
TestRequestMappingInfoHandlerMapping mapping, String requestURI, String allowHeader) throws Exception {
private void testHttpOptions(TestRequestMappingInfoHandlerMapping mapping, String requestURI,
String allowHeader, @Nullable MediaType acceptPatch) throws Exception {

MockHttpServletRequest request = new MockHttpServletRequest("OPTIONS", requestURI);
HandlerMethod handlerMethod = getHandler(mapping, request);
Expand All @@ -413,7 +417,15 @@ private void testHttpOptions(

assertThat(result).isNotNull();
assertThat(result.getClass()).isEqualTo(HttpHeaders.class);
assertThat(((HttpHeaders) result).getFirst("Allow")).isEqualTo(allowHeader);
HttpHeaders headers = (HttpHeaders) result;
Set<HttpMethod> allowedMethods = Arrays.stream(allowHeader.split(","))
.map(HttpMethod::valueOf)
.collect(Collectors.toSet());
assertThat(headers.getAllow()).hasSameElementsAs(allowedMethods);

if (acceptPatch != null && headers.getAllow().contains(HttpMethod.PATCH) ) {
assertThat(headers.getAcceptPatch()).containsExactly(acceptPatch);
}
}

private void testHttpMediaTypeNotAcceptableException(TestRequestMappingInfoHandlerMapping mapping, String url) {
Expand Down Expand Up @@ -502,6 +514,15 @@ public HttpHeaders fooOptions() {
headers.add("Allow", "PUT,POST");
return headers;
}

@RequestMapping(value = "/qux", method = RequestMethod.GET, produces = "application/xml")
public String getBaz() {
return "";
}

@RequestMapping(value = "/qux", method = RequestMethod.PATCH, consumes = "foo/bar")
public void patchBaz(String value) {
}
}


Expand Down

0 comments on commit 1d1bd45

Please sign in to comment.