Skip to content

Commit a33aaa1

Browse files
but don't keep nullable if that happens somehow
1 parent f485ba7 commit a33aaa1

11 files changed

Lines changed: 526 additions & 0 deletions

File tree

springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,6 +1297,8 @@ private PathItem buildPathItem(RequestMethod requestMethod, Operation operation,
12971297
String name = parameter.getName();
12981298
if (!StringUtils.containsAny(operationPath, "{" + name + "}", "{*" + name + "}"))
12991299
paramIt.remove();
1300+
else
1301+
SpringDocUtils.fixNullablePathParameter(parameter);
13001302
}
13011303
}
13021304
}

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SpringDocUtils.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import io.swagger.v3.oas.models.media.ComposedSchema;
4141
import io.swagger.v3.oas.models.media.Content;
4242
import io.swagger.v3.oas.models.media.Schema;
43+
import io.swagger.v3.oas.models.parameters.Parameter;
4344
import org.apache.commons.lang3.ArrayUtils;
4445
import org.apache.commons.lang3.StringUtils;
4546
import org.jetbrains.annotations.NotNull;
@@ -215,6 +216,30 @@ else if (types == null && "null".equals(addPropSchema.getType())) {
215216
}
216217
}
217218

219+
/**
220+
* Removes nullability from a path parameter's schema. A path parameter is always
221+
* required and can never be {@code null}, so a nullable schema (e.g. propagated from a
222+
* JSpecify {@code @Nullable} annotation on a backing {@code @ParameterObject} field that
223+
* is reused as both a path and an optional query parameter) is invalid here.
224+
*
225+
* @param parameter the path parameter
226+
*/
227+
public static void fixNullablePathParameter(Parameter parameter) {
228+
Schema<?> schema = parameter.getSchema();
229+
if (schema == null)
230+
return;
231+
Set<String> types = schema.getTypes();
232+
if (types != null) {
233+
types.remove("null");
234+
if (types.isEmpty())
235+
schema.setTypes(null);
236+
}
237+
if ("null".equals(schema.getType()))
238+
schema.setType(null);
239+
if (Boolean.TRUE.equals(schema.getNullable()))
240+
schema.setNullable(null);
241+
}
242+
218243
/**
219244
* Handle schema types.
220245
*

springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,39 @@ springDocProviders, new SpringDocCustomizers(Optional.empty(), Optional.empty(),
188188
assertThat(parameterWithoutSchema.getIn(), is(ParameterIn.QUERY.toString()));
189189
}
190190

191+
@Test
192+
void removesNullableFromPathParameterSchema() {
193+
resource = new EmptyPathsOpenApiResource(
194+
GROUP_NAME,
195+
openAPIBuilderObjectFactory,
196+
requestBuilder,
197+
responseBuilder,
198+
operationParser,
199+
new SpringDocConfigProperties(),
200+
springDocProviders, new SpringDocCustomizers(Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())
201+
);
202+
203+
final String pathParamName = "clinicId";
204+
final Parameter nullablePathParameter = new Parameter()
205+
.name(pathParamName)
206+
.in(ParameterIn.PATH.toString())
207+
.schema(new StringSchema().nullable(true));
208+
209+
final Operation operation = new Operation();
210+
operation.setParameters(singletonList(nullablePathParameter));
211+
212+
final RouterOperation routerOperation = new RouterOperation();
213+
routerOperation.setMethods(new RequestMethod[] { GET });
214+
routerOperation.setOperationModel(operation);
215+
routerOperation.setPath(PATH + "/{" + pathParamName + "}");
216+
217+
resource.calculatePath(routerOperation, Locale.getDefault(), this.openAPI);
218+
219+
final Parameter pathParameter = resource.getOpenApi(null, Locale.getDefault())
220+
.getPaths().get(PATH + "/{" + pathParamName + "}").getGet().getParameters().get(0);
221+
assertThat(pathParameter.getSchema().getNullable(), nullValue());
222+
}
223+
191224
@Test
192225
void preLoadingModeShouldNotOverwriteServers() throws InterruptedException {
193226
doCallRealMethod().when(openAPIService).updateServers(any(), any());
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * *
7+
* * * * * * Copyright 2019-2026 the original author or authors.
8+
* * * * * *
9+
* * * * * * Licensed under the Apache License, Version 2.0 (the "License");
10+
* * * * * * you may not use this file except in compliance with the License.
11+
* * * * * * You may obtain a copy of the License at
12+
* * * * * *
13+
* * * * * * https://www.apache.org/licenses/LICENSE-2.0
14+
* * * * * *
15+
* * * * * * Unless required by applicable law or agreed to in writing, software
16+
* * * * * * distributed under the License is distributed on an "AS IS" BASIS,
17+
* * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* * * * * * See the License for the specific language governing permissions and
19+
* * * * * * limitations under the License.
20+
* * * * *
21+
* * * *
22+
* * *
23+
* *
24+
*
25+
*/
26+
27+
package test.org.springdoc.api.v30.app176;
28+
29+
import io.swagger.v3.oas.annotations.Parameter;
30+
import io.swagger.v3.oas.annotations.enums.ParameterIn;
31+
import org.springdoc.core.annotations.ParameterObject;
32+
33+
import org.springframework.web.bind.annotation.GetMapping;
34+
import org.springframework.web.bind.annotation.RestController;
35+
36+
@RestController
37+
class HelloController {
38+
39+
@GetMapping("/clinics/{clinicId}/vets")
40+
@Parameter(name = "clinicId", in = ParameterIn.PATH)
41+
public void find(@ParameterObject SearchCriteria searchCriteria) {
42+
}
43+
44+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * *
7+
* * * * * * Copyright 2019-2026 the original author or authors.
8+
* * * * * *
9+
* * * * * * Licensed under the Apache License, Version 2.0 (the "License");
10+
* * * * * * you may not use this file except in compliance with the License.
11+
* * * * * * You may obtain a copy of the License at
12+
* * * * * *
13+
* * * * * * https://www.apache.org/licenses/LICENSE-2.0
14+
* * * * * *
15+
* * * * * * Unless required by applicable law or agreed to in writing, software
16+
* * * * * * distributed under the License is distributed on an "AS IS" BASIS,
17+
* * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* * * * * * See the License for the specific language governing permissions and
19+
* * * * * * limitations under the License.
20+
* * * * *
21+
* * * *
22+
* * *
23+
* *
24+
*
25+
*/
26+
27+
package test.org.springdoc.api.v30.app176;
28+
29+
import java.lang.annotation.ElementType;
30+
import java.lang.annotation.Retention;
31+
import java.lang.annotation.RetentionPolicy;
32+
import java.lang.annotation.Target;
33+
34+
@Target(ElementType.TYPE_USE)
35+
@Retention(RetentionPolicy.RUNTIME)
36+
@interface Nullable {
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * *
7+
* * * * * * Copyright 2019-2026 the original author or authors.
8+
* * * * * *
9+
* * * * * * Licensed under the Apache License, Version 2.0 (the "License");
10+
* * * * * * you may not use this file except in compliance with the License.
11+
* * * * * * You may obtain a copy of the License at
12+
* * * * * *
13+
* * * * * * https://www.apache.org/licenses/LICENSE-2.0
14+
* * * * * *
15+
* * * * * * Unless required by applicable law or agreed to in writing, software
16+
* * * * * * distributed under the License is distributed on an "AS IS" BASIS,
17+
* * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* * * * * * See the License for the specific language governing permissions and
19+
* * * * * * limitations under the License.
20+
* * * * *
21+
* * * *
22+
* * *
23+
* *
24+
*
25+
*/
26+
27+
package test.org.springdoc.api.v30.app176;
28+
29+
import io.swagger.v3.oas.annotations.Parameter;
30+
31+
/**
32+
* A parameter object whose {@code clinicId} field is reused as both an optional, nullable
33+
* query parameter and a (required, non-null) path parameter, depending on the controller.
34+
*/
35+
class SearchCriteria {
36+
37+
@Parameter(description = "Find vets affiliated with this clinic id.")
38+
private @Nullable String clinicId;
39+
40+
@Parameter(description = "Find vets with this name.")
41+
private @Nullable String name;
42+
43+
public String getClinicId() {
44+
return clinicId;
45+
}
46+
47+
public void setClinicId(String clinicId) {
48+
this.clinicId = clinicId;
49+
}
50+
51+
public String getName() {
52+
return name;
53+
}
54+
55+
public void setName(String name) {
56+
this.name = name;
57+
}
58+
59+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * *
7+
* * * * * * Copyright 2019-2026 the original author or authors.
8+
* * * * * *
9+
* * * * * * Licensed under the Apache License, Version 2.0 (the "License");
10+
* * * * * * you may not use this file except in compliance with the License.
11+
* * * * * * You may obtain a copy of the License at
12+
* * * * * *
13+
* * * * * * https://www.apache.org/licenses/LICENSE-2.0
14+
* * * * * *
15+
* * * * * * Unless required by applicable law or agreed to in writing, software
16+
* * * * * * distributed under the License is distributed on an "AS IS" BASIS,
17+
* * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* * * * * * See the License for the specific language governing permissions and
19+
* * * * * * limitations under the License.
20+
* * * * *
21+
* * * *
22+
* * *
23+
* *
24+
*
25+
*/
26+
27+
package test.org.springdoc.api.v30.app176;
28+
29+
import com.jayway.jsonpath.JsonPath;
30+
import net.minidev.json.JSONArray;
31+
import org.junit.jupiter.api.Test;
32+
import org.springdoc.core.utils.Constants;
33+
34+
import org.springframework.beans.factory.annotation.Autowired;
35+
import org.springframework.boot.autoconfigure.SpringBootApplication;
36+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
37+
import org.springframework.boot.test.context.SpringBootTest;
38+
import org.springframework.test.context.ActiveProfiles;
39+
import org.springframework.test.context.TestPropertySource;
40+
import org.springframework.test.web.servlet.MockMvc;
41+
import org.springframework.test.web.servlet.MvcResult;
42+
43+
import static org.assertj.core.api.Assertions.assertThat;
44+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
45+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
46+
47+
/**
48+
* Verifies that {@code nullable: true} (propagated from a TYPE_USE {@code @Nullable}
49+
* annotation on a {@code @ParameterObject} field under OpenAPI 3.0) is cleared when that
50+
* field is reused as a path parameter, while it is preserved for query parameters.
51+
*/
52+
@ActiveProfiles("test")
53+
@SpringBootTest
54+
@AutoConfigureMockMvc
55+
@TestPropertySource(properties = "springdoc.api-docs.version=openapi_3_0")
56+
class SpringDocApp176Test {
57+
58+
private static final String PATH = "$.paths.['/clinics/{clinicId}/vets'].get.parameters";
59+
60+
@Autowired
61+
protected MockMvc mockMvc;
62+
63+
private static Object readSingle(String result, String jsonPath) {
64+
return ((JSONArray) JsonPath.parse(result).read(jsonPath)).get(0);
65+
}
66+
67+
@Test
68+
void pathParameterIsNotNullableButQueryParameterIs() throws Exception {
69+
MvcResult mockMvcResult = mockMvc.perform(get(Constants.DEFAULT_API_DOCS_URL))
70+
.andExpect(status().isOk()).andReturn();
71+
String result = mockMvcResult.getResponse().getContentAsString();
72+
73+
// A path parameter is always required and can never be null.
74+
assertThat(readSingle(result, PATH + "[?(@.name == 'clinicId')].required"))
75+
.isEqualTo(Boolean.TRUE);
76+
assertThat(readSingle(result, PATH + "[?(@.name == 'clinicId')].schema.type"))
77+
.isEqualTo("string");
78+
assertThat((JSONArray) JsonPath.parse(result).read(PATH + "[?(@.name == 'clinicId')].schema.nullable"))
79+
.isEmpty();
80+
81+
// A nullable query parameter keeps nullable: true.
82+
assertThat(readSingle(result, PATH + "[?(@.name == 'name')].required"))
83+
.isEqualTo(Boolean.FALSE);
84+
assertThat(readSingle(result, PATH + "[?(@.name == 'name')].schema.type"))
85+
.isEqualTo("string");
86+
assertThat(readSingle(result, PATH + "[?(@.name == 'name')].schema.nullable"))
87+
.isEqualTo(Boolean.TRUE);
88+
}
89+
90+
@SpringBootApplication
91+
static class SpringDocTestApp {
92+
}
93+
94+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * *
7+
* * * * * * Copyright 2019-2026 the original author or authors.
8+
* * * * * *
9+
* * * * * * Licensed under the Apache License, Version 2.0 (the "License");
10+
* * * * * * you may not use this file except in compliance with the License.
11+
* * * * * * You may obtain a copy of the License at
12+
* * * * * *
13+
* * * * * * https://www.apache.org/licenses/LICENSE-2.0
14+
* * * * * *
15+
* * * * * * Unless required by applicable law or agreed to in writing, software
16+
* * * * * * distributed under the License is distributed on an "AS IS" BASIS,
17+
* * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* * * * * * See the License for the specific language governing permissions and
19+
* * * * * * limitations under the License.
20+
* * * * *
21+
* * * *
22+
* * *
23+
* *
24+
*
25+
*/
26+
27+
package test.org.springdoc.api.v31.app176;
28+
29+
import io.swagger.v3.oas.annotations.Parameter;
30+
import io.swagger.v3.oas.annotations.enums.ParameterIn;
31+
import org.springdoc.core.annotations.ParameterObject;
32+
33+
import org.springframework.web.bind.annotation.GetMapping;
34+
import org.springframework.web.bind.annotation.RestController;
35+
36+
@RestController
37+
class HelloController {
38+
39+
@GetMapping("/clinics/{clinicId}/vets")
40+
@Parameter(name = "clinicId", in = ParameterIn.PATH)
41+
public void find(@ParameterObject SearchCriteria searchCriteria) {
42+
}
43+
44+
}

0 commit comments

Comments
 (0)