Skip to content

Commit f05b1fc

Browse files
committed
Merge backport of #3323/#3321, #3317 and the Spring Data REST response fixes
Backports to the Spring Boot 3 line: - #3323 / #3321 - login endpoint example values - #3317 - ignore an injected HttpHeaders parameter - the shared-schema fix on top of the #3136 backport
2 parents 998a254 + 38acf19 commit f05b1fc

29 files changed

Lines changed: 1797 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
- #3340 – Describe `JsonNullable` values without their Java wrapper
1818
- #3325 – Manage the swagger artifacts in `springdoc-openapi-bom`, so that modules holding only the annotations stay in lockstep
19+
- #3321 – Add `springdoc.login-endpoint.username-example` and `springdoc.login-endpoint.password-example` to document the Spring Security login endpoint
1920

2021
### Changed
2122

@@ -39,6 +40,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3940
- #3338 – Kotlin nullability interpretation of the `Any?` type
4041
- #3332 – The properties a Kotlin entity inherits from an `@Embeddable` are missing from the Spring Data REST schemas
4142
- #3136 – A Spring Data REST association to a non-exported entity expands its `@EmbeddedId` and `@MapsId` fields recursively in the response schemas
43+
- 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
44+
- #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
45+
- Harden the Spring Data REST response post-processing against an `_embedded` schema that carries no properties
4246

4347
## [2.9.0] - 2026-07-31
4448

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/configuration/SpringDocSecurityConfiguration.java

Lines changed: 85 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
import org.springdoc.core.configuration.hints.SpringDocSecurityHints;
4646
import org.springdoc.core.customizers.GlobalOpenApiCustomizer;
4747
import org.springdoc.core.customizers.OpenApiCustomizer;
48+
import org.springdoc.core.properties.SpringDocConfigProperties;
49+
import org.springdoc.core.properties.SpringDocConfigProperties.LoginEndpoint;
4850

4951
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
5052
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -113,14 +115,18 @@ class SpringSecurityLoginEndpointConfiguration {
113115
/**
114116
* Spring security login endpoint customiser open api customiser.
115117
*
116-
* @param applicationContext the application context
118+
* @param applicationContext the application context
119+
* @param springDocConfigProperties the springdoc configuration properties
117120
* @return the open api customiser
118121
*/
119122
@Bean
120123
@ConditionalOnProperty(SPRINGDOC_SHOW_LOGIN_ENDPOINT)
121124
@Lazy(false)
122-
OpenApiCustomizer springSecurityLoginEndpointCustomizer(ApplicationContext applicationContext) {
125+
OpenApiCustomizer springSecurityLoginEndpointCustomizer(ApplicationContext applicationContext, SpringDocConfigProperties springDocConfigProperties) {
123126
FilterChainProxy filterChainProxy = applicationContext.getBean(AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME, FilterChainProxy.class);
127+
LoginEndpoint loginEndpoint = springDocConfigProperties.getLoginEndpoint();
128+
String usernameExample = loginEndpoint.getUsernameExample();
129+
String passwordExample = loginEndpoint.getPasswordExample();
124130
return openAPI -> {
125131
for (SecurityFilterChain filterChain : filterChainProxy.getFilterChains()) {
126132
Optional<UsernamePasswordAuthenticationFilter> optionalFilter =
@@ -135,29 +141,8 @@ OpenApiCustomizer springSecurityLoginEndpointCustomizer(ApplicationContext appli
135141
.findAny();
136142
if (optionalFilter.isPresent()) {
137143
UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter = optionalFilter.get();
138-
Operation operation = new Operation();
139-
Schema<?> schema = new ObjectSchema()
140-
.addProperty(usernamePasswordAuthenticationFilter.getUsernameParameter(), new StringSchema())
141-
.addProperty(usernamePasswordAuthenticationFilter.getPasswordParameter(), new StringSchema());
142-
String mediaType = org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
143-
if (optionalDefaultLoginPageGeneratingFilter.isPresent()) {
144-
DefaultLoginPageGeneratingFilter defaultLoginPageGeneratingFilter = optionalDefaultLoginPageGeneratingFilter.get();
145-
try {
146-
boolean formLoginEnabled = (boolean) FieldUtils.readDeclaredField(defaultLoginPageGeneratingFilter, "formLoginEnabled", true);
147-
if (formLoginEnabled)
148-
mediaType = org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE;
149-
}
150-
catch (IllegalAccessException e) {
151-
LOGGER.warn(e.getMessage());
152-
}
153-
}
154-
RequestBody requestBody = new RequestBody().content(new Content().addMediaType(mediaType, new MediaType().schema(schema)));
155-
operation.requestBody(requestBody);
156-
ApiResponses apiResponses = new ApiResponses();
157-
apiResponses.addApiResponse(String.valueOf(HttpStatus.OK.value()), new ApiResponse().description(HttpStatus.OK.getReasonPhrase()));
158-
apiResponses.addApiResponse(String.valueOf(HttpStatus.UNAUTHORIZED.value()), new ApiResponse().description(HttpStatus.UNAUTHORIZED.getReasonPhrase()));
159-
operation.responses(apiResponses);
160-
operation.addTagsItem("login-endpoint");
144+
String mediaType = resolveMediaType(optionalDefaultLoginPageGeneratingFilter);
145+
Operation operation = buildOperation(usernamePasswordAuthenticationFilter, mediaType, usernameExample, passwordExample);
161146
PathItem pathItem = new PathItem().post(operation);
162147
try {
163148
RequestMatcher requestMatcher = (RequestMatcher) FieldUtils.readField(
@@ -191,6 +176,81 @@ else if (requestMatcher instanceof PathPatternRequestMatcher) {
191176
}
192177
};
193178
}
179+
180+
/**
181+
* Resolves the request body media type based on the presence of a form login configuration.
182+
*
183+
* @param optionalDefaultLoginPageGeneratingFilter the optional default login page generating filter
184+
* @return the resolved media type
185+
*/
186+
private String resolveMediaType(Optional<DefaultLoginPageGeneratingFilter> optionalDefaultLoginPageGeneratingFilter) {
187+
String mediaType = org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
188+
if (optionalDefaultLoginPageGeneratingFilter.isPresent()) {
189+
DefaultLoginPageGeneratingFilter defaultLoginPageGeneratingFilter = optionalDefaultLoginPageGeneratingFilter.get();
190+
try {
191+
boolean formLoginEnabled = (boolean) FieldUtils.readDeclaredField(defaultLoginPageGeneratingFilter, "formLoginEnabled", true);
192+
if (formLoginEnabled)
193+
mediaType = org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE;
194+
}
195+
catch (IllegalAccessException e) {
196+
LOGGER.warn(e.getMessage());
197+
}
198+
}
199+
return mediaType;
200+
}
201+
202+
/**
203+
* Builds the login endpoint operation.
204+
*
205+
* @param usernamePasswordAuthenticationFilter the username password authentication filter
206+
* @param mediaType the request body media type
207+
* @param usernameExample the username example value
208+
* @param passwordExample the password example value
209+
* @return the operation
210+
*/
211+
private Operation buildOperation(UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter,
212+
String mediaType, String usernameExample, String passwordExample) {
213+
Operation operation = new Operation();
214+
operation.requestBody(buildRequestBody(usernamePasswordAuthenticationFilter, mediaType, usernameExample, passwordExample));
215+
operation.responses(buildApiResponses());
216+
operation.addTagsItem("login-endpoint");
217+
return operation;
218+
}
219+
220+
/**
221+
* Builds the request body for the login endpoint operation.
222+
*
223+
* @param usernamePasswordAuthenticationFilter the username password authentication filter
224+
* @param mediaType the request body media type
225+
* @param usernameExample the username example value
226+
* @param passwordExample the password example value
227+
* @return the request body
228+
*/
229+
private RequestBody buildRequestBody(UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter,
230+
String mediaType, String usernameExample, String passwordExample) {
231+
StringSchema usernameSchema = new StringSchema();
232+
if (usernameExample != null)
233+
usernameSchema.example(usernameExample);
234+
StringSchema passwordSchema = new StringSchema();
235+
if (passwordExample != null)
236+
passwordSchema.example(passwordExample);
237+
Schema<?> schema = new ObjectSchema()
238+
.addProperty(usernamePasswordAuthenticationFilter.getUsernameParameter(), usernameSchema)
239+
.addProperty(usernamePasswordAuthenticationFilter.getPasswordParameter(), passwordSchema);
240+
return new RequestBody().content(new Content().addMediaType(mediaType, new MediaType().schema(schema)));
241+
}
242+
243+
/**
244+
* Builds the API responses for the login endpoint operation.
245+
*
246+
* @return the api responses
247+
*/
248+
private ApiResponses buildApiResponses() {
249+
ApiResponses apiResponses = new ApiResponses();
250+
apiResponses.addApiResponse(String.valueOf(HttpStatus.OK.value()), new ApiResponse().description(HttpStatus.OK.getReasonPhrase()));
251+
apiResponses.addApiResponse(String.valueOf(HttpStatus.UNAUTHORIZED.value()), new ApiResponse().description(HttpStatus.UNAUTHORIZED.getReasonPhrase()));
252+
return apiResponses;
253+
}
194254
}
195255

196256
/**

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/properties/SpringDocConfigProperties.java

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,11 @@ public class SpringDocConfigProperties {
168168
*/
169169
private boolean showLoginEndpoint;
170170

171+
/**
172+
* The login endpoint configuration.
173+
*/
174+
private LoginEndpoint loginEndpoint = new LoginEndpoint();
175+
171176
/**
172177
* Allow for pre-loading OpenAPI
173178
*/
@@ -749,6 +754,24 @@ public void setShowLoginEndpoint(boolean showLoginEndpoint) {
749754
this.showLoginEndpoint = showLoginEndpoint;
750755
}
751756

757+
/**
758+
* Gets login endpoint.
759+
*
760+
* @return the login endpoint
761+
*/
762+
public LoginEndpoint getLoginEndpoint() {
763+
return loginEndpoint;
764+
}
765+
766+
/**
767+
* Sets login endpoint.
768+
*
769+
* @param loginEndpoint the login endpoint
770+
*/
771+
public void setLoginEndpoint(LoginEndpoint loginEndpoint) {
772+
this.loginEndpoint = loginEndpoint;
773+
}
774+
752775
/**
753776
* Gets packages to scan.
754777
*
@@ -1984,4 +2007,61 @@ public int hashCode() {
19842007
return Objects.hash(group);
19852008
}
19862009
}
2010+
2011+
/**
2012+
* The type Login endpoint.
2013+
* <p>
2014+
* These settings only take effect when the login endpoint is exposed, i.e. when
2015+
* {@code springdoc.show-login-endpoint=true}. Otherwise, they are ignored.
2016+
*/
2017+
public static class LoginEndpoint {
2018+
2019+
/**
2020+
* The example value for the username field of the login request body.
2021+
* Only applied when {@code springdoc.show-login-endpoint=true}.
2022+
*/
2023+
private String usernameExample;
2024+
2025+
/**
2026+
* The example value for the password field of the login request body.
2027+
* Only applied when {@code springdoc.show-login-endpoint=true}.
2028+
*/
2029+
private String passwordExample;
2030+
2031+
/**
2032+
* Gets username example.
2033+
*
2034+
* @return the username example
2035+
*/
2036+
public String getUsernameExample() {
2037+
return usernameExample;
2038+
}
2039+
2040+
/**
2041+
* Sets username example.
2042+
*
2043+
* @param usernameExample the username example
2044+
*/
2045+
public void setUsernameExample(String usernameExample) {
2046+
this.usernameExample = usernameExample;
2047+
}
2048+
2049+
/**
2050+
* Gets password example.
2051+
*
2052+
* @return the password example
2053+
*/
2054+
public String getPasswordExample() {
2055+
return passwordExample;
2056+
}
2057+
2058+
/**
2059+
* Sets password example.
2060+
*
2061+
* @param passwordExample the password example
2062+
*/
2063+
public void setPasswordExample(String passwordExample) {
2064+
this.passwordExample = passwordExample;
2065+
}
2066+
}
19872067
}

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575

7676
import org.springframework.core.MethodParameter;
7777
import org.springframework.core.annotation.AnnotatedElementUtils;
78+
import org.springframework.http.HttpHeaders;
7879
import org.springframework.http.HttpMethod;
7980
import org.springframework.ui.Model;
8081
import org.springframework.ui.ModelMap;
@@ -122,6 +123,7 @@ public abstract class AbstractRequestService {
122123
PARAM_TYPES_TO_IGNORE.add(NativeWebRequest.class);
123124
PARAM_TYPES_TO_IGNORE.add(Principal.class);
124125
PARAM_TYPES_TO_IGNORE.add(HttpMethod.class);
126+
PARAM_TYPES_TO_IGNORE.add(HttpHeaders.class);
125127
PARAM_TYPES_TO_IGNORE.add(Locale.class);
126128
PARAM_TYPES_TO_IGNORE.add(TimeZone.class);
127129
PARAM_TYPES_TO_IGNORE.add(InputStream.class);

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import io.swagger.v3.core.converter.AnnotatedType;
4040
import io.swagger.v3.core.converter.ModelConverters;
4141
import io.swagger.v3.core.converter.ResolvedSchema;
42+
import io.swagger.v3.core.util.AnnotationsUtils;
4243
import io.swagger.v3.oas.models.Components;
4344
import io.swagger.v3.oas.models.OpenAPI;
4445
import io.swagger.v3.oas.models.PathItem;
@@ -288,7 +289,11 @@ else if (EMBEDDED.equals(propId)) {
288289
updateResponseSchemaEmbedded(components, entityInfo, entry, openapi31);
289290
}
290291
else if (allAssociationsFieldsMap.getOrDefault(className, Collections.emptySet()).contains(propId)) {
291-
updateResponseSchemaProperty(entry.getValue(), components, openapi31);
292+
// the property schema may be shared with the request body representation,
293+
// so rewrite a copy of it instead of the resolved instance
294+
Schema propertyCopy = AnnotationsUtils.clone(entry.getValue(), openapi31);
295+
updateResponseSchemaProperty(propertyCopy, components, openapi31);
296+
entry.setValue(propertyCopy);
292297
}
293298
}
294299
}
@@ -334,15 +339,16 @@ else if (property.getItems() != null) {
334339
*/
335340
private void updateResponseSchemaEmbedded(Components components, EntityInfo entityInfo, Entry<String, Schema> entry, boolean openapi31) {
336341
String entityClassName = linkRelationProvider.getCollectionResourceRelFor(entityInfo.getDomainType()).value();
342+
Map<String, Schema> embeddedProperties = entry.getValue().getProperties();
343+
if (CollectionUtils.isEmpty(embeddedProperties))
344+
return;
337345
Schema itemsSchema = null;
338346
if (openapi31) {
339-
JsonSchema jsonSchema = (JsonSchema) entry.getValue().getProperties().get(entityClassName);
340-
if (jsonSchema != null)
347+
if (embeddedProperties.get(entityClassName) instanceof JsonSchema jsonSchema)
341348
itemsSchema = jsonSchema.getItems();
342349
}
343350
else {
344-
ArraySchema arraySchema = (ArraySchema) entry.getValue().getProperties().get(entityClassName);
345-
if (arraySchema != null)
351+
if (embeddedProperties.get(entityClassName) instanceof ArraySchema arraySchema)
346352
itemsSchema = arraySchema.getItems();
347353
}
348354
if (itemsSchema != null) {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2019-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package test.org.springdoc.api.v30.app272;
18+
19+
import org.springframework.http.HttpHeaders;
20+
import org.springframework.web.bind.annotation.GetMapping;
21+
import org.springframework.web.bind.annotation.RequestHeader;
22+
import org.springframework.web.bind.annotation.RequestParam;
23+
import org.springframework.web.bind.annotation.RestController;
24+
25+
/**
26+
* A controller that asks for every request header at once.
27+
*
28+
* @author bnasslahsen
29+
*/
30+
@RestController
31+
public class HelloController {
32+
33+
@GetMapping("/hello")
34+
public String hello(@RequestHeader HttpHeaders headers, @RequestParam String name) {
35+
return name;
36+
}
37+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2019-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package test.org.springdoc.api.v30.app272;
18+
19+
import test.org.springdoc.api.v30.AbstractSpringDocV30Test;
20+
21+
import org.springframework.boot.autoconfigure.SpringBootApplication;
22+
23+
/**
24+
* An injected HttpHeaders is a request wrapper, not a schema.
25+
*
26+
* @author bnasslahsen
27+
*/
28+
public class SpringDocApp272Test extends AbstractSpringDocV30Test {
29+
30+
@SpringBootApplication
31+
static class SpringDocTestApp {
32+
}
33+
}

0 commit comments

Comments
 (0)