This article covers the introduction of the newly added native API versioning when upgrading to Spring Boot 4, and the troubleshooting of Swagger integration issues experienced during the process.
I abandoned the traditional approach of hardcoding versions in @RequestMapping("/api/v1/...") and applied usePathSegment(1), a native versioning configuration provided by the framework. However, contrary to expectations, as soon as the server started and I called the API, I encountered a 404 Not Found error.
To make matters worse, the Swagger (Springdoc) documentation unpredictably displayed /api/1/auth in the path, and even presented a parameter form prompting me to manually input {version}, causing pure chaos. Through this trial and error, I learned the internal mechanics of Spring. I am documenting these findings along with two practical versioning strategies and Swagger configurations tailored to different situations.
Core Mechanics: How Does Spring Recognize Versions?
The core of Spring versioning is the separation of the physical path from the logical version condition.
When a client requests /api/v1/auth, Spring does not use this entire string as the mapping path. Instead, it goes through the following three steps:
- Extraction: It extracts v1 from the URL according to a specified rule (PathSegment or Resolver).
- Parsing: It normalizes v1 into the number 1 using a Parser.
- Matching: It looks for the remaining path excluding the version (/api/auth or the /api/{version}/auth pattern) in the controllers and performs a final validation to check if the @RequestMapping(version="1") condition matches.
Based on this principle, let's look at two practical application scenarios.
Scenario 1: Standard Path Segment Approach
This is the most basic and recommended approach.
It declares to the framework that a specific position (index) in the URL always represents the version.
1. ApiConfig Configuration (WebMvcConfigurer)
To keep the controller code clean, use configurePathMatch to automatically append the /api/v{version} prefix to all REST APIs. Then, specify that this position is the version using the usePathSegment(1) configuration.
ApiVersionConfig.java
Java
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import org.springframework.web.accept.ApiVersionParser;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerTypePredicate;
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class ApiVersionConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
// Restrict supported versions and handle defaults
.addSupportedVersions("1", "2")
.setDefaultVersion("1")
.setVersionRequired(false)
// Specify that the Spring routing engine uses the second segment (index 1) as the version
// (e.g., /api/v1/auth -> separated into path '/api/auth' + version '1')
.usePathSegment(1)
// Normalize incoming version strings into an internal standard format
.setVersionParser(new SimpleVersionParser());
}
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
// Apply /api/v{version} prefix globally to all RestControllers (excluding Swagger API)
configurer.addPathPrefix(
"/api/v{version:(?:1|2)}",
HandlerTypePredicate.forAnnotation(RestController.class)
.and(HandlerTypePredicate.forBasePackage("org.springdoc").negate())
);
}
/** * Parser that converts version strings into the standard format used internally.
*/
private static class SimpleVersionParser implements ApiVersionParser<String> {
@Override
public String parseVersion(String version) {
if (!StringUtils.hasText(version)) {
return null;
}
if (version.startsWith("v") || version.startsWith("V")) {
version = version.substring(1);
}
int dotIndex = version.indexOf('.');
return dotIndex == -1 ? version : version.substring(0, dotIndex);
}
}
}
2. Applying to the Controller
Manage versions only as attributes without hardcoding them in the path. Since ApiConfig already appends /api/v{version}, you only need to write the pure domain path here.
AuthController.java
Java
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(value = "/auth", version = "1")
public class AuthController {
// Final mapping: /api/v1/auth/login
@PostMapping("/login")
public String login() {
return "v1 login";
}
@PostMapping(value = "/login", version = "2")
public String loginV2() {
return "v2 login";
}
}
3. Swagger Configuration
Although Springdoc recognizes the /api/v{version} path by default, accurately isolating versions by domain requires an additional step to read and filter based on the @RequestMapping(version="...") attribute specified in the controller.
SwaggerConfig.java
Java
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Objects;
@Configuration
public class SwaggerConfig {
@Bean
public GroupedOpenApi authV1Api() {
return GroupedOpenApi.builder()
.group("auth-v1")
.pathsToMatch("/api/v*/auth/**")
.addOpenApiMethodFilter(method -> isVersionMatch(method, "1"))
.build();
}
@Bean
public GroupedOpenApi authV2Api() {
return GroupedOpenApi.builder()
.group("auth-v2")
.pathsToMatch("/api/v*/auth/**")
.addOpenApiMethodFilter(method -> isVersionMatch(method, "2"))
.build();
}
private boolean isVersionMatch(Method method, String version) {
// 1. Method-level search (takes priority if the version attribute is specified on @PostMapping, etc.)
RequestMapping methodMapping = AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class);
if (methodMapping != null && !methodMapping.version().isEmpty()) {
if (Objects.equals(methodMapping.version(), version)) {
return Objects.equals(methodMapping.version(), version);
}
return false;
}
// 2. Class-level search (checks controller class configuration if not specified on the method)
RequestMapping classMapping = AnnotatedElementUtils.findMergedAnnotation(method.getDeclaringClass(), RequestMapping.class);
if (classMapping != null && Objects.equals(classMapping.version(), version)) {
return Objects.equals(classMapping.version(), version);
}
// 3. Match fails if both are missing
return false;
}
}
Scenario 2: Custom Resolver Approach (When Exceptional APIs Exist)
If your project includes exceptional APIs that do not follow the versioning rules, such as /health or Webhooks, the usePathSegment(1) configuration will throw a fatal IndexOutOfBounds error. This happens because the path is too short to have an index 1. In this case, you need to write a Custom Resolver that manually validates the path length.
1. Custom Annotation
Create an annotation to mark APIs that should be excluded from version checks.
NonVersionApi.java
Java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface NonVersionApi {
// Controllers with this annotation are excluded from the /api/{version} prefix rule.
}
2. ApiConfig Configuration
Apply a custom useVersionResolver instead of usePathSegment.
ApiVersionConfig.java
Java
import jakarta.annotation.Nullable;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.accept.ApiVersionResolver;
@Configuration
public class ApiVersionConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
.addSupportedVersions("1", "2")
.setDefaultVersion("1")
.setVersionRequired(false)
// Use a flexible resolver that parses the version only after verifying the path structure
.useVersionResolver(new ApiPathVersionResolver())
.setVersionParser(new SimpleVersionParser());
}
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix(
"/api/v{version:(?:1|2)}",
HandlerTypePredicate.forAnnotation(RestController.class)
.and(HandlerTypePredicate.forBasePackage("org.springdoc").negate())
.and(HandlerTypePredicate.forAnnotation(NonVersionApi.class).negate()) // Exclude exceptional APIs
);
}
/** * A flexible resolver that verifies the path structure and parses the version
*/
private static class ApiPathVersionResolver implements ApiVersionResolver {
@Override
public @Nullable String resolveVersion(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null || !uri.startsWith("/api/v")) {
return null; // Ignore APIs that do not follow version rules
}
int start = 6; // Start point after "/api/v"
int end = uri.indexOf('/', start);
if (end != -1) {
return uri.substring(start, end);
}
return null;
}
}
// ... (omitted)
}
3. Applying to the Controller
Add APIs that do not have a version.
HealthController.java
Java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@NonVersionApi // Custom annotation
@RestController
public class HealthController {
@GetMapping("/health") // Bypasses version check
public String health() {
return "OK";
}
}
4. Swagger Configuration (Advanced)
When using a custom ApiVersionResolver, /api/v{version} will be displayed in the Swagger URL.
When using the default usePathSegment(), Springdoc (OpenAPI) internally recognizes the version segment and auto-completes the path. However, if you introduce a custom ApiVersionResolver and register the physical /api/v{version} path using addPathPrefix, Springdoc treats it as a simple Path Variable and exposes it raw in the documentation.
In this case, along with filtering using reflection, you need an OpenApiCustomizer to replace the path variable with the actual version string.
SwaggerConfig.java
Java
import io.swagger.v3.oas.models.Paths;
import org.springdoc.core.customizers.OpenApiCustomizer;
@Configuration
public class SwaggerConfig {
@Bean
public GroupedOpenApi v1Api() {
return GroupedOpenApi.builder()
.group("Others")
.pathsToMatch("/health")
.addOpenApiCustomizer(openApiCustomizer(null))
.build();
}
@Bean
public GroupedOpenApi authV1Api() {
return GroupedOpenApi.builder()
.group("auth-v1")
.pathsToMatch("/api/v*/auth/**")
.addOpenApiMethodFilter(method -> isVersionMatch(method, "1"))
.addOpenApiCustomizer(openApiCustomizer("1"))
.build();
}
@Bean
public GroupedOpenApi authV2Api() {
return GroupedOpenApi.builder()
.group("auth-v2")
.pathsToMatch("/api/v*/auth/**")
.addOpenApiMethodFilter(method -> isVersionMatch(method, "2"))
.addOpenApiCustomizer(openApiCustomizer("2"))
.build();
}
private OpenApiCustomizer openApiCustomizer(@Nullable String version) {
return openApi -> {
if (openApi.getPaths() == null) {
return;
}
if (version != null) {
Paths newPaths = new Paths();
openApi.getPaths().forEach((path, pathItem) -> {
String newPath = path.replaceAll("/api/(v)?\\{version[^}]*}", "/api/v" + version);
newPaths.addPathItem(newPath, pathItem);
});
openApi.setPaths(newPaths);
}
};
}
// (Note: The isVersionMatch method is written exactly as in Scenario 1.)
}
Conclusion
Spring's new versioning mechanism is not a simple string substitution. It is an advanced routing engine where the framework thoroughly breaks down the physical URL (/{version}) into a virtual logical path and version information, and then matches these against the controller's mapping conditions.
Although the initial setup may involve some trial and error, understanding this principle enables robust version management while keeping your controller code much cleaner. I hope this article serves as a helpful milestone for those looking to adopt API versioning in a Spring Boot environment.
References
Spring Framework 7.0 Release Notes (Regarding API Versioning)

