Add handling of deprecated API versions

See gh-35049
This commit is contained in:
rstoyanchev 2025-06-20 16:37:27 +01:00
parent 0eec1dc636
commit 492e51f3ba
25 changed files with 950 additions and 19 deletions

View File

@ -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);
}

View File

@ -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);
}

View File

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

View File

@ -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));
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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.

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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[" +

View File

@ -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));
}
}
}

View File

@ -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);

View File

@ -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) {
}
}
}

View File

@ -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);

View File

@ -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) {

View File

@ -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);
}
}

View File

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

View File

@ -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);
}
}

View File

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

View File

@ -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);

View File

@ -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) {
}
}
}

View File

@ -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;
}
}
}

View File

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

View File

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

View File

@ -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);
}
}