diff --git a/flash-extensions/flash-ext-openapi/README.md b/flash-extensions/flash-ext-openapi/README.md index e32d760..2ced2e2 100644 --- a/flash-extensions/flash-ext-openapi/README.md +++ b/flash-extensions/flash-ext-openapi/README.md @@ -128,6 +128,25 @@ Field-level exclusion: `@Schema(hidden = true)`, `@SchemaProperty(hidden = true) `@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`) become the schema's own bounds and required fields, so a rule is written once and documented for free. +A `@Pattern` that declares a message says it in the description too, after whatever the property +already said: a regex is precise and unreadable, and both belong in the document. + +```java +@SchemaProperty(description = "Unique in the project.") +@Pattern(regexp = "[a-z.]+", message = "uses lowercase letters and dots") +String key +``` + +```yaml +key: + type: string + description: Unique in the project. Uses lowercase letters and dots. + pattern: '[a-z.]+' +``` + +No other constraint does this: `required`, `maxLength` and `format: email` are already readable, +and repeating them as prose would be noise. + ## Contributor API OpenAPI is extension-agnostic. Other extensions contribute through `OpenApiContributor`, held in diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java index a92a8aa..971a835 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java @@ -64,7 +64,10 @@ final class ConstraintHints { if (field.isAnnotationPresent(Email.class)) property.putIfAbsent("format", "email"); Pattern pattern = field.getAnnotation(Pattern.class); - if (pattern != null) property.putIfAbsent("pattern", pattern.regexp()); + if (pattern != null) { + property.putIfAbsent("pattern", pattern.regexp()); + describe(property, pattern.message()); + } if (field.isAnnotationPresent(NotBlank.class) && isString) property.putIfAbsent("minLength", 1); if (field.isAnnotationPresent(NotEmpty.class)) { @@ -78,6 +81,25 @@ final class ConstraintHints { } /** Constraint annotations this bridge understands, for documentation and tests. */ + /** + * Says in words what a regex says in symbols. + * + *

Only {@code @Pattern} needs this: every other constraint has a keyword a reader + * understands — {@code required}, {@code maxLength}, {@code format: email} — and repeating it + * as prose would be noise. A regex has none, so the rule's own message goes in the + * description beside it, after whatever the property already said. + */ + private static void describe(Map property, String message) { + if (message == null || message.isBlank() || message.startsWith("{")) return; // jakarta's default is a bundle key + + String sentence = Character.toUpperCase(message.charAt(0)) + message.substring(1); + if (!sentence.endsWith(".")) sentence += "."; + + Object said = property.get("description"); + if (said == null) property.put("description", sentence); + else if (!said.toString().contains(sentence)) property.put("description", said + " " + sentence); + } + static List supported() { return List.of("@NotNull", "@NotBlank", "@NotEmpty", "@Size", "@Min", "@Max", "@Email", "@Pattern"); } diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java index debca82..5054e02 100644 --- a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java +++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java @@ -226,6 +226,40 @@ class OpenApiBuilderTest { @Override public Object handle(Request request, Response response) { return null; } } + @Schema(name = "Keyed") + static class KeyedDto { + @SchemaProperty(description = "Unique in the project.") + @jakarta.validation.constraints.Pattern(regexp = "[a-z.]+", message = "uses lowercase letters and dots") + public String key; + + @jakarta.validation.constraints.Pattern(regexp = "[0-9]+") + public String code; + } + + @GET("/keyed") + @ApiOperation(summary = "Keyed") + @APIResponse(responseCode = "200", content = @Content(schema = KeyedDto.class)) + static class KeyedHandler extends RequestHandler { + @Override public Object handle(Request request, Response response) { return null; } + } + + @Test + void a_pattern_says_in_words_what_its_regex_says_in_symbols() { + OpenApiBuilder b = new OpenApiBuilder(); + b.addOperation(OpenApiBuilder.routeOf(KeyedHandler.class), KeyedHandler.class.getAnnotation(ApiOperation.class), KeyedHandler.class); + + Map components = cast(b.build().get("components")); + Map schemas = cast(components.get("schemas")); + Map keyed = cast(schemas.get("Keyed")); + Map properties = cast(keyed.get("properties")); + Map key = cast(properties.get("key")); + Map code = cast(properties.get("code")); + + assertEquals("Unique in the project. Uses lowercase letters and dots.", key.get("description")); + assertEquals("[a-z.]+", key.get("pattern")); + assertFalse(code.containsKey("description"), "a pattern with no message of its own says nothing extra"); + } + @Test void a_route_that_says_it_is_not_part_of_the_api_is_left_out() { OpenApiBuilder b = new OpenApiBuilder();