From 51d34fff64d1cdad2d09ab085de13f5a7b2e8dd2 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 6 Mar 2025 14:09:43 +0000 Subject: [PATCH] API versioning support for Spring MVC See gh-34566 --- .../web/accept/ApiVersionParser.java | 36 ++++ .../web/accept/ApiVersionResolver.java | 39 ++++ .../web/accept/ApiVersionStrategy.java | 61 ++++++ .../web/accept/DefaultApiVersionStrategy.java | 129 +++++++++++ .../accept/InvalidApiVersionException.java | 54 +++++ .../accept/MissingApiVersionException.java | 35 +++ .../NotAcceptableApiVersionException.java | 36 ++++ .../web/accept/PathApiVersionResolver.java | 63 ++++++ .../web/accept/SemanticApiVersionParser.java | 130 +++++++++++ .../web/bind/annotation/DeleteMapping.java | 8 +- .../web/bind/annotation/GetMapping.java | 8 +- .../web/bind/annotation/PatchMapping.java | 8 +- .../web/bind/annotation/PostMapping.java | 8 +- .../web/bind/annotation/PutMapping.java | 8 +- .../web/bind/annotation/RequestMapping.java | 15 ++ .../DefaultApiVersionStrategiesTests.java | 70 ++++++ .../accept/PathApiVersionResolverTests.java | 50 +++++ .../accept/SemanticApiVersionParserTests.java | 63 ++++++ .../annotation/ApiVersionConfigurer.java | 156 ++++++++++++++ .../DelegatingWebMvcConfiguration.java | 7 +- .../WebMvcConfigurationSupport.java | 31 ++- .../config/annotation/WebMvcConfigurer.java | 11 +- .../annotation/WebMvcConfigurerComposite.java | 7 + .../condition/VersionRequestCondition.java | 202 ++++++++++++++++++ .../mvc/method/RequestMappingInfo.java | 129 +++++++++-- .../RequestMappingInfoHandlerMapping.java | 2 + .../RequestMappingHandlerMapping.java | 30 +++ .../DelegatingWebMvcConfigurationTests.java | 12 +- ...MvcConfigurationSupportExtensionTests.java | 13 +- .../VersionRequestConditionTests.java | 167 +++++++++++++++ ...ndlerMethodMappingNamingStrategyTests.java | 2 +- ...questMappingVersionHandlerMethodTests.java | 121 +++++++++++ 32 files changed, 1673 insertions(+), 38 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/accept/ApiVersionParser.java create mode 100644 spring-web/src/main/java/org/springframework/web/accept/ApiVersionResolver.java create mode 100644 spring-web/src/main/java/org/springframework/web/accept/ApiVersionStrategy.java create mode 100644 spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java create mode 100644 spring-web/src/main/java/org/springframework/web/accept/InvalidApiVersionException.java create mode 100644 spring-web/src/main/java/org/springframework/web/accept/MissingApiVersionException.java create mode 100644 spring-web/src/main/java/org/springframework/web/accept/NotAcceptableApiVersionException.java create mode 100644 spring-web/src/main/java/org/springframework/web/accept/PathApiVersionResolver.java create mode 100644 spring-web/src/main/java/org/springframework/web/accept/SemanticApiVersionParser.java create mode 100644 spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java create mode 100644 spring-web/src/test/java/org/springframework/web/accept/PathApiVersionResolverTests.java create mode 100644 spring-web/src/test/java/org/springframework/web/accept/SemanticApiVersionParserTests.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingVersionHandlerMethodTests.java diff --git a/spring-web/src/main/java/org/springframework/web/accept/ApiVersionParser.java b/spring-web/src/main/java/org/springframework/web/accept/ApiVersionParser.java new file mode 100644 index 0000000000..13038d9cad --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/ApiVersionParser.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2025 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; + +/** + * Contract to parse a version into an Object representation. + * + * @author Rossen Stoyanchev + * @since 7.0 + * @param the parsed object type + */ +@FunctionalInterface +public interface ApiVersionParser> { + + /** + * Parse the version into an Object. + * @param version the value to parse + * @return an Object that represents the version + */ + V parseVersion(String version); + +} diff --git a/spring-web/src/main/java/org/springframework/web/accept/ApiVersionResolver.java b/spring-web/src/main/java/org/springframework/web/accept/ApiVersionResolver.java new file mode 100644 index 0000000000..963a63c67e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/ApiVersionResolver.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2025 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 org.jspecify.annotations.Nullable; + +/** + * Contract to extract the version from a request. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +@FunctionalInterface +public +interface ApiVersionResolver { + + /** + * Resolve the version for the given request. + * @param request the current request + * @return the version value, or {@code null} if not found + */ + @Nullable String resolveVersion(HttpServletRequest request); + +} 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 new file mode 100644 index 0000000000..0a0f93cfcc --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/ApiVersionStrategy.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-2025 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 org.jspecify.annotations.Nullable; + +/** + * The main component that encapsulates configuration preferences and strategies + * to manage API versioning for an application. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +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} + */ + @Nullable + String resolveVersion(HttpServletRequest request); + + /** + * Parse the version of a request into an Object. + * @param version the value to parse + * @return an Object that represents the version + */ + Comparable parseVersion(String version); + + /** + * Validate a request version, including required and supported version checks. + * @param requestVersion the version to validate + * @param request the request + * @throws MissingApiVersionException if the version is required, but not specified + * @throws InvalidApiVersionException if the version is not supported + */ + void validateVersion(@Nullable Comparable requestVersion, HttpServletRequest request) + throws MissingApiVersionException, InvalidApiVersionException; + + /** + * Return a default version to use for requests that don't specify one. + */ + @Nullable Comparable getDefaultVersion(); + +} 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 new file mode 100644 index 0000000000..ead551e1f0 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java @@ -0,0 +1,129 @@ +/* + * Copyright 2002-2025 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.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import jakarta.servlet.http.HttpServletRequest; +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. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public class DefaultApiVersionStrategy implements ApiVersionStrategy { + + private final List versionResolvers; + + private final ApiVersionParser versionParser; + + private final boolean versionRequired; + + private final @Nullable Comparable defaultVersion; + + private final Set> supportedVersions = new TreeSet<>(); + + + /** + * Create an instance. + * @param versionResolvers one or more resolvers to try; the first non-null + * value returned by any resolver becomes the resolved used + * @param versionParser parser for to raw version values + * @param versionRequired whether a version is required; if a request + * does not have a version, and a {@code defaultVersion} is not specified, + * validation fails with {@link MissingApiVersionException} + * @param defaultVersion a default version to assign to requests that + * don't specify one + */ + public DefaultApiVersionStrategy( + List versionResolvers, ApiVersionParser versionParser, + boolean versionRequired, @Nullable String defaultVersion) { + + Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required"); + Assert.notNull(versionParser, "ApiVersionParser is required"); + + this.versionResolvers = new ArrayList<>(versionResolvers); + this.versionParser = versionParser; + this.versionRequired = (versionRequired && defaultVersion == null); + this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null); + } + + + @Override + public @Nullable Comparable getDefaultVersion() { + return this.defaultVersion; + } + + /** + * Add to the list of known, supported versions to check against in + * {@link ApiVersionStrategy#validateVersion}. Request versions that are not + * in the supported result in {@link InvalidApiVersionException} + * in {@link ApiVersionStrategy#validateVersion}. + * @param versions the versions to add + */ + public void addSupportedVersion(String... versions) { + for (String version : versions) { + this.supportedVersions.add(parseVersion(version)); + } + } + + @Override + public @Nullable String resolveVersion(HttpServletRequest request) { + for (ApiVersionResolver resolver : this.versionResolvers) { + String version = resolver.resolveVersion(request); + if (version != null) { + return version; + } + } + return null; + } + + @Override + public Comparable parseVersion(String version) { + return this.versionParser.parseVersion(version); + } + + public void validateVersion(@Nullable Comparable requestVersion, HttpServletRequest request) + throws MissingApiVersionException, InvalidApiVersionException { + + if (requestVersion == null) { + if (this.versionRequired) { + throw new MissingApiVersionException(); + } + return; + } + + if (!this.supportedVersions.contains(requestVersion)) { + throw new InvalidApiVersionException(requestVersion.toString()); + } + } + + @Override + public String toString() { + return "DefaultApiVersionStrategy[supportedVersions=" + this.supportedVersions + + ", versionRequired=" + this.versionRequired + ", defaultVersion=" + this.defaultVersion + "]"; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/accept/InvalidApiVersionException.java b/spring-web/src/main/java/org/springframework/web/accept/InvalidApiVersionException.java new file mode 100644 index 0000000000..a59306b9f0 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/InvalidApiVersionException.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2025 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 org.jspecify.annotations.Nullable; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** + * Exception raised when an API version cannot be parsed, or is not in the + * supported version set. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +@SuppressWarnings("serial") +public class InvalidApiVersionException extends ResponseStatusException { + + private final String version; + + + public InvalidApiVersionException(String version) { + this(version, null, null); + } + + public InvalidApiVersionException(String version, @Nullable String msg, @Nullable Exception cause) { + super(HttpStatus.BAD_REQUEST, (msg != null ? msg : "Invalid API version: '" + version + "'."), cause); + this.version = version; + } + + + /** + * Return the requested version. + */ + public String getVersion() { + return this.version; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/accept/MissingApiVersionException.java b/spring-web/src/main/java/org/springframework/web/accept/MissingApiVersionException.java new file mode 100644 index 0000000000..2baa861720 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/MissingApiVersionException.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2025 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 org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** + * Exception raised when an API version is required, but is not present. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +@SuppressWarnings("serial") +public class MissingApiVersionException extends ResponseStatusException { + + public MissingApiVersionException() { + super(HttpStatus.BAD_REQUEST, "API version is required."); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/accept/NotAcceptableApiVersionException.java b/spring-web/src/main/java/org/springframework/web/accept/NotAcceptableApiVersionException.java new file mode 100644 index 0000000000..913de02576 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/NotAcceptableApiVersionException.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2025 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; + +/** + * Exception raised when an API version is valid, but did not match the versions + * declared in request mappings for the endpoint. This can happen when a + * controller method is mapped with a fixed version, e.g. "2", but the request + * is for a higher version. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +@SuppressWarnings("serial") +public class NotAcceptableApiVersionException extends InvalidApiVersionException { + + + public NotAcceptableApiVersionException(String version) { + super(version); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/accept/PathApiVersionResolver.java b/spring-web/src/main/java/org/springframework/web/accept/PathApiVersionResolver.java new file mode 100644 index 0000000000..4870940416 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/PathApiVersionResolver.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2025 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 org.jspecify.annotations.Nullable; + +import org.springframework.http.server.PathContainer; +import org.springframework.http.server.RequestPath; +import org.springframework.util.Assert; +import org.springframework.web.util.ServletRequestPathUtils; + +/** + * {@link ApiVersionResolver} that extract the version from a path segment. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public class PathApiVersionResolver implements ApiVersionResolver { + + private final int pathSegmentIndex; + + + /** + * Create a resolver instance. + * @param pathSegmentIndex the index of the path segment that contains + * the API version + */ + public PathApiVersionResolver(int pathSegmentIndex) { + Assert.isTrue(pathSegmentIndex >= 0, "'pathSegmentIndex' must be >= 0"); + this.pathSegmentIndex = pathSegmentIndex; + } + + + @Override + public @Nullable String resolveVersion(HttpServletRequest request) { + if (ServletRequestPathUtils.hasParsedRequestPath(request)) { + RequestPath path = ServletRequestPathUtils.getParsedRequestPath(request); + int i = 0; + for (PathContainer.Element e : path.pathWithinApplication().elements()) { + if (e instanceof PathContainer.PathSegment && i++ == this.pathSegmentIndex) { + return e.value(); + } + } + } + return null; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/accept/SemanticApiVersionParser.java b/spring-web/src/main/java/org/springframework/web/accept/SemanticApiVersionParser.java new file mode 100644 index 0000000000..decfd64bcc --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/SemanticApiVersionParser.java @@ -0,0 +1,130 @@ +/* + * Copyright 2002-2025 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.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.util.Assert; + +/** + * Parser for semantic API versioning with a major, minor, and patch values. + * For example "1", "1.0", "1.2", "1.2.0", "1.2.3". Leading, non-integer + * characters, as in "v1.0", are skipped. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public class SemanticApiVersionParser implements ApiVersionParser { + + private static final Pattern semantinVersionPattern = Pattern.compile("^(\\d+)(\\.(\\d+))?(\\.(\\d+))?$"); + + + @Override + public Version parseVersion(String version) { + Assert.notNull(version, "'version' is required"); + + version = skipNonDigits(version); + + Matcher matcher = semantinVersionPattern.matcher(version); + Assert.state(matcher.matches(), "Invalid API version format"); + + String major = matcher.group(1); + String minor = matcher.group(3); + String patch = matcher.group(5); + + return new Version( + Integer.parseInt(major), + (minor != null ? Integer.parseInt(minor) : 0), + (patch != null ? Integer.parseInt(patch) : 0)); + } + + private static String skipNonDigits(String value) { + for (int i = 0; i < value.length(); i++) { + if (Character.isDigit(value.charAt(i))) { + return value.substring(i); + } + } + return ""; + } + + + /** + * Representation of a semantic version. + */ + public static final class Version implements Comparable { + + private final int major; + + private final int minor; + + private final int patch; + + Version(int major, int minor, int patch) { + this.major = major; + this.minor = minor; + this.patch = patch; + } + + public int getMajor() { + return this.major; + } + + public int getMinor() { + return this.minor; + } + + public int getPatch() { + return this.patch; + } + + @Override + public int compareTo(SemanticApiVersionParser.Version other) { + int result = Integer.compare(this.major, other.major); + if (result != 0) { + return result; + } + result = Integer.compare(this.minor, other.minor); + if (result != 0) { + return result; + } + return Integer.compare(this.patch, other.patch); + } + + @Override + public boolean equals(Object other) { + return (this == other || (other instanceof Version otherVersion && + this.major == otherVersion.major && + this.minor == otherVersion.minor && + this.patch == otherVersion.patch)); + } + + @Override + public int hashCode() { + int result = this.major; + result = 31 * result + this.minor; + result = 31 * result + this.patch; + return result; + } + + @Override + public String toString() { + return this.major + "." + this.minor + "." + this.patch; + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/DeleteMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/DeleteMapping.java index 4681a719a9..ef09e51240 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/DeleteMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/DeleteMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -94,4 +94,10 @@ public @interface DeleteMapping { @AliasFor(annotation = RequestMapping.class) String[] produces() default {}; + /** + * Alias for {@link RequestMapping#version()}. + */ + @AliasFor(annotation = RequestMapping.class) + String version() default ""; + } diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/GetMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/GetMapping.java index 22092f77fc..5c1e962ac4 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/GetMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/GetMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -95,4 +95,10 @@ public @interface GetMapping { @AliasFor(annotation = RequestMapping.class) String[] produces() default {}; + /** + * Alias for {@link RequestMapping#version()}. + */ + @AliasFor(annotation = RequestMapping.class) + String version() default ""; + } diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/PatchMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/PatchMapping.java index c11f39e4b0..1cce89e254 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/PatchMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/PatchMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -94,4 +94,10 @@ public @interface PatchMapping { @AliasFor(annotation = RequestMapping.class) String[] produces() default {}; + /** + * Alias for {@link RequestMapping#version()}. + */ + @AliasFor(annotation = RequestMapping.class) + String version() default ""; + } diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/PostMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/PostMapping.java index 18a0b47db5..8cfb36a6e3 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/PostMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/PostMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -94,4 +94,10 @@ public @interface PostMapping { @AliasFor(annotation = RequestMapping.class) String[] produces() default {}; + /** + * Alias for {@link RequestMapping#version()}. + */ + @AliasFor(annotation = RequestMapping.class) + String version() default ""; + } diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/PutMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/PutMapping.java index 8e8cb005d0..8cfc46101a 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/PutMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/PutMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -94,4 +94,10 @@ public @interface PutMapping { @AliasFor(annotation = RequestMapping.class) String[] produces() default {}; + /** + * Alias for {@link RequestMapping#version()}. + */ + @AliasFor(annotation = RequestMapping.class) + String version() default ""; + } diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java index d01491b346..6cb0386e84 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java @@ -216,4 +216,19 @@ public @interface RequestMapping { */ String[] produces() default {}; + /** + * Narrows the primary mapping by an API version. The version may be one + * of the following: + *
    + *
  • Fixed version ("1.2") -- match this version only. + *
  • Baseline version ("1.2+") -- match this and subsequent versions. + *
+ *

A baseline version allows an endpoint to continue to work in + * subsequent versions if it remains compatible. When an incompatible change + * is made eventually, a new controller method for the same endpoint but + * with a higher version takes precedence. + * @since 7.0 + */ + String version() default ""; + } 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 new file mode 100644 index 0000000000..900fdbe06c --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java @@ -0,0 +1,70 @@ +/* + * Copyright 2002-2025 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.util.List; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +/** + * Unit tests for {@link DefaultApiVersionStrategy}. + * @author Rossen Stoyanchev + */ +public class DefaultApiVersionStrategiesTests { + + private final SemanticApiVersionParser parser = new SemanticApiVersionParser(); + + + @Test + void defaultVersion() { + SemanticApiVersionParser.Version version = this.parser.parseVersion("1.2.3"); + ApiVersionStrategy strategy = initVersionStrategy(version.toString()); + + assertThat(strategy.getDefaultVersion()).isEqualTo(version); + } + + @Test + void supportedVersions() { + SemanticApiVersionParser.Version v1 = this.parser.parseVersion("1"); + SemanticApiVersionParser.Version v2 = this.parser.parseVersion("2"); + SemanticApiVersionParser.Version v9 = this.parser.parseVersion("9"); + + DefaultApiVersionStrategy strategy = initVersionStrategy(null); + strategy.addSupportedVersion(v1.toString()); + strategy.addSupportedVersion(v2.toString()); + + MockHttpServletRequest request = new MockHttpServletRequest(); + strategy.validateVersion(v1, request); + strategy.validateVersion(v2, request); + + assertThatThrownBy(() -> strategy.validateVersion(v9, request)) + .isInstanceOf(InvalidApiVersionException.class); + } + + private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) { + return new DefaultApiVersionStrategy( + List.of(request -> request.getParameter("api-version")), + new SemanticApiVersionParser(), true, defaultValue); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/accept/PathApiVersionResolverTests.java b/spring-web/src/test/java/org/springframework/web/accept/PathApiVersionResolverTests.java new file mode 100644 index 0000000000..34631ffa7d --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/accept/PathApiVersionResolverTests.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2025 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 org.junit.jupiter.api.Test; + +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.util.ServletRequestPathUtils; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +/** + * Unit tests for {@link PathApiVersionResolver}. + * @author Rossen Stoyanchev + */ +public class PathApiVersionResolverTests { + + @Test + void resolve() { + testResolve(0, "/1.0/path", "1.0"); + testResolve(1, "/app/1.1/path", "1.1"); + } + + private static void testResolve(int index, String requestUri, String expected) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); + try { + ServletRequestPathUtils.parseAndCache(request); + String actual = new PathApiVersionResolver(index).resolveVersion(request); + assertThat(actual).isEqualTo(expected); + } + finally { + ServletRequestPathUtils.clearParsedRequestPath(request); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/accept/SemanticApiVersionParserTests.java b/spring-web/src/test/java/org/springframework/web/accept/SemanticApiVersionParserTests.java new file mode 100644 index 0000000000..11b1f2ee80 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/accept/SemanticApiVersionParserTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2025 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 org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +/** + * Unit tests for {@link SemanticApiVersionParser}. + * @author Rossen Stoyanchev + */ +public class SemanticApiVersionParserTests { + + private final SemanticApiVersionParser parser = new SemanticApiVersionParser(); + + + @Test + void parse() { + testParse("0", 0, 0, 0); + testParse("0.3", 0, 3, 0); + testParse("4.5", 4, 5, 0); + testParse("6.7.8", 6, 7, 8); + testParse("v01", 1, 0, 0); + testParse("version-1.2", 1, 2, 0); + } + + private void testParse(String input, int major, int minor, int patch) { + SemanticApiVersionParser.Version actual = this.parser.parseVersion(input); + assertThat(actual.getMajor()).isEqualTo(major); + assertThat(actual.getMinor()).isEqualTo(minor); + assertThat(actual.getPatch()).isEqualTo(patch); + } + + @ParameterizedTest + @ValueSource(strings = {"", "v", "1a", "1.0a", "1.0.0a", "1.0.0.", "1.0.0-"}) + void parseInvalid(String input) { + testParseInvalid(input); + } + + private void testParseInvalid(String input) { + assertThatIllegalStateException().isThrownBy(() -> this.parser.parseVersion(input)) + .withMessage("Invalid API version format"); + } + +} 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 new file mode 100644 index 0000000000..4578797caf --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java @@ -0,0 +1,156 @@ +/* + * Copyright 2002-2025 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.servlet.config.annotation; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.jspecify.annotations.Nullable; + +import org.springframework.web.accept.ApiVersionParser; +import org.springframework.web.accept.ApiVersionResolver; +import org.springframework.web.accept.ApiVersionStrategy; +import org.springframework.web.accept.DefaultApiVersionStrategy; +import org.springframework.web.accept.PathApiVersionResolver; +import org.springframework.web.accept.SemanticApiVersionParser; + +/** + * Configure API versioning. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public class ApiVersionConfigurer { + + private final List versionResolvers = new ArrayList<>(); + + private @Nullable ApiVersionParser versionParser; + + private boolean versionRequired = true; + + private @Nullable String defaultVersion; + + private final Set supportedVersions = new LinkedHashSet<>(); + + + /** + * Add a resolver that extracts the API version from a request header. + * @param headerName the header name to check + */ + public ApiVersionConfigurer useRequestHeader(String headerName) { + this.versionResolvers.add(request -> request.getHeader(headerName)); + return this; + } + + /** + * Add a resolver that extracts the API version from a request parameter. + * @param paramName the parameter name to check + */ + public ApiVersionConfigurer useRequestParam(String paramName) { + this.versionResolvers.add(request -> request.getParameter(paramName)); + return this; + } + + /** + * Add a resolver that extracts the API version from a path segment. + * @param index the index of the path segment to check; e.g. for URL's like + * "/{version}/..." use index 0, for "/api/{version}/..." index 1. + */ + public ApiVersionConfigurer usePathSegment(int index) { + this.versionResolvers.add(new PathApiVersionResolver(index)); + return this; + } + + /** + * Add custom resolvers to resolve the API version. + * @param resolvers the resolvers to use + */ + public ApiVersionConfigurer useVersionResolver(ApiVersionResolver... resolvers) { + this.versionResolvers.addAll(Arrays.asList(resolvers)); + return this; + } + + /** + * Configure a parser to parse API versions with. + *

By default, {@link SemanticApiVersionParser} is used. + * @param versionParser the parser to user + */ + public ApiVersionConfigurer setVersionParser(@Nullable ApiVersionParser versionParser) { + this.versionParser = versionParser; + return this; + } + + /** + * Whether requests are required to have an API version. When set to + * {@code true}, {@link org.springframework.web.accept.MissingApiVersionException} + * is raised, resulting in a 400 response if the request doesn't have an API + * version. When set to false, a request without a version is considered to + * accept any version. + *

By default, this is set to {@code true} when API versioning is enabled + * by adding at least one {@link ApiVersionResolver}). When a + * {@link #setDefaultVersion defaultVersion} is also set, this is + * automatically set to {@code false}. + * @param required whether an API version is required. + */ + public ApiVersionConfigurer setVersionRequired(boolean required) { + this.versionRequired = required; + return this; + } + + /** + * Configure a default version to assign to requests that don't specify one. + * @param defaultVersion the default version to use + */ + public ApiVersionConfigurer setDefaultVersion(@Nullable String defaultVersion) { + this.defaultVersion = defaultVersion; + return this; + } + + /** + * Add to the list of supported versions to validate request versions against. + * Request versions that are not supported result in + * {@link org.springframework.web.accept.InvalidApiVersionException}. + *

Note that the set of supported versions is populated from versions + * listed in controller mappings. Therefore, typically you do not have to + * manage this list except for the initial API version, when controller + * don't have to have a version to start. + * @param versions supported versions to add + */ + public ApiVersionConfigurer addSupportedVersions(String... versions) { + Collections.addAll(this.supportedVersions, versions); + return this; + } + + protected @Nullable ApiVersionStrategy getApiVersionStrategy() { + if (this.versionResolvers.isEmpty()) { + return null; + } + + DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers, + (this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()), + this.versionRequired, this.defaultVersion); + + this.supportedVersions.forEach(strategy::addSupportedVersion); + + return strategy; + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfiguration.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfiguration.java index 1e49d23974..420254c31a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfiguration.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -65,6 +65,11 @@ public class DelegatingWebMvcConfiguration extends WebMvcConfigurationSupport { this.configurers.configureContentNegotiation(configurer); } + @Override + protected void configureApiVersioning(ApiVersionConfigurer configurer) { + this.configurers.configureApiVersioning(configurer); + } + @Override protected void configureAsyncSupport(AsyncSupportConfigurer configurer) { this.configurers.configureAsyncSupport(configurer); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java index 14afae7a49..8d031b7990 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java @@ -70,6 +70,7 @@ import org.springframework.validation.Validator; import org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean; import org.springframework.web.ErrorResponse; import org.springframework.web.HttpRequestHandler; +import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.accept.ContentNegotiationManager; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; @@ -243,6 +244,8 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv private @Nullable ContentNegotiationManager contentNegotiationManager; + private @Nullable ApiVersionStrategy apiVersionStrategy; + private @Nullable List argumentResolvers; private @Nullable List returnValueHandlers; @@ -295,15 +298,16 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv * requests to annotated controllers. */ @Bean - @SuppressWarnings("deprecation") public RequestMappingHandlerMapping requestMappingHandlerMapping( @Qualifier("mvcContentNegotiationManager") ContentNegotiationManager contentNegotiationManager, + @Qualifier("mvcApiVersionStrategy") @Nullable ApiVersionStrategy apiVersionStrategy, @Qualifier("mvcConversionService") FormattingConversionService conversionService, @Qualifier("mvcResourceUrlProvider") ResourceUrlProvider resourceUrlProvider) { RequestMappingHandlerMapping mapping = createRequestMappingHandlerMapping(); mapping.setOrder(0); mapping.setContentNegotiationManager(contentNegotiationManager); + mapping.setApiVersionStrategy(apiVersionStrategy); initHandlerMapping(mapping, conversionService, resourceUrlProvider); @@ -466,6 +470,31 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv protected void configureContentNegotiation(ContentNegotiationConfigurer configurer) { } + /** + * Return the central strategy to manage API versioning with, or {@code null} + * if the application does not use versioning. + * @since 7.0 + */ + @Bean + public @Nullable ApiVersionStrategy mvcApiVersionStrategy() { + if (this.apiVersionStrategy == null) { + ApiVersionConfigurer configurer = new ApiVersionConfigurer(); + configureApiVersioning(configurer); + ApiVersionStrategy strategy = configurer.getApiVersionStrategy(); + if (strategy != null) { + this.apiVersionStrategy = strategy; + } + } + return this.apiVersionStrategy; + } + + /** + * Override this method to configure API versioning. + * @since 7.0 + */ + protected void configureApiVersioning(ApiVersionConfigurer configurer) { + } + /** * Return a handler mapping ordered at 1 to map URL paths directly to * view names. To configure view controllers, override diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurer.java index afa3ab093a..b80fba94de 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurer.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -66,6 +66,15 @@ public interface WebMvcConfigurer { default void configureContentNegotiation(ContentNegotiationConfigurer configurer) { } + /** + * Configure API versioning for the application. In order for versioning to + * be enabled, you must configure at least one way to resolve the API + * version from a request (e.g. via request header). + * @since 7.0 + */ + default void configureApiVersioning(ApiVersionConfigurer configurer) { + } + /** * Configure asynchronous request handling options. */ diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurerComposite.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurerComposite.java index e093d6770b..e0da20055a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurerComposite.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurerComposite.java @@ -63,6 +63,13 @@ class WebMvcConfigurerComposite implements WebMvcConfigurer { } } + @Override + public void configureApiVersioning(ApiVersionConfigurer configurer) { + for (WebMvcConfigurer delegate : this.delegates) { + delegate.configureApiVersioning(configurer); + } + } + @Override public void configureAsyncSupport(AsyncSupportConfigurer configurer) { for (WebMvcConfigurer delegate : this.delegates) { 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 new file mode 100644 index 0000000000..e38297941d --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java @@ -0,0 +1,202 @@ +/* + * Copyright 2002-2025 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.servlet.mvc.condition; + +import java.util.Collection; +import java.util.Collections; +import java.util.Set; + +import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.Nullable; + +import org.springframework.util.Assert; +import org.springframework.web.accept.ApiVersionStrategy; +import org.springframework.web.accept.InvalidApiVersionException; +import org.springframework.web.accept.NotAcceptableApiVersionException; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * Request condition to map based on the API version of the request. + * Versions can be fixed (e.g. "1.2") or baseline (e.g. "1.2+") as described + * in {@link RequestMapping#version()}. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public final class VersionRequestCondition extends AbstractRequestCondition { + + private static final String VERSION_ATTRIBUTE_NAME = VersionRequestCondition.class.getName() + ".VERSION"; + + private static final String NO_VERSION_ATTRIBUTE = "NO_VERSION"; + + private static final ApiVersionStrategy NO_OP_VERSION_STRATEGY = new NoOpApiVersionStrategy(); + + + private final @Nullable String versionValue; + + private final @Nullable Object version; + + private final boolean baselineVersion; + + private final ApiVersionStrategy versionStrategy; + + private final Set content; + + + public VersionRequestCondition() { + this.versionValue = null; + this.version = null; + this.baselineVersion = false; + this.versionStrategy = NO_OP_VERSION_STRATEGY; + this.content = Collections.emptySet(); + } + + public VersionRequestCondition(String configuredVersion, ApiVersionStrategy versionStrategy) { + this.baselineVersion = configuredVersion.endsWith("+"); + this.versionValue = updateVersion(configuredVersion, this.baselineVersion); + this.version = versionStrategy.parseVersion(this.versionValue); + this.versionStrategy = versionStrategy; + this.content = Set.of(configuredVersion); + } + + private static String updateVersion(String version, boolean baselineVersion) { + return (baselineVersion ? version.substring(0, version.length() - 1) : version); + } + + + @Override + protected Collection getContent() { + return this.content; + } + + @Override + protected String getToStringInfix() { + return " && "; + } + + public @Nullable String getVersion() { + return this.versionValue; + } + + @Override + public VersionRequestCondition combine(VersionRequestCondition other) { + return (other.version != null ? other : this); + } + + @Override + public @Nullable VersionRequestCondition getMatchingCondition(HttpServletRequest request) { + if (this.version == null) { + return this; + } + + Comparable version = (Comparable) request.getAttribute(VERSION_ATTRIBUTE_NAME); + if (version == null) { + String value = this.versionStrategy.resolveVersion(request); + version = (value != null ? parseVersion(value) : this.versionStrategy.getDefaultVersion()); + this.versionStrategy.validateVersion(version, request); + version = (version != null ? version : NO_VERSION_ATTRIBUTE); + request.setAttribute(VERSION_ATTRIBUTE_NAME, (version)); + } + + if (version == NO_VERSION_ATTRIBUTE) { + return this; + } + + // At this stage, match all versions as baseline versions. + // Strict matching for fixed versions is enforced at the end in handleMatch. + + int result = compareVersions(this.version, version); + return (result <= 0 ? this : null); + } + + private Comparable parseVersion(String value) { + try { + return this.versionStrategy.parseVersion(value); + } + catch (Exception ex) { + throw new InvalidApiVersionException(value, null, ex); + } + } + + @SuppressWarnings("unchecked") + private > int compareVersions(Object v1, Object v2) { + return ((V) v1).compareTo((V) v2); + } + + @Override + public int compareTo(VersionRequestCondition other, HttpServletRequest request) { + Object otherVersion = other.version; + if (this.version == null && otherVersion == null) { + return 0; + } + else if (this.version != null && otherVersion != null) { + // make higher version bubble up + return (-1 * compareVersions(this.version, otherVersion)); + } + else { + return (this.version != null ? -1 : 1); + } + } + + /** + * Perform a final check on the matched request mapping version. + *

In order to ensure baseline versions are properly capped by higher + * fixed versions, initially we match all versions as baseline versions in + * {@link #getMatchingCondition(HttpServletRequest)}. Once the highest of + * potentially multiple matches is selected, we enforce the strict match + * for fixed versions. + *

For example, given controller methods for "1.2+" and "1.5", and + * a request for "1.6", both are matched, allowing "1.5" to be selected, but + * that is then rejected as not acceptable since it is not an exact match. + * @param request the current request + * @throws NotAcceptableApiVersionException if the matched condition has a + * fixed version that is not equal to the request version + */ + public void handleMatch(HttpServletRequest request) { + if (this.version != null && !this.baselineVersion) { + Comparable version = (Comparable) request.getAttribute(VERSION_ATTRIBUTE_NAME); + Assert.state(version != null, "No API version attribute"); + if (!this.version.equals(version)) { + throw new NotAcceptableApiVersionException(version.toString()); + } + } + } + + + private static final class NoOpApiVersionStrategy implements ApiVersionStrategy { + + @Override + public @Nullable String resolveVersion(HttpServletRequest request) { + return null; + } + + @Override + public String parseVersion(String version) { + return version; + } + + @Override + public void validateVersion(@Nullable Comparable requestVersion, HttpServletRequest request) { + } + + @Override + public @Nullable Comparable getDefaultVersion() { + return null; + } + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfo.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfo.java index 9f0c0bfc3f..1e9da47725 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfo.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfo.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -27,6 +27,7 @@ import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; import org.springframework.util.PathMatcher; import org.springframework.util.StringUtils; +import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.accept.ContentNegotiationManager; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.servlet.handler.AbstractHandlerMapping; @@ -39,6 +40,7 @@ import org.springframework.web.servlet.mvc.condition.ProducesRequestCondition; import org.springframework.web.servlet.mvc.condition.RequestCondition; import org.springframework.web.servlet.mvc.condition.RequestConditionHolder; import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition; +import org.springframework.web.servlet.mvc.condition.VersionRequestCondition; import org.springframework.web.util.ServletRequestPathUtils; import org.springframework.web.util.UrlPathHelper; import org.springframework.web.util.pattern.PathPattern; @@ -78,6 +80,8 @@ public final class RequestMappingInfo implements RequestCondition custom) { + @Nullable ProducesRequestCondition produces, @Nullable VersionRequestCondition version, + @Nullable RequestCondition custom) { this(name, null, (patterns != null ? patterns : EMPTY_PATTERNS), @@ -124,6 +131,7 @@ public final class RequestMappingInfo implements RequestCondition custom) { + @Nullable ProducesRequestCondition produces, @Nullable VersionRequestCondition version, + @Nullable RequestCondition custom) { - this(null, patterns, methods, params, headers, consumes, produces, custom); + this(null, patterns, methods, params, headers, consumes, produces, version, custom); } /** @@ -149,8 +158,9 @@ public final class RequestMappingInfo implements RequestCondition customRequestCondition) { - this(info.name, info.patternsCondition, info.methodsCondition, info.paramsCondition, info.headersCondition, - info.consumesCondition, info.producesCondition, customRequestCondition); + this(info.name, info.patternsCondition, info.methodsCondition, info.paramsCondition, + info.headersCondition, info.consumesCondition, info.producesCondition, + info.versionCondition, customRequestCondition); } @SuppressWarnings("removal") @@ -159,8 +169,8 @@ public final class RequestMappingInfo implements RequestCondition condition = info.getActivePatternsCondition(); if (condition instanceof PathPatternsRequestCondition pprc) { extractMatchDetails(pprc, lookupPath, request); 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 a26c9389db..2089a31dd7 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 @@ -42,7 +42,9 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.util.StringValueResolver; +import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.accept.ContentNegotiationManager; +import org.springframework.web.accept.DefaultApiVersionStrategy; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -84,6 +86,8 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi private ContentNegotiationManager contentNegotiationManager = new ContentNegotiationManager(); + private @Nullable ApiVersionStrategy apiVersionStrategy; + private @Nullable StringValueResolver embeddedValueResolver; private RequestMappingInfo.BuilderConfiguration config = new RequestMappingInfo.BuilderConfiguration(); @@ -130,6 +134,23 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi return this.contentNegotiationManager; } + /** + * Configure a strategy to manage API versioning. + * @param strategy the strategy to use + * @since 7.0 + */ + public void setApiVersionStrategy(@Nullable ApiVersionStrategy strategy) { + this.apiVersionStrategy = strategy; + } + + /** + * Return the configured {@link ApiVersionStrategy} strategy. + * @since 7.0 + */ + public @Nullable ApiVersionStrategy getApiVersionStrategy() { + return this.apiVersionStrategy; + } + @Override public void setEmbeddedValueResolver(StringValueResolver resolver) { this.embeddedValueResolver = resolver; @@ -140,6 +161,7 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi public void afterPropertiesSet() { this.config = new RequestMappingInfo.BuilderConfiguration(); this.config.setContentNegotiationManager(getContentNegotiationManager()); + this.config.setApiVersionStrategy(getApiVersionStrategy()); if (getPatternParser() != null) { this.config.setPatternParser(getPatternParser()); @@ -245,6 +267,13 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi requestMappingInfo = createRequestMappingInfo((HttpExchange) httpExchanges.get(0).annotation, customCondition); } + if (requestMappingInfo != null && this.apiVersionStrategy instanceof DefaultApiVersionStrategy davs) { + String version = requestMappingInfo.getVersionCondition().getVersion(); + if (version != null) { + davs.addSupportedVersion(version); + } + } + return requestMappingInfo; } @@ -294,6 +323,7 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi .headers(requestMapping.headers()) .consumes(requestMapping.consumes()) .produces(requestMapping.produces()) + .version(requestMapping.version()) .mappingName(requestMapping.name()); if (customCondition != null) { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfigurationTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfigurationTests.java index f0e05be62a..6d51a9998b 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfigurationTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -264,9 +264,8 @@ public class DelegatingWebMvcConfigurationTests { }; RequestMappingHandlerMapping annotationsMapping = webMvcConfig.requestMappingHandlerMapping( - webMvcConfig.mvcContentNegotiationManager(), - webMvcConfig.mvcConversionService(), - webMvcConfig.mvcResourceUrlProvider()); + webMvcConfig.mvcContentNegotiationManager(), webMvcConfig.mvcApiVersionStrategy(), + webMvcConfig.mvcConversionService(), webMvcConfig.mvcResourceUrlProvider()); assertThat(annotationsMapping).isNotNull(); configAssertion.accept(annotationsMapping.getUrlPathHelper(), annotationsMapping.getPathMatcher()); @@ -330,9 +329,8 @@ public class DelegatingWebMvcConfigurationTests { }; RequestMappingHandlerMapping annotationsMapping = webMvcConfig.requestMappingHandlerMapping( - webMvcConfig.mvcContentNegotiationManager(), - webMvcConfig.mvcConversionService(), - webMvcConfig.mvcResourceUrlProvider()); + webMvcConfig.mvcContentNegotiationManager(), webMvcConfig.mvcApiVersionStrategy(), + webMvcConfig.mvcConversionService(), webMvcConfig.mvcResourceUrlProvider()); assertThat(annotationsMapping).isNotNull(); assertThat(annotationsMapping.getPatternParser()) 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 a608992b3c..38a8fa4da5 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 @@ -126,7 +126,7 @@ class WebMvcConfigurationSupportExtensionTests { @Test void handlerMappings() throws Exception { RequestMappingHandlerMapping rmHandlerMapping = this.config.requestMappingHandlerMapping( - this.config.mvcContentNegotiationManager(), + this.config.mvcContentNegotiationManager(), this.config.mvcApiVersionStrategy(), this.config.mvcConversionService(), this.config.mvcResourceUrlProvider()); rmHandlerMapping.setApplicationContext(this.context); rmHandlerMapping.afterPropertiesSet(); @@ -266,8 +266,8 @@ class WebMvcConfigurationSupportExtensionTests { NativeWebRequest webRequest = new ServletWebRequest(request); RequestMappingHandlerMapping mapping = this.config.requestMappingHandlerMapping( - this.config.mvcContentNegotiationManager(), this.config.mvcConversionService(), - this.config.mvcResourceUrlProvider()); + this.config.mvcContentNegotiationManager(), this.config.mvcApiVersionStrategy(), + this.config.mvcConversionService(), this.config.mvcResourceUrlProvider()); request.setParameter("f", "json"); ContentNegotiationManager manager = mapping.getContentNegotiationManager(); @@ -347,7 +347,7 @@ class WebMvcConfigurationSupportExtensionTests { * plus WebMvcConfigurer can switch to extending WebMvcConfigurationSupport directly for * more advanced configuration needs. */ - private class TestWebMvcConfigurationSupport extends WebMvcConfigurationSupport implements WebMvcConfigurer { + private static class TestWebMvcConfigurationSupport extends WebMvcConfigurationSupport implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registry) { @@ -384,6 +384,11 @@ class WebMvcConfigurationSupportExtensionTests { configurer.favorParameter(true).parameterName("f"); } + @Override + public void configureApiVersioning(ApiVersionConfigurer configurer) { + configurer.useRequestHeader("X-API-Version"); + } + @Override @SuppressWarnings("deprecation") public void configureAsyncSupport(AsyncSupportConfigurer configurer) { 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 new file mode 100644 index 0000000000..4ed634038e --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java @@ -0,0 +1,167 @@ +/* + * Copyright 2002-2025 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.servlet.mvc.condition; + +import java.util.Arrays; +import java.util.List; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.web.accept.DefaultApiVersionStrategy; +import org.springframework.web.accept.NotAcceptableApiVersionException; +import org.springframework.web.accept.SemanticApiVersionParser; +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +/** + * Unit tests for {@link VersionRequestCondition}. + * @author Rossen Stoyanchev + */ +public class VersionRequestConditionTests { + + private DefaultApiVersionStrategy strategy; + + + @BeforeEach + void setUp() { + this.strategy = initVersionStrategy(null); + } + + private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) { + return new DefaultApiVersionStrategy( + List.of(request -> request.getParameter("api-version")), + new SemanticApiVersionParser(), true, defaultValue); + } + + @Test + void combineMethodLevelOnly() { + VersionRequestCondition condition = emptyCondition().combine(condition("1.1")); + assertThat(condition.getVersion()).isEqualTo("1.1"); + } + + @Test + void combineTypeLevelOnly() { + VersionRequestCondition condition = condition("1.1").combine(emptyCondition()); + assertThat(condition.getVersion()).isEqualTo("1.1"); + } + + @Test + void combineTypeAndMethodLevel() { + assertThat(condition("1.1").combine(condition("1.2")).getVersion()).isEqualTo("1.2"); + } + + @Test + void fixedVersionMatch() { + String conditionVersion = "1.2"; + this.strategy.addSupportedVersion("1.1", "1.3"); + + testMatch("v1.1", conditionVersion, true, false); + testMatch("v1.2", conditionVersion, false, false); + testMatch("v1.3", conditionVersion, false, true); + } + + @Test + void baselineVersionMatch() { + String conditionVersion = "1.2+"; + this.strategy.addSupportedVersion("1.1", "1.3"); + + testMatch("v1.1", conditionVersion, true, false); + testMatch("v1.2", conditionVersion, false, false); + testMatch("v1.3", conditionVersion, false, false); + } + + private void testMatch( + String requestVersion, String conditionVersion, boolean notCompatible, boolean notAcceptable) { + + MockHttpServletRequest request = requestWithVersion(requestVersion); + VersionRequestCondition condition = condition(conditionVersion); + VersionRequestCondition match = condition.getMatchingCondition(request); + + if (notCompatible) { + assertThat(match).isNull(); + return; + } + + assertThat(match).isSameAs(condition); + + if (notAcceptable) { + assertThatThrownBy(() -> condition.handleMatch(request)).isInstanceOf(NotAcceptableApiVersionException.class); + return; + } + + condition.handleMatch(request); + } + + @Test + void missingRequiredVersion() { + assertThatThrownBy(() -> condition("1.2").getMatchingCondition(new MockHttpServletRequest("GET", "/path"))) + .hasMessage("400 BAD_REQUEST \"API version is required.\""); + } + + @Test + void defaultVersion() { + String version = "1.2"; + this.strategy = initVersionStrategy(version); + VersionRequestCondition condition = condition(version); + VersionRequestCondition match = condition.getMatchingCondition(new MockHttpServletRequest("GET", "/path")); + + assertThat(match).isSameAs(condition); + } + + @Test + void unsupportedVersion() { + assertThatThrownBy(() -> condition("1.2").getMatchingCondition(requestWithVersion("1.3"))) + .hasMessage("400 BAD_REQUEST \"Invalid API version: '1.3.0'.\""); + } + + @Test + void compare() { + testCompare("1.1", "1", "1.1"); + testCompare("1.1.1", "1", "1.1", "1.1.1"); + testCompare("10", "1.1", "10"); + testCompare("10", "2", "10"); + } + + private void testCompare(String expected, String... versions) { + List list = Arrays.stream(versions) + .map(this::condition) + .sorted((c1, c2) -> c1.compareTo(c2, new MockHttpServletRequest())) + .toList(); + + assertThat(list.get(0)).isEqualTo(condition(expected)); + } + + private VersionRequestCondition condition(String v) { + this.strategy.addSupportedVersion(v.endsWith("+") ? v.substring(0, v.length() - 1) : v); + return new VersionRequestCondition(v, this.strategy); + } + + private VersionRequestCondition emptyCondition() { + return new VersionRequestCondition(); + } + + private MockHttpServletRequest requestWithVersion(String v) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/path"); + request.addParameter("api-version", v); + return request; + } + +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMethodMappingNamingStrategyTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMethodMappingNamingStrategyTests.java index 71f569a1ef..7998a1ad34 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMethodMappingNamingStrategyTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMethodMappingNamingStrategyTests.java @@ -40,7 +40,7 @@ class RequestMappingInfoHandlerMethodMappingNamingStrategyTests { HandlerMethod handlerMethod = new HandlerMethod(new TestController(), method); @SuppressWarnings("deprecation") - RequestMappingInfo rmi = new RequestMappingInfo("foo", null, null, null, null, null, null, null); + RequestMappingInfo rmi = new RequestMappingInfo("foo", null, null, null, null, null, null, null, null); HandlerMethodMappingNamingStrategy strategy = new RequestMappingInfoHandlerMethodMappingNamingStrategy(); 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 new file mode 100644 index 0000000000..15dea79a60 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingVersionHandlerMethodTests.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2025 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.servlet.mvc.method.annotation; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.testfixture.servlet.MockHttpServletResponse; +import org.springframework.web.testfixture.servlet.MockServletConfig; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +/** + * Integration tests for API versioning. + * @author Rossen Stoyanchev + */ +public class RequestMappingVersionHandlerMethodTests { + + private DispatcherServlet dispatcherServlet; + + + @BeforeEach + void setUp() throws ServletException { + AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); + context.setServletConfig(new MockServletConfig()); + context.register(WebConfig.class, TestController.class); + context.afterPropertiesSet(); + + this.dispatcherServlet = new DispatcherServlet(context); + this.dispatcherServlet.init(new MockServletConfig()); + } + + + @Test + void initialVersion() throws Exception { + assertThat(requestWithVersion("1.0").getContentAsString()).isEqualTo("none"); + assertThat(requestWithVersion("1.1").getContentAsString()).isEqualTo("none"); + } + + @Test + void baselineVersion() throws Exception { + assertThat(requestWithVersion("1.2").getContentAsString()).isEqualTo("1.2"); + assertThat(requestWithVersion("1.3").getContentAsString()).isEqualTo("1.2"); + } + + @Test + void fixedVersion() throws Exception { + assertThat(requestWithVersion("1.5").getContentAsString()).isEqualTo("1.5"); + + MockHttpServletResponse response = requestWithVersion("1.6"); + assertThat(response.getStatus()).isEqualTo(400); + } + + private MockHttpServletResponse requestWithVersion(String version) throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/"); + request.addHeader("X-API-VERSION", version); + MockHttpServletResponse response = new MockHttpServletResponse(); + this.dispatcherServlet.service(request, response); + return response; + } + + + @EnableWebMvc + private static class WebConfig implements WebMvcConfigurer { + + @Override + public void configureApiVersioning(ApiVersionConfigurer configurer) { + configurer.useRequestHeader("X-API-Version").addSupportedVersions("1", "1.1", "1.3", "1.6"); + } + } + + + @RestController + private static class TestController { + + @GetMapping + String noVersion() { + return getBody("none"); + } + + @GetMapping(version = "1.2+") + String version1_2() { + return getBody("1.2"); + } + + @GetMapping(version = "1.5") + String version1_5() { + return getBody("1.5"); + } + + private static String getBody(String version) { + return version; + } + } + +}