diff --git a/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt index 690fc60c..4a33b1ba 100644 --- a/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt +++ b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt @@ -12,10 +12,10 @@ import com.epages.restdocs.apispec.model.Attributes import com.epages.restdocs.apispec.model.FieldDescriptor import org.everit.json.schema.ArraySchema import org.everit.json.schema.BooleanSchema -import org.everit.json.schema.CombinedSchema import org.everit.json.schema.CombinedSchema.oneOf import org.everit.json.schema.EmptySchema import org.everit.json.schema.EnumSchema +import org.everit.json.schema.FormatValidator import org.everit.json.schema.NullSchema import org.everit.json.schema.NumberSchema import org.everit.json.schema.ObjectSchema @@ -24,9 +24,11 @@ import org.everit.json.schema.StringSchema import org.everit.json.schema.internal.JSONPrinter import tools.jackson.databind.SerializationFeature import tools.jackson.module.kotlin.jacksonMapperBuilder -import tools.jackson.module.kotlin.jacksonObjectMapper import java.io.StringWriter +import java.time.LocalDate +import java.time.format.DateTimeParseException import java.util.Collections.emptyList +import java.util.Optional import java.util.function.Predicate class JsonSchemaFromFieldDescriptorsGenerator { @@ -280,15 +282,27 @@ class JsonSchemaFromFieldDescriptorsGenerator { "boolean" -> BooleanSchema.builder() "number" -> NumberSchema.builder().applyConstraints(this) "string" -> StringSchema.builder().applyConstraints(this) + "date" -> + StringSchema + .builder() + .formatValidator(DATE_FORMAT_VALIDATOR) + .applyConstraints(this) "enum" -> - CombinedSchema - .oneOf( - listOf( - StringSchema.builder().build(), - EnumSchema.builder().possibleValues(this.attributes.enumValues).build(), - ), - ).isSynthetic(true) - else -> throw IllegalArgumentException("unknown field type $type") + oneOf( + listOf( + StringSchema.builder().build(), + EnumSchema.builder().possibleValues(this.attributes.enumValues).build(), + ), + ).isSynthetic(true) + else -> throw IllegalArgumentException( + """ + Unknown field type '$type'. + Supported types for FieldDescriptor are (case-insensitive): + STRING, NUMBER, BOOLEAN, OBJECT, ARRAY, DATE, ENUM, NULL, VARIES. + Note: VARIES is treated as an empty schema (accepts any value). + Note: DATE produces a string schema with format "date" (ISO-8601, e.g. "2024-01-15"). + """.trimIndent(), + ) } private fun NullSchema.Builder.nullable(): NullSchema.Builder { @@ -299,15 +313,14 @@ class JsonSchemaFromFieldDescriptorsGenerator { private fun arrayItemsSchema(): Schema = attributes.itemsType ?.let { typeToSchema(it.lowercase()).build() } - ?: CombinedSchema - .oneOf( - listOf( - ObjectSchema.builder().build(), - BooleanSchema.builder().build(), - StringSchema.builder().build(), - NumberSchema.builder().build(), - ), - ).build() + ?: oneOf( + listOf( + ObjectSchema.builder().build(), + BooleanSchema.builder().build(), + StringSchema.builder().build(), + NumberSchema.builder().build(), + ), + ).build() fun equalsOnPathAndType(f: FieldDescriptorWithSchemaType): Boolean = ( @@ -326,6 +339,31 @@ class JsonSchemaFromFieldDescriptorsGenerator { attributes = fieldDescriptor.attributes, ) + /** + * Format validator for the "date" type. + * Accepts ISO-8601 date strings (e.g. "2024-01-15"). + * Returns a non-empty Optional with an error message when the value is not a valid date. + * + * Must be registered explicitly in [org.everit.json.schema.loader.SchemaLoader] via + * `addFormatValidator(DATE_FORMAT_VALIDATOR)` when reloading a schema from its JSON + * representation — otherwise the `"format": "date"` property is treated as a plain + * annotation and format validation is silently skipped. + */ + val DATE_FORMAT_VALIDATOR: FormatValidator = + object : FormatValidator { + override fun validate(subject: String): Optional = + try { + LocalDate.parse(subject) + Optional.empty() + } catch ( + @Suppress("SwallowedException") exception: DateTimeParseException, + ) { + Optional.of("'$subject' is not a valid ISO-8601 date (expected format: yyyy-MM-dd)") + } + + override fun formatName(): String = "date" + } + private fun jsonSchemaPrimitiveTypeFromDescriptorType(fieldDescriptorType: String) = fieldDescriptorType .lowercase() diff --git a/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt b/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt index c1abb4fc..1d858c0f 100644 --- a/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt +++ b/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt @@ -271,11 +271,124 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { then(objSchema.requiredProperties).contains("array") } + @Test + fun should_generate_schema_for_date_field() { + givenFieldDescriptorWithDateType() + + whenSchemaGenerated() + + then(schema).isInstanceOf(ObjectSchema::class.java) + val objectSchema = schema as ObjectSchema + then(objectSchema.definesProperty("createdAt")).isTrue() + val dateSchema = objectSchema.propertySchemas["createdAt"] + then(dateSchema).isInstanceOf(StringSchema::class.java) + then(JsonPath.read(schemaString, "properties.createdAt.format")).isEqualTo("date") + thenSchemaIsValid() + } + + @Test + fun should_generate_schema_for_date_field_with_format_annotation() { + givenFieldDescriptorWithDateType() + + // SchemaLoader must be configured with DATE_FORMAT_VALIDATOR to enforce "format": "date". + // Without it the format key is a plain annotation and any string passes validation. + whenSchemaGeneratedWithDateFormatValidation() + + then(JsonPath.read(schemaString, "properties.createdAt.format")).isEqualTo("date") + thenSchemaIsValid() + thenSchemaValidatesJson("""{"createdAt": "2024-01-15"}""") + thenSchemaDoesNotValidateJson("""{"createdAt": "not-a-date"}""") + thenSchemaDoesNotValidateJson("""{"createdAt": "2024/01/15"}""") + thenSchemaDoesNotValidateJson("""{"createdAt": "15-01-2024"}""") + } + + @Test + fun should_generate_schema_for_varies_field() { + givenFieldDescriptorWithVariesType() + + whenSchemaGenerated() + + then(schema).isInstanceOf(ObjectSchema::class.java) + thenSchemaIsValid() + // VARIES maps to "empty" which allows any value + thenSchemaValidatesJson("""{"data": "string value"}""") + } + + @Test + fun should_generate_string_schema_with_not_blank_constraint_as_min_length_1() { + givenFieldDescriptorWithNotBlankConstraint() + + whenSchemaGenerated() + + then(schema).isInstanceOf(ObjectSchema::class.java) + val objectSchema = schema as ObjectSchema + val tenantSchema = objectSchema.propertySchemas["tenant"] as StringSchema + then(tenantSchema.minLength).isEqualTo(1) + @Suppress("USELESS_CAST") + then(tenantSchema.maxLength as Int?).isNull() + thenSchemaIsValid() + } + + @Test + fun should_generate_string_schema_with_size_constraint_as_min_and_max_length() { + givenFieldDescriptorWithSizeConstraintOnStringField() + + whenSchemaGenerated() + + then(schema).isInstanceOf(ObjectSchema::class.java) + val objectSchema = schema as ObjectSchema + val codeSchema = objectSchema.propertySchemas["code"] as StringSchema + then(codeSchema.minLength).isEqualTo(3) + then(codeSchema.maxLength).isEqualTo(10) + thenSchemaIsValid() + } + + @Test + fun should_generate_string_schema_with_pattern_and_length_constraints_combined() { + givenFieldDescriptorWithPatternAndLengthConstraints() + + whenSchemaGenerated() + + then(schema).isInstanceOf(ObjectSchema::class.java) + val objectSchema = schema as ObjectSchema + val localeSchema = objectSchema.propertySchemas["locale"] as StringSchema + then(localeSchema.pattern.pattern()).isEqualTo("[a-z]{2}-[A-Z]{2}") + then(localeSchema.minLength).isEqualTo(5) + then(localeSchema.maxLength).isEqualTo(5) + thenSchemaIsValid() + } + @Test fun should_fail_on_unknown_field_type() { givenFieldDescriptorWithInvalidType() - thenThrownBy { this.whenSchemaGenerated() }.isInstanceOf(IllegalArgumentException::class.java) + thenThrownBy { this.whenSchemaGenerated() } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("invalid-type") + .hasMessageContaining("STRING") + .hasMessageContaining("DATE") + .hasMessageContaining("VARIES") + .hasMessageContaining("ISO-8601") + } + + @Test + fun should_include_supported_types_in_error_message_for_unsupported_type() { + fieldDescriptors = listOf(FieldDescriptor("field", "some field", "DATETIME")) + + thenThrownBy { this.whenSchemaGenerated() } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("datetime") + .hasMessageContainingAll( + "STRING", + "NUMBER", + "BOOLEAN", + "OBJECT", + "ARRAY", + "DATE", + "ENUM", + "NULL", + "VARIES", + ) } @Test @@ -476,6 +589,20 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { .build() } + private fun whenSchemaGeneratedWithDateFormatValidation() { + schemaString = generator.generateSchema(fieldDescriptors!!) + schema = + SchemaLoader + .builder() + .nullableSupport(true) + .schemaJson(JSONObject(schemaString)) + .schemaClient(DefaultSchemaClient()) + .addFormatValidator(JsonSchemaFromFieldDescriptorsGenerator.FieldDescriptorWithSchemaType.DATE_FORMAT_VALIDATOR) + .build() + .load() + .build() + } + private fun givenFieldDescriptorWithPrimitiveArray() { fieldDescriptors = listOf(FieldDescriptor("a[]", "some", "ARRAY")) } @@ -617,6 +744,84 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { ) } + private fun givenFieldDescriptorWithDateType() { + fieldDescriptors = + listOf( + FieldDescriptor("createdAt", "Creation date", "DATE"), + ) + } + + private fun givenFieldDescriptorWithVariesType() { + fieldDescriptors = + listOf( + FieldDescriptor("data", "Any value", "VARIES"), + ) + } + + private fun givenFieldDescriptorWithNotBlankConstraint() { + fieldDescriptors = + listOf( + FieldDescriptor( + "tenant", + "Tenant identifier", + "STRING", + attributes = + Attributes( + listOf( + Constraint( + "javax.validation.constraints.NotBlank", + emptyMap(), + ), + ), + ), + ), + ) + } + + private fun givenFieldDescriptorWithSizeConstraintOnStringField() { + fieldDescriptors = + listOf( + FieldDescriptor( + "code", + "Country code", + "STRING", + attributes = + Attributes( + listOf( + Constraint( + "org.hibernate.validator.constraints.Length", + mapOf("min" to 3, "max" to 10), + ), + ), + ), + ), + ) + } + + private fun givenFieldDescriptorWithPatternAndLengthConstraints() { + fieldDescriptors = + listOf( + FieldDescriptor( + "locale", + "Locale code, e.g. en-US", + "STRING", + attributes = + Attributes( + listOf( + Constraint( + "javax.validation.constraints.Pattern", + mapOf("regexp" to "[a-z]{2}-[A-Z]{2}"), + ), + Constraint( + "org.hibernate.validator.constraints.Length", + mapOf("min" to 5, "max" to 5), + ), + ), + ), + ), + ) + } + private fun givenFieldDescriptorWithInvalidType() { fieldDescriptors = listOf(FieldDescriptor("id", "some", "invalid-type")) } diff --git a/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3Generator.kt b/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3Generator.kt index 4b4b10bf..5ce30b42 100644 --- a/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3Generator.kt +++ b/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3Generator.kt @@ -2,6 +2,7 @@ package com.epages.restdocs.apispec.openapi3 import com.epages.restdocs.apispec.jsonschema.JsonSchemaFromFieldDescriptorsGenerator import com.epages.restdocs.apispec.model.AbstractParameterDescriptor +import com.epages.restdocs.apispec.model.Attributes import com.epages.restdocs.apispec.model.FieldDescriptor import com.epages.restdocs.apispec.model.HTTPMethod import com.epages.restdocs.apispec.model.HeaderDescriptor @@ -534,7 +535,7 @@ object OpenApi3Generator { example = headerDescriptor.example } - private fun simpleTypeToSchema(parameterDescriptor: AbstractParameterDescriptor): Schema<*>? = + private fun simpleTypeToSchema(parameterDescriptor: AbstractParameterDescriptor): Schema<*> = when (parameterDescriptor.type.lowercase()) { SimpleType.BOOLEAN.name.lowercase() -> BooleanSchema().apply { @@ -550,6 +551,18 @@ object OpenApi3Generator { parameterDescriptor.attributes.enumValues .map { it as String } .forEach { this.addEnumItem(it) } + + ParameterConstraintResolver + .minLengthString(parameterDescriptor.attributes) + ?.let { minLength(it) } + + ParameterConstraintResolver + .maxLengthString(parameterDescriptor.attributes) + ?.let { maxLength(it) } + + ParameterConstraintResolver + .pattern(parameterDescriptor.attributes) + ?.let { pattern(it) } } SimpleType.NUMBER.name.lowercase() -> @@ -558,6 +571,14 @@ object OpenApi3Generator { parameterDescriptor.attributes.enumValues .map { it.asBigDecimal() } .forEach { this.addEnumItem(it) } + + ParameterConstraintResolver + .minimum(parameterDescriptor.attributes) + ?.let { minimum(it) } + + ParameterConstraintResolver + .maximum(parameterDescriptor.attributes) + ?.let { maximum(it) } } SimpleType.INTEGER.name.lowercase() -> @@ -566,6 +587,14 @@ object OpenApi3Generator { parameterDescriptor.attributes.enumValues .map { it.asInt() } .forEach { this.addEnumItem(it) } + + ParameterConstraintResolver + .minimum(parameterDescriptor.attributes) + ?.let { minimum(it) } + + ParameterConstraintResolver + .maximum(parameterDescriptor.attributes) + ?.let { maximum(it) } } else -> throw IllegalArgumentException("Unknown type '${parameterDescriptor.type}'") @@ -601,3 +630,127 @@ object OpenApi3Generator { val response: ResponseModel, ) } + +/** + * Resolves Bean Validation constraints stored in [Attributes.validationConstraints] + * for use in OpenAPI parameter schemas. + * + * Handles: @Size, @Length, @NotEmpty, @NotBlank and @Pattern for strings, + * and @Min and @Max for numbers. + */ +private object ParameterConstraintResolver { + private val NOT_EMPTY_CONSTRAINTS = + setOf( + "org.hibernate.validator.constraints.NotEmpty", + "javax.validation.constraints.NotEmpty", + "jakarta.validation.constraints.NotEmpty", + ) + + private val NOT_BLANK_CONSTRAINTS = + setOf( + "javax.validation.constraints.NotBlank", + "org.hibernate.validator.constraints.NotBlank", + "jakarta.validation.constraints.NotBlank", + ) + + private const val LENGTH_CONSTRAINT = + "org.hibernate.validator.constraints.Length" + + private val SIZE_CONSTRAINTS = + setOf( + "javax.validation.constraints.Size", + "jakarta.validation.constraints.Size", + ) + + private val PATTERN_CONSTRAINTS = + setOf( + "javax.validation.constraints.Pattern", + "jakarta.validation.constraints.Pattern", + ) + + private val MIN_CONSTRAINTS = + setOf( + "javax.validation.constraints.Min", + "jakarta.validation.constraints.Min", + ) + + private val MAX_CONSTRAINTS = + setOf( + "javax.validation.constraints.Max", + "jakarta.validation.constraints.Max", + ) + + /** + * Returns the minimum length for a string parameter, + * derived from @NotEmpty, @NotBlank, @Length or @Size. + */ + fun minLengthString(attributes: Attributes): Int? = + attributes.validationConstraints + .mapNotNull { constraint -> + when { + constraint.name in NOT_EMPTY_CONSTRAINTS || + constraint.name in NOT_BLANK_CONSTRAINTS -> 1 + + constraint.name == LENGTH_CONSTRAINT || + constraint.name in SIZE_CONSTRAINTS -> + constraint.configuration.intValue("min") + + else -> null + } + }.maxOrNull() + + /** + * Returns the maximum length for a string parameter, + * derived from @Length or @Size. + */ + fun maxLengthString(attributes: Attributes): Int? = + attributes.validationConstraints + .filter { + it.name == LENGTH_CONSTRAINT || + it.name in SIZE_CONSTRAINTS + }.mapNotNull { + it.configuration.intValue("max") + }.minOrNull() + + /** + * Returns the pattern for a string parameter, + * derived from @Pattern. + */ + fun pattern(attributes: Attributes): String? = + attributes.validationConstraints + .firstOrNull { it.name in PATTERN_CONSTRAINTS } + ?.configuration + ?.get("regexp") as? String + + /** + * Returns the minimum numeric value for a number/integer parameter, + * derived from @Min. + */ + fun minimum(attributes: Attributes): BigDecimal? = + attributes.validationConstraints + .filter { it.name in MIN_CONSTRAINTS } + .mapNotNull { + it.configuration.bigDecimalValue("value") + }.maxOrNull() + + /** + * Returns the maximum numeric value for a number/integer parameter, + * derived from @Max. + */ + fun maximum(attributes: Attributes): BigDecimal? = + attributes.validationConstraints + .filter { it.name in MAX_CONSTRAINTS } + .mapNotNull { + it.configuration.bigDecimalValue("value") + }.minOrNull() + + private fun Map.intValue(name: String): Int? = (this[name] as? Number)?.toInt() + + private fun Map.bigDecimalValue(name: String): BigDecimal? = + when (val value = this[name]) { + is BigDecimal -> value + is Number -> value.toString().toBigDecimalOrNull() + is String -> value.toBigDecimalOrNull() + else -> null + } +} diff --git a/restdocs-api-spec-openapi3-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3GeneratorTest.kt b/restdocs-api-spec-openapi3-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3GeneratorTest.kt index 1f9c565c..a8401e47 100644 --- a/restdocs-api-spec-openapi3-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3GeneratorTest.kt +++ b/restdocs-api-spec-openapi3-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3GeneratorTest.kt @@ -1,6 +1,7 @@ package com.epages.restdocs.apispec.openapi3 import com.epages.restdocs.apispec.model.Attributes +import com.epages.restdocs.apispec.model.Constraint import com.epages.restdocs.apispec.model.FieldDescriptor import com.epages.restdocs.apispec.model.HTTPMethod import com.epages.restdocs.apispec.model.HeaderDescriptor @@ -409,6 +410,112 @@ class OpenApi3GeneratorTest { thenOpenApiSpecIsValid() } + @Test + fun `should reflect Size constraint as minLength and maxLength on string path parameter`() { + givenResourcesWithValidationConstraintsOnParameters() + + whenOpenApiObjectGenerated() + + val params = openApiJsonPathContext.read>>("paths./users/{id}.get.parameters.*") + then(params).anyMatch { + it["name"] == "id" && + it["in"] == "path" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "string" && + (it["schema"] as LinkedHashMap<*, *>)["minLength"] == 1 && + (it["schema"] as LinkedHashMap<*, *>)["maxLength"] == 36 + } + + thenOpenApiSpecIsValid() + } + + @Test + fun `should reflect Length constraint as minLength and maxLength on string query parameter`() { + givenResourcesWithValidationConstraintsOnParameters() + + whenOpenApiObjectGenerated() + + val params = openApiJsonPathContext.read>>("paths./users/{id}.get.parameters.*") + then(params).anyMatch { + it["name"] == "filter" && + it["in"] == "query" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "string" && + (it["schema"] as LinkedHashMap<*, *>)["minLength"] == 2 && + (it["schema"] as LinkedHashMap<*, *>)["maxLength"] == 50 + } + + thenOpenApiSpecIsValid() + } + + @Test + fun `should reflect Pattern constraint on string query parameter`() { + givenResourcesWithValidationConstraintsOnParameters() + + whenOpenApiObjectGenerated() + + val params = openApiJsonPathContext.read>>("paths./users/{id}.get.parameters.*") + then(params).anyMatch { + it["name"] == "code" && + it["in"] == "query" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "string" && + (it["schema"] as LinkedHashMap<*, *>)["pattern"] == "[A-Z]{3}" + } + + thenOpenApiSpecIsValid() + } + + @Test + fun `should reflect NotBlank constraint as minLength 1 on string header parameter`() { + givenResourcesWithValidationConstraintsOnParameters() + + whenOpenApiObjectGenerated() + + val params = openApiJsonPathContext.read>>("paths./users/{id}.get.parameters.*") + then(params).anyMatch { + it["name"] == "X-TENANT" && + it["in"] == "header" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "string" && + (it["schema"] as LinkedHashMap<*, *>)["minLength"] == 1 + } + + thenOpenApiSpecIsValid() + } + + @Test + fun `should combine NotBlank and Size constraints on string query parameter`() { + givenResourcesWithValidationConstraintsOnParameters() + + whenOpenApiObjectGenerated() + + val params = openApiJsonPathContext.read>>("paths./users/{id}.get.parameters.*") + then(params).anyMatch { + it["name"] == "search" && + it["in"] == "query" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "string" && + (it["schema"] as LinkedHashMap<*, *>)["minLength"] == 1 && + (it["schema"] as LinkedHashMap<*, *>)["maxLength"] == 50 + } + + thenOpenApiSpecIsValid() + } + + @Test + fun `should reflect Min and Max constraints on integer query parameter`() { + givenResourcesWithValidationConstraintsOnParameters() + + whenOpenApiObjectGenerated() + + val params = openApiJsonPathContext.read>>("paths./users/{id}.get.parameters.*") + then(params).anyMatch { + it["name"] == "page" && + it["in"] == "query" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "integer" && + (it["schema"] as LinkedHashMap<*, *>)["minimum"] == 0 && + (it["schema"] as LinkedHashMap<*, *>)["maximum"] == 100 + } + + thenOpenApiSpecIsValid() + } + @Test fun `should fail for enum values request parameter with wrong type`() { givenResourcesWithRequestParameterWithWrongEnumValues() @@ -972,6 +1079,154 @@ class OpenApi3GeneratorTest { ) } + private fun givenResourcesWithValidationConstraintsOnParameters() { + resources = + listOf( + ResourceModel( + operationId = "getUser", + summary = "Get a user", + description = "Returns a user by id", + privateResource = false, + deprecated = false, + tags = setOf("users"), + request = + RequestModel( + path = "/users/{id}", + method = HTTPMethod.GET, + headers = + listOf( + HeaderDescriptor( + name = "X-TENANT", + description = "Tenant identifier", + type = "STRING", + optional = false, + attributes = + Attributes( + validationConstraints = + listOf( + Constraint( + "javax.validation.constraints.NotBlank", + emptyMap(), + ), + ), + ), + ), + ), + pathParameters = + listOf( + ParameterDescriptor( + name = "id", + description = "User ID", + type = "STRING", + optional = false, + ignored = false, + attributes = + Attributes( + validationConstraints = + listOf( + Constraint( + "javax.validation.constraints.Size", + mapOf("min" to 1, "max" to 36), + ), + ), + ), + ), + ), + queryParameters = + listOf( + ParameterDescriptor( + name = "filter", + description = "Filter expression", + type = "STRING", + optional = true, + ignored = false, + attributes = + Attributes( + validationConstraints = + listOf( + Constraint( + "org.hibernate.validator.constraints.Length", + mapOf("min" to 2, "max" to 50), + ), + ), + ), + ), + ParameterDescriptor( + name = "code", + description = "Country code", + type = "STRING", + optional = true, + ignored = false, + attributes = + Attributes( + validationConstraints = + listOf( + Constraint( + "javax.validation.constraints.Pattern", + mapOf("regexp" to "[A-Z]{3}"), + ), + ), + ), + ), + ParameterDescriptor( + name = "search", + description = "Search term", + type = "STRING", + optional = true, + ignored = false, + attributes = + Attributes( + validationConstraints = + listOf( + Constraint( + "jakarta.validation.constraints.NotBlank", + emptyMap(), + ), + Constraint( + "jakarta.validation.constraints.Size", + mapOf("min" to 0, "max" to 50), + ), + ), + ), + ), + ParameterDescriptor( + name = "page", + description = "Page number", + type = "INTEGER", + optional = true, + ignored = false, + attributes = + Attributes( + validationConstraints = + listOf( + Constraint( + "javax.validation.constraints.Min", + mapOf("value" to 0), + ), + Constraint( + "javax.validation.constraints.Max", + mapOf("value" to 100), + ), + ), + ), + ), + ), + formParameters = listOf(), + requestFields = listOf(), + securityRequirements = null, + ), + response = + ResponseModel( + status = 200, + contentType = "application/json", + headers = emptyList(), + responseFields = listOf(), + example = """{"id": "abc"}""", + ), + ), + ) + } + private fun givenDeleteProductResourceModel() { resources = listOf( diff --git a/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/ProductSearchController.java b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/ProductSearchController.java new file mode 100644 index 00000000..1317609e --- /dev/null +++ b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/ProductSearchController.java @@ -0,0 +1,63 @@ +package com.epages.restdocs.apispec.sample; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.stream.StreamSupport; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Demonstrates Bean Validation constraints on {@code @RequestParam} and {@code @PathVariable} + * parameters. When tests document these endpoints with + * {@code ResourceDocumentation.parameterWithName(...)} and the + * {@code "validationConstraints"} attribute, the generated OpenAPI specification reflects the + * constraints as {@code minLength}, {@code maxLength}, {@code pattern}, {@code minimum} and + * {@code maximum} fields on the parameter schemas — thanks to + * {@code ParameterConstraintResolver} in the OpenAPI 3 generator. + */ +@RestController +@RequestMapping("/product-search") +public class ProductSearchController { + + private final ProductRepository productRepository; + + public ProductSearchController(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + @GetMapping + public ResponseEntity> search( + @RequestParam @NotBlank @Size(max = 50) String name, + @RequestParam(required = false, defaultValue = "0") @Min(0) @Max(100) Integer page, + @RequestParam(required = false) @Pattern(regexp = "[A-Z]{3}") String currency) { + + List results = StreamSupport + .stream(productRepository.findAll().spliterator(), false) + .filter(p -> p.getName().toLowerCase().contains(name.toLowerCase())) + .map(p -> new ProductView(p.getName(), p.getPrice().toPlainString(), currency)) + .toList(); + + return ResponseEntity.ok(results); + } + + @GetMapping("/{sku}") + public ResponseEntity getBySku( + @PathVariable @Size(min = 8, max = 8) @Pattern(regexp = "[A-Z]{3}[0-9]{5}") String sku) { + + return StreamSupport.stream(productRepository.findAll().spliterator(), false) + .findFirst() + .map(p -> new ProductView(p.getName(), p.getPrice().toPlainString(), null)) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + public record ProductView(String name, String price, String currency) {} +} diff --git a/samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/apispec/sample/ProductSearchIntegrationTest.java b/samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/apispec/sample/ProductSearchIntegrationTest.java new file mode 100644 index 00000000..8b663903 --- /dev/null +++ b/samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/apispec/sample/ProductSearchIntegrationTest.java @@ -0,0 +1,373 @@ +package com.epages.restdocs.apispec.sample; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.snippet.Attributes.key; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.epages.restdocs.apispec.SimpleType; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.restdocs.test.autoconfigure.AutoConfigureRestDocs; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.restdocs.constraints.Constraint; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * Demonstrates how to attach Bean Validation constraints to path/query parameters so that the + * generated OpenAPI specification includes {@code minLength}, {@code maxLength}, {@code pattern}, + * {@code minimum} and {@code maximum} on the parameter schemas. + * + * The key ingredient is the {@code "validationConstraints"} attribute on a parameter descriptor. + * The OpenAPI 3 generator's {@code ParameterConstraintResolver} reads these constraints and + * translates them into the appropriate JSON Schema keywords. + * + *

Use {@link org.springframework.restdocs.snippet.Attributes#key} to attach constraints: + *

{@code
+ * parameterWithName("id")
+ *     .description("User ID")
+ *     .attributes(key("validationConstraints").value(
+ *         List.of(new Constraint("javax.validation.constraints.Size",
+ *                 Map.of("min", 1, "max", 36)))
+ *     ))
+ * }
+ */ +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@SpringBootTest +@ExtendWith(SpringExtension.class) +public class ProductSearchIntegrationTest extends BaseIntegrationTest { + + private static final String SIZE = "jakarta.validation.constraints.Size"; + private static final String NOT_BLANK = "jakarta.validation.constraints.NotBlank"; + private static final String PATTERN = "jakarta.validation.constraints.Pattern"; + private static final String MIN = "jakarta.validation.constraints.Min"; + private static final String MAX = "jakarta.validation.constraints.Max"; + + /** + * GET /product-search?name=&page=¤cy= + * + *

Shows how {@code @NotBlank + @Size(max=50)} on a {@code String} query parameter + * becomes {@code minLength: 1, maxLength: 50} in OpenAPI, and how {@code @Min + @Max} on an + * {@code Integer} becomes {@code minimum: 0, maximum: 100}. + */ + @Test + public void should_document_product_search_with_constrained_query_parameters() throws Exception { + givenProduct("Fancy Shirt", "15.10"); + + resultActions = mockMvc.perform(get("/product-search") + .param("name", "Fancy") + .param("page", "0") + .param("currency", "EUR")) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1)))) + .andExpect(jsonPath("$[0].name", notNullValue())) + .andExpect(jsonPath("$[0].price", notNullValue())) + .andDo(document("product-search", + resource(ResourceSnippetParameters.builder() + .summary("Search products") + .description("Returns products whose name contains the given search term.") + .queryParameters( + + // required – @NotBlank + @Size(max=50) on String + // → OpenAPI: type: string, minLength: 1, maxLength: 50 + parameterWithName("name") + .description("Case-insensitive product name filter (required, 1–50 characters).") + .attributes(key("validationConstraints").value( + List.of( + new Constraint(NOT_BLANK, Map.of()), + new Constraint(SIZE, Map.of("max", 50)) + ) + )), + + // optional – @Min(0) + @Max(100) on Integer + // → OpenAPI: type: integer, minimum: 0, maximum: 100 + parameterWithName("page") + .type(SimpleType.INTEGER) + .optional() + .description("Zero-based page number (default 0, range 0–100).") + .attributes(key("validationConstraints").value( + List.of( + new Constraint(MIN, Map.of("value", 0)), + new Constraint(MAX, Map.of("value", 100)) + ) + )), + + // optional – @Pattern(regexp="[A-Z]{3}") on String + // → OpenAPI: type: string, pattern: "[A-Z]{3}" + parameterWithName("currency") + .optional() + .description("ISO 4217 three-letter currency code, e.g. EUR, USD, PLN.") + .attributes(key("validationConstraints").value( + List.of( + new Constraint(PATTERN, Map.of("regexp", "[A-Z]{3}")) + ) + )) + ) + .responseFields( + fieldWithPath("[].name").description("Product name."), + fieldWithPath("[].price").description("Product price as a plain decimal string."), + fieldWithPath("[].currency").description("ISO 4217 currency code passed in the request; null when not supplied.").optional() + ) + .build() + ) + )); + } + + /** + * GET /product-search/{sku} + * + *

Shows how {@code @Size(min=8, max=8) + @Pattern(regexp="[A-Z]{3}[0-9]{5}")} on a + * {@code String} path variable becomes {@code minLength: 8, maxLength: 8, + * pattern: "[A-Z]{3}[0-9]{5}"} in OpenAPI. + */ + @Test + public void should_document_get_product_by_sku_with_constrained_path_variable() throws Exception { + givenProduct(); + + resultActions = mockMvc.perform(get("/product-search/{sku}", "SHR00001")) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name", notNullValue())) + .andExpect(jsonPath("$.price", notNullValue())) + .andDo(document("product-get-by-sku", + resource(ResourceSnippetParameters.builder() + .summary("Get product by SKU") + .description("Retrieves a product identified by its 8-character SKU.") + .pathParameters( + + // @Size(min=8, max=8) + @Pattern on String + // → OpenAPI: type: string, minLength: 8, maxLength: 8, + // pattern: "[A-Z]{3}[0-9]{5}" + parameterWithName("sku") + .description("Stock-keeping unit: exactly 8 characters — 3 uppercase letters followed by 5 digits (e.g. CAP00001).") + .attributes(key("validationConstraints").value( + List.of( + new Constraint(SIZE, Map.of("min", 8, "max", 8)), + new Constraint(PATTERN, Map.of("regexp", "[A-Z]{3}[0-9]{5}")) + ) + )) + ) + .responseFields( + fieldWithPath("name").description("Product name."), + fieldWithPath("price").description("Product price as a plain decimal string."), + fieldWithPath("currency").description("Currency code; null when not requested.").optional() + ) + .build() + ) + )); + } + + @Test + public void should_return_400_when_name_is_blank() throws Exception { + resultActions = mockMvc.perform(get("/product-search") + .param("name", "")) + .andDo(print()); + + resultActions + .andExpect(status().isBadRequest()) + .andDo(document("product-search-name-blank", + resource(ResourceSnippetParameters.builder() + .summary("Search products – blank name (400)") + .description("Returns 400 when the required `name` parameter is blank (violates @NotBlank).") + .queryParameters( + parameterWithName("name") + .description("Blank value — violates @NotBlank constraint.") + .attributes(key("validationConstraints").value( + List.of(new Constraint(NOT_BLANK, Map.of())) + )) + ) + .build() + ) + )); + } + + @Test + public void should_return_400_when_name_exceeds_max_length() throws Exception { + String tooLongName = "A".repeat(51); + + resultActions = mockMvc.perform(get("/product-search") + .param("name", tooLongName)) + .andDo(print()); + + resultActions + .andExpect(status().isBadRequest()) + .andDo(document("product-search-name-too-long", + resource(ResourceSnippetParameters.builder() + .summary("Search products – name too long (400)") + .description("Returns 400 when `name` exceeds 50 characters (violates @Size(max=50)).") + .queryParameters( + parameterWithName("name") + .description("Value of 51+ characters — violates @Size(max=50) constraint.") + .attributes(key("validationConstraints").value( + List.of(new Constraint(SIZE, Map.of("max", 50))) + )) + ) + .build() + ) + )); + } + + @Test + public void should_return_400_when_page_is_negative() throws Exception { + resultActions = mockMvc.perform(get("/product-search") + .param("name", "Shirt") + .param("page", "-1")) + .andDo(print()); + + resultActions + .andExpect(status().isBadRequest()) + .andDo(document("product-search-page-negative", + resource(ResourceSnippetParameters.builder() + .summary("Search products – page below minimum (400)") + .description("Returns 400 when `page` is negative (violates @Min(0)).") + .queryParameters( + parameterWithName("name") + .description("Product name filter.") + .attributes(key("validationConstraints").value( + List.of(new Constraint(NOT_BLANK, Map.of())) + )), + parameterWithName("page") + .type(SimpleType.INTEGER) + .optional() + .description("Negative value — violates @Min(0) constraint.") + .attributes(key("validationConstraints").value( + List.of(new Constraint(MIN, Map.of("value", 0))) + )) + ) + .build() + ) + )); + } + + /** + * Verifies that a {@code page} value above 100 triggers 400. + * The {@code @Max(100)} constraint enforces the upper bound. + */ + @Test + public void should_return_400_when_page_exceeds_maximum() throws Exception { + resultActions = mockMvc.perform(get("/product-search") + .param("name", "Shirt") + .param("page", "101")) + .andDo(print()); + + resultActions + .andExpect(status().isBadRequest()) + .andDo(document("product-search-page-too-large", + resource(ResourceSnippetParameters.builder() + .summary("Search products – page above maximum (400)") + .description("Returns 400 when `page` exceeds 100 (violates @Max(100)).") + .queryParameters( + parameterWithName("name") + .description("Product name filter.") + .attributes(key("validationConstraints").value( + List.of(new Constraint(NOT_BLANK, Map.of())) + )), + parameterWithName("page") + .type(SimpleType.INTEGER) + .optional() + .description("Value of 101 — violates @Max(100) constraint.") + .attributes(key("validationConstraints").value( + List.of(new Constraint(MAX, Map.of("value", 100))) + )) + ) + .build() + ) + )); + } + + @Test + public void should_return_400_when_currency_does_not_match_pattern() throws Exception { + resultActions = mockMvc.perform(get("/product-search") + .param("name", "Shirt") + .param("currency", "eur")) // lowercase – violates [A-Z]{3} + .andDo(print()); + + resultActions + .andExpect(status().isBadRequest()) + .andDo(document("product-search-currency-invalid", + resource(ResourceSnippetParameters.builder() + .summary("Search products – invalid currency code (400)") + .description("Returns 400 when `currency` does not match `[A-Z]{3}` (violates @Pattern).") + .queryParameters( + parameterWithName("name") + .description("Product name filter.") + .attributes(key("validationConstraints").value( + List.of(new Constraint(NOT_BLANK, Map.of())) + )), + parameterWithName("currency") + .optional() + .description("Lowercase value 'eur' — violates @Pattern(regexp=\"[A-Z]{3}\") constraint.") + .attributes(key("validationConstraints").value( + List.of(new Constraint(PATTERN, Map.of("regexp", "[A-Z]{3}"))) + )) + ) + .build() + ) + )); + } + + @Test + public void should_return_400_when_sku_has_wrong_length() throws Exception { + resultActions = mockMvc.perform(get("/product-search/{sku}", "CAP001")) // 6 chars + .andDo(print()); + + resultActions + .andExpect(status().isBadRequest()) + .andDo(document("product-get-by-sku-wrong-length", + resource(ResourceSnippetParameters.builder() + .summary("Get product by SKU – wrong length (400)") + .description("Returns 400 when the SKU is not exactly 8 characters (violates @Size(min=8, max=8)).") + .pathParameters( + parameterWithName("sku") + .description("6-character SKU 'CAP001' — violates @Size(min=8, max=8) constraint.") + .attributes(key("validationConstraints").value( + List.of(new Constraint(SIZE, Map.of("min", 8, "max", 8))) + )) + ) + .build() + ) + )); + } + + @Test + public void should_return_400_when_sku_does_not_match_pattern() throws Exception { + resultActions = mockMvc.perform(get("/product-search/{sku}", "123ABCDE")) // digits first + .andDo(print()); + + resultActions + .andExpect(status().isBadRequest()) + .andDo(document("product-get-by-sku-invalid-pattern", + resource(ResourceSnippetParameters.builder() + .summary("Get product by SKU – invalid format (400)") + .description("Returns 400 when the SKU does not match `[A-Z]{3}[0-9]{5}` (violates @Pattern).") + .pathParameters( + parameterWithName("sku") + .description("Value '123ABCDE' — violates @Pattern(regexp=\"[A-Z]{3}[0-9]{5}\") constraint.") + .attributes(key("validationConstraints").value( + List.of(new Constraint(PATTERN, Map.of("regexp", "[A-Z]{3}[0-9]{5}"))) + )) + ) + .build() + ) + )); + } +}