From a7c3e3c869fcf140a56115b8aa9ace33871179dd Mon Sep 17 00:00:00 2001 From: Taha El Amine Kassabi Date: Tue, 15 Sep 2026 16:24:58 +0200 Subject: [PATCH] Mark PagedModel properties as required Spring Data's PagedModel is the preferred stable JSON representation for Page responses. Reflect its guaranteed content, page, and page metadata properties in generated OpenAPI schemas. Co-Authored-By: Codex GPT-5 --- .../core/converters/PageOpenAPIConverter.java | 37 +++++++++++++++++++ .../v31/app10/SpringDocApp10DirectTest.java | 5 +++ .../resources/results/3.0.1/app10-direct.json | 26 +++++++++++-- .../results/3.0.1/app10-via_dto.json | 32 +++++++++++++--- .../resources/results/3.1.0/app10-direct.json | 26 +++++++++++-- .../results/3.1.0/app10-via_dto.json | 26 +++++++++++-- 6 files changed, 135 insertions(+), 17 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 a761a1c93..66eb13364 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 @@ -47,6 +47,7 @@ * The Spring Data Page type model converter. * * @author Claudio Nave + * @author dpkass */ public class PageOpenAPIConverter implements ModelConverter { @@ -77,6 +78,15 @@ public class PageOpenAPIConverter implements ModelConverter { "empty" ); + private static final List PAGED_MODEL_REQUIRED_PROPERTIES = List.of("content", "page"); + + private static final List PAGE_METADATA_REQUIRED_PROPERTIES = List.of( + "size", + "number", + "totalElements", + "totalPages" + ); + /** * The Spring doc object mapper. */ @@ -110,9 +120,11 @@ public PageOpenAPIConverter(boolean replacePageWithPagedModel, ObjectMapperProvi public Schema resolve(AnnotatedType type, ModelConverterContext context, Iterator chain) { JavaType javaType = springDocObjectMapper.jsonMapper().constructType(type.getType()); boolean isPageType = false; + boolean isPagedModelType = false; if (javaType != null) { Class cls = javaType.getRawClass(); isPageType = PAGE_TO_REPLACE.equals(cls.getCanonicalName()); + isPagedModelType = PagedModel.class.isAssignableFrom(cls); if (replacePageWithPagedModel && isPageType) { if (!type.isSchemaProperty()) type = resolvePagedModelType(javaType, type); @@ -124,6 +136,8 @@ public Schema resolve(AnnotatedType type, ModelConverterContext context, Iterato if (isPageType && !replacePageWithPagedModel) sortPageSchemaProperties(schema, context); + else if (isPagedModelType || isPageType) + requirePagedModelProperties(schema, context); return schema; } @@ -181,6 +195,29 @@ private void sortPageSchemaProperties(Schema schema, ModelConverterContext conte pageSchema.setProperties(sortedProperties); } + /** + * Require properties emitted by Spring Data's stable page representation. + * + * @param schema the schema + * @param context the context + */ + private void requirePagedModelProperties(Schema schema, ModelConverterContext context) { + Schema pagedModelSchema = resolveReferencedSchema(schema, context); + if (pagedModelSchema == null || pagedModelSchema.getProperties() == null + || !pagedModelSchema.getProperties().keySet().containsAll(PAGED_MODEL_REQUIRED_PROPERTIES)) + return; + + pagedModelSchema.setRequired(PAGED_MODEL_REQUIRED_PROPERTIES); + + Schema metadataSchema = resolveReferencedSchema( + (Schema) pagedModelSchema.getProperties().get("page"), context); + if (metadataSchema == null || metadataSchema.getProperties() == null + || !metadataSchema.getProperties().keySet().containsAll(PAGE_METADATA_REQUIRED_PROPERTIES)) + return; + + metadataSchema.setRequired(PAGE_METADATA_REQUIRED_PROPERTIES); + } + /** * Resolve referenced schema. * diff --git a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app10/SpringDocApp10DirectTest.java b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app10/SpringDocApp10DirectTest.java index e5222ec38..26c68c3a4 100644 --- a/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app10/SpringDocApp10DirectTest.java +++ b/springdoc-openapi-tests/springdoc-openapi-hateoas-tests/src/test/java/test/org/springdoc/api/v31/app10/SpringDocApp10DirectTest.java @@ -33,6 +33,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.web.config.EnableSpringDataWebSupport; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.is; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -47,6 +48,10 @@ protected void testApp() throws Exception { mockMvc.perform(get(Constants.DEFAULT_API_DOCS_URL)) .andExpect(status().isOk()) .andExpect(jsonPath("$.openapi", is("3.1.0"))) + .andExpect(jsonPath("$.components.schemas.PagedModelString.required", + containsInAnyOrder("content", "page"))) + .andExpect(jsonPath("$.components.schemas.PageMetadata.required", + containsInAnyOrder("size", "number", "totalElements", "totalPages"))) .andExpect(content().json(getContent("results/3.1.0/app10-direct.json"), true)); } 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 12f41a67f..5bba83aa3 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 @@ -213,7 +213,13 @@ "type": "integer", "format": "int64" } - } + }, + "required": [ + "number", + "size", + "totalElements", + "totalPages" + ] }, "PagedModelString": { "type": "object", @@ -227,7 +233,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "PagedModel": { "type": "object", @@ -241,7 +251,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "DummyListString": { "type": "object", @@ -266,7 +280,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "PageString": { "type": "object", 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 ed88f14ce..87aff1e7b 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 @@ -213,7 +213,13 @@ "type": "integer", "format": "int64" } - } + }, + "required": [ + "number", + "size", + "totalElements", + "totalPages" + ] }, "PagedModelString": { "type": "object", @@ -227,7 +233,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "PagedModel": { "type": "object", @@ -241,7 +251,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "DummyListString": { "type": "object", @@ -266,7 +280,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "PagedModelUserDto": { "type": "object", @@ -280,7 +298,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "UserDto": { "type": "object", 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 e3fa6c995..996d86bb1 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 @@ -193,7 +193,13 @@ "type": "integer", "format": "int64" } - } + }, + "required": [ + "number", + "size", + "totalElements", + "totalPages" + ] }, "PagedModelString": { "type": "object", @@ -207,7 +213,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "PagedModel": { "type": "object", @@ -219,7 +229,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "DummyListString": { "type": "object", @@ -244,7 +258,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "PageString": { "type": "object", 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 d8d18c9cf..dc3ffb1cd 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 @@ -193,7 +193,13 @@ "type": "integer", "format": "int64" } - } + }, + "required": [ + "number", + "size", + "totalElements", + "totalPages" + ] }, "PagedModelString": { "type": "object", @@ -207,7 +213,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "PagedModel": { "type": "object", @@ -219,7 +229,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "DummyListString": { "type": "object", @@ -244,7 +258,11 @@ "page": { "$ref": "#/components/schemas/PageMetadata" } - } + }, + "required": [ + "content", + "page" + ] }, "DummyPageString": { "type": "object",