From f485ba733a2bf1b8a7327651558b30a8bba7f2dc Mon Sep 17 00:00:00 2001 From: Taylore Thornton Date: Wed, 24 Jun 2026 20:57:54 -0400 Subject: [PATCH 01/42] fix: pass along type_use annotations to swagger(-core) --- .../core/service/GenericParameterService.java | 16 +++++- .../api/v31/app175/HelloController.java | 41 +++++++++++++++ .../springdoc/api/v31/app175/Nullable.java | 37 +++++++++++++ .../api/v31/app175/SearchCriteria.java | 52 +++++++++++++++++++ .../api/v31/app175/SpringDocApp175Test.java | 39 ++++++++++++++ .../test/resources/results/3.1.0/app175.json | 50 ++++++++++++++++++ 6 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/HelloController.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/Nullable.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SearchCriteria.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SpringDocApp175Test.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.1.0/app175.json diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java index d7d630c06..9b2e870c3 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java @@ -439,7 +439,7 @@ private TypeAndTypeAnnotations resolveTypeAndTypeAnnotationsForParameter(MethodP && delegatingMethodParameter.getField() != null) { AnnotatedType annotated = delegatingMethodParameter.getField().getAnnotatedType(); Type type = GenericTypeResolver.resolveType(annotated.getType(), methodParameter.getContainingClass()); - return new TypeAndTypeAnnotations(type, annotationsFromAnnotatedTypeArguments(annotated)); + return new TypeAndTypeAnnotations(type, annotationsFromAnnotatedType(annotated)); } Type type = GenericTypeResolver.resolveType(methodParameter.getGenericParameterType(), methodParameter.getContainingClass()); @@ -462,6 +462,20 @@ private TypeAndTypeAnnotations resolveTypeAndTypeAnnotationsForParameter(MethodP private record TypeAndTypeAnnotations(Type type, Annotation[] typeAnnotations) { } + /** + * Collects annotations declared on the type itself and on each type argument of an + * {@link AnnotatedParameterizedType}. + * + * @param annotatedType the annotated type + * @return a new array, possibly empty + */ + private static Annotation[] annotationsFromAnnotatedType(AnnotatedType annotatedType) { + return Stream.concat( + Arrays.stream(annotatedType.getAnnotations()), + Arrays.stream(annotationsFromAnnotatedTypeArguments(annotatedType))) + .toArray(Annotation[]::new); + } + /** * Collects annotations declared on each type argument of an {@link AnnotatedParameterizedType}. * diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/HelloController.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/HelloController.java new file mode 100644 index 000000000..039554997 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/HelloController.java @@ -0,0 +1,41 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app175; + +import org.springdoc.core.annotations.ParameterObject; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class HelloController { + + @GetMapping("/vets") + public void find(@ParameterObject SearchCriteria searchCriteria) { + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/Nullable.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/Nullable.java new file mode 100644 index 000000000..504ff7078 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/Nullable.java @@ -0,0 +1,37 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app175; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE_USE) +@Retention(RetentionPolicy.RUNTIME) +@interface Nullable { +} diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SearchCriteria.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SearchCriteria.java new file mode 100644 index 000000000..7ab520c10 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SearchCriteria.java @@ -0,0 +1,52 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app175; + +import io.swagger.v3.oas.annotations.Parameter; + +class SearchCriteria { + + @Parameter(description = "Statuses to filter by.") + private Status @Nullable [] status; + + public Status[] getStatus() { + return status; + } + + public void setStatus(Status[] status) { + this.status = status; + } + + enum Status { + + ACTIVE, + + INACTIVE + + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SpringDocApp175Test.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SpringDocApp175Test.java new file mode 100644 index 000000000..4fc3e4604 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SpringDocApp175Test.java @@ -0,0 +1,39 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app175; + +import test.org.springdoc.api.v31.AbstractSpringDocTest; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +class SpringDocApp175Test extends AbstractSpringDocTest { + + @SpringBootApplication + static class SpringDocTestApp { + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.1.0/app175.json b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.1.0/app175.json new file mode 100644 index 000000000..e61cec90c --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.1.0/app175.json @@ -0,0 +1,50 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/vets": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "find", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Statuses to filter by.", + "required": false, + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE" + ] + } + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "components": {} +} From a33aaa1b9b56cef3f8a8c87905ff93b4c2029084 Mon Sep 17 00:00:00 2001 From: Taylore Thornton Date: Thu, 25 Jun 2026 06:52:12 -0400 Subject: [PATCH 02/42] but don't keep nullable if that happens somehow --- .../api/AbstractOpenApiResource.java | 2 + .../springdoc/core/utils/SpringDocUtils.java | 25 +++++ .../api/AbstractOpenApiResourceTest.java | 33 +++++++ .../api/v30/app176/HelloController.java | 44 +++++++++ .../springdoc/api/v30/app176/Nullable.java | 37 ++++++++ .../api/v30/app176/SearchCriteria.java | 59 ++++++++++++ .../api/v30/app176/SpringDocApp176Test.java | 94 +++++++++++++++++++ .../api/v31/app176/HelloController.java | 44 +++++++++ .../springdoc/api/v31/app176/Nullable.java | 37 ++++++++ .../api/v31/app176/SearchCriteria.java | 59 ++++++++++++ .../api/v31/app176/SpringDocApp176Test.java | 92 ++++++++++++++++++ 11 files changed, 526 insertions(+) create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/HelloController.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/Nullable.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SearchCriteria.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SpringDocApp176Test.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/HelloController.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/Nullable.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SearchCriteria.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SpringDocApp176Test.java diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java index 9b6d5ffa5..b44b68319 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java @@ -1297,6 +1297,8 @@ private PathItem buildPathItem(RequestMethod requestMethod, Operation operation, String name = parameter.getName(); if (!StringUtils.containsAny(operationPath, "{" + name + "}", "{*" + name + "}")) paramIt.remove(); + else + SpringDocUtils.fixNullablePathParameter(parameter); } } } diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java index e705d1b09..4b3ea9542 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java @@ -40,6 +40,7 @@ import io.swagger.v3.oas.models.media.ComposedSchema; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; @@ -215,6 +216,30 @@ else if (types == null && "null".equals(addPropSchema.getType())) { } } + /** + * Removes nullability from a path parameter's schema. A path parameter is always + * required and can never be {@code null}, so a nullable schema (e.g. propagated from a + * JSpecify {@code @Nullable} annotation on a backing {@code @ParameterObject} field that + * is reused as both a path and an optional query parameter) is invalid here. + * + * @param parameter the path parameter + */ + public static void fixNullablePathParameter(Parameter parameter) { + Schema schema = parameter.getSchema(); + if (schema == null) + return; + Set types = schema.getTypes(); + if (types != null) { + types.remove("null"); + if (types.isEmpty()) + schema.setTypes(null); + } + if ("null".equals(schema.getType())) + schema.setType(null); + if (Boolean.TRUE.equals(schema.getNullable())) + schema.setNullable(null); + } + /** * Handle schema types. * diff --git a/springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java b/springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java index 035765bbc..064b4daee 100644 --- a/springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java +++ b/springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java @@ -188,6 +188,39 @@ springDocProviders, new SpringDocCustomizers(Optional.empty(), Optional.empty(), assertThat(parameterWithoutSchema.getIn(), is(ParameterIn.QUERY.toString())); } + @Test + void removesNullableFromPathParameterSchema() { + resource = new EmptyPathsOpenApiResource( + GROUP_NAME, + openAPIBuilderObjectFactory, + requestBuilder, + responseBuilder, + operationParser, + new SpringDocConfigProperties(), + springDocProviders, new SpringDocCustomizers(Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()) + ); + + final String pathParamName = "clinicId"; + final Parameter nullablePathParameter = new Parameter() + .name(pathParamName) + .in(ParameterIn.PATH.toString()) + .schema(new StringSchema().nullable(true)); + + final Operation operation = new Operation(); + operation.setParameters(singletonList(nullablePathParameter)); + + final RouterOperation routerOperation = new RouterOperation(); + routerOperation.setMethods(new RequestMethod[] { GET }); + routerOperation.setOperationModel(operation); + routerOperation.setPath(PATH + "/{" + pathParamName + "}"); + + resource.calculatePath(routerOperation, Locale.getDefault(), this.openAPI); + + final Parameter pathParameter = resource.getOpenApi(null, Locale.getDefault()) + .getPaths().get(PATH + "/{" + pathParamName + "}").getGet().getParameters().get(0); + assertThat(pathParameter.getSchema().getNullable(), nullValue()); + } + @Test void preLoadingModeShouldNotOverwriteServers() throws InterruptedException { doCallRealMethod().when(openAPIService).updateServers(any(), any()); diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/HelloController.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/HelloController.java new file mode 100644 index 000000000..781591ba1 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/HelloController.java @@ -0,0 +1,44 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app176; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import org.springdoc.core.annotations.ParameterObject; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class HelloController { + + @GetMapping("/clinics/{clinicId}/vets") + @Parameter(name = "clinicId", in = ParameterIn.PATH) + public void find(@ParameterObject SearchCriteria searchCriteria) { + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/Nullable.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/Nullable.java new file mode 100644 index 000000000..4a39a5b99 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/Nullable.java @@ -0,0 +1,37 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app176; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE_USE) +@Retention(RetentionPolicy.RUNTIME) +@interface Nullable { +} diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SearchCriteria.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SearchCriteria.java new file mode 100644 index 000000000..c621028f3 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SearchCriteria.java @@ -0,0 +1,59 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app176; + +import io.swagger.v3.oas.annotations.Parameter; + +/** + * A parameter object whose {@code clinicId} field is reused as both an optional, nullable + * query parameter and a (required, non-null) path parameter, depending on the controller. + */ +class SearchCriteria { + + @Parameter(description = "Find vets affiliated with this clinic id.") + private @Nullable String clinicId; + + @Parameter(description = "Find vets with this name.") + private @Nullable String name; + + public String getClinicId() { + return clinicId; + } + + public void setClinicId(String clinicId) { + this.clinicId = clinicId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SpringDocApp176Test.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SpringDocApp176Test.java new file mode 100644 index 000000000..44f97736a --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SpringDocApp176Test.java @@ -0,0 +1,94 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app176; + +import com.jayway.jsonpath.JsonPath; +import net.minidev.json.JSONArray; +import org.junit.jupiter.api.Test; +import org.springdoc.core.utils.Constants; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies that {@code nullable: true} (propagated from a TYPE_USE {@code @Nullable} + * annotation on a {@code @ParameterObject} field under OpenAPI 3.0) is cleared when that + * field is reused as a path parameter, while it is preserved for query parameters. + */ +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource(properties = "springdoc.api-docs.version=openapi_3_0") +class SpringDocApp176Test { + + private static final String PATH = "$.paths.['/clinics/{clinicId}/vets'].get.parameters"; + + @Autowired + protected MockMvc mockMvc; + + private static Object readSingle(String result, String jsonPath) { + return ((JSONArray) JsonPath.parse(result).read(jsonPath)).get(0); + } + + @Test + void pathParameterIsNotNullableButQueryParameterIs() throws Exception { + MvcResult mockMvcResult = mockMvc.perform(get(Constants.DEFAULT_API_DOCS_URL)) + .andExpect(status().isOk()).andReturn(); + String result = mockMvcResult.getResponse().getContentAsString(); + + // A path parameter is always required and can never be null. + assertThat(readSingle(result, PATH + "[?(@.name == 'clinicId')].required")) + .isEqualTo(Boolean.TRUE); + assertThat(readSingle(result, PATH + "[?(@.name == 'clinicId')].schema.type")) + .isEqualTo("string"); + assertThat((JSONArray) JsonPath.parse(result).read(PATH + "[?(@.name == 'clinicId')].schema.nullable")) + .isEmpty(); + + // A nullable query parameter keeps nullable: true. + assertThat(readSingle(result, PATH + "[?(@.name == 'name')].required")) + .isEqualTo(Boolean.FALSE); + assertThat(readSingle(result, PATH + "[?(@.name == 'name')].schema.type")) + .isEqualTo("string"); + assertThat(readSingle(result, PATH + "[?(@.name == 'name')].schema.nullable")) + .isEqualTo(Boolean.TRUE); + } + + @SpringBootApplication + static class SpringDocTestApp { + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/HelloController.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/HelloController.java new file mode 100644 index 000000000..13f19db96 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/HelloController.java @@ -0,0 +1,44 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app176; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import org.springdoc.core.annotations.ParameterObject; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class HelloController { + + @GetMapping("/clinics/{clinicId}/vets") + @Parameter(name = "clinicId", in = ParameterIn.PATH) + public void find(@ParameterObject SearchCriteria searchCriteria) { + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/Nullable.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/Nullable.java new file mode 100644 index 000000000..da1e23c7d --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/Nullable.java @@ -0,0 +1,37 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app176; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE_USE) +@Retention(RetentionPolicy.RUNTIME) +@interface Nullable { +} diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SearchCriteria.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SearchCriteria.java new file mode 100644 index 000000000..68fe4e1a5 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SearchCriteria.java @@ -0,0 +1,59 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app176; + +import io.swagger.v3.oas.annotations.Parameter; + +/** + * A parameter object whose {@code clinicId} field is reused as both an optional, nullable + * query parameter and a (required, non-null) path parameter, depending on the controller. + */ +class SearchCriteria { + + @Parameter(description = "Find vets affiliated with this clinic id.") + private @Nullable String clinicId; + + @Parameter(description = "Find vets with this name.") + private @Nullable String name; + + public String getClinicId() { + return clinicId; + } + + public void setClinicId(String clinicId) { + this.clinicId = clinicId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SpringDocApp176Test.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SpringDocApp176Test.java new file mode 100644 index 000000000..639705d1a --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SpringDocApp176Test.java @@ -0,0 +1,92 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app176; + +import java.util.List; + +import com.jayway.jsonpath.JsonPath; +import net.minidev.json.JSONArray; +import org.junit.jupiter.api.Test; +import org.springdoc.core.utils.Constants; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies that a {@code null} type (propagated from a TYPE_USE {@code @Nullable} + * annotation on a {@code @ParameterObject} field) is stripped when that field is reused as + * a path parameter, while it is preserved for query parameters. + */ +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +class SpringDocApp176Test { + + private static final String PATH = "$.paths.['/clinics/{clinicId}/vets'].get.parameters"; + + @Autowired + protected MockMvc mockMvc; + + private static Object readSingle(String result, String jsonPath) { + return ((JSONArray) JsonPath.parse(result).read(jsonPath)).get(0); + } + + @Test + void pathParameterIsNotNullableButQueryParameterIs() throws Exception { + MvcResult mockMvcResult = mockMvc.perform(get(Constants.DEFAULT_API_DOCS_URL)) + .andExpect(status().isOk()).andReturn(); + String result = mockMvcResult.getResponse().getContentAsString(); + + // A path parameter is always required and can never be null. + assertThat(readSingle(result, PATH + "[?(@.name == 'clinicId')].required")) + .isEqualTo(Boolean.TRUE); + assertThat(readSingle(result, PATH + "[?(@.name == 'clinicId')].schema.type")) + .isEqualTo("string"); + + // A nullable query parameter keeps its null type. + assertThat(readSingle(result, PATH + "[?(@.name == 'name')].required")) + .isEqualTo(Boolean.FALSE); + assertThat(readSingle(result, PATH + "[?(@.name == 'name')].schema.type")) + .isEqualTo(new JSONArray() {{ + addAll(List.of("string", "null")); + }}); + } + + @SpringBootApplication + static class SpringDocTestApp { + } + +} From 24d2acf3600a78bcb4bcd8925029e2e80566a6d0 Mon Sep 17 00:00:00 2001 From: jenkins Date: Sat, 1 Aug 2026 21:51:43 +0000 Subject: [PATCH 03/42] [maven-release-plugin] prepare for next development iteration --- pom.xml | 4 ++-- springdoc-openapi-starter-common/pom.xml | 2 +- springdoc-openapi-starter-webflux-api/pom.xml | 2 +- springdoc-openapi-starter-webflux-scalar/pom.xml | 2 +- springdoc-openapi-starter-webflux-ui/pom.xml | 2 +- springdoc-openapi-starter-webmvc-api/pom.xml | 2 +- springdoc-openapi-starter-webmvc-scalar/pom.xml | 2 +- springdoc-openapi-starter-webmvc-ui/pom.xml | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pom.xml b/pom.xml index d59a34c2f..35e596c80 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 org.springdoc springdoc-openapi - 2.9.0 + 2.9.1-SNAPSHOT pom Spring openapi documentation Spring openapi documentation @@ -35,7 +35,7 @@ scm:git:git@github.com:springdoc/springdoc-openapi.git scm:git:git@github.com:springdoc/springdoc-openapi.git - v2.9.0 + HEAD diff --git a/springdoc-openapi-starter-common/pom.xml b/springdoc-openapi-starter-common/pom.xml index 7d82b86e0..c1a2cbcfd 100644 --- a/springdoc-openapi-starter-common/pom.xml +++ b/springdoc-openapi-starter-common/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.0 + 2.9.1-SNAPSHOT springdoc-openapi-starter-common ${project.artifactId} diff --git a/springdoc-openapi-starter-webflux-api/pom.xml b/springdoc-openapi-starter-webflux-api/pom.xml index d21e01ae4..357ac0f3d 100644 --- a/springdoc-openapi-starter-webflux-api/pom.xml +++ b/springdoc-openapi-starter-webflux-api/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.0 + 2.9.1-SNAPSHOT springdoc-openapi-starter-webflux-api ${project.artifactId} diff --git a/springdoc-openapi-starter-webflux-scalar/pom.xml b/springdoc-openapi-starter-webflux-scalar/pom.xml index 6d3969bbb..f2d487be6 100644 --- a/springdoc-openapi-starter-webflux-scalar/pom.xml +++ b/springdoc-openapi-starter-webflux-scalar/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.0 + 2.9.1-SNAPSHOT springdoc-openapi-starter-webflux-scalar ${project.artifactId} diff --git a/springdoc-openapi-starter-webflux-ui/pom.xml b/springdoc-openapi-starter-webflux-ui/pom.xml index edac592d5..adeffec08 100644 --- a/springdoc-openapi-starter-webflux-ui/pom.xml +++ b/springdoc-openapi-starter-webflux-ui/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.0 + 2.9.1-SNAPSHOT springdoc-openapi-starter-webflux-ui ${project.artifactId} diff --git a/springdoc-openapi-starter-webmvc-api/pom.xml b/springdoc-openapi-starter-webmvc-api/pom.xml index 1a257566e..230f18a91 100644 --- a/springdoc-openapi-starter-webmvc-api/pom.xml +++ b/springdoc-openapi-starter-webmvc-api/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.0 + 2.9.1-SNAPSHOT springdoc-openapi-starter-webmvc-api ${project.artifactId} diff --git a/springdoc-openapi-starter-webmvc-scalar/pom.xml b/springdoc-openapi-starter-webmvc-scalar/pom.xml index 862c1ebbc..2771a62e3 100644 --- a/springdoc-openapi-starter-webmvc-scalar/pom.xml +++ b/springdoc-openapi-starter-webmvc-scalar/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.0 + 2.9.1-SNAPSHOT springdoc-openapi-starter-webmvc-scalar ${project.artifactId} diff --git a/springdoc-openapi-starter-webmvc-ui/pom.xml b/springdoc-openapi-starter-webmvc-ui/pom.xml index f2233fcca..13ed99e98 100644 --- a/springdoc-openapi-starter-webmvc-ui/pom.xml +++ b/springdoc-openapi-starter-webmvc-ui/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.0 + 2.9.1-SNAPSHOT springdoc-openapi-starter-webmvc-ui ${project.artifactId} From a2377648a9d10cea11d5433010be433a3cf37cc3 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 2 Aug 2026 00:11:12 +0200 Subject: [PATCH 04/42] fixes test versions --- pom.xml | 15 +++------------ springdoc-openapi-bom/pom.xml | 2 +- springdoc-openapi-tests/pom.xml | 2 +- .../pom.xml | 2 +- .../pom.xml | 2 +- .../springdoc-openapi-data-rest-tests/pom.xml | 2 +- .../pom.xml | 2 +- .../pom.xml | 2 +- .../springdoc-openapi-groovy-tests/pom.xml | 2 +- .../springdoc-openapi-hateoas-tests/pom.xml | 2 +- .../springdoc-openapi-javadoc-tests/pom.xml | 2 +- .../pom.xml | 2 +- .../springdoc-openapi-kotlin-webmvc-tests/pom.xml | 2 +- .../springdoc-openapi-security-tests/pom.xml | 2 +- 14 files changed, 16 insertions(+), 25 deletions(-) diff --git a/pom.xml b/pom.xml index 35e596c80..d31c2facb 100644 --- a/pom.xml +++ b/pom.xml @@ -46,12 +46,14 @@ springdoc-openapi-starter-webflux-ui springdoc-openapi-starter-webmvc-scalar springdoc-openapi-starter-webflux-scalar + springdoc-openapi-bom + springdoc-openapi-tests 1.6 2.5.3 - 0.7.0 + 0.11.0 1.5.0 2.2.52 @@ -169,17 +171,6 @@ true - - springdoc-openapi-starter-common - springdoc-openapi-starter-webmvc-api - springdoc-openapi-starter-webflux-api - springdoc-openapi-starter-webmvc-ui - springdoc-openapi-starter-webflux-ui - springdoc-openapi-starter-webmvc-scalar - springdoc-openapi-starter-webflux-scalar - springdoc-openapi-bom - springdoc-openapi-tests - gpg diff --git a/springdoc-openapi-bom/pom.xml b/springdoc-openapi-bom/pom.xml index 47716befc..7670e42d7 100644 --- a/springdoc-openapi-bom/pom.xml +++ b/springdoc-openapi-bom/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT springdoc-openapi-bom ${project.artifactId} diff --git a/springdoc-openapi-tests/pom.xml b/springdoc-openapi-tests/pom.xml index df7f4e9fe..5ee7df2e3 100644 --- a/springdoc-openapi-tests/pom.xml +++ b/springdoc-openapi-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi org.springdoc - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT pom 4.0.0 diff --git a/springdoc-openapi-tests/springdoc-openapi-actuator-webflux-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-actuator-webflux-tests/pom.xml index aacb3825f..74170cc81 100644 --- a/springdoc-openapi-tests/springdoc-openapi-actuator-webflux-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-actuator-webflux-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT 4.0.0 diff --git a/springdoc-openapi-tests/springdoc-openapi-actuator-webmvc-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-actuator-webmvc-tests/pom.xml index 95d18e53f..448f65f12 100644 --- a/springdoc-openapi-tests/springdoc-openapi-actuator-webmvc-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-actuator-webmvc-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT 4.0.0 diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/pom.xml index a31f0ac97..72a918bc6 100644 --- a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT 4.0.0 springdoc-openapi-data-rest-tests diff --git a/springdoc-openapi-tests/springdoc-openapi-function-webflux-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-function-webflux-tests/pom.xml index 93479ab01..fe470602a 100644 --- a/springdoc-openapi-tests/springdoc-openapi-function-webflux-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-function-webflux-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT 4.0.0 diff --git a/springdoc-openapi-tests/springdoc-openapi-function-webmvc-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-function-webmvc-tests/pom.xml index 1a22650e8..a064f0213 100644 --- a/springdoc-openapi-tests/springdoc-openapi-function-webmvc-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-function-webmvc-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT 4.0.0 diff --git a/springdoc-openapi-tests/springdoc-openapi-groovy-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/pom.xml index f15ba3a31..c2e27a472 100644 --- a/springdoc-openapi-tests/springdoc-openapi-groovy-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi-tests - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT springdoc-openapi-groovy-tests ${project.artifactId} diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/pom.xml index 1a98d43b3..ff7e8edd4 100644 --- a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT 4.0.0 springdoc-openapi-hateoas-tests diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/pom.xml index e7be050d6..9f0654848 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/pom.xml @@ -2,7 +2,7 @@ org.springdoc springdoc-openapi-tests - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT 4.0.0 diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webflux-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-kotlin-webflux-tests/pom.xml index 7f09cf189..9842aa29c 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webflux-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webflux-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT 4.0.0 springdoc-openapi-kotlin-webflux-tests diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/pom.xml index c4028baa6..2e19d52d7 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT 4.0.0 springdoc-openapi-kotlin-webmvc-tests diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-security-tests/pom.xml index bf84ee17c..51248977b 100644 --- a/springdoc-openapi-tests/springdoc-openapi-security-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi-tests - 2.8.18-SNAPSHOT + 2.9.1-SNAPSHOT springdoc-openapi-security-tests ${project.artifactId} From ef2875d0d2022d9b9b64e71acfa0444baa804ddc Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Fri, 21 Aug 2026 11:38:17 +0200 Subject: [PATCH 05/42] Create a discoverable security policy for maven/org.springdoc:springdoc-openapi --- CONTRIBUTING.adoc | 5 ++++- README.md | 7 +++++++ SECURITY.md | 8 +++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc index aeb377cd3..6505d66b2 100644 --- a/CONTRIBUTING.adoc +++ b/CONTRIBUTING.adoc @@ -45,10 +45,13 @@ If you are reporting a bug, please help to speed up problem diagnosis by providi == Reporting Security Vulnerabilities -If you think you have found a security vulnerability in Spring Boot please *DO NOT* +If you think you have found a security vulnerability in springdoc-openapi please *DO NOT* disclose it publicly until we've had a chance to fix it. Please don't report security vulnerabilities using GitHub issues, instead head over to support@springdoc.org and learn how to disclose them responsibly. +To report a security vulnerability, you can also use the https://tidelift.com/security[Tidelift security contact]. +Tidelift will coordinate the fix and disclosure. + == Code Conventions and Housekeeping None of these is essential for a pull request, but they will all help. diff --git a/README.md b/README.md index efab041a3..36e2eafd3 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ This project is sponsored by # Table of Contents - [Full documentation](#full-documentation) +- [Security contact information](#security-contact-information) - [**Introduction**](#introduction) - [**Getting Started**](#getting-started) - [Library for springdoc-openapi integration with spring-boot and swagger-ui](#library-for-springdoc-openapi-integration-with-spring-boot-and-swagger-ui) @@ -64,6 +65,12 @@ This project is sponsored by # [Full documentation](https://springdoc.org/) +## Security contact information + +To report a security vulnerability, please use the +[Tidelift security contact](https://tidelift.com/security). +Tidelift will coordinate the fix and disclosure. + # **Introduction** The springdoc-openapi Java library helps automating the generation of API documentation diff --git a/SECURITY.md b/SECURITY.md index fda5252c2..be5f3db8e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,13 @@ ## Reporting a Vulnerability -If you think you have found a security vulnerability in Spring Boot please *DO NOT* +If you think you have found a security vulnerability in springdoc-openapi please *DO NOT* disclose it publicly until we've had a chance to fix it. Please don't report security vulnerabilities using GitHub issues, instead head over to support@springdoc.org and learn how to disclose them responsibly. + +## Security contact information + +To report a security vulnerability, please use the +[Tidelift security contact](https://tidelift.com/security). +Tidelift will coordinate the fix and disclosure. From f9be48aefd288ca5b13e66d3faa6685cea8691fc Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Fri, 21 Aug 2026 11:45:24 +0200 Subject: [PATCH 06/42] Set versioning scheme for maven/org.springdoc:springdoc-openapi --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 36e2eafd3..7fc4793ed 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ This project is sponsored by - [Full documentation](#full-documentation) - [Security contact information](#security-contact-information) +- [Versioning](#versioning) - [**Introduction**](#introduction) - [**Getting Started**](#getting-started) - [Library for springdoc-openapi integration with spring-boot and swagger-ui](#library-for-springdoc-openapi-integration-with-spring-boot-and-swagger-ui) @@ -71,6 +72,15 @@ To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. +## Versioning + +springdoc-openapi follows [Semantic Versioning](https://semver.org/). +MAJOR version increments are released in lockstep with Spring Boot MAJOR +releases and may include incompatible/breaking changes. MINOR and PATCH +releases follow standard SemVer conventions for backwards-compatible +features and fixes, respectively. See [CHANGELOG.md](CHANGELOG.md) for the +full release history. + # **Introduction** The springdoc-openapi Java library helps automating the generation of API documentation From 331317646644bc32eb4226b945d8caad33f7cda4 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Fri, 21 Aug 2026 11:55:58 +0200 Subject: [PATCH 07/42] updated security maintenance plan --- SECURITY.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index be5f3db8e..b9dcb0464 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,9 +2,15 @@ ## Supported Versions -| Version | Supported | -|---------------|--------------------| -| latest-stable | :white_check_mark: | +Security fixes are backported to the **latest minor release of the current major +version** and the **latest minor release of the immediately preceding major version**. +Older major/minor streams do not receive security patches. + +| Version stream | Supported | +|-------------------------------------|--------------------| +| Latest minor of the current major | :white_check_mark: | +| Latest minor of the previous major | :white_check_mark: | +| Anything older | :x: | ## Reporting a Vulnerability From adf0d5c53327806b91e073d586945a3a2687aa71 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sat, 5 Sep 2026 11:59:26 +0200 Subject: [PATCH 08/42] Bound the per-locale OpenAPI cache (scrutineer #25) The generated OpenAPI document is cached under the language tag of the requested locale, and that locale comes from the client Accept-Language header. Nothing capped the map, so a caller could grow the heap by one full document per distinct tag. Cap the cache with an insertion-ordered LinkedHashMap and a new springdoc.cache.max-size property (default 100, non-positive falls back to the default rather than meaning unlimited). Insertion order is deliberate: eviction then only happens on put, which AbstractOpenApiResource#getOpenApi performs under its lock, whereas an access-ordered map would restructure on get and turn the lock-free read path into a concurrent modification. GenericResponseService had the same per-locale growth: its controller advice lists were appended to on every build. Key them by advice bean instead. That also fixes a correctness bug - getGenericMapResponse uses putIfAbsent, so the first locale's translated descriptions were winning for every later request. --- CHANGELOG.md | 10 ++ .../properties/SpringDocConfigProperties.java | 33 +++++ .../core/service/GenericResponseService.java | 29 ++++- .../core/service/OpenAPIService.java | 40 +++++- .../core/service/OpenAPIServiceCacheTest.java | 115 ++++++++++++++++++ 5 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 springdoc-openapi-starter-common/src/test/java/org/springdoc/core/service/OpenAPIServiceCacheTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 1069ec58c..8db995a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Security + +- Bound the per-locale OpenAPI cache. The cache key is derived from the client-supplied `Accept-Language` header, so an + unbounded map allowed any caller to grow the heap by one full OpenAPI document per distinct language tag. The cache is + now capped by the new `springdoc.cache.max-size` property (default `100`), and the per-locale controller-advice lists in + `GenericResponseService` are keyed by advice bean instead of appended to on every build — which also stops a later + request from picking up another locale's translated descriptions. + ## [2.9.0] - 2026-07-31 ### Added diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java index 015522743..17a2f5791 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java @@ -1640,11 +1640,26 @@ public void setEnabled(boolean enabled) { * @author bnasslahsen */ public static class Cache { + + /** + * The default maximum number of cached OpenAPI documents. + */ + private static final int DEFAULT_MAX_SIZE = 100; + /** * The Disabled. */ private boolean disabled; + /** + * The maximum number of generated OpenAPI documents kept in memory, one per + * requested locale. The locale comes from the client {@code Accept-Language} + * header, so the cache is always bounded: a non-positive value falls back to the + * default of {@value #DEFAULT_MAX_SIZE} rather than meaning "unlimited". Narrow it + * further with {@code springdoc.allowed-locales}. + */ + private int maxSize = DEFAULT_MAX_SIZE; + /** * Is disabled boolean. * @@ -1662,6 +1677,24 @@ public boolean isDisabled() { public void setDisabled(boolean disabled) { this.disabled = disabled; } + + /** + * Gets max size. + * + * @return the max size + */ + public int getMaxSize() { + return maxSize; + } + + /** + * Sets max size. + * + * @param maxSize the max size + */ + public void setMaxSize(int maxSize) { + this.maxSize = maxSize > 0 ? maxSize : DEFAULT_MAX_SIZE; + } } /** diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericResponseService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericResponseService.java index edae2bf1d..7ec5c5da2 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericResponseService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericResponseService.java @@ -344,14 +344,39 @@ public void buildGenericResponse(Components components, Map find } } if (AnnotatedElementUtils.hasAnnotation(objClz, ControllerAdvice.class)) { - controllerAdviceInfos.add(controllerAdviceInfo); + replaceControllerAdviceInfo(controllerAdviceInfos, controllerAdviceInfo); } else { - localExceptionHandlers.add(controllerAdviceInfo); + replaceControllerAdviceInfo(localExceptionHandlers, controllerAdviceInfo); } } } + /** + * Stores the advice info for a controller advice bean, replacing any entry previously + * recorded for that same bean. + *

+ * {@code buildGenericResponse} runs once per requested locale, and the locale comes + * from the client {@code Accept-Language} header. Appending unconditionally therefore + * grew these lists without bound - and, because the reader keeps the first match, left + * later requests reading another locale's translated descriptions. Keying on the + * advice bean caps the lists at the number of advice beans and keeps the newest build + * authoritative. + * + * @param adviceInfos the list to update + * @param controllerAdviceInfo the advice info to store + */ + private void replaceControllerAdviceInfo(List adviceInfos, ControllerAdviceInfo controllerAdviceInfo) { + reentrantLock.lock(); + try { + adviceInfos.removeIf(existing -> existing.getControllerAdvice() == controllerAdviceInfo.getControllerAdvice()); + adviceInfos.add(controllerAdviceInfo); + } + finally { + reentrantLock.unlock(); + } + } + /** * Is response entity exception handler method boolean. * diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java index 2386f32be..caadeffdc 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java @@ -156,9 +156,12 @@ public class OpenAPIService implements ApplicationContextAware { private final SpringDocConfigProperties springDocConfigProperties; /** - * The Cached open api map. + * The Cached open api map. Bounded by {@code springdoc.cache.max-size}: the key is the + * language tag of the requested locale, which is client-controlled through the + * {@code Accept-Language} header, so an unbounded map would let any caller grow the + * heap by one full OpenAPI document per distinct tag. */ - private final Map cachedOpenAPI = new HashMap<>(); + private final Map cachedOpenAPI; /** * The Property resolver utils. @@ -216,6 +219,7 @@ public OpenAPIService(Optional openAPI, SecurityService securityParser, this.openApiBuilderCustomisers = openApiBuilderCustomizers; this.serverBaseUrlCustomizers = serverBaseUrlCustomizers; this.javadocProvider = javadocProvider; + this.cachedOpenAPI = new BoundedOpenAPICache(springDocConfigProperties.getCache().getMaxSize()); if (springDocConfigProperties.isUseFqn()) TypeNameResolver.std.setUseFqn(true); } @@ -941,4 +945,36 @@ public SecurityService getSecurityParser() { public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.context = applicationContext; } + + /** + * A size-bounded cache of generated OpenAPI documents, keyed by locale language tag. + *

+ * Insertion-ordered on purpose: eviction then only ever happens on {@code put}, which + * {@code AbstractOpenApiResource#getOpenApi} performs while holding its lock. An + * access-ordered map would restructure itself on {@code get} as well, turning the + * lock-free read path into a concurrent modification. + * + * @author bnasslahsen + */ + private static class BoundedOpenAPICache extends LinkedHashMap { + + /** + * The maximum number of entries retained. + */ + private final int maxSize; + + /** + * Instantiates a new bounded open api cache. + * + * @param maxSize the maximum number of entries retained + */ + BoundedOpenAPICache(int maxSize) { + this.maxSize = maxSize; + } + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxSize; + } + } } diff --git a/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/service/OpenAPIServiceCacheTest.java b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/service/OpenAPIServiceCacheTest.java new file mode 100644 index 000000000..e7185c62b --- /dev/null +++ b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/service/OpenAPIServiceCacheTest.java @@ -0,0 +1,115 @@ +/* + * + * * + * * * Copyright 2019-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.springdoc.core.service; + +import java.util.Locale; +import java.util.Optional; + +import io.swagger.v3.oas.models.OpenAPI; +import org.junit.jupiter.api.Test; +import org.springdoc.core.properties.SpringDocConfigProperties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Tests that the per-locale OpenAPI cache is bounded. The cache key derives from the + * client-supplied {@code Accept-Language} header, so it must never grow without limit. + * + * @author bnasslahsen + */ +class OpenAPIServiceCacheTest { + + private OpenAPIService openAPIService(SpringDocConfigProperties properties) { + return new OpenAPIService(Optional.empty(), null, properties, null, Optional.empty(), Optional.empty(), + Optional.empty()); + } + + /** + * A distinct, well-formed locale per index. The region has to be three digits: a + * two-digit one is not valid BCP 47 and would be dropped, collapsing every index onto + * the same cache key. + * @param index the index + * @return the locale + */ + private Locale locale(int index) { + return Locale.forLanguageTag(String.format("xx-%03d", index)); + } + + @Test + void evictsBeyondTheConfiguredMaxSize() { + SpringDocConfigProperties properties = new SpringDocConfigProperties(); + properties.getCache().setMaxSize(3); + OpenAPIService service = openAPIService(properties); + + for (int i = 0; i < 50; i++) { + service.setCachedOpenAPI(new OpenAPI(), locale(i)); + } + + // the three most recently written locales survive, everything older is evicted + assertNotNull(service.getCachedOpenAPI(locale(49))); + assertNotNull(service.getCachedOpenAPI(locale(48))); + assertNotNull(service.getCachedOpenAPI(locale(47))); + assertNull(service.getCachedOpenAPI(locale(46))); + assertNull(service.getCachedOpenAPI(locale(0))); + } + + @Test + void appliesADefaultBoundWhenUnconfigured() { + SpringDocConfigProperties properties = new SpringDocConfigProperties(); + assertEquals(100, properties.getCache().getMaxSize()); + OpenAPIService service = openAPIService(properties); + + for (int i = 0; i < 500; i++) { + service.setCachedOpenAPI(new OpenAPI(), locale(i)); + } + + assertNotNull(service.getCachedOpenAPI(locale(499))); + assertNull(service.getCachedOpenAPI(locale(399))); + } + + @Test + void aNonPositiveMaxSizeFallsBackToTheDefaultRatherThanMeaningUnlimited() { + SpringDocConfigProperties properties = new SpringDocConfigProperties(); + properties.getCache().setMaxSize(0); + assertEquals(100, properties.getCache().getMaxSize()); + + properties.getCache().setMaxSize(-1); + assertEquals(100, properties.getCache().getMaxSize()); + } + + @Test + void rewritingTheSameLocaleDoesNotConsumeCapacity() { + SpringDocConfigProperties properties = new SpringDocConfigProperties(); + properties.getCache().setMaxSize(2); + OpenAPIService service = openAPIService(properties); + + for (int i = 0; i < 10; i++) { + service.setCachedOpenAPI(new OpenAPI(), Locale.ENGLISH); + } + service.setCachedOpenAPI(new OpenAPI(), Locale.FRENCH); + + assertNotNull(service.getCachedOpenAPI(Locale.ENGLISH)); + assertNotNull(service.getCachedOpenAPI(Locale.FRENCH)); + } + +} From 67abb9036cf595dc4e9e57ccf1869c7daeafb1f5 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sat, 5 Sep 2026 12:05:28 +0200 Subject: [PATCH 09/42] Defer forwarded-header handling to the application (scrutineer #30) Both Scalar starters registered a ForwardedHeaderFilter (WebMVC) or a ForwardedHeaderTransformer (WebFlux) unconditionally. That is not scoped to the Scalar endpoints: it makes the whole application derive scheme, host and prefix from Forwarded / X-Forwarded-* on every request. An application that is not fronted by a proxy stripping those headers then trusts values any client can set, and it never asked for that - it only added a documentation UI. Drop both beans. Forwarded headers are now handled exactly as Spring Boot handles them everywhere else, through server.forward-headers-strategy, and the class Javadoc points proxy deployments at that property. --- CHANGELOG.md | 5 ++ .../webflux/scalar/ScalarConfiguration.java | 19 ++--- .../forwarded/ScalarForwardedHeaderTest.java | 74 +++++++++++++++++ .../webmvc/scalar/ScalarConfiguration.java | 20 ++--- .../forwarded/ScalarForwardedHeaderTest.java | 83 +++++++++++++++++++ 5 files changed, 174 insertions(+), 27 deletions(-) create mode 100644 springdoc-openapi-starter-webflux-scalar/src/test/java/test/org/springdoc/webflux/scalar/forwarded/ScalarForwardedHeaderTest.java create mode 100644 springdoc-openapi-starter-webmvc-scalar/src/test/java/test/org/springdoc/webmvc/scalar/forwarded/ScalarForwardedHeaderTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db995a04..f74d09e12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 now capped by the new `springdoc.cache.max-size` property (default `100`), and the per-locale controller-advice lists in `GenericResponseService` are keyed by advice bean instead of appended to on every build — which also stops a later request from picking up another locale's translated descriptions. +- The Scalar starters no longer register a `ForwardedHeaderFilter` (WebMVC) or `ForwardedHeaderTransformer` (WebFlux). + Adding the starter silently made the *whole* application trust `Forwarded` / `X-Forwarded-*` headers, which is a + deployment decision and unsafe when the application is not fronted by a proxy that strips them. Forwarded-header + handling now follows `server.forward-headers-strategy` like any other Spring Boot application. **Behaviour change**: + if you serve Scalar behind a reverse proxy, set `server.forward-headers-strategy=framework` (or `native`). ## [2.9.0] - 2026-07-31 diff --git a/springdoc-openapi-starter-webflux-scalar/src/main/java/org/springdoc/webflux/scalar/ScalarConfiguration.java b/springdoc-openapi-starter-webflux-scalar/src/main/java/org/springdoc/webflux/scalar/ScalarConfiguration.java index fe5e31ec5..9bf396802 100644 --- a/springdoc-openapi-starter-webflux-scalar/src/main/java/org/springdoc/webflux/scalar/ScalarConfiguration.java +++ b/springdoc-openapi-starter-webflux-scalar/src/main/java/org/springdoc/webflux/scalar/ScalarConfiguration.java @@ -45,7 +45,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; -import org.springframework.web.server.adapter.ForwardedHeaderTransformer; import static org.springdoc.core.utils.Constants.SCALAR_ENABLED; import static org.springdoc.core.utils.Constants.SPRINGDOC_USE_MANAGEMENT_PORT; @@ -53,6 +52,12 @@ /** * The type Scalar configuration. + *

+ * Adding this starter does not change how {@code Forwarded} and {@code X-Forwarded-*} + * headers are handled: that stays a deployment decision, made once for the whole + * application through {@code server.forward-headers-strategy}. Behind a reverse proxy, + * set it to {@code framework} (or {@code native}) so that the Scalar page resolves the + * externally visible URL of the OpenAPI description. * * @author bnasslahsen */ @@ -79,18 +84,6 @@ ScalarWebFluxController scalarWebMvcController(SpringBootScalarProperties scalar return new ScalarWebFluxController(scalarProperties, springDocConfigProperties); } - /** - * Forwarded header transformer forwarded header transformer. - * - * @return the forwarded header transformer - */ - @Bean - @ConditionalOnMissingBean - @Lazy(false) - ForwardedHeaderTransformer forwardedHeaderTransformer() { - return new ForwardedHeaderTransformer(); - } - /** * Spring doc app initializer spring doc app initializer. * diff --git a/springdoc-openapi-starter-webflux-scalar/src/test/java/test/org/springdoc/webflux/scalar/forwarded/ScalarForwardedHeaderTest.java b/springdoc-openapi-starter-webflux-scalar/src/test/java/test/org/springdoc/webflux/scalar/forwarded/ScalarForwardedHeaderTest.java new file mode 100644 index 000000000..35be60c10 --- /dev/null +++ b/springdoc-openapi-starter-webflux-scalar/src/test/java/test/org/springdoc/webflux/scalar/forwarded/ScalarForwardedHeaderTest.java @@ -0,0 +1,74 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.webflux.scalar.forwarded; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.server.adapter.ForwardedHeaderTransformer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Checks that the scalar starter leaves forwarded-header handling to the application, so + * that adding it never makes the whole application start trusting {@code X-Forwarded-*}. + * + * @author bnasslahsen + */ +@ActiveProfiles("test") +@SpringBootTest +class ScalarForwardedHeaderTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void doesNotRegisterForwardedHeaderTransformerByDefault() { + assertThat(applicationContext.getBeanNamesForType(ForwardedHeaderTransformer.class)).isEmpty(); + } + + /** + * The application asked for forwarded headers: Spring Boot, not the scalar starter, + * registers the transformer. + */ + @Nested + @SpringBootTest(properties = "server.forward-headers-strategy=framework") + class WhenTheApplicationOptsIn { + + @Autowired + private ApplicationContext nestedApplicationContext; + + @Test + void registersForwardedHeaderTransformer() { + assertThat(nestedApplicationContext.getBeanNamesForType(ForwardedHeaderTransformer.class)).isNotEmpty(); + } + + } + + @SpringBootApplication + static class SpringDocTestApp { + + } + +} diff --git a/springdoc-openapi-starter-webmvc-scalar/src/main/java/org/springdoc/webmvc/scalar/ScalarConfiguration.java b/springdoc-openapi-starter-webmvc-scalar/src/main/java/org/springdoc/webmvc/scalar/ScalarConfiguration.java index 1d648ef20..89871abc0 100644 --- a/springdoc-openapi-starter-webmvc-scalar/src/main/java/org/springdoc/webmvc/scalar/ScalarConfiguration.java +++ b/springdoc-openapi-starter-webmvc-scalar/src/main/java/org/springdoc/webmvc/scalar/ScalarConfiguration.java @@ -42,11 +42,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; -import org.springframework.web.filter.ForwardedHeaderFilter; import static org.springdoc.core.utils.Constants.SCALAR_ENABLED; import static org.springdoc.core.utils.Constants.SPRINGDOC_USE_MANAGEMENT_PORT; @@ -54,6 +52,12 @@ /** * The type Scalar configuration. + *

+ * Adding this starter does not change how {@code Forwarded} and {@code X-Forwarded-*} + * headers are handled: that stays a deployment decision, made once for the whole + * application through {@code server.forward-headers-strategy}. Behind a reverse proxy, + * set it to {@code framework} (or {@code native}) so that the Scalar page resolves the + * externally visible URL of the OpenAPI description. * * @author bnasslahsen */ @@ -80,18 +84,6 @@ ScalarWebMvcController scalarWebMvcController(SpringBootScalarProperties scalarP return new ScalarWebMvcController(scalarProperties, springDocConfigProperties); } - /** - * Forwarded header filter filter registration bean. - * - * @return the filter registration bean - */ - @Bean - @ConditionalOnMissingBean - @Lazy(false) - public FilterRegistrationBean forwardedHeaderFilter() { - return new FilterRegistrationBean<>(new ForwardedHeaderFilter()); - } - /** * Spring doc app initializer spring doc app initializer. * diff --git a/springdoc-openapi-starter-webmvc-scalar/src/test/java/test/org/springdoc/webmvc/scalar/forwarded/ScalarForwardedHeaderTest.java b/springdoc-openapi-starter-webmvc-scalar/src/test/java/test/org/springdoc/webmvc/scalar/forwarded/ScalarForwardedHeaderTest.java new file mode 100644 index 000000000..ab2138e62 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-scalar/src/test/java/test/org/springdoc/webmvc/scalar/forwarded/ScalarForwardedHeaderTest.java @@ -0,0 +1,83 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.webmvc.scalar.forwarded; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ResolvableType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.filter.ForwardedHeaderFilter; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Checks that the scalar starter leaves forwarded-header handling to the application, so + * that adding it never makes the whole application start trusting {@code X-Forwarded-*}. + * + * @author bnasslahsen + */ +@ActiveProfiles("test") +@SpringBootTest +class ScalarForwardedHeaderTest { + + /** + * The type Spring Boot registers when {@code server.forward-headers-strategy=framework}. + */ + private static final ResolvableType FILTER_REGISTRATION = ResolvableType + .forClassWithGenerics(FilterRegistrationBean.class, ForwardedHeaderFilter.class); + + @Autowired + private ApplicationContext applicationContext; + + @Test + void doesNotRegisterForwardedHeaderFilterByDefault() { + assertThat(applicationContext.getBeanNamesForType(ForwardedHeaderFilter.class)).isEmpty(); + assertThat(applicationContext.getBeanNamesForType(FILTER_REGISTRATION)).isEmpty(); + } + + /** + * The application asked for forwarded headers: Spring Boot, not the scalar starter, + * registers the filter. + */ + @Nested + @SpringBootTest(properties = "server.forward-headers-strategy=framework") + class WhenTheApplicationOptsIn { + + @Autowired + private ApplicationContext nestedApplicationContext; + + @Test + void registersForwardedHeaderFilter() { + assertThat(nestedApplicationContext.getBeanNamesForType(FILTER_REGISTRATION)).isNotEmpty(); + } + + } + + @SpringBootApplication + static class SpringDocTestApp { + + } + +} From 6316b2cb1f3c30c5fe5e7fb7bedba31b84dd108c Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sat, 5 Sep 2026 12:18:19 +0200 Subject: [PATCH 10/42] Render the Scalar page from request-local state (scrutineer #31) AbstractScalarController computed the URL of the OpenAPI description from the current request and wrote it back into the ScalarProperties instance every request shares, then - when groups are configured - replaced it with the group sources and set the URL to null. Two requests in flight at once overwrote each other's URL, and one landing between those two writes rendered a page whose url was null, so the reference loaded nothing. Build the properties for the render from a copy instead, and leave the shared instance alone. BeanUtils does the copying rather than an explicit list of setters, so options added by future scalar versions are carried over on their own. --- CHANGELOG.md | 4 + .../scalar/AbstractScalarController.java | 45 +++++--- .../shared/ScalarSharedPropertiesTest.java | 103 ++++++++++++++++++ 3 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 springdoc-openapi-starter-webmvc-scalar/src/test/java/test/org/springdoc/webmvc/scalar/shared/ScalarSharedPropertiesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f74d09e12..343abe2b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 deployment decision and unsafe when the application is not fronted by a proxy that strips them. Forwarded-header handling now follows `server.forward-headers-strategy` like any other Spring Boot application. **Behaviour change**: if you serve Scalar behind a reverse proxy, set `server.forward-headers-strategy=framework` (or `native`). +- Render the Scalar page from request-local state. `AbstractScalarController` wrote the request-derived URL, and the + group sources built from it, back into the `ScalarProperties` instance shared by every request. Concurrent requests + overwrote each other's URL, and one landing between the two writes made when groups are configured rendered a page + whose `url` was `null`. ## [2.9.0] - 2026-07-31 diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/scalar/AbstractScalarController.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/scalar/AbstractScalarController.java index 890c0f2e7..c9b4080ed 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/scalar/AbstractScalarController.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/scalar/AbstractScalarController.java @@ -36,6 +36,7 @@ import io.swagger.v3.oas.annotations.Operation; import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springframework.beans.BeanUtils; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.CollectionUtils; @@ -109,17 +110,7 @@ public ResponseEntity getScalarJs() throws IOException { * @throws IOException the io exception */ protected ResponseEntity getDocs(String requestUrl, String apiDocsPath, String scalarPath) throws IOException { - ScalarProperties configuredProperties = configureProperties(scalarProperties, requestUrl, apiDocsPath); - String url = configuredProperties.getUrl(); - List scalarSources = springDocConfigProperties.getGroupConfigs().stream() - .map(groupConfig -> new ScalarSource(url + DEFAULT_PATH_SEPARATOR + groupConfig.getGroup(), groupConfig.getDisplayName(), null, false)).toList(); - - if(!CollectionUtils.isEmpty(scalarSources)) { - scalarProperties.setSources(scalarSources); - scalarProperties.setUrl(null); - } - - String html = ScalarHtmlRenderer.render(configuredProperties); + String html = ScalarHtmlRenderer.render(configureProperties(requestUrl, apiDocsPath)); String bundleUrl = buildJsBundleUrl(requestUrl, scalarPath); html = SCRIPT_SRC_PATTERN.matcher(html).replaceAll("$1" + bundleUrl + "$3"); return ResponseEntity.ok() @@ -128,16 +119,34 @@ protected ResponseEntity getDocs(String requestUrl, String apiDocsPath, } /** - * Configure properties scalar properties. + * Builds the properties used to render the page for one request. + *

+ * The URL of the OpenAPI description is derived from the current request, so it is + * request state and must not be written back to the {@link ScalarProperties} singleton: + * concurrent requests would overwrite each other's URL, and one landing between the two + * writes made when groups are configured would render a page whose {@code url} is + * {@code null}. The configured properties are therefore copied, and only the copy is + * adjusted. {@code BeanUtils} does the copying rather than an explicit list of setters, + * so that options added by future scalar versions are carried over automatically. * - * @param properties the properties - * @param requestUrl the request url - * @param apiDocsPath the api docs path - * @return the scalar properties + * @param requestUrl the request url + * @param apiDocsPath the api docs path + * @return the scalar properties for this request */ - private ScalarProperties configureProperties(ScalarProperties properties, String requestUrl, String apiDocsPath) { + private ScalarProperties configureProperties(String requestUrl, String apiDocsPath) { + ScalarProperties properties = new ScalarProperties(); + BeanUtils.copyProperties(scalarProperties, properties); String url = buildApiDocsUrl(requestUrl, apiDocsPath); - properties.setUrl(url); + List scalarSources = springDocConfigProperties.getGroupConfigs().stream() + .map(groupConfig -> new ScalarSource(url + DEFAULT_PATH_SEPARATOR + groupConfig.getGroup(), groupConfig.getDisplayName(), null, false)).toList(); + + if (!CollectionUtils.isEmpty(scalarSources)) { + properties.setSources(scalarSources); + properties.setUrl(null); + } + else { + properties.setUrl(url); + } return properties; } diff --git a/springdoc-openapi-starter-webmvc-scalar/src/test/java/test/org/springdoc/webmvc/scalar/shared/ScalarSharedPropertiesTest.java b/springdoc-openapi-starter-webmvc-scalar/src/test/java/test/org/springdoc/webmvc/scalar/shared/ScalarSharedPropertiesTest.java new file mode 100644 index 000000000..8c8772cec --- /dev/null +++ b/springdoc-openapi-starter-webmvc-scalar/src/test/java/test/org/springdoc/webmvc/scalar/shared/ScalarSharedPropertiesTest.java @@ -0,0 +1,103 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.webmvc.scalar.shared; + +import com.scalar.maven.core.ScalarProperties; +import org.junit.jupiter.api.Test; +import org.springdoc.core.models.GroupedOpenApi; +import org.springdoc.webmvc.scalar.ScalarWebMvcController; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springdoc.scalar.ScalarConstants.SCALAR_DEFAULT_PATH; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Checks that rendering the Scalar page keeps the URL and the group sources it derives from + * the current request out of the controller's {@link ScalarProperties}, which every request + * shares. Groups are configured on purpose: that is the path that used to leave the shared + * URL set to {@code null}, so that a concurrent request could render a page pointing at no + * OpenAPI description at all. + * + * @author bnasslahsen + */ +@AutoConfigureMockMvc +@ActiveProfiles("test") +@SpringBootTest +class ScalarSharedPropertiesTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ScalarWebMvcController scalarWebMvcController; + + @Test + void doesNotMutateThePropertiesSharedByAllRequests() throws Exception { + ScalarProperties shared = (ScalarProperties) ReflectionTestUtils.getField(scalarWebMvcController, + "scalarProperties"); + String url = shared.getUrl(); + assertThat(shared.getSources()).isNullOrEmpty(); + + mockMvc.perform(get(SCALAR_DEFAULT_PATH)).andExpect(status().isOk()); + + assertThat(shared.getUrl()).isEqualTo(url); + assertThat(shared.getSources()).isNullOrEmpty(); + } + + @Test + void rendersTheGroupSourcesOnEveryRequest() throws Exception { + String first = getScalarPage(); + String second = getScalarPage(); + + assertThat(first).contains("http://localhost/v3/api-docs/stores", "http://localhost/v3/api-docs/pets"); + assertThat(second).isEqualTo(first); + } + + private String getScalarPage() throws Exception { + return mockMvc.perform(get(SCALAR_DEFAULT_PATH)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + } + + @SpringBootApplication + static class SpringDocTestApp { + + @Bean + GroupedOpenApi storeOpenApi() { + return GroupedOpenApi.builder().group("stores").pathsToMatch("/store/**").build(); + } + + @Bean + GroupedOpenApi petOpenApi() { + return GroupedOpenApi.builder().group("pets").pathsToMatch("/pet/**").build(); + } + + } + +} From c99c95e66c8fa5a1b66f600f4082735267761391 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sat, 5 Sep 2026 13:05:06 +0200 Subject: [PATCH 11/42] Rename springdoc.cache.max-size to springdoc.cache.max-entries Aligns the property that bounds the per-locale OpenAPI cache (scrutineer #25) with the name already used on the Spring Boot 4 line, so both release lines document one name. The property is new and unreleased, so nothing depends on the old name yet. --- CHANGELOG.md | 2 +- .../properties/SpringDocConfigProperties.java | 22 +++++++++---------- .../core/service/OpenAPIService.java | 14 ++++++------ .../core/service/OpenAPIServiceCacheTest.java | 18 +++++++-------- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 343abe2b2..980307626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bound the per-locale OpenAPI cache. The cache key is derived from the client-supplied `Accept-Language` header, so an unbounded map allowed any caller to grow the heap by one full OpenAPI document per distinct language tag. The cache is - now capped by the new `springdoc.cache.max-size` property (default `100`), and the per-locale controller-advice lists in + now capped by the new `springdoc.cache.max-entries` property (default `100`), and the per-locale controller-advice lists in `GenericResponseService` are keyed by advice bean instead of appended to on every build — which also stops a later request from picking up another locale's translated descriptions. - The Scalar starters no longer register a `ForwardedHeaderFilter` (WebMVC) or `ForwardedHeaderTransformer` (WebFlux). diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java index 17a2f5791..01ed7407a 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java @@ -1644,7 +1644,7 @@ public static class Cache { /** * The default maximum number of cached OpenAPI documents. */ - private static final int DEFAULT_MAX_SIZE = 100; + private static final int DEFAULT_MAX_ENTRIES = 100; /** * The Disabled. @@ -1655,10 +1655,10 @@ public static class Cache { * The maximum number of generated OpenAPI documents kept in memory, one per * requested locale. The locale comes from the client {@code Accept-Language} * header, so the cache is always bounded: a non-positive value falls back to the - * default of {@value #DEFAULT_MAX_SIZE} rather than meaning "unlimited". Narrow it + * default of {@value #DEFAULT_MAX_ENTRIES} rather than meaning "unlimited". Narrow it * further with {@code springdoc.allowed-locales}. */ - private int maxSize = DEFAULT_MAX_SIZE; + private int maxEntries = DEFAULT_MAX_ENTRIES; /** * Is disabled boolean. @@ -1679,21 +1679,21 @@ public void setDisabled(boolean disabled) { } /** - * Gets max size. + * Gets max entries. * - * @return the max size + * @return the max entries */ - public int getMaxSize() { - return maxSize; + public int getMaxEntries() { + return maxEntries; } /** - * Sets max size. + * Sets max entries. * - * @param maxSize the max size + * @param maxEntries the max entries */ - public void setMaxSize(int maxSize) { - this.maxSize = maxSize > 0 ? maxSize : DEFAULT_MAX_SIZE; + public void setMaxEntries(int maxEntries) { + this.maxEntries = maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES; } } diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java index caadeffdc..2ddd355af 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java @@ -156,7 +156,7 @@ public class OpenAPIService implements ApplicationContextAware { private final SpringDocConfigProperties springDocConfigProperties; /** - * The Cached open api map. Bounded by {@code springdoc.cache.max-size}: the key is the + * The Cached open api map. Bounded by {@code springdoc.cache.max-entries}: the key is the * language tag of the requested locale, which is client-controlled through the * {@code Accept-Language} header, so an unbounded map would let any caller grow the * heap by one full OpenAPI document per distinct tag. @@ -219,7 +219,7 @@ public OpenAPIService(Optional openAPI, SecurityService securityParser, this.openApiBuilderCustomisers = openApiBuilderCustomizers; this.serverBaseUrlCustomizers = serverBaseUrlCustomizers; this.javadocProvider = javadocProvider; - this.cachedOpenAPI = new BoundedOpenAPICache(springDocConfigProperties.getCache().getMaxSize()); + this.cachedOpenAPI = new BoundedOpenAPICache(springDocConfigProperties.getCache().getMaxEntries()); if (springDocConfigProperties.isUseFqn()) TypeNameResolver.std.setUseFqn(true); } @@ -961,20 +961,20 @@ private static class BoundedOpenAPICache extends LinkedHashMap /** * The maximum number of entries retained. */ - private final int maxSize; + private final int maxEntries; /** * Instantiates a new bounded open api cache. * - * @param maxSize the maximum number of entries retained + * @param maxEntries the maximum number of entries retained */ - BoundedOpenAPICache(int maxSize) { - this.maxSize = maxSize; + BoundedOpenAPICache(int maxEntries) { + this.maxEntries = maxEntries; } @Override protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > maxSize; + return size() > maxEntries; } } } diff --git a/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/service/OpenAPIServiceCacheTest.java b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/service/OpenAPIServiceCacheTest.java index e7185c62b..657c19e7b 100644 --- a/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/service/OpenAPIServiceCacheTest.java +++ b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/service/OpenAPIServiceCacheTest.java @@ -56,9 +56,9 @@ private Locale locale(int index) { } @Test - void evictsBeyondTheConfiguredMaxSize() { + void evictsBeyondTheConfiguredMaxEntries() { SpringDocConfigProperties properties = new SpringDocConfigProperties(); - properties.getCache().setMaxSize(3); + properties.getCache().setMaxEntries(3); OpenAPIService service = openAPIService(properties); for (int i = 0; i < 50; i++) { @@ -76,7 +76,7 @@ void evictsBeyondTheConfiguredMaxSize() { @Test void appliesADefaultBoundWhenUnconfigured() { SpringDocConfigProperties properties = new SpringDocConfigProperties(); - assertEquals(100, properties.getCache().getMaxSize()); + assertEquals(100, properties.getCache().getMaxEntries()); OpenAPIService service = openAPIService(properties); for (int i = 0; i < 500; i++) { @@ -88,19 +88,19 @@ void appliesADefaultBoundWhenUnconfigured() { } @Test - void aNonPositiveMaxSizeFallsBackToTheDefaultRatherThanMeaningUnlimited() { + void aNonPositiveMaxEntriesFallsBackToTheDefaultRatherThanMeaningUnlimited() { SpringDocConfigProperties properties = new SpringDocConfigProperties(); - properties.getCache().setMaxSize(0); - assertEquals(100, properties.getCache().getMaxSize()); + properties.getCache().setMaxEntries(0); + assertEquals(100, properties.getCache().getMaxEntries()); - properties.getCache().setMaxSize(-1); - assertEquals(100, properties.getCache().getMaxSize()); + properties.getCache().setMaxEntries(-1); + assertEquals(100, properties.getCache().getMaxEntries()); } @Test void rewritingTheSameLocaleDoesNotConsumeCapacity() { SpringDocConfigProperties properties = new SpringDocConfigProperties(); - properties.getCache().setMaxSize(2); + properties.getCache().setMaxEntries(2); OpenAPIService service = openAPIService(properties); for (int i = 0; i < 10; i++) { From 75e7aa776ee8f9ba2d9ae612fe51e6a942187037 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sat, 5 Sep 2026 15:04:16 +0200 Subject: [PATCH 12/42] test: cover the request-local Scalar rendering AbstractScalarController now builds a per-request ScalarProperties instead of writing the request-derived url and group sources back into the shared bean, but nothing pinned that down on this line. Port the test the main line already has: rendering leaves the singleton untouched, and two requests each render their own url. --- .../scalar/AbstractScalarControllerTest.java | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 springdoc-openapi-starter-common/src/test/java/org/springdoc/scalar/AbstractScalarControllerTest.java diff --git a/springdoc-openapi-starter-common/src/test/java/org/springdoc/scalar/AbstractScalarControllerTest.java b/springdoc-openapi-starter-common/src/test/java/org/springdoc/scalar/AbstractScalarControllerTest.java new file mode 100644 index 000000000..376f39d08 --- /dev/null +++ b/springdoc-openapi-starter-common/src/test/java/org/springdoc/scalar/AbstractScalarControllerTest.java @@ -0,0 +1,99 @@ +/* + * + * * + * * * Copyright 2019-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.springdoc.scalar; + +import java.io.IOException; +import java.util.Set; + +import com.scalar.maven.core.ScalarProperties; +import org.junit.jupiter.api.Test; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springdoc.core.properties.SpringDocConfigProperties.GroupConfig; + +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springdoc.scalar.ScalarConstants.SCALAR_DEFAULT_PATH; + +/** + * The Scalar properties bean is a singleton shared by every request, while the api-docs url + * and the group sources it is rendered with are request-specific. Rendering must therefore + * leave the shared bean untouched, otherwise concurrent requests interleave their state. + * + * @author bnasslahsen + */ +class AbstractScalarControllerTest { + + /** + * Minimal concrete controller exposing the protected rendering entry point. + */ + private static class TestScalarController extends AbstractScalarController { + + TestScalarController(ScalarProperties scalarProperties, SpringDocConfigProperties springDocConfigProperties) { + super(scalarProperties, springDocConfigProperties); + } + + ResponseEntity render(String requestUrl) throws IOException { + return getDocs(requestUrl, "/v3/api-docs", SCALAR_DEFAULT_PATH); + } + + } + + /** + * Builds config properties declaring a single group, so that the sources branch is taken. + * + * @return the spring doc config properties + */ + private SpringDocConfigProperties groupedConfigProperties() { + SpringDocConfigProperties springDocConfigProperties = new SpringDocConfigProperties(); + GroupConfig groupConfig = new GroupConfig(); + groupConfig.setGroup("stores"); + springDocConfigProperties.setGroupConfigs(Set.of(groupConfig)); + return springDocConfigProperties; + } + + @Test + void testRenderingDoesNotMutateTheSharedProperties() throws IOException { + ScalarProperties scalarProperties = new ScalarProperties(); + scalarProperties.setPath(SCALAR_DEFAULT_PATH); + String originalUrl = scalarProperties.getUrl(); + TestScalarController controller = new TestScalarController(scalarProperties, groupedConfigProperties()); + + controller.render("http://first-host:8080" + SCALAR_DEFAULT_PATH); + + assertThat(scalarProperties.getUrl()).isEqualTo(originalUrl); + assertThat(scalarProperties.getSources()).isNull(); + } + + @Test + void testEachRequestIsRenderedWithItsOwnUrl() throws IOException { + ScalarProperties scalarProperties = new ScalarProperties(); + scalarProperties.setPath(SCALAR_DEFAULT_PATH); + TestScalarController controller = new TestScalarController(scalarProperties, groupedConfigProperties()); + + String first = controller.render("http://first-host:8080" + SCALAR_DEFAULT_PATH).getBody(); + String second = controller.render("http://second-host:9090" + SCALAR_DEFAULT_PATH).getBody(); + + assertThat(first).contains("http://first-host:8080/v3/api-docs/stores").doesNotContain("second-host"); + assertThat(second).contains("http://second-host:9090/v3/api-docs/stores").doesNotContain("first-host"); + } + +} From c7741dfe6738910f10685d525420e6e48b90dfc3 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sat, 5 Sep 2026 15:04:16 +0200 Subject: [PATCH 13/42] docs: add the 2.9.1 changelog section Reference both fixes by their published advisory and short title, and call out the behaviour change an upgrade needs: the Scalar starters leave forwarded-header handling to the application. --- CHANGELOG.md | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 980307626..090066c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,24 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.9.1] - 2026-09-05 ### Security -- Bound the per-locale OpenAPI cache. The cache key is derived from the client-supplied `Accept-Language` header, so an - unbounded map allowed any caller to grow the heap by one full OpenAPI document per distinct language tag. The cache is - now capped by the new `springdoc.cache.max-entries` property (default `100`), and the per-locale controller-advice lists in - `GenericResponseService` are keyed by advice bean instead of appended to on every build — which also stops a later - request from picking up another locale's translated descriptions. -- The Scalar starters no longer register a `ForwardedHeaderFilter` (WebMVC) or `ForwardedHeaderTransformer` (WebFlux). - Adding the starter silently made the *whole* application trust `Forwarded` / `X-Forwarded-*` headers, which is a - deployment decision and unsafe when the application is not fronted by a proxy that strips them. Forwarded-header - handling now follows `server.forward-headers-strategy` like any other Spring Boot application. **Behaviour change**: - if you serve Scalar behind a reverse proxy, set `server.forward-headers-strategy=framework` (or `native`). -- Render the Scalar page from request-local state. `AbstractScalarController` wrote the request-derived URL, and the - group sources built from it, back into the `ScalarProperties` instance shared by every request. Concurrent requests - overwrote each other's URL, and one landing between the two writes made when groups are configured rendered a page - whose `url` was `null`. +- [GHSA-rhhx-6j8h-8cvw](https://github.com/springdoc/springdoc-openapi/security/advisories/GHSA-rhhx-6j8h-8cvw) – Unbounded per-locale OpenAPI cache allows memory exhaustion via `Accept-Language` +- [GHSA-c925-vm88-mpp9](https://github.com/springdoc/springdoc-openapi/security/advisories/GHSA-c925-vm88-mpp9) – Scalar starters trust client-supplied forwarded headers and render from a shared mutable bean + +### Changed + +- **The Scalar starters no longer register forwarded-header handling.** Set `server.forward-headers-strategy=framework` (or `native`) behind a trusted proxy +- Add `springdoc.cache.max-entries` (default `100`) to bound the per-locale OpenAPI cache +- Document the security policy and the release versioning scheme ## [2.9.0] - 2026-07-31 From 6ca0ca93269374d8e6cb16219ae9ad6b3578e407 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sat, 5 Sep 2026 21:15:18 +0200 Subject: [PATCH 14/42] ci: migrate build and snapshot publishing to GitHub Actions Ports the workflows already running on main to this release line and retires maven.yml, which only ran `mvn package` on pull requests. The npm cache step from main is omitted: this branch has no frontend module. -Pci is kept on both commands so they match main verbatim; the ci profile is empty here, so it is a no-op. maven-gpg-plugin 1.6 -> 3.2.7. 1.6 predates GPG 2.1 and cannot sign non-interactively, so it fails on a runner. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 36 +++++++++++++++++++++++++++ .github/workflows/maven.yml | 29 ---------------------- .github/workflows/snapshot.yml | 45 ++++++++++++++++++++++++++++++++++ pom.xml | 2 +- 4 files changed, 82 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/maven.yml create mode 100644 .github/workflows/snapshot.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..7e9afa5b0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +# Builds and tests every pull request. +name: CI + +on: + pull_request: + paths-ignore: ['**.md', 'docs/**'] + workflow_dispatch: + +# Supersede in-flight runs for the same ref. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + MAVEN_ARGS: -B --no-transfer-progress -Dmaven.artifact.threads=16 + MAVEN_OPTS: -Xmx3g + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + cache: maven + + # -Pci is a no-op on this branch (the profile is empty here, there is no + # frontend module) but is kept so the command matches main. + - name: Test + run: mvn $MAVEN_ARGS -Pci -T1C test diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml deleted file mode 100644 index 8be3b52e0..000000000 --- a/.github/workflows/maven.yml +++ /dev/null @@ -1,29 +0,0 @@ -# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Java CI with Maven - -on: - pull_request: - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - cache: maven - - name: Build with Maven - run: mvn -B package --file pom.xml - diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml new file mode 100644 index 000000000..990a4228f --- /dev/null +++ b/.github/workflows/snapshot.yml @@ -0,0 +1,45 @@ +# Tests and publishes a SNAPSHOT to Maven Central on every push to a release line. +name: Snapshot + +on: + push: + branches: [main, spring-boot-3] + paths-ignore: ['**.md', 'docs/**'] + +# Never cancel a deploy that is already publishing. +concurrency: + group: snapshot-${{ github.ref }} + cancel-in-progress: false + +env: + MAVEN_ARGS: -B --no-transfer-progress -Dmaven.artifact.threads=16 + MAVEN_OPTS: -Xmx3g + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + cache: maven + server-id: central + server-username: MAVEN_USERNAME + server-password: MAVEN_PASSWORD + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + + # -Pci is a no-op on this branch (the profile is empty here, there is no + # frontend module) but is kept so the command matches main. + - name: Test and deploy snapshot + run: mvn $MAVEN_ARGS -Pci,gpg -T1C -Dmaven.javadoc.skip=true deploy + env: + MAVEN_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} diff --git a/pom.xml b/pom.xml index d31c2facb..860724f6f 100644 --- a/pom.xml +++ b/pom.xml @@ -51,7 +51,7 @@ - 1.6 + 3.2.7 2.5.3 0.11.0 From 76639a87aac159947f3cfec5adf777927fa80fc9 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 02:51:27 +0200 Subject: [PATCH 15/42] Upgrade swagger-ui from 5.32.11 to 5.32.14 Port of PR #3342 (main) to the Spring Boot 3 line: the upgrade fixes vulnerabilities reported against the bundled swagger-ui. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + pom.xml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 090066c80..c537b5186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The Scalar starters no longer register forwarded-header handling.** Set `server.forward-headers-strategy=framework` (or `native`) behind a trusted proxy - Add `springdoc.cache.max-entries` (default `100`) to bound the per-locale OpenAPI cache - Document the security policy and the release versioning scheme +- Upgrade swagger-ui to version **5.32.14** ## [2.9.0] - 2026-07-31 diff --git a/pom.xml b/pom.xml index 860724f6f..64a54ff4f 100644 --- a/pom.xml +++ b/pom.xml @@ -57,7 +57,7 @@ 1.5.0 2.2.52 - 5.32.11 + 5.32.14 1.13.1 0.9.1 0.15.0 From 06f5ad80785e6a8b7d7ff93cf25bbc56362fe0a1 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 02:58:45 +0200 Subject: [PATCH 16/42] Upgrade swagger-core from 2.2.52 to 2.2.53 Port of PR #3312 (main) to the Spring Boot 3 line. swagger-core 2.2.53 fixes upstream the four behaviours springdoc worked around locally, so the workarounds are removed: - PropertyNamingStrategyConverter (swagger-core#415 / springdoc#3293) - SchemaUtils.fixOAS31ExclusiveConstraints (swagger-core#5170) - SpringDocUtils.fixNullOnlyAdditionalProperties (swagger-core#5115) - SpringDocUtils.removeNullKeyProperties and its caller in AbstractOpenApiResource (swagger-core#5193) Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + pom.xml | 2 +- .../api/AbstractOpenApiResource.java | 19 -- .../configuration/SpringDocConfiguration.java | 14 -- .../PropertyNamingStrategyConverter.java | 168 ------------------ .../org/springdoc/core/utils/SchemaUtils.java | 26 --- .../springdoc/core/utils/SpringDocUtils.java | 52 ------ .../api/v31/app25/NullKeyController.kt | 2 + .../test/resources/results/3.1.0/app25.json | 48 +++-- 9 files changed, 35 insertions(+), 297 deletions(-) delete mode 100644 springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/PropertyNamingStrategyConverter.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c537b5186..3ecdf5d0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The Scalar starters no longer register forwarded-header handling.** Set `server.forward-headers-strategy=framework` (or `native`) behind a trusted proxy - Add `springdoc.cache.max-entries` (default `100`) to bound the per-locale OpenAPI cache - Document the security policy and the release versioning scheme +- Upgrade swagger-core to version **2.2.53** - Upgrade swagger-ui to version **5.32.14** ## [2.9.0] - 2026-07-31 diff --git a/pom.xml b/pom.xml index 64a54ff4f..2ad4d39f1 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ 0.11.0 1.5.0 - 2.2.52 + 2.2.53 5.32.14 1.13.1 0.9.1 diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java index 996f423f9..ced4498c6 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java @@ -385,8 +385,6 @@ protected OpenAPI getOpenApi(String serverBaseUrl, Locale locale) { } getPaths(mappingsMap, finalLocale, openAPI); - removeNullKeyComponentProperties(openAPI); - if (springDocConfigProperties.isTrimKotlinIndent()) this.trimIndent(openAPI); @@ -447,23 +445,6 @@ private Locale selectLocale(Locale inputLocale) { return inputLocale == null ? Locale.getDefault() : inputLocale; } - /** - * Removes {@code null}-keyed entries from the component schema properties. Swagger-core - * inserts a {@code null} property key when resolving a {@code @JsonUnwrapped} member whose - * content is a {@code $ref} (for example Spring HATEOAS {@code EntityModel.getContent()} - * with HAL disabled), which otherwise breaks JSON serialization of the document. - * - * @param openAPI the open api - */ - private static void removeNullKeyComponentProperties(OpenAPI openAPI) { - if (openAPI.getComponents() == null || openAPI.getComponents().getSchemas() == null) { - return; - } - for (Schema schema : openAPI.getComponents().getSchemas().values()) { - SpringDocUtils.removeNullKeyProperties(schema); - } - } - /** * Indents are removed for properties that are mainly used as “explanations” using Open API. * diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocConfiguration.java index 9d9e538b3..c38eb7462 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocConfiguration.java @@ -56,7 +56,6 @@ import org.springdoc.core.converters.OAS31ModelConverter; import org.springdoc.core.converters.PolymorphicModelConverter; import org.springdoc.core.converters.PropertyCustomizingConverter; -import org.springdoc.core.converters.PropertyNamingStrategyConverter; import org.springdoc.core.converters.ResponseSupportConverter; import org.springdoc.core.converters.SchemaPropertyDeprecatingConverter; import org.springdoc.core.converters.WebFluxSupportConverter; @@ -290,19 +289,6 @@ PolymorphicModelConverter polymorphicModelConverter(ObjectMapperProvider objectM return new PolymorphicModelConverter(objectMapperProvider); } - /** - * Property naming strategy converter property naming strategy converter. - * - * @param objectMapperProvider the object mapper provider - * @return the property naming strategy converter - */ - @Bean - @ConditionalOnMissingBean - @Lazy(false) - PropertyNamingStrategyConverter propertyNamingStrategyConverter(ObjectMapperProvider objectMapperProvider) { - return new PropertyNamingStrategyConverter(objectMapperProvider); - } - /** * Open api builder open api builder. * diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/PropertyNamingStrategyConverter.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/PropertyNamingStrategyConverter.java deleted file mode 100644 index d711e942c..000000000 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/PropertyNamingStrategyConverter.java +++ /dev/null @@ -1,168 +0,0 @@ -/* - * - * * - * * * - * * * * - * * * * * - * * * * * * 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. - * * * * * * See the License for the specific language governing permissions and - * * * * * * limitations under the License. - * * * * - * * * - * * - * - */ - -package org.springdoc.core.converters; - -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import com.fasterxml.jackson.databind.BeanDescription; -import com.fasterxml.jackson.databind.JavaType; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; -import io.swagger.v3.core.converter.AnnotatedType; -import io.swagger.v3.core.converter.ModelConverter; -import io.swagger.v3.core.converter.ModelConverterContext; -import io.swagger.v3.core.converter.ModelConverters; -import io.swagger.v3.core.jackson.ModelResolver; -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.media.Schema; -import org.springdoc.core.providers.ObjectMapperProvider; - -/** - * Repairs schema property names that swagger-core leaves in their raw (untranslated) form - * when a Jackson {@code PropertyNamingStrategy} (for example {@code SNAKE_CASE}) is in - * effect. - * - *

- * swagger-core's {@code ModelResolver} carries a long-standing workaround - * (swagger-core#415) - * that overwrites the Jackson-translated property name with the raw member name whenever - * that member name starts with {@code get}/{@code is} followed by a lower-case character. - * For Java records the accessor is named exactly like the component (e.g. - * {@code issuanceDate()}), so a component such as {@code issuanceDate} is clobbered back - * to camelCase while sibling properties like {@code familyName} convert correctly. This - * converter restores the translated name. - *

- * - *

- * The mapper used by the active {@code ModelResolver} is consulted so that the fix is - * self-consistent: it only renames a property when Jackson actually maps its internal - * name to a different external name and swagger-core emitted the internal name. - *

- * - * See: springdoc-openapi#3293 - * - * @author bnasslahsen - */ -public class PropertyNamingStrategyConverter implements ModelConverter { - - /** - * The Spring doc object mapper. - */ - private final ObjectMapperProvider springDocObjectMapper; - - /** - * Instantiates a new Property naming strategy converter. - * @param springDocObjectMapper the spring doc object mapper - */ - public PropertyNamingStrategyConverter(ObjectMapperProvider springDocObjectMapper) { - this.springDocObjectMapper = springDocObjectMapper; - } - - @Override - public Schema resolve(AnnotatedType type, ModelConverterContext context, Iterator chain) { - if (!chain.hasNext()) - return null; - Schema resolvedSchema = chain.next().resolve(type, context, chain); - - ObjectMapper mapper = resolverObjectMapper(); - if (mapper == null) - return resolvedSchema; - - Schema targetSchema = resolvedSchema; - if (resolvedSchema != null && resolvedSchema.get$ref() != null) - targetSchema = context.getDefinedModels() - .get(resolvedSchema.get$ref().substring(Components.COMPONENTS_SCHEMAS_REF.length())); - - if (targetSchema == null || targetSchema.getProperties() == null) - return resolvedSchema; - - JavaType javaType = mapper.constructType(type.getType()); - if (javaType == null || javaType.getRawClass().getPackageName().startsWith("java.")) - return resolvedSchema; - - renameClobberedProperties(targetSchema, mapper, javaType); - return resolvedSchema; - } - - /** - * Renames properties that swagger-core emitted under their raw member name back to - * the name Jackson computed from the active naming strategy, preserving property - * order and the required list. - * @param schema the schema to fix - * @param mapper the object mapper used by the active model resolver - * @param javaType the resolved java type - */ - private void renameClobberedProperties(Schema schema, ObjectMapper mapper, JavaType javaType) { - BeanDescription beanDescription; - try { - beanDescription = mapper.getSerializationConfig().introspect(javaType); - } - catch (Exception e) { - return; - } - - Map renames = new LinkedHashMap<>(); - for (BeanPropertyDefinition property : beanDescription.findProperties()) { - String externalName = property.getName(); - String internalName = property.getInternalName(); - if (externalName != null && !externalName.equals(internalName) - && schema.getProperties().containsKey(internalName) - && !schema.getProperties().containsKey(externalName)) { - renames.put(internalName, externalName); - } - } - if (renames.isEmpty()) - return; - - Map renamedProperties = new LinkedHashMap<>(); - schema.getProperties().forEach((name, value) -> renamedProperties.put(renames.getOrDefault(name, name), value)); - schema.setProperties(renamedProperties); - - List required = schema.getRequired(); - if (required != null) - required.replaceAll(name -> renames.getOrDefault(name, name)); - } - - /** - * Returns the {@link ObjectMapper} of the active swagger-core {@code ModelResolver}, - * which is the mapper that applied (or did not apply) the naming strategy while - * generating the schema. - * @return the object mapper, or {@code null} if none could be located - */ - private ObjectMapper resolverObjectMapper() { - for (ModelConverter converter : ModelConverters.getInstance(springDocObjectMapper.isOpenapi31()) - .getConverters()) { - if (converter instanceof ModelResolver modelResolver) - return modelResolver.objectMapper(); - } - return null; - } - -} diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java index 2ef544fe5..ed34d7706 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java @@ -12,7 +12,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import io.swagger.v3.oas.models.SpecVersion; import io.swagger.v3.oas.models.media.Schema; import jakarta.validation.OverridesAttribute; import jakarta.validation.constraints.DecimalMax; @@ -300,7 +299,6 @@ else if (OPENAPI_STRING_TYPE.equals(type)) { schema.setMaximum(BigDecimal.valueOf(((Range) anno).max())); } }); - fixOAS31ExclusiveConstraints(schema); if (schema!=null && annotatedNotNull(annotations)) { String specVersion = schema.getSpecVersion().name(); if (!"V30".equals(specVersion)) { @@ -569,28 +567,4 @@ private static JsonProperty getJsonProperty(Field f) { if (g != null) return g.getAnnotation(JsonProperty.class); return null; } - - /** - * Swagger-core 2.2.49 introduced so that {@link Positive} and {@link Negative} are introspected. - * It does not correctly use the fact that exclusiveMinimum/exclusiveMaximum are values in OAS31. - *

- * Tracked under swagger-core#5170. - * - * @param schema the schema to fix - */ - public static void fixOAS31ExclusiveConstraints(Schema schema) { - if (schema == null) { - return; - } - if (schema.getSpecVersion().equals(SpecVersion.V31)) { - if (schema.getExclusiveMaximumValue() != null && schema.getMaximum() != null - && schema.getMaximum().compareTo(schema.getExclusiveMaximumValue()) == 0) { - schema.setMaximum(null); - } - if (schema.getExclusiveMinimumValue() != null && schema.getMinimum() != null && - schema.getMinimum().compareTo(schema.getExclusiveMinimumValue()) == 0) { - schema.setMinimum(null); - } - } - } } diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java index d1007e7e4..4348e4355 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java @@ -31,7 +31,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.Set; import java.util.function.Predicate; import com.fasterxml.jackson.core.type.TypeReference; @@ -181,60 +180,9 @@ else if (schema.getItems() != null && schema.getItems().getType() != null if (schema.getProperties() != null) { schema.getProperties().forEach((key, value) -> handleSchemaTypes(value)); } - fixNullOnlyAdditionalProperties(schema); } } - /** - * Fix additionalProperties incorrectly set to {"type": "null"} when @Nullable - * propagates from a Map field to its Object value type (resolved as "any type" = {}). - *

- * Tracked under swagger-core#5115. - * - * @param schema the schema to fix - */ - public static void fixNullOnlyAdditionalProperties(Schema schema) { - if (schema == null) { - return; - } - Object additionalProperties = schema.getAdditionalProperties(); - if (additionalProperties instanceof Schema addPropSchema) { - boolean isNullOnlyType = false; - Set types = addPropSchema.getTypes(); - boolean onlyNullTypeOAS31 = types != null && types.size() == 1 && types.contains("null"); - boolean onlyNullTypeOAS30 = types == null && "null".equals(addPropSchema.getType()); - if (onlyNullTypeOAS31 || onlyNullTypeOAS30) { - isNullOnlyType = true; - } - if (isNullOnlyType && addPropSchema.get$ref() == null - && addPropSchema.getProperties() == null && addPropSchema.getFormat() == null) { - addPropSchema.setTypes(null); - addPropSchema.setType(null); - } - } - if (schema.getProperties() != null) { - schema.getProperties().values().forEach(SpringDocUtils::fixNullOnlyAdditionalProperties); - } - } - - /** - * Removes {@code null}-keyed entries from a schema's properties map (and its nested - * schemas). When swagger-core resolves a {@code @JsonUnwrapped} member (for example - * Spring HATEOAS {@code EntityModel.getContent()} with HAL disabled), the unwrapped - * property schemas may have a {@code null} name and get inserted into the properties map - * under a {@code null} key. Such a key cannot be serialized by Jackson, which fails the - * whole OpenAPI document with {@code "Null key for a Map not allowed in JSON"}. - * - * @param schema the schema to fix - */ - public static void removeNullKeyProperties(Schema schema) { - if (schema == null || schema.getProperties() == null) { - return; - } - schema.getProperties().keySet().removeIf(Objects::isNull); - schema.getProperties().values().forEach(SpringDocUtils::removeNullKeyProperties); - } - /** * Handle schema types. * diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app25/NullKeyController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app25/NullKeyController.kt index 6419037a9..f273b1e9e 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app25/NullKeyController.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app25/NullKeyController.kt @@ -8,6 +8,8 @@ import org.springframework.web.bind.annotation.RestController * Reproduces the swagger-core `@JsonUnwrapped` null-property-key issue: when the unwrapped * member resolves to a `$ref` (a named component model), swagger-core adds a `null`-keyed * entry to the enclosing schema properties, which breaks JSON serialization of the document. + * + * Fixed upstream by PR 5193 */ @RestController class NullKeyController { diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app25.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app25.json index 650dcae89..148a3c002 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app25.json +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app25.json @@ -34,27 +34,41 @@ }, "components": { "schemas": { - "OuterResponse": { - "type": "object", - "properties": { - "contractAddress": { - "type": "string" + "NestedObject" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string" }, - "chainType": { - "type": "string", - "enum": [ - "ETH" - ] + "description" : { + "type" : [ "string", "null" ] + } + }, + "required" : [ "name" ] + }, + "OuterResponse" : { + "type" : "object", + "properties" : { + "contractAddress" : { + "type" : "string" + }, + "chainType" : { + "type" : "string", + "enum" : [ "ETH" ] + }, + "nested" : { + "type" : "null", + "oneOf" : [ { + "$ref" : "#/components/schemas/NestedObject" + }, { + "type" : "null" + } ] }, - "success": { - "type": "boolean" + "success" : { + "type" : "boolean" } }, - "required": [ - "chainType", - "contractAddress", - "success" - ] + "required" : [ "chainType", "contractAddress", "success" ] } } } From c78e5bd8609d6b80f4e601b7f13120ee68db3208 Mon Sep 17 00:00:00 2001 From: Taha El Amine Kassabi Date: Sun, 6 Sep 2026 03:08:18 +0200 Subject: [PATCH 17/42] Stabilize Spring Data Sort and Pageable schema property order Apply the same property-order stabilization as PR #3281 to SortObject and PageableObject, avoiding noisy OpenAPI and generated-client diffs. Backport of PR #3341 from the main line. Co-Authored-By: Codex GPT-6 Co-Authored-By: Claude Opus 5 --- .../converters/PageableOpenAPIConverter.java | 43 +++++++++++++++++-- .../core/converters/SortOpenAPIConverter.java | 43 +++++++++++++++++-- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/PageableOpenAPIConverter.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/PageableOpenAPIConverter.java index 6005ca317..566fdee20 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/PageableOpenAPIConverter.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/PageableOpenAPIConverter.java @@ -27,11 +27,15 @@ package org.springdoc.core.converters; import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import com.fasterxml.jackson.databind.JavaType; import io.swagger.v3.core.converter.AnnotatedType; import io.swagger.v3.core.converter.ModelConverter; import io.swagger.v3.core.converter.ModelConverterContext; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.media.Schema; import org.springdoc.core.converters.models.Pageable; import org.springdoc.core.providers.ObjectMapperProvider; @@ -42,6 +46,7 @@ * The Pageable Type models converter. * * @author bnasslahsen + * @author dpkass */ public class PageableOpenAPIConverter implements ModelConverter { @@ -60,6 +65,11 @@ public class PageableOpenAPIConverter implements ModelConverter { */ private static final AnnotatedType PAGEABLE = new AnnotatedType(Pageable.class).resolveAsRef(true); + /** + * The standard pageable response property order. + */ + private static final List PAGEABLE_PROPERTY_ORDER = List.of("offset", "paged", "pageNumber", "pageSize", "sort", "unpaged"); + /** * The Spring doc object mapper. */ @@ -85,16 +95,43 @@ public PageableOpenAPIConverter(ObjectMapperProvider springDocObjectMapper) { @Override public Schema resolve(AnnotatedType type, ModelConverterContext context, Iterator chain) { JavaType javaType = springDocObjectMapper.jsonMapper().constructType(type.getType()); + boolean isPageableType = false; if (javaType != null) { Class cls = javaType.getRawClass(); - if (PAGEABLE_TO_REPLACE.equals(cls.getCanonicalName()) || PAGE_REQUEST_TO_REPLACE.equals(cls.getCanonicalName())) { + isPageableType = PAGEABLE_TO_REPLACE.equals(cls.getCanonicalName()) || PAGE_REQUEST_TO_REPLACE.equals(cls.getCanonicalName()); + if (isPageableType) { if (!type.isSchemaProperty()) type = PAGEABLE; else type.name(getParentTypeName(type, cls)); } } - return (chain.hasNext()) ? chain.next().resolve(type, context, chain) : null; + Schema schema = (chain.hasNext()) ? chain.next().resolve(type, context, chain) : null; + if (isPageableType) + sortSchemaProperties(schema, context); + return schema; + } + + /** + * Sort the response schema properties. + * + * @param schema the schema + * @param context the context + */ + private void sortSchemaProperties(Schema schema, ModelConverterContext context) { + if (schema != null && schema.get$ref() != null && schema.get$ref().startsWith(Components.COMPONENTS_SCHEMAS_REF)) + schema = context.getDefinedModels().get(schema.get$ref().substring(Components.COMPONENTS_SCHEMAS_REF.length())); + if (schema == null || schema.getProperties() == null) + return; + + Map properties = schema.getProperties(); + if (!properties.keySet().containsAll(PAGEABLE_PROPERTY_ORDER)) + return; + + Map sortedProperties = new LinkedHashMap<>(); + PAGEABLE_PROPERTY_ORDER.forEach(property -> sortedProperties.put(property, properties.get(property))); + properties.forEach(sortedProperties::putIfAbsent); + schema.setProperties(sortedProperties); } -} \ No newline at end of file +} diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/SortOpenAPIConverter.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/SortOpenAPIConverter.java index 13fba0731..93d7fbf4d 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/SortOpenAPIConverter.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/SortOpenAPIConverter.java @@ -27,11 +27,15 @@ package org.springdoc.core.converters; import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import com.fasterxml.jackson.databind.JavaType; import io.swagger.v3.core.converter.AnnotatedType; import io.swagger.v3.core.converter.ModelConverter; import io.swagger.v3.core.converter.ModelConverterContext; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.media.Schema; import org.springdoc.core.converters.models.Sort; import org.springdoc.core.providers.ObjectMapperProvider; @@ -42,6 +46,7 @@ * The Spring Data Sort type model converter. * * @author daniel -shuy + * @author dpkass */ public class SortOpenAPIConverter implements ModelConverter { @@ -55,6 +60,11 @@ public class SortOpenAPIConverter implements ModelConverter { */ private static final AnnotatedType SORT = new AnnotatedType(Sort.class).resolveAsRef(true); + /** + * The standard sort response property order. + */ + private static final List SORT_PROPERTY_ORDER = List.of("empty", "sorted", "unsorted"); + /** * The Spring doc object mapper. */ @@ -80,16 +90,43 @@ public SortOpenAPIConverter(ObjectMapperProvider springDocObjectMapper) { @Override public Schema resolve(AnnotatedType type, ModelConverterContext context, Iterator chain) { JavaType javaType = springDocObjectMapper.jsonMapper().constructType(type.getType()); + boolean isSortType = false; if (javaType != null) { Class cls = javaType.getRawClass(); - if (SORT_TO_REPLACE.equals(cls.getCanonicalName())) { + isSortType = SORT_TO_REPLACE.equals(cls.getCanonicalName()); + if (isSortType) { if (!type.isSchemaProperty()) type = SORT; else type.name(getParentTypeName(type, cls)); } } - return (chain.hasNext()) ? chain.next().resolve(type, context, chain) : null; + Schema schema = (chain.hasNext()) ? chain.next().resolve(type, context, chain) : null; + if (isSortType) + sortSchemaProperties(schema, context); + return schema; + } + + /** + * Sort the response schema properties. + * + * @param schema the schema + * @param context the context + */ + private void sortSchemaProperties(Schema schema, ModelConverterContext context) { + if (schema != null && schema.get$ref() != null && schema.get$ref().startsWith(Components.COMPONENTS_SCHEMAS_REF)) + schema = context.getDefinedModels().get(schema.get$ref().substring(Components.COMPONENTS_SCHEMAS_REF.length())); + if (schema == null || schema.getProperties() == null) + return; + + Map properties = schema.getProperties(); + if (!properties.keySet().containsAll(SORT_PROPERTY_ORDER)) + return; + + Map sortedProperties = new LinkedHashMap<>(); + SORT_PROPERTY_ORDER.forEach(property -> sortedProperties.put(property, properties.get(property))); + properties.forEach(sortedProperties::putIfAbsent); + schema.setProperties(sortedProperties); } -} \ No newline at end of file +} From 73b2f1d8154d65a34e7bf420a2c825283c55a356 Mon Sep 17 00:00:00 2001 From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:08:27 +0200 Subject: [PATCH 18/42] fix: Kotlin nullability interpretation of Any? type A schema representing Any? carries no type constraint, so it already permits any value including null. Leave it untouched instead of narrowing it to type: "null" (3.1) or decorating it with a redundant nullable: true (3.0). Backport of PR #3339 from the main line. Co-Authored-By: Claude Opus 5 --- .../KotlinNullablePropertyCustomizer.kt | 17 +++++++++++++++++ .../api/v30/app18/NullableController.kt | 1 + .../api/v30/app19/NullableController.kt | 1 + .../api/v31/app23/NullableController.kt | 1 + .../api/v31/app24/NullableController.kt | 1 + .../src/test/resources/results/3.0.1/app18.json | 4 ++++ .../src/test/resources/results/3.0.1/app19.json | 3 +++ .../src/test/resources/results/3.1.0/app23.json | 1 + .../src/test/resources/results/3.1.0/app24.json | 1 + 9 files changed, 30 insertions(+) diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/KotlinNullablePropertyCustomizer.kt b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/KotlinNullablePropertyCustomizer.kt index 5d49f11e4..7c1b582b8 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/KotlinNullablePropertyCustomizer.kt +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/KotlinNullablePropertyCustomizer.kt @@ -128,8 +128,15 @@ class KotlinNullablePropertyCustomizer( * Marks a non-$ref property as nullable. * - OAS 3.0: `nullable: true` * - OAS 3.1: adds `"null"` to the `types` set + * + * A schema representing `Any?` carries no `type`/`types` constraint, i.e. it is an + * "any" schema which already permits any value including `null`. Such a schema is left + * untouched (kept as an empty schema) rather than being narrowed to `type: "null"` + * (3.1) or decorated with a redundant `nullable: true` (3.0). */ private fun markNullable(property: Schema<*>, specVersion: SpecVersion) { + if (isAnySchema(property)) return + if (specVersion == SpecVersion.V31) { val currentTypes = property.types ?: property.type?.let { setOf(it) } ?: emptySet() if ("null" !in currentTypes) { @@ -140,6 +147,16 @@ class KotlinNullablePropertyCustomizer( } } + /** + * Returns true when the schema imposes no type constraint (i.e. represents `Any`), + * in which case it is treated as an empty schema that already allows any value, + * including `null`. + */ + private fun isAnySchema(property: Schema<*>): Boolean = + property.`$ref` == null && + property.type == null && + property.types.isNullOrEmpty() + /** * Wraps a $ref property in a nullable composite schema. A fresh wrapper schema is returned * (the original property object is left untouched) with any sibling attributes such as diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/NullableController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/NullableController.kt index b190333fa..88d11b2f0 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/NullableController.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app18/NullableController.kt @@ -8,6 +8,7 @@ data class NullableFieldsResponse( val requiredField: String, val nullableString: String? = null, val nullableInt: Int? = null, + val nullableAny: Any? = null, @field:Schema(description = "The nested object") val nullableNested: NestedObject? = null, ) diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app19/NullableController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app19/NullableController.kt index 5befb6ecc..cbb40c68f 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app19/NullableController.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v30/app19/NullableController.kt @@ -7,6 +7,7 @@ data class NullableFieldsResponse( val requiredField: String, val nullableString: String? = null, val nullableInt: Int? = null, + val nullableAny: Any? = null, val nullableNested: NestedObject? = null, ) diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/NullableController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/NullableController.kt index 9061d33b0..8caecbdc3 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/NullableController.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app23/NullableController.kt @@ -8,6 +8,7 @@ data class NullableFieldsResponse( val requiredField: String, val nullableString: String? = null, val nullableInt: Int? = null, + val nullableAny: Any? = null, @field:Schema(description = "The nested object") val nullableNested: NestedObject? = null, ) diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app24/NullableController.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app24/NullableController.kt index f3692c404..9fae28b65 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app24/NullableController.kt +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/kotlin/test/org/springdoc/api/v31/app24/NullableController.kt @@ -7,6 +7,7 @@ data class NullableFieldsResponse( val requiredField: String, val nullableString: String? = null, val nullableInt: Int? = null, + val nullableAny: Any? = null, val nullableNested: NestedObject? = null, ) diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app18.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app18.json index c01658b44..cbcb8e772 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app18.json +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app18.json @@ -68,6 +68,10 @@ "format": "int32", "nullable": true }, + "nullableAny" : { + "type" : "object", + "nullable" : true + }, "nullableNested": { "nullable": true, "allOf": [ diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app19.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app19.json index 1e2388013..c0c18ec03 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app19.json +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.0.1/app19.json @@ -64,6 +64,9 @@ "type": "integer", "format": "int32" }, + "nullableAny" : { + "type" : "object" + }, "nullableNested": { "$ref": "#/components/schemas/NestedObject" } diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app23.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app23.json index bc3974feb..f25a1d44f 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app23.json +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app23.json @@ -70,6 +70,7 @@ ], "format": "int32" }, + "nullableAny": { }, "nullableNested": { "description": "The nested object", "oneOf": [ diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app24.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app24.json index 12b512a12..d71921666 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app24.json +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app24.json @@ -61,6 +61,7 @@ "type": "integer", "format": "int32" }, + "nullableAny": { }, "nullableNested": { "$ref": "#/components/schemas/NestedObject" } From 43f868e6e5e11d3467e5d81c2d7b4c1213b332b5 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 03:11:45 +0200 Subject: [PATCH 19/42] test: drop the contradictory type null on the nullable $ref property of app25 The Kotlin Any? fix stops markNullable from narrowing a schema that carries no type constraint. For a nullable $ref property the wrapper already expresses nullability through oneOf, so the extra type: "null" sibling (which made the property only ever validate against null) is now gone. Co-Authored-By: Claude Opus 5 --- .../src/test/resources/results/3.1.0/app25.json | 1 - 1 file changed, 1 deletion(-) diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app25.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app25.json index 148a3c002..856cd521a 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app25.json +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app25.json @@ -57,7 +57,6 @@ "enum" : [ "ETH" ] }, "nested" : { - "type" : "null", "oneOf" : [ { "$ref" : "#/components/schemas/NestedObject" }, { From 0b8279acd032c075bdbb49530bd35504e2bb0c62 Mon Sep 17 00:00:00 2001 From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:51:41 +0200 Subject: [PATCH 20/42] fix: Add a type deserializer to allow for JSON cloning of a JsonSchema object Backport of PR #3315 from the Spring Boot 4 line. Co-Authored-By: Claude Opus 5 --- .../deserializers/TypeSetDeserializer.java | 65 ++++++++++++ .../core/mixins/SchemaTypeMixin.java | 52 ++++++++++ .../core/providers/ObjectMapperProvider.java | 3 + .../core/utils/SpringDocUtilsTest.java | 99 +++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 springdoc-openapi-starter-common/src/main/java/org/springdoc/core/deserializers/TypeSetDeserializer.java create mode 100644 springdoc-openapi-starter-common/src/main/java/org/springdoc/core/mixins/SchemaTypeMixin.java create mode 100644 springdoc-openapi-starter-common/src/test/java/org/springdoc/core/utils/SpringDocUtilsTest.java diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/deserializers/TypeSetDeserializer.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/deserializers/TypeSetDeserializer.java new file mode 100644 index 000000000..f1afd82cf --- /dev/null +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/deserializers/TypeSetDeserializer.java @@ -0,0 +1,65 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package org.springdoc.core.deserializers; + +import java.io.IOException; +import java.util.LinkedHashSet; +import java.util.Set; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Reads an OpenAPI 3.1 schema {@code type} that swagger-core serializes as the scalar + * {@code "type": "integer"} back into the {@code Set} field, while still supporting + * the array form {@code "type": ["string","null"]}. + *

+ * Reported upstream with swagger-core issue 5264 + * + * @author Mattias-Sehlstedt + */ +public class TypeSetDeserializer extends JsonDeserializer> { + + @Override + public Set deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = ctxt.readTree(p); + if (node == null || node.isNull()) + return null; + Set types = new LinkedHashSet<>(); + if (node.isArray()) + node.forEach(typeNode -> { + if (!typeNode.isNull()) + types.add(typeNode.asText()); + }); + else + types.add(node.asText()); + return types.isEmpty() ? null : types; + } + +} diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/mixins/SchemaTypeMixin.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/mixins/SchemaTypeMixin.java new file mode 100644 index 000000000..72a494d21 --- /dev/null +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/mixins/SchemaTypeMixin.java @@ -0,0 +1,52 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package org.springdoc.core.mixins; + +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.springdoc.core.deserializers.TypeSetDeserializer; + +/** + * The type Schema type mixin. Makes the OpenAPI 3.1 {@code type} readable back into the + * {@code types} set, whichever of the two serialized forms it takes. + * + * @author Mattias-Sehlstedt + */ +public interface SchemaTypeMixin { + + /** + * Sets types. + * + * @param types the types + */ + @JsonProperty("type") + @JsonDeserialize(using = TypeSetDeserializer.class) + void setTypes(Set types); + +} diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/providers/ObjectMapperProvider.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/providers/ObjectMapperProvider.java index 11124f58e..a2a498b60 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/providers/ObjectMapperProvider.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/providers/ObjectMapperProvider.java @@ -35,7 +35,9 @@ import io.swagger.v3.core.util.Yaml; import io.swagger.v3.core.util.Yaml31; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.JsonSchema; import io.swagger.v3.oas.models.media.Schema; +import org.springdoc.core.mixins.SchemaTypeMixin; import org.springdoc.core.mixins.SortedOpenAPIMixin; import org.springdoc.core.mixins.SortedOpenAPIMixin31; import org.springdoc.core.mixins.SortedSchemaMixin; @@ -74,6 +76,7 @@ public ObjectMapperProvider(SpringDocConfigProperties springDocConfigProperties) if (openApiVersion == OpenApiVersion.OPENAPI_3_1) { jsonMapper = Json31.mapper(); yamlMapper = Yaml31.mapper(); + jsonMapper.addMixIn(JsonSchema.class, SchemaTypeMixin.class); if (springDocConfigProperties.isUseArbitrarySchemas()) { System.setProperty(Schema.USE_ARBITRARY_SCHEMA_PROPERTY, "true"); } diff --git a/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/utils/SpringDocUtilsTest.java b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/utils/SpringDocUtilsTest.java new file mode 100644 index 000000000..eeb0de530 --- /dev/null +++ b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/utils/SpringDocUtilsTest.java @@ -0,0 +1,99 @@ +/* + * + * * + * * * + * * * * + * * * * * 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. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package org.springdoc.core.utils; + +import java.util.Set; + +import io.swagger.v3.oas.models.media.JsonSchema; +import org.junit.jupiter.api.Test; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springdoc.core.properties.SpringDocConfigProperties.ApiDocs.OpenApiVersion; +import org.springdoc.core.providers.ObjectMapperProvider; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Tests that an OpenAPI 3.1 {@code JsonSchema} survives a JSON clone: swagger-core writes a + * single type as the scalar {@code type}, which has to be read back into the {@code types} set + * instead of falling back to returning the original instance. + * + * @author Mattias-Sehlstedt + */ +class SpringDocUtilsTest { + + @Test + void singleTypeForJsonSchemaJsonCloning() { + ObjectMapperProvider provider = openapi31Provider(); + + JsonSchema jsonSchema = new JsonSchema(); + jsonSchema.setTypes(Set.of("integer")); + + JsonSchema cloned = SpringDocUtils.cloneViaJson(jsonSchema, JsonSchema.class, provider.jsonMapper()); + + // The object is cloned properly, we do not get the type cast fallback + assertNotSame(jsonSchema, cloned); + assertNotNull(cloned); + assertEquals(Set.of("integer"), cloned.getTypes()); + } + + @Test + void nullTypeBecomesNullTypesForJsonSchemaJsonCloning() { + ObjectMapperProvider provider = openapi31Provider(); + + JsonSchema jsonSchema = new JsonSchema(); + + JsonSchema cloned = SpringDocUtils.cloneViaJson(jsonSchema, JsonSchema.class, provider.jsonMapper()); + + // The object is cloned properly, we do not get the type cast fallback + assertNotSame(jsonSchema, cloned); + assertNotNull(cloned); + assertNull(cloned.getTypes()); + } + + @Test + void typeArrayIsRetainedForJsonSchemaJsonCloning() { + ObjectMapperProvider provider = openapi31Provider(); + + JsonSchema jsonSchema = new JsonSchema(); + jsonSchema.setTypes(Set.of("integer", "null")); + + JsonSchema cloned = SpringDocUtils.cloneViaJson(jsonSchema, JsonSchema.class, provider.jsonMapper()); + + // The object is cloned properly, we do not get the type cast fallback + assertNotSame(jsonSchema, cloned); + assertNotNull(cloned); + assertEquals(Set.of("integer", "null"), cloned.getTypes()); + } + + private ObjectMapperProvider openapi31Provider() { + SpringDocConfigProperties properties = new SpringDocConfigProperties(); + properties.getApiDocs().setVersion(OpenApiVersion.OPENAPI_3_1); + return new ObjectMapperProvider(properties); + } + +} From 64595875bc4b5a6451ab7abdde4d419cdb1b787b Mon Sep 17 00:00:00 2001 From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:57:28 +0200 Subject: [PATCH 21/42] fix: stop container validation annotations leaking between parameters Annotations declared on a container's type argument were invisible when the schema was first resolved, so the constraint was applied afterwards to a cached schema shared by every parameter of that type. Reading them from the parameter's annotated type at resolution time keeps each parameter's constraints its own. Backport of PR #3322. The app267 test fixtures, introduced with PR #3259 and never backported, come along so the regression stays covered on this line too. Co-Authored-By: Claude Opus 5 --- .../extractor/DelegatingMethodParameter.java | 4 +- .../core/service/AbstractRequestService.java | 7 +- .../core/service/GenericParameterService.java | 6 +- .../api/v30/app267/HelloController.java | 41 ++++++ .../api/v30/app267/PersonQueryFilter.java | 37 ++++++ .../api/v30/app267/SpringDocApp267Test.java | 32 +++++ .../api/v31/app267/HelloController.java | 41 ++++++ .../api/v31/app267/PersonQueryFilter.java | 37 ++++++ .../api/v31/app267/SpringDocApp267Test.java | 32 +++++ .../test/resources/results/3.0.1/app267.json | 118 ++++++++++++++++++ .../test/resources/results/3.1.0/app267.json | 105 ++++++++++++++++ 11 files changed, 453 insertions(+), 7 deletions(-) create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app267/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app267/PersonQueryFilter.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app267/SpringDocApp267Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app267/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app267/PersonQueryFilter.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app267/SpringDocApp267Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app267.json create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app267.json diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java index f9450f512..7168f962c 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java @@ -306,10 +306,10 @@ public boolean isParameterObject() { } /** - * Gets field. If Is parameter object. then The Field should be not null + * Gets field. If it is a parameter object, then The {@code Field} should be not null. + * see {@link DelegatingMethodParameter#isParameterObject()} * * @return the field - * @see #isParameterObject */ @Nullable public Field getField() { diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java index 0854000c8..31ad68910 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java @@ -676,17 +676,16 @@ public void applyBeanValidatorAnnotations(final MethodParameter methodParameter, parameter.setSchema(schema); } SchemaUtils.applyValidationsToSchema(schema, annotations, openapiVersion); - if (schema instanceof ArraySchema && methodParameter instanceof DelegatingMethodParameter mp) { + if (schema instanceof ArraySchema && methodParameter instanceof DelegatingMethodParameter delegatingMethodParameter) { java.lang.reflect.AnnotatedType annotatedType = null; if (isParameterObject) { - Field field = mp.getField(); + Field field = delegatingMethodParameter.getField(); if (field != null) { annotatedType = field.getAnnotatedType(); } } else { - java.lang.reflect.Parameter param = mp.getParameter(); - annotatedType = param.getAnnotatedType(); + annotatedType = delegatingMethodParameter.getParameter().getAnnotatedType(); } if (annotatedType instanceof AnnotatedParameterizedType paramType) { java.lang.reflect.AnnotatedType[] typeArgs = paramType.getAnnotatedActualTypeArguments(); diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java index 1bef34383..d8fa6deed 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java @@ -453,7 +453,11 @@ private TypeAndTypeAnnotations resolveTypeAndTypeAnnotationsForParameter(MethodP : new TypeAndTypeAnnotations(type, new ArrayList<>()); } - return new TypeAndTypeAnnotations(type, Arrays.asList(methodParameter.getParameterType().getAnnotations())); + AnnotatedType annotated = methodParameter.getParameter().getAnnotatedType(); + List parameterAnnotations = Stream.concat( + Arrays.stream(annotationsFromAnnotatedTypeArguments(annotated)), + Arrays.stream(methodParameter.getParameterType().getAnnotations())).toList(); + return new TypeAndTypeAnnotations(type, parameterAnnotations); } /** diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app267/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app267/HelloController.java new file mode 100644 index 000000000..baec9736b --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app267/HelloController.java @@ -0,0 +1,41 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v30.app267; + +import java.util.List; + +import jakarta.validation.constraints.Pattern; +import org.springdoc.core.annotations.ParameterObject; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HelloController { + + @GetMapping("/items") + public String list(@ParameterObject PersonQueryFilter criteria) { + return "ok"; + } + + @GetMapping("/persons") + public String persons( + List<@Pattern(regexp = "^[a-zA-Z]$") String> middleNames, + List<@Pattern(regexp = "^\\d+$") String> phoneNumbers) { + return "ok"; + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app267/PersonQueryFilter.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app267/PersonQueryFilter.java new file mode 100644 index 000000000..192191d83 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app267/PersonQueryFilter.java @@ -0,0 +1,37 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v30.app267; + +import java.util.List; + +import jakarta.validation.constraints.Pattern; + +/** + * Sample person search criteria: several {@code List} query parameters; only one uses a type-use + * {@link Pattern} on the element type. + */ +public record PersonQueryFilter( + List firstNames, + List middleNames, + List<@Pattern(regexp = "^\\d+$") String> phoneNumbers) { + + public PersonQueryFilter { + firstNames = firstNames != null ? firstNames : List.of(); + middleNames = middleNames != null ? middleNames : List.of(); + phoneNumbers = phoneNumbers != null ? phoneNumbers : List.of(); + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app267/SpringDocApp267Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app267/SpringDocApp267Test.java new file mode 100644 index 000000000..c5ddad624 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app267/SpringDocApp267Test.java @@ -0,0 +1,32 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v30.app267; + +import test.org.springdoc.api.v30.AbstractSpringDocV30Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Regression: {@code @Pattern} on one {@code List} field in a {@code @ParameterObject} must not + * be applied to sibling {@code List} query parameters' item schemas. + */ +public class SpringDocApp267Test extends AbstractSpringDocV30Test { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app267/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app267/HelloController.java new file mode 100644 index 000000000..4ba121faf --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app267/HelloController.java @@ -0,0 +1,41 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v31.app267; + +import java.util.List; + +import jakarta.validation.constraints.Pattern; +import org.springdoc.core.annotations.ParameterObject; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HelloController { + + @GetMapping("/items") + public String list(@ParameterObject PersonQueryFilter criteria) { + return "ok"; + } + + @GetMapping("/persons") + public String persons( + List<@Pattern(regexp = "^[a-zA-Z]$") String> middleNames, + List<@Pattern(regexp = "^\\d+$") String> phoneNumbers) { + return "ok"; + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app267/PersonQueryFilter.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app267/PersonQueryFilter.java new file mode 100644 index 000000000..6569a5340 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app267/PersonQueryFilter.java @@ -0,0 +1,37 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v31.app267; + +import java.util.List; + +import jakarta.validation.constraints.Pattern; + +/** + * Sample person search criteria: several {@code List} query parameters; only one uses a type-use + * {@link Pattern} on the element type. + */ +public record PersonQueryFilter( + List firstNames, + List middleNames, + List<@Pattern(regexp = "^\\d+$") String> phoneNumbers) { + + public PersonQueryFilter { + firstNames = firstNames != null ? firstNames : List.of(); + middleNames = middleNames != null ? middleNames : List.of(); + phoneNumbers = phoneNumbers != null ? phoneNumbers : List.of(); + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app267/SpringDocApp267Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app267/SpringDocApp267Test.java new file mode 100644 index 000000000..878fa8b54 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app267/SpringDocApp267Test.java @@ -0,0 +1,32 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v31.app267; + +import test.org.springdoc.api.v31.AbstractSpringDocTest; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Regression: {@code @Pattern} on one {@code List} field in a {@code @ParameterObject} must not + * be applied to sibling {@code List} query parameters' item schemas. + */ +public class SpringDocApp267Test extends AbstractSpringDocTest { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app267.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app267.json new file mode 100644 index 000000000..7727ad454 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app267.json @@ -0,0 +1,118 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/persons": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "persons", + "parameters": [ + { + "name": "middleNames", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "pattern": "^[a-zA-Z]$", + "type": "string" + } + } + }, + { + "name": "phoneNumbers", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "pattern": "^\\d+$", + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/items": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "list", + "parameters": [ + { + "name": "firstNames", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "middleNames", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "phoneNumbers", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "pattern": "^\\d+$", + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": {} +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app267.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app267.json new file mode 100644 index 000000000..5a9320eba --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app267.json @@ -0,0 +1,105 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "OpenAPI definition", + "version" : "v0" + }, + "servers" : [ { + "url" : "http://localhost", + "description" : "Generated server url" + } ], + "paths" : { + "/persons" : { + "get" : { + "tags" : [ "hello-controller" ], + "operationId" : "persons", + "parameters" : [ { + "name" : "middleNames", + "in" : "query", + "required" : true, + "schema" : { + "type" : "array", + "items" : { + "type" : "string", + "pattern" : "^[a-zA-Z]$" + } + } + }, { + "name" : "phoneNumbers", + "in" : "query", + "required" : true, + "schema" : { + "type" : "array", + "items" : { + "type" : "string", + "pattern" : "^\\d+$" + } + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + }, + "/items" : { + "get" : { + "tags" : [ "hello-controller" ], + "operationId" : "list", + "parameters" : [ { + "name" : "firstNames", + "in" : "query", + "required" : false, + "schema" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, { + "name" : "middleNames", + "in" : "query", + "required" : false, + "schema" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, { + "name" : "phoneNumbers", + "in" : "query", + "required" : false, + "schema" : { + "type" : "array", + "items" : { + "type" : "string", + "pattern" : "^\\d+$" + } + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + } + }, + "components" : { } +} From a5c8b4bfbeb75a57ab9958cf478b2f9305e1744f Mon Sep 17 00:00:00 2001 From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:58:13 +0200 Subject: [PATCH 22/42] fix: resolve validation annotations declared inside Optional parameters Constraints written on the type argument of an Optional parameter were dropped. Annotations are now read from every parameter's annotated type, through a guarded accessor that returns null instead of throwing when the method parameter has no index. Backport of PR #3331. Co-Authored-By: Claude Opus 5 --- .../core/service/GenericParameterService.java | 24 +++- .../api/v30/app270/HelloController.java | 60 +++++++++ .../api/v30/app270/SpringDocApp270Test.java | 41 ++++++ .../api/v31/app270/HelloController.java | 60 +++++++++ .../api/v31/app270/SpringDocApp270Test.java | 41 ++++++ .../test/resources/results/3.0.1/app270.json | 118 ++++++++++++++++++ .../test/resources/results/3.1.0/app270.json | 103 +++++++++++++++ 7 files changed, 443 insertions(+), 4 deletions(-) create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/SpringDocApp270Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/SpringDocApp270Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app270.json create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app270.json diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java index d8fa6deed..4d165cb14 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java @@ -453,11 +453,27 @@ private TypeAndTypeAnnotations resolveTypeAndTypeAnnotationsForParameter(MethodP : new TypeAndTypeAnnotations(type, new ArrayList<>()); } - AnnotatedType annotated = methodParameter.getParameter().getAnnotatedType(); - List parameterAnnotations = Stream.concat( - Arrays.stream(annotationsFromAnnotatedTypeArguments(annotated)), + List typeAnnotations = Stream.concat( + Arrays.stream(annotationsFromAnnotatedTypeArguments(getParameterAnnotatedType(methodParameter))), Arrays.stream(methodParameter.getParameterType().getAnnotations())).toList(); - return new TypeAndTypeAnnotations(type, parameterAnnotations); + return new TypeAndTypeAnnotations(type, typeAnnotations); + } + + /** + * Resolves the {@link AnnotatedType} of a method parameter so that annotations declared on its + * generic type arguments (for example inside a {@link List} or an {@link Optional}) can be inspected. + * + * @param methodParameter the method parameter + * @return the annotated type, or {@code null} if it cannot be resolved + */ + private static AnnotatedType getParameterAnnotatedType(MethodParameter methodParameter) { + int index = methodParameter.getParameterIndex(); + if (index < 0) + return null; + java.lang.reflect.Parameter[] parameters = methodParameter.getExecutable().getParameters(); + if (index >= parameters.length) + return null; + return parameters[index].getAnnotatedType(); } /** diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/HelloController.java new file mode 100644 index 000000000..28540cf06 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/HelloController.java @@ -0,0 +1,60 @@ +/* + * + * * + * * * + * * * * + * * * * * 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. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app270; + +import java.util.List; +import java.util.Optional; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Verifies that Bean Validation constraint annotations declared on the type argument of an + * {@link Optional} controller parameter are read and applied to the generated OpenAPI schema. + */ +@RestController +public class HelloController { + + @GetMapping("/api/string") + String testString(@RequestParam Optional<@Size(min = 2, max = 10) @Pattern(regexp = "[a-z]+") String> name) { + return null; + } + + @GetMapping("/api/integer") + String testInteger(@RequestParam Optional<@Min(5) Integer> age) { + return null; + } + + @GetMapping("/api/list") + String testList(@RequestParam @Size(min = 2, max = 10) List<@Pattern(regexp = "[a-z]+") String> age) { + return null; + } + +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/SpringDocApp270Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/SpringDocApp270Test.java new file mode 100644 index 000000000..9601557a3 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/SpringDocApp270Test.java @@ -0,0 +1,41 @@ +/* + * + * * + * * * + * * * * + * * * * * 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. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app270; + +import test.org.springdoc.api.v30.AbstractSpringDocV30Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Regression: Bean Validation constraints declared on the type argument of an {@code Optional} + * controller parameter must be applied to the generated parameter schema. + */ +public class SpringDocApp270Test extends AbstractSpringDocV30Test { + + @SpringBootApplication + static class SpringDocTestApp { + } +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/HelloController.java new file mode 100644 index 000000000..4ab0035f3 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/HelloController.java @@ -0,0 +1,60 @@ +/* + * + * * + * * * + * * * * + * * * * * 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. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app270; + +import java.util.List; +import java.util.Optional; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Verifies that Bean Validation constraint annotations declared on the type argument of an + * {@link Optional} controller parameter are read and applied to the generated OpenAPI schema. + */ +@RestController +public class HelloController { + + @GetMapping("/api/string") + String testString(@RequestParam Optional<@Size(min = 2, max = 10) @Pattern(regexp = "[a-z]+") String> name) { + return null; + } + + @GetMapping("/api/integer") + String testInteger(@RequestParam Optional<@Min(5) Integer> age) { + return null; + } + + @GetMapping("/api/list") + String testList(@RequestParam @Size(min = 2, max = 10) List<@Pattern(regexp = "[a-z]+") String> age) { + return null; + } + +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/SpringDocApp270Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/SpringDocApp270Test.java new file mode 100644 index 000000000..597cfc5b3 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/SpringDocApp270Test.java @@ -0,0 +1,41 @@ +/* + * + * * + * * * + * * * * + * * * * * 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. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app270; + +import test.org.springdoc.api.v31.AbstractSpringDocTest; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Regression: Bean Validation constraints declared on the type argument of an {@code Optional} + * controller parameter must be applied to the generated parameter schema. + */ +public class SpringDocApp270Test extends AbstractSpringDocTest { + + @SpringBootApplication + static class SpringDocTestApp { + } +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app270.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app270.json new file mode 100644 index 000000000..1e6765c92 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app270.json @@ -0,0 +1,118 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/api/string": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "testString", + "parameters": [ + { + "name": "name", + "in": "query", + "required": false, + "schema": { + "maxLength": 10, + "minLength": 2, + "pattern": "[a-z]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/list": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "testList", + "parameters": [ + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "maxItems": 10, + "minItems": 2, + "type": "array", + "items": { + "pattern": "[a-z]+", + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/integer": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "testInteger", + "parameters": [ + { + "name": "age", + "in": "query", + "required": false, + "schema": { + "minimum": 5, + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": {} +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app270.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app270.json new file mode 100644 index 000000000..744a8d655 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app270.json @@ -0,0 +1,103 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "OpenAPI definition", + "version" : "v0" + }, + "servers" : [ { + "url" : "http://localhost", + "description" : "Generated server url" + } ], + "paths" : { + "/api/string" : { + "get" : { + "tags" : [ "hello-controller" ], + "operationId" : "testString", + "parameters" : [ { + "name" : "name", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string", + "maxLength" : 10, + "minLength" : 2, + "pattern" : "[a-z]+" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + }, + "/api/list" : { + "get" : { + "tags" : [ "hello-controller" ], + "operationId" : "testList", + "parameters" : [ { + "name" : "age", + "in" : "query", + "required" : true, + "schema" : { + "type" : "array", + "items" : { + "type" : "string", + "pattern" : "[a-z]+" + }, + "maxItems" : 10, + "minItems" : 2 + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + }, + "/api/integer" : { + "get" : { + "tags" : [ "hello-controller" ], + "operationId" : "testInteger", + "parameters" : [ { + "name" : "age", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "minimum" : 5 + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + } + }, + "components" : { } +} \ No newline at end of file From 68043c485917ca84a7ad0130ebf5b94f0d10d614 Mon Sep 17 00:00:00 2001 From: Taha El Amine Kassabi Date: Sun, 6 Sep 2026 04:00:17 +0200 Subject: [PATCH 23/42] feat: describe JsonNullable values without their Java wrapper JsonNullable now renders as the wrapped value, nullable unless a non-null constraint applies, and no longer becomes required merely because such a constraint is present - the wrapper exists precisely to tell an omitted property apart from an explicit null. The converter is registered only when jackson-databind-nullable is on the classpath. Backport of PR #3340. jspecify is not on this line's test classpath, so the fixture declares its own TYPE_USE @Nullable; nullability is matched by simple name, so the behaviour under test is unchanged. Co-Authored-By: Claude Opus 5 --- pom.xml | 6 + .../configuration/SpringDocConfiguration.java | 13 ++ .../JsonNullableSupportConverter.java | 193 +++++++++++++++++ springdoc-openapi-starter-webmvc-api/pom.xml | 5 + .../api/v30/app271/HelloController.java | 110 ++++++++++ .../api/v30/app271/SpringDocApp271Test.java | 33 +++ .../api/v31/app271/HelloController.java | 110 ++++++++++ .../api/v31/app271/SpringDocApp271Test.java | 33 +++ .../test/resources/results/3.0.1/app271.json | 195 +++++++++++++++++ .../test/resources/results/3.1.0/app271.json | 203 ++++++++++++++++++ 10 files changed, 901 insertions(+) create mode 100644 springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/JsonNullableSupportConverter.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app271/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app271/SpringDocApp271Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app271/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app271/SpringDocApp271Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app271.json create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app271.json diff --git a/pom.xml b/pom.xml index 2ad4d39f1..22d06f0f7 100644 --- a/pom.xml +++ b/pom.xml @@ -60,6 +60,7 @@ 5.32.14 1.13.1 0.9.1 + 0.2.8 0.15.0 4.2.2 0.5.55 @@ -92,6 +93,11 @@ jjwt ${jjwt.version} + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + org.springframework.cloud diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocConfiguration.java index c38eb7462..973c456f1 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocConfiguration.java @@ -52,6 +52,7 @@ import org.springdoc.core.configurer.SpringdocBeanFactoryConfigurer; import org.springdoc.core.converters.AdditionalModelsConverter; import org.springdoc.core.converters.FileSupportConverter; +import org.springdoc.core.converters.JsonNullableSupportConverter; import org.springdoc.core.converters.ModelConverterRegistrar; import org.springdoc.core.converters.OAS31ModelConverter; import org.springdoc.core.converters.PolymorphicModelConverter; @@ -262,6 +263,18 @@ ResponseSupportConverter responseSupportConverter(ObjectMapperProvider objectMap return new ResponseSupportConverter(objectMapperProvider); } + /** + * @param objectMapperProvider the OpenAPI object mapper provider + * @return the JsonNullable support converter + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnClass(name = "org.openapitools.jackson.nullable.JsonNullable") + @Lazy(false) + JsonNullableSupportConverter jsonNullableSupportConverter(ObjectMapperProvider objectMapperProvider) { + return new JsonNullableSupportConverter(objectMapperProvider); + } + /** * Schema property deprecating converter schema property deprecating converter. * diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/JsonNullableSupportConverter.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/JsonNullableSupportConverter.java new file mode 100644 index 000000000..c39606d53 --- /dev/null +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/JsonNullableSupportConverter.java @@ -0,0 +1,193 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package org.springdoc.core.converters; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedParameterizedType; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; +import io.swagger.v3.core.converter.AnnotatedType; +import io.swagger.v3.core.converter.ModelConverter; +import io.swagger.v3.core.converter.ModelConverterContext; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.jackson.ModelResolver; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.media.ComposedSchema; +import io.swagger.v3.oas.models.media.Schema; +import org.springdoc.core.providers.ObjectMapperProvider; +import org.springdoc.core.utils.SchemaUtils; + +/** + * Describes JsonNullable values without exposing their Java wrapper. + * + * @author dpkass + */ +public class JsonNullableSupportConverter implements ModelConverter { + + /** + * The constant JSON_NULLABLE. + */ + private static final String JSON_NULLABLE = "org.openapitools.jackson.nullable.JsonNullable"; + + /** + * The Object mapper provider. + */ + private final ObjectMapperProvider mapperProvider; + + /** + * Instantiates a new JsonNullable support converter. + * + * @param mapperProvider the OpenAPI object mapper provider + */ + public JsonNullableSupportConverter(ObjectMapperProvider mapperProvider) { + this.mapperProvider = mapperProvider; + } + + @Override + public Schema resolve(AnnotatedType type, ModelConverterContext context, Iterator chain) { + JavaType javaType = mapperProvider.jsonMapper().constructType(type.getType()); + if (javaType != null && JSON_NULLABLE.equals(javaType.getRawClass().getName())) + return resolveValue(javaType, type.getCtxAnnotations(), type, context); + + Schema resolved = chain.hasNext() ? chain.next().resolve(type, context, chain) : null; + if (resolved == null || javaType == null) + return resolved; + Schema model = resolved; + if (model.get$ref() != null) { + if (!model.get$ref().startsWith(Components.COMPONENTS_SCHEMAS_REF)) + return resolved; + model = context.getDefinedModels().get(model.get$ref().substring(Components.COMPONENTS_SCHEMAS_REF.length())); + } + if (model == null || model.getProperties() == null) + return resolved; + + ObjectMapper mapper = ModelConverters.getInstance(mapperProvider.isOpenapi31()).getConverters().stream() + .filter(ModelResolver.class::isInstance).map(ModelResolver.class::cast) + .map(ModelResolver::objectMapper).findFirst().orElse(null); + if (mapper == null) + return resolved; + var bean = mapper.getSerializationConfig().introspect(javaType); + var schema = bean.getClassInfo().getAnnotation(io.swagger.v3.oas.annotations.media.Schema.class); + List requiredProperties = schema == null ? List.of() : Arrays.asList(schema.requiredProperties()); + for (BeanPropertyDefinition property : bean.findProperties()) { + if (!JSON_NULLABLE.equals(property.getPrimaryType().getRawClass().getName()) + || !model.getProperties().containsKey(property.getName())) + continue; + List annotations = typeArgumentAnnotations(property); + if (!annotations.isEmpty()) { + property.getPrimaryMember().annotations().forEach(annotations::add); + model.addProperty(property.getName(), resolveValue(property.getPrimaryType(), + annotations.toArray(Annotation[]::new), type, context)); + } + // ModelResolver infers presence from validation constraints after resolving + // a property. JsonNullable instead permits omission unless explicitly required. + var propertySchema = property.getPrimaryMember().getAnnotation(io.swagger.v3.oas.annotations.media.Schema.class); + Boolean required = SchemaUtils.swaggerRequired(propertySchema, null); + boolean explicitlyRequired = required != null ? required : property.isRequired(); + if (model.getRequired() != null && !explicitlyRequired && !requiredProperties.contains(property.getName())) + model.getRequired().remove(property.getName()); + } + if (model.getRequired() != null && model.getRequired().isEmpty()) + model.setRequired(null); + return resolved; + } + + /** + * Resolve the value normally, then add null only when no non-null constraint applies. + * Conflicting annotations retain ModelResolver's behavior. + * + * @param wrapper the JsonNullable wrapper type + * @param annotations the context annotations + * @param original the annotated type being resolved + * @param context the model converter context + * @return the schema of the wrapped value + */ + private Schema resolveValue(JavaType wrapper, Annotation[] annotations, AnnotatedType original, + ModelConverterContext context) { + Schema value = context.resolve(new AnnotatedType(wrapper.containedTypeOrUnknown(0)) + .ctxAnnotations(annotations).jsonViewAnnotation(original.getJsonViewAnnotation()).resolveAsRef(true)); + if (value == null || (annotations != null && SchemaUtils.annotatedNotNull(Arrays.asList(annotations)))) + return value; + + if (value.get$ref() == null && value.getEnum() == null + && value.getAllOf() == null && value.getAnyOf() == null && value.getOneOf() == null + && (value.getType() != null || value.getTypes() != null)) { + // Keep nullability local rather than mutating a cached/shared schema. + Schema nullable = mapperProvider.jsonMapper().convertValue(value, Schema.class); + if (mapperProvider.isOpenapi31()) { + if (nullable.getTypes() == null && nullable.getType() != null) + nullable.addType(nullable.getType()); + nullable.addType("null"); + } + else + nullable.setNullable(true); + return nullable; + } + Schema nullValue = new Schema(); + if (mapperProvider.isOpenapi31()) + nullValue.addType("null"); + else { + nullValue.setType("object"); + nullValue.setNullable(true); + nullValue.setEnum(Collections.singletonList(null)); + } + return new ComposedSchema().addAnyOfItem(value).addAnyOfItem(nullValue); + } + + /** + * JavaType does not retain type-use annotations; retrieve them from the members. + * + * @param property the bean property + * @return the annotations declared on the wrapped type argument + */ + private List typeArgumentAnnotations(BeanPropertyDefinition property) { + List annotations = new ArrayList<>(); + for (var member : Arrays.asList(property.getField(), property.getGetter(), property.getSetter())) { + if (member == null) + continue; + java.lang.reflect.AnnotatedType annotatedType = null; + if (member.getMember() instanceof Field field) + annotatedType = field.getAnnotatedType(); + else if (member.getMember() instanceof Method method) + annotatedType = method.getParameterCount() == 0 ? method.getAnnotatedReturnType() + : method.getAnnotatedParameterTypes()[0]; + if (annotatedType instanceof AnnotatedParameterizedType parameterized) + annotations.addAll(Arrays.asList(parameterized.getAnnotatedActualTypeArguments()[0].getAnnotations())); + } + return annotations; + } + +} diff --git a/springdoc-openapi-starter-webmvc-api/pom.xml b/springdoc-openapi-starter-webmvc-api/pom.xml index 230f18a91..b0dfa7c5b 100644 --- a/springdoc-openapi-starter-webmvc-api/pom.xml +++ b/springdoc-openapi-starter-webmvc-api/pom.xml @@ -29,6 +29,11 @@ spring-boot-actuator-autoconfigure true + + org.openapitools + jackson-databind-nullable + test + javax.money money-api diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app271/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app271/HelloController.java new file mode 100644 index 000000000..0eb5cd4a8 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app271/HelloController.java @@ -0,0 +1,110 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v30.app271; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.openapitools.jackson.nullable.JsonNullable; + +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * A merge-patch model with optional, nullable and explicitly required properties. + * + * @author dpkass + */ +@RestController +public class HelloController { + + @PatchMapping("/example") + public Patch patch(@RequestBody Patch patch) { + return patch; + } + + @PatchMapping("/bean") + public Bean patchBean(@RequestBody Bean bean) { + return bean; + } + + public record Patch( + JsonNullable nullable, + @NotNull JsonNullable nonNull, + JsonNullable<@NotNull String> innerNonNull, + JsonNullable<@NotBlank @Size(max = 20) String> innerNonBlank, + @jakarta.annotation.Nullable JsonNullable<@NotNull String> outerNullable, + @NotNull JsonNullable<@Nullable String> innerNullable, + @NonNull JsonNullable nonNullAlias, + @NotBlank @Size(max = 20) JsonNullable nonBlank, + @NotEmpty JsonNullable> nonEmpty, + JsonNullable> list, + JsonNullable> map, + JsonNullable child, + @NotNull JsonNullable nonNullChild, + @JsonProperty("renamed") @NotNull JsonNullable original, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) JsonNullable required, + @NotNull String ordinary) {} + + public record Child(@NotNull String name) {} + + @Retention(RetentionPolicy.RUNTIME) + public @interface NonNull {} + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE_USE) + public @interface Nullable {} + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static class Bean { + public JsonNullable<@NotNull String> fieldValue; + + private JsonNullable setterValue; + + private JsonNullable getterValue; + + public JsonNullable getSetterValue() { + return setterValue; + } + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + public void setSetterValue(JsonNullable<@NotNull String> value) { + setterValue = value; + } + + public JsonNullable<@NotNull String> getGetterValue() { + return getterValue; + } + + public void setGetterValue(JsonNullable value) { + getterValue = value; + } + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app271/SpringDocApp271Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app271/SpringDocApp271Test.java new file mode 100644 index 000000000..4f214204b --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app271/SpringDocApp271Test.java @@ -0,0 +1,33 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v30.app271; + +import test.org.springdoc.api.v30.AbstractSpringDocV30Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * JsonNullable schema regression fixture. + * + * @author dpkass + */ +public class SpringDocApp271Test extends AbstractSpringDocV30Test { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app271/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app271/HelloController.java new file mode 100644 index 000000000..2feedaf86 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app271/HelloController.java @@ -0,0 +1,110 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v31.app271; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.openapitools.jackson.nullable.JsonNullable; + +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * A merge-patch model with optional, nullable and explicitly required properties. + * + * @author dpkass + */ +@RestController +public class HelloController { + + @PatchMapping("/example") + public Patch patch(@RequestBody Patch patch) { + return patch; + } + + @PatchMapping("/bean") + public Bean patchBean(@RequestBody Bean bean) { + return bean; + } + + public record Patch( + JsonNullable nullable, + @NotNull JsonNullable nonNull, + JsonNullable<@NotNull String> innerNonNull, + JsonNullable<@NotBlank @Size(max = 20) String> innerNonBlank, + @jakarta.annotation.Nullable JsonNullable<@NotNull String> outerNullable, + @NotNull JsonNullable<@Nullable String> innerNullable, + @NonNull JsonNullable nonNullAlias, + @NotBlank @Size(max = 20) JsonNullable nonBlank, + @NotEmpty JsonNullable> nonEmpty, + JsonNullable> list, + JsonNullable> map, + JsonNullable child, + @NotNull JsonNullable nonNullChild, + @JsonProperty("renamed") @NotNull JsonNullable original, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) JsonNullable required, + @NotNull String ordinary) {} + + public record Child(@NotNull String name) {} + + @Retention(RetentionPolicy.RUNTIME) + public @interface NonNull {} + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE_USE) + public @interface Nullable {} + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static class Bean { + public JsonNullable<@NotNull String> fieldValue; + + private JsonNullable setterValue; + + private JsonNullable getterValue; + + public JsonNullable getSetterValue() { + return setterValue; + } + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + public void setSetterValue(JsonNullable<@NotNull String> value) { + setterValue = value; + } + + public JsonNullable<@NotNull String> getGetterValue() { + return getterValue; + } + + public void setGetterValue(JsonNullable value) { + getterValue = value; + } + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app271/SpringDocApp271Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app271/SpringDocApp271Test.java new file mode 100644 index 000000000..7b41e546d --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app271/SpringDocApp271Test.java @@ -0,0 +1,33 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v31.app271; + +import test.org.springdoc.api.v31.AbstractSpringDocV31Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * JsonNullable schema regression fixture. + * + * @author dpkass + */ +public class SpringDocApp271Test extends AbstractSpringDocV31Test { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app271.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app271.json new file mode 100644 index 000000000..59aa1348e --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app271.json @@ -0,0 +1,195 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/example": { + "patch": { + "tags": [ + "hello-controller" + ], + "operationId": "patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Patch" + } + } + } + } + } + } + }, + "/bean": { + "patch": { + "tags": [ + "hello-controller" + ], + "operationId": "patchBean", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Bean" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Bean" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Child": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "Patch": { + "required": [ + "ordinary", + "required" + ], + "type": "object", + "properties": { + "nullable": { + "type": "string", + "nullable": true + }, + "nonNull": { + "type": "string" + }, + "innerNonNull": { + "type": "string" + }, + "innerNonBlank": { + "maxLength": 20, + "minLength": 0, + "type": "string" + }, + "outerNullable": { + "type": "string", + "nullable": true + }, + "innerNullable": { + "type": "string", + "nullable": true + }, + "nonNullAlias": { + "type": "string" + }, + "nonBlank": { + "maxLength": 20, + "minLength": 0, + "type": "string" + }, + "nonEmpty": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "list": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "map": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "child": { + "anyOf": [ + { + "$ref": "#/components/schemas/Child" + }, + { + "type": "object", + "nullable": true, + "enum": [ + null + ] + } + ] + }, + "nonNullChild": { + "$ref": "#/components/schemas/Child" + }, + "renamed": { + "type": "string" + }, + "required": { + "type": "string", + "nullable": true + }, + "ordinary": { + "type": "string" + } + } + }, + "Bean": { + "required": [ + "setter_value" + ], + "type": "object", + "properties": { + "field_value": { + "type": "string" + }, + "setter_value": { + "type": "string" + }, + "getter_value": { + "type": "string" + } + } + } + } + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app271.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app271.json new file mode 100644 index 000000000..07ec45e80 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app271.json @@ -0,0 +1,203 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/example": { + "patch": { + "tags": [ + "hello-controller" + ], + "operationId": "patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Patch" + } + } + } + } + } + } + }, + "/bean": { + "patch": { + "tags": [ + "hello-controller" + ], + "operationId": "patchBean", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Bean" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Bean" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Child": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "Patch": { + "type": "object", + "properties": { + "nullable": { + "type": [ + "string", + "null" + ] + }, + "nonNull": { + "type": "string" + }, + "innerNonNull": { + "type": "string" + }, + "innerNonBlank": { + "type": "string", + "maxLength": 20, + "minLength": 0 + }, + "outerNullable": { + "type": [ + "string", + "null" + ] + }, + "innerNullable": { + "type": [ + "string", + "null" + ] + }, + "nonNullAlias": { + "type": "string" + }, + "nonBlank": { + "type": "string", + "maxLength": 20, + "minLength": 0 + }, + "nonEmpty": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "list": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "map": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "child": { + "anyOf": [ + { + "$ref": "#/components/schemas/Child" + }, + { + "type": "null" + } + ] + }, + "nonNullChild": { + "$ref": "#/components/schemas/Child" + }, + "renamed": { + "type": "string" + }, + "required": { + "type": [ + "string", + "null" + ] + }, + "ordinary": { + "type": "string" + } + }, + "required": [ + "ordinary", + "required" + ] + }, + "Bean": { + "type": "object", + "properties": { + "field_value": { + "type": "string" + }, + "setter_value": { + "type": "string" + }, + "getter_value": { + "type": "string" + } + }, + "required": [ + "setter_value" + ] + } + } + } +} From dbe1faea09dc82a85ffbbe0478c5871d9727235a Mon Sep 17 00:00:00 2001 From: king-407 <95581750+king-407@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:02:10 +0200 Subject: [PATCH 24/42] fix: apply customizers in Spring order Customizer collections were iterated in set order, so @Order and Ordered were ignored. They are now sorted with AnnotationAwareOrderComparator at the point where they are consumed, which leaves the existing collection-based construction API (also used by GroupedOpenApi) untouched. Backport of PR #3330, fixes #3320. Co-Authored-By: Claude Opus 5 --- .../api/AbstractMultipleOpenApiResource.java | 4 +- .../api/AbstractOpenApiResource.java | 38 ++---- .../customizers/SpringDocCustomizers.java | 64 ++++++++++ .../api/AbstractOpenApiResourceTest.java | 8 ++ .../customizers/SpringDocCustomizersTest.java | 113 ++++++++++++++++++ 5 files changed, 199 insertions(+), 28 deletions(-) create mode 100644 springdoc-openapi-starter-common/src/test/java/org/springdoc/core/customizers/SpringDocCustomizersTest.java diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractMultipleOpenApiResource.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractMultipleOpenApiResource.java index 5e2728a25..9bdd1448b 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractMultipleOpenApiResource.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractMultipleOpenApiResource.java @@ -126,8 +126,8 @@ protected AbstractMultipleOpenApiResource(List groupedOpenApis, @Override public void afterPropertiesSet() { this.groupedOpenApis.forEach(groupedOpenApi -> { - springDocCustomizers.getGlobalOpenApiCustomizers().ifPresent(groupedOpenApi::addAllOpenApiCustomizer); - springDocCustomizers.getGlobalOperationCustomizers().ifPresent(groupedOpenApi::addAllOperationCustomizer); + groupedOpenApi.addAllOpenApiCustomizer(springDocCustomizers.getGlobalOpenApiCustomizersStream().toList()); + groupedOpenApi.addAllOperationCustomizer(springDocCustomizers.getGlobalOperationCustomizersStream().toList()); springDocCustomizers.getGlobalOpenApiMethodFilters().ifPresent(groupedOpenApi::addAllOpenApiMethodFilter); } ); diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java index ced4498c6..9e7286cd5 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java @@ -407,9 +407,9 @@ protected OpenAPI getOpenApi(String serverBaseUrl, Locale locale) { // run the optional customizers List servers = openAPI.getServers(); List serversCopy = cloneViaJson(servers, new TypeReference>() {}, springDocProviders.jsonMapper()); - - openAPIService.getContext().getBeansOfType(OpenApiLocaleCustomizer.class).values().forEach(openApiLocaleCustomizer -> openApiLocaleCustomizer.customise(openAPI, finalLocale)); - springDocCustomizers.getOpenApiCustomizers().ifPresent(apiCustomizers -> apiCustomizers.forEach(openApiCustomizer -> openApiCustomizer.customise(openAPI))); + + openAPIService.getContext().getBeanProvider(OpenApiLocaleCustomizer.class).orderedStream().forEach(openApiLocaleCustomizer -> openApiLocaleCustomizer.customise(openAPI, finalLocale)); + springDocCustomizers.getOpenApiCustomizersStream().forEach(openApiCustomizer -> openApiCustomizer.customise(openAPI)); if (!CollectionUtils.isEmpty(openAPI.getServers()) && !openAPI.getServers().equals(serversCopy)) openAPIService.setServersPresent(true); @@ -801,13 +801,8 @@ protected void calculatePath(RouterOperation routerOperation, Locale locale, Ope * @return the router operation */ private RouterOperation customizeDataRestRouterOperation(RouterOperation routerOperation) { - Optional> optionalDataRestRouterOperationCustomizers = springDocCustomizers.getDataRestRouterOperationCustomizers(); - if (optionalDataRestRouterOperationCustomizers.isPresent()) { - Set dataRestRouterOperationCustomizerList = optionalDataRestRouterOperationCustomizers.get(); - for (DataRestRouterOperationCustomizer dataRestRouterOperationCustomizer : dataRestRouterOperationCustomizerList) { - routerOperation = dataRestRouterOperationCustomizer.customize(routerOperation); - } - } + for (DataRestRouterOperationCustomizer dataRestRouterOperationCustomizer : springDocCustomizers.getDataRestRouterOperationCustomizersStream().toList()) + routerOperation = dataRestRouterOperationCustomizer.customize(routerOperation); return routerOperation; } @@ -1030,15 +1025,11 @@ protected Set getDefaultAllowedHttpMethods() { * @return the operation */ protected Operation customizeOperation(Operation operation, Components components, HandlerMethod handlerMethod) { - Optional> optionalOperationCustomizers = springDocCustomizers.getOperationCustomizers(); - if (optionalOperationCustomizers.isPresent()) { - Set operationCustomizerList = optionalOperationCustomizers.get(); - for (OperationCustomizer operationCustomizer : operationCustomizerList) { - if (operationCustomizer instanceof GlobalOperationComponentsCustomizer globalOperationComponentsCustomizer) - operation = globalOperationComponentsCustomizer.customize(operation, components, handlerMethod); - else - operation = operationCustomizer.customize(operation, handlerMethod); - } + for (OperationCustomizer operationCustomizer : springDocCustomizers.getOperationCustomizersStream().toList()) { + if (operationCustomizer instanceof GlobalOperationComponentsCustomizer globalOperationComponentsCustomizer) + operation = globalOperationComponentsCustomizer.customize(operation, components, handlerMethod); + else + operation = operationCustomizer.customize(operation, handlerMethod); } return operation; } @@ -1051,13 +1042,8 @@ protected Operation customizeOperation(Operation operation, Components component * @return the router operation */ protected RouterOperation customizeRouterOperation(RouterOperation routerOperation, HandlerMethod handlerMethod) { - Optional> optionalRouterOperationCustomizers = springDocCustomizers.getRouterOperationCustomizers(); - if (optionalRouterOperationCustomizers.isPresent()) { - Set routerOperationCustomizerList = optionalRouterOperationCustomizers.get(); - for (RouterOperationCustomizer routerOperationCustomizer : routerOperationCustomizerList) { - routerOperation = routerOperationCustomizer.customize(routerOperation, handlerMethod); - } - } + for (RouterOperationCustomizer routerOperationCustomizer : springDocCustomizers.getRouterOperationCustomizersStream().toList()) + routerOperation = routerOperationCustomizer.customize(routerOperation, handlerMethod); return routerOperation; } diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/SpringDocCustomizers.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/SpringDocCustomizers.java index b3f9f53a3..076e8d5db 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/SpringDocCustomizers.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/customizers/SpringDocCustomizers.java @@ -25,11 +25,13 @@ */ package org.springdoc.core.customizers; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Stream; import org.springdoc.core.filters.GlobalOpenApiMethodFilter; import org.springdoc.core.filters.OpenApiMethodFilter; @@ -38,6 +40,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.util.CollectionUtils; import static org.springdoc.core.utils.Constants.LINKS_SCHEMA_CUSTOMIZER; @@ -166,6 +169,15 @@ public Optional> getOpenApiCustomizers() { return openApiCustomizers; } + /** + * Gets ordered open api customizer stream. + * + * @return the ordered open api customizer stream + */ + public Stream getOpenApiCustomizersStream() { + return orderedStream(openApiCustomizers); + } + /** * Gets operation customizers. * @@ -175,6 +187,15 @@ public Optional> getOperationCustomizers() { return operationCustomizers; } + /** + * Gets ordered operation customizer stream. + * + * @return the ordered operation customizer stream + */ + public Stream getOperationCustomizersStream() { + return orderedStream(operationCustomizers); + } + /** * Gets router operation customizers. * @@ -184,6 +205,15 @@ public Optional> getRouterOperationCustomizers() return routerOperationCustomizers; } + /** + * Gets ordered router operation customizer stream. + * + * @return the ordered router operation customizer stream + */ + public Stream getRouterOperationCustomizersStream() { + return orderedStream(routerOperationCustomizers); + } + /** * Gets data rest router operation customizers. * @@ -193,6 +223,15 @@ public Optional> getDataRestRouterOperati return dataRestRouterOperationCustomizers; } + /** + * Gets ordered data rest router operation customizer stream. + * + * @return the ordered data rest router operation customizer stream + */ + public Stream getDataRestRouterOperationCustomizersStream() { + return orderedStream(dataRestRouterOperationCustomizers); + } + /** * Gets method filters. * @@ -216,6 +255,15 @@ public Optional> getGlobalOpenApiCustomizers() { return globalOpenApiCustomizers; } + /** + * Gets ordered global open api customizer stream. + * + * @return the ordered global open api customizer stream + */ + public Stream getGlobalOpenApiCustomizersStream() { + return orderedStream(globalOpenApiCustomizers); + } + /** * Gets global operation customizers. * @@ -225,6 +273,15 @@ public Optional> getGlobalOperationCustomizers() return globalOperationCustomizers; } + /** + * Gets ordered global operation customizer stream. + * + * @return the ordered global operation customizer stream + */ + public Stream getGlobalOperationCustomizersStream() { + return orderedStream(globalOperationCustomizers); + } + /** * Gets global open api method filters. * @@ -252,6 +309,13 @@ public Optional> getParameterCustomizers() { return parameterCustomizers; } + private static Stream orderedStream(Optional> customizers) { + return customizers.stream() + .flatMap(Collection::stream) + .filter(Objects::nonNull) + .sorted(AnnotationAwareOrderComparator.INSTANCE); + } + @Override public void afterPropertiesSet() { //add the default customizers diff --git a/springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java b/springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java index 035765bbc..8d5d540fc 100644 --- a/springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java +++ b/springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java @@ -30,6 +30,7 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.stream.Stream; import io.swagger.v3.core.util.Json; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -48,6 +49,7 @@ import org.mockito.internal.stubbing.answers.CallsRealMethods; import org.mockito.junit.jupiter.MockitoExtension; import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.customizers.OpenApiLocaleCustomizer; import org.springdoc.core.customizers.ServerBaseUrlCustomizer; import org.springdoc.core.customizers.SpringDocCustomizers; import org.springdoc.core.fn.RouterOperation; @@ -59,6 +61,7 @@ import org.springdoc.core.service.OperationService; import org.springframework.beans.factory.ObjectFactory; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationContext; import org.springframework.mock.http.client.MockClientHttpRequest; import org.springframework.test.util.ReflectionTestUtils; @@ -112,6 +115,9 @@ class AbstractOpenApiResourceTest { @Mock private ApplicationContext context; + @Mock + private ObjectProvider localeCustomizersProvider; + private OpenAPI openAPI; private AbstractOpenApiResource resource; @@ -125,6 +131,8 @@ public void setUp() { when(openAPIService.build(any())).thenReturn(openAPI); when(openAPIService.getContext()).thenReturn(context); + when(context.getBeanProvider(OpenApiLocaleCustomizer.class)).thenReturn(localeCustomizersProvider); + when(localeCustomizersProvider.orderedStream()).thenAnswer(invocation -> Stream.empty()); when(openAPIService.getWebhooksClasses()).thenReturn(new Class[0]); doAnswer(new CallsRealMethods()).when(openAPIService).setServersPresent(false); diff --git a/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/customizers/SpringDocCustomizersTest.java b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/customizers/SpringDocCustomizersTest.java new file mode 100644 index 000000000..4eae51d7f --- /dev/null +++ b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/customizers/SpringDocCustomizersTest.java @@ -0,0 +1,113 @@ +/* + * + * * + * * * + * * * * + * * * * * 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. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package org.springdoc.core.customizers; + +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; + +import io.swagger.v3.oas.models.OpenAPI; +import org.junit.jupiter.api.Test; + +import org.springframework.core.annotation.Order; + +import static org.assertj.core.api.Assertions.assertThat; + +class SpringDocCustomizersTest { + + @Test + void openApiCustomizersStreamHonorsOrderAnnotation() { + OpenApiCustomizer lastCustomizer = new LastOpenApiCustomizer(); + OpenApiCustomizer firstCustomizer = new FirstOpenApiCustomizer(); + Set unorderedCustomizers = new LinkedHashSet<>(); + unorderedCustomizers.add(lastCustomizer); + unorderedCustomizers.add(firstCustomizer); + + SpringDocCustomizers springDocCustomizers = new SpringDocCustomizers( + Optional.of(unorderedCustomizers), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + + assertThat(springDocCustomizers.getOpenApiCustomizersStream().toList()).containsExactly(firstCustomizer, lastCustomizer); + } + + @Test + void globalOpenApiCustomizersStreamHonorsOrderAnnotation() { + GlobalOpenApiCustomizer lastCustomizer = new LastGlobalOpenApiCustomizer(); + GlobalOpenApiCustomizer firstCustomizer = new FirstGlobalOpenApiCustomizer(); + Set unorderedCustomizers = new LinkedHashSet<>(); + unorderedCustomizers.add(lastCustomizer); + unorderedCustomizers.add(firstCustomizer); + + SpringDocCustomizers springDocCustomizers = new SpringDocCustomizers( + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.of(unorderedCustomizers), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + + assertThat(springDocCustomizers.getGlobalOpenApiCustomizersStream().toList()).containsExactly(firstCustomizer, lastCustomizer); + } + + @Order(1) + private static class FirstOpenApiCustomizer implements OpenApiCustomizer { + + @Override + public void customise(OpenAPI openApi) { + } + } + + @Order(2) + private static class LastOpenApiCustomizer implements OpenApiCustomizer { + + @Override + public void customise(OpenAPI openApi) { + } + } + + @Order(1) + private static class FirstGlobalOpenApiCustomizer implements GlobalOpenApiCustomizer { + + @Override + public void customise(OpenAPI openApi) { + } + } + + @Order(2) + private static class LastGlobalOpenApiCustomizer implements GlobalOpenApiCustomizer { + + @Override + public void customise(OpenAPI openApi) { + } + } +} From 7999f353e698bbc477cc984bf389836d7c68f1fc Mon Sep 17 00:00:00 2001 From: kdelay Date: Sun, 6 Sep 2026 04:03:15 +0200 Subject: [PATCH 25/42] fix: order the springdoc auto-configurations after SpringDocConfiguration Auto-configurations gated on @ConditionalOnBean(SpringDocConfiguration) could be evaluated before that bean was contributed, so their beans were silently skipped depending on the auto-configuration order. They now declare @AutoConfigureAfter(SpringDocConfiguration.class), and a test walks the AutoConfiguration.imports entries to keep that invariant. Backport of PR #3316, fixes #3313. Co-Authored-By: Claude Opus 5 --- .../SpringDocDataRestConfiguration.java | 2 + ...SpringDocFunctionCatalogConfiguration.java | 2 + .../SpringDocGroovyConfiguration.java | 2 + .../SpringDocHateoasConfiguration.java | 2 + ...ngDocJacksonKotlinModuleConfiguration.java | 2 + .../SpringDocJavadocConfiguration.java | 2 + .../SpringDocKotlinConfiguration.kt | 2 + .../SpringDocKotlinxConfiguration.java | 2 + .../SpringDocPageableConfiguration.java | 2 + .../SpringDocSecurityConfiguration.java | 2 + .../SpringDocSortConfiguration.java | 2 + .../SpringDocSpecPropertiesConfiguration.java | 2 + .../properties/SpringDocConfigProperties.java | 2 + .../properties/SwaggerUiConfigProperties.java | 2 + .../properties/SwaggerUiOAuthProperties.java | 2 + .../MultipleOpenApiSupportConfiguration.java | 2 + .../SpringDocWebFluxConfiguration.java | 2 + .../webflux/scalar/ScalarConfiguration.java | 2 + .../springdoc/webflux/ui/SwaggerConfig.java | 2 + .../SpringDocWebMvcConfiguration.java | 2 + .../SpringDocAutoConfigurationOrderTest.java | 146 ++++++++++++++++++ .../webmvc/scalar/ScalarConfiguration.java | 2 + .../springdoc/webmvc/ui/SwaggerConfig.java | 2 + 23 files changed, 190 insertions(+) create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/org/springdoc/autoconfigure/SpringDocAutoConfigurationOrderTest.java diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocDataRestConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocDataRestConfiguration.java index 21c656f15..ffe0876e8 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocDataRestConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocDataRestConfiguration.java @@ -47,6 +47,7 @@ import org.springdoc.core.service.OperationService; import org.springdoc.core.utils.SpringDocDataRestUtils; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -76,6 +77,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnExpression("${springdoc.api-docs.enabled:true} and ${springdoc.enable-data-rest:true}") @ConditionalOnClass(RepositoryRestConfiguration.class) @ConditionalOnWebApplication diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocFunctionCatalogConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocFunctionCatalogConfiguration.java index cbcca5843..c2c1e13db 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocFunctionCatalogConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocFunctionCatalogConfiguration.java @@ -32,6 +32,7 @@ import org.springdoc.core.providers.CloudFunctionProvider; import org.springdoc.core.providers.SpringCloudFunctionProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -50,6 +51,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnExpression("${springdoc.api-docs.enabled:true} and ${springdoc.show-spring-cloud-functions:true}") @ConditionalOnClass(FunctionEndpointInitializer.class) @ConditionalOnWebApplication diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocGroovyConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocGroovyConfiguration.java index 397c96e22..abcc9d7df 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocGroovyConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocGroovyConfiguration.java @@ -32,6 +32,7 @@ import org.springdoc.core.providers.ObjectMapperProvider; import org.springdoc.core.utils.SpringDocUtils; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -47,6 +48,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnExpression("${springdoc.api-docs.enabled:true} and ${springdoc.enable-groovy:true}") @ConditionalOnClass(MetaClass.class) @ConditionalOnWebApplication diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocHateoasConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocHateoasConfiguration.java index b3fac14b5..c752f100c 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocHateoasConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocHateoasConfiguration.java @@ -39,6 +39,7 @@ import org.springdoc.core.providers.ObjectMapperProvider; import org.springdoc.core.utils.Constants; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -58,6 +59,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnExpression("${springdoc.api-docs.enabled:true} and ${springdoc.enable-hateoas:true}") @ConditionalOnClass(LinkRelationProvider.class) @ConditionalOnWebApplication diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocJacksonKotlinModuleConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocJacksonKotlinModuleConfiguration.java index 23255b269..cedd8bb06 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocJacksonKotlinModuleConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocJacksonKotlinModuleConfiguration.java @@ -30,6 +30,7 @@ import org.springdoc.core.properties.SpringDocConfigProperties; import org.springdoc.core.providers.ObjectMapperProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -46,6 +47,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnClass(KotlinModule.class) @ConditionalOnExpression("${springdoc.api-docs.enabled:true} and ${springdoc.enable-kotlin:true}") @ConditionalOnWebApplication diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocJavadocConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocJavadocConfiguration.java index 09650eb42..8a3300b59 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocJavadocConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocJavadocConfiguration.java @@ -32,6 +32,7 @@ import org.springdoc.core.providers.ObjectMapperProvider; import org.springdoc.core.providers.SpringDocJavadocProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -50,6 +51,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnExpression("${springdoc.api-docs.enabled:true} and ${springdoc.enable-javadoc:true}") @ConditionalOnClass(CommentFormatter.class) @ConditionalOnWebApplication diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt index 54acfb6e8..46aeb3a19 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinConfiguration.kt @@ -33,6 +33,7 @@ import org.springdoc.core.providers.ObjectMapperProvider import org.springdoc.core.utils.Constants import org.springdoc.core.utils.SpringDocKotlinUtils import org.springdoc.core.utils.SpringDocUtils +import org.springframework.boot.autoconfigure.AutoConfigureAfter import org.springframework.boot.autoconfigure.condition.ConditionalOnBean import org.springframework.boot.autoconfigure.condition.ConditionalOnClass import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression @@ -50,6 +51,7 @@ import kotlin.coroutines.Continuation */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration::class) @ConditionalOnProperty(name = [Constants.SPRINGDOC_ENABLED], matchIfMissing = true) @ConditionalOnExpression("\${springdoc.api-docs.enabled:true} and \${springdoc.enable-kotlin:true}") @ConditionalOnClass(Continuation::class) diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinxConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinxConfiguration.java index b2385fc25..8bd6ee108 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinxConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocKotlinxConfiguration.java @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.Flow; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -44,6 +45,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnExpression("${springdoc.api-docs.enabled:true} and ${springdoc.enable-kotlin:true}") @ConditionalOnClass(Flow.class) @ConditionalOnWebApplication diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocPageableConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocPageableConfiguration.java index 0872b66f7..d6c7b15fd 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocPageableConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocPageableConfiguration.java @@ -35,6 +35,7 @@ import org.springdoc.core.providers.RepositoryRestConfigurationProvider; import org.springdoc.core.providers.SpringDataWebPropertiesProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -59,6 +60,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnProperty(name = SPRINGDOC_ENABLED, matchIfMissing = true) @ConditionalOnClass(Pageable.class) @ConditionalOnWebApplication diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java index 78fb05df5..9150df950 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java @@ -46,6 +46,7 @@ import org.springdoc.core.customizers.GlobalOpenApiCustomizer; import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -82,6 +83,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnExpression("${springdoc.api-docs.enabled:true} and ${springdoc.enable-spring-security:true}") @ConditionalOnClass(SecurityFilterChain.class) @ConditionalOnWebApplication diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSortConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSortConfiguration.java index 1d0dfd76b..3e3d8cb55 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSortConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSortConfiguration.java @@ -34,6 +34,7 @@ import org.springdoc.core.providers.RepositoryRestConfigurationProvider; import org.springdoc.core.providers.SpringDataWebPropertiesProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -55,6 +56,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnProperty(name = SPRINGDOC_ENABLED, matchIfMissing = true) @ConditionalOnClass(Sort.class) @ConditionalOnWebApplication diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSpecPropertiesConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSpecPropertiesConfiguration.java index c14a2e18d..c7a39fc93 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSpecPropertiesConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSpecPropertiesConfiguration.java @@ -36,6 +36,7 @@ import org.springdoc.core.properties.SpringDocConfigProperties.GroupConfig; import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; @@ -51,6 +52,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnBean(SpringDocConfiguration.class) @Conditional(SpecPropertiesCondition.class) public class SpringDocSpecPropertiesConfiguration { diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java index 01ed7407a..e494c5ed5 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java @@ -38,6 +38,7 @@ import org.springdoc.core.utils.Constants; import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -55,6 +56,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConfigurationProperties(prefix = Constants.SPRINGDOC_PREFIX) @ConditionalOnProperty(name = SPRINGDOC_ENABLED, matchIfMissing = true) @ConditionalOnBean(SpringDocConfiguration.class) diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SwaggerUiConfigProperties.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SwaggerUiConfigProperties.java index 8a1a31fe7..bb3ffaf90 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SwaggerUiConfigProperties.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SwaggerUiConfigProperties.java @@ -37,6 +37,7 @@ import org.springdoc.core.utils.Constants; import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -58,6 +59,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConfigurationProperties(prefix = SPRINGDOC_SWAGGER_PREFIX) @ConditionalOnProperty(name = SPRINGDOC_SWAGGER_UI_ENABLED, matchIfMissing = true) @ConditionalOnBean(SpringDocConfiguration.class) diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SwaggerUiOAuthProperties.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SwaggerUiOAuthProperties.java index 579ed85ec..fb86d311f 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SwaggerUiOAuthProperties.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SwaggerUiOAuthProperties.java @@ -34,6 +34,7 @@ import org.springdoc.core.configuration.SpringDocConfiguration; import org.springdoc.core.utils.SpringDocPropertiesUtils; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -52,6 +53,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConfigurationProperties(prefix = "springdoc.swagger-ui.oauth") @ConditionalOnProperty(name = SPRINGDOC_SWAGGER_UI_ENABLED, matchIfMissing = true) @ConditionalOnBean(SpringDocConfiguration.class) diff --git a/springdoc-openapi-starter-webflux-api/src/main/java/org/springdoc/webflux/core/configuration/MultipleOpenApiSupportConfiguration.java b/springdoc-openapi-starter-webflux-api/src/main/java/org/springdoc/webflux/core/configuration/MultipleOpenApiSupportConfiguration.java index 2f44f99f8..96774ce03 100644 --- a/springdoc-openapi-starter-webflux-api/src/main/java/org/springdoc/webflux/core/configuration/MultipleOpenApiSupportConfiguration.java +++ b/springdoc-openapi-starter-webflux-api/src/main/java/org/springdoc/webflux/core/configuration/MultipleOpenApiSupportConfiguration.java @@ -45,6 +45,7 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ConditionalOnManagementPort; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.endpoint.web.reactive.WebFluxEndpointHandlerMapping; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -66,6 +67,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) @ConditionalOnProperty(name = SPRINGDOC_ENABLED, matchIfMissing = true) @Conditional(MultipleOpenApiSupportCondition.class) diff --git a/springdoc-openapi-starter-webflux-api/src/main/java/org/springdoc/webflux/core/configuration/SpringDocWebFluxConfiguration.java b/springdoc-openapi-starter-webflux-api/src/main/java/org/springdoc/webflux/core/configuration/SpringDocWebFluxConfiguration.java index bd3dbc6be..b120982bb 100644 --- a/springdoc-openapi-starter-webflux-api/src/main/java/org/springdoc/webflux/core/configuration/SpringDocWebFluxConfiguration.java +++ b/springdoc-openapi-starter-webflux-api/src/main/java/org/springdoc/webflux/core/configuration/SpringDocWebFluxConfiguration.java @@ -55,6 +55,7 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementServerProperties; import org.springframework.boot.actuate.endpoint.web.reactive.WebFluxEndpointHandlerMapping; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -75,6 +76,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) @ConditionalOnProperty(name = SPRINGDOC_ENABLED, matchIfMissing = true) @ConditionalOnBean(SpringDocConfiguration.class) diff --git a/springdoc-openapi-starter-webflux-scalar/src/main/java/org/springdoc/webflux/scalar/ScalarConfiguration.java b/springdoc-openapi-starter-webflux-scalar/src/main/java/org/springdoc/webflux/scalar/ScalarConfiguration.java index 9bf396802..52680d162 100644 --- a/springdoc-openapi-starter-webflux-scalar/src/main/java/org/springdoc/webflux/scalar/ScalarConfiguration.java +++ b/springdoc-openapi-starter-webflux-scalar/src/main/java/org/springdoc/webflux/scalar/ScalarConfiguration.java @@ -35,6 +35,7 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ConditionalOnManagementPort; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.endpoint.web.reactive.WebFluxEndpointHandlerMapping; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -63,6 +64,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnProperty(name = SCALAR_ENABLED, matchIfMissing = true) @ConditionalOnWebApplication(type = Type.REACTIVE) @ConditionalOnBean(SpringDocConfiguration.class) diff --git a/springdoc-openapi-starter-webflux-ui/src/main/java/org/springdoc/webflux/ui/SwaggerConfig.java b/springdoc-openapi-starter-webflux-ui/src/main/java/org/springdoc/webflux/ui/SwaggerConfig.java index 68007d335..cb579a54a 100644 --- a/springdoc-openapi-starter-webflux-ui/src/main/java/org/springdoc/webflux/ui/SwaggerConfig.java +++ b/springdoc-openapi-starter-webflux-ui/src/main/java/org/springdoc/webflux/ui/SwaggerConfig.java @@ -42,6 +42,7 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ConditionalOnManagementPort; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.endpoint.web.reactive.WebFluxEndpointHandlerMapping; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -68,6 +69,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnProperty(name = SPRINGDOC_SWAGGER_UI_ENABLED, matchIfMissing = true) @ConditionalOnWebApplication(type = Type.REACTIVE) @ConditionalOnBean(SpringDocConfiguration.class) diff --git a/springdoc-openapi-starter-webmvc-api/src/main/java/org/springdoc/webmvc/core/configuration/SpringDocWebMvcConfiguration.java b/springdoc-openapi-starter-webmvc-api/src/main/java/org/springdoc/webmvc/core/configuration/SpringDocWebMvcConfiguration.java index 301284dbb..3fed54342 100644 --- a/springdoc-openapi-starter-webmvc-api/src/main/java/org/springdoc/webmvc/core/configuration/SpringDocWebMvcConfiguration.java +++ b/springdoc-openapi-starter-webmvc-api/src/main/java/org/springdoc/webmvc/core/configuration/SpringDocWebMvcConfiguration.java @@ -56,6 +56,7 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementServerProperties; import org.springframework.boot.actuate.endpoint.web.servlet.WebMvcEndpointHandlerMapping; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -80,6 +81,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) @ConditionalOnProperty(name = SPRINGDOC_ENABLED, matchIfMissing = true) @ConditionalOnBean(SpringDocConfiguration.class) diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/org/springdoc/autoconfigure/SpringDocAutoConfigurationOrderTest.java b/springdoc-openapi-starter-webmvc-api/src/test/java/org/springdoc/autoconfigure/SpringDocAutoConfigurationOrderTest.java new file mode 100644 index 000000000..0d23ff8b4 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/org/springdoc/autoconfigure/SpringDocAutoConfigurationOrderTest.java @@ -0,0 +1,146 @@ +/* + * + * * + * * * + * * * * + * * * * * 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. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package org.springdoc.autoconfigure; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springdoc.core.configuration.SpringDocConfiguration; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.core.type.classreading.SimpleMetadataReaderFactory; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An auto-configuration that is only registered when {@link SpringDocConfiguration} already + * contributed its beans has to declare that order, otherwise the {@code @ConditionalOnBean} match + * depends on where the class name happens to sort in Spring Boot's auto-configuration sort. A + * third-party auto-configuration that sorts earlier and orders itself around a springdoc + * auto-configuration is then enough to move that class ahead of {@link SpringDocConfiguration}, + * which silently drops it (gh-3313). + * + * @author kdelay + */ +class SpringDocAutoConfigurationOrderTest { + + private static final String IMPORTS_RESOURCE = "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports"; + + private final MetadataReaderFactory metadataReaderFactory = new SimpleMetadataReaderFactory(); + + @Test + void auto_configurations_conditional_on_spring_doc_configuration_declare_their_order() throws IOException { + List gatedWithoutDeclaredOrder = new ArrayList<>(); + for (String candidate : autoConfigurationImports()) { + AnnotationMetadata metadata = annotationMetadata(candidate); + if (isConditionalOnSpringDocConfiguration(metadata) && !declaresOrderAfterSpringDocConfiguration(metadata)) { + gatedWithoutDeclaredOrder.add(candidate); + } + } + assertThat(gatedWithoutDeclaredOrder).isEmpty(); + } + + private Set autoConfigurationImports() throws IOException { + Set candidates = new LinkedHashSet<>(); + Enumeration resources = getClass().getClassLoader().getResources(IMPORTS_RESOURCE); + while (resources.hasMoreElements()) { + try (InputStream inputStream = resources.nextElement().openStream()) { + String content = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + for (String line : content.split("\n")) { + String candidate = line.trim(); + if (StringUtils.hasText(candidate) && candidate.startsWith("org.springdoc.")) { + candidates.add(candidate); + } + } + } + } + assertThat(candidates).isNotEmpty(); + return candidates; + } + + /** + * Reads the annotations without loading the class, because several of these + * auto-configurations reference optional types that are absent from this module's classpath. + * @param candidate the auto-configuration class name as written in the imports file + * @return the annotation metadata + */ + private AnnotationMetadata annotationMetadata(String candidate) throws IOException { + try { + return this.metadataReaderFactory.getMetadataReader(candidate).getAnnotationMetadata(); + } + catch (IOException ex) { + // nested classes are written with a dot separator in the imports file + int lastDot = candidate.lastIndexOf('.'); + String nested = candidate.substring(0, lastDot) + '$' + candidate.substring(lastDot + 1); + return this.metadataReaderFactory.getMetadataReader(nested).getAnnotationMetadata(); + } + } + + private boolean isConditionalOnSpringDocConfiguration(AnnotationMetadata metadata) { + return referencesSpringDocConfiguration(metadata.getAnnotationAttributes(ConditionalOnBean.class.getName(), true), + "value", "name"); + } + + private boolean declaresOrderAfterSpringDocConfiguration(AnnotationMetadata metadata) { + return referencesSpringDocConfiguration(metadata.getAnnotationAttributes(AutoConfigureAfter.class.getName(), true), + "value", "name") + || referencesSpringDocConfiguration(metadata.getAnnotationAttributes(AutoConfiguration.class.getName(), true), + "after", "afterName"); + } + + private boolean referencesSpringDocConfiguration(Map attributes, String... attributeNames) { + if (attributes == null) { + return false; + } + for (String attributeName : attributeNames) { + Object value = attributes.get(attributeName); + if (value instanceof String[] values + && Arrays.asList(values).contains(SpringDocConfiguration.class.getName())) { + return true; + } + if (value instanceof Collection values && values.contains(SpringDocConfiguration.class.getName())) { + return true; + } + } + return false; + } + +} diff --git a/springdoc-openapi-starter-webmvc-scalar/src/main/java/org/springdoc/webmvc/scalar/ScalarConfiguration.java b/springdoc-openapi-starter-webmvc-scalar/src/main/java/org/springdoc/webmvc/scalar/ScalarConfiguration.java index 89871abc0..784071102 100644 --- a/springdoc-openapi-starter-webmvc-scalar/src/main/java/org/springdoc/webmvc/scalar/ScalarConfiguration.java +++ b/springdoc-openapi-starter-webmvc-scalar/src/main/java/org/springdoc/webmvc/scalar/ScalarConfiguration.java @@ -35,6 +35,7 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ConditionalOnManagementPort; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.endpoint.web.servlet.WebMvcEndpointHandlerMapping; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -63,6 +64,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnProperty(name = SCALAR_ENABLED, matchIfMissing = true) @ConditionalOnWebApplication(type = Type.SERVLET) @ConditionalOnBean(SpringDocConfiguration.class) diff --git a/springdoc-openapi-starter-webmvc-ui/src/main/java/org/springdoc/webmvc/ui/SwaggerConfig.java b/springdoc-openapi-starter-webmvc-ui/src/main/java/org/springdoc/webmvc/ui/SwaggerConfig.java index 97e19bb9b..69b4012ec 100644 --- a/springdoc-openapi-starter-webmvc-ui/src/main/java/org/springdoc/webmvc/ui/SwaggerConfig.java +++ b/springdoc-openapi-starter-webmvc-ui/src/main/java/org/springdoc/webmvc/ui/SwaggerConfig.java @@ -40,6 +40,7 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ConditionalOnManagementPort; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.endpoint.web.servlet.WebMvcEndpointHandlerMapping; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -65,6 +66,7 @@ */ @Lazy(false) @Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(SpringDocConfiguration.class) @ConditionalOnProperty(name = SPRINGDOC_SWAGGER_UI_ENABLED, matchIfMissing = true) @ConditionalOnWebApplication(type = Type.SERVLET) @ConditionalOnBean(SpringDocConfiguration.class) From 4820bb9dac89e9e641a989fe0957b8ed0f4a5177 Mon Sep 17 00:00:00 2001 From: jjh75607 Date: Sun, 6 Sep 2026 04:03:58 +0200 Subject: [PATCH 26/42] fix: replace nested Page with PagedModel in schema properties replace-page-with-paged-model only rewrote Page when it was the resolved type itself, so a Page nested inside another schema kept its Page reference. Schema properties are now rewritten as a PagedModel reference carrying the same generics. Backport of PR #3335, fixes #3319. Co-Authored-By: Claude Opus 5 --- .../core/converters/PageOpenAPIConverter.java | 26 ++++++--- .../api/v30/app10/HelloController.java | 10 ++++ .../api/v31/app10/HelloController.java | 10 ++++ .../resources/results/3.0.1/app10-direct.json | 56 +++++++++++++++++++ .../results/3.0.1/app10-via_dto.json | 56 +++++++++++++++++++ .../resources/results/3.1.0/app10-direct.json | 56 +++++++++++++++++++ .../results/3.1.0/app10-via_dto.json | 56 +++++++++++++++++++ 7 files changed, 262 insertions(+), 8 deletions(-) diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/PageOpenAPIConverter.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/PageOpenAPIConverter.java index e80fe1944..a761a1c93 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/PageOpenAPIConverter.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/converters/PageOpenAPIConverter.java @@ -43,8 +43,6 @@ import org.springframework.core.ResolvableType; import org.springframework.data.web.PagedModel; -import static org.springdoc.core.utils.SpringDocUtils.getParentTypeName; - /** * The Spring Data Page type model converter. * @@ -119,7 +117,7 @@ public Schema resolve(AnnotatedType type, ModelConverterContext context, Iterato if (!type.isSchemaProperty()) type = resolvePagedModelType(javaType, type); else - type.name(getParentTypeName(type, cls)); + type.type(pagedModelType(javaType)).resolveAsRef(true); } } Schema schema = (chain.hasNext()) ? chain.next().resolve(type, context, chain) : null; @@ -137,11 +135,7 @@ public Schema resolve(AnnotatedType type, ModelConverterContext context, Iterato */ private AnnotatedType resolvePagedModelType(JavaType type, AnnotatedType originalType) { if (type.hasGenericTypes()) { - JavaType innerType = type.containedType(0); - Type pagedModelType = ResolvableType - .forClassWithGenerics(PagedModel.class, ResolvableType.forType(innerType)) - .getType(); - return new AnnotatedType(pagedModelType) + return new AnnotatedType(pagedModelType(type)) .resolveAsRef(true) .ctxAnnotations(originalType.getCtxAnnotations()); } @@ -150,6 +144,22 @@ private AnnotatedType resolvePagedModelType(JavaType type, AnnotatedType origina } } + /** + * The PagedModel type matching the given Page type. + * + * @param type the page type + * @return the paged model type + */ + private Type pagedModelType(JavaType type) { + if (type.hasGenericTypes()) { + JavaType innerType = type.containedType(0); + return ResolvableType + .forClassWithGenerics(PagedModel.class, ResolvableType.forType(innerType)) + .getType(); + } + return PagedModel.class; + } + /** * Sort page schema properties. * diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v30/app10/HelloController.java b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v30/app10/HelloController.java index 0a22c1053..e902b6b3e 100644 --- a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v30/app10/HelloController.java +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v30/app10/HelloController.java @@ -74,6 +74,16 @@ public PagedModel pagedModelRaw() { return pagedModelSimple(); } + @GetMapping("/dummy-page-simple") + public Dummy> dummyPageSimple() { + return new Dummy<>(pageSimple()); + } + + @GetMapping("/dummy-page-complex") + public Dummy>>> dummyPageComplex() { + return new Dummy<>(pageComplex()); + } + private PagedModel pagedModel(T value) { return new PagedModel<>(pageImpl(value)); } diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app10/HelloController.java b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app10/HelloController.java index 57327b946..c7673f975 100644 --- a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app10/HelloController.java +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app10/HelloController.java @@ -68,6 +68,16 @@ public PagedModel pagedModelRaw() { return pagedModelSimple(); } + @GetMapping("/dummy-page-simple") + public Dummy> dummyPageSimple() { + return new Dummy<>(pageSimple()); + } + + @GetMapping("/dummy-page-complex") + public Dummy>>> dummyPageComplex() { + return new Dummy<>(pageComplex()); + } + private PagedModel pagedModel(T value) { return new PagedModel<>(pageImpl(value)); } diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.0.1/app10-direct.json b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.0.1/app10-direct.json index d234570e6..12f41a67f 100644 --- a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.0.1/app10-direct.json +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.0.1/app10-direct.json @@ -150,6 +150,46 @@ } } } + }, + "/dummy-page-simple": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "dummyPageSimple", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DummyPageString" + } + } + } + } + } + } + }, + "/dummy-page-complex": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "dummyPageComplex", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DummyPageDummyListString" + } + } + } + } + } + } } }, "components": { @@ -462,6 +502,22 @@ "type": "boolean" } } + }, + "DummyPageString": { + "type": "object", + "properties": { + "value": { + "$ref": "#/components/schemas/PageString" + } + } + }, + "DummyPageDummyListString": { + "type": "object", + "properties": { + "value": { + "$ref": "#/components/schemas/PageDummyListString" + } + } } } } diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.0.1/app10-via_dto.json b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.0.1/app10-via_dto.json index 6b4da9192..ed88f14ce 100644 --- a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.0.1/app10-via_dto.json +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.0.1/app10-via_dto.json @@ -150,6 +150,46 @@ } } } + }, + "/dummy-page-simple": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "dummyPageSimple", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DummyPageString" + } + } + } + } + } + } + }, + "/dummy-page-complex": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "dummyPageComplex", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DummyPageDummyListString" + } + } + } + } + } + } } }, "components": { @@ -252,6 +292,22 @@ "type": "string" } } + }, + "DummyPageString": { + "type": "object", + "properties": { + "value": { + "$ref": "#/components/schemas/PagedModelString" + } + } + }, + "DummyPageDummyListString": { + "type": "object", + "properties": { + "value": { + "$ref": "#/components/schemas/PagedModelDummyListString" + } + } } } } diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.1.0/app10-direct.json b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.1.0/app10-direct.json index 1c93f179f..e3fa6c995 100644 --- a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.1.0/app10-direct.json +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.1.0/app10-direct.json @@ -130,6 +130,46 @@ } } } + }, + "/dummy-page-simple": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "dummyPageSimple", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DummyPageString" + } + } + } + } + } + } + }, + "/dummy-page-complex": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "dummyPageComplex", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DummyPageDummyListString" + } + } + } + } + } + } } }, "components": { @@ -381,6 +421,22 @@ "type": "boolean" } } + }, + "DummyPageString": { + "type": "object", + "properties": { + "value": { + "$ref": "#/components/schemas/PageString" + } + } + }, + "DummyPageDummyListString": { + "type": "object", + "properties": { + "value": { + "$ref": "#/components/schemas/PageDummyListString" + } + } } } } diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.1.0/app10-via_dto.json b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.1.0/app10-via_dto.json index f7853b31f..d8d18c9cf 100644 --- a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.1.0/app10-via_dto.json +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/resources/results/3.1.0/app10-via_dto.json @@ -130,6 +130,46 @@ } } } + }, + "/dummy-page-simple": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "dummyPageSimple", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DummyPageString" + } + } + } + } + } + } + }, + "/dummy-page-complex": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "dummyPageComplex", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/DummyPageDummyListString" + } + } + } + } + } + } } }, "components": { @@ -205,6 +245,22 @@ "$ref": "#/components/schemas/PageMetadata" } } + }, + "DummyPageString": { + "type": "object", + "properties": { + "value": { + "$ref": "#/components/schemas/PagedModelString" + } + } + }, + "DummyPageDummyListString": { + "type": "object", + "properties": { + "value": { + "$ref": "#/components/schemas/PagedModelDummyListString" + } + } } } } From 7708899beaa2267769b14305a86be74b3cc4aeab Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 11:26:54 +0200 Subject: [PATCH 27/42] style: align the TYPE_USE annotation fixtures with the project conventions Co-Authored-By: Claude Opus 5 --- .../org/springdoc/core/service/GenericParameterService.java | 5 ++--- .../main/java/org/springdoc/core/utils/SpringDocUtils.java | 1 + .../test/org/springdoc/api/v30/app176/HelloController.java | 5 +++++ .../java/test/org/springdoc/api/v30/app176/Nullable.java | 5 +++++ .../test/org/springdoc/api/v30/app176/SearchCriteria.java | 5 +++++ .../org/springdoc/api/v30/app176/SpringDocApp176Test.java | 2 ++ .../test/org/springdoc/api/v31/app175/HelloController.java | 5 +++++ .../java/test/org/springdoc/api/v31/app175/Nullable.java | 5 +++++ .../test/org/springdoc/api/v31/app175/SearchCriteria.java | 5 +++++ .../org/springdoc/api/v31/app175/SpringDocApp175Test.java | 5 +++++ .../test/org/springdoc/api/v31/app176/HelloController.java | 5 +++++ .../java/test/org/springdoc/api/v31/app176/Nullable.java | 5 +++++ .../test/org/springdoc/api/v31/app176/SearchCriteria.java | 5 +++++ .../org/springdoc/api/v31/app176/SpringDocApp176Test.java | 2 ++ .../src/test/resources/results/3.1.0/app175.json | 6 ++++++ 15 files changed, 63 insertions(+), 3 deletions(-) diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java index b6af5a224..7a0d72b22 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java @@ -491,9 +491,8 @@ private record TypeAndTypeAnnotations(Type type, List typeAnnotation */ private static Annotation[] annotationsFromAnnotatedType(AnnotatedType annotatedType) { return Stream.concat( - Arrays.stream(annotatedType.getAnnotations()), - Arrays.stream(annotationsFromAnnotatedTypeArguments(annotatedType))) - .toArray(Annotation[]::new); + Arrays.stream(annotatedType.getAnnotations()), + Arrays.stream(annotationsFromAnnotatedTypeArguments(annotatedType))).toArray(Annotation[]::new); } /** diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java index fabf3d087..c0f87f6de 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java @@ -31,6 +31,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.function.Predicate; import com.fasterxml.jackson.core.type.TypeReference; diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/HelloController.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/HelloController.java index 781591ba1..789caf0ef 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/HelloController.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/HelloController.java @@ -33,6 +33,11 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +/** + * Controller reusing a parameter object field as both path and query parameter. + * + * @author tthornton3-chwy + */ @RestController class HelloController { diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/Nullable.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/Nullable.java index 4a39a5b99..a235032ea 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/Nullable.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/Nullable.java @@ -31,6 +31,11 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * TYPE_USE nullability marker used by the regression fixture. + * + * @author tthornton3-chwy + */ @Target(ElementType.TYPE_USE) @Retention(RetentionPolicy.RUNTIME) @interface Nullable { diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SearchCriteria.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SearchCriteria.java index c621028f3..eae363ff6 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SearchCriteria.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SearchCriteria.java @@ -32,6 +32,11 @@ * A parameter object whose {@code clinicId} field is reused as both an optional, nullable * query parameter and a (required, non-null) path parameter, depending on the controller. */ +/** + * Search criteria reused as both a path and a query parameter. + * + * @author tthornton3-chwy + */ class SearchCriteria { @Parameter(description = "Find vets affiliated with this clinic id.") diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SpringDocApp176Test.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SpringDocApp176Test.java index 44f97736a..1200c56bb 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SpringDocApp176Test.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app176/SpringDocApp176Test.java @@ -48,6 +48,8 @@ * Verifies that {@code nullable: true} (propagated from a TYPE_USE {@code @Nullable} * annotation on a {@code @ParameterObject} field under OpenAPI 3.0) is cleared when that * field is reused as a path parameter, while it is preserved for query parameters. + * + * @author tthornton3-chwy */ @ActiveProfiles("test") @SpringBootTest diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/HelloController.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/HelloController.java index 039554997..45611b45a 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/HelloController.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/HelloController.java @@ -31,6 +31,11 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +/** + * Controller exposing a parameter object with a TYPE_USE annotated array field. + * + * @author tthornton3-chwy + */ @RestController class HelloController { diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/Nullable.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/Nullable.java index 504ff7078..7be6c3bf6 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/Nullable.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/Nullable.java @@ -31,6 +31,11 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * TYPE_USE nullability marker used by the regression fixture. + * + * @author tthornton3-chwy + */ @Target(ElementType.TYPE_USE) @Retention(RetentionPolicy.RUNTIME) @interface Nullable { diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SearchCriteria.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SearchCriteria.java index 7ab520c10..e03bc1fde 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SearchCriteria.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SearchCriteria.java @@ -28,6 +28,11 @@ import io.swagger.v3.oas.annotations.Parameter; +/** + * Search criteria whose array field carries a TYPE_USE annotation. + * + * @author tthornton3-chwy + */ class SearchCriteria { @Parameter(description = "Statuses to filter by.") diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SpringDocApp175Test.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SpringDocApp175Test.java index 4fc3e4604..f8ef44391 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SpringDocApp175Test.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app175/SpringDocApp175Test.java @@ -30,6 +30,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; +/** + * TYPE_USE annotations on @ParameterObject fields regression fixture. + * + * @author tthornton3-chwy + */ class SpringDocApp175Test extends AbstractSpringDocTest { @SpringBootApplication diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/HelloController.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/HelloController.java index 13f19db96..08a3f771a 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/HelloController.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/HelloController.java @@ -33,6 +33,11 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +/** + * Controller reusing a parameter object field as both path and query parameter. + * + * @author tthornton3-chwy + */ @RestController class HelloController { diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/Nullable.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/Nullable.java index da1e23c7d..0062df8fb 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/Nullable.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/Nullable.java @@ -31,6 +31,11 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * TYPE_USE nullability marker used by the regression fixture. + * + * @author tthornton3-chwy + */ @Target(ElementType.TYPE_USE) @Retention(RetentionPolicy.RUNTIME) @interface Nullable { diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SearchCriteria.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SearchCriteria.java index 68fe4e1a5..d1eb89851 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SearchCriteria.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SearchCriteria.java @@ -32,6 +32,11 @@ * A parameter object whose {@code clinicId} field is reused as both an optional, nullable * query parameter and a (required, non-null) path parameter, depending on the controller. */ +/** + * Search criteria reused as both a path and a query parameter. + * + * @author tthornton3-chwy + */ class SearchCriteria { @Parameter(description = "Find vets affiliated with this clinic id.") diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SpringDocApp176Test.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SpringDocApp176Test.java index 639705d1a..d60b68777 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SpringDocApp176Test.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app176/SpringDocApp176Test.java @@ -49,6 +49,8 @@ * Verifies that a {@code null} type (propagated from a TYPE_USE {@code @Nullable} * annotation on a {@code @ParameterObject} field) is stripped when that field is reused as * a path parameter, while it is preserved for query parameters. + * + * @author tthornton3-chwy */ @ActiveProfiles("test") @SpringBootTest diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.1.0/app175.json b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.1.0/app175.json index e61cec90c..05336e8c5 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.1.0/app175.json +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.1.0/app175.json @@ -10,6 +10,12 @@ "description": "Generated server url" } ], + "tags": [ + { + "name": "hello-controller", + "description": "Controller exposing a parameter object with a TYPE_USE annotated array field." + } + ], "paths": { "/vets": { "get": { From 7f960bb49b3d54c15971455c2dc45769f1539e84 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 04:50:40 +0200 Subject: [PATCH 28/42] fix: read the OAS 3.1 type back for every schema subclass The type deserializer was only registered for JsonSchema, so cloning any other schema subclass still failed and logged a Json Processing Exception warning for every constrained parameter. Extend the swagger-core mixin so that it can be registered on Schema itself without dropping the serialization it declares, register it on the standalone mappers too, and carry the deserializer over to the sorting mixin that replaces it. Fixes #3314 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../core/mixins/SchemaTypeMixin.java | 9 ++- .../core/mixins/SortedSchemaMixin31.java | 11 ++++ .../core/providers/ObjectMapperProvider.java | 7 ++- .../core/utils/SpringDocUtilsTest.java | 55 ++++++++++++++++++- 5 files changed, 77 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a447509a..9489233bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #3331 – Validation annotations declared inside `Optional` parameters are dropped - #3322 – Validation annotations on a container's type argument leak between parameters - #3315 – An OAS 3.1 `JsonSchema` cannot be cloned through JSON +- #3314 – `Json Processing Exception occurred` is logged for every constrained parameter whose schema is not a `JsonSchema` - #3300 – TYPE_USE annotations on `@ParameterObject` fields are not passed along - #3341 – Stabilize Spring Data `Sort` and `Pageable` schema property order - #3338 – Kotlin nullability interpretation of the `Any?` type diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/mixins/SchemaTypeMixin.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/mixins/SchemaTypeMixin.java index 72a494d21..1be746c2b 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/mixins/SchemaTypeMixin.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/mixins/SchemaTypeMixin.java @@ -30,15 +30,20 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import io.swagger.v3.core.jackson.mixin.Schema31Mixin; import org.springdoc.core.deserializers.TypeSetDeserializer; /** * The type Schema type mixin. Makes the OpenAPI 3.1 {@code type} readable back into the * {@code types} set, whichever of the two serialized forms it takes. + *

+ * It extends the swagger-core mixin so that it can be registered on {@code Schema} itself + * without losing the serialization it declares, which is what makes the deserializer apply + * to every schema subclass rather than to {@code JsonSchema} alone. * * @author Mattias-Sehlstedt */ -public interface SchemaTypeMixin { +public abstract class SchemaTypeMixin extends Schema31Mixin { /** * Sets types. @@ -47,6 +52,6 @@ public interface SchemaTypeMixin { */ @JsonProperty("type") @JsonDeserialize(using = TypeSetDeserializer.class) - void setTypes(Set types); + public abstract void setTypes(Set types); } diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/mixins/SortedSchemaMixin31.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/mixins/SortedSchemaMixin31.java index 7014b4727..02adfb4bd 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/mixins/SortedSchemaMixin31.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/mixins/SortedSchemaMixin31.java @@ -36,8 +36,10 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.swagger.v3.core.jackson.mixin.Schema31Mixin; +import org.springdoc.core.deserializers.TypeSetDeserializer; /** * The interface Sorted schema mixin 31. @@ -121,6 +123,15 @@ public interface SortedSchemaMixin31 { @JsonSerialize(using = Schema31Mixin.TypeSerializer.class) Set getTypes(); + /** + * Sets types. + * + * @param types the types + */ + @JsonProperty("type") + @JsonDeserialize(using = TypeSetDeserializer.class) + void setTypes(Set types); + /** * Add extension. * diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/providers/ObjectMapperProvider.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/providers/ObjectMapperProvider.java index a2a498b60..a403719b3 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/providers/ObjectMapperProvider.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/providers/ObjectMapperProvider.java @@ -35,7 +35,6 @@ import io.swagger.v3.core.util.Yaml; import io.swagger.v3.core.util.Yaml31; import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.media.JsonSchema; import io.swagger.v3.oas.models.media.Schema; import org.springdoc.core.mixins.SchemaTypeMixin; import org.springdoc.core.mixins.SortedOpenAPIMixin; @@ -76,7 +75,7 @@ public ObjectMapperProvider(SpringDocConfigProperties springDocConfigProperties) if (openApiVersion == OpenApiVersion.OPENAPI_3_1) { jsonMapper = Json31.mapper(); yamlMapper = Yaml31.mapper(); - jsonMapper.addMixIn(JsonSchema.class, SchemaTypeMixin.class); + jsonMapper.addMixIn(Schema.class, SchemaTypeMixin.class); if (springDocConfigProperties.isUseArbitrarySchemas()) { System.setProperty(Schema.USE_ARBITRARY_SCHEMA_PROPERTY, "true"); } @@ -99,8 +98,10 @@ public ObjectMapperProvider(SpringDocConfigProperties springDocConfigProperties) public static ObjectMapper createJson(SpringDocConfigProperties springDocConfigProperties) { OpenApiVersion openApiVersion = springDocConfigProperties.getApiDocs().getVersion(); ObjectMapper objectMapper; - if (openApiVersion == OpenApiVersion.OPENAPI_3_1) + if (openApiVersion == OpenApiVersion.OPENAPI_3_1) { objectMapper = ObjectMapperFactory.createJson31(); + objectMapper.addMixIn(Schema.class, SchemaTypeMixin.class); + } else objectMapper = ObjectMapperFactory.createJson(); diff --git a/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/utils/SpringDocUtilsTest.java b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/utils/SpringDocUtilsTest.java index eeb0de530..5c683eba5 100644 --- a/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/utils/SpringDocUtilsTest.java +++ b/springdoc-openapi-starter-common/src/test/java/org/springdoc/core/utils/SpringDocUtilsTest.java @@ -26,7 +26,9 @@ import java.util.Set; +import io.swagger.v3.oas.models.media.ArraySchema; import io.swagger.v3.oas.models.media.JsonSchema; +import io.swagger.v3.oas.models.media.StringSchema; import org.junit.jupiter.api.Test; import org.springdoc.core.properties.SpringDocConfigProperties; import org.springdoc.core.properties.SpringDocConfigProperties.ApiDocs.OpenApiVersion; @@ -90,10 +92,61 @@ void typeArrayIsRetainedForJsonSchemaJsonCloning() { assertEquals(Set.of("integer", "null"), cloned.getTypes()); } + @Test + void singleTypeForSchemaSubclassJsonCloning() { + ObjectMapperProvider provider = openapi31Provider(); + + ArraySchema arraySchema = new ArraySchema(); + arraySchema.setTypes(Set.of("array")); + arraySchema.setItems(new StringSchema()); + + ArraySchema cloned = SpringDocUtils.cloneViaJson(arraySchema, ArraySchema.class, provider.jsonMapper()); + + // The object is cloned properly, we do not get the type cast fallback + assertNotSame(arraySchema, cloned); + assertNotNull(cloned); + assertEquals(Set.of("array"), cloned.getTypes()); + } + + @Test + void singleTypeForSchemaSubclassJsonCloningWithAStandaloneMapper() { + ArraySchema arraySchema = new ArraySchema(); + arraySchema.setTypes(Set.of("array")); + arraySchema.setItems(new StringSchema()); + + ArraySchema cloned = SpringDocUtils.cloneViaJson(arraySchema, ArraySchema.class, ObjectMapperProvider.createJson(openapi31Properties())); + + // The object is cloned properly, we do not get the type cast fallback + assertNotSame(arraySchema, cloned); + assertNotNull(cloned); + assertEquals(Set.of("array"), cloned.getTypes()); + } + + @Test + void singleTypeForSchemaSubclassJsonCloningWithASortingMapper() { + SpringDocConfigProperties properties = openapi31Properties(); + properties.setWriterWithOrderByKeys(true); + + ArraySchema arraySchema = new ArraySchema(); + arraySchema.setTypes(Set.of("array")); + arraySchema.setItems(new StringSchema()); + + ArraySchema cloned = SpringDocUtils.cloneViaJson(arraySchema, ArraySchema.class, ObjectMapperProvider.createJson(properties)); + + // The object is cloned properly, we do not get the type cast fallback + assertNotSame(arraySchema, cloned); + assertNotNull(cloned); + assertEquals(Set.of("array"), cloned.getTypes()); + } + private ObjectMapperProvider openapi31Provider() { + return new ObjectMapperProvider(openapi31Properties()); + } + + private SpringDocConfigProperties openapi31Properties() { SpringDocConfigProperties properties = new SpringDocConfigProperties(); properties.getApiDocs().setVersion(OpenApiVersion.OPENAPI_3_1); - return new ObjectMapperProvider(properties); + return properties; } } From 2ac22a8659799f9bd086e9508bddc9c069f409f2 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 04:06:55 +0200 Subject: [PATCH 29/42] docs: record the backported pull requests in the 2.9.1 changelog Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ecdf5d0c..2a447509a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [GHSA-rhhx-6j8h-8cvw](https://github.com/springdoc/springdoc-openapi/security/advisories/GHSA-rhhx-6j8h-8cvw) – Unbounded per-locale OpenAPI cache allows memory exhaustion via `Accept-Language` - [GHSA-c925-vm88-mpp9](https://github.com/springdoc/springdoc-openapi/security/advisories/GHSA-c925-vm88-mpp9) – Scalar starters trust client-supplied forwarded headers and render from a shared mutable bean +### Added + +- #3340 – Describe `JsonNullable` values without their Java wrapper + ### Changed - **The Scalar starters no longer register forwarded-header handling.** Set `server.forward-headers-strategy=framework` (or `native`) behind a trusted proxy @@ -20,6 +24,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgrade swagger-core to version **2.2.53** - Upgrade swagger-ui to version **5.32.14** +### Fixed + +- #3320 – `@Order` and `Ordered` ignored when applying customizers +- #3319 – A `Page` nested in another schema is not replaced by `PagedModel` +- #3313 – Springdoc auto-configurations rely on unspecified auto-configuration ordering +- #3331 – Validation annotations declared inside `Optional` parameters are dropped +- #3322 – Validation annotations on a container's type argument leak between parameters +- #3315 – An OAS 3.1 `JsonSchema` cannot be cloned through JSON +- #3300 – TYPE_USE annotations on `@ParameterObject` fields are not passed along +- #3341 – Stabilize Spring Data `Sort` and `Pageable` schema property order +- #3338 – Kotlin nullability interpretation of the `Any?` type + ## [2.9.0] - 2026-07-31 ### Added From 49f8420e9f10323e87c6f366dd85be32b856f089 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 04:51:42 +0200 Subject: [PATCH 30/42] feat: manage the swagger artifacts in the BOM Modules that only carry the swagger annotations had no way to pick up the version springdoc resolves elsewhere: importing the BOM copies its managed dependencies, not its properties. They are now managed by the BOM, whose flattened form has to keep the properties for ${swagger-api.version} to be resolvable by the consumer. Fixes #3325 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + springdoc-openapi-bom/pom.xml | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9489233bd..4e1ce892f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - #3340 – Describe `JsonNullable` values without their Java wrapper +- #3325 – Manage the swagger artifacts in `springdoc-openapi-bom`, so that modules holding only the annotations stay in lockstep ### Changed diff --git a/springdoc-openapi-bom/pom.xml b/springdoc-openapi-bom/pom.xml index 7670e42d7..860919b3f 100644 --- a/springdoc-openapi-bom/pom.xml +++ b/springdoc-openapi-bom/pom.xml @@ -51,6 +51,22 @@ springdoc-openapi-starter-webflux-scalar ${project.version} + + + io.swagger.core.v3 + swagger-annotations-jakarta + ${swagger-api.version} + + + io.swagger.core.v3 + swagger-models-jakarta + ${swagger-api.version} + + + io.swagger.core.v3 + swagger-core-jakarta + ${swagger-api.version} + @@ -73,7 +89,7 @@ true bom - remove + expand remove keep remove From c1e51f777e48d07c4ed39670fb2831b07008109c Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 04:53:14 +0200 Subject: [PATCH 31/42] test: cover Spring Data REST with Kotlin entities An entity holding an @Embeddable lost that property, and its schema, because swagger-core keyed it under a null property name. The swagger-core 2.2.53 upgrade already fixes it; this pins the behaviour down, as no test module so far combined Kotlin with Spring Data REST. Fixes #3332 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + springdoc-openapi-tests/pom.xml | 1 + .../.gitignore | 144 ++++++++ .../pom.xml | 131 +++++++ .../api/v31/AbstractKotlinDataRestTest.kt | 54 +++ .../test/org/springdoc/api/v31/app1/Person.kt | 24 ++ .../api/v31/app1/PersonRepository.kt | 7 + .../api/v31/app1/SpringDocApp1Test.kt | 14 + .../src/test/resources/application-test.yml | 9 + .../src/test/resources/logback-test.xml | 6 + .../test/resources/results/3.1.0/app1.json | 348 ++++++++++++++++++ 11 files changed, 739 insertions(+) create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/.gitignore create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/pom.xml create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/AbstractKotlinDataRestTest.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/app1/Person.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/app1/PersonRepository.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/app1/SpringDocApp1Test.kt create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/resources/application-test.yml create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/resources/logback-test.xml create mode 100644 springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/resources/results/3.1.0/app1.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e1ce892f..54411c172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #3300 – TYPE_USE annotations on `@ParameterObject` fields are not passed along - #3341 – Stabilize Spring Data `Sort` and `Pageable` schema property order - #3338 – Kotlin nullability interpretation of the `Any?` type +- #3332 – The properties a Kotlin entity inherits from an `@Embeddable` are missing from the Spring Data REST schemas ## [2.9.0] - 2026-07-31 diff --git a/springdoc-openapi-tests/pom.xml b/springdoc-openapi-tests/pom.xml index 5ee7df2e3..5eb6b93c6 100644 --- a/springdoc-openapi-tests/pom.xml +++ b/springdoc-openapi-tests/pom.xml @@ -24,6 +24,7 @@ springdoc-openapi-actuator-webmvc-tests springdoc-openapi-kotlin-webflux-tests springdoc-openapi-kotlin-webmvc-tests + springdoc-openapi-kotlin-data-rest-tests springdoc-openapi-hateoas-tests springdoc-openapi-data-rest-tests diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/.gitignore b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/.gitignore new file mode 100644 index 000000000..ab21548c1 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/.gitignore @@ -0,0 +1,144 @@ +###################### +# Project Specific +###################### +/target/www/** +/src/test/javascript/coverage/ + +###################### +# Node +###################### +/node/ +node_tmp/ +node_modules/ +npm-debug.log.* +/.awcache/* +/.cache-loader/* + +###################### +# SASS +###################### +.sass-cache/ + +###################### +# Eclipse +###################### +*.pydevproject +.project +.metadata +tmp/ +tmp/**/* +*.tmp +*.bak +*.swp +*~.nib +local.properties +.classpath +.settings/ +.loadpath +.factorypath +/src/main/resources/rebel.xml + +# External tool builders +.externalToolBuilders/** + +# Locally stored "Eclipse launch configurations" +*.launch + +# CDT-specific +.cproject + +# PDT-specific +.buildpath + +###################### +# Intellij +###################### +.idea/ +*.iml +*.iws +*.ipr +*.ids +*.orig +classes/ +out/ + +###################### +# Visual Studio Code +###################### +.vscode/ + +###################### +# Maven +###################### +/log/ +/target/ + +###################### +# Gradle +###################### +.gradle/ +/build/ + +###################### +# Package Files +###################### +*.jar +*.war +*.ear +*.db + +###################### +# Windows +###################### +# Windows image file caches +Thumbs.db + +# Folder config file +Desktop.ini + +###################### +# Mac OSX +###################### +.DS_Store +.svn + +# Thumbnails +._* + +# Files that might appear on external disk +.Spotlight-V100 +.Trashes + +###################### +# Directories +###################### +/bin/ +/deploy/ + +###################### +# Logs +###################### +*.log* + +###################### +# Others +###################### +*.class +*.*~ +*~ +.merge_file* + +###################### +# Gradle Wrapper +###################### +!gradle/wrapper/gradle-wrapper.jar + +###################### +# Maven Wrapper +###################### +!.mvn/wrapper/maven-wrapper.jar + +###################### +# ESLint +###################### +.eslintcache \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/pom.xml new file mode 100644 index 000000000..cb459e5d1 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/pom.xml @@ -0,0 +1,131 @@ + + + springdoc-openapi-tests + org.springdoc + 2.9.1-SNAPSHOT + + 4.0.0 + springdoc-openapi-kotlin-data-rest-tests + ${project.artifactId} + + + + org.springdoc + springdoc-openapi-starter-webmvc-api + ${project.version} + test + + + com.fasterxml.jackson.module + jackson-module-kotlin + test + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + test + + + org.springframework.boot + spring-boot-starter-web + test + + + org.springframework.boot + spring-boot-starter-data-rest + test + + + org.springframework.boot + spring-boot-starter-data-jpa + test + + + com.h2database + h2 + test + + + + + + + kotlin-maven-plugin + org.jetbrains.kotlin + + + compile + process-sources + + compile + + + + ${project.basedir}/src/main/kotlin + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + + + + + + + spring + + + -java-parameters + + -Xemit-jvm-type-annotations + + + + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + default-compile + none + + + + default-testCompile + none + + + java-compile + compile + + compile + + + + java-test-compile + test-compile + + testCompile + + + + + + + diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/AbstractKotlinDataRestTest.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/AbstractKotlinDataRestTest.kt new file mode 100644 index 000000000..15f625a3a --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/AbstractKotlinDataRestTest.kt @@ -0,0 +1,54 @@ +package test.org.springdoc.api.v31 + +import org.junit.jupiter.api.Test +import org.skyscreamer.jsonassert.JSONAssert +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Paths + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +abstract class AbstractKotlinDataRestTest { + + @Autowired + val mockMvc: MockMvc? = null + + private val logger = LoggerFactory.getLogger(AbstractKotlinDataRestTest::class.java) + + @Test + fun testApp() { + var result: String? = null + try { + val response = mockMvc!!.perform(MockMvcRequestBuilders.get("/v3/api-docs")) + .andExpect(MockMvcResultMatchers.status().isOk).andReturn() + + result = response.response.contentAsString + val className = javaClass.simpleName + val testNumber = className.replace("[^0-9]".toRegex(), "") + + val expected = getContent("results/3.1.0/app$testNumber.json") + JSONAssert.assertEquals(expected, result, true) + } catch (e: AssertionError) { + logger.error(result) + throw e + } + } + + companion object { + fun getContent(fileName: String): String { + val path = Paths.get( + AbstractKotlinDataRestTest::class.java.classLoader.getResource(fileName)!!.toURI() + ) + return String(Files.readAllBytes(path), StandardCharsets.UTF_8) + } + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/app1/Person.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/app1/Person.kt new file mode 100644 index 000000000..4bc9a9415 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/app1/Person.kt @@ -0,0 +1,24 @@ +package test.org.springdoc.api.v31.app1 + +import jakarta.persistence.Embeddable +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import jakarta.persistence.Id + +@Entity +class Person { + + @Id + var id: Long? = null + + @Embedded + var name: Name? = null +} + +@Embeddable +class Name { + + var firstName: String? = null + + var lastName: String? = null +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/app1/PersonRepository.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/app1/PersonRepository.kt new file mode 100644 index 000000000..c0cd8d213 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/app1/PersonRepository.kt @@ -0,0 +1,7 @@ +package test.org.springdoc.api.v31.app1 + +import org.springframework.data.repository.PagingAndSortingRepository +import org.springframework.data.rest.core.annotation.RepositoryRestResource + +@RepositoryRestResource(collectionResourceRel = "people", path = "people") +interface PersonRepository : PagingAndSortingRepository diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/app1/SpringDocApp1Test.kt b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/app1/SpringDocApp1Test.kt new file mode 100644 index 000000000..6d635d413 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/kotlin/test/org/springdoc/api/v31/app1/SpringDocApp1Test.kt @@ -0,0 +1,14 @@ +package test.org.springdoc.api.v31.app1 + +import org.springframework.boot.autoconfigure.SpringBootApplication +import test.org.springdoc.api.v31.AbstractKotlinDataRestTest + +/** + * The properties an entity inherits from a Kotlin `@Embeddable` have to be described, + * see https://github.com/springdoc/springdoc-openapi/issues/3332 + */ +class SpringDocApp1Test : AbstractKotlinDataRestTest() { + + @SpringBootApplication + class SpringDocTestApp +} diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/resources/application-test.yml b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/resources/application-test.yml new file mode 100644 index 000000000..544f357e4 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/resources/application-test.yml @@ -0,0 +1,9 @@ +spring: + main: + banner-mode: "off" + lazy-initialization: true +logging: + level: + root: ERROR + pattern: + console: '%m%n' \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/resources/logback-test.xml b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/resources/logback-test.xml new file mode 100644 index 000000000..a715d5a44 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/resources/logback-test.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/resources/results/3.1.0/app1.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/resources/results/3.1.0/app1.json new file mode 100644 index 000000000..f3a2b828f --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/src/test/resources/results/3.1.0/app1.json @@ -0,0 +1,348 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/people": { + "get": { + "tags": [ + "person-entity-controller" + ], + "description": "get-person", + "operationId": "getCollectionResource-person-get", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "Zero-based page index (0..N)", + "required": false, + "schema": { + "type": "integer", + "default": 0, + "minimum": 0 + } + }, + { + "name": "size", + "in": "query", + "description": "The size of the page to be returned", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "minimum": 1 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/PagedModelEntityModelPerson" + } + }, + "application/x-spring-data-compact+json": { + "schema": { + "$ref": "#/components/schemas/PagedModelEntityModelPerson" + } + }, + "text/uri-list": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/profile": { + "get": { + "tags": [ + "profile-controller" + ], + "operationId": "listAllFormsOfMetadata", + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/RepresentationModelObject" + } + } + } + } + } + } + }, + "/profile/people": { + "get": { + "tags": [ + "profile-controller" + ], + "operationId": "descriptor", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + }, + "application/alps+json": { + "schema": { + "type": "string" + } + }, + "application/schema+json": { + "schema": { + "$ref": "#/components/schemas/JsonSchema" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AbstractJsonSchemaPropertyObject": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + } + } + }, + "Item": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/AbstractJsonSchemaPropertyObject" + } + }, + "requiredProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "JsonSchema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/AbstractJsonSchemaPropertyObject" + } + }, + "requiredProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "definitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Item" + } + }, + "type": { + "type": "string" + }, + "$schema": { + "type": "string" + } + } + }, + "RepresentationModelObject": { + "type": "object", + "properties": { + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "EntityModelPerson": { + "type": "object", + "properties": { + "id": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "name": { + "oneOf": [ + { + "$ref": "#/components/schemas/Name" + }, + { + "type": "null" + } + ] + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "Name": { + "type": "object", + "properties": { + "firstName": { + "type": [ + "string", + "null" + ] + }, + "lastName": { + "type": [ + "string", + "null" + ] + } + } + }, + "PageMetadata": { + "type": "object", + "properties": { + "size": { + "type": "integer", + "format": "int64" + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "format": "int64" + }, + "number": { + "type": "integer", + "format": "int64" + } + } + }, + "PagedModelEntityModelPerson": { + "type": "object", + "properties": { + "_embedded": { + "type": "object", + "properties": { + "people": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityModelPerson" + } + } + } + }, + "_links": { + "$ref": "#/components/schemas/Links" + }, + "page": { + "$ref": "#/components/schemas/PageMetadata" + } + } + }, + "Link": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "hreflang": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": [ + "string", + "null" + ] + }, + "deprecation": { + "type": [ + "string", + "null" + ] + }, + "profile": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "templated": { + "type": "boolean" + } + } + }, + "Links": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Link" + } + } + } + } +} From 2792bc96b2ad593c2d7889a1f72de276fd51aa23 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 12:46:37 +0200 Subject: [PATCH 32/42] Fix embedded ID relation response schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring Data REST serializes associations to entities that are not themselves exported as repositories inside the containing representation. SpringDocDataRestUtils rewrote only top-level and _embedded entity references, so those properties kept the raw ModelConverters schema and expanded their @EmbeddedId and @MapsId association fields recursively. Backport of #3334 to the Spring Boot 3 line, with the OAS 3.0 variant of the regression test added: the two versions take different branches when the _embedded items schema is resolved, JsonSchema for 3.1 and ArraySchema for 3.0. Fixes #3136 Co-Authored-By: 한의준 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../core/utils/SpringDocDataRestUtils.java | 60 ++ .../org/springdoc/api/v30/app40/Goal.java | 59 ++ .../api/v30/app40/GoalInitiativeImpactId.java | 52 ++ .../api/v30/app40/GoalRepository.java | 31 + .../springdoc/api/v30/app40/Initiative.java | 47 + .../api/v30/app40/InitiativeImpactOnGoal.java | 73 ++ .../api/v30/app40/InitiativeRepository.java | 31 + .../api/v30/app40/SpringDocApp40Test.java | 35 + .../org/springdoc/api/v31/app40/Goal.java | 59 ++ .../api/v31/app40/GoalInitiativeImpactId.java | 52 ++ .../api/v31/app40/GoalRepository.java | 31 + .../springdoc/api/v31/app40/Initiative.java | 47 + .../api/v31/app40/InitiativeImpactOnGoal.java | 73 ++ .../api/v31/app40/InitiativeRepository.java | 31 + .../api/v31/app40/SpringDocApp40Test.java | 35 + .../test/resources/results/3.0.1/app40.json | 815 +++++++++++++++++ .../test/resources/results/3.1.0/app40.json | 827 ++++++++++++++++++ 18 files changed, 2359 insertions(+) create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/Goal.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/GoalInitiativeImpactId.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/GoalRepository.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/Initiative.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/InitiativeImpactOnGoal.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/InitiativeRepository.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/SpringDocApp40Test.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Goal.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalInitiativeImpactId.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalRepository.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Initiative.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeImpactOnGoal.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeRepository.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/SpringDocApp40Test.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.0.1/app40.json create mode 100644 springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.1.0/app40.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 54411c172..492044f02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #3341 – Stabilize Spring Data `Sort` and `Pageable` schema property order - #3338 – Kotlin nullability interpretation of the `Any?` type - #3332 – The properties a Kotlin entity inherits from an `@Embeddable` are missing from the Spring Data REST schemas +- #3136 – A Spring Data REST association to a non-exported entity expands its `@EmbeddedId` and `@MapsId` fields recursively in the response schemas ## [2.9.0] - 2026-07-31 diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java index aaa940a44..791d08e0b 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java @@ -26,6 +26,7 @@ package org.springdoc.core.utils; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -99,6 +100,11 @@ public class SpringDocDataRestUtils { */ private final HashMap entityInoMap = new HashMap(); + /** + * The associations fields by entity. + */ + private final HashMap> allAssociationsFieldsMap = new HashMap<>(); + /** * The Repository rest configuration. */ @@ -136,6 +142,7 @@ public void customise(OpenAPI openAPI, ResourceMappings mappings, PersistentEnti entityInfo.setIgnoredFields(ignoredFields); Set associationsFields = getAssociationsFields(resourceMetadata, entity); entityInfo.setAssociationsFields(associationsFields); + allAssociationsFieldsMap.put(domainType.getSimpleName(), getAllAssociationsFields(resourceMetadata, entity)); entityInoMap.put(domainType.getSimpleName(), entityInfo); } @@ -266,6 +273,8 @@ private void updateRequestBodySchemaProperties(String key, Schema referencedSche * @return the schema */ private Schema updateResponseSchema(String className, Schema existingSchema, Components components, boolean openapi31) { + if (existingSchema == null) + return null; Map properties = existingSchema.getProperties(); EntityInfo entityInfo = entityInoMap.get(className); if (!CollectionUtils.isEmpty(properties)) { @@ -278,11 +287,43 @@ private Schema updateResponseSchema(String className, Schema existingSchema, Com else if (EMBEDDED.equals(propId)) { updateResponseSchemaEmbedded(components, entityInfo, entry, openapi31); } + else if (allAssociationsFieldsMap.getOrDefault(className, Collections.emptySet()).contains(propId)) { + updateResponseSchemaProperty(entry.getValue(), components, openapi31); + } } } return existingSchema; } + /** + * Update a response schema property that points to an entity which is not an + * exported repository. Spring Data REST serializes these associations in + * the containing representation, so they need the same association filtering + * as an exported entity response. + * + * @param property the property + * @param components the components + * @param openapi31 the openapi 31 + */ + private void updateResponseSchemaProperty(Schema property, Components components, boolean openapi31) { + if (property == null) + return; + if (property.get$ref() != null && !property.get$ref().endsWith(RESPONSE)) { + String key = property.get$ref().substring(Components.COMPONENTS_SCHEMAS_REF.length()); + if (entityInoMap.containsKey(key)) { + String newKey = property.get$ref() + RESPONSE; + if (!components.getSchemas().containsKey(key + RESPONSE)) { + createNewResponseSchema(key, components, openapi31); + updateResponseSchema(key, components.getSchemas().get(key + RESPONSE), components, openapi31); + } + property.set$ref(newKey); + } + } + else if (property.getItems() != null) { + updateResponseSchemaProperty(property.getItems(), components, openapi31); + } + } + /** * Update response schema embedded. * @@ -449,6 +490,25 @@ private Set getAssociationsFields(ResourceMetadata return associationsFields; } + /** + * Gets all associations fields. + * + * @param resourceMetadata the resource metadata + * @param entity the entity + * @return all associations fields + */ + private Set getAllAssociationsFields(ResourceMetadata + resourceMetadata, PersistentEntity entity) { + Set associationsFields = new HashSet<>(); + entity.doWithAssociations((SimpleAssociationHandler) association -> { + PersistentProperty property = association.getInverse(); + ResourceMapping mapping = resourceMetadata.getMappingFor(property); + String fieldName = mapping.getRel().value(); + associationsFields.add(fieldName); + }); + return associationsFields; + } + /** * Gets ignored fields. * diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/Goal.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/Goal.java new file mode 100644 index 000000000..164f38f46 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/Goal.java @@ -0,0 +1,59 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v30.app40; + +import java.util.HashSet; +import java.util.Set; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; + +/** + * Aggregate root with a collection of composite-key join entities. + * + * @author hej090224 + */ +@Entity +public class Goal { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + private String name; + + @OneToMany(mappedBy = "goal", cascade = CascadeType.ALL) + private Set impactsByInitiatives = new HashSet<>(); + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Set getImpactsByInitiatives() { + return impactsByInitiatives; + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/GoalInitiativeImpactId.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/GoalInitiativeImpactId.java new file mode 100644 index 000000000..29f825fa4 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/GoalInitiativeImpactId.java @@ -0,0 +1,52 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v30.app40; + +import java.io.Serializable; + +import jakarta.persistence.Embeddable; + +/** + * Composite identifier for the join entity used by the issue reproducer. + * + * @author hej090224 + */ +@Embeddable +public class GoalInitiativeImpactId implements Serializable { + + private Long goalId; + + private Long initiativeId; + + public Long getGoalId() { + return goalId; + } + + public void setGoalId(Long goalId) { + this.goalId = goalId; + } + + public Long getInitiativeId() { + return initiativeId; + } + + public void setInitiativeId(Long initiativeId) { + this.initiativeId = initiativeId; + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/GoalRepository.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/GoalRepository.java new file mode 100644 index 000000000..75e23563d --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/GoalRepository.java @@ -0,0 +1,31 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v30.app40; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +/** + * Spring Data REST repository for the issue reproducer aggregate. + * + * @author hej090224 + */ +@RepositoryRestResource +public interface GoalRepository extends CrudRepository { +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/Initiative.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/Initiative.java new file mode 100644 index 000000000..32313c72b --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/Initiative.java @@ -0,0 +1,47 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v30.app40; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +/** + * Entity referenced by the composite-key join entity. + * + * @author hej090224 + */ +@Entity +public class Initiative { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + private String name; + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/InitiativeImpactOnGoal.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/InitiativeImpactOnGoal.java new file mode 100644 index 000000000..744b5b6f0 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/InitiativeImpactOnGoal.java @@ -0,0 +1,73 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v30.app40; + +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; + +/** + * Join entity combining an embedded identifier with two mapped associations. + * + * @author hej090224 + */ +@Entity +public class InitiativeImpactOnGoal { + + @EmbeddedId + private GoalInitiativeImpactId id; + + @ManyToOne + @MapsId("goalId") + @JoinColumn(name = "goal_id") + private Goal goal; + + @ManyToOne + @MapsId("initiativeId") + @JoinColumn(name = "initiative_id") + private Initiative initiative; + + @Enumerated(EnumType.STRING) + private ImpactLevel impactLevel; + + public GoalInitiativeImpactId getId() { + return id; + } + + public Goal getGoal() { + return goal; + } + + public Initiative getInitiative() { + return initiative; + } + + public ImpactLevel getImpactLevel() { + return impactLevel; + } + + enum ImpactLevel { + TRIVIAL, + SIGNIFICANT + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/InitiativeRepository.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/InitiativeRepository.java new file mode 100644 index 000000000..4268d41f7 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/InitiativeRepository.java @@ -0,0 +1,31 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v30.app40; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +/** + * Spring Data REST repository for initiatives. + * + * @author hej090224 + */ +@RepositoryRestResource +public interface InitiativeRepository extends CrudRepository { +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/SpringDocApp40Test.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/SpringDocApp40Test.java new file mode 100644 index 000000000..564b2c441 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v30/app40/SpringDocApp40Test.java @@ -0,0 +1,35 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v30.app40; + +import test.org.springdoc.api.v30.AbstractSpringDocTest; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Verifies the response schemas for an embedded ID and mapped associations. + * + * @author hej090224 + */ +public class SpringDocApp40Test extends AbstractSpringDocTest { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Goal.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Goal.java new file mode 100644 index 000000000..5d657bbb8 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Goal.java @@ -0,0 +1,59 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v31.app40; + +import java.util.HashSet; +import java.util.Set; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; + +/** + * Aggregate root with a collection of composite-key join entities. + * + * @author hej090224 + */ +@Entity +public class Goal { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + private String name; + + @OneToMany(mappedBy = "goal", cascade = CascadeType.ALL) + private Set impactsByInitiatives = new HashSet<>(); + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Set getImpactsByInitiatives() { + return impactsByInitiatives; + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalInitiativeImpactId.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalInitiativeImpactId.java new file mode 100644 index 000000000..bb50b5b1d --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalInitiativeImpactId.java @@ -0,0 +1,52 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v31.app40; + +import java.io.Serializable; + +import jakarta.persistence.Embeddable; + +/** + * Composite identifier for the join entity used by the issue reproducer. + * + * @author hej090224 + */ +@Embeddable +public class GoalInitiativeImpactId implements Serializable { + + private Long goalId; + + private Long initiativeId; + + public Long getGoalId() { + return goalId; + } + + public void setGoalId(Long goalId) { + this.goalId = goalId; + } + + public Long getInitiativeId() { + return initiativeId; + } + + public void setInitiativeId(Long initiativeId) { + this.initiativeId = initiativeId; + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalRepository.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalRepository.java new file mode 100644 index 000000000..99b3d5375 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/GoalRepository.java @@ -0,0 +1,31 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v31.app40; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +/** + * Spring Data REST repository for the issue reproducer aggregate. + * + * @author hej090224 + */ +@RepositoryRestResource +public interface GoalRepository extends CrudRepository { +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Initiative.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Initiative.java new file mode 100644 index 000000000..685c84663 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/Initiative.java @@ -0,0 +1,47 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v31.app40; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +/** + * Entity referenced by the composite-key join entity. + * + * @author hej090224 + */ +@Entity +public class Initiative { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + private String name; + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeImpactOnGoal.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeImpactOnGoal.java new file mode 100644 index 000000000..0d1c2ee11 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeImpactOnGoal.java @@ -0,0 +1,73 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v31.app40; + +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; + +/** + * Join entity combining an embedded identifier with two mapped associations. + * + * @author hej090224 + */ +@Entity +public class InitiativeImpactOnGoal { + + @EmbeddedId + private GoalInitiativeImpactId id; + + @ManyToOne + @MapsId("goalId") + @JoinColumn(name = "goal_id") + private Goal goal; + + @ManyToOne + @MapsId("initiativeId") + @JoinColumn(name = "initiative_id") + private Initiative initiative; + + @Enumerated(EnumType.STRING) + private ImpactLevel impactLevel; + + public GoalInitiativeImpactId getId() { + return id; + } + + public Goal getGoal() { + return goal; + } + + public Initiative getInitiative() { + return initiative; + } + + public ImpactLevel getImpactLevel() { + return impactLevel; + } + + enum ImpactLevel { + TRIVIAL, + SIGNIFICANT + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeRepository.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeRepository.java new file mode 100644 index 000000000..9768eb39a --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/InitiativeRepository.java @@ -0,0 +1,31 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v31.app40; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +/** + * Spring Data REST repository for initiatives. + * + * @author hej090224 + */ +@RepositoryRestResource +public interface InitiativeRepository extends CrudRepository { +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/SpringDocApp40Test.java b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/SpringDocApp40Test.java new file mode 100644 index 000000000..b8d89f0fd --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/java/test/org/springdoc/api/v31/app40/SpringDocApp40Test.java @@ -0,0 +1,35 @@ +/* + * + * * 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. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.api.v31.app40; + +import test.org.springdoc.api.v31.AbstractSpringDocTest; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Verifies the response schemas for an embedded ID and mapped associations. + * + * @author hej090224 + */ +public class SpringDocApp40Test extends AbstractSpringDocTest { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.0.1/app40.json b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.0.1/app40.json new file mode 100644 index 000000000..de396923b --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.0.1/app40.json @@ -0,0 +1,815 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/goals": { + "get": { + "tags": [ + "goal-entity-controller" + ], + "description": "get-goal", + "operationId": "getCollectionResource-goal-get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/CollectionModelEntityModelGoal" + } + }, + "application/x-spring-data-compact+json": { + "schema": { + "$ref": "#/components/schemas/CollectionModelEntityModelGoal" + } + }, + "text/uri-list": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "goal-entity-controller" + ], + "description": "create-goal", + "operationId": "postCollectionResource-goal-post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRequestBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + } + } + } + }, + "/goals/{id}": { + "get": { + "tags": [ + "goal-entity-controller" + ], + "description": "get-goal", + "operationId": "getItemResource-goal-get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "404": { + "description": "Not Found" + } + } + }, + "put": { + "tags": [ + "goal-entity-controller" + ], + "description": "update-goal", + "operationId": "putItemResource-goal-put", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "204": { + "description": "No Content" + } + } + }, + "delete": { + "tags": [ + "goal-entity-controller" + ], + "description": "delete-goal", + "operationId": "deleteItemResource-goal-delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found" + } + } + }, + "patch": { + "tags": [ + "goal-entity-controller" + ], + "description": "patch-goal", + "operationId": "patchItemResource-goal-patch", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "204": { + "description": "No Content" + } + } + } + }, + "/initiatives": { + "get": { + "tags": [ + "initiative-entity-controller" + ], + "description": "get-initiative", + "operationId": "getCollectionResource-initiative-get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/CollectionModelEntityModelInitiative" + } + }, + "application/x-spring-data-compact+json": { + "schema": { + "$ref": "#/components/schemas/CollectionModelEntityModelInitiative" + } + }, + "text/uri-list": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "initiative-entity-controller" + ], + "description": "create-initiative", + "operationId": "postCollectionResource-initiative-post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitiativeRequestBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + } + } + } + }, + "/initiatives/{id}": { + "get": { + "tags": [ + "initiative-entity-controller" + ], + "description": "get-initiative", + "operationId": "getItemResource-initiative-get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "404": { + "description": "Not Found" + } + } + }, + "put": { + "tags": [ + "initiative-entity-controller" + ], + "description": "update-initiative", + "operationId": "putItemResource-initiative-put", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitiativeRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "204": { + "description": "No Content" + } + } + }, + "delete": { + "tags": [ + "initiative-entity-controller" + ], + "description": "delete-initiative", + "operationId": "deleteItemResource-initiative-delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found" + } + } + }, + "patch": { + "tags": [ + "initiative-entity-controller" + ], + "description": "patch-initiative", + "operationId": "patchItemResource-initiative-patch", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitiativeRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "204": { + "description": "No Content" + } + } + } + }, + "/profile": { + "get": { + "tags": [ + "profile-controller" + ], + "operationId": "listAllFormsOfMetadata", + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/RepresentationModelObject" + } + } + } + } + } + } + }, + "/profile/goals": { + "get": { + "tags": [ + "profile-controller" + ], + "operationId": "descriptor", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + }, + "application/alps+json": { + "schema": { + "type": "string" + } + }, + "application/schema+json": { + "schema": { + "$ref": "#/components/schemas/JsonSchema" + } + } + } + } + } + } + }, + "/profile/initiatives": { + "get": { + "tags": [ + "profile-controller" + ], + "operationId": "descriptor_1", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + }, + "application/alps+json": { + "schema": { + "type": "string" + } + }, + "application/schema+json": { + "schema": { + "$ref": "#/components/schemas/JsonSchema" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AbstractJsonSchemaPropertyObject": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + } + } + }, + "Item": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/AbstractJsonSchemaPropertyObject" + } + }, + "requiredProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "JsonSchema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/AbstractJsonSchemaPropertyObject" + } + }, + "requiredProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "definitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Item" + } + }, + "type": { + "type": "string" + }, + "$schema": { + "type": "string" + } + } + }, + "RepresentationModelObject": { + "type": "object", + "properties": { + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "CollectionModelEntityModelInitiative": { + "type": "object", + "properties": { + "_embedded": { + "type": "object", + "properties": { + "initiatives": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "EntityModelInitiative": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "Initiative": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "CollectionModelEntityModelGoal": { + "type": "object", + "properties": { + "_embedded": { + "type": "object", + "properties": { + "goals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "EntityModelGoal": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "impactsByInitiatives": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/InitiativeImpactOnGoalResponse" + } + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "Goal": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "impactsByInitiatives": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/InitiativeImpactOnGoalResponse" + } + } + } + }, + "GoalInitiativeImpactId": { + "type": "object", + "properties": { + "goalId": { + "type": "integer", + "format": "int64" + }, + "initiativeId": { + "type": "integer", + "format": "int64" + } + } + }, + "InitiativeImpactOnGoal": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/GoalInitiativeImpactId" + }, + "goal": { + "$ref": "#/components/schemas/Goal" + }, + "initiative": { + "$ref": "#/components/schemas/Initiative" + }, + "impactLevel": { + "type": "string", + "enum": [ + "TRIVIAL", + "SIGNIFICANT" + ] + } + } + }, + "GoalRequestBody": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "impactsByInitiatives": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/InitiativeImpactOnGoal" + } + } + } + }, + "InitiativeRequestBody": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "InitiativeImpactOnGoalResponse": { + "type": "object", + "properties": { + "impactLevel": { + "type": "string", + "enum": [ + "TRIVIAL", + "SIGNIFICANT" + ] + } + } + }, + "Link": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "hreflang": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string", + "nullable": true + }, + "deprecation": { + "type": "string", + "nullable": true + }, + "profile": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "templated": { + "type": "boolean" + } + } + }, + "Links": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Link" + } + } + } + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.1.0/app40.json b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.1.0/app40.json new file mode 100644 index 000000000..d9ebe3a75 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.1.0/app40.json @@ -0,0 +1,827 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/goals": { + "get": { + "tags": [ + "goal-entity-controller" + ], + "description": "get-goal", + "operationId": "getCollectionResource-goal-get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/CollectionModelEntityModelGoal" + } + }, + "application/x-spring-data-compact+json": { + "schema": { + "$ref": "#/components/schemas/CollectionModelEntityModelGoal" + } + }, + "text/uri-list": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "goal-entity-controller" + ], + "description": "create-goal", + "operationId": "postCollectionResource-goal-post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRequestBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + } + } + } + }, + "/goals/{id}": { + "get": { + "tags": [ + "goal-entity-controller" + ], + "description": "get-goal", + "operationId": "getItemResource-goal-get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "404": { + "description": "Not Found" + } + } + }, + "put": { + "tags": [ + "goal-entity-controller" + ], + "description": "update-goal", + "operationId": "putItemResource-goal-put", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "204": { + "description": "No Content" + } + } + }, + "delete": { + "tags": [ + "goal-entity-controller" + ], + "description": "delete-goal", + "operationId": "deleteItemResource-goal-delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found" + } + } + }, + "patch": { + "tags": [ + "goal-entity-controller" + ], + "description": "patch-goal", + "operationId": "patchItemResource-goal-patch", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "204": { + "description": "No Content" + } + } + } + }, + "/initiatives": { + "get": { + "tags": [ + "initiative-entity-controller" + ], + "description": "get-initiative", + "operationId": "getCollectionResource-initiative-get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/CollectionModelEntityModelInitiative" + } + }, + "application/x-spring-data-compact+json": { + "schema": { + "$ref": "#/components/schemas/CollectionModelEntityModelInitiative" + } + }, + "text/uri-list": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "initiative-entity-controller" + ], + "description": "create-initiative", + "operationId": "postCollectionResource-initiative-post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitiativeRequestBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + } + } + } + }, + "/initiatives/{id}": { + "get": { + "tags": [ + "initiative-entity-controller" + ], + "description": "get-initiative", + "operationId": "getItemResource-initiative-get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "404": { + "description": "Not Found" + } + } + }, + "put": { + "tags": [ + "initiative-entity-controller" + ], + "description": "update-initiative", + "operationId": "putItemResource-initiative-put", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitiativeRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "204": { + "description": "No Content" + } + } + }, + "delete": { + "tags": [ + "initiative-entity-controller" + ], + "description": "delete-initiative", + "operationId": "deleteItemResource-initiative-delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found" + } + } + }, + "patch": { + "tags": [ + "initiative-entity-controller" + ], + "description": "patch-initiative", + "operationId": "patchItemResource-initiative-patch", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitiativeRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "204": { + "description": "No Content" + } + } + } + }, + "/profile": { + "get": { + "tags": [ + "profile-controller" + ], + "operationId": "listAllFormsOfMetadata", + "responses": { + "200": { + "description": "OK", + "content": { + "application/hal+json": { + "schema": { + "$ref": "#/components/schemas/RepresentationModelObject" + } + } + } + } + } + } + }, + "/profile/goals": { + "get": { + "tags": [ + "profile-controller" + ], + "operationId": "descriptor", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + }, + "application/alps+json": { + "schema": { + "type": "string" + } + }, + "application/schema+json": { + "schema": { + "$ref": "#/components/schemas/JsonSchema" + } + } + } + } + } + } + }, + "/profile/initiatives": { + "get": { + "tags": [ + "profile-controller" + ], + "operationId": "descriptor_1", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + }, + "application/alps+json": { + "schema": { + "type": "string" + } + }, + "application/schema+json": { + "schema": { + "$ref": "#/components/schemas/JsonSchema" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AbstractJsonSchemaPropertyObject": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + } + } + }, + "Item": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/AbstractJsonSchemaPropertyObject" + } + }, + "requiredProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "JsonSchema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/AbstractJsonSchemaPropertyObject" + } + }, + "requiredProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "definitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Item" + } + }, + "type": { + "type": "string" + }, + "$schema": { + "type": "string" + } + } + }, + "RepresentationModelObject": { + "type": "object", + "properties": { + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "CollectionModelEntityModelInitiative": { + "type": "object", + "properties": { + "_embedded": { + "type": "object", + "properties": { + "initiatives": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityModelInitiative" + } + } + } + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "EntityModelInitiative": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "Initiative": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "CollectionModelEntityModelGoal": { + "type": "object", + "properties": { + "_embedded": { + "type": "object", + "properties": { + "goals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityModelGoal" + } + } + } + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "EntityModelGoal": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "impactsByInitiatives": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InitiativeImpactOnGoalResponse" + }, + "uniqueItems": true + }, + "_links": { + "$ref": "#/components/schemas/Links" + } + } + }, + "Goal": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "impactsByInitiatives": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InitiativeImpactOnGoalResponse" + }, + "uniqueItems": true + } + } + }, + "GoalInitiativeImpactId": { + "type": "object", + "properties": { + "goalId": { + "type": "integer", + "format": "int64" + }, + "initiativeId": { + "type": "integer", + "format": "int64" + } + } + }, + "InitiativeImpactOnGoal": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/GoalInitiativeImpactId" + }, + "goal": { + "$ref": "#/components/schemas/Goal" + }, + "initiative": { + "$ref": "#/components/schemas/Initiative" + }, + "impactLevel": { + "type": "string", + "enum": [ + "TRIVIAL", + "SIGNIFICANT" + ] + } + } + }, + "GoalRequestBody": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "impactsByInitiatives": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InitiativeImpactOnGoal" + }, + "uniqueItems": true + } + } + }, + "InitiativeRequestBody": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "InitiativeImpactOnGoalResponse": { + "type": "object", + "properties": { + "impactLevel": { + "type": "string", + "enum": [ + "TRIVIAL", + "SIGNIFICANT" + ] + } + } + }, + "Link": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "hreflang": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": [ + "string", + "null" + ] + }, + "deprecation": { + "type": [ + "string", + "null" + ] + }, + "profile": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "templated": { + "type": "boolean" + } + } + }, + "Links": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Link" + } + } + } + } +} From 1003f270ed2803bbb575d0cb64c606da83b5051b Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 13:50:28 +0200 Subject: [PATCH 33/42] Add example values to the Spring Security login endpoint Backport of #3323, which resolves #3321. `springdoc.login-endpoint.username-example` and `springdoc.login-endpoint.password-example` set the example shown for the generated login endpoint. Both are optional: when unset the endpoint is documented exactly as before. Co-Authored-By: Mattias Sehlstedt Co-Authored-By: Claude Opus 5 --- .../SpringDocSecurityConfiguration.java | 110 ++++++++++---- .../properties/SpringDocConfigProperties.java | 80 ++++++++++ .../api/v30/app13/SpringDocApp13Test.java | 51 +++++++ .../api/v30/app13/SpringDocConfig.java | 57 ++++++++ .../v30/app13/controllers/MyController.java | 56 +++++++ .../security/JWTAuthenticationFilter.java | 138 ++++++++++++++++++ .../security/JWTAuthorizationFilter.java | 109 ++++++++++++++ .../app13/security/MyUserDetailsService.java | 55 +++++++ .../api/v30/app13/security/WebSecurity.java | 126 ++++++++++++++++ .../api/v31/app13/SpringDocApp13Test.java | 51 +++++++ .../api/v31/app13/SpringDocConfig.java | 57 ++++++++ .../v31/app13/controllers/MyController.java | 56 +++++++ .../security/JWTAuthenticationFilter.java | 137 +++++++++++++++++ .../security/JWTAuthorizationFilter.java | 109 ++++++++++++++ .../app13/security/MyUserDetailsService.java | 55 +++++++ .../api/v31/app13/security/WebSecurity.java | 126 ++++++++++++++++ .../test/resources/results/3.0.1/app13.json | 105 +++++++++++++ .../test/resources/results/3.1.0/app13.json | 105 +++++++++++++ 18 files changed, 1558 insertions(+), 25 deletions(-) create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocApp13Test.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocConfig.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/controllers/MyController.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthenticationFilter.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthorizationFilter.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/MyUserDetailsService.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/WebSecurity.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocApp13Test.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocConfig.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/controllers/MyController.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthenticationFilter.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthorizationFilter.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/MyUserDetailsService.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/WebSecurity.java create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.0.1/app13.json create mode 100644 springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.1.0/app13.json diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java index 9150df950..f71b97fcf 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java @@ -45,6 +45,8 @@ import org.springdoc.core.configuration.hints.SpringDocSecurityHints; import org.springdoc.core.customizers.GlobalOpenApiCustomizer; import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springdoc.core.properties.SpringDocConfigProperties.LoginEndpoint; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -113,14 +115,18 @@ class SpringSecurityLoginEndpointConfiguration { /** * Spring security login endpoint customiser open api customiser. * - * @param applicationContext the application context + * @param applicationContext the application context + * @param springDocConfigProperties the springdoc configuration properties * @return the open api customiser */ @Bean @ConditionalOnProperty(SPRINGDOC_SHOW_LOGIN_ENDPOINT) @Lazy(false) - OpenApiCustomizer springSecurityLoginEndpointCustomizer(ApplicationContext applicationContext) { + OpenApiCustomizer springSecurityLoginEndpointCustomizer(ApplicationContext applicationContext, SpringDocConfigProperties springDocConfigProperties) { FilterChainProxy filterChainProxy = applicationContext.getBean(AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME, FilterChainProxy.class); + LoginEndpoint loginEndpoint = springDocConfigProperties.getLoginEndpoint(); + String usernameExample = loginEndpoint.getUsernameExample(); + String passwordExample = loginEndpoint.getPasswordExample(); return openAPI -> { for (SecurityFilterChain filterChain : filterChainProxy.getFilterChains()) { Optional optionalFilter = @@ -135,29 +141,8 @@ OpenApiCustomizer springSecurityLoginEndpointCustomizer(ApplicationContext appli .findAny(); if (optionalFilter.isPresent()) { UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter = optionalFilter.get(); - Operation operation = new Operation(); - Schema schema = new ObjectSchema() - .addProperty(usernamePasswordAuthenticationFilter.getUsernameParameter(), new StringSchema()) - .addProperty(usernamePasswordAuthenticationFilter.getPasswordParameter(), new StringSchema()); - String mediaType = org.springframework.http.MediaType.APPLICATION_JSON_VALUE; - if (optionalDefaultLoginPageGeneratingFilter.isPresent()) { - DefaultLoginPageGeneratingFilter defaultLoginPageGeneratingFilter = optionalDefaultLoginPageGeneratingFilter.get(); - try { - boolean formLoginEnabled = (boolean) FieldUtils.readDeclaredField(defaultLoginPageGeneratingFilter, "formLoginEnabled", true); - if (formLoginEnabled) - mediaType = org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; - } - catch (IllegalAccessException e) { - LOGGER.warn(e.getMessage()); - } - } - RequestBody requestBody = new RequestBody().content(new Content().addMediaType(mediaType, new MediaType().schema(schema))); - operation.requestBody(requestBody); - ApiResponses apiResponses = new ApiResponses(); - apiResponses.addApiResponse(String.valueOf(HttpStatus.OK.value()), new ApiResponse().description(HttpStatus.OK.getReasonPhrase())); - apiResponses.addApiResponse(String.valueOf(HttpStatus.UNAUTHORIZED.value()), new ApiResponse().description(HttpStatus.UNAUTHORIZED.getReasonPhrase())); - operation.responses(apiResponses); - operation.addTagsItem("login-endpoint"); + String mediaType = resolveMediaType(optionalDefaultLoginPageGeneratingFilter); + Operation operation = buildOperation(usernamePasswordAuthenticationFilter, mediaType, usernameExample, passwordExample); PathItem pathItem = new PathItem().post(operation); try { RequestMatcher requestMatcher = (RequestMatcher) FieldUtils.readField( @@ -191,6 +176,81 @@ else if (requestMatcher instanceof PathPatternRequestMatcher) { } }; } + + /** + * Resolves the request body media type based on the presence of a form login configuration. + * + * @param optionalDefaultLoginPageGeneratingFilter the optional default login page generating filter + * @return the resolved media type + */ + private String resolveMediaType(Optional optionalDefaultLoginPageGeneratingFilter) { + String mediaType = org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + if (optionalDefaultLoginPageGeneratingFilter.isPresent()) { + DefaultLoginPageGeneratingFilter defaultLoginPageGeneratingFilter = optionalDefaultLoginPageGeneratingFilter.get(); + try { + boolean formLoginEnabled = (boolean) FieldUtils.readDeclaredField(defaultLoginPageGeneratingFilter, "formLoginEnabled", true); + if (formLoginEnabled) + mediaType = org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; + } + catch (IllegalAccessException e) { + LOGGER.warn(e.getMessage()); + } + } + return mediaType; + } + + /** + * Builds the login endpoint operation. + * + * @param usernamePasswordAuthenticationFilter the username password authentication filter + * @param mediaType the request body media type + * @param usernameExample the username example value + * @param passwordExample the password example value + * @return the operation + */ + private Operation buildOperation(UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter, + String mediaType, String usernameExample, String passwordExample) { + Operation operation = new Operation(); + operation.requestBody(buildRequestBody(usernamePasswordAuthenticationFilter, mediaType, usernameExample, passwordExample)); + operation.responses(buildApiResponses()); + operation.addTagsItem("login-endpoint"); + return operation; + } + + /** + * Builds the request body for the login endpoint operation. + * + * @param usernamePasswordAuthenticationFilter the username password authentication filter + * @param mediaType the request body media type + * @param usernameExample the username example value + * @param passwordExample the password example value + * @return the request body + */ + private RequestBody buildRequestBody(UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter, + String mediaType, String usernameExample, String passwordExample) { + StringSchema usernameSchema = new StringSchema(); + if (usernameExample != null) + usernameSchema.example(usernameExample); + StringSchema passwordSchema = new StringSchema(); + if (passwordExample != null) + passwordSchema.example(passwordExample); + Schema schema = new ObjectSchema() + .addProperty(usernamePasswordAuthenticationFilter.getUsernameParameter(), usernameSchema) + .addProperty(usernamePasswordAuthenticationFilter.getPasswordParameter(), passwordSchema); + return new RequestBody().content(new Content().addMediaType(mediaType, new MediaType().schema(schema))); + } + + /** + * Builds the API responses for the login endpoint operation. + * + * @return the api responses + */ + private ApiResponses buildApiResponses() { + ApiResponses apiResponses = new ApiResponses(); + apiResponses.addApiResponse(String.valueOf(HttpStatus.OK.value()), new ApiResponse().description(HttpStatus.OK.getReasonPhrase())); + apiResponses.addApiResponse(String.valueOf(HttpStatus.UNAUTHORIZED.value()), new ApiResponse().description(HttpStatus.UNAUTHORIZED.getReasonPhrase())); + return apiResponses; + } } /** diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java index e494c5ed5..0e7728870 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java @@ -168,6 +168,11 @@ public class SpringDocConfigProperties { */ private boolean showLoginEndpoint; + /** + * The login endpoint configuration. + */ + private LoginEndpoint loginEndpoint = new LoginEndpoint(); + /** * Allow for pre-loading OpenAPI */ @@ -749,6 +754,24 @@ public void setShowLoginEndpoint(boolean showLoginEndpoint) { this.showLoginEndpoint = showLoginEndpoint; } + /** + * Gets login endpoint. + * + * @return the login endpoint + */ + public LoginEndpoint getLoginEndpoint() { + return loginEndpoint; + } + + /** + * Sets login endpoint. + * + * @param loginEndpoint the login endpoint + */ + public void setLoginEndpoint(LoginEndpoint loginEndpoint) { + this.loginEndpoint = loginEndpoint; + } + /** * Gets packages to scan. * @@ -1984,4 +2007,61 @@ public int hashCode() { return Objects.hash(group); } } + + /** + * The type Login endpoint. + *

+ * These settings only take effect when the login endpoint is exposed, i.e. when + * {@code springdoc.show-login-endpoint=true}. Otherwise, they are ignored. + */ + public static class LoginEndpoint { + + /** + * The example value for the username field of the login request body. + * Only applied when {@code springdoc.show-login-endpoint=true}. + */ + private String usernameExample; + + /** + * The example value for the password field of the login request body. + * Only applied when {@code springdoc.show-login-endpoint=true}. + */ + private String passwordExample; + + /** + * Gets username example. + * + * @return the username example + */ + public String getUsernameExample() { + return usernameExample; + } + + /** + * Sets username example. + * + * @param usernameExample the username example + */ + public void setUsernameExample(String usernameExample) { + this.usernameExample = usernameExample; + } + + /** + * Gets password example. + * + * @return the password example + */ + public String getPasswordExample() { + return passwordExample; + } + + /** + * Sets password example. + * + * @param passwordExample the password example + */ + public void setPasswordExample(String passwordExample) { + this.passwordExample = passwordExample; + } + } } diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocApp13Test.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocApp13Test.java new file mode 100644 index 000000000..49a0e4fe1 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocApp13Test.java @@ -0,0 +1,51 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13; + +import test.org.springdoc.api.v30.AbstractSpringDocTest; +import test.org.springdoc.api.v30.app13.security.MyUserDetailsService; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.TestPropertySource; + +@TestPropertySource(properties = { + "springdoc.show-login-endpoint=true", + "springdoc.login-endpoint.username-example=demouser", + "springdoc.login-endpoint.password-example=secret" +}) +public class SpringDocApp13Test extends AbstractSpringDocTest { + + @SpringBootApplication(scanBasePackages = { "test.org.springdoc.api.v30.configuration", "test.org.springdoc.api.v30.app13" }) + static class SpringDocTestApp { + @Bean + MyUserDetailsService userDetailsService() { + return new MyUserDetailsService(); + } + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocConfig.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocConfig.java new file mode 100644 index 000000000..b5fba862f --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/SpringDocConfig.java @@ -0,0 +1,57 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +public class SpringDocConfig { + + + @Bean + public OpenAPI myOpenAPI() { + final String securitySchemeName = "bearerAuth"; + return new OpenAPI().info(new Info().title("My MWE API") + .description("This document specifies the API") + .version("v23")) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components().addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/controllers/MyController.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/controllers/MyController.java new file mode 100644 index 000000000..532b4a485 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/controllers/MyController.java @@ -0,0 +1,56 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13.controllers; + +import java.util.List; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + + +@RestController +@RequestMapping("/fax") +@Tag(name = "Fax stuff", description = "For managing fax machines.") +public class MyController { + + @Operation(summary = "Get information about currently existing fax machines") + @ApiResponse(responseCode = "200", description = "list of existing fax machines") + @GetMapping("list") + public List getFaxList(@RequestParam(name = "vendorName", required = false) + @Parameter(description = "vendor name to restrict the list") String vendorFilter) { + + return null; + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthenticationFilter.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthenticationFilter.java new file mode 100644 index 000000000..851e38c4c --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthenticationFilter.java @@ -0,0 +1,138 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13.security; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import static org.springdoc.core.utils.SpringDocUtils.cloneViaJson; + + +public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + + private final AuthenticationManager authenticationManager; + + + private final long lifetime; + + + private final String key; + + + public JWTAuthenticationFilter(AuthenticationManager authenticationManager, long lifetime, + String key) { + this.authenticationManager = authenticationManager; + this.lifetime = lifetime; + this.key = key; + } + + + @Override + public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) + throws AuthenticationException { + try { + UserCredentials credentials = cloneViaJson(req.getInputStream(),UserCredentials.class,new ObjectMapper()); + return authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(credentials.getUsername(), + credentials.getPassword(), new ArrayList<>())); + + } + catch (IOException e) { + throw new InternalAuthenticationServiceException("Error processing credentials", e); + } + } + + + @Override + protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, + FilterChain chain, Authentication auth) + throws IOException, ServletException { + Date notBefore = new Date(); + Date expirationDate = new Date(notBefore.getTime() + lifetime); + + String token = Jwts.builder() + .setClaims(new HashMap<>()) + .setSubject(((User) auth.getPrincipal()).getUsername()) + .setNotBefore(notBefore) + .setExpiration(expirationDate) + .signWith(SignatureAlgorithm.HS512, key) + .compact(); + res.addHeader(WebSecurity.HeaderString, WebSecurity.TokenPrefix + token); + + } + + + private static class UserCredentials { + + + private String username; + + + private String password; + + + String getUsername() { + return username; + } + + + public void setUsername(String username) { + this.username = username; + } + + + String getPassword() { + return password; + } + + + public void setPassword(String password) { + this.password = password; + } + + } + +} \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthorizationFilter.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthorizationFilter.java new file mode 100644 index 000000000..0e9058885 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/JWTAuthorizationFilter.java @@ -0,0 +1,109 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13.security; + +import java.io.IOException; +import java.util.ArrayList; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; + + +public class JWTAuthorizationFilter extends BasicAuthenticationFilter { + + + public static final String AUTH_ERROR_ATTRIBUTE = "authError"; + + + private final String key; + + + public JWTAuthorizationFilter(AuthenticationManager authManager, String key) { + super(authManager); + this.key = key; + } + + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws IOException, ServletException { + String header = req.getHeader(WebSecurity.HeaderString); + + if (header == null || !header.startsWith(WebSecurity.TokenPrefix)) { + chain.doFilter(req, res); + return; + } + + UsernamePasswordAuthenticationToken authentication = getAuthentication(req); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + chain.doFilter(req, res); + } + + + /** + * Check the validity of the JWT (JWS, more precisely) as submitted via the + * {@link HttpServletRequest}. + * + * @param request the {@link HttpServletRequest} containing a JWS. + * @return a {@link UsernamePasswordAuthenticationToken} if the JWS is + * valid, {@code null} otherwise. + */ + + private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) { + String token = request.getHeader(WebSecurity.HeaderString); + if (token != null) { + String user = null; + try { + user = Jwts.parser() + .setSigningKey(key) + .parseClaimsJws(token.replace(WebSecurity.TokenPrefix, "")) + .getBody() + .getSubject(); + } + catch (ExpiredJwtException e) { + request.setAttribute(AUTH_ERROR_ATTRIBUTE, e.getMessage()); + } + if (user != null) { + return new UsernamePasswordAuthenticationToken(user, null, new ArrayList<>()); + } + return null; + } + return null; + } + +} \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/MyUserDetailsService.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/MyUserDetailsService.java new file mode 100644 index 000000000..6e297e53e --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/MyUserDetailsService.java @@ -0,0 +1,55 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13.security; + +import java.util.Collections; + +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + + +@Service +public class MyUserDetailsService implements UserDetailsService { + + @Override + public UserDetails loadUserByUsername(String username) + throws UsernameNotFoundException { + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + + + if (!username.equals("demouser")) { + throw new UsernameNotFoundException(username); + } + + return new User("demouser", encoder.encode("secret"), Collections.emptyList()); + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/WebSecurity.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/WebSecurity.java new file mode 100644 index 000000000..32e4c2458 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v30/app13/security/WebSecurity.java @@ -0,0 +1,126 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app13.security; + +import org.springdoc.core.properties.SpringDocConfigProperties; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import static org.springdoc.core.utils.Constants.ALL_PATTERN; + +@Configuration +@EnableWebSecurity +@Order(200) +public class WebSecurity { + + + public static final String TokenPrefix = "Bearer "; + + + public static final String HeaderString = "Authorization"; + + + private final UserDetailsService userDetailsService; + + + @Autowired + SpringDocConfigProperties configProperties; + + + private long lifetime = 123456789L; + + + private String key = + "YRv13MrZah/rHJPMGIN6AjdjB09F9gpIC7i9mdFwdIDZ296doUg/nhG/mQ/CnlxPNtcWR6z6RCKtW5cCspGM9w=="; + + + public WebSecurity(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + + } + + + @Bean + public SecurityFilterChain securityWebFilterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception { + String apiDocsPath = configProperties.getApiDocs().getPath(); + http.cors() + .and() + .csrf() + .disable() + .authorizeRequests() + .requestMatchers(apiDocsPath + ALL_PATTERN) + .permitAll() + .requestMatchers(apiDocsPath.substring(0, apiDocsPath.lastIndexOf("/") + 1) + "api-docs.yaml") + .permitAll() + .anyRequest() + .authenticated() + .and() + .exceptionHandling() + .and() + .addFilter(new JWTAuthenticationFilter(authenticationManager, lifetime, key)) + .addFilter(new JWTAuthorizationFilter(authenticationManager, key)) + // this disables session creation on Spring Security + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); + return http.build(); + } + + + @Autowired + public void configure(AuthenticationManagerBuilder auth) + throws Exception { + auth.userDetailsService(userDetailsService).passwordEncoder(new BCryptPasswordEncoder()); + } + + + @Bean + CorsConfigurationSource corsConfigurationSource() { + final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + + CorsConfiguration configuration = new CorsConfiguration().applyPermitDefaultValues(); + + configuration.addExposedHeader(HeaderString); + source.registerCorsConfiguration(ALL_PATTERN, configuration); + + return source; + } + +} \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocApp13Test.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocApp13Test.java new file mode 100644 index 000000000..3eea16927 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocApp13Test.java @@ -0,0 +1,51 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13; + +import test.org.springdoc.api.v31.AbstractSpringDocTest; +import test.org.springdoc.api.v31.app13.security.MyUserDetailsService; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.TestPropertySource; + +@TestPropertySource(properties = { + "springdoc.show-login-endpoint=true", + "springdoc.login-endpoint.username-example=demouser", + "springdoc.login-endpoint.password-example=secret" +}) +public class SpringDocApp13Test extends AbstractSpringDocTest { + + @SpringBootApplication(scanBasePackages = { "test.org.springdoc.api.v31.configuration", "test.org.springdoc.api.v31.app13" }) + static class SpringDocTestApp { + @Bean + MyUserDetailsService userDetailsService() { + return new MyUserDetailsService(); + } + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocConfig.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocConfig.java new file mode 100644 index 000000000..5869e11fd --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/SpringDocConfig.java @@ -0,0 +1,57 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +public class SpringDocConfig { + + + @Bean + public OpenAPI myOpenAPI() { + final String securitySchemeName = "bearerAuth"; + return new OpenAPI().info(new Info().title("My MWE API") + .description("This document specifies the API") + .version("v23")) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components().addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/controllers/MyController.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/controllers/MyController.java new file mode 100644 index 000000000..ae2eaa383 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/controllers/MyController.java @@ -0,0 +1,56 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13.controllers; + +import java.util.List; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + + +@RestController +@RequestMapping("/fax") +@Tag(name = "Fax stuff", description = "For managing fax machines.") +public class MyController { + + @Operation(summary = "Get information about currently existing fax machines") + @ApiResponse(responseCode = "200", description = "list of existing fax machines") + @GetMapping("list") + public List getFaxList(@RequestParam(name = "vendorName", required = false) + @Parameter(description = "vendor name to restrict the list") String vendorFilter) { + + return null; + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthenticationFilter.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthenticationFilter.java new file mode 100644 index 000000000..2a898e2fb --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthenticationFilter.java @@ -0,0 +1,137 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13.security; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import static org.springdoc.core.utils.SpringDocUtils.cloneViaJson; + + +public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + + private final AuthenticationManager authenticationManager; + + + private final long lifetime; + + + private final String key; + + + public JWTAuthenticationFilter(AuthenticationManager authenticationManager, long lifetime, + String key) { + this.authenticationManager = authenticationManager; + this.lifetime = lifetime; + this.key = key; + } + + + @Override + public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) + throws AuthenticationException { + try { + UserCredentials credentials = cloneViaJson(req.getInputStream(), UserCredentials.class, new ObjectMapper()); + return authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(credentials.getUsername(), + credentials.getPassword(), new ArrayList<>())); + } + catch (IOException e) { + throw new InternalAuthenticationServiceException("Error processing credentials", e); + } + } + + + @Override + protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, + FilterChain chain, Authentication auth) + throws IOException, ServletException { + Date notBefore = new Date(); + Date expirationDate = new Date(notBefore.getTime() + lifetime); + + String token = Jwts.builder() + .setClaims(new HashMap<>()) + .setSubject(((User) auth.getPrincipal()).getUsername()) + .setNotBefore(notBefore) + .setExpiration(expirationDate) + .signWith(SignatureAlgorithm.HS512, key) + .compact(); + res.addHeader(WebSecurity.HeaderString, WebSecurity.TokenPrefix + token); + + } + + + private static class UserCredentials { + + + private String username; + + + private String password; + + + String getUsername() { + return username; + } + + + public void setUsername(String username) { + this.username = username; + } + + + String getPassword() { + return password; + } + + + public void setPassword(String password) { + this.password = password; + } + + } + +} \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthorizationFilter.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthorizationFilter.java new file mode 100644 index 000000000..6841f2ced --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/JWTAuthorizationFilter.java @@ -0,0 +1,109 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13.security; + +import java.io.IOException; +import java.util.ArrayList; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; + + +public class JWTAuthorizationFilter extends BasicAuthenticationFilter { + + + public static final String AUTH_ERROR_ATTRIBUTE = "authError"; + + + private final String key; + + + public JWTAuthorizationFilter(AuthenticationManager authManager, String key) { + super(authManager); + this.key = key; + } + + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws IOException, ServletException { + String header = req.getHeader(WebSecurity.HeaderString); + + if (header == null || !header.startsWith(WebSecurity.TokenPrefix)) { + chain.doFilter(req, res); + return; + } + + UsernamePasswordAuthenticationToken authentication = getAuthentication(req); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + chain.doFilter(req, res); + } + + + /** + * Check the validity of the JWT (JWS, more precisely) as submitted via the + * {@link HttpServletRequest}. + * + * @param request the {@link HttpServletRequest} containing a JWS. + * @return a {@link UsernamePasswordAuthenticationToken} if the JWS is + * valid, {@code null} otherwise. + */ + + private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) { + String token = request.getHeader(WebSecurity.HeaderString); + if (token != null) { + String user = null; + try { + user = Jwts.parser() + .setSigningKey(key) + .parseClaimsJws(token.replace(WebSecurity.TokenPrefix, "")) + .getBody() + .getSubject(); + } + catch (ExpiredJwtException e) { + request.setAttribute(AUTH_ERROR_ATTRIBUTE, e.getMessage()); + } + if (user != null) { + return new UsernamePasswordAuthenticationToken(user, null, new ArrayList<>()); + } + return null; + } + return null; + } + +} \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/MyUserDetailsService.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/MyUserDetailsService.java new file mode 100644 index 000000000..1edd84240 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/MyUserDetailsService.java @@ -0,0 +1,55 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13.security; + +import java.util.Collections; + +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + + +@Service +public class MyUserDetailsService implements UserDetailsService { + + @Override + public UserDetails loadUserByUsername(String username) + throws UsernameNotFoundException { + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + + + if (!username.equals("demouser")) { + throw new UsernameNotFoundException(username); + } + + return new User("demouser", encoder.encode("secret"), Collections.emptyList()); + } + +} diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/WebSecurity.java b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/WebSecurity.java new file mode 100644 index 000000000..45ac8e15a --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/java/test/org/springdoc/api/v31/app13/security/WebSecurity.java @@ -0,0 +1,126 @@ +/* + * + * * + * * * + * * * * + * * * * * + * * * * * * 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. + * * * * * * See the License for the specific language governing permissions and + * * * * * * limitations under the License. + * * * * * + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app13.security; + +import org.springdoc.core.properties.SpringDocConfigProperties; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import static org.springdoc.core.utils.Constants.ALL_PATTERN; + +@Configuration +@EnableWebSecurity +@Order(200) +public class WebSecurity { + + + public static final String TokenPrefix = "Bearer "; + + + public static final String HeaderString = "Authorization"; + + + private final UserDetailsService userDetailsService; + + + @Autowired + SpringDocConfigProperties configProperties; + + + private long lifetime = 123456789L; + + + private String key = + "YRv13MrZah/rHJPMGIN6AjdjB09F9gpIC7i9mdFwdIDZ296doUg/nhG/mQ/CnlxPNtcWR6z6RCKtW5cCspGM9w=="; + + + public WebSecurity(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + + } + + + @Bean + public SecurityFilterChain securityWebFilterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception { + String apiDocsPath = configProperties.getApiDocs().getPath(); + http.cors() + .and() + .csrf() + .disable() + .authorizeRequests() + .requestMatchers(apiDocsPath + ALL_PATTERN) + .permitAll() + .requestMatchers(apiDocsPath.substring(0, apiDocsPath.lastIndexOf("/") + 1) + "api-docs.yaml") + .permitAll() + .anyRequest() + .authenticated() + .and() + .exceptionHandling() + .and() + .addFilter(new JWTAuthenticationFilter(authenticationManager, lifetime, key)) + .addFilter(new JWTAuthorizationFilter(authenticationManager, key)) + // this disables session creation on Spring Security + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); + return http.build(); + } + + + @Autowired + public void configure(AuthenticationManagerBuilder auth) + throws Exception { + auth.userDetailsService(userDetailsService).passwordEncoder(new BCryptPasswordEncoder()); + } + + + @Bean + CorsConfigurationSource corsConfigurationSource() { + final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + + CorsConfiguration configuration = new CorsConfiguration().applyPermitDefaultValues(); + + configuration.addExposedHeader(HeaderString); + source.registerCorsConfiguration(ALL_PATTERN, configuration); + + return source; + } + +} \ No newline at end of file diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.0.1/app13.json b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.0.1/app13.json new file mode 100644 index 000000000..78f650a91 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.0.1/app13.json @@ -0,0 +1,105 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "My MWE API", + "description": "This document specifies the API", + "version": "v23" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "Fax stuff", + "description": "For managing fax machines." + } + ], + "paths": { + "/fax/list": { + "get": { + "tags": [ + "Fax stuff" + ], + "summary": "Get information about currently existing fax machines", + "operationId": "getFaxList", + "parameters": [ + { + "name": "vendorName", + "in": "query", + "description": "vendor name to restrict the list", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "list of existing fax machines", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "/login": { + "post": { + "tags": [ + "login-endpoint" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "example": "demouser" + }, + "password": { + "type": "string", + "example": "secret" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + } +} diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.1.0/app13.json b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.1.0/app13.json new file mode 100644 index 000000000..990f25579 --- /dev/null +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/src/test/resources/results/3.1.0/app13.json @@ -0,0 +1,105 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "My MWE API", + "description": "This document specifies the API", + "version": "v23" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "Fax stuff", + "description": "For managing fax machines." + } + ], + "paths": { + "/fax/list": { + "get": { + "tags": [ + "Fax stuff" + ], + "summary": "Get information about currently existing fax machines", + "operationId": "getFaxList", + "parameters": [ + { + "name": "vendorName", + "in": "query", + "description": "vendor name to restrict the list", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "list of existing fax machines", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "/login": { + "post": { + "tags": [ + "login-endpoint" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "example": "demouser" + }, + "password": { + "type": "string", + "example": "secret" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + } +} From c1aaf2a94f23f5c9bd645322063846cc3e727e8e Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 13:50:28 +0200 Subject: [PATCH 34/42] Ignore an injected HttpHeaders parameter explicitly Backport of the fix for #3317. The reported failure needs Spring Framework 7, where `HttpHeaders` stopped implementing `MultiValueMap`. On this line `HttpHeaders` is still covered by the `Map` entry of the ignore list, so this is regression cover rather than a behaviour change. Co-Authored-By: Claude Opus 5 --- .../core/service/AbstractRequestService.java | 2 + .../api/v30/app272/HelloController.java | 37 +++++++++++++++++ .../api/v30/app272/SpringDocApp272Test.java | 33 +++++++++++++++ .../api/v31/app272/HelloController.java | 37 +++++++++++++++++ .../api/v31/app272/SpringDocApp272Test.java | 33 +++++++++++++++ .../test/resources/results/3.0.1/app272.json | 40 +++++++++++++++++++ .../test/resources/results/3.1.0/app272.json | 40 +++++++++++++++++++ 7 files changed, 222 insertions(+) create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app272/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app272/SpringDocApp272Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app272/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app272/SpringDocApp272Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app272.json create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app272.json diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java index 31ad68910..f05d964b4 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java @@ -75,6 +75,7 @@ import org.springframework.core.MethodParameter; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.ui.Model; import org.springframework.ui.ModelMap; @@ -122,6 +123,7 @@ public abstract class AbstractRequestService { PARAM_TYPES_TO_IGNORE.add(NativeWebRequest.class); PARAM_TYPES_TO_IGNORE.add(Principal.class); PARAM_TYPES_TO_IGNORE.add(HttpMethod.class); + PARAM_TYPES_TO_IGNORE.add(HttpHeaders.class); PARAM_TYPES_TO_IGNORE.add(Locale.class); PARAM_TYPES_TO_IGNORE.add(TimeZone.class); PARAM_TYPES_TO_IGNORE.add(InputStream.class); diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app272/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app272/HelloController.java new file mode 100644 index 000000000..f3078ec80 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app272/HelloController.java @@ -0,0 +1,37 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v30.app272; + +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * A controller that asks for every request header at once. + * + * @author bnasslahsen + */ +@RestController +public class HelloController { + + @GetMapping("/hello") + public String hello(@RequestHeader HttpHeaders headers, @RequestParam String name) { + return name; + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app272/SpringDocApp272Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app272/SpringDocApp272Test.java new file mode 100644 index 000000000..41d9a4a75 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app272/SpringDocApp272Test.java @@ -0,0 +1,33 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v30.app272; + +import test.org.springdoc.api.v30.AbstractSpringDocV30Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * An injected HttpHeaders is a request wrapper, not a schema. + * + * @author bnasslahsen + */ +public class SpringDocApp272Test extends AbstractSpringDocV30Test { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app272/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app272/HelloController.java new file mode 100644 index 000000000..a21130630 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app272/HelloController.java @@ -0,0 +1,37 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v31.app272; + +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * A controller that asks for every request header at once. + * + * @author bnasslahsen + */ +@RestController +public class HelloController { + + @GetMapping("/hello") + public String hello(@RequestHeader HttpHeaders headers, @RequestParam String name) { + return name; + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app272/SpringDocApp272Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app272/SpringDocApp272Test.java new file mode 100644 index 000000000..25cd078ce --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app272/SpringDocApp272Test.java @@ -0,0 +1,33 @@ +/* + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test.org.springdoc.api.v31.app272; + +import test.org.springdoc.api.v31.AbstractSpringDocV31Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * An injected HttpHeaders is a request wrapper, not a schema. + * + * @author bnasslahsen + */ +public class SpringDocApp272Test extends AbstractSpringDocV31Test { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app272.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app272.json new file mode 100644 index 000000000..93d97a19d --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app272.json @@ -0,0 +1,40 @@ +{ + "openapi" : "3.0.1", + "info" : { + "title" : "OpenAPI definition", + "version" : "v0" + }, + "servers" : [ { + "url" : "http://localhost", + "description" : "Generated server url" + } ], + "paths" : { + "/hello" : { + "get" : { + "tags" : [ "hello-controller" ], + "operationId" : "hello", + "parameters" : [ { + "name" : "name", + "in" : "query", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + } + }, + "components" : { } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app272.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app272.json new file mode 100644 index 000000000..748af1ade --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app272.json @@ -0,0 +1,40 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "OpenAPI definition", + "version" : "v0" + }, + "servers" : [ { + "url" : "http://localhost", + "description" : "Generated server url" + } ], + "paths" : { + "/hello" : { + "get" : { + "tags" : [ "hello-controller" ], + "operationId" : "hello", + "parameters" : [ { + "name" : "name", + "in" : "query", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + } + }, + "components" : { } +} From 8e0bf2f6cde1ed41d59c4c7c905e26b7cf68a6b6 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 13:50:28 +0200 Subject: [PATCH 35/42] Rewrite a copy of a Spring Data REST association property The response post-processing added for #3136 rewrote the association property in place. Swagger can hand out the same Schema instance for the plain entity schema and for the `EntityModel` projection of it, so the response-only `...Response` refs leaked into the schema shared with the request body representation, depending on the order the schemas were resolved in. Clone the property before rewriting it, so only the response representation is changed, and guard the `_embedded` post-processing against a schema that carries no properties. Co-Authored-By: Claude Opus 5 --- .../core/utils/SpringDocDataRestUtils.java | 16 +++++++++++----- .../src/test/resources/results/3.0.1/app40.json | 2 +- .../src/test/resources/results/3.1.0/app40.json | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java index 791d08e0b..df95ef523 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocDataRestUtils.java @@ -39,6 +39,7 @@ import io.swagger.v3.core.converter.AnnotatedType; import io.swagger.v3.core.converter.ModelConverters; import io.swagger.v3.core.converter.ResolvedSchema; +import io.swagger.v3.core.util.AnnotationsUtils; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.PathItem; @@ -288,7 +289,11 @@ else if (EMBEDDED.equals(propId)) { updateResponseSchemaEmbedded(components, entityInfo, entry, openapi31); } else if (allAssociationsFieldsMap.getOrDefault(className, Collections.emptySet()).contains(propId)) { - updateResponseSchemaProperty(entry.getValue(), components, openapi31); + // the property schema may be shared with the request body representation, + // so rewrite a copy of it instead of the resolved instance + Schema propertyCopy = AnnotationsUtils.clone(entry.getValue(), openapi31); + updateResponseSchemaProperty(propertyCopy, components, openapi31); + entry.setValue(propertyCopy); } } } @@ -334,15 +339,16 @@ else if (property.getItems() != null) { */ private void updateResponseSchemaEmbedded(Components components, EntityInfo entityInfo, Entry entry, boolean openapi31) { String entityClassName = linkRelationProvider.getCollectionResourceRelFor(entityInfo.getDomainType()).value(); + Map embeddedProperties = entry.getValue().getProperties(); + if (CollectionUtils.isEmpty(embeddedProperties)) + return; Schema itemsSchema = null; if (openapi31) { - JsonSchema jsonSchema = (JsonSchema) entry.getValue().getProperties().get(entityClassName); - if (jsonSchema != null) + if (embeddedProperties.get(entityClassName) instanceof JsonSchema jsonSchema) itemsSchema = jsonSchema.getItems(); } else { - ArraySchema arraySchema = (ArraySchema) entry.getValue().getProperties().get(entityClassName); - if (arraySchema != null) + if (embeddedProperties.get(entityClassName) instanceof ArraySchema arraySchema) itemsSchema = arraySchema.getItems(); } if (itemsSchema != null) { diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.0.1/app40.json b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.0.1/app40.json index de396923b..767901478 100644 --- a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.0.1/app40.json +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.0.1/app40.json @@ -687,7 +687,7 @@ "uniqueItems": true, "type": "array", "items": { - "$ref": "#/components/schemas/InitiativeImpactOnGoalResponse" + "$ref": "#/components/schemas/InitiativeImpactOnGoal" } } } diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.1.0/app40.json b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.1.0/app40.json index d9ebe3a75..5fd93055b 100644 --- a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.1.0/app40.json +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/src/test/resources/results/3.1.0/app40.json @@ -686,7 +686,7 @@ "impactsByInitiatives": { "type": "array", "items": { - "$ref": "#/components/schemas/InitiativeImpactOnGoalResponse" + "$ref": "#/components/schemas/InitiativeImpactOnGoal" }, "uniqueItems": true } From 38acf19b461a85c638d30ee81f950d197a09e705 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 13:50:28 +0200 Subject: [PATCH 36/42] Record the backported fixes in the changelog Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 492044f02..6543320f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #3340 – Describe `JsonNullable` values without their Java wrapper - #3325 – Manage the swagger artifacts in `springdoc-openapi-bom`, so that modules holding only the annotations stay in lockstep +- #3321 – Add `springdoc.login-endpoint.username-example` and `springdoc.login-endpoint.password-example` to document the Spring Security login endpoint ### Changed @@ -39,6 +40,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #3338 – Kotlin nullability interpretation of the `Any?` type - #3332 – The properties a Kotlin entity inherits from an `@Embeddable` are missing from the Spring Data REST schemas - #3136 – A Spring Data REST association to a non-exported entity expands its `@EmbeddedId` and `@MapsId` fields recursively in the response schemas +- The Spring Data REST response post-processing rewrote an association property in place, so the `…Response` refs could leak into the schema shared with the request body representation +- #3317 – Ignore an injected `HttpHeaders` parameter explicitly. The reported failure needs Spring Framework 7, where `HttpHeaders` stopped implementing `MultiValueMap`; on this line it is still covered by the `Map` entry of the ignore list, so this is regression cover rather than a behaviour change +- Harden the Spring Data REST response post-processing against an `_embedded` schema that carries no properties ## [2.9.0] - 2026-07-31 From a15cb5ab4812137b1a95d42efbb1a543b8144e79 Mon Sep 17 00:00:00 2001 From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:25:18 +0200 Subject: [PATCH 37/42] upgrade swagger-core from 2.2.53 to 2.2.54 (cherry picked from commit d78abd954a1069a324f3909c4363059b17b7a59f) --- pom.xml | 2 +- .../main/java/org/springdoc/core/utils/SpringDocUtils.java | 6 ------ .../api/v31/app115/JavaTimeOperationCustomizer.java | 1 - .../src/test/resources/results/3.0.1/app115.json | 3 ++- .../src/test/resources/results/3.1.0/app115.json | 2 +- .../src/test/resources/results/3.1.0/app243.json | 3 ++- .../api/v30/app115/JavaTimeOperationCustomizer.java | 1 - .../api/v31/app115/JavaTimeOperationCustomizer.java | 1 - .../src/test/resources/results/3.0.1/app115.json | 2 +- .../src/test/resources/results/3.1.0/app115.json | 2 +- 10 files changed, 8 insertions(+), 15 deletions(-) diff --git a/pom.xml b/pom.xml index 22d06f0f7..388e6a6d5 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ 0.11.0 1.5.0 - 2.2.53 + 2.2.54 5.32.14 1.13.1 0.9.1 diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java index c0f87f6de..02cd50f54 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java @@ -563,13 +563,10 @@ public SpringDocUtils addParentType(String... parentTypes) { * Init extra schemas. */ public SpringDocUtils initExtraSchemas() { - customClasses().put("java.time.Duration", PrimitiveType.STRING); - customClasses().put("java.time.LocalTime", PrimitiveType.STRING); customClasses().put("java.time.YearMonth", PrimitiveType.STRING); customClasses().put("java.time.MonthDay", PrimitiveType.STRING); customClasses().put("java.time.Year", PrimitiveType.STRING); customClasses().put("java.time.Period", PrimitiveType.STRING); - customClasses().put("java.time.OffsetTime", PrimitiveType.STRING); customClasses().put("java.time.ZoneId", PrimitiveType.STRING); customClasses().put("java.time.ZoneOffset", PrimitiveType.STRING); customClasses().put("java.util.TimeZone", PrimitiveType.STRING); @@ -584,13 +581,10 @@ public SpringDocUtils initExtraSchemas() { * @return the spring doc utils */ public SpringDocUtils resetExtraSchemas() { - customClasses().remove("java.time.Duration"); - customClasses().remove("java.time.LocalTime"); customClasses().remove("java.time.YearMonth"); customClasses().remove("java.time.MonthDay"); customClasses().remove("java.time.Year"); customClasses().remove("java.time.Period"); - customClasses().remove("java.time.OffsetTime"); customClasses().remove("java.time.ZoneId"); customClasses().remove("java.time.ZoneOffset"); customClasses().remove("java.util.TimeZone"); diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app115/JavaTimeOperationCustomizer.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app115/JavaTimeOperationCustomizer.java index 32e9be4e5..0ed502e2f 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app115/JavaTimeOperationCustomizer.java +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app115/JavaTimeOperationCustomizer.java @@ -47,7 +47,6 @@ public Operation customize(Operation operation, HandlerMethod handlerMethod) { Content content = response.getContent(); if (content.containsKey(MediaType.APPLICATION_JSON_VALUE)) { Schema schema = content.get(MediaType.APPLICATION_JSON_VALUE).getSchema(); - schema.getProperties().clear(); schema.types(Set.of("string")); } } diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app115.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app115.json index 82ae0ddf5..b2728b733 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app115.json +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app115.json @@ -23,7 +23,8 @@ "content": { "application/json": { "schema": { - "type": "string" + "type": "string", + "format": "duration" } } } diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app115.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app115.json index f3685cb97..322ede3ed 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app115.json +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app115.json @@ -24,7 +24,7 @@ "application/json": { "schema": { "type": "string", - "properties": {} + "format": "duration" } } } diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app243.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app243.json index f332ddaea..444a30ca9 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app243.json +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app243.json @@ -56,7 +56,8 @@ "format": "date" }, "localTime": { - "type": "string" + "type": "string", + "format": "partial-time" }, "yearMonth": { "type": "string" diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app115/JavaTimeOperationCustomizer.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app115/JavaTimeOperationCustomizer.java index 544878a19..9034b691d 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app115/JavaTimeOperationCustomizer.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v30/app115/JavaTimeOperationCustomizer.java @@ -58,7 +58,6 @@ public Operation customize(Operation operation, HandlerMethod handlerMethod) { Content content = response.getContent(); if (content.containsKey(MediaType.APPLICATION_JSON_VALUE)) { Schema schema = content.get(MediaType.APPLICATION_JSON_VALUE).getSchema(); - schema.getProperties().clear(); schema.setType("string"); } } diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app115/JavaTimeOperationCustomizer.java b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app115/JavaTimeOperationCustomizer.java index e891dcec7..6bc73c4b5 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app115/JavaTimeOperationCustomizer.java +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/java/test/org/springdoc/api/v31/app115/JavaTimeOperationCustomizer.java @@ -60,7 +60,6 @@ public Operation customize(Operation operation, HandlerMethod handlerMethod) { if (content.containsKey(MediaType.APPLICATION_JSON_VALUE)) { Schema schema = content.get(MediaType.APPLICATION_JSON_VALUE).getSchema(); schema.types(Set.of("string")); - schema.getProperties().clear(); } } } diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.0.1/app115.json b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.0.1/app115.json index 174ff999c..cde841b58 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.0.1/app115.json +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.0.1/app115.json @@ -32,7 +32,7 @@ "application/json": { "schema": { "type": "string", - "properties": {} + "format": "duration" } } } diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.1.0/app115.json b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.1.0/app115.json index 052562581..0dc185f8f 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.1.0/app115.json +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/src/test/resources/results/3.1.0/app115.json @@ -32,7 +32,7 @@ "application/json": { "schema": { "type": "string", - "properties": {} + "format": "duration" } } } From c772cdc020fc4fc1bade5da8f4fb8ce51d3130fc Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 14:19:07 +0200 Subject: [PATCH 38/42] Record the swagger-core 2.2.54 upgrade in the changelog Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6543320f7..b79e9b36b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The Scalar starters no longer register forwarded-header handling.** Set `server.forward-headers-strategy=framework` (or `native`) behind a trusted proxy - Add `springdoc.cache.max-entries` (default `100`) to bound the per-locale OpenAPI cache - Document the security policy and the release versioning scheme -- Upgrade swagger-core to version **2.2.53** +- #3351 – `java.time.Duration`, `LocalTime` and `OffsetTime` are now resolved by swagger-core instead of being forced to a bare `string`, so they carry a `format` (`duration` and `partial-time` respectively for the first two) +- Upgrade swagger-core to version **2.2.54** - Upgrade swagger-ui to version **5.32.14** ### Fixed From 39968b35c5f75a919614d582ed79a27f40e33e64 Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 14:50:11 +0200 Subject: [PATCH 39/42] Upgrade swagger-core to 2.2.55 swagger-core now detects indirect Set implementations (swagger-api/swagger-core#5265), so a `LinkedHashSet` property is described with `uniqueItems: true`. Adjusted the Kotlin app3 expectation accordingly. The matching swagger-ui 5.32.15 bump is not included: org.webjars:swagger-ui is still only published up to 5.32.14 on Maven Central. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 ++- pom.xml | 2 +- .../src/test/resources/results/3.1.0/app3.json | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b79e9b36b..c9a68d231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `springdoc.cache.max-entries` (default `100`) to bound the per-locale OpenAPI cache - Document the security policy and the release versioning scheme - #3351 – `java.time.Duration`, `LocalTime` and `OffsetTime` are now resolved by swagger-core instead of being forced to a bare `string`, so they carry a `format` (`duration` and `partial-time` respectively for the first two) -- Upgrade swagger-core to version **2.2.54** +- A property whose type only implements `Set` indirectly (`LinkedHashSet`, `TreeSet`, …) is now described with `uniqueItems: true`, following [swagger-api/swagger-core#5265](https://github.com/swagger-api/swagger-core/pull/5265) +- Upgrade swagger-core to version **2.2.55** - Upgrade swagger-ui to version **5.32.14** ### Fixed diff --git a/pom.xml b/pom.xml index 388e6a6d5..cc7acb05c 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ 0.11.0 1.5.0 - 2.2.54 + 2.2.55 5.32.14 1.13.1 0.9.1 diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app3.json b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app3.json index 4dda47b5f..ae184f029 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app3.json +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/src/test/resources/results/3.1.0/app3.json @@ -96,7 +96,8 @@ "type": "array", "items": { "type": "string" - } + }, + "uniqueItems": true } }, "required": [ From bfd570320ebd90d7242886ff59c043afe617369c Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 15:58:35 +0200 Subject: [PATCH 40/42] docs: record the swagger-ui 5.32.14 upgrade as a security fix for 2.9.1 The bump from swagger-ui 5.32.11 to 5.32.14 addressed CVE-2026-75838, a cross-site scripting issue in the bundled DOMPurify, but was only listed under Changed. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9a68d231..1c1397c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.9.1] - 2026-09-05 +## [2.9.1] - 2026-09-06 ### Security - [GHSA-rhhx-6j8h-8cvw](https://github.com/springdoc/springdoc-openapi/security/advisories/GHSA-rhhx-6j8h-8cvw) – Unbounded per-locale OpenAPI cache allows memory exhaustion via `Accept-Language` - [GHSA-c925-vm88-mpp9](https://github.com/springdoc/springdoc-openapi/security/advisories/GHSA-c925-vm88-mpp9) – Scalar starters trust client-supplied forwarded headers and render from a shared mutable bean +- [CVE-2026-75838](https://github.com/advisories/GHSA-748c-f84h-hp2v) – Cross-site scripting in the DOMPurify bundled with swagger-ui, addressed by upgrading swagger-ui to **5.32.14** ### Added From 94190e9bb76d0c5ab0ee2d7346b8322a571e748b Mon Sep 17 00:00:00 2001 From: Badr NASS LAHSEN Date: Sun, 6 Sep 2026 17:12:47 +0200 Subject: [PATCH 41/42] ci: give the 2.x branch its own release workflow The workflow told you to run it from the branch you wanted to release, but it only ever existed on main, so 2.x could not be dispatched at all. Same file, minus the npm cache: there is no frontend module on this branch. Also drop the absolute path that a shell redirect prepended to the CHANGELOG heading. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 88 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 2 +- 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..82fc962e5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +# Cuts a release: tags it, publishes to Maven Central, bumps to the next SNAPSHOT. +# Run it from the branch you want to release (main or spring-boot-3). +name: Release + +on: + workflow_dispatch: + inputs: + releaseVersion: + description: 'Version to release, e.g. 2.9.1' + required: true + developmentVersion: + description: 'Next development version, e.g. 2.9.2-SNAPSHOT' + required: true + dryRun: + description: 'Rehearse only: no tag, no push, no publish' + type: boolean + default: false + +# release:prepare pushes the version-bump commits and the tag. +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + MAVEN_ARGS: -B --no-transfer-progress -Dmaven.artifact.threads=16 + MAVEN_OPTS: -Xmx3g + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + # release:prepare inspects history and tags. + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + cache: maven + server-id: central + server-username: MAVEN_USERNAME + server-password: MAVEN_PASSWORD + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + + - name: Configure git + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + # uses an SSH URL, and release:perform clones it fresh into + # target/checkout. The runner has no SSH key, so route it over HTTPS. + git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf 'git@github.com:' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Tests run once, here. The release build itself uses -DskipTests so the + # suite is not executed a third time (release:prepare verifies, then + # release:perform builds again from the tag). + - name: Test + run: mvn $MAVEN_ARGS -Pci -T1C test + + # -Pgpg is required, not optional: maven-release-plugin is declared inside + # the gpg profile, so without it the plugin falls back to the parent's + # pluginManagement version and none of its configuration applies — + # wrong tag name, no autoVersionSubmodules, and release:perform would + # deploy unsigned artifacts that Central rejects. + - name: Release + run: | + mvn $MAVEN_ARGS -Pci,gpg release:prepare release:perform \ + -DreleaseVersion='${{ inputs.releaseVersion }}' \ + -DdevelopmentVersion='${{ inputs.developmentVersion }}' \ + -DdryRun=${{ inputs.dryRun }} \ + -Darguments="-DskipTests" + env: + MAVEN_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Clean up a dry run + if: ${{ inputs.dryRun }} + run: mvn $MAVEN_ARGS -Pci,gpg release:clean diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c1397c8e..c7fc8a0dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -/Users/bnasslahsen/Documents/workspace/4.x/springdoc-openapi/CHANGELOG.md# Changelog +# Changelog All notable changes to this project will be documented in this file. From 1c534645ab8ee0d608d87b402154c4c92cc1c19b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 6 Sep 2026 16:11:36 +0000 Subject: [PATCH 42/42] [maven-release-plugin] prepare release v2.9.1 --- pom.xml | 4 ++-- springdoc-openapi-bom/pom.xml | 2 +- springdoc-openapi-starter-common/pom.xml | 2 +- springdoc-openapi-starter-webflux-api/pom.xml | 2 +- springdoc-openapi-starter-webflux-scalar/pom.xml | 2 +- springdoc-openapi-starter-webflux-ui/pom.xml | 2 +- springdoc-openapi-starter-webmvc-api/pom.xml | 2 +- springdoc-openapi-starter-webmvc-scalar/pom.xml | 2 +- springdoc-openapi-starter-webmvc-ui/pom.xml | 2 +- springdoc-openapi-tests/pom.xml | 2 +- .../springdoc-openapi-actuator-webflux-tests/pom.xml | 2 +- .../springdoc-openapi-actuator-webmvc-tests/pom.xml | 2 +- .../springdoc-openapi-data-rest-tests/pom.xml | 2 +- .../springdoc-openapi-function-webflux-tests/pom.xml | 2 +- .../springdoc-openapi-function-webmvc-tests/pom.xml | 2 +- .../springdoc-openapi-groovy-tests/pom.xml | 2 +- .../springdoc-openapi-hateoas-tests/pom.xml | 2 +- .../springdoc-openapi-javadoc-tests/pom.xml | 2 +- .../springdoc-openapi-kotlin-data-rest-tests/pom.xml | 2 +- .../springdoc-openapi-kotlin-webflux-tests/pom.xml | 2 +- .../springdoc-openapi-kotlin-webmvc-tests/pom.xml | 2 +- .../springdoc-openapi-security-tests/pom.xml | 2 +- 22 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pom.xml b/pom.xml index cc7acb05c..e0bc1102c 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 org.springdoc springdoc-openapi - 2.9.1-SNAPSHOT + 2.9.1 pom Spring openapi documentation Spring openapi documentation @@ -35,7 +35,7 @@ scm:git:git@github.com:springdoc/springdoc-openapi.git scm:git:git@github.com:springdoc/springdoc-openapi.git - HEAD + v2.9.1 diff --git a/springdoc-openapi-bom/pom.xml b/springdoc-openapi-bom/pom.xml index 860919b3f..1f449d1bd 100644 --- a/springdoc-openapi-bom/pom.xml +++ b/springdoc-openapi-bom/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.1-SNAPSHOT + 2.9.1 springdoc-openapi-bom ${project.artifactId} diff --git a/springdoc-openapi-starter-common/pom.xml b/springdoc-openapi-starter-common/pom.xml index c1a2cbcfd..092cd8248 100644 --- a/springdoc-openapi-starter-common/pom.xml +++ b/springdoc-openapi-starter-common/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.1-SNAPSHOT + 2.9.1 springdoc-openapi-starter-common ${project.artifactId} diff --git a/springdoc-openapi-starter-webflux-api/pom.xml b/springdoc-openapi-starter-webflux-api/pom.xml index 357ac0f3d..a9780f3ae 100644 --- a/springdoc-openapi-starter-webflux-api/pom.xml +++ b/springdoc-openapi-starter-webflux-api/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.1-SNAPSHOT + 2.9.1 springdoc-openapi-starter-webflux-api ${project.artifactId} diff --git a/springdoc-openapi-starter-webflux-scalar/pom.xml b/springdoc-openapi-starter-webflux-scalar/pom.xml index f2d487be6..37e54df7b 100644 --- a/springdoc-openapi-starter-webflux-scalar/pom.xml +++ b/springdoc-openapi-starter-webflux-scalar/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.1-SNAPSHOT + 2.9.1 springdoc-openapi-starter-webflux-scalar ${project.artifactId} diff --git a/springdoc-openapi-starter-webflux-ui/pom.xml b/springdoc-openapi-starter-webflux-ui/pom.xml index adeffec08..5b40686b9 100644 --- a/springdoc-openapi-starter-webflux-ui/pom.xml +++ b/springdoc-openapi-starter-webflux-ui/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.1-SNAPSHOT + 2.9.1 springdoc-openapi-starter-webflux-ui ${project.artifactId} diff --git a/springdoc-openapi-starter-webmvc-api/pom.xml b/springdoc-openapi-starter-webmvc-api/pom.xml index b0dfa7c5b..0eb0e9e61 100644 --- a/springdoc-openapi-starter-webmvc-api/pom.xml +++ b/springdoc-openapi-starter-webmvc-api/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.1-SNAPSHOT + 2.9.1 springdoc-openapi-starter-webmvc-api ${project.artifactId} diff --git a/springdoc-openapi-starter-webmvc-scalar/pom.xml b/springdoc-openapi-starter-webmvc-scalar/pom.xml index 2771a62e3..91eb7e61b 100644 --- a/springdoc-openapi-starter-webmvc-scalar/pom.xml +++ b/springdoc-openapi-starter-webmvc-scalar/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.1-SNAPSHOT + 2.9.1 springdoc-openapi-starter-webmvc-scalar ${project.artifactId} diff --git a/springdoc-openapi-starter-webmvc-ui/pom.xml b/springdoc-openapi-starter-webmvc-ui/pom.xml index 13ed99e98..e9ab4a7fd 100644 --- a/springdoc-openapi-starter-webmvc-ui/pom.xml +++ b/springdoc-openapi-starter-webmvc-ui/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi - 2.9.1-SNAPSHOT + 2.9.1 springdoc-openapi-starter-webmvc-ui ${project.artifactId} diff --git a/springdoc-openapi-tests/pom.xml b/springdoc-openapi-tests/pom.xml index 5eb6b93c6..71311181b 100644 --- a/springdoc-openapi-tests/pom.xml +++ b/springdoc-openapi-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi org.springdoc - 2.9.1-SNAPSHOT + 2.9.1 pom 4.0.0 diff --git a/springdoc-openapi-tests/springdoc-openapi-actuator-webflux-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-actuator-webflux-tests/pom.xml index 74170cc81..e30349770 100644 --- a/springdoc-openapi-tests/springdoc-openapi-actuator-webflux-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-actuator-webflux-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.9.1-SNAPSHOT + 2.9.1 4.0.0 diff --git a/springdoc-openapi-tests/springdoc-openapi-actuator-webmvc-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-actuator-webmvc-tests/pom.xml index 448f65f12..6cfde982c 100644 --- a/springdoc-openapi-tests/springdoc-openapi-actuator-webmvc-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-actuator-webmvc-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.9.1-SNAPSHOT + 2.9.1 4.0.0 diff --git a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/pom.xml index 72a918bc6..e08a1319b 100644 --- a/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-data-rest-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.9.1-SNAPSHOT + 2.9.1 4.0.0 springdoc-openapi-data-rest-tests diff --git a/springdoc-openapi-tests/springdoc-openapi-function-webflux-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-function-webflux-tests/pom.xml index fe470602a..284bfe799 100644 --- a/springdoc-openapi-tests/springdoc-openapi-function-webflux-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-function-webflux-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.9.1-SNAPSHOT + 2.9.1 4.0.0 diff --git a/springdoc-openapi-tests/springdoc-openapi-function-webmvc-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-function-webmvc-tests/pom.xml index a064f0213..f75ebb0e7 100644 --- a/springdoc-openapi-tests/springdoc-openapi-function-webmvc-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-function-webmvc-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.9.1-SNAPSHOT + 2.9.1 4.0.0 diff --git a/springdoc-openapi-tests/springdoc-openapi-groovy-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/pom.xml index c2e27a472..7834434f1 100644 --- a/springdoc-openapi-tests/springdoc-openapi-groovy-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-groovy-tests/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi-tests - 2.9.1-SNAPSHOT + 2.9.1 springdoc-openapi-groovy-tests ${project.artifactId} diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/pom.xml index ff7e8edd4..7f7708776 100644 --- a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.9.1-SNAPSHOT + 2.9.1 4.0.0 springdoc-openapi-hateoas-tests diff --git a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/pom.xml index 9f0654848..e481e2287 100644 --- a/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-javadoc-tests/pom.xml @@ -2,7 +2,7 @@ org.springdoc springdoc-openapi-tests - 2.9.1-SNAPSHOT + 2.9.1 4.0.0 diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/pom.xml index cb459e5d1..51c8a38ca 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-data-rest-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.9.1-SNAPSHOT + 2.9.1 4.0.0 springdoc-openapi-kotlin-data-rest-tests diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webflux-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-kotlin-webflux-tests/pom.xml index 9842aa29c..1743929c4 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webflux-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webflux-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.9.1-SNAPSHOT + 2.9.1 4.0.0 springdoc-openapi-kotlin-webflux-tests diff --git a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/pom.xml index 2e19d52d7..2acdd6267 100644 --- a/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-kotlin-webmvc-tests/pom.xml @@ -2,7 +2,7 @@ springdoc-openapi-tests org.springdoc - 2.9.1-SNAPSHOT + 2.9.1 4.0.0 springdoc-openapi-kotlin-webmvc-tests diff --git a/springdoc-openapi-tests/springdoc-openapi-security-tests/pom.xml b/springdoc-openapi-tests/springdoc-openapi-security-tests/pom.xml index 51248977b..1e96f9e1d 100644 --- a/springdoc-openapi-tests/springdoc-openapi-security-tests/pom.xml +++ b/springdoc-openapi-tests/springdoc-openapi-security-tests/pom.xml @@ -3,7 +3,7 @@ org.springdoc springdoc-openapi-tests - 2.9.1-SNAPSHOT + 2.9.1 springdoc-openapi-security-tests ${project.artifactId}