diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 1fb3c78a9e89..9f35a5d8012c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -1100,6 +1100,8 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation List attrs = new ArrayList<>(); if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); + if (constraints.minSize >= 0) attrs.add("minSize = " + constraints.minSize); + if (constraints.minPage >= 0) attrs.add("minPage = " + constraints.minPage); pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); codegenOperation.imports.add("ValidPageable"); } @@ -1212,7 +1214,7 @@ public void preprocessOpenAPI(OpenAPI openAPI) { } if (substituteGenericPagedModel) { - pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI); + pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI, this::toModelName); if (!pagedModelRegistry.isEmpty()) { boolean customMapping = importMapping.containsKey("PagedModel"); importMapping.putIfAbsent("PagedModel", configPackage + ".PagedModel"); @@ -1383,24 +1385,27 @@ public Map postProcessAllModels(Map objs) if (getAnnotationLibrary() == AnnotationLibrary.NONE) { // No @ApiResponse annotations are generated when annotationLibrary=none, // so paged schemas are not referenced anywhere → safe to suppress. - Set metaSchemasToCheck = new HashSet<>(); + // metaSchemasToCheck maps transformed name (for imports check) → raw name (for objs.remove) + Map metaSchemasToCheck = new LinkedHashMap<>(); for (PagedModelScanUtils.DetectedPagedModel detected : pagedModelRegistry.values()) { if (detected.metaSchemaName != null) { - metaSchemasToCheck.add(detected.metaSchemaName); + metaSchemasToCheck.put(detected.metaSchemaName, detected.rawMetaSchemaName); } } // Remove paged schemas first so reference checks below reflect the post-suppression state. for (Map.Entry entry : pagedModelRegistry.entrySet()) { - String schemaName = entry.getKey(); PagedModelScanUtils.DetectedPagedModel detected = entry.getValue(); - if (objs.remove(schemaName) != null) { + // objs is keyed by raw schema name (DefaultGenerator uses the raw OpenAPI name as key) + if (objs.remove(detected.rawSchemaName) != null) { LOGGER.info("substituteGenericPagedModel: suppressing model '{}' — replaced by PagedModel<{}>", - schemaName, detected.itemSchemaName); + detected.rawSchemaName, detected.itemSchemaName); } } // Suppress meta schemas only when no remaining (non-suppressed) schema references them. // Example: if SearchResult has a 'page: PageMeta' property, PageMeta must be kept. - for (String metaName : metaSchemasToCheck) { + for (Map.Entry metaEntry : metaSchemasToCheck.entrySet()) { + String metaName = metaEntry.getKey(); // transformed — matches cm.imports values + String rawMetaName = metaEntry.getValue(); // raw — matches objs key boolean referencedElsewhere = objs.values().stream() .flatMap(mm -> mm.getModels().stream()) .map(ModelMap::getModel) @@ -1408,7 +1413,7 @@ public Map postProcessAllModels(Map objs) if (referencedElsewhere) { LOGGER.info("substituteGenericPagedModel: keeping pagination metadata model '{}'" + " — referenced by a non-paged schema", metaName); - } else if (objs.remove(metaName) != null) { + } else if (objs.remove(rawMetaName) != null) { LOGGER.info("substituteGenericPagedModel: suppressing pagination metadata model '{}'" + " — replaced by PagedModel.PageMetadata", metaName); } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java index 67279c10d818..f948e4da1d3e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java @@ -21,6 +21,7 @@ import org.openapitools.codegen.utils.ModelUtils; import java.util.*; +import java.util.function.UnaryOperator; /** * Language-agnostic utility for detecting OpenAPI schemas that represent paginated responses @@ -61,21 +62,56 @@ private PagedModelScanUtils() {} /** * Carries the result of a single detected paged-model schema. * - * @param schemaName Name of the detected schema to suppress (e.g. {@code UserPage}). - * @param itemSchemaName Simple name of the array item type (e.g. {@code User}). - * @param metaSchemaName Name of the pagination-metadata schema to suppress - * (e.g. {@code PageMetadata}), or {@code null} if it could not - * be resolved to a named component. + *

Two name variants are stored for each schema:

+ *
    + *
  • transformed ({@code schemaName} / {@code metaSchemaName}) — the model name + * after the generator's {@code toModelName()} has been applied. These are the names + * that appear in codegen-operation imports and {@code CodegenModel.imports}, so they + * must be used for import removal / import-presence checks.
  • + *
  • raw ({@code rawSchemaName} / {@code rawMetaSchemaName}) — the original + * OpenAPI component-schema name. {@code DefaultGenerator} keys {@code allProcessedModels} + * (the {@code objs} map passed to {@code postProcessAllModels}) by the raw + * schema name, so these values must be used for {@code objs.remove()} calls.
  • + *
+ * + *

When {@link #scanPagedModels(OpenAPI)} is used (no transform), the raw and transformed + * names are identical. When {@link #scanPagedModels(OpenAPI, UnaryOperator)} is used, they + * may differ (e.g. {@code rawSchemaName="UserPage"}, {@code schemaName="UserPageDto"}).

+ * + * @param schemaName Transformed model name of the detected paged schema. + * @param itemSchemaName Raw item schema name (always raw; callers apply + * {@code toModelName()} at the point of use). + * @param metaSchemaName Transformed model name of the pagination-metadata schema, + * or {@code null} if unresolved. + * @param rawSchemaName Raw OpenAPI schema name of the paged schema (for {@code objs.remove}). + * @param rawMetaSchemaName Raw OpenAPI schema name of the pagination-metadata schema + * (for {@code objs.remove}), or {@code null} if unresolved. */ public static final class DetectedPagedModel { + /** Transformed model name — use for import removal / import-presence checks. */ public final String schemaName; public final String itemSchemaName; + /** Transformed meta model name — use for import-presence checks. */ public final String metaSchemaName; - + /** Raw OpenAPI schema name — use for {@code objs.remove()} in {@code postProcessAllModels}. */ + public final String rawSchemaName; + /** Raw OpenAPI meta schema name — use for {@code objs.remove()} in {@code postProcessAllModels}. */ + public final String rawMetaSchemaName; + + /** + * Convenience constructor used when no name transform is active (raw == transformed). + */ public DetectedPagedModel(String schemaName, String itemSchemaName, String metaSchemaName) { + this(schemaName, itemSchemaName, metaSchemaName, schemaName, metaSchemaName); + } + + DetectedPagedModel(String schemaName, String itemSchemaName, String metaSchemaName, + String rawSchemaName, String rawMetaSchemaName) { this.schemaName = schemaName; this.itemSchemaName = itemSchemaName; this.metaSchemaName = metaSchemaName; + this.rawSchemaName = rawSchemaName; + this.rawMetaSchemaName = rawMetaSchemaName; } } @@ -112,7 +148,43 @@ public static Map scanPagedModels(OpenAPI openAPI) { } /** - * Returns {@code true} if the given schema looks like a pagination-metadata schema. + * Convenience overload that scans for paged-model schemas and immediately re-keys the + * resulting map by applying {@code toModelName} to every schema name. + * + *

Generator classes must use this overload (passing {@code this::toModelName}) so that + * the registry keys match the model-name-processed values used at lookup time + * (e.g. {@code codegenOperation.returnBaseType}, {@code objs} keys). This ensures + * correctness when {@code modelNameSuffix}, {@code modelNamePrefix}, {@code schemaMapping}, + * or {@code modelNameMapping} are active.

+ * + *

{@code itemSchemaName} inside each {@link DetectedPagedModel} is intentionally left as + * the raw spec name because every call site already passes it through {@code toModelName()} + * at the point of use.

+ * + * @param openAPI the parsed OpenAPI document + * @param toModelName name-transformation function supplied by the generator + * (typically {@code this::toModelName}) + * @return map from transformed schema name to {@link DetectedPagedModel} + */ + public static Map scanPagedModels( + OpenAPI openAPI, UnaryOperator toModelName) { + Map raw = scanPagedModels(openAPI); + if (raw.isEmpty()) { + return raw; + } + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + DetectedPagedModel d = entry.getValue(); + String rawKey = entry.getKey(); + String newKey = toModelName.apply(rawKey); + String rawMeta = d.metaSchemaName; + String newMeta = rawMeta != null ? toModelName.apply(rawMeta) : null; + result.put(newKey, new DetectedPagedModel(newKey, d.itemSchemaName, newMeta, rawKey, rawMeta)); + } + return result; + } + + /** * *

The heuristic checks that at least {@value #PAGINATION_FIELD_THRESHOLD} of the * well-known field names ({@code size}, {@code number}, {@code page}, diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index 9329e8a1b5c5..bfb320edfd47 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java @@ -872,7 +872,7 @@ public void preprocessOpenAPI(OpenAPI openAPI) { } if (substituteGenericPagedModel) { - pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI); + pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI, this::toModelName); if (!pagedModelRegistry.isEmpty()) { boolean customMapping = importMapping.containsKey("PagedModel"); importMapping.putIfAbsent("PagedModel", configPackage + ".PagedModel"); @@ -1272,6 +1272,8 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation List attrs = new ArrayList<>(); if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); + if (constraints.minSize >= 0) attrs.add("minSize = " + constraints.minSize); + if (constraints.minPage >= 0) attrs.add("minPage = " + constraints.minPage); pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); codegenOperation.imports.add("ValidPageable"); } @@ -1450,24 +1452,27 @@ public Map postProcessAllModels(Map objs) if (getAnnotationLibrary() == AnnotationLibrary.NONE) { // No @ApiResponse annotations are generated when annotationLibrary=none, // so paged schemas are not referenced anywhere → safe to suppress. - Set metaSchemasToCheck = new HashSet<>(); + // metaSchemasToCheck maps transformed name (for imports check) → raw name (for objs.remove) + Map metaSchemasToCheck = new LinkedHashMap<>(); for (PagedModelScanUtils.DetectedPagedModel detected : pagedModelRegistry.values()) { if (detected.metaSchemaName != null) { - metaSchemasToCheck.add(detected.metaSchemaName); + metaSchemasToCheck.put(detected.metaSchemaName, detected.rawMetaSchemaName); } } // Remove paged schemas first so reference checks below reflect the post-suppression state. for (Map.Entry entry : pagedModelRegistry.entrySet()) { - String schemaName = entry.getKey(); PagedModelScanUtils.DetectedPagedModel detected = entry.getValue(); - if (objs.remove(schemaName) != null) { + // objs is keyed by raw schema name (DefaultGenerator uses the raw OpenAPI name as key) + if (objs.remove(detected.rawSchemaName) != null) { LOGGER.info("substituteGenericPagedModel: suppressing model '{}' — replaced by PagedModel<{}>", - schemaName, detected.itemSchemaName); + detected.rawSchemaName, detected.itemSchemaName); } } // Suppress meta schemas only when no remaining (non-suppressed) schema references them. // Example: if SearchResult has a 'page: PageMeta' property, PageMeta must be kept. - for (String metaName : metaSchemasToCheck) { + for (Map.Entry metaEntry : metaSchemasToCheck.entrySet()) { + String metaName = metaEntry.getKey(); // transformed — matches cm.imports values + String rawMetaName = metaEntry.getValue(); // raw — matches objs key boolean referencedElsewhere = objs.values().stream() .flatMap(mm -> mm.getModels().stream()) .map(ModelMap::getModel) @@ -1475,7 +1480,7 @@ public Map postProcessAllModels(Map objs) if (referencedElsewhere) { LOGGER.info("substituteGenericPagedModel: keeping pagination metadata model '{}'" + " — referenced by a non-paged schema", metaName); - } else if (objs.remove(metaName) != null) { + } else if (objs.remove(rawMetaName) != null) { LOGGER.info("substituteGenericPagedModel: suppressing pagination metadata model '{}'" + " — replaced by PagedModel.PageMetadata", metaName); } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java index 17ceb3757fdb..88311a2b2ddd 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java @@ -24,6 +24,7 @@ import io.swagger.v3.oas.models.parameters.Parameter; import org.openapitools.codegen.utils.ModelUtils; +import java.math.BigDecimal; import java.util.*; import java.util.stream.Collectors; @@ -71,22 +72,28 @@ public boolean hasAny() { } /** - * Carries max constraints for page number and page size from a pageable operation. - * {@code -1} means no constraint specified (no {@code maximum:} in the spec). + * Carries max and min constraints for page number and page size from a pageable operation. + * {@code -1} means no constraint specified (no {@code maximum:}/{@code minimum:} in the spec). */ public static final class PageableConstraintsData { /** Maximum allowed page number, or {@code -1} if unconstrained. */ public final int maxPage; /** Maximum allowed page size, or {@code -1} if unconstrained. */ public final int maxSize; + /** Minimum allowed page number, or {@code -1} if unconstrained. */ + public final int minPage; + /** Minimum allowed page size, or {@code -1} if unconstrained. */ + public final int minSize; - public PageableConstraintsData(int maxPage, int maxSize) { + public PageableConstraintsData(int maxPage, int maxSize, int minPage, int minSize) { this.maxPage = maxPage; this.maxSize = maxSize; + this.minPage = minPage; + this.minSize = minSize; } public boolean hasAny() { - return maxPage >= 0 || maxSize >= 0; + return maxPage >= 0 || maxSize >= 0 || minPage >= 0 || minSize >= 0; } } @@ -155,9 +162,9 @@ public static Map> scanSortValidationEnums( } // If the top-level schema is an array, the enum lives on its items Schema enumSchema = schema; - if (schema.getItems() != null) { + if (ModelUtils.isArraySchema(schema)) { enumSchema = schema.getItems(); - if (enumSchema.get$ref() != null) { + if (enumSchema != null && enumSchema.get$ref() != null) { enumSchema = ModelUtils.getReferencedSchema(openAPI, enumSchema); } } @@ -205,13 +212,10 @@ public static Map scanPageableDefaults( if (schema == null) { continue; } - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - } - if (schema == null || schema.getDefault() == null) { + Object defaultValue = ModelUtils.resolveDefault(openAPI, schema); + if (defaultValue == null) { continue; } - Object defaultValue = schema.getDefault(); switch (param.getName()) { case "page": if (defaultValue instanceof Number) { @@ -256,11 +260,12 @@ public static Map scanPageableDefaults( } /** - * Scans all pageable operations for {@code maximum:} constraints on {@code page} and - * {@code size} parameters. + * Scans all pageable operations for {@code maximum:} and {@code minimum:} constraints on + * {@code page} and {@code size} parameters. Values are resolved through {@code allOf} and + * {@code $ref} schemas so that constraints defined on shared component schemas are honoured. * * @return map from operationId to {@link PageableConstraintsData} (only operations with - * at least one {@code maximum:} constraint are included) + * at least one constraint are included) */ public static Map scanPageableConstraints( OpenAPI openAPI, boolean autoXSpringPaginated) { @@ -279,30 +284,29 @@ public static Map scanPageableConstraints( } int maxPage = -1; int maxSize = -1; + int minPage = -1; + int minSize = -1; for (Parameter param : operation.getParameters()) { Schema schema = param.getSchema(); if (schema == null) { continue; } - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - } - if (schema == null || schema.getMaximum() == null) { - continue; - } - int maximum = schema.getMaximum().intValue(); + BigDecimal maximum = ModelUtils.resolveMaximum(openAPI, schema); + BigDecimal minimum = ModelUtils.resolveMinimum(openAPI, schema); switch (param.getName()) { case "page": - maxPage = maximum; + if (maximum != null) maxPage = maximum.intValue(); + if (minimum != null) minPage = minimum.intValue(); break; case "size": - maxSize = maximum; + if (maximum != null) maxSize = maximum.intValue(); + if (minimum != null) minSize = minimum.intValue(); break; default: break; } } - PageableConstraintsData data = new PageableConstraintsData(maxPage, maxSize); + PageableConstraintsData data = new PageableConstraintsData(maxPage, maxSize, minPage, minSize); if (data.hasAny()) { result.put(operationId, data); } @@ -310,4 +314,5 @@ public static Map scanPageableConstraints( } return result; } + } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 88346d9046f7..763769927c26 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -852,6 +852,94 @@ public static boolean isModelWithPropertiesOnly(Schema schema) { ); } + /** + * Returns the effective {@code maximum} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive + * (smallest) value wins. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective maximum, or {@code null} if none is defined + */ + public static BigDecimal resolveMaximum(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + BigDecimal result = schema.getMaximum(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getMaximum() != null) { + if (result == null || resolved.getMaximum().compareTo(result) < 0) { + result = resolved.getMaximum(); + } + } + } + } + return result; + } + + /** + * Returns the effective {@code minimum} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive + * (largest) value wins. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective minimum, or {@code null} if none is defined + */ + public static BigDecimal resolveMinimum(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + BigDecimal result = schema.getMinimum(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getMinimum() != null) { + if (result == null || resolved.getMinimum().compareTo(result) > 0) { + result = resolved.getMinimum(); + } + } + } + } + return result; + } + + /** + * Returns the effective {@code default} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Unlike constraints, the inline schema's default takes precedence (explicit per-endpoint + * override); falls back to the first non-null default found in {@code allOf} items. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective default value, or {@code null} if none is defined + */ + public static Object resolveDefault(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + if (schema.getDefault() != null) return schema.getDefault(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getDefault() != null) { + return resolved.getDefault(); + } + } + } + return null; + } + public static boolean hasValidation(Schema sc) { return ( sc.getMaxItems() != null || diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache index daf547481640..7cb93f4b6bf6 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache @@ -13,13 +13,15 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Validates that the page number and page size in the annotated {@link Pageable} parameter do not - * exceed their configured maximums. + * Validates that the page number and page size in the annotated {@link Pageable} parameter are + * within their configured bounds. * *

Apply directly on a {@code Pageable} parameter. Each attribute is independently optional: *

    *
  • {@link #maxSize()} — when set (>= 0), validates {@code pageable.getPageSize() <= maxSize} *
  • {@link #maxPage()} — when set (>= 0), validates {@code pageable.getPageNumber() <= maxPage} + *
  • {@link #minSize()} — when set (>= 0), validates {@code pageable.getPageSize() >= minSize} + *
  • {@link #minPage()} — when set (>= 0), validates {@code pageable.getPageNumber() >= minPage} *
* *

Use {@link #NO_LIMIT} (= {@code -1}, the default) to leave an attribute unconstrained. @@ -43,6 +45,12 @@ public @interface ValidPageable { /** Maximum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ int maxPage() default NO_LIMIT; + /** Minimum allowed page size, or {@link #NO_LIMIT} if unconstrained. */ + int minSize() default NO_LIMIT; + + /** Minimum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ + int minPage() default NO_LIMIT; + Class[] groups() default {}; Class[] payload() default {}; @@ -53,11 +61,15 @@ public @interface ValidPageable { private int maxSize = NO_LIMIT; private int maxPage = NO_LIMIT; + private int minSize = NO_LIMIT; + private int minPage = NO_LIMIT; @Override public void initialize(ValidPageable constraintAnnotation) { maxSize = constraintAnnotation.maxSize(); maxPage = constraintAnnotation.maxPage(); + minSize = constraintAnnotation.minSize(); + minPage = constraintAnnotation.minPage(); } @Override @@ -93,6 +105,26 @@ public @interface ValidPageable { valid = false; } + if (minSize >= 0 && pageable.getPageSize() < minSize) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page size " + pageable.getPageSize() + + " is below minimum " + minSize) + .addPropertyNode("size") + .addConstraintViolation(); + valid = false; + } + + if (minPage >= 0 && pageable.getPageNumber() < minPage) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page number " + pageable.getPageNumber() + + " is below minimum " + minPage) + .addPropertyNode("page") + .addConstraintViolation(); + valid = false; + } + return valid; } } diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache index 6b26b7a26803..c87a9da537cb 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache @@ -7,12 +7,14 @@ import {{javaxPackage}}.validation.Payload import org.springframework.data.domain.Pageable /** - * Validates that the page number and page size in the annotated [Pageable] parameter do not - * exceed their configured maximums. + * Validates that the page number and page size in the annotated [Pageable] parameter are within + * their configured bounds. * * Apply directly on a `pageable: Pageable` parameter. Each attribute is independently optional: * - [maxSize] — when set (>= 0), validates `pageable.pageSize <= maxSize` * - [maxPage] — when set (>= 0), validates `pageable.pageNumber <= maxPage` + * - [minSize] — when set (>= 0), validates `pageable.pageSize >= minSize` + * - [minPage] — when set (>= 0), validates `pageable.pageNumber >= minPage` * * Use [NO_LIMIT] (= -1, the default) to leave an attribute unconstrained. * @@ -21,6 +23,8 @@ import org.springframework.data.domain.Pageable * * @property maxSize Maximum allowed page size, or [NO_LIMIT] if unconstrained * @property maxPage Maximum allowed page number (0-based), or [NO_LIMIT] if unconstrained + * @property minSize Minimum allowed page size, or [NO_LIMIT] if unconstrained + * @property minPage Minimum allowed page number (0-based), or [NO_LIMIT] if unconstrained * @property groups Validation groups (optional) * @property payload Additional payload (optional) * @property message Validation error message (default: "Invalid page request") @@ -32,6 +36,8 @@ import org.springframework.data.domain.Pageable annotation class ValidPageable( val maxSize: Int = ValidPageable.NO_LIMIT, val maxPage: Int = ValidPageable.NO_LIMIT, + val minSize: Int = ValidPageable.NO_LIMIT, + val minPage: Int = ValidPageable.NO_LIMIT, val groups: Array> = [], val payload: Array> = [], val message: String = "Invalid page request" @@ -45,10 +51,14 @@ class PageableConstraintValidator : ConstraintValidator private var maxSize = ValidPageable.NO_LIMIT private var maxPage = ValidPageable.NO_LIMIT + private var minSize = ValidPageable.NO_LIMIT + private var minPage = ValidPageable.NO_LIMIT override fun initialize(constraintAnnotation: ValidPageable) { maxSize = constraintAnnotation.maxSize maxPage = constraintAnnotation.maxPage + minSize = constraintAnnotation.minSize + minPage = constraintAnnotation.minPage } override fun isValid(pageable: Pageable?, context: ConstraintValidatorContext): Boolean { @@ -76,6 +86,24 @@ class PageableConstraintValidator : ConstraintValidator valid = false } + if (minSize >= 0 && pageable.pageSize < minSize) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page size ${pageable.pageSize} is below minimum $minSize" + ) + .addPropertyNode("size") + .addConstraintViolation() + valid = false + } + + if (minPage >= 0 && pageable.pageNumber < minPage) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page number ${pageable.pageNumber} is below minimum $minPage" + ) + .addPropertyNode("page") + .addConstraintViolation() + valid = false + } + return valid } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index da1e4aefface..f9d37a199c66 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -7176,6 +7176,65 @@ public void generatePageableConstraintValidationWithBothConstraints() throws IOE .containsWithNameAndAttributes("ValidPageable", Map.of("maxSize", "50", "maxPage", "999")); } + @Test + public void generatePageableConstraintValidationResolvesMaximumFromAllOfRef() throws IOException { + Map props = new HashMap<>(); + props.put(SpringCodegen.INTERFACE_ONLY, "true"); + props.put(SpringCodegen.SKIP_DEFAULT_INTERFACE, "true"); + props.put(SpringCodegen.USE_TAGS, "true"); + props.put(SpringCodegen.USE_SPRING_BOOT3, "true"); + props.put(SpringCodegen.GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // findPetsWithSizeConstraintFromAllOfRef: maximum: 75 is on the referenced schema only + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithSizeConstraintFromAllOfRef") + .assertParameter("pageable") + .assertParameterAnnotations() + .containsWithNameAndAttributes("ValidPageable", Map.of("maxSize", "75")); + } + + @Test + public void generatePageableConstraintValidationResolvesMinimumFromAllOfRef() throws IOException { + Map props = new HashMap<>(); + props.put(SpringCodegen.INTERFACE_ONLY, "true"); + props.put(SpringCodegen.SKIP_DEFAULT_INTERFACE, "true"); + props.put(SpringCodegen.USE_TAGS, "true"); + props.put(SpringCodegen.USE_SPRING_BOOT3, "true"); + props.put(SpringCodegen.GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // findPetsWithMinSizeConstraintFromAllOfRef: minimum: 5 is on the referenced schema only + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithMinSizeConstraintFromAllOfRef") + .assertParameter("pageable") + .assertParameterAnnotations() + .containsWithNameAndAttributes("ValidPageable", Map.of("minSize", "5")); + } + + @Test + public void scanPageableDefaultsResolvesDefaultFromAllOfRef() throws IOException { + Map props = new HashMap<>(); + props.put(SpringCodegen.INTERFACE_ONLY, "true"); + props.put(SpringCodegen.SKIP_DEFAULT_INTERFACE, "true"); + props.put(SpringCodegen.USE_TAGS, "true"); + props.put(SpringCodegen.USE_SPRING_BOOT3, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // findPetsWithDefaultFromAllOfRef: default: 7 is on the referenced schema only + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithDefaultFromAllOfRef") + .assertParameter("pageable") + .assertParameterAnnotations() + .containsWithNameAndAttributes("PageableDefault", Map.of("size", "7")); + } + // ------------------------------------------------------------------------- // @PageableDefault / @SortDefault tests // ------------------------------------------------------------------------- @@ -7583,6 +7642,59 @@ private Map springCloudPagedModelProps() { } + // ------------------------------------------------------------------------- + // substituteGenericPagedModel — modelNameSuffix / modelNamePrefix + // ------------------------------------------------------------------------- + + @Test + public void substituteGenericPagedModel_withModelNameSuffix_replacesReturnType() throws IOException { + // When modelNameSuffix is set the returnBaseType includes the suffix, + // so the registry lookup must also use the suffix-applied key. + Map props = commonPagedModelProps(); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", SPRING_BOOT, props, + configurator -> configurator.addAdditionalProperty("modelNameSuffix", "Dto")); + + // listUsers returns UserPage → suffix applied → UserPageDto → replaced with PagedModel + JavaFileAssert.assertThat(files.get("UserApi.java")) + .assertMethod("listUsers") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void substituteGenericPagedModel_withModelNamePrefix_replacesReturnType() throws IOException { + // When modelNamePrefix is set the returnBaseType includes the prefix, + // so the registry lookup must also use the prefix-applied key. + Map props = commonPagedModelProps(); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", SPRING_BOOT, props, + configurator -> configurator.addAdditionalProperty("modelNamePrefix", "My")); + + // listUsers returns UserPage → prefix applied → MyUserPage → replaced with PagedModel + JavaFileAssert.assertThat(files.get("UserApi.java")) + .assertMethod("listUsers") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void substituteGenericPagedModel_withModelNameSuffix_suppressesPagedSchemasWhenNoAnnotations() + throws IOException { + // Verify schema suppression also works correctly under modelNameSuffix + // (objs keys are suffix-applied, registry keys must match them). + Map props = noAnnotationPagedModelProps(); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", SPRING_BOOT, props, + configurator -> configurator.addAdditionalProperty("modelNameSuffix", "Dto")); + + assertThat(files).doesNotContainKey("UserPageDto.java"); + assertThat(files).doesNotContainKey("OrderPageDto.java"); + assertThat(files).doesNotContainKey("PetPageAllOfDto.java"); + } + + @DataProvider(name = "replaceOneOf") public Object[][] replaceOneOf() { return new Object[][]{ diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index 9f17ad216091..0e3f0c4c5b1b 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -4231,6 +4231,8 @@ public void generatePageableConstraintValidationGeneratesValidPageableFile() thr assertFileContains(validPageableFile.toPath(), "class PageableConstraintValidator"); assertFileContains(validPageableFile.toPath(), "val maxSize: Int"); assertFileContains(validPageableFile.toPath(), "val maxPage: Int"); + assertFileContains(validPageableFile.toPath(), "val minSize: Int"); + assertFileContains(validPageableFile.toPath(), "val minPage: Int"); assertFileContains(validPageableFile.toPath(), "NO_LIMIT"); } @@ -4267,7 +4269,63 @@ public void generatePageableConstraintValidationDoesNotGenerateFileWhenBeanValid // ========== AUTO X-SPRING-PAGINATED TESTS ========== - // ========== GENERATE SORT VALIDATION TESTS ========== + @Test + public void generatePageableConstraintValidationResolvesMaximumFromAllOfRef() throws Exception { + Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + additionalProperties.put(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "true"); + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + File petApi = files.get("PetApi.kt"); + String content = Files.readString(petApi.toPath()); + + // findPetsWithSizeConstraintFromAllOfRef: maximum: 75 is on the referenced schema only + int methodStart = content.indexOf("fun findPetsWithSizeConstraintFromAllOfRef("); + Assert.assertTrue(methodStart >= 0, "findPetsWithSizeConstraintFromAllOfRef method should exist"); + String paramBlock = content.substring(methodStart, Math.min(content.length(), methodStart + 500)); + Assert.assertTrue(paramBlock.contains("@ValidPageable(maxSize = 75)"), + "@ValidPageable(maxSize = 75) should be resolved from allOf $ref schema"); + } + + @Test + public void generatePageableConstraintValidationResolvesMinimumFromAllOfRef() throws Exception { + Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + additionalProperties.put(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "true"); + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + File petApi = files.get("PetApi.kt"); + String content = Files.readString(petApi.toPath()); + + // findPetsWithMinSizeConstraintFromAllOfRef: minimum: 5 is on the referenced schema only + int methodStart = content.indexOf("fun findPetsWithMinSizeConstraintFromAllOfRef("); + Assert.assertTrue(methodStart >= 0, "findPetsWithMinSizeConstraintFromAllOfRef method should exist"); + String paramBlock = content.substring(methodStart, Math.min(content.length(), methodStart + 500)); + Assert.assertTrue(paramBlock.contains("@ValidPageable(minSize = 5)"), + "@ValidPageable(minSize = 5) should be resolved from allOf $ref schema"); + } + + @Test + public void scanPageableDefaultsResolvesDefaultFromAllOfRef() throws Exception { + Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + File petApi = files.get("PetApi.kt"); + // findPetsWithDefaultFromAllOfRef: default: 7 is on the referenced schema only + assertFileContains(petApi.toPath(), "@PageableDefault(size = 7)"); + } + + // ========== AUTO X-SPRING-PAGINATED TESTS ========== @Test public void generateSortValidationAddsAnnotationForExplicitPaginated() throws Exception { @@ -5900,7 +5958,61 @@ private Map springCloudKotlinPagedModelProps() { return props; } - @Test(description = "oneOf with discriminator generates thin sealed interface with Jackson annotations") + // ------------------------------------------------------------------------- + // substituteGenericPagedModel — modelNameSuffix / modelNamePrefix + // ------------------------------------------------------------------------- + + @Test + public void substituteGenericPagedModel_withModelNameSuffix_replacesReturnType() throws IOException { + // When modelNameSuffix is set the returnBaseType includes the suffix, + // so the registry lookup must also use the suffix-applied key. + Map props = commonKotlinPagedModelProps(); + props.put("modelNameSuffix", "Dto"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", props); + + // listUsers returns UserPage → suffix applied → UserPageDto → replaced with PagedModel + File userApi = files.get("UserApi.kt"); + assertThat(userApi).isNotNull(); + String content = Files.readString(userApi.toPath()); + assertThat(content).contains("PagedModel"); + } + + @Test + public void substituteGenericPagedModel_withModelNamePrefix_replacesReturnType() throws IOException { + // When modelNamePrefix is set the returnBaseType includes the prefix, + // so the registry lookup must also use the prefix-applied key. + Map props = commonKotlinPagedModelProps(); + props.put("modelNamePrefix", "My"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", props); + + // listUsers returns UserPage → prefix applied → MyUserPage → replaced with PagedModel + File userApi = files.get("UserApi.kt"); + assertThat(userApi).isNotNull(); + String content = Files.readString(userApi.toPath()); + assertThat(content).contains("PagedModel"); + } + + @Test + public void substituteGenericPagedModel_withModelNameSuffix_suppressesPagedSchemasWhenNoAnnotations() + throws IOException { + // Verify schema suppression also works correctly under modelNameSuffix + // (objs keys are suffix-applied, registry keys must match them). + Map props = noAnnotationKotlinPagedModelProps(); + props.put("modelNameSuffix", "Dto"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", props); + + assertThat(files).doesNotContainKey("UserPageDto.kt"); + assertThat(files).doesNotContainKey("OrderPageDto.kt"); + assertThat(files).doesNotContainKey("PetPageAllOfDto.kt"); + } + + public void testOneOfWithDiscriminatorGeneratesThinInterface() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); output.deleteOnExit(); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/PagedModelScanUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/PagedModelScanUtilsTest.java index ef39ad954208..3aedae62e724 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/PagedModelScanUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/PagedModelScanUtilsTest.java @@ -353,4 +353,57 @@ public void extractSchemaNameFromRef_returnsRefAsIsWhenNoSlash() { public void extractSchemaNameFromRef_returnsNullForNull() { assertThat(PagedModelScanUtils.extractSchemaNameFromRef(null)).isNull(); } + + // ------------------------------------------------------------------------- + // scanPagedModels(OpenAPI, UnaryOperator) — transform overload + // ------------------------------------------------------------------------- + + @Test + public void scanPagedModels_withTransform_appliesTransformToKeySchemaNameAndMetaSchemaName() { + // Build a minimal paged schema so the scan detects one entry. + ArraySchema contentSchema = new ArraySchema(); + contentSchema.setItems(new Schema<>().$ref(ref("User"))); + + ObjectSchema userPageSchema = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("content", contentSchema); + props.put("page", new Schema<>().$ref(ref("PageMetadata"))); + userPageSchema.setProperties(props); + + Map schemas = new LinkedHashMap<>(); + schemas.put("PageMetadata", pageMetadataSchema()); + schemas.put("User", new ObjectSchema()); + schemas.put("UserPage", userPageSchema); + + OpenAPI openAPI = buildOpenAPI(schemas); + + // Simulate a generator that appends "Dto" to every model name. + Map result = + PagedModelScanUtils.scanPagedModels(openAPI, name -> name + "Dto"); + + // Key, schemaName, and metaSchemaName must all have the suffix applied. + assertThat(result).containsKey("UserPageDto"); + assertThat(result).doesNotContainKey("UserPage"); + + PagedModelScanUtils.DetectedPagedModel detected = result.get("UserPageDto"); + assertThat(detected.schemaName).isEqualTo("UserPageDto"); + assertThat(detected.metaSchemaName).isEqualTo("PageMetadataDto"); + // itemSchemaName is intentionally left raw (transform is applied at call site). + assertThat(detected.itemSchemaName).isEqualTo("User"); + // Raw names must be preserved for objs.remove() in postProcessAllModels. + assertThat(detected.rawSchemaName).isEqualTo("UserPage"); + assertThat(detected.rawMetaSchemaName).isEqualTo("PageMetadata"); + } + + @Test + public void scanPagedModels_withTransform_returnsEmptyWhenNoSchemasDetected() { + OpenAPI openAPI = new OpenAPI(); + openAPI.setComponents(new Components()); + + Map result = + PagedModelScanUtils.scanPagedModels(openAPI, name -> name + "Dto"); + + assertThat(result).isEmpty(); + } } + diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java new file mode 100644 index 000000000000..6a27ccb216a7 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java @@ -0,0 +1,154 @@ +package org.openapitools.codegen.languages; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; +import org.testng.annotations.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Unit tests for {@link SpringPageableScanUtils}. + */ +public class SpringPageableScanUtilsTest { + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Builds an OpenAPI doc with a single GET /items operation marked x-spring-paginated. + */ + private static OpenAPI buildPageableOperation(Parameter sortParam) { + Operation op = new Operation(); + op.setOperationId("listItems"); + op.addExtension("x-spring-paginated", true); + op.addParametersItem(sortParam); + + PathItem pathItem = new PathItem(); + pathItem.setGet(op); + + Paths paths = new Paths(); + paths.addPathItem("/items", pathItem); + + OpenAPI openAPI = new OpenAPI(); + openAPI.setPaths(paths); + return openAPI; + } + + // ------------------------------------------------------------------------- + // scanSortValidationEnums — NPE regression for array schema without items + // ------------------------------------------------------------------------- + + /** + * Regression: array sort parameter with no {@code items} must not throw NPE. + * {@code isArraySchema()} returns {@code true} but {@code schema.getItems()} returns + * {@code null}, which would NPE on the subsequent {@code enumSchema.get$ref()} call + * before the fix. + * + *

+     * parameters:
+     *   - name: sort
+     *     in: query
+     *     schema:
+     *       type: array
+     *       # items: intentionally absent
+     * 
+ */ + @Test + public void scanSortValidationEnums_arraySchemaWithNoItems_doesNotThrow_and_returnsEmptyMap() { + // sort param: type=array but items intentionally absent + Schema sortSchema = new ArraySchema(); + // getItems() == null + assertThat(sortSchema.getItems()).isNull(); + Parameter sortParam = new Parameter().name("sort").schema(sortSchema); + OpenAPI openAPI = buildPageableOperation(sortParam); + + // does not throw NPE + assertThatCode(() -> SpringPageableScanUtils.scanSortValidationEnums(openAPI, false)) + .doesNotThrowAnyException(); + + // and returns empty map + Map> result = SpringPageableScanUtils.scanSortValidationEnums(openAPI, false); + assertThat(result).isEmpty(); + } + + // ------------------------------------------------------------------------- + // scanSortValidationEnums — happy path + // ------------------------------------------------------------------------- + + /** + *
+     * parameters:
+     *   - name: sort
+     *     in: query
+     *     schema:
+     *       type: array # sort as multi-column
+     *       items:
+     *         type: string
+     *         enum: ["name,asc", "name,desc", "id,asc"]
+     * 
+ */ + @Test + public void scanSortValidationEnums_arraySchemaWithEnumItems_returnsMappedEnums() { + Schema items = new StringSchema()._enum(List.of("name,asc", "name,desc", "id,asc")); + Schema sortSchema = new ArraySchema().items(items); + Parameter sortParam = new Parameter().name("sort").schema(sortSchema); + OpenAPI openAPI = buildPageableOperation(sortParam); + + Map> result = SpringPageableScanUtils.scanSortValidationEnums(openAPI, false); + assertThat(result) + .containsKey("listItems") + .satisfies(m -> assertThat(m.get("listItems")) + .containsExactly("name,asc", "name,desc", "id,asc")); + } + + /** + *
+     * parameters:
+     *   - name: sort
+     *     in: query
+     *     schema:
+     *       type: string # sort as single-column
+     *       enum: ["id,asc", "id,desc"]
+     * 
+ */ + @Test + public void scanSortValidationEnums_nonArraySortSchemaWithEnum_returnsIt() { + Schema sortSchema = new StringSchema()._enum(List.of("id,asc", "id,desc")); + Parameter sortParam = new Parameter().name("sort").schema(sortSchema); + OpenAPI openAPI = buildPageableOperation(sortParam); + + Map> result = SpringPageableScanUtils.scanSortValidationEnums(openAPI, false); + assertThat(result) + .containsKey("listItems") + .satisfies(m -> assertThat(m.get("listItems")).containsExactly("id,asc", "id,desc")); + } + + /** + *
+     * parameters:
+     *   - name: sort
+     *     in: query
+     *     schema:
+     *       type: string # sort as single-column
+     *       # enum: absent — no validation constraint
+     * 
+ */ + @Test + public void scanSortValidationEnums_sortSchemaWithNoEnum_returnsEmptyMap() { + Parameter sortParam = new Parameter().name("sort").schema(new StringSchema()); + OpenAPI openAPI = buildPageableOperation(sortParam); + + assertThat(SpringPageableScanUtils.scanSortValidationEnums(openAPI, false)).isEmpty(); + } +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java index 906ace829bcb..a445a02391b8 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java @@ -757,4 +757,234 @@ public void getParentNameMultipleInterfacesTest() { Schema composedSchema = allSchemas.get("RandomAnimalsResponse_animals_inner"); assertNull(ModelUtils.getParentName(composedSchema, allSchemas)); } + + // ------------------------------------------------------------------------- + // resolveMaximum + // ------------------------------------------------------------------------- + + @Test + public void resolveMaximum_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveMaximum(new OpenAPI(), null)); + } + + @Test + public void resolveMaximum_noMaximumDefined_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + assertNull(ModelUtils.resolveMaximum(openAPI, schema)); + } + + @Test + public void resolveMaximum_inlineMaximum_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(100)); + assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(100)); + } + + @Test + public void resolveMaximum_refToSchemaWithMaximum_resolvesRef() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema refTarget = new IntegerSchema(); + refTarget.setMaximum(BigDecimal.valueOf(50)); + openAPI.getComponents().addSchemas("MyInt", refTarget); + + Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); + assertEquals(ModelUtils.resolveMaximum(openAPI, ref), BigDecimal.valueOf(50)); + } + + @Test + public void resolveMaximum_allOf_returnsMostRestrictive() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf item with max=200 and item with max=50 — 50 should win + Schema loose = new IntegerSchema(); + loose.setMaximum(BigDecimal.valueOf(200)); + openAPI.getComponents().addSchemas("Loose", loose); + + Schema strict = new IntegerSchema(); + strict.setMaximum(BigDecimal.valueOf(50)); + openAPI.getComponents().addSchemas("Strict", strict); + + Schema schema = new Schema<>().allOf(Arrays.asList( + new Schema<>().$ref("#/components/schemas/Loose"), + new Schema<>().$ref("#/components/schemas/Strict") + )); + assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(50)); + } + + @Test + public void resolveMaximum_inlineAndAllOf_mostRestrictiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf item has maximum=30, which is more restrictive than inline maximum=100 + Schema allOfItem = new IntegerSchema(); + allOfItem.setMaximum(BigDecimal.valueOf(30)); + openAPI.getComponents().addSchemas("Base", allOfItem); + + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(100)); + schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(30)); + } + + @Test + public void resolveMaximum_allOfItemWithoutMaximum_ignored() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + openAPI.getComponents().addSchemas("NoMax", new IntegerSchema()); // no maximum + + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/NoMax"))); + assertNull(ModelUtils.resolveMaximum(openAPI, schema)); + } + + // ------------------------------------------------------------------------- + // resolveMinimum + // ------------------------------------------------------------------------- + + @Test + public void resolveMinimum_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveMinimum(new OpenAPI(), null)); + } + + @Test + public void resolveMinimum_noMinimumDefined_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + assertNull(ModelUtils.resolveMinimum(openAPI, new IntegerSchema())); + } + + @Test + public void resolveMinimum_inlineMinimum_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(1)); + assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(1)); + } + + @Test + public void resolveMinimum_refToSchemaWithMinimum_resolvesRef() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema refTarget = new IntegerSchema(); + refTarget.setMinimum(BigDecimal.valueOf(5)); + openAPI.getComponents().addSchemas("MyInt", refTarget); + + Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); + assertEquals(ModelUtils.resolveMinimum(openAPI, ref), BigDecimal.valueOf(5)); + } + + @Test + public void resolveMinimum_allOf_returnsMostRestrictive() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf item with min=1 and item with min=10 — 10 should win (larger = more restrictive lower bound) + Schema permissive = new IntegerSchema(); + permissive.setMinimum(BigDecimal.valueOf(1)); + openAPI.getComponents().addSchemas("Permissive", permissive); + + Schema strict = new IntegerSchema(); + strict.setMinimum(BigDecimal.valueOf(10)); + openAPI.getComponents().addSchemas("Strict", strict); + + Schema schema = new Schema<>().allOf(Arrays.asList( + new Schema<>().$ref("#/components/schemas/Permissive"), + new Schema<>().$ref("#/components/schemas/Strict") + )); + assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(10)); + } + + @Test + public void resolveMinimum_inlineAndAllOf_mostRestrictiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf item has minimum=20, which is more restrictive than inline minimum=0 + Schema allOfItem = new IntegerSchema(); + allOfItem.setMinimum(BigDecimal.valueOf(20)); + openAPI.getComponents().addSchemas("Base", allOfItem); + + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(0)); + schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(20)); + } + + @Test + public void resolveMinimum_allOfItemWithoutMinimum_ignored() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + openAPI.getComponents().addSchemas("NoMin", new IntegerSchema()); + + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/NoMin"))); + assertNull(ModelUtils.resolveMinimum(openAPI, schema)); + } + + // ------------------------------------------------------------------------- + // resolveDefault + // ------------------------------------------------------------------------- + + @Test + public void resolveDefault_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveDefault(new OpenAPI(), null)); + } + + @Test + public void resolveDefault_noDefaultDefined_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + assertNull(ModelUtils.resolveDefault(openAPI, new IntegerSchema())); + } + + @Test + public void resolveDefault_inlineDefault_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setDefault(10); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), 10); + } + + @Test + public void resolveDefault_refToSchemaWithDefault_resolvesRef() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema refTarget = new IntegerSchema(); + refTarget.setDefault(0); + openAPI.getComponents().addSchemas("MyInt", refTarget); + + Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); + assertEquals(ModelUtils.resolveDefault(openAPI, ref), 0); + } + + @Test + public void resolveDefault_allOfItemHasDefault_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema allOfItem = new IntegerSchema(); + allOfItem.setDefault(20); + openAPI.getComponents().addSchemas("Base", allOfItem); + + // Inline schema has no default; allOf item has default=20 + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), 20); + } + + @Test + public void resolveDefault_inlineDefaultTakesPrecedenceOverAllOf() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema allOfItem = new IntegerSchema(); + allOfItem.setDefault(99); + openAPI.getComponents().addSchemas("Base", allOfItem); + + // Inline schema default=5 should win over allOf item default=99 + Schema schema = new IntegerSchema(); + schema.setDefault(5); + schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), 5); + } + + @Test + public void resolveDefault_allOfItemsNoDefault_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + openAPI.getComponents().addSchemas("Base", new IntegerSchema()); // no default + + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertNull(ModelUtils.resolveDefault(openAPI, schema)); + } + + @Test + public void resolveDefault_stringDefault_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new StringSchema(); + schema.setDefault("hello"); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), "hello"); + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml index 86d398d2c407..e6368583e71a 100644 --- a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml @@ -540,6 +540,96 @@ paths: type: array items: $ref: '#/components/schemas/Pet' + /pet/findWithSizeConstraintFromAllOfRef: + get: + tags: + - pet + summary: Find pets — size maximum resolved from allOf $ref (no inline maximum) + operationId: findPetsWithSizeConstraintFromAllOfRef + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + allOf: + - $ref: '#/components/schemas/PageSizeWithMax' + - name: sort + in: query + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithDefaultFromAllOfRef: + get: + tags: + - pet + summary: Find pets — size default resolved from allOf $ref (no inline default) + operationId: findPetsWithDefaultFromAllOfRef + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + allOf: + - $ref: '#/components/schemas/PageSizeWithDefault' + - name: sort + in: query + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithMinSizeConstraintFromAllOfRef: + get: + tags: + - pet + summary: Find pets — size minimum resolved from allOf $ref (no inline minimum) + operationId: findPetsWithMinSizeConstraintFromAllOfRef + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + allOf: + - $ref: '#/components/schemas/PageSizeWithMin' + - name: sort + in: query + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' components: schemas: PetSort: @@ -549,6 +639,18 @@ components: - "id,desc" - "createdAt,asc" - "createdAt,desc" + PageSizeWithMax: + type: integer + format: int32 + maximum: 75 + PageSizeWithDefault: + type: integer + format: int32 + default: 7 + PageSizeWithMin: + type: integer + format: int32 + minimum: 5 Pet: type: object required: diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApi.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApi.kt index 23cd662f699c..98e5aa03d13e 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApi.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApi.kt @@ -104,6 +104,17 @@ interface PetApi { } + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithDefaultFromAllOfRef" + value = [PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF], + produces = ["application/json"] + ) + fun findPetsWithDefaultFromAllOfRef(@PageableDefault(size = 7) pageable: Pageable): ResponseEntity> { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + @RequestMapping( method = [RequestMethod.GET], // "/pet/findWithExternalParamRefArraySort" @@ -115,6 +126,17 @@ interface PetApi { } + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithMinSizeConstraintFromAllOfRef" + value = [PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF], + produces = ["application/json"] + ) + fun findPetsWithMinSizeConstraintFromAllOfRef(@ValidPageable(minSize = 5) pageable: Pageable): ResponseEntity> { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + @RequestMapping( method = [RequestMethod.GET], // "/pet/findWithMixedSortDefaults" @@ -181,6 +203,17 @@ interface PetApi { } + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithSizeConstraintFromAllOfRef" + value = [PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF], + produces = ["application/json"] + ) + fun findPetsWithSizeConstraintFromAllOfRef(@ValidPageable(maxSize = 75) pageable: Pageable): ResponseEntity> { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + @RequestMapping( method = [RequestMethod.GET], // "/pet/findWithSortDefaultAsc" @@ -235,13 +268,16 @@ interface PetApi { const val PATH_FIND_PETS_WITH_ALL_DEFAULTS: String = "/pet/findWithAllDefaults" const val PATH_FIND_PETS_WITH_ARRAY_SORT_ENUM: String = "/pet/findWithArraySortEnum" const val PATH_FIND_PETS_WITH_ARRAY_SORT_REF_ENUM: String = "/pet/findWithArraySortRefEnum" + const val PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF: String = "/pet/findWithDefaultFromAllOfRef" const val PATH_FIND_PETS_WITH_EXTERNAL_PARAM_REF_ARRAY_SORT: String = "/pet/findWithExternalParamRefArraySort" + const val PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF: String = "/pet/findWithMinSizeConstraintFromAllOfRef" const val PATH_FIND_PETS_WITH_MIXED_SORT_DEFAULTS: String = "/pet/findWithMixedSortDefaults" const val PATH_FIND_PETS_WITH_NON_EXPLODED_EXTERNAL_PARAM_REF_ARRAY_SORT: String = "/pet/findWithNonExplodedExternalParamRefArraySort" const val PATH_FIND_PETS_WITH_PAGE_AND_SIZE_CONSTRAINT: String = "/pet/findWithPageAndSizeConstraint" const val PATH_FIND_PETS_WITH_PAGE_SIZE_DEFAULTS_ONLY: String = "/pet/findWithPageSizeDefaultsOnly" const val PATH_FIND_PETS_WITH_REF_SORT: String = "/pet/findWithRefSort" const val PATH_FIND_PETS_WITH_SIZE_CONSTRAINT: String = "/pet/findWithSizeConstraint" + const val PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF: String = "/pet/findWithSizeConstraintFromAllOfRef" const val PATH_FIND_PETS_WITH_SORT_DEFAULT_ASC: String = "/pet/findWithSortDefaultAsc" const val PATH_FIND_PETS_WITH_SORT_DEFAULT_ONLY: String = "/pet/findWithSortDefaultOnly" const val PATH_FIND_PETS_WITH_SORT_ENUM: String = "/pet/findByStatusWithSort" diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt index 671e682ec6fe..095e3ba8fac5 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt @@ -7,12 +7,14 @@ import jakarta.validation.Payload import org.springframework.data.domain.Pageable /** - * Validates that the page number and page size in the annotated [Pageable] parameter do not - * exceed their configured maximums. + * Validates that the page number and page size in the annotated [Pageable] parameter are within + * their configured bounds. * * Apply directly on a `pageable: Pageable` parameter. Each attribute is independently optional: * - [maxSize] — when set (>= 0), validates `pageable.pageSize <= maxSize` * - [maxPage] — when set (>= 0), validates `pageable.pageNumber <= maxPage` + * - [minSize] — when set (>= 0), validates `pageable.pageSize >= minSize` + * - [minPage] — when set (>= 0), validates `pageable.pageNumber >= minPage` * * Use [NO_LIMIT] (= -1, the default) to leave an attribute unconstrained. * @@ -21,6 +23,8 @@ import org.springframework.data.domain.Pageable * * @property maxSize Maximum allowed page size, or [NO_LIMIT] if unconstrained * @property maxPage Maximum allowed page number (0-based), or [NO_LIMIT] if unconstrained + * @property minSize Minimum allowed page size, or [NO_LIMIT] if unconstrained + * @property minPage Minimum allowed page number (0-based), or [NO_LIMIT] if unconstrained * @property groups Validation groups (optional) * @property payload Additional payload (optional) * @property message Validation error message (default: "Invalid page request") @@ -32,6 +36,8 @@ import org.springframework.data.domain.Pageable annotation class ValidPageable( val maxSize: Int = ValidPageable.NO_LIMIT, val maxPage: Int = ValidPageable.NO_LIMIT, + val minSize: Int = ValidPageable.NO_LIMIT, + val minPage: Int = ValidPageable.NO_LIMIT, val groups: Array> = [], val payload: Array> = [], val message: String = "Invalid page request" @@ -45,10 +51,14 @@ class PageableConstraintValidator : ConstraintValidator private var maxSize = ValidPageable.NO_LIMIT private var maxPage = ValidPageable.NO_LIMIT + private var minSize = ValidPageable.NO_LIMIT + private var minPage = ValidPageable.NO_LIMIT override fun initialize(constraintAnnotation: ValidPageable) { maxSize = constraintAnnotation.maxSize maxPage = constraintAnnotation.maxPage + minSize = constraintAnnotation.minSize + minPage = constraintAnnotation.minPage } override fun isValid(pageable: Pageable?, context: ConstraintValidatorContext): Boolean { @@ -76,6 +86,24 @@ class PageableConstraintValidator : ConstraintValidator valid = false } + if (minSize >= 0 && pageable.pageSize < minSize) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page size ${pageable.pageSize} is below minimum $minSize" + ) + .addPropertyNode("size") + .addConstraintViolation() + valid = false + } + + if (minPage >= 0 && pageable.pageNumber < minPage) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page number ${pageable.pageNumber} is below minimum $minPage" + ) + .addPropertyNode("page") + .addConstraintViolation() + valid = false + } + return valid } } diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java index df6af79e77d0..57841e79adac 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java @@ -120,6 +120,22 @@ ResponseEntity> findPetsWithArraySortRefEnum( ); + String PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF = "/pet/findWithDefaultFromAllOfRef"; + /** + * GET /pet/findWithDefaultFromAllOfRef : Find pets — size default resolved from allOf $ref (no inline default) + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF, + produces = { "application/json" } + ) + ResponseEntity> findPetsWithDefaultFromAllOfRef( + @PageableDefault(size = 7) final Pageable pageable + ); + + String PATH_FIND_PETS_WITH_EXTERNAL_PARAM_REF_ARRAY_SORT = "/pet/findWithExternalParamRefArraySort"; /** * GET /pet/findWithExternalParamRefArraySort : Find pets with x-spring-paginated and sort param referenced from an external components file @@ -136,6 +152,22 @@ ResponseEntity> findPetsWithExternalParamRefArraySort( ); + String PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF = "/pet/findWithMinSizeConstraintFromAllOfRef"; + /** + * GET /pet/findWithMinSizeConstraintFromAllOfRef : Find pets — size minimum resolved from allOf $ref (no inline minimum) + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF, + produces = { "application/json" } + ) + ResponseEntity> findPetsWithMinSizeConstraintFromAllOfRef( + @ValidPageable(minSize = 5) final Pageable pageable + ); + + String PATH_FIND_PETS_WITH_MIXED_SORT_DEFAULTS = "/pet/findWithMixedSortDefaults"; /** * GET /pet/findWithMixedSortDefaults : Find pets — multiple sort defaults with mixed directions (array sort param) @@ -232,6 +264,22 @@ ResponseEntity> findPetsWithSizeConstraint( ); + String PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF = "/pet/findWithSizeConstraintFromAllOfRef"; + /** + * GET /pet/findWithSizeConstraintFromAllOfRef : Find pets — size maximum resolved from allOf $ref (no inline maximum) + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF, + produces = { "application/json" } + ) + ResponseEntity> findPetsWithSizeConstraintFromAllOfRef( + @ValidPageable(maxSize = 75) final Pageable pageable + ); + + String PATH_FIND_PETS_WITH_SORT_DEFAULT_ASC = "/pet/findWithSortDefaultAsc"; /** * GET /pet/findWithSortDefaultAsc : Find pets — sort default only (single field, no explicit direction defaults to ASC) diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java index bef1f3a47ab0..12b29649db49 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java @@ -90,6 +90,16 @@ public ResponseEntity> findPetsWithPageAndSizeConstraint(Pageable page return ResponseEntity.ok(Collections.emptyList()); } + @Override + public ResponseEntity> findPetsWithMinSizeConstraintFromAllOfRef(Pageable pageable) { + return ResponseEntity.ok(Collections.emptyList()); + } + + @Override + public ResponseEntity> findPetsWithSizeConstraintFromAllOfRef(Pageable pageable) { + return ResponseEntity.ok(Collections.emptyList()); + } + // ── @PageableDefault ───────────────────────────────────────────────────── // @PageableDefault(page = 0, size = 25) @@ -106,6 +116,17 @@ public ResponseEntity> findPetsWithPageSizeDefaultsOnly(Pageable pagea return ResponseEntity.ok(Collections.emptyList()); } + // @PageableDefault(size = 7) + + @Override + public ResponseEntity> findPetsWithDefaultFromAllOfRef(Pageable pageable) { + if (pageable.getPageSize() != 7) { + throw new IllegalStateException( + "@PageableDefault size: expected 7, got " + pageable.getPageSize()); + } + return ResponseEntity.ok(Collections.emptyList()); + } + // ── @SortDefault ───────────────────────────────────────────────────────── // @SortDefault(sort = {"name"}, direction = DESC) diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java index 04b2ce26a5fc..42995b27d115 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java @@ -13,13 +13,15 @@ import java.lang.annotation.Target; /** - * Validates that the page number and page size in the annotated {@link Pageable} parameter do not - * exceed their configured maximums. + * Validates that the page number and page size in the annotated {@link Pageable} parameter are + * within their configured bounds. * *

Apply directly on a {@code Pageable} parameter. Each attribute is independently optional: *

    *
  • {@link #maxSize()} — when set (>= 0), validates {@code pageable.getPageSize() <= maxSize} *
  • {@link #maxPage()} — when set (>= 0), validates {@code pageable.getPageNumber() <= maxPage} + *
  • {@link #minSize()} — when set (>= 0), validates {@code pageable.getPageSize() >= minSize} + *
  • {@link #minPage()} — when set (>= 0), validates {@code pageable.getPageNumber() >= minPage} *
* *

Use {@link #NO_LIMIT} (= {@code -1}, the default) to leave an attribute unconstrained. @@ -43,6 +45,12 @@ /** Maximum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ int maxPage() default NO_LIMIT; + /** Minimum allowed page size, or {@link #NO_LIMIT} if unconstrained. */ + int minSize() default NO_LIMIT; + + /** Minimum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ + int minPage() default NO_LIMIT; + Class[] groups() default {}; Class[] payload() default {}; @@ -53,11 +61,15 @@ class PageableConstraintValidator implements ConstraintValidator= 0 && pageable.getPageSize() < minSize) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page size " + pageable.getPageSize() + + " is below minimum " + minSize) + .addPropertyNode("size") + .addConstraintViolation(); + valid = false; + } + + if (minPage >= 0 && pageable.getPageNumber() < minPage) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page number " + pageable.getPageNumber() + + " is below minimum " + minPage) + .addPropertyNode("page") + .addConstraintViolation(); + valid = false; + } + return valid; } } diff --git a/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml b/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml index 295696bfc7fe..c5e0d4996181 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml +++ b/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml @@ -757,6 +757,135 @@ paths: - application/json x-tags: - tag: pet + /pet/findWithSizeConstraintFromAllOfRef: + get: + operationId: findPetsWithSizeConstraintFromAllOfRef + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + allOf: + - $ref: "#/components/schemas/PageSizeWithMax" + style: form + - explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets — size maximum resolved from allOf $ref (no inline maximum) + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithDefaultFromAllOfRef: + get: + operationId: findPetsWithDefaultFromAllOfRef + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + allOf: + - $ref: "#/components/schemas/PageSizeWithDefault" + style: form + - explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets — size default resolved from allOf $ref (no inline default) + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithMinSizeConstraintFromAllOfRef: + get: + operationId: findPetsWithMinSizeConstraintFromAllOfRef + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + allOf: + - $ref: "#/components/schemas/PageSizeWithMin" + style: form + - explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets — size minimum resolved from allOf $ref (no inline minimum) + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet components: parameters: PetSortParam: @@ -790,6 +919,18 @@ components: - "createdAt,asc" - "createdAt,desc" type: string + PageSizeWithMax: + format: int32 + maximum: 75 + type: integer + PageSizeWithDefault: + default: 7 + format: int32 + type: integer + PageSizeWithMin: + format: int32 + minimum: 5 + type: integer Pet: example: id: 0 diff --git a/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/api/PetApiValidationTest.java b/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/api/PetApiValidationTest.java index 7e3bceea5072..4cb3fda52b0d 100644 --- a/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/api/PetApiValidationTest.java +++ b/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/api/PetApiValidationTest.java @@ -178,4 +178,70 @@ void pageableDefaultAndSortDefaults_absentParamsResolveAllDefaults() throws Exce mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_ALL_DEFAULTS)) .andExpect(status().isOk()); } + + // ── @PageableDefault — size default from allOf $ref ─────────────────────── + // Endpoint: GET /pet/findWithDefaultFromAllOfRef @PageableDefault(size = 7) + // PetApiController asserts size == 7; returns 200 on success, throws on mismatch. + + @Test + void pageableDefault_absentSizeParamResolvesToSizeSevenDefault() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF)) + .andExpect(status().isOk()); + } + + // ── @ValidPageable — minSize constraint from allOf $ref ─────────────────── + // Endpoint: GET /pet/findWithMinSizeConstraintFromAllOfRef minSize = 5 + + @Test + void validPageable_sizeAboveMinimumReturns200() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "6")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_sizeAtMinimumReturns200() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "5")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_sizeBelowMinimumReturns400() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "4")) + .andExpect(status().isBadRequest()); + } + + @Test + void validPageable_unpagedPageableIsAllowedForMinConstraint() throws Exception { + // Unpaged Pageable (no params, no @PageableDefault) bypasses the validator per + // PageableConstraintValidator#isValid which returns true immediately for !isPaged(). + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF)) + .andExpect(status().isOk()); + } + + // ── @ValidPageable — maxSize constraint from allOf $ref ─────────────────── + // Endpoint: GET /pet/findWithSizeConstraintFromAllOfRef maxSize = 75 + + @Test + void validPageable_sizeBelowMaximumReturns200_forSizeConstraintFromAllOfRef() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "50")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_sizeAtMaximumReturns200_forSizeConstraintFromAllOfRef() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "75")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_sizeExceedsMaximumReturns400_forSizeConstraintFromAllOfRef() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "76")) + .andExpect(status().isBadRequest()); + } }