Add handling of deprecated API versions

See gh-35049
This commit is contained in:
rstoyanchev
2025-06-23 18:03:56 +01:00
parent 0eec1dc636
commit 492e51f3ba
25 changed files with 950 additions and 19 deletions
@@ -0,0 +1,43 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.accept;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* Contract to add handling of requests with a deprecated API version. Typically,
* this involves use of response headers to send hints and information about
* the deprecation to clients.
*
* @author Rossen Stoyanchev
* @since 7.0
* @see StandardApiDeprecationHandler
*/
public interface ApiDeprecationHandler {
/**
* Check if the requested API version is deprecated, and if so handle it
* accordingly, e.g. by setting response headers to signal the deprecation,
* to specify relevant dates and provide links to further details.
* @param version the resolved and parsed request version
* @param request the current request
* @param response the current response
*/
void handleVersion(Comparable<?> version, HttpServletRequest request, HttpServletResponse response);
}
@@ -17,6 +17,7 @@
package org.springframework.web.accept;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.Nullable;
/**
@@ -25,6 +26,7 @@ import org.jspecify.annotations.Nullable;
*
* @author Rossen Stoyanchev
* @since 7.0
* @see DefaultApiVersionStrategy
*/
public interface ApiVersionStrategy {
@@ -32,6 +34,7 @@ public interface ApiVersionStrategy {
* Resolve the version value from a request, e.g. from a request header.
* @param request the current request
* @return the version, if present or {@code null}
* @see ApiVersionResolver
*/
@Nullable
String resolveVersion(HttpServletRequest request);
@@ -40,6 +43,7 @@ public interface ApiVersionStrategy {
* Parse the version of a request into an Object.
* @param version the value to parse
* @return an Object that represents the version
* @see ApiVersionParser
*/
Comparable<?> parseVersion(String version);
@@ -58,4 +62,15 @@ public interface ApiVersionStrategy {
*/
@Nullable Comparable<?> getDefaultVersion();
/**
* Check if the requested API version is deprecated, and if so handle it
* accordingly, e.g. by setting response headers to signal the deprecation,
* to specify relevant dates and provide links to further details.
* @param version the resolved and parsed request version
* @param request the current request
* @param response the current response
* @see ApiDeprecationHandler
*/
void handleDeprecations(Comparable<?> version, HttpServletRequest request, HttpServletResponse response);
}
@@ -22,13 +22,14 @@ import java.util.Set;
import java.util.TreeSet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert;
/**
* Default implementation of {@link ApiVersionStrategy} that delegates to the
* configured version resolvers and version parser.
* configured version resolvers, version parser, and deprecation handler.
*
* @author Rossen Stoyanchev
* @since 7.0
@@ -43,6 +44,8 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
private final @Nullable Comparable<?> defaultVersion;
private final @Nullable ApiDeprecationHandler deprecationHandler;
private final Set<Comparable<?>> supportedVersions = new TreeSet<>();
@@ -56,10 +59,13 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
* validation fails with {@link MissingApiVersionException}
* @param defaultVersion a default version to assign to requests that
* don't specify one
* @param deprecationHandler handler to send hints and information about
* deprecated API versions to clients
*/
public DefaultApiVersionStrategy(
List<ApiVersionResolver> versionResolvers, ApiVersionParser<?> versionParser,
boolean versionRequired, @Nullable String defaultVersion) {
boolean versionRequired, @Nullable String defaultVersion,
@Nullable ApiDeprecationHandler deprecationHandler) {
Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required");
Assert.notNull(versionParser, "ApiVersionParser is required");
@@ -68,6 +74,7 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
this.versionParser = versionParser;
this.versionRequired = (versionRequired && defaultVersion == null);
this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null);
this.deprecationHandler = deprecationHandler;
}
@@ -120,6 +127,13 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
}
}
@Override
public void handleDeprecations(Comparable<?> version, HttpServletRequest request, HttpServletResponse response) {
if (this.deprecationHandler != null) {
this.deprecationHandler.handleVersion(version, request, response);
}
}
@Override
public String toString() {
return "DefaultApiVersionStrategy[supportedVersions=" + this.supportedVersions +
@@ -0,0 +1,255 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.accept;
import java.net.URI;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import java.util.function.Predicate;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.Nullable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
/**
* {@code ApiDeprecationHandler} based on
* <a href="https://datatracker.ietf.org/doc/html/rfc9745">RFC 9745</a> and
* <a href="https://datatracker.ietf.org/doc/html/rfc8594">RFC 8594</a> that
* provides the option to set the "Deprecation" and "Sunset" response headers,
* as well as to add "Link" headers with further details about both.
* <p>To use this handler, create an instance, call {@link #configureVersion}
* for each deprecated version, and use the returned {@link VersionSpec} to
* provide the deprecation details to send to clients.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
public class StandardApiDeprecationHandler implements ApiDeprecationHandler {
private final ApiVersionParser<?> versionParser;
private final Map<Comparable<?>, VersionInfo> infos = new HashMap<>();
/**
* Create an instance.
* <p>By default, {@link SemanticApiVersionParser} is used to parse configured
* API versions, so those can be compared to request versions parsed at runtime.
* If you have a custom parser, then please use the
* {@link #StandardApiDeprecationHandler(ApiVersionParser)} constructor.
*/
public StandardApiDeprecationHandler() {
this(new SemanticApiVersionParser());
}
/**
* Variant of the default constructor with a custom {@link ApiVersionParser}.
* This needs to be the same as the parser type used at runtime to parse
* request versions.
*/
public StandardApiDeprecationHandler(ApiVersionParser<?> parser) {
this.versionParser = parser;
}
/**
* Mark the given API version as deprecated, and use the returned
* {@link VersionSpec} to configure the deprecation details to send to clients.
* @param version the version to mark as deprecated
* @return a spec to configure deprecation details
*/
public VersionSpec configureVersion(String version) {
Comparable<?> parsedVersion = this.versionParser.parseVersion(version);
return new VersionSpec(parsedVersion);
}
@Override
public void handleVersion(
Comparable<?> requestVersion, HttpServletRequest request, HttpServletResponse response) {
for (VersionInfo info : this.infos.values()) {
if (info.match(requestVersion, request)) {
if (info.deprecationDate() != null) {
response.setHeader("Deprecation", info.deprecationDate());
}
if (info.deprecationLink() != null) {
response.addHeader(HttpHeaders.LINK, info.deprecationLink());
}
if (info.sunsetDate() != null) {
response.setHeader("Sunset", info.sunsetDate());
}
if (info.sunsetLink() != null) {
response.addHeader(HttpHeaders.LINK, info.sunsetLink());
}
}
}
}
@Override
public String toString() {
return "StandardApiDeprecationHandler " + this.infos.values();
}
/**
* A spec to configure deprecation details for an API version.
*/
public final class VersionSpec {
private final Comparable<?> version;
private VersionSpec(Comparable<?> version) {
this.version = version;
StandardApiDeprecationHandler.this.infos.put(version, new VersionInfo(version));
}
/**
* Set a predicate to filter which requests to send deprecation info to.
* <p>By default, all requests with the deprecated version are handled.
* This predicate to narrow the requests that should expose deprecation
* information.
* @param predicate a predicate to check the request with
* @return the same spec instance
*/
public VersionSpec setRequestPredicate(Predicate<HttpServletRequest> predicate) {
return map(info -> info.withRequestPredicate(predicate));
}
/**
* Specify a deprecation date for the "Deprecation" response header.
* @param date the deprecation date
* @return the same spec instance
*/
public VersionSpec setDeprecationDate(ZonedDateTime date) {
return map(info -> info.withDeprecationDate(date));
}
/**
* Specify a URL for the "Link" response header with
* {@code rel="deprecation"} and {@code type="text/html"}.
* @param uri the link value
* @return the same spec instance
*/
public VersionSpec setDeprecationLink(URI uri) {
return setDeprecationLink(uri, MediaType.TEXT_HTML);
}
/**
* Variation of {@link #setDeprecationLink(URI)} for use with a media type
* other than "text/html".
* @param uri the link value
* @param mediaType the media type to use
* @return the same spec instance
*/
public VersionSpec setDeprecationLink(URI uri, MediaType mediaType) {
return map(info -> info.withDeprecationLink(uri, mediaType));
}
/**
* Specify a deprecation date for the "Sunset" response header.
* @param date the sunset date
* @return the same spec instance
*/
public VersionSpec setSunsetDate(ZonedDateTime date) {
return map(info -> info.withSunsetDate(date));
}
/**
* Specify a URL for the "Link" response header with
* {@code rel="sunset"} and {@code type="text/html"}.
* @param uri the link value
* @return the same spec instance
*/
public VersionSpec setSunsetLink(URI uri) {
return setSunsetLink(uri, MediaType.TEXT_HTML);
}
/**
* Variation of {@link #setSunsetLink(URI)} for use with a media type
* other than "text/html".
* @param uri the link value
* @param mediaType the media type to use
* @return the same spec instance
*/
public VersionSpec setSunsetLink(URI uri, MediaType mediaType) {
return map(info -> info.withSunsetLink(uri, mediaType));
}
private VersionSpec map(Function<VersionInfo, VersionInfo> function) {
StandardApiDeprecationHandler.this.infos.compute(this.version, (version, versionInfo) -> {
Assert.state(versionInfo != null, "No VersionInfo");
return function.apply(versionInfo);
});
return this;
}
}
private record VersionInfo(
Comparable<?> version,
Predicate<HttpServletRequest> requestPredicate,
@Nullable String deprecationDate, @Nullable String deprecationLink,
@Nullable String sunsetDate, @Nullable String sunsetLink) {
VersionInfo(Comparable<?> version) {
this(version, request -> true, null, null, null, null);
}
public VersionInfo withRequestPredicate(Predicate<HttpServletRequest> predicate) {
return new VersionInfo(version(), predicate,
deprecationDate(), deprecationLink(), sunsetDate(), sunsetLink());
}
public VersionInfo withDeprecationDate(ZonedDateTime deprecationDate) {
return new VersionInfo(version(), requestPredicate(),
"@" + deprecationDate.toInstant().getEpochSecond(), deprecationLink(),
sunsetDate(), sunsetLink());
}
public VersionInfo withDeprecationLink(URI uri, MediaType mediaType) {
return new VersionInfo(version(), requestPredicate(),
deprecationDate(), String.format("<%s>; rel=\"deprecation\"; type=\"%s\"", uri, mediaType),
sunsetDate(), sunsetLink());
}
public VersionInfo withSunsetDate(ZonedDateTime sunsetDate) {
return new VersionInfo(version(), requestPredicate(),
deprecationDate(), deprecationLink(),
sunsetDate.format(DateTimeFormatter.RFC_1123_DATE_TIME), sunsetLink());
}
public VersionInfo withSunsetLink(URI uri, MediaType mediaType) {
return new VersionInfo(version(), requestPredicate(),
deprecationDate(), deprecationLink(),
sunsetDate(), String.format("<%s>; rel=\"sunset\"; type=\"%s\"", uri, mediaType));
}
boolean match(Comparable<?> requestVersion, HttpServletRequest request) {
return (version().equals(requestVersion) && requestPredicate().test(request));
}
}
}