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/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-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/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/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 { + } + +} 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": {} +}