И так, рассмотрим пример настройки.

Дано:

Мы хотим настроить доступ к нашему API по пути /api/v1/**. А так же сделать заготовку для того чтобы можно было легко добавить новую версию API доступную по /api/v2/**.

Чем нам может помочь Spring Boot 4 из коробки?

Spring Boot предоставляет нам несколько способов конфигурирования версионности API - через properties файлы и в явном виде через соответствующий класс конфигурации.

Конфигурация через properties

Самый простой вариант будет выглядеть следующим образом:

# указываем сегмент пути в котором будет указана версия API
spring.mvc.apiversion.use.path-segment=1
# указываем версию API по-умолчанию
spring.mvc.apiversion.default=v1
# указываем возможные версии API
spring.mvc.apiversion.supported=v1,v2

Через конфигурационный класс

Для этого нам нужен класс конфигурации реализующий интерфейс WebMvcConfigurer, в котором нужно переопределить метод configureApiVersioning():

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void configureApiVersioning(ApiVersionConfigurer configurer) {
        configurer.usePathSegment(1)
                .setDefaultVersion("v1")
                .addSupportedVersions("v1", "v2");
    }
}

Теперь нам достаточно создать контроллер вида:

@RestController
@RequestMapping(value = "/api/{version}/test-entity")
public class TestController {
    @GetMapping(value = "/{id}", version="v1")
    public Map<Long, String> getTestEntityName(@PathVariable Long id) {
        return Map.of(id, String.valueOf(id));
    }

    @GetMapping(value = "/{id}", version="v2")
    public Map<String, Long> getTestEntityNameV2(@PathVariable Long id) {
        return Map.of(String.valueOf(id), id);
    }
}

И вроде бы наша задача выглядит реализованной. Можем попробовать в этом убедиться протестировав наше API запросами

GET http://localhost:8080/api/v1/test-entity/1
Accept: application/json

и

GET http://localhost:8080/api/v2/test-entity/1
Accept: application/json

Неужели все так просто и больше ничего делать не надо?

Не совсем так. Если мы попробуем отправить запрос:

GET http://localhost:8080/api/1/test-entity/1
Accept: application/json

или

GET http://localhost:8080/api/version1/test-entity/1
Accept: application/json

или даже

GET http://localhost:8080/api/something1/test-entity/1
Accept: application/json

Наше API все равно вернет корректный ответ со статусом 200. Что будет выглядеть как минимум странно. Ну и это явно не то поведение, которое мы хотели бы увидеть.

Происходит это потому, что по умолчанию Spring использует SemanticApiVersionParser для парсинга версии API, который просто отбрасывает все символы не являющиеся цифрами, а потом парсит оставшуюся строку состоящую из цифр как версию API.

Вторая проблема заключается в том, что теперь нам надо в пути для методов нашего API в явном виде каждый раз указывать переменную, в которую будет подставляться версия API. А так как мы не будем использовать эту переменную в методах нашего контроллера, еще и IDE или прочие анализаторы кода могут ругаться на неиспользуемую переменную.

Допилим напильником

Для того чтобы исправить это поведение, и реализовать логику из нашего задания, мы можем немного скорректировать наш WebConfig и переопределить ещё один метод:

@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
    configurer.addPathPrefix("/api/v{version}", HandlerTypePredicate.forAnnotation(RestController.class));
}

После этого в контроллерах, отмеченных аннотацией @RestController, нужно будет указывать только часть пути после версии API, а буква v в начале второго сегмента позволит нам отсеить запросы вида /api/1. Теперь наш контроллер будет выглядеть следующим образом:

@RestController
@RequestMapping(value = "/test-entity")
public class TestController {
    @GetMapping(value = "/{id}", version="v1")
    public Map<Long, String> getTestEntityName(@PathVariable Long id) {
        return Map.of(id, String.valueOf(id));
    }
}

Теперь если мы попробуем выполнить запрос

GET http://localhost:8080/api/something1/test-entity/1
Accept: application/json

или

GET http://localhost:8080/api/1/test-entity/1
Accept: application/json

Мы получим в ответ ошибку с 404 статусом. А запросы вида:

GET http://localhost:8080/api/v1/test-entity/1
Accept: application/json

Будут выполняться корректно - и кажется что теперь мы уж точно добились желаемого поведения.

Но для верности попробуем выполнить запрос вида:

GET http://localhost:8080/api/vsomething1/test-entity/1
Accept: application/json

И увидим что он тоже выполнится и вернет нам статус 200.

Ещё чуть-чуть допилим

Для этого сделаем собственную реализацию ApiVersionParser и добавим его в наш WebConfig:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void configureApiVersioning(ApiVersionConfigurer configurer) {
        configurer.usePathSegment(1)
                // тут мы теперь можем опустить префикс v для простоты
                .setDefaultVersion("1")
                .addSupportedVersions("1", "2")
                // Добавим собственный парсер версий
                .setVersionParser(new VersionParser());
    }

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        configurer.addPathPrefix("/api/v{version}", HandlerTypePredicate.forAnnotation(RestController.class));
    }

    private static final class VersionParser implements ApiVersionParser {
        @Override
        public Byte parseVersion(@Nonnull String version) {
            // При получении запросов наша строка с корректной версией всегда будет начинаться с v
            // потому что сюда будет попадать второй сегмент пути целиком.
            // А в PathMatchConfigurer мы добавили ему префикс v.
            if (version.charAt(0) == 'v' && version.length() > 1) {
                version = version.substring(1);
            }

            try {
                // Парсим в Byte потому что вряд ли у нас будет слишком много версий API
                return Byte.parseByte(version);
            } catch (NumberFormatException e) {
                // Вернем значение явно не соответствующее поддерживаемой версии API
                return -1;
            }
        }
    }
}

И в методах нашего контроллера мы теперь так же можем избавиться от префикса v при указании версии:

@RestController
@RequestMapping(value = "/test-entity")
public class TestController {
    @GetMapping(value = "/{id}", version="1")
    public Map<Long, String> getTestEntityName(@PathVariable Long id) {
        return Map.of(id, String.valueOf(id));
    }

    @GetMapping(value = "/{id}", version="2")
    public Map<String, Long> getTestEntityNameV2(@PathVariable Long id) {
        return Map.of(String.valueOf(id), id);
    }
}

И снова попробуем выполнить запрос:

GET http://localhost:8080/api/vsomething1/test-entity/1
Accept: application/json

Теперь в ответ мы получаем 400 статус и только запрос вида

GET http://localhost:8080/api/v1/test-entity/1
Accept: application/json

Проходит успешно.

400 статус мы получаем из-за того что при несоответствии версии полученной из парсера допустимым версиям указанным в конфиге, где-то в глубинах спринга выбрасывается InvalidApiVersionException, которое как раз и дает статус 400. Поэтому если мы хотим в случае указания ошибочной версии API выдавать 404 статус, нам нужно позаботится об это отдельно и использовать, например, @ControllerAdvice и @ExceptionHandler.