Prepare to support API versioning for fn

Add default method to resolve, parse, and validate version
Simplify tests
This commit is contained in:
rstoyanchev 2025-06-27 12:13:00 +01:00
parent d045f44693
commit 224f1af08e
7 changed files with 65 additions and 56 deletions

View File

@ -62,6 +62,27 @@ public interface ApiVersionStrategy {
*/ */
@Nullable Comparable<?> getDefaultVersion(); @Nullable Comparable<?> getDefaultVersion();
/**
* Convenience method to return the parsed and validated request version,
* or the default version if configured.
* @param request the current request
* @return the parsed request version, or the default version
*/
default @Nullable Comparable<?> resolveParseAndValidateVersion(HttpServletRequest request) {
String value = resolveVersion(request);
if (value == null) {
return getDefaultVersion();
}
try {
Comparable<?> version = parseVersion(value);
validateVersion(version, request);
return version;
}
catch (Exception ex) {
throw new InvalidApiVersionException(value, null, ex);
}
}
/** /**
* Check if the requested API version is deprecated, and if so handle it * Check if the requested API version is deprecated, and if so handle it
* accordingly, e.g. by setting response headers to signal the deprecation, * accordingly, e.g. by setting response headers to signal the deprecation,

View File

@ -64,6 +64,27 @@ public interface ApiVersionStrategy {
*/ */
@Nullable Comparable<?> getDefaultVersion(); @Nullable Comparable<?> getDefaultVersion();
/**
* Convenience method to return the parsed and validated request version,
* or the default version if configured.
* @param exchange the current exchange
* @return the parsed request version, or the default version
*/
default @Nullable Comparable<?> resolveParseAndValidateVersion(ServerWebExchange exchange) {
String value = resolveVersion(exchange);
if (value == null) {
return getDefaultVersion();
}
try {
Comparable<?> version = parseVersion(value);
validateVersion(version, exchange);
return version;
}
catch (Exception ex) {
throw new InvalidApiVersionException(value, null, ex);
}
}
/** /**
* Check if the requested API version is deprecated, and if so handle it * Check if the requested API version is deprecated, and if so handle it
* accordingly, e.g. by setting response headers to signal the deprecation, * accordingly, e.g. by setting response headers to signal the deprecation,

View File

@ -93,6 +93,16 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
return this.defaultVersion; return this.defaultVersion;
} }
/**
* Whether the strategy is configured to detect supported versions.
* If this is set to {@code false} then {@link #addMappedVersion} is ignored
* and the list of supported versions can be built explicitly through calls
* to {@link #addSupportedVersion}.
*/
public boolean detectSupportedVersions() {
return this.detectSupportedVersions;
}
/** /**
* Add to the list of supported versions to check against in * Add to the list of supported versions to check against in
* {@link ApiVersionStrategy#validateVersion} before raising * {@link ApiVersionStrategy#validateVersion} before raising

View File

@ -42,7 +42,6 @@ import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.util.StringValueResolver; import org.springframework.util.StringValueResolver;
import org.springframework.web.accept.InvalidApiVersionException;
import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -180,7 +179,7 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
if (this.apiVersionStrategy != null) { if (this.apiVersionStrategy != null) {
Comparable<?> version = exchange.getAttribute(API_VERSION_ATTRIBUTE); Comparable<?> version = exchange.getAttribute(API_VERSION_ATTRIBUTE);
if (version == null) { if (version == null) {
version = getApiVersion(exchange, this.apiVersionStrategy); version = this.apiVersionStrategy.resolveParseAndValidateVersion(exchange);
if (version != null) { if (version != null) {
exchange.getAttributes().put(API_VERSION_ATTRIBUTE, version); exchange.getAttributes().put(API_VERSION_ATTRIBUTE, version);
} }
@ -189,21 +188,6 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
return super.getHandlerInternal(exchange); return super.getHandlerInternal(exchange);
} }
private static @Nullable Comparable<?> getApiVersion(ServerWebExchange exchange, ApiVersionStrategy strategy) {
String value = strategy.resolveVersion(exchange);
if (value == null) {
return strategy.getDefaultVersion();
}
try {
Comparable<?> version = strategy.parseVersion(value);
strategy.validateVersion(version, exchange);
return version;
}
catch (Exception ex) {
throw new InvalidApiVersionException(value, null, ex);
}
}
/** /**
* Uses type-level and method-level {@link RequestMapping @RequestMapping} * Uses type-level and method-level {@link RequestMapping @RequestMapping}
* and {@link HttpExchange @HttpExchange} annotations to create the * and {@link HttpExchange @HttpExchange} annotations to create the

View File

@ -50,29 +50,24 @@ public class RequestMappingVersionIntegrationTests extends AbstractRequestMappin
@ParameterizedHttpServerTest @ParameterizedHttpServerTest
void initialVersion(HttpServer httpServer) throws Exception { void mapVersion(HttpServer httpServer) throws Exception {
startServer(httpServer); startServer(httpServer);
assertThat(exchangeWithVersion("1.0").getBody()).isEqualTo("none"); assertThat(exchangeWithVersion("1.0").getBody()).isEqualTo("none");
assertThat(exchangeWithVersion("1.1").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.2").getBody()).isEqualTo("1.2");
assertThat(exchangeWithVersion("1.3").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"); assertThat(exchangeWithVersion("1.5").getBody()).isEqualTo("1.5");
assertThatThrownBy(() -> exchangeWithVersion("1.6")).isInstanceOf(HttpClientErrorException.BadRequest.class);
assertThatThrownBy(() -> exchangeWithVersion("1.6"))
.as("Should reject if highest supported below request version is fixed")
.isInstanceOf(HttpClientErrorException.BadRequest.class);
} }
@ParameterizedHttpServerTest @ParameterizedHttpServerTest
void deprecation(HttpServer httpServer) throws Exception { void deprecation(HttpServer httpServer) throws Exception {
startServer(httpServer); startServer(httpServer);
assertThat(exchangeWithVersion("1").getHeaders().getFirst("Link")) assertThat(exchangeWithVersion("1").getHeaders().getFirst("Link"))
.isEqualTo("<https://example.org/deprecation>; rel=\"deprecation\"; type=\"text/html\""); .isEqualTo("<https://example.org/deprecation>; rel=\"deprecation\"; type=\"text/html\"");
} }

View File

@ -46,7 +46,6 @@ import org.springframework.util.StringValueResolver;
import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.accept.ApiVersionStrategy;
import org.springframework.web.accept.ContentNegotiationManager; import org.springframework.web.accept.ContentNegotiationManager;
import org.springframework.web.accept.DefaultApiVersionStrategy; import org.springframework.web.accept.DefaultApiVersionStrategy;
import org.springframework.web.accept.InvalidApiVersionException;
import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -207,7 +206,7 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
if (this.apiVersionStrategy != null) { if (this.apiVersionStrategy != null) {
Comparable<?> version = (Comparable<?>) request.getAttribute(API_VERSION_ATTRIBUTE); Comparable<?> version = (Comparable<?>) request.getAttribute(API_VERSION_ATTRIBUTE);
if (version == null) { if (version == null) {
version = getApiVersion(request, this.apiVersionStrategy); version = this.apiVersionStrategy.resolveParseAndValidateVersion(request);
if (version != null) { if (version != null) {
request.setAttribute(API_VERSION_ATTRIBUTE, version); request.setAttribute(API_VERSION_ATTRIBUTE, version);
} }
@ -216,21 +215,6 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
return super.getHandlerInternal(request); return super.getHandlerInternal(request);
} }
private static @Nullable Comparable<?> getApiVersion(HttpServletRequest request, ApiVersionStrategy strategy) {
String value = strategy.resolveVersion(request);
if (value == null) {
return strategy.getDefaultVersion();
}
try {
Comparable<?> version = strategy.parseVersion(value);
strategy.validateVersion(version, request);
return version;
}
catch (Exception ex) {
throw new InvalidApiVersionException(value, null, ex);
}
}
/** /**
* Uses type-level and method-level {@link RequestMapping @RequestMapping} * Uses type-level and method-level {@link RequestMapping @RequestMapping}
* and {@link HttpExchange @HttpExchange} annotations to create the * and {@link HttpExchange @HttpExchange} annotations to create the

View File

@ -59,23 +59,17 @@ public class RequestMappingVersionHandlerMethodTests {
@Test @Test
void initialVersion() throws Exception { void mapVersion() throws Exception {
assertThat(requestWithVersion("1.0").getContentAsString()).isEqualTo("none"); assertThat(requestWithVersion("1.0").getContentAsString()).isEqualTo("none");
assertThat(requestWithVersion("1.1").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.2").getContentAsString()).isEqualTo("1.2");
assertThat(requestWithVersion("1.3").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"); assertThat(requestWithVersion("1.5").getContentAsString()).isEqualTo("1.5");
MockHttpServletResponse response = requestWithVersion("1.6"); MockHttpServletResponse response = requestWithVersion("1.6");
assertThat(response.getStatus()).isEqualTo(400); assertThat(response.getStatus())
.as("Should reject if highest supported below request version is fixed")
.isEqualTo(400);
} }
@Test @Test