parent
0eec1dc636
commit
492e51f3ba
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright 2002-present 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.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package org.springframework.web.accept;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* Contract to add handling of requests with a deprecated API version. Typically,
|
||||
* this involves use of response headers to send hints and information about
|
||||
* the deprecation to clients.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 7.0
|
||||
* @see StandardApiDeprecationHandler
|
||||
*/
|
||||
public interface ApiDeprecationHandler {
|
||||
|
||||
/**
|
||||
* Check if the requested API version is deprecated, and if so handle it
|
||||
* accordingly, e.g. by setting response headers to signal the deprecation,
|
||||
* to specify relevant dates and provide links to further details.
|
||||
* @param version the resolved and parsed request version
|
||||
* @param request the current request
|
||||
* @param response the current response
|
||||
*/
|
||||
void handleVersion(Comparable<?> version, HttpServletRequest request, HttpServletResponse response);
|
||||
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
package org.springframework.web.accept;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
|
@ -25,6 +26,7 @@ import org.jspecify.annotations.Nullable;
|
|||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 7.0
|
||||
* @see DefaultApiVersionStrategy
|
||||
*/
|
||||
public interface ApiVersionStrategy {
|
||||
|
||||
|
@ -32,6 +34,7 @@ public interface ApiVersionStrategy {
|
|||
* Resolve the version value from a request, e.g. from a request header.
|
||||
* @param request the current request
|
||||
* @return the version, if present or {@code null}
|
||||
* @see ApiVersionResolver
|
||||
*/
|
||||
@Nullable
|
||||
String resolveVersion(HttpServletRequest request);
|
||||
|
@ -40,6 +43,7 @@ public interface ApiVersionStrategy {
|
|||
* Parse the version of a request into an Object.
|
||||
* @param version the value to parse
|
||||
* @return an Object that represents the version
|
||||
* @see ApiVersionParser
|
||||
*/
|
||||
Comparable<?> parseVersion(String version);
|
||||
|
||||
|
@ -58,4 +62,15 @@ public interface ApiVersionStrategy {
|
|||
*/
|
||||
@Nullable Comparable<?> getDefaultVersion();
|
||||
|
||||
/**
|
||||
* Check if the requested API version is deprecated, and if so handle it
|
||||
* accordingly, e.g. by setting response headers to signal the deprecation,
|
||||
* to specify relevant dates and provide links to further details.
|
||||
* @param version the resolved and parsed request version
|
||||
* @param request the current request
|
||||
* @param response the current response
|
||||
* @see ApiDeprecationHandler
|
||||
*/
|
||||
void handleDeprecations(Comparable<?> version, HttpServletRequest request, HttpServletResponse response);
|
||||
|
||||
}
|
||||
|
|
|
@ -22,13 +22,14 @@ import java.util.Set;
|
|||
import java.util.TreeSet;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* Default implementation of {@link ApiVersionStrategy} that delegates to the
|
||||
* configured version resolvers and version parser.
|
||||
* configured version resolvers, version parser, and deprecation handler.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 7.0
|
||||
|
@ -43,6 +44,8 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
|
|||
|
||||
private final @Nullable Comparable<?> defaultVersion;
|
||||
|
||||
private final @Nullable ApiDeprecationHandler deprecationHandler;
|
||||
|
||||
private final Set<Comparable<?>> supportedVersions = new TreeSet<>();
|
||||
|
||||
|
||||
|
@ -56,10 +59,13 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
|
|||
* validation fails with {@link MissingApiVersionException}
|
||||
* @param defaultVersion a default version to assign to requests that
|
||||
* don't specify one
|
||||
* @param deprecationHandler handler to send hints and information about
|
||||
* deprecated API versions to clients
|
||||
*/
|
||||
public DefaultApiVersionStrategy(
|
||||
List<ApiVersionResolver> versionResolvers, ApiVersionParser<?> versionParser,
|
||||
boolean versionRequired, @Nullable String defaultVersion) {
|
||||
boolean versionRequired, @Nullable String defaultVersion,
|
||||
@Nullable ApiDeprecationHandler deprecationHandler) {
|
||||
|
||||
Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required");
|
||||
Assert.notNull(versionParser, "ApiVersionParser is required");
|
||||
|
@ -68,6 +74,7 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
|
|||
this.versionParser = versionParser;
|
||||
this.versionRequired = (versionRequired && defaultVersion == null);
|
||||
this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null);
|
||||
this.deprecationHandler = deprecationHandler;
|
||||
}
|
||||
|
||||
|
||||
|
@ -120,6 +127,13 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleDeprecations(Comparable<?> version, HttpServletRequest request, HttpServletResponse response) {
|
||||
if (this.deprecationHandler != null) {
|
||||
this.deprecationHandler.handleVersion(version, request, response);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DefaultApiVersionStrategy[supportedVersions=" + this.supportedVersions +
|
||||
|
|
|
@ -0,0 +1,255 @@
|
|||
/*
|
||||
* Copyright 2002-present 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.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package org.springframework.web.accept;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* {@code ApiDeprecationHandler} based on
|
||||
* <a href="https://datatracker.ietf.org/doc/html/rfc9745">RFC 9745</a> and
|
||||
* <a href="https://datatracker.ietf.org/doc/html/rfc8594">RFC 8594</a> that
|
||||
* provides the option to set the "Deprecation" and "Sunset" response headers,
|
||||
* as well as to add "Link" headers with further details about both.
|
||||
* <p>To use this handler, create an instance, call {@link #configureVersion}
|
||||
* for each deprecated version, and use the returned {@link VersionSpec} to
|
||||
* provide the deprecation details to send to clients.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 7.0
|
||||
*/
|
||||
public class StandardApiDeprecationHandler implements ApiDeprecationHandler {
|
||||
|
||||
private final ApiVersionParser<?> versionParser;
|
||||
|
||||
private final Map<Comparable<?>, VersionInfo> infos = new HashMap<>();
|
||||
|
||||
|
||||
/**
|
||||
* Create an instance.
|
||||
* <p>By default, {@link SemanticApiVersionParser} is used to parse configured
|
||||
* API versions, so those can be compared to request versions parsed at runtime.
|
||||
* If you have a custom parser, then please use the
|
||||
* {@link #StandardApiDeprecationHandler(ApiVersionParser)} constructor.
|
||||
*/
|
||||
public StandardApiDeprecationHandler() {
|
||||
this(new SemanticApiVersionParser());
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of the default constructor with a custom {@link ApiVersionParser}.
|
||||
* This needs to be the same as the parser type used at runtime to parse
|
||||
* request versions.
|
||||
*/
|
||||
public StandardApiDeprecationHandler(ApiVersionParser<?> parser) {
|
||||
this.versionParser = parser;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mark the given API version as deprecated, and use the returned
|
||||
* {@link VersionSpec} to configure the deprecation details to send to clients.
|
||||
* @param version the version to mark as deprecated
|
||||
* @return a spec to configure deprecation details
|
||||
*/
|
||||
public VersionSpec configureVersion(String version) {
|
||||
Comparable<?> parsedVersion = this.versionParser.parseVersion(version);
|
||||
return new VersionSpec(parsedVersion);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void handleVersion(
|
||||
Comparable<?> requestVersion, HttpServletRequest request, HttpServletResponse response) {
|
||||
|
||||
for (VersionInfo info : this.infos.values()) {
|
||||
if (info.match(requestVersion, request)) {
|
||||
if (info.deprecationDate() != null) {
|
||||
response.setHeader("Deprecation", info.deprecationDate());
|
||||
}
|
||||
if (info.deprecationLink() != null) {
|
||||
response.addHeader(HttpHeaders.LINK, info.deprecationLink());
|
||||
}
|
||||
if (info.sunsetDate() != null) {
|
||||
response.setHeader("Sunset", info.sunsetDate());
|
||||
}
|
||||
if (info.sunsetLink() != null) {
|
||||
response.addHeader(HttpHeaders.LINK, info.sunsetLink());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "StandardApiDeprecationHandler " + this.infos.values();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A spec to configure deprecation details for an API version.
|
||||
*/
|
||||
public final class VersionSpec {
|
||||
|
||||
private final Comparable<?> version;
|
||||
|
||||
private VersionSpec(Comparable<?> version) {
|
||||
this.version = version;
|
||||
StandardApiDeprecationHandler.this.infos.put(version, new VersionInfo(version));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a predicate to filter which requests to send deprecation info to.
|
||||
* <p>By default, all requests with the deprecated version are handled.
|
||||
* This predicate to narrow the requests that should expose deprecation
|
||||
* information.
|
||||
* @param predicate a predicate to check the request with
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setRequestPredicate(Predicate<HttpServletRequest> predicate) {
|
||||
return map(info -> info.withRequestPredicate(predicate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify a deprecation date for the "Deprecation" response header.
|
||||
* @param date the deprecation date
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setDeprecationDate(ZonedDateTime date) {
|
||||
return map(info -> info.withDeprecationDate(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify a URL for the "Link" response header with
|
||||
* {@code rel="deprecation"} and {@code type="text/html"}.
|
||||
* @param uri the link value
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setDeprecationLink(URI uri) {
|
||||
return setDeprecationLink(uri, MediaType.TEXT_HTML);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variation of {@link #setDeprecationLink(URI)} for use with a media type
|
||||
* other than "text/html".
|
||||
* @param uri the link value
|
||||
* @param mediaType the media type to use
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setDeprecationLink(URI uri, MediaType mediaType) {
|
||||
return map(info -> info.withDeprecationLink(uri, mediaType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify a deprecation date for the "Sunset" response header.
|
||||
* @param date the sunset date
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setSunsetDate(ZonedDateTime date) {
|
||||
return map(info -> info.withSunsetDate(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify a URL for the "Link" response header with
|
||||
* {@code rel="sunset"} and {@code type="text/html"}.
|
||||
* @param uri the link value
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setSunsetLink(URI uri) {
|
||||
return setSunsetLink(uri, MediaType.TEXT_HTML);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variation of {@link #setSunsetLink(URI)} for use with a media type
|
||||
* other than "text/html".
|
||||
* @param uri the link value
|
||||
* @param mediaType the media type to use
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setSunsetLink(URI uri, MediaType mediaType) {
|
||||
return map(info -> info.withSunsetLink(uri, mediaType));
|
||||
}
|
||||
|
||||
private VersionSpec map(Function<VersionInfo, VersionInfo> function) {
|
||||
StandardApiDeprecationHandler.this.infos.compute(this.version, (version, versionInfo) -> {
|
||||
Assert.state(versionInfo != null, "No VersionInfo");
|
||||
return function.apply(versionInfo);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private record VersionInfo(
|
||||
Comparable<?> version,
|
||||
Predicate<HttpServletRequest> requestPredicate,
|
||||
@Nullable String deprecationDate, @Nullable String deprecationLink,
|
||||
@Nullable String sunsetDate, @Nullable String sunsetLink) {
|
||||
|
||||
VersionInfo(Comparable<?> version) {
|
||||
this(version, request -> true, null, null, null, null);
|
||||
}
|
||||
|
||||
public VersionInfo withRequestPredicate(Predicate<HttpServletRequest> predicate) {
|
||||
return new VersionInfo(version(), predicate,
|
||||
deprecationDate(), deprecationLink(), sunsetDate(), sunsetLink());
|
||||
}
|
||||
|
||||
public VersionInfo withDeprecationDate(ZonedDateTime deprecationDate) {
|
||||
return new VersionInfo(version(), requestPredicate(),
|
||||
"@" + deprecationDate.toInstant().getEpochSecond(), deprecationLink(),
|
||||
sunsetDate(), sunsetLink());
|
||||
}
|
||||
|
||||
public VersionInfo withDeprecationLink(URI uri, MediaType mediaType) {
|
||||
return new VersionInfo(version(), requestPredicate(),
|
||||
deprecationDate(), String.format("<%s>; rel=\"deprecation\"; type=\"%s\"", uri, mediaType),
|
||||
sunsetDate(), sunsetLink());
|
||||
}
|
||||
|
||||
public VersionInfo withSunsetDate(ZonedDateTime sunsetDate) {
|
||||
return new VersionInfo(version(), requestPredicate(),
|
||||
deprecationDate(), deprecationLink(),
|
||||
sunsetDate.format(DateTimeFormatter.RFC_1123_DATE_TIME), sunsetLink());
|
||||
}
|
||||
|
||||
public VersionInfo withSunsetLink(URI uri, MediaType mediaType) {
|
||||
return new VersionInfo(version(), requestPredicate(),
|
||||
deprecationDate(), deprecationLink(),
|
||||
sunsetDate(), String.format("<%s>; rel=\"sunset\"; type=\"%s\"", uri, mediaType));
|
||||
}
|
||||
|
||||
boolean match(Comparable<?> requestVersion, HttpServletRequest request) {
|
||||
return (version().equals(requestVersion) && requestPredicate().test(request));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -64,7 +64,7 @@ public class DefaultApiVersionStrategiesTests {
|
|||
private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) {
|
||||
return new DefaultApiVersionStrategy(
|
||||
List.of(request -> request.getParameter("api-version")),
|
||||
new SemanticApiVersionParser(), true, defaultValue);
|
||||
new SemanticApiVersionParser(), true, defaultValue, null);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Copyright 2002-present 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.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package org.springframework.web.accept;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
|
||||
import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link StandardApiDeprecationHandler}.
|
||||
* @author Rossen Stoyanchev
|
||||
*/
|
||||
public class StandardApiDeprecationHandlerTests {
|
||||
|
||||
private final MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
|
||||
private final MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
|
||||
@Test
|
||||
void basic() {
|
||||
String deprecationUrl = "https://example.org/deprecation";
|
||||
String sunsetDate = "Wed, 11 Nov 2026 11:11:11 GMT";
|
||||
String sunsetUrl = "https://example.org/sunset";
|
||||
|
||||
ApiVersionParser<String> parser = version -> version;
|
||||
StandardApiDeprecationHandler handler = new StandardApiDeprecationHandler(parser);
|
||||
|
||||
handler.configureVersion("1.1")
|
||||
.setDeprecationDate(getDate("Fri, 30 Jun 2023 23:59:59 GMT"))
|
||||
.setDeprecationLink(URI.create(deprecationUrl))
|
||||
.setSunsetDate(getDate(sunsetDate))
|
||||
.setSunsetLink(URI.create(sunsetUrl));
|
||||
|
||||
handler.handleVersion("1.1", request, response);
|
||||
|
||||
assertThat(response.getHeader("Deprecation")).isEqualTo("@1688169599");
|
||||
assertThat(response.getHeader("Sunset")).isEqualTo(sunsetDate);
|
||||
assertThat(response.getHeaders("Link")).containsExactlyInAnyOrder(
|
||||
"<" + deprecationUrl + ">; rel=\"deprecation\"; type=\"text/html\"",
|
||||
"<" + sunsetUrl + ">; rel=\"sunset\"; type=\"text/html\""
|
||||
);
|
||||
}
|
||||
|
||||
private static ZonedDateTime getDate(String date) {
|
||||
return ZonedDateTime.parse(date, DateTimeFormatter.RFC_1123_DATE_TIME);
|
||||
}
|
||||
|
||||
}
|
|
@ -83,6 +83,13 @@ public interface HandlerMapping {
|
|||
*/
|
||||
String PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE = HandlerMapping.class.getName() + ".producibleMediaTypes";
|
||||
|
||||
/**
|
||||
* Name of the {@link ServerWebExchange#getAttributes() attribute} containing
|
||||
* the resolved and parsed API version.
|
||||
* @since 7.0
|
||||
*/
|
||||
String API_VERSION_ATTRIBUTE = HandlerMapping.class.getName() + ".apiVersion";
|
||||
|
||||
|
||||
/**
|
||||
* Return a handler for this request.
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright 2002-present 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.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package org.springframework.web.reactive.accept;
|
||||
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
/**
|
||||
* Contract to add handling of requests with a deprecated API version. Typically,
|
||||
* this involves use of response headers to send hints and information about
|
||||
* the deprecation to clients.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 7.0
|
||||
*/
|
||||
public interface ApiDeprecationHandler {
|
||||
|
||||
/**
|
||||
* Check if the requested API version is deprecated, and if so handle it
|
||||
* accordingly, e.g. by setting response headers to signal the deprecation,
|
||||
* to specify relevant dates and provide links to further details.
|
||||
* @param version the resolved and parsed request version
|
||||
* @param exchange the current exchange
|
||||
*/
|
||||
void handleVersion(Comparable<?> version, ServerWebExchange exchange);
|
||||
|
||||
}
|
|
@ -28,6 +28,7 @@ import org.springframework.web.server.ServerWebExchange;
|
|||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 7.0
|
||||
* @see DefaultApiVersionStrategy
|
||||
*/
|
||||
public interface ApiVersionStrategy {
|
||||
|
||||
|
@ -35,6 +36,7 @@ public interface ApiVersionStrategy {
|
|||
* Resolve the version value from a request, e.g. from a request header.
|
||||
* @param exchange the current exchange
|
||||
* @return the version, if present or {@code null}
|
||||
* @see ApiVersionResolver
|
||||
*/
|
||||
@Nullable
|
||||
String resolveVersion(ServerWebExchange exchange);
|
||||
|
@ -43,6 +45,7 @@ public interface ApiVersionStrategy {
|
|||
* Parse the version of a request into an Object.
|
||||
* @param version the value to parse
|
||||
* @return an Object that represents the version
|
||||
* @see org.springframework.web.accept.ApiVersionParser
|
||||
*/
|
||||
Comparable<?> parseVersion(String version);
|
||||
|
||||
|
@ -61,4 +64,14 @@ public interface ApiVersionStrategy {
|
|||
*/
|
||||
@Nullable Comparable<?> getDefaultVersion();
|
||||
|
||||
/**
|
||||
* Check if the requested API version is deprecated, and if so handle it
|
||||
* accordingly, e.g. by setting response headers to signal the deprecation,
|
||||
* to specify relevant dates and provide links to further details.
|
||||
* @param version the resolved and parsed request version
|
||||
* @param exchange the current exchange
|
||||
* @see ApiDeprecationHandler
|
||||
*/
|
||||
void handleDeprecations(Comparable<?> version, ServerWebExchange exchange);
|
||||
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ import org.springframework.web.server.ServerWebExchange;
|
|||
|
||||
/**
|
||||
* Default implementation of {@link ApiVersionStrategy} that delegates to the
|
||||
* configured version resolvers and version parser.
|
||||
* configured version resolvers, version parser, and deprecation handler.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 7.0
|
||||
|
@ -52,6 +52,8 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
|
|||
|
||||
private final Set<Comparable<?>> detectedVersions = new TreeSet<>();
|
||||
|
||||
private final @Nullable ApiDeprecationHandler deprecationHandler;
|
||||
|
||||
|
||||
/**
|
||||
* Create an instance.
|
||||
|
@ -66,10 +68,13 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
|
|||
* @param detectSupportedVersions whether to use API versions that appear in
|
||||
* mappings for supported version validation (true), or use only explicitly
|
||||
* configured versions (false).
|
||||
* @param deprecationHandler handler to send hints and information about
|
||||
* deprecated API versions to clients
|
||||
*/
|
||||
public DefaultApiVersionStrategy(
|
||||
List<ApiVersionResolver> versionResolvers, ApiVersionParser<?> versionParser,
|
||||
boolean versionRequired, @Nullable String defaultVersion, boolean detectSupportedVersions) {
|
||||
boolean versionRequired, @Nullable String defaultVersion, boolean detectSupportedVersions,
|
||||
@Nullable ApiDeprecationHandler deprecationHandler) {
|
||||
|
||||
Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required");
|
||||
Assert.notNull(versionParser, "ApiVersionParser is required");
|
||||
|
@ -79,6 +84,7 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
|
|||
this.versionRequired = (versionRequired && defaultVersion == null);
|
||||
this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null);
|
||||
this.detectSupportedVersions = detectSupportedVersions;
|
||||
this.deprecationHandler = deprecationHandler;
|
||||
}
|
||||
|
||||
|
||||
|
@ -155,6 +161,13 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
|
|||
this.detectSupportedVersions && this.detectedVersions.contains(requestVersion));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleDeprecations(Comparable<?> version, ServerWebExchange exchange) {
|
||||
if (this.deprecationHandler != null) {
|
||||
this.deprecationHandler.handleVersion(version, exchange);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DefaultApiVersionStrategy[" +
|
||||
|
|
|
@ -0,0 +1,253 @@
|
|||
/*
|
||||
* Copyright 2002-present 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.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package org.springframework.web.reactive.accept;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.accept.ApiVersionParser;
|
||||
import org.springframework.web.accept.SemanticApiVersionParser;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
/**
|
||||
* {@code ApiDeprecationHandler} based on
|
||||
* <a href="https://datatracker.ietf.org/doc/html/rfc9745">RFC 9745</a> and
|
||||
* <a href="https://datatracker.ietf.org/doc/html/rfc8594">RFC 8594</a> that
|
||||
* provides the option to set the "Deprecation" and "Sunset" response headers,
|
||||
* as well as to add "Link" headers with further details about both.
|
||||
* <p>To use this handler, create an instance, call {@link #configureVersion}
|
||||
* for each deprecated version, and use the returned {@link VersionSpec} to
|
||||
* provide the deprecation details to send to clients.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 7.0
|
||||
*/
|
||||
public class StandardApiDeprecationHandler implements ApiDeprecationHandler {
|
||||
|
||||
private final ApiVersionParser<?> versionParser;
|
||||
|
||||
private final Map<Comparable<?>, VersionInfo> infos = new HashMap<>();
|
||||
|
||||
|
||||
/**
|
||||
* Create an instance.
|
||||
* <p>By default, {@link SemanticApiVersionParser} is used to parse configured
|
||||
* API versions, so those can be compared to request versions parsed at runtime.
|
||||
* If you have a custom parser, then please use the
|
||||
* {@link #StandardApiDeprecationHandler(ApiVersionParser)} constructor.
|
||||
*/
|
||||
public StandardApiDeprecationHandler() {
|
||||
this(new SemanticApiVersionParser());
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of the default constructor with a custom {@link ApiVersionParser}.
|
||||
* This needs to be the same as the parser type used at runtime to parse
|
||||
* request versions.
|
||||
*/
|
||||
public StandardApiDeprecationHandler(ApiVersionParser<?> parser) {
|
||||
this.versionParser = parser;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mark the given API version as deprecated, and use the returned
|
||||
* {@link VersionSpec} to configure the deprecation details to send to clients.
|
||||
* @param version the version to mark as deprecated
|
||||
* @return a spec to configure deprecation details
|
||||
*/
|
||||
public VersionSpec configureVersion(String version) {
|
||||
Comparable<?> parsedVersion = this.versionParser.parseVersion(version);
|
||||
return new VersionSpec(parsedVersion);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleVersion(Comparable<?> requestVersion, ServerWebExchange exchange) {
|
||||
for (VersionInfo info : this.infos.values()) {
|
||||
if (info.match(requestVersion, exchange)) {
|
||||
HttpHeaders headers = exchange.getResponse().getHeaders();
|
||||
if (info.deprecationDate() != null) {
|
||||
headers.set("Deprecation", info.deprecationDate());
|
||||
}
|
||||
if (info.deprecationLink() != null) {
|
||||
headers.add(HttpHeaders.LINK, info.deprecationLink());
|
||||
}
|
||||
if (info.sunsetDate() != null) {
|
||||
headers.set("Sunset", info.sunsetDate());
|
||||
}
|
||||
if (info.sunsetLink() != null) {
|
||||
headers.add(HttpHeaders.LINK, info.sunsetLink());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "StandardApiDeprecationHandler " + this.infos.values();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A spec to configure deprecation details for an API version.
|
||||
*/
|
||||
public final class VersionSpec {
|
||||
|
||||
private final Comparable<?> version;
|
||||
|
||||
private VersionSpec(Comparable<?> version) {
|
||||
this.version = version;
|
||||
StandardApiDeprecationHandler.this.infos.put(version, new VersionInfo(version));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a predicate to further filter the exchanges to handle.
|
||||
* <p>By default, all exchanges with the deprecated version are handled.
|
||||
* This predicate can help to further narrow down the exchanges that
|
||||
* should expose deprecation information.
|
||||
* @param predicate a predicate to check the request with
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setExchangePredicate(Predicate<ServerWebExchange> predicate) {
|
||||
return map(info -> info.withExchangePredicate(predicate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify a deprecation date for the "Deprecation" response header.
|
||||
* @param date the deprecation date
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setDeprecationDate(ZonedDateTime date) {
|
||||
return map(info -> info.withDeprecationDate(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify a URL for the "Link" response header with
|
||||
* {@code rel="deprecation"} and {@code type="text/html"}.
|
||||
* @param uri the link value
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setDeprecationLink(URI uri) {
|
||||
return setDeprecationLink(uri, MediaType.TEXT_HTML);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variation of {@link #setDeprecationLink(URI)} for use with a media type
|
||||
* other than "text/html".
|
||||
* @param uri the link value
|
||||
* @param mediaType the media type to use
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setDeprecationLink(URI uri, MediaType mediaType) {
|
||||
return map(info -> info.withDeprecationLink(uri, mediaType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify a deprecation date for the "Sunset" response header.
|
||||
* @param date the sunset date
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setSunsetDate(ZonedDateTime date) {
|
||||
return map(info -> info.withSunsetDate(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify a URL for the "Link" response header with
|
||||
* {@code rel="sunset"} and {@code type="text/html"}.
|
||||
* @param uri the link value
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setSunsetLink(URI uri) {
|
||||
return setSunsetLink(uri, MediaType.TEXT_HTML);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variation of {@link #setSunsetLink(URI)} for use with a media type
|
||||
* other than "text/html".
|
||||
* @param uri the link value
|
||||
* @param mediaType the media type to use
|
||||
* @return the same spec instance
|
||||
*/
|
||||
public VersionSpec setSunsetLink(URI uri, MediaType mediaType) {
|
||||
return map(info -> info.withSunsetLink(uri, mediaType));
|
||||
}
|
||||
|
||||
private VersionSpec map(Function<VersionInfo, VersionInfo> function) {
|
||||
StandardApiDeprecationHandler.this.infos.compute(this.version, (version, versionInfo) -> {
|
||||
Assert.state(versionInfo != null, "No VersionInfo");
|
||||
return function.apply(versionInfo);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private record VersionInfo(
|
||||
Comparable<?> version,
|
||||
Predicate<ServerWebExchange> exchangePredicate,
|
||||
@Nullable String deprecationDate, @Nullable String deprecationLink,
|
||||
@Nullable String sunsetDate, @Nullable String sunsetLink) {
|
||||
|
||||
VersionInfo(Comparable<?> version) {
|
||||
this(version, request -> true, null, null, null, null);
|
||||
}
|
||||
|
||||
public VersionInfo withExchangePredicate(Predicate<ServerWebExchange> predicate) {
|
||||
return new VersionInfo(version(), predicate,
|
||||
deprecationDate(), deprecationLink(), sunsetDate(), sunsetLink());
|
||||
}
|
||||
|
||||
public VersionInfo withDeprecationDate(ZonedDateTime deprecationDate) {
|
||||
return new VersionInfo(version(), exchangePredicate(),
|
||||
"@" + deprecationDate.toInstant().getEpochSecond(), deprecationLink(),
|
||||
sunsetDate(), sunsetLink());
|
||||
}
|
||||
|
||||
public VersionInfo withDeprecationLink(URI uri, MediaType mediaType) {
|
||||
return new VersionInfo(version(), exchangePredicate(),
|
||||
deprecationDate(), String.format("<%s>; rel=\"deprecation\"; type=\"%s\"", uri, mediaType),
|
||||
sunsetDate(), sunsetLink());
|
||||
}
|
||||
|
||||
public VersionInfo withSunsetDate(ZonedDateTime sunsetDate) {
|
||||
return new VersionInfo(version(), exchangePredicate(),
|
||||
deprecationDate(), deprecationLink(),
|
||||
sunsetDate.format(DateTimeFormatter.RFC_1123_DATE_TIME), sunsetLink());
|
||||
}
|
||||
|
||||
public VersionInfo withSunsetLink(URI uri, MediaType mediaType) {
|
||||
return new VersionInfo(version(), exchangePredicate(),
|
||||
deprecationDate(), deprecationLink(),
|
||||
sunsetDate(), String.format("<%s>; rel=\"sunset\"; type=\"%s\"", uri, mediaType));
|
||||
}
|
||||
|
||||
boolean match(Comparable<?> requestVersion, ServerWebExchange exchange) {
|
||||
return (version().equals(requestVersion) && exchangePredicate().test(exchange));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -29,11 +29,13 @@ import org.springframework.http.MediaType;
|
|||
import org.springframework.web.accept.ApiVersionParser;
|
||||
import org.springframework.web.accept.InvalidApiVersionException;
|
||||
import org.springframework.web.accept.SemanticApiVersionParser;
|
||||
import org.springframework.web.reactive.accept.ApiDeprecationHandler;
|
||||
import org.springframework.web.reactive.accept.ApiVersionResolver;
|
||||
import org.springframework.web.reactive.accept.ApiVersionStrategy;
|
||||
import org.springframework.web.reactive.accept.DefaultApiVersionStrategy;
|
||||
import org.springframework.web.reactive.accept.MediaTypeParamApiVersionResolver;
|
||||
import org.springframework.web.reactive.accept.PathApiVersionResolver;
|
||||
import org.springframework.web.reactive.accept.StandardApiDeprecationHandler;
|
||||
|
||||
/**
|
||||
* Configure API versioning.
|
||||
|
@ -51,6 +53,8 @@ public class ApiVersionConfigurer {
|
|||
|
||||
private @Nullable String defaultVersion;
|
||||
|
||||
private @Nullable ApiDeprecationHandler deprecationHandler;
|
||||
|
||||
private final Set<String> supportedVersions = new LinkedHashSet<>();
|
||||
|
||||
private boolean detectSupportedVersions = true;
|
||||
|
@ -141,6 +145,18 @@ public class ApiVersionConfigurer {
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a handler to add handling for requests with a deprecated API
|
||||
* version. Typically, this involves sending hints and information about
|
||||
* the deprecation in response headers.
|
||||
* @param handler the handler to use
|
||||
* @see StandardApiDeprecationHandler
|
||||
*/
|
||||
public ApiVersionConfigurer setDeprecationHandler(ApiDeprecationHandler handler) {
|
||||
this.deprecationHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to the list of supported versions to check against before raising
|
||||
* {@link InvalidApiVersionException} for unknown versions.
|
||||
|
@ -178,7 +194,8 @@ public class ApiVersionConfigurer {
|
|||
|
||||
DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers,
|
||||
(this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()),
|
||||
this.versionRequired, this.defaultVersion, this.detectSupportedVersions);
|
||||
this.versionRequired, this.defaultVersion, this.detectSupportedVersions,
|
||||
this.deprecationHandler);
|
||||
|
||||
this.supportedVersions.forEach(strategy::addSupportedVersion);
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ import org.springframework.util.StringUtils;
|
|||
import org.springframework.web.accept.InvalidApiVersionException;
|
||||
import org.springframework.web.accept.NotAcceptableApiVersionException;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.reactive.HandlerMapping;
|
||||
import org.springframework.web.reactive.accept.ApiVersionStrategy;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
|
@ -64,14 +65,16 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
|
|||
* the {@code ApiVersionStrategy}, if API versioning is enabled.
|
||||
*/
|
||||
public VersionRequestCondition(@Nullable String version, @Nullable ApiVersionStrategy strategy) {
|
||||
this.versionStrategy = (strategy != null ? strategy : NO_OP_VERSION_STRATEGY);
|
||||
if (StringUtils.hasText(version)) {
|
||||
Assert.isTrue(strategy != null, "ApiVersionStrategy is required for mapping by version");
|
||||
this.versionStrategy = strategy;
|
||||
this.baselineVersion = version.endsWith("+");
|
||||
this.versionValue = updateVersion(version, this.baselineVersion);
|
||||
this.version = this.versionStrategy.parseVersion(this.versionValue);
|
||||
this.content = Set.of(version);
|
||||
}
|
||||
else {
|
||||
this.versionStrategy = (strategy != null ? strategy : NO_OP_VERSION_STRATEGY);
|
||||
this.versionValue = null;
|
||||
this.version = null;
|
||||
this.baselineVersion = false;
|
||||
|
@ -94,6 +97,9 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
|
|||
return " && ";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the raw version value.
|
||||
*/
|
||||
public @Nullable String getVersion() {
|
||||
return this.versionValue;
|
||||
}
|
||||
|
@ -111,13 +117,15 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
|
|||
requestVersion = (value != null ? parseVersion(value) : this.versionStrategy.getDefaultVersion());
|
||||
this.versionStrategy.validateVersion(requestVersion, exchange);
|
||||
requestVersion = (requestVersion != null ? requestVersion : NO_VERSION_ATTRIBUTE);
|
||||
exchange.getAttributes().put(VERSION_ATTRIBUTE_NAME, (requestVersion));
|
||||
exchange.getAttributes().put(VERSION_ATTRIBUTE_NAME, requestVersion);
|
||||
}
|
||||
|
||||
if (this.version == null || requestVersion == NO_VERSION_ATTRIBUTE) {
|
||||
return this;
|
||||
}
|
||||
|
||||
exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, requestVersion);
|
||||
|
||||
// At this stage, match all versions as baseline versions.
|
||||
// Strict matching for fixed versions is enforced at the end in handleMatch.
|
||||
|
||||
|
@ -199,6 +207,10 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
|
|||
public @Nullable Comparable<?> getDefaultVersion() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleDeprecations(Comparable<?> version, ServerWebExchange exchange) {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -55,6 +55,7 @@ import org.springframework.web.reactive.result.condition.ConsumesRequestConditio
|
|||
import org.springframework.web.reactive.result.condition.RequestCondition;
|
||||
import org.springframework.web.reactive.result.method.RequestMappingInfo;
|
||||
import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.service.annotation.HttpExchange;
|
||||
|
||||
/**
|
||||
|
@ -358,6 +359,17 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
|
|||
new RequestMethod[] {RequestMethod.valueOf(method)} : EMPTY_REQUEST_METHOD_ARRAY);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleMatch(RequestMappingInfo info, HandlerMethod handlerMethod, ServerWebExchange exchange) {
|
||||
super.handleMatch(info, handlerMethod, exchange);
|
||||
|
||||
Comparable<?> version = exchange.getAttribute(API_VERSION_ATTRIBUTE);
|
||||
if (version != null) {
|
||||
Assert.state(this.apiVersionStrategy != null, "No ApiVersionStrategy");
|
||||
this.apiVersionStrategy.handleDeprecations(version, exchange);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerMapping(RequestMappingInfo mapping, Object handler, Method method) {
|
||||
super.registerMapping(mapping, handler, method);
|
||||
|
|
|
@ -89,7 +89,7 @@ public class DefaultApiVersionStrategiesTests {
|
|||
|
||||
return new DefaultApiVersionStrategy(
|
||||
List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")),
|
||||
parser, true, defaultValue, detectSupportedVersions);
|
||||
parser, true, defaultValue, detectSupportedVersions, null);
|
||||
}
|
||||
|
||||
private static void validateVersion(String version, DefaultApiVersionStrategy strategy) {
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* Copyright 2002-present 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.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package org.springframework.web.reactive.accept;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.web.accept.ApiVersionParser;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.web.testfixture.server.MockServerWebExchange;
|
||||
|
||||
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link StandardApiDeprecationHandler}.
|
||||
* @author Rossen Stoyanchev
|
||||
*/
|
||||
public class StandardApiDeprecationHandlerTests {
|
||||
|
||||
private final ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/").build());
|
||||
|
||||
|
||||
@Test
|
||||
void basic() {
|
||||
String deprecationUrl = "https://example.org/deprecation";
|
||||
String sunsetDate = "Wed, 11 Nov 2026 11:11:11 GMT";
|
||||
String sunsetUrl = "https://example.org/sunset";
|
||||
|
||||
ApiVersionParser<String> parser = version -> version;
|
||||
StandardApiDeprecationHandler handler = new StandardApiDeprecationHandler(parser);
|
||||
|
||||
handler.configureVersion("1.1")
|
||||
.setDeprecationDate(getDate("Fri, 30 Jun 2023 23:59:59 GMT"))
|
||||
.setDeprecationLink(URI.create(deprecationUrl))
|
||||
.setSunsetDate(getDate(sunsetDate))
|
||||
.setSunsetLink(URI.create(sunsetUrl));
|
||||
|
||||
handler.handleVersion("1.1", exchange);
|
||||
|
||||
HttpHeaders headers = exchange.getResponse().getHeaders();
|
||||
assertThat(headers.getFirst("Deprecation")).isEqualTo("@1688169599");
|
||||
assertThat(headers.getFirst("Sunset")).isEqualTo(sunsetDate);
|
||||
assertThat(headers.get("Link")).containsExactlyInAnyOrder(
|
||||
"<" + deprecationUrl + ">; rel=\"deprecation\"; type=\"text/html\"",
|
||||
"<" + sunsetUrl + ">; rel=\"sunset\"; type=\"text/html\""
|
||||
);
|
||||
}
|
||||
|
||||
private static ZonedDateTime getDate(String date) {
|
||||
return ZonedDateTime.parse(date, DateTimeFormatter.RFC_1123_DATE_TIME);
|
||||
}
|
||||
|
||||
}
|
|
@ -52,7 +52,7 @@ public class VersionRequestConditionTests {
|
|||
private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) {
|
||||
return new DefaultApiVersionStrategy(
|
||||
List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")),
|
||||
new SemanticApiVersionParser(), true, defaultValue, false);
|
||||
new SemanticApiVersionParser(), true, defaultValue, false, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
|
||||
package org.springframework.web.reactive.result.method.annotation;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.http.RequestEntity;
|
||||
|
@ -23,6 +25,7 @@ import org.springframework.http.ResponseEntity;
|
|||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.reactive.accept.StandardApiDeprecationHandler;
|
||||
import org.springframework.web.reactive.config.ApiVersionConfigurer;
|
||||
import org.springframework.web.reactive.config.EnableWebFlux;
|
||||
import org.springframework.web.reactive.config.WebFluxConfigurer;
|
||||
|
@ -67,6 +70,13 @@ public class RequestMappingVersionIntegrationTests extends AbstractRequestMappin
|
|||
assertThatThrownBy(() -> exchangeWithVersion("1.6")).isInstanceOf(HttpClientErrorException.BadRequest.class);
|
||||
}
|
||||
|
||||
@ParameterizedHttpServerTest
|
||||
void deprecation(HttpServer httpServer) throws Exception {
|
||||
startServer(httpServer);
|
||||
assertThat(exchangeWithVersion("1").getHeaders().getFirst("Link"))
|
||||
.isEqualTo("<https://example.org/deprecation>; rel=\"deprecation\"; type=\"text/html\"");
|
||||
}
|
||||
|
||||
private ResponseEntity<String> exchangeWithVersion(String version) {
|
||||
String url = "http://localhost:" + this.port;
|
||||
RequestEntity<Void> requestEntity = RequestEntity.get(url).header("X-API-Version", version).build();
|
||||
|
@ -79,7 +89,13 @@ public class RequestMappingVersionIntegrationTests extends AbstractRequestMappin
|
|||
|
||||
@Override
|
||||
public void configureApiVersioning(ApiVersionConfigurer configurer) {
|
||||
configurer.useRequestHeader("X-API-Version").addSupportedVersions("1", "1.1", "1.3", "1.6");
|
||||
|
||||
StandardApiDeprecationHandler handler = new StandardApiDeprecationHandler();
|
||||
handler.configureVersion("1").setDeprecationLink(URI.create("https://example.org/deprecation"));
|
||||
|
||||
configurer.useRequestHeader("X-API-Version")
|
||||
.addSupportedVersions("1", "1.1", "1.3", "1.6")
|
||||
.setDeprecationHandler(handler);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -135,6 +135,13 @@ public interface HandlerMapping {
|
|||
*/
|
||||
String PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE = HandlerMapping.class.getName() + ".producibleMediaTypes";
|
||||
|
||||
/**
|
||||
* Name of the {@link HttpServletRequest} attribute that contains the
|
||||
* resolved and parsed API version.
|
||||
* @since 7.0
|
||||
*/
|
||||
String API_VERSION_ATTRIBUTE = HandlerMapping.class.getName() + ".apiVersion";
|
||||
|
||||
|
||||
/**
|
||||
* Whether this {@code HandlerMapping} instance has been enabled to use parsed
|
||||
|
|
|
@ -26,6 +26,7 @@ import java.util.Set;
|
|||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.accept.ApiDeprecationHandler;
|
||||
import org.springframework.web.accept.ApiVersionParser;
|
||||
import org.springframework.web.accept.ApiVersionResolver;
|
||||
import org.springframework.web.accept.ApiVersionStrategy;
|
||||
|
@ -33,6 +34,7 @@ import org.springframework.web.accept.DefaultApiVersionStrategy;
|
|||
import org.springframework.web.accept.MediaTypeParamApiVersionResolver;
|
||||
import org.springframework.web.accept.PathApiVersionResolver;
|
||||
import org.springframework.web.accept.SemanticApiVersionParser;
|
||||
import org.springframework.web.accept.StandardApiDeprecationHandler;
|
||||
|
||||
/**
|
||||
* Configure API versioning.
|
||||
|
@ -50,6 +52,8 @@ public class ApiVersionConfigurer {
|
|||
|
||||
private @Nullable String defaultVersion;
|
||||
|
||||
private @Nullable ApiDeprecationHandler deprecationHandler;
|
||||
|
||||
private final Set<String> supportedVersions = new LinkedHashSet<>();
|
||||
|
||||
|
||||
|
@ -138,6 +142,18 @@ public class ApiVersionConfigurer {
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a handler to add handling for requests with a deprecated API
|
||||
* version. Typically, this involves sending hints and information about
|
||||
* the deprecation in response headers.
|
||||
* @param handler the handler to use
|
||||
* @see StandardApiDeprecationHandler
|
||||
*/
|
||||
public ApiVersionConfigurer setDeprecationHandler(ApiDeprecationHandler handler) {
|
||||
this.deprecationHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to the list of supported versions to validate request versions against.
|
||||
* Request versions that are not supported result in
|
||||
|
@ -160,7 +176,7 @@ public class ApiVersionConfigurer {
|
|||
|
||||
DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers,
|
||||
(this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()),
|
||||
this.versionRequired, this.defaultVersion);
|
||||
this.versionRequired, this.defaultVersion, this.deprecationHandler);
|
||||
|
||||
this.supportedVersions.forEach(strategy::addSupportedVersion);
|
||||
|
||||
|
|
|
@ -21,15 +21,16 @@ import java.util.Collections;
|
|||
import java.util.Set;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.accept.ApiVersionStrategy;
|
||||
import org.springframework.web.accept.InvalidApiVersionException;
|
||||
import org.springframework.web.accept.MissingApiVersionException;
|
||||
import org.springframework.web.accept.NotAcceptableApiVersionException;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
/**
|
||||
* Request condition to map based on the API version of the request.
|
||||
|
@ -50,7 +51,7 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
|
|||
|
||||
private final @Nullable String versionValue;
|
||||
|
||||
private final @Nullable Object version;
|
||||
private final @Nullable Comparable<?> version;
|
||||
|
||||
private final boolean baselineVersion;
|
||||
|
||||
|
@ -64,14 +65,16 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
|
|||
* the {@code ApiVersionStrategy}, if API versioning is enabled.
|
||||
*/
|
||||
public VersionRequestCondition(@Nullable String version, @Nullable ApiVersionStrategy strategy) {
|
||||
this.versionStrategy = (strategy != null ? strategy : NO_OP_VERSION_STRATEGY);
|
||||
if (StringUtils.hasText(version)) {
|
||||
Assert.isTrue(strategy != null, "ApiVersionStrategy is required for mapping by version");
|
||||
this.versionStrategy = strategy;
|
||||
this.baselineVersion = version.endsWith("+");
|
||||
this.versionValue = updateVersion(version, this.baselineVersion);
|
||||
this.version = this.versionStrategy.parseVersion(this.versionValue);
|
||||
this.content = Set.of(version);
|
||||
}
|
||||
else {
|
||||
this.versionStrategy = (strategy != null ? strategy : NO_OP_VERSION_STRATEGY);
|
||||
this.versionValue = null;
|
||||
this.version = null;
|
||||
this.baselineVersion = false;
|
||||
|
@ -94,6 +97,9 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
|
|||
return " && ";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the raw version value.
|
||||
*/
|
||||
public @Nullable String getVersion() {
|
||||
return this.versionValue;
|
||||
}
|
||||
|
@ -111,13 +117,15 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
|
|||
requestVersion = (value != null ? parseVersion(value) : this.versionStrategy.getDefaultVersion());
|
||||
this.versionStrategy.validateVersion(requestVersion, request);
|
||||
requestVersion = (requestVersion != null ? requestVersion : NO_VERSION_ATTRIBUTE);
|
||||
request.setAttribute(VERSION_ATTRIBUTE_NAME, (requestVersion));
|
||||
request.setAttribute(VERSION_ATTRIBUTE_NAME, requestVersion);
|
||||
}
|
||||
|
||||
if (this.version == null || requestVersion == NO_VERSION_ATTRIBUTE) {
|
||||
return this;
|
||||
}
|
||||
|
||||
request.setAttribute(HandlerMapping.API_VERSION_ATTRIBUTE, requestVersion);
|
||||
|
||||
// At this stage, match all versions as baseline versions.
|
||||
// Strict matching for fixed versions is enforced at the end in handleMatch.
|
||||
|
||||
|
@ -199,6 +207,10 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
|
|||
public @Nullable Comparable<?> getDefaultVersion() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleDeprecations(Comparable<?> version, HttpServletRequest request, HttpServletResponse response) {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ import java.util.function.Predicate;
|
|||
import java.util.stream.Stream;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.context.EmbeddedValueResolverAware;
|
||||
|
@ -52,6 +53,8 @@ import org.springframework.web.bind.annotation.RequestMethod;
|
|||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.service.annotation.HttpExchange;
|
||||
import org.springframework.web.servlet.HandlerExecutionChain;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import org.springframework.web.servlet.handler.MatchableHandlerMapping;
|
||||
import org.springframework.web.servlet.handler.RequestMatchResult;
|
||||
import org.springframework.web.servlet.mvc.condition.AbstractRequestCondition;
|
||||
|
@ -383,6 +386,16 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
|
|||
new RequestMethod[] {RequestMethod.valueOf(method)} : EMPTY_REQUEST_METHOD_ARRAY);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HandlerExecutionChain getHandlerExecutionChain(Object handler, HttpServletRequest request) {
|
||||
HandlerExecutionChain executionChain = super.getHandlerExecutionChain(handler, request);
|
||||
Comparable<?> version = (Comparable<?>) request.getAttribute(API_VERSION_ATTRIBUTE);
|
||||
if (version != null) {
|
||||
executionChain.addInterceptor(new DeprecationInterceptor(version));
|
||||
}
|
||||
return executionChain;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerMapping(RequestMappingInfo mapping, Object handler, Method method) {
|
||||
super.registerMapping(mapping, handler, method);
|
||||
|
@ -554,4 +567,21 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
|
|||
|
||||
}
|
||||
|
||||
|
||||
private final class DeprecationInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final Comparable<?> version;
|
||||
|
||||
private DeprecationInterceptor(Comparable<?> version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
Assert.state(apiVersionStrategy != null, "No ApiVersionStrategy");
|
||||
apiVersionStrategy.handleDeprecations(this.version, request, response);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -386,7 +386,7 @@ class WebMvcConfigurationSupportExtensionTests {
|
|||
|
||||
@Override
|
||||
public void configureApiVersioning(ApiVersionConfigurer configurer) {
|
||||
configurer.useRequestHeader("X-API-Version");
|
||||
configurer.useRequestHeader("X-API-Version").setVersionRequired(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -50,7 +50,7 @@ public class VersionRequestConditionTests {
|
|||
private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) {
|
||||
return new DefaultApiVersionStrategy(
|
||||
List.of(request -> request.getParameter("api-version")),
|
||||
new SemanticApiVersionParser(), true, defaultValue);
|
||||
new SemanticApiVersionParser(), true, defaultValue, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -17,11 +17,13 @@
|
|||
package org.springframework.web.servlet.mvc.method.annotation;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.web.accept.StandardApiDeprecationHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
|
||||
|
@ -76,6 +78,12 @@ public class RequestMappingVersionHandlerMethodTests {
|
|||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deprecation() throws Exception {
|
||||
assertThat(requestWithVersion("1").getHeader("Link"))
|
||||
.isEqualTo("<https://example.org/deprecation>; rel=\"deprecation\"; type=\"text/html\"");
|
||||
}
|
||||
|
||||
private MockHttpServletResponse requestWithVersion(String version) throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/");
|
||||
request.addHeader("X-API-VERSION", version);
|
||||
|
@ -90,7 +98,13 @@ public class RequestMappingVersionHandlerMethodTests {
|
|||
|
||||
@Override
|
||||
public void configureApiVersioning(ApiVersionConfigurer configurer) {
|
||||
configurer.useRequestHeader("X-API-Version").addSupportedVersions("1", "1.1", "1.3", "1.6");
|
||||
|
||||
StandardApiDeprecationHandler handler = new StandardApiDeprecationHandler();
|
||||
handler.configureVersion("1").setDeprecationLink(URI.create("https://example.org/deprecation"));
|
||||
|
||||
configurer.useRequestHeader("X-API-Version")
|
||||
.addSupportedVersions("1", "1.1", "1.3", "1.6")
|
||||
.setDeprecationHandler(handler);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue