Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 =
(
Expand All @@ -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<String> =
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>(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<String>(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
Expand Down Expand Up @@ -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"))
}
Expand Down Expand Up @@ -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"))
}
Expand Down
Loading
Loading