feat(ext-openapi): a pattern says in words what its regex says in symbols

A schema's pattern is precise and unreadable. When the constraint declares a
message, that message now goes in the property's description too, after
whatever the property already said, so the document carries both the rule a
machine checks and the sentence a person reads.

Only @Pattern does this. Every other constraint has a keyword that already
reads: required, maxLength, format: email. Repeating those as prose would be
noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-24 09:56:54 +00:00
co-authored by Claude Opus 5
parent 5ece97ca7b
commit 5164f8c41f
3 changed files with 76 additions and 1 deletions
@@ -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
@@ -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.
*
* <p>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<String, Object> 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<String> supported() {
return List.of("@NotNull", "@NotBlank", "@NotEmpty", "@Size", "@Min", "@Max", "@Email", "@Pattern");
}
@@ -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<String, Object> components = cast(b.build().get("components"));
Map<String, Object> schemas = cast(components.get("schemas"));
Map<String, Object> keyed = cast(schemas.get("Keyed"));
Map<String, Object> properties = cast(keyed.get("properties"));
Map<String, Object> key = cast(properties.get("key"));
Map<String, Object> 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();