feat(ext-openapi): pick the page that reads the document #26
@@ -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
|
||||
|
||||
+23
-1
@@ -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");
|
||||
}
|
||||
|
||||
+34
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user