From 492e51f3baadccf90b1941e941c85f31c431317b Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 20 Jun 2025 16:37:27 +0100 Subject: [PATCH] Add handling of deprecated API versions See gh-35049 --- .../web/accept/ApiDeprecationHandler.java | 43 +++ .../web/accept/ApiVersionStrategy.java | 15 ++ .../web/accept/DefaultApiVersionStrategy.java | 18 +- .../accept/StandardApiDeprecationHandler.java | 255 ++++++++++++++++++ .../DefaultApiVersionStrategiesTests.java | 2 +- .../StandardApiDeprecationHandlerTests.java | 70 +++++ .../web/reactive/HandlerMapping.java | 7 + .../accept/ApiDeprecationHandler.java | 40 +++ .../reactive/accept/ApiVersionStrategy.java | 13 + .../accept/DefaultApiVersionStrategy.java | 17 +- .../accept/StandardApiDeprecationHandler.java | 253 +++++++++++++++++ .../reactive/config/ApiVersionConfigurer.java | 19 +- .../condition/VersionRequestCondition.java | 16 +- .../RequestMappingHandlerMapping.java | 12 + .../DefaultApiVersionStrategiesTests.java | 2 +- .../StandardApiDeprecationHandlerTests.java | 72 +++++ .../VersionRequestConditionTests.java | 2 +- ...RequestMappingVersionIntegrationTests.java | 18 +- .../web/servlet/HandlerMapping.java | 7 + .../annotation/ApiVersionConfigurer.java | 18 +- .../condition/VersionRequestCondition.java | 20 +- .../RequestMappingHandlerMapping.java | 30 +++ ...MvcConfigurationSupportExtensionTests.java | 2 +- .../VersionRequestConditionTests.java | 2 +- ...questMappingVersionHandlerMethodTests.java | 16 +- 25 files changed, 950 insertions(+), 19 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/accept/ApiDeprecationHandler.java create mode 100644 spring-web/src/main/java/org/springframework/web/accept/StandardApiDeprecationHandler.java create mode 100644 spring-web/src/test/java/org/springframework/web/accept/StandardApiDeprecationHandlerTests.java create mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiDeprecationHandler.java create mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/accept/StandardApiDeprecationHandler.java create mode 100644 spring-webflux/src/test/java/org/springframework/web/reactive/accept/StandardApiDeprecationHandlerTests.java diff --git a/spring-web/src/main/java/org/springframework/web/accept/ApiDeprecationHandler.java b/spring-web/src/main/java/org/springframework/web/accept/ApiDeprecationHandler.java new file mode 100644 index 00000000000..5ca59aa5d43 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/ApiDeprecationHandler.java @@ -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); + +} diff --git a/spring-web/src/main/java/org/springframework/web/accept/ApiVersionStrategy.java b/spring-web/src/main/java/org/springframework/web/accept/ApiVersionStrategy.java index c241c098bdd..46ae7c1b502 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/ApiVersionStrategy.java +++ b/spring-web/src/main/java/org/springframework/web/accept/ApiVersionStrategy.java @@ -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); + } diff --git a/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java index 11ea6d1fc96..89bcfcd2a91 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java +++ b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java @@ -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> 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 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 + diff --git a/spring-web/src/main/java/org/springframework/web/accept/StandardApiDeprecationHandler.java b/spring-web/src/main/java/org/springframework/web/accept/StandardApiDeprecationHandler.java new file mode 100644 index 00000000000..eb1aabe577a --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/StandardApiDeprecationHandler.java @@ -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 + * RFC 9745 and + * RFC 8594 that + * provides the option to set the "Deprecation" and "Sunset" response headers, + * as well as to add "Link" headers with further details about both. + *

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, VersionInfo> infos = new HashMap<>(); + + + /** + * Create an instance. + *

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. + *

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 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 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 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 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)); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java b/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java index e7a8eb3af68..812831953f5 100644 --- a/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java +++ b/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java @@ -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); } } diff --git a/spring-web/src/test/java/org/springframework/web/accept/StandardApiDeprecationHandlerTests.java b/spring-web/src/test/java/org/springframework/web/accept/StandardApiDeprecationHandlerTests.java new file mode 100644 index 00000000000..920b2cbea7c --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/accept/StandardApiDeprecationHandlerTests.java @@ -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 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); + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/HandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/HandlerMapping.java index 97fc0ec1851..bb249c3c4a7 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/HandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/HandlerMapping.java @@ -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. diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiDeprecationHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiDeprecationHandler.java new file mode 100644 index 00000000000..e78d1cca7c3 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiDeprecationHandler.java @@ -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); + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java index 60c3fff24b0..a3061ebb3f0 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java @@ -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); + } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java index 0d64aecaa22..b312f9de594 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java @@ -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> 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 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[" + diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/StandardApiDeprecationHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/StandardApiDeprecationHandler.java new file mode 100644 index 00000000000..54f387b0913 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/StandardApiDeprecationHandler.java @@ -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 + * RFC 9745 and + * RFC 8594 that + * provides the option to set the "Deprecation" and "Sunset" response headers, + * as well as to add "Link" headers with further details about both. + *

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, VersionInfo> infos = new HashMap<>(); + + + /** + * Create an instance. + *

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. + *

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 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 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 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 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)); + } + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java index 7b97a9b9f53..2a891a3171b 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java @@ -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 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); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java index 8bc7734921d..0a24c8475a9 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java @@ -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 getDefaultVersion() { return null; } + + @Override + public void handleDeprecations(Comparable version, ServerWebExchange exchange) { + } } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java index 90e2873bcfd..117c6661145 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java @@ -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); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java index 9812a8d6e62..0da6278b761 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java @@ -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) { diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/StandardApiDeprecationHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/StandardApiDeprecationHandlerTests.java new file mode 100644 index 00000000000..d894c31451a --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/StandardApiDeprecationHandlerTests.java @@ -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 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); + } + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java index 2a0b6d3348a..933c91635a4 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java @@ -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 diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java index 76887abf3f9..5d1cbd80078 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java @@ -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("; rel=\"deprecation\"; type=\"text/html\""); + } + private ResponseEntity exchangeWithVersion(String version) { String url = "http://localhost:" + this.port; RequestEntity 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); } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/HandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/HandlerMapping.java index 4a4692a4e66..081ac53ff02 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/HandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/HandlerMapping.java @@ -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 diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java index cc552bf2485..fcc652d5acc 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java @@ -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 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); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java index 2df3288d475..0dcd117e7e1 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java @@ -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 version; private final boolean baselineVersion; @@ -64,14 +65,16 @@ public final class VersionRequestCondition extends AbstractRequestCondition getDefaultVersion() { return null; } + + @Override + public void handleDeprecations(Comparable version, HttpServletRequest request, HttpServletResponse response) { + } } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java index f3ef8c65635..a51170d7737 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java @@ -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; + } + } + } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java index 91dd48d5a95..0275705d5d3 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java @@ -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 diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java index a0642ae81b4..394ad04b9d3 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java @@ -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 diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingVersionHandlerMethodTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingVersionHandlerMethodTests.java index 98808887b05..59ea21bd795 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingVersionHandlerMethodTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingVersionHandlerMethodTests.java @@ -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("; 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); } }