18 changed files with 1244 additions and 14 deletions
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
/* |
||||
* 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.reactive.accept; |
||||
|
||||
import org.jspecify.annotations.Nullable; |
||||
|
||||
import org.springframework.web.server.ServerWebExchange; |
||||
|
||||
/** |
||||
* Contract to extract the version from a request. |
||||
* |
||||
* @author Rossen Stoyanchev |
||||
* @since 7.0 |
||||
*/ |
||||
@FunctionalInterface |
||||
public |
||||
interface ApiVersionResolver { |
||||
|
||||
/** |
||||
* Resolve the version for the given exchange. |
||||
* @param exchange the current exchange |
||||
* @return the version value, or {@code null} if not found |
||||
*/ |
||||
@Nullable String resolveVersion(ServerWebExchange exchange); |
||||
|
||||
} |
||||
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
/* |
||||
* 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.reactive.accept; |
||||
|
||||
import org.jspecify.annotations.Nullable; |
||||
|
||||
import org.springframework.web.accept.InvalidApiVersionException; |
||||
import org.springframework.web.accept.MissingApiVersionException; |
||||
import org.springframework.web.server.ServerWebExchange; |
||||
|
||||
/** |
||||
* 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 exchange the current exchange |
||||
* @return the version, if present or {@code null} |
||||
*/ |
||||
@Nullable |
||||
String resolveVersion(ServerWebExchange exchange); |
||||
|
||||
/** |
||||
* 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 exchange the exchange |
||||
* @throws MissingApiVersionException if the version is required, but not specified |
||||
* @throws InvalidApiVersionException if the version is not supported |
||||
*/ |
||||
void validateVersion(@Nullable Comparable<?> requestVersion, ServerWebExchange exchange) |
||||
throws MissingApiVersionException, InvalidApiVersionException; |
||||
|
||||
/** |
||||
* Return a default version to use for requests that don't specify one. |
||||
*/ |
||||
@Nullable Comparable<?> getDefaultVersion(); |
||||
|
||||
} |
||||
@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
|
||||
/* |
||||
* 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.reactive.accept; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
import java.util.Set; |
||||
import java.util.TreeSet; |
||||
|
||||
import org.jspecify.annotations.Nullable; |
||||
|
||||
import org.springframework.util.Assert; |
||||
import org.springframework.web.accept.ApiVersionParser; |
||||
import org.springframework.web.accept.InvalidApiVersionException; |
||||
import org.springframework.web.accept.MissingApiVersionException; |
||||
import org.springframework.web.server.ServerWebExchange; |
||||
|
||||
/** |
||||
* 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(ServerWebExchange exchange) { |
||||
for (ApiVersionResolver resolver : this.versionResolvers) { |
||||
String version = resolver.resolveVersion(exchange); |
||||
if (version != null) { |
||||
return version; |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
@Override |
||||
public Comparable<?> parseVersion(String version) { |
||||
return this.versionParser.parseVersion(version); |
||||
} |
||||
|
||||
public void validateVersion(@Nullable Comparable<?> requestVersion, ServerWebExchange exchange) |
||||
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,58 @@
@@ -0,0 +1,58 @@
|
||||
/* |
||||
* 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.reactive.accept; |
||||
|
||||
import org.jspecify.annotations.Nullable; |
||||
|
||||
import org.springframework.http.server.PathContainer; |
||||
import org.springframework.util.Assert; |
||||
import org.springframework.web.server.ServerWebExchange; |
||||
|
||||
/** |
||||
* {@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(ServerWebExchange exchange) { |
||||
int i = 0; |
||||
for (PathContainer.Element e : exchange.getRequest().getPath().pathWithinApplication().elements()) { |
||||
if (e instanceof PathContainer.PathSegment && i++ == this.pathSegmentIndex) { |
||||
return e.value(); |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,156 @@
@@ -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.reactive.config; |
||||
|
||||
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.SemanticApiVersionParser; |
||||
import org.springframework.web.reactive.accept.ApiVersionResolver; |
||||
import org.springframework.web.reactive.accept.ApiVersionStrategy; |
||||
import org.springframework.web.reactive.accept.DefaultApiVersionStrategy; |
||||
import org.springframework.web.reactive.accept.PathApiVersionResolver; |
||||
|
||||
/** |
||||
* 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(exchange -> exchange.getRequest().getHeaders().getFirst(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(exchange -> exchange.getRequest().getQueryParams().getFirst(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; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,203 @@
@@ -0,0 +1,203 @@
|
||||
/* |
||||
* 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.reactive.result.condition; |
||||
|
||||
import java.util.Collection; |
||||
import java.util.Collections; |
||||
import java.util.Set; |
||||
|
||||
import org.jspecify.annotations.Nullable; |
||||
|
||||
import org.springframework.util.Assert; |
||||
import org.springframework.web.accept.InvalidApiVersionException; |
||||
import org.springframework.web.accept.NotAcceptableApiVersionException; |
||||
import org.springframework.web.bind.annotation.RequestMapping; |
||||
import org.springframework.web.reactive.accept.ApiVersionStrategy; |
||||
import org.springframework.web.server.ServerWebExchange; |
||||
|
||||
/** |
||||
* 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(ServerWebExchange exchange) { |
||||
if (this.version == null) { |
||||
return this; |
||||
} |
||||
|
||||
Comparable<?> version = exchange.getAttribute(VERSION_ATTRIBUTE_NAME); |
||||
if (version == null) { |
||||
String value = this.versionStrategy.resolveVersion(exchange); |
||||
version = (value != null ? parseVersion(value) : this.versionStrategy.getDefaultVersion()); |
||||
this.versionStrategy.validateVersion(version, exchange); |
||||
version = (version != null ? version : NO_VERSION_ATTRIBUTE); |
||||
exchange.getAttributes().put(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, ServerWebExchange exchange) { |
||||
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(ServerWebExchange)}. 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 exchange the current exchange |
||||
* @throws NotAcceptableApiVersionException if the matched condition has a |
||||
* fixed version that is not equal to the request version |
||||
*/ |
||||
public void handleMatch(ServerWebExchange exchange) { |
||||
if (this.version != null && !this.baselineVersion) { |
||||
Comparable<?> version = exchange.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(ServerWebExchange exchange) { |
||||
return null; |
||||
} |
||||
|
||||
@Override |
||||
public String parseVersion(String version) { |
||||
return version; |
||||
} |
||||
|
||||
@Override |
||||
public void validateVersion(@Nullable Comparable<?> requestVersion, ServerWebExchange exchange) { |
||||
} |
||||
|
||||
@Override |
||||
public @Nullable Comparable<?> getDefaultVersion() { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
|
||||
/* |
||||
* 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.reactive.accept; |
||||
|
||||
import java.util.List; |
||||
|
||||
import org.jspecify.annotations.Nullable; |
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import org.springframework.web.accept.InvalidApiVersionException; |
||||
import org.springframework.web.accept.SemanticApiVersionParser; |
||||
import org.springframework.web.server.ServerWebExchange; |
||||
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; |
||||
import org.springframework.web.testfixture.server.MockServerWebExchange; |
||||
|
||||
import static org.assertj.core.api.AssertionsForClassTypes.assertThat; |
||||
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; |
||||
|
||||
/** |
||||
* Unit tests for {@link org.springframework.web.accept.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()); |
||||
|
||||
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); |
||||
strategy.validateVersion(v1, exchange); |
||||
strategy.validateVersion(v2, exchange); |
||||
|
||||
assertThatThrownBy(() -> strategy.validateVersion(v9, exchange)) |
||||
.isInstanceOf(InvalidApiVersionException.class); |
||||
} |
||||
|
||||
private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) { |
||||
return new DefaultApiVersionStrategy( |
||||
List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")), |
||||
new SemanticApiVersionParser(), true, defaultValue); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
|
||||
/* |
||||
* 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.reactive.accept; |
||||
|
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import org.springframework.web.server.ServerWebExchange; |
||||
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; |
||||
import org.springframework.web.testfixture.server.MockServerWebExchange; |
||||
|
||||
import static org.assertj.core.api.AssertionsForClassTypes.assertThat; |
||||
|
||||
/** |
||||
* Unit tests for {@link org.springframework.web.accept.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) { |
||||
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(requestUri)); |
||||
String actual = new PathApiVersionResolver(index).resolveVersion(exchange); |
||||
assertThat(actual).isEqualTo(expected); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,172 @@
@@ -0,0 +1,172 @@
|
||||
/* |
||||
* 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.reactive.result.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.NotAcceptableApiVersionException; |
||||
import org.springframework.web.accept.SemanticApiVersionParser; |
||||
import org.springframework.web.reactive.accept.DefaultApiVersionStrategy; |
||||
import org.springframework.web.server.ServerWebExchange; |
||||
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; |
||||
import org.springframework.web.testfixture.server.MockServerWebExchange; |
||||
|
||||
import static org.assertj.core.api.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(exchange -> exchange.getRequest().getQueryParams().getFirst("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) { |
||||
|
||||
ServerWebExchange exchange = exchangeWithVersion(requestVersion); |
||||
VersionRequestCondition condition = condition(conditionVersion); |
||||
VersionRequestCondition match = condition.getMatchingCondition(exchange); |
||||
|
||||
if (notCompatible) { |
||||
assertThat(match).isNull(); |
||||
return; |
||||
} |
||||
|
||||
assertThat(match).isSameAs(condition); |
||||
|
||||
if (notAcceptable) { |
||||
assertThatThrownBy(() -> condition.handleMatch(exchange)).isInstanceOf(NotAcceptableApiVersionException.class); |
||||
return; |
||||
} |
||||
|
||||
condition.handleMatch(exchange); |
||||
} |
||||
|
||||
@Test |
||||
void missingRequiredVersion() { |
||||
assertThatThrownBy(() -> condition("1.2").getMatchingCondition(exchange())) |
||||
.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(exchange()); |
||||
|
||||
assertThat(match).isSameAs(condition); |
||||
} |
||||
|
||||
@Test |
||||
void unsupportedVersion() { |
||||
assertThatThrownBy(() -> condition("1.2").getMatchingCondition(exchangeWithVersion("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, exchange())) |
||||
.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 static MockServerWebExchange exchange() { |
||||
return MockServerWebExchange.from(MockServerHttpRequest.get("/path")); |
||||
} |
||||
|
||||
private ServerWebExchange exchangeWithVersion(String v) { |
||||
return MockServerWebExchange.from( |
||||
MockServerHttpRequest.get("/path").queryParam("api-version", v)); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
|
||||
/* |
||||
* 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.reactive.result.method.annotation; |
||||
|
||||
import org.springframework.context.ApplicationContext; |
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext; |
||||
import org.springframework.http.RequestEntity; |
||||
import org.springframework.http.ResponseEntity; |
||||
import org.springframework.web.bind.annotation.GetMapping; |
||||
import org.springframework.web.bind.annotation.RestController; |
||||
import org.springframework.web.client.HttpClientErrorException; |
||||
import org.springframework.web.reactive.config.ApiVersionConfigurer; |
||||
import org.springframework.web.reactive.config.EnableWebFlux; |
||||
import org.springframework.web.reactive.config.WebFluxConfigurer; |
||||
import org.springframework.web.testfixture.http.server.reactive.bootstrap.HttpServer; |
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
||||
/** |
||||
* {@code @RequestMapping} integration focusing on API versioning. |
||||
* @author Rossen Stoyanchev |
||||
*/ |
||||
public class RequestMappingVersionIntegrationTests extends AbstractRequestMappingIntegrationTests { |
||||
|
||||
@Override |
||||
protected ApplicationContext initApplicationContext() { |
||||
AnnotationConfigApplicationContext wac = new AnnotationConfigApplicationContext(); |
||||
wac.register(WebConfig.class, TestController.class); |
||||
wac.refresh(); |
||||
return wac; |
||||
} |
||||
|
||||
|
||||
@ParameterizedHttpServerTest |
||||
void initialVersion(HttpServer httpServer) throws Exception { |
||||
startServer(httpServer); |
||||
assertThat(exchangeWithVersion("1.0").getBody()).isEqualTo("none"); |
||||
assertThat(exchangeWithVersion("1.1").getBody()).isEqualTo("none"); |
||||
} |
||||
|
||||
@ParameterizedHttpServerTest |
||||
void baselineVersion(HttpServer httpServer) throws Exception { |
||||
startServer(httpServer); |
||||
assertThat(exchangeWithVersion("1.2").getBody()).isEqualTo("1.2"); |
||||
assertThat(exchangeWithVersion("1.3").getBody()).isEqualTo("1.2"); |
||||
} |
||||
|
||||
@ParameterizedHttpServerTest |
||||
void fixedVersion(HttpServer httpServer) throws Exception { |
||||
startServer(httpServer); |
||||
assertThat(exchangeWithVersion("1.5").getBody()).isEqualTo("1.5"); |
||||
assertThatThrownBy(() -> exchangeWithVersion("1.6")).isInstanceOf(HttpClientErrorException.BadRequest.class); |
||||
} |
||||
|
||||
private ResponseEntity<String> exchangeWithVersion(String version) { |
||||
String url = "http://localhost:" + this.port; |
||||
RequestEntity<Void> requestEntity = RequestEntity.get(url).header("X-API-Version", version).build(); |
||||
return getRestTemplate().exchange(requestEntity, String.class); |
||||
} |
||||
|
||||
|
||||
@EnableWebFlux |
||||
private static class WebConfig implements WebFluxConfigurer { |
||||
|
||||
@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; |
||||
} |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue