diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 6e5a8c3e1..8be3b52e0 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -9,9 +9,6 @@ name: Java CI with Maven on: - push: - branches: - - spring-boot-4 pull_request: jobs: diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/fn/AbstractRouterFunctionVisitor.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/fn/AbstractRouterFunctionVisitor.java index b619fb28e..18521311b 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/fn/AbstractRouterFunctionVisitor.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/fn/AbstractRouterFunctionVisitor.java @@ -76,6 +76,12 @@ public class AbstractRouterFunctionVisitor { */ protected Map> nestedPaths = new LinkedHashMap<>(); + /** + * The Nested versions, keyed by nesting level, set by {@code version(...)} predicates + * declared on {@code nest(...)} calls. Sticky for all routes within the nest level. + */ + protected Map nestedVersions = new LinkedHashMap<>(); + /** * The Is or. */ @@ -189,7 +195,16 @@ public void queryParam(String name, String value) { * @param version the version */ public void version(String version) { - this.version = version; + // When visited as a nest(...) predicate, currentRouterFunctionDatas is null + // (cleared by commonStartNested) and the version must apply to every route in the + // nested block. When visited as a per-route predicate, currentRouterFunctionDatas + // has been freshly initialized by route(...) and the version is one-shot. + if (this.currentRouterFunctionDatas == null && this.level > 0) { + this.nestedVersions.put(this.level, version); + } + else { + this.version = version; + } } /** @@ -281,6 +296,7 @@ public void attributes(Map map) { */ protected void commonEndNested() { nestedPaths.remove(this.level); + nestedVersions.remove(this.level); this.level--; } @@ -296,7 +312,7 @@ protected void commonStartNested() { * Common route. */ protected void commonRoute() { - String currentVersion = this.version; + String currentVersion = (this.version != null) ? this.version : currentNestedVersion(); this.version = null; this.routerFunctionDatas.addAll(currentRouterFunctionDatas); currentRouterFunctionDatas.forEach(routerFunctionData -> { @@ -308,6 +324,15 @@ protected void commonRoute() { this.attributes = new HashMap<>(); } + private String currentNestedVersion() { + if (nestedVersions.isEmpty()) { + return null; + } + // Innermost nest wins + int innermost = nestedVersions.keySet().stream().mapToInt(Integer::intValue).max().orElse(-1); + return nestedVersions.get(innermost); + } + /** * Calculate header. * diff --git a/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/fn/AbstractRouterFunctionVisitorTest.java b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/fn/AbstractRouterFunctionVisitorTest.java new file mode 100644 index 000000000..66b2461fd --- /dev/null +++ b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/fn/AbstractRouterFunctionVisitorTest.java @@ -0,0 +1,159 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * Copyright 2019-2026 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. + * * * * * + * * * * + * * * + * * + * + */ + +package org.springdoc.core.fn; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AbstractRouterFunctionVisitor}. + * + * @author oss-bot + */ +class AbstractRouterFunctionVisitorTest { + + @Test + void versionFromNestPredicateAppliesToEveryRouteInTheNest() { + TestVisitor visitor = new TestVisitor(); + + // nest(version("v1"), route(GET "/foo").and(route(GET "/bar"))) + visitor.commonStartNestedPublic(); + visitor.version("v1"); + visitor.routePublic(() -> visitor.path("/foo")); + visitor.routePublic(() -> visitor.path("/bar")); + visitor.commonEndNestedPublic(); + + List routes = visitor.getRouterFunctionDatas(); + assertThat(routes).hasSize(2); + assertThat(routes).extracting(RouterFunctionData::getPath).containsExactly("/foo", "/bar"); + assertThat(routes).extracting(RouterFunctionData::getVersion).containsExactly("v1", "v1"); + } + + @Test + void versionFromAndNestPredicateAppliesToEveryRouteInItsOwnNest() { + TestVisitor visitor = new TestVisitor(); + + // nest(version("v1"), routes).andNest(version("v2"), routes) + visitor.commonStartNestedPublic(); + visitor.version("v1"); + visitor.routePublic(() -> visitor.path("/foo")); + visitor.routePublic(() -> visitor.path("/bar")); + visitor.commonEndNestedPublic(); + + visitor.commonStartNestedPublic(); + visitor.version("v2"); + visitor.routePublic(() -> visitor.path("/foo")); + visitor.routePublic(() -> visitor.path("/bar")); + visitor.commonEndNestedPublic(); + + List routes = visitor.getRouterFunctionDatas(); + assertThat(routes).hasSize(4); + assertThat(routes).extracting(RouterFunctionData::getVersion) + .containsExactly("v1", "v1", "v2", "v2"); + } + + @Test + void perRouteVersionRemainsOneShotInsideNest() { + TestVisitor visitor = new TestVisitor(); + + // nest(path("/api"), route().version("v1").GET("/foo").and(route().GET("/bar"))) + visitor.commonStartNestedPublic(); + visitor.path("/api"); + + visitor.routePublic(() -> { + visitor.version("v1"); + visitor.path("/foo"); + }); + visitor.routePublic(() -> visitor.path("/bar")); + + visitor.commonEndNestedPublic(); + + List routes = visitor.getRouterFunctionDatas(); + assertThat(routes).hasSize(2); + assertThat(routes).extracting(RouterFunctionData::getVersion).containsExactly("v1", null); + } + + @Test + void innerNestVersionOverridesOuterNestVersion() { + TestVisitor visitor = new TestVisitor(); + + // nest(version("v1"), nest(version("v2"), route)) + visitor.commonStartNestedPublic(); + visitor.version("v1"); + + visitor.commonStartNestedPublic(); + visitor.version("v2"); + visitor.routePublic(() -> visitor.path("/foo")); + visitor.commonEndNestedPublic(); + + // After the inner nest ends, the outer version applies again + visitor.routePublic(() -> visitor.path("/bar")); + visitor.commonEndNestedPublic(); + + List routes = visitor.getRouterFunctionDatas(); + assertThat(routes).extracting(RouterFunctionData::getPath).containsExactly("/foo", "/bar"); + assertThat(routes).extracting(RouterFunctionData::getVersion).containsExactly("v2", "v1"); + } + + @Test + void topLevelPerRouteVersionRemainsOneShot() { + TestVisitor visitor = new TestVisitor(); + + // route().version("v0").GET("/top") followed by route().GET("/plain") + visitor.routePublic(() -> { + visitor.version("v0"); + visitor.path("/top"); + }); + visitor.routePublic(() -> visitor.path("/plain")); + + List routes = visitor.getRouterFunctionDatas(); + assertThat(routes).extracting(RouterFunctionData::getVersion).containsExactly("v0", null); + } + + /** + * Exposes the relevant {@code protected} hooks so tests can simulate the call order a + * real {@code RouterFunctions.Visitor} produces. + */ + private static class TestVisitor extends AbstractRouterFunctionVisitor { + + void commonStartNestedPublic() { + commonStartNested(); + } + + void commonEndNestedPublic() { + commonEndNested(); + } + + void routePublic(Runnable predicateVisits) { + this.currentRouterFunctionDatas = new ArrayList<>(); + predicateVisits.run(); + commonRoute(); + } + } +}