API versioning support for Spring MVC
See gh-34566
This commit is contained in:
@@ -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 <V> the parsed object type
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface ApiVersionParser<V extends Comparable<V>> {
|
||||
|
||||
/**
|
||||
* Parse the version into an Object.
|
||||
* @param version the value to parse
|
||||
* @return an Object that represents the version
|
||||
*/
|
||||
V parseVersion(String version);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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<ApiVersionResolver> versionResolvers;
|
||||
|
||||
private final ApiVersionParser<?> versionParser;
|
||||
|
||||
private final boolean versionRequired;
|
||||
|
||||
private final @Nullable Comparable<?> defaultVersion;
|
||||
|
||||
private final Set<Comparable<?>> 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<ApiVersionResolver> 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 + "]";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<SemanticApiVersionParser.Version> {
|
||||
|
||||
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<Version> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 "";
|
||||
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
* <ul>
|
||||
* <li>Fixed version ("1.2") -- match this version only.
|
||||
* <li>Baseline version ("1.2+") -- match this and subsequent versions.
|
||||
* </ul>
|
||||
* <p>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 "";
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<ApiVersionResolver> versionResolvers = new ArrayList<>();
|
||||
|
||||
private @Nullable ApiVersionParser<?> versionParser;
|
||||
|
||||
private boolean versionRequired = true;
|
||||
|
||||
private @Nullable String defaultVersion;
|
||||
|
||||
private final Set<String> 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.
|
||||
* <p>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.
|
||||
* <p>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}.
|
||||
* <p>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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HandlerMethodArgumentResolver> argumentResolvers;
|
||||
|
||||
private @Nullable List<HandlerMethodReturnValueHandler> 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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<VersionRequestCondition> {
|
||||
|
||||
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<String> 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<String> 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 <V extends Comparable<V>> 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.
|
||||
* <p>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.
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<RequestMapping
|
||||
|
||||
private static final ProducesRequestCondition EMPTY_PRODUCES = new ProducesRequestCondition();
|
||||
|
||||
private static final VersionRequestCondition EMPTY_VERSION = new VersionRequestCondition();
|
||||
|
||||
private static final RequestConditionHolder EMPTY_CUSTOM = new RequestConditionHolder(null);
|
||||
|
||||
|
||||
@@ -98,6 +102,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
|
||||
private final ProducesRequestCondition producesCondition;
|
||||
|
||||
private final VersionRequestCondition versionCondition;
|
||||
|
||||
private final RequestConditionHolder customConditionHolder;
|
||||
|
||||
private final int hashCode;
|
||||
@@ -115,7 +121,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
public RequestMappingInfo(@Nullable String name, @Nullable PatternsRequestCondition patterns,
|
||||
@Nullable RequestMethodsRequestCondition methods, @Nullable ParamsRequestCondition params,
|
||||
@Nullable HeadersRequestCondition headers, @Nullable ConsumesRequestCondition consumes,
|
||||
@Nullable ProducesRequestCondition produces, @Nullable 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<RequestMapping
|
||||
(headers != null ? headers : EMPTY_HEADERS),
|
||||
(consumes != null ? consumes : EMPTY_CONSUMES),
|
||||
(produces != null ? produces : EMPTY_PRODUCES),
|
||||
(version != null ? version : EMPTY_VERSION),
|
||||
(custom != null ? new RequestConditionHolder(custom) : EMPTY_CUSTOM),
|
||||
new BuilderConfiguration());
|
||||
}
|
||||
@@ -138,9 +146,10 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
public RequestMappingInfo(@Nullable PatternsRequestCondition patterns,
|
||||
@Nullable RequestMethodsRequestCondition methods, @Nullable ParamsRequestCondition params,
|
||||
@Nullable HeadersRequestCondition headers, @Nullable ConsumesRequestCondition consumes,
|
||||
@Nullable ProducesRequestCondition produces, @Nullable 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<RequestMapping
|
||||
*/
|
||||
@Deprecated
|
||||
public RequestMappingInfo(RequestMappingInfo info, @Nullable 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<RequestMapping
|
||||
@Nullable PatternsRequestCondition patternsCondition,
|
||||
RequestMethodsRequestCondition methodsCondition, ParamsRequestCondition paramsCondition,
|
||||
HeadersRequestCondition headersCondition, ConsumesRequestCondition consumesCondition,
|
||||
ProducesRequestCondition producesCondition, RequestConditionHolder customCondition,
|
||||
BuilderConfiguration options) {
|
||||
ProducesRequestCondition producesCondition, VersionRequestCondition versionCondition,
|
||||
RequestConditionHolder customCondition, BuilderConfiguration options) {
|
||||
|
||||
Assert.isTrue(pathPatternsCondition != null || patternsCondition != null,
|
||||
"Neither PathPatterns nor String patterns condition");
|
||||
@@ -173,13 +183,15 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
this.headersCondition = headersCondition;
|
||||
this.consumesCondition = consumesCondition;
|
||||
this.producesCondition = producesCondition;
|
||||
this.versionCondition = versionCondition;
|
||||
this.customConditionHolder = customCondition;
|
||||
this.options = options;
|
||||
|
||||
this.hashCode = calculateHashCode(
|
||||
this.pathPatternsCondition, this.patternsCondition,
|
||||
this.methodsCondition, this.paramsCondition, this.headersCondition,
|
||||
this.consumesCondition, this.producesCondition, this.customConditionHolder);
|
||||
this.consumesCondition, this.producesCondition, this.versionCondition,
|
||||
this.customConditionHolder);
|
||||
}
|
||||
|
||||
|
||||
@@ -310,6 +322,15 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
return this.producesCondition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the version condition of this {@link RequestMappingInfo},
|
||||
* or an instance without a version.
|
||||
* @since 7.0
|
||||
*/
|
||||
public VersionRequestCondition getVersionCondition() {
|
||||
return this.versionCondition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the "custom" condition of this {@link RequestMappingInfo}, or {@code null}.
|
||||
*/
|
||||
@@ -327,7 +348,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
return new RequestMappingInfo(this.name,
|
||||
this.pathPatternsCondition, this.patternsCondition,
|
||||
this.methodsCondition, this.paramsCondition, this.headersCondition,
|
||||
this.consumesCondition, this.producesCondition,
|
||||
this.consumesCondition, this.producesCondition, this.versionCondition,
|
||||
new RequestConditionHolder(customCondition), this.options);
|
||||
}
|
||||
|
||||
@@ -355,10 +376,11 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
HeadersRequestCondition headers = this.headersCondition.combine(other.headersCondition);
|
||||
ConsumesRequestCondition consumes = this.consumesCondition.combine(other.consumesCondition);
|
||||
ProducesRequestCondition produces = this.producesCondition.combine(other.producesCondition);
|
||||
VersionRequestCondition version = this.versionCondition.combine(other.versionCondition);
|
||||
RequestConditionHolder custom = this.customConditionHolder.combine(other.customConditionHolder);
|
||||
|
||||
return new RequestMappingInfo(name, pathPatterns, patterns,
|
||||
methods, params, headers, consumes, produces, custom, this.options);
|
||||
return new RequestMappingInfo(name, pathPatterns, patterns, methods,
|
||||
params, headers, consumes, produces, version, custom, this.options);
|
||||
}
|
||||
|
||||
private @Nullable String combineNames(RequestMappingInfo other) {
|
||||
@@ -406,6 +428,10 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
if (produces == null) {
|
||||
return null;
|
||||
}
|
||||
VersionRequestCondition version = this.versionCondition.getMatchingCondition(request);
|
||||
if (version == null) {
|
||||
return null;
|
||||
}
|
||||
PathPatternsRequestCondition pathPatterns = null;
|
||||
if (this.pathPatternsCondition != null) {
|
||||
pathPatterns = this.pathPatternsCondition.getMatchingCondition(request);
|
||||
@@ -425,7 +451,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
return null;
|
||||
}
|
||||
return new RequestMappingInfo(this.name, pathPatterns, patterns,
|
||||
methods, params, headers, consumes, produces, custom, this.options);
|
||||
methods, params, headers, consumes, produces, version, custom, this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -465,6 +491,10 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = this.versionCondition.compareTo(other.getVersionCondition(), request);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
// Implicit (no method) vs explicit HTTP method mappings
|
||||
result = this.methodsCondition.compareTo(other.getMethodsCondition(), request);
|
||||
if (result != 0) {
|
||||
@@ -486,6 +516,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
this.headersCondition.equals(that.headersCondition) &&
|
||||
this.consumesCondition.equals(that.consumesCondition) &&
|
||||
this.producesCondition.equals(that.producesCondition) &&
|
||||
this.versionCondition.equals(that.versionCondition) &&
|
||||
this.customConditionHolder.equals(that.customConditionHolder)));
|
||||
}
|
||||
|
||||
@@ -498,12 +529,13 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
private static int calculateHashCode(
|
||||
@Nullable PathPatternsRequestCondition pathPatterns, @Nullable PatternsRequestCondition patterns,
|
||||
RequestMethodsRequestCondition methods, ParamsRequestCondition params, HeadersRequestCondition headers,
|
||||
ConsumesRequestCondition consumes, ProducesRequestCondition produces, RequestConditionHolder custom) {
|
||||
ConsumesRequestCondition consumes, ProducesRequestCondition produces,
|
||||
VersionRequestCondition version, RequestConditionHolder custom) {
|
||||
|
||||
return (pathPatterns != null ? pathPatterns : patterns).hashCode() * 31 +
|
||||
methods.hashCode() + params.hashCode() +
|
||||
headers.hashCode() + consumes.hashCode() + produces.hashCode() +
|
||||
custom.hashCode();
|
||||
version.hashCode() + custom.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -529,6 +561,9 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
if (!this.producesCondition.isEmpty()) {
|
||||
builder.append(", produces ").append(this.producesCondition);
|
||||
}
|
||||
if (!this.versionCondition.isEmpty()) {
|
||||
builder.append(", version ").append(this.versionCondition);
|
||||
}
|
||||
if (!this.customConditionHolder.isEmpty()) {
|
||||
builder.append(", and ").append(this.customConditionHolder);
|
||||
}
|
||||
@@ -593,6 +628,12 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
*/
|
||||
Builder produces(String... produces);
|
||||
|
||||
/**
|
||||
* Set the API version condition.
|
||||
* @since 7.0
|
||||
*/
|
||||
Builder version(String version);
|
||||
|
||||
/**
|
||||
* Set the mapping name.
|
||||
*/
|
||||
@@ -629,6 +670,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
|
||||
private String[] produces = new String[0];
|
||||
|
||||
private @Nullable String version;
|
||||
|
||||
private boolean hasContentType;
|
||||
|
||||
private boolean hasAccept;
|
||||
@@ -685,6 +728,12 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Builder version(String version) {
|
||||
this.version = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DefaultBuilder mappingName(String name) {
|
||||
this.mappingName = name;
|
||||
@@ -709,9 +758,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
|
||||
PathPatternsRequestCondition pathPatternsCondition = null;
|
||||
PatternsRequestCondition patternsCondition = null;
|
||||
|
||||
PathPatternParser parser = this.options.getPatternParserToUse();
|
||||
|
||||
if (parser != null) {
|
||||
pathPatternsCondition = (ObjectUtils.isEmpty(this.paths) ?
|
||||
EMPTY_PATH_PATTERNS :
|
||||
@@ -725,6 +772,16 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
|
||||
ContentNegotiationManager manager = this.options.getContentNegotiationManager();
|
||||
|
||||
VersionRequestCondition versionCondition;
|
||||
ApiVersionStrategy versionStrategy = this.options.getApiVersionStrategy();
|
||||
if (StringUtils.hasText(this.version)) {
|
||||
Assert.state(versionStrategy != null, "API version specified, but no ApiVersionStrategy configured");
|
||||
versionCondition = new VersionRequestCondition(this.version, versionStrategy);
|
||||
}
|
||||
else {
|
||||
versionCondition = EMPTY_VERSION;
|
||||
}
|
||||
|
||||
return new RequestMappingInfo(
|
||||
this.mappingName, pathPatternsCondition, patternsCondition,
|
||||
ObjectUtils.isEmpty(this.methods) ?
|
||||
@@ -737,6 +794,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
EMPTY_CONSUMES : new ConsumesRequestCondition(this.consumes, this.headers),
|
||||
ObjectUtils.isEmpty(this.produces) && !this.hasAccept ?
|
||||
EMPTY_PRODUCES : new ProducesRequestCondition(this.produces, this.headers, manager),
|
||||
versionCondition,
|
||||
this.customCondition != null ?
|
||||
new RequestConditionHolder(this.customCondition) : EMPTY_CUSTOM, this.options);
|
||||
}
|
||||
@@ -762,6 +820,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
|
||||
private ProducesRequestCondition producesCondition;
|
||||
|
||||
private VersionRequestCondition versionCondition;
|
||||
|
||||
private RequestConditionHolder customConditionHolder;
|
||||
|
||||
private BuilderConfiguration options;
|
||||
@@ -775,6 +835,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
this.headersCondition = other.headersCondition;
|
||||
this.consumesCondition = other.consumesCondition;
|
||||
this.producesCondition = other.producesCondition;
|
||||
this.versionCondition = other.versionCondition;
|
||||
this.customConditionHolder = other.customConditionHolder;
|
||||
this.options = other.options;
|
||||
}
|
||||
@@ -832,6 +893,19 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Builder version(@Nullable String version) {
|
||||
if (version != null) {
|
||||
ApiVersionStrategy strategy = this.options.getApiVersionStrategy();
|
||||
Assert.state(strategy != null, "API version specified, but no ApiVersionStrategy configured");
|
||||
this.versionCondition = new VersionRequestCondition(version, strategy);
|
||||
}
|
||||
else {
|
||||
this.versionCondition = EMPTY_VERSION;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Builder mappingName(String name) {
|
||||
this.name = name;
|
||||
@@ -855,7 +929,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
return new RequestMappingInfo(this.name,
|
||||
this.pathPatternsCondition, this.patternsCondition,
|
||||
this.methodsCondition, this.paramsCondition, this.headersCondition,
|
||||
this.consumesCondition, this.producesCondition,
|
||||
this.consumesCondition, this.producesCondition, this.versionCondition,
|
||||
this.customConditionHolder, this.options);
|
||||
}
|
||||
}
|
||||
@@ -879,6 +953,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
|
||||
private @Nullable ContentNegotiationManager contentNegotiationManager;
|
||||
|
||||
private @Nullable ApiVersionStrategy apiVersionStrategy;
|
||||
|
||||
|
||||
/**
|
||||
* Enable use of parsed {@link PathPattern}s as described in
|
||||
@@ -978,6 +1054,23 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
|
||||
public @Nullable ContentNegotiationManager getContentNegotiationManager() {
|
||||
return this.contentNegotiationManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the strategy for API versioning.
|
||||
* @param apiVersionStrategy the strategy to use
|
||||
* @since 7.0
|
||||
*/
|
||||
public void setApiVersionStrategy(@Nullable ApiVersionStrategy apiVersionStrategy) {
|
||||
this.apiVersionStrategy = apiVersionStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the configured strategy for API versioning.
|
||||
* @since 7.0
|
||||
*/
|
||||
public @Nullable ApiVersionStrategy getApiVersionStrategy() {
|
||||
return this.apiVersionStrategy;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -131,6 +131,8 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe
|
||||
protected void handleMatch(RequestMappingInfo info, String lookupPath, HttpServletRequest request) {
|
||||
super.handleMatch(info, lookupPath, request);
|
||||
|
||||
info.getVersionCondition().handleMatch(request);
|
||||
|
||||
RequestCondition<?> condition = info.getActivePatternsCondition();
|
||||
if (condition instanceof PathPatternsRequestCondition pprc) {
|
||||
extractMatchDetails(pprc, lookupPath, request);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<VersionRequestCondition> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<RequestMappingInfo> strategy = new RequestMappingInfoHandlerMethodMappingNamingStrategy();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user