6 Commits
Author SHA1 Message Date
Relism 5ece97ca7b Merge pull request 'fix(ext-openapi): one shared answer per status, not a numbered family' (#24) from fix/openapi/one-shared-answer-per-status into master
Publish Maven packages / publish (push) Successful in 3m36s
2026-09-23 16:34:15 +00:00
Zakaria El OrcheandClaude Opus 5 5fe6fb46c8 fix(ext-openapi): one shared answer per status, not a numbered family
Hoisting named a shared response after its status and disambiguated with a
counter, so a document with three wordings for 403 grew Forbidden, Forbidden2
and Forbidden3 in its components. Numbered names say nothing and move as soon
as a route is added.

The answer a status is usually given is now the one hoisted, under that
status's own name, and a route that answers the same status differently keeps
its wording inline where it belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:34:13 +00:00
Relism c0c9480fa5 Merge pull request 'feat(ext-openapi): a route can say it is not part of the API' (#23) from feat/openapi/undocumented into master
Publish Maven packages / publish (push) Successful in 2m56s
2026-09-23 15:47:37 +00:00
Zakaria El OrcheandClaude Opus 5 9090ba59f5 feat(ext-openapi): a route can say it is not part of the API
Every class-based route is documented, which is what keeps a document from
lying by omission. Some routes are not API at all — a health check, an
internal callback, something on its way out — and @Undocumented says so, once,
where the handler is. Inherited, so a base class leaves out every handler
written against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:47:35 +00:00
Relism 6a6431c528 Merge pull request 'fix(ext-jackson): a constraint says what it wants in its own words' (#22) from fix/validation/constraint-messages into master
Publish Maven packages / publish (push) Successful in 2m27s
2026-09-23 14:51:28 +00:00
Zakaria El OrcheandClaude Opus 5 491553e9f5 fix(ext-jackson): a constraint says what it wants in its own words
The compiler ignored the message a constraint declares and always wrote its
own, so a failed @Pattern answered the caller with a regex. It now uses the
annotation's message whenever one is set, and keeps the plain description for
jakarta's default, which is a resource bundle key and not something to put in
front of whoever sent the request.

This is what makes moving a check out of a service and onto the type it
belongs to cost nothing: the wording moves with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:51:16 +00:00
7 changed files with 126 additions and 38 deletions
@@ -38,6 +38,18 @@ public record NewUser(@NotBlank @Size(max = 80) String name, @Email String email
Supported: `@NotNull`, `@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`. Supported: `@NotNull`, `@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`.
Jakarta semantics: only `@NotNull` rejects null, every other constraint passes it. Jakarta semantics: only `@NotNull` rejects null, every other constraint passes it.
A failure reads `<field> <message>`, and the message is the constraint's own when it sets one —
which is how the person who sent the request is told something better than a regex:
```java
@Pattern(regexp = "[A-Za-z0-9_][A-Za-z0-9_.-]{0,254}", message = "uses up to 255 letters, digits, _, . and -")
String key
```
```json
{"error": "key uses up to 255 letters, digits, _, . and -", "status": 422}
```
The constraints of a type are compiled the first time it is seen and kept in a `ClassValue`, The constraints of a type are compiled the first time it is seen and kept in a `ClassValue`,
beside the class itself — no map, no lock. A check reads the field through an exact-signature beside the class itself — no map, no lock. A check reads the field through an exact-signature
`MethodHandle`: no boxing, no argument array, no iterator, and nothing allocated at all unless `MethodHandle`: no boxing, no argument array, no iterator, and nothing allocated at all unless
@@ -178,25 +178,28 @@ public final class Validator {
Class<?> type = field.getType(); Class<?> type = field.getType();
MethodHandle ref = type.isPrimitive() ? null : asReference(getter); MethodHandle ref = type.isPrimitive() ? null : asReference(getter);
if (field.isAnnotationPresent(NotNull.class) && ref != null) NotNull notNull = field.getAnnotation(NotNull.class);
checks.add(Check.reference(Check.NOT_NULL, name, "must not be null", ref)); if (notNull != null && ref != null)
checks.add(Check.reference(Check.NOT_NULL, name, said(notNull.message(), "must not be null"), ref));
if (field.isAnnotationPresent(NotBlank.class) && ref != null) NotBlank notBlank = field.getAnnotation(NotBlank.class);
checks.add(Check.reference(Check.NOT_BLANK, name, "must not be blank", ref)); if (notBlank != null && ref != null)
checks.add(Check.reference(Check.NOT_BLANK, name, said(notBlank.message(), "must not be blank"), ref));
if (field.isAnnotationPresent(NotEmpty.class) && ref != null) NotEmpty notEmpty = field.getAnnotation(NotEmpty.class);
checks.add(Check.reference(Check.NOT_EMPTY, name, "must not be empty", ref)); if (notEmpty != null && ref != null)
checks.add(Check.reference(Check.NOT_EMPTY, name, said(notEmpty.message(), "must not be empty"), ref));
Size size = field.getAnnotation(Size.class); Size size = field.getAnnotation(Size.class);
if (size != null && ref != null) if (size != null && ref != null)
checks.add(Check.size(name, sizeMessage(size), ref, size.min(), size.max())); checks.add(Check.size(name, said(size.message(), sizeMessage(size)), ref, size.min(), size.max()));
Min min = field.getAnnotation(Min.class); Min min = field.getAnnotation(Min.class);
Max max = field.getAnnotation(Max.class); Max max = field.getAnnotation(Max.class);
if (min != null || max != null) { if (min != null || max != null) {
long lo = min != null ? min.value() : Long.MIN_VALUE; long lo = min != null ? min.value() : Long.MIN_VALUE;
long hi = max != null ? max.value() : Long.MAX_VALUE; long hi = max != null ? max.value() : Long.MAX_VALUE;
String message = rangeMessage(min, max); String message = said(min != null ? min.message() : max.message(), rangeMessage(min, max));
if (isIntegralPrimitive(type)) { if (isIntegralPrimitive(type)) {
checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi)); checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi));
} else if (Number.class.isAssignableFrom(type) && ref != null) { } else if (Number.class.isAssignableFrom(type) && ref != null) {
@@ -204,14 +207,15 @@ public final class Validator {
} }
} }
if (field.isAnnotationPresent(Email.class) && ref != null) Email email = field.getAnnotation(Email.class);
checks.add(Check.reference(Check.EMAIL, name, "must be a well-formed email address", ref)); if (email != null && ref != null)
checks.add(Check.reference(Check.EMAIL, name, said(email.message(), "must be a well-formed email address"), ref));
Pattern pattern = field.getAnnotation(Pattern.class); Pattern pattern = field.getAnnotation(Pattern.class);
if (pattern != null && ref != null) { if (pattern != null && ref != null) {
// ponytail: the one allocating check — Pattern.matcher() per call. The regex itself is // ponytail: the one allocating check — Pattern.matcher() per call. The regex itself is
// compiled once here; swap for a structural check if a hot route ever needs it. // compiled once here; swap for a structural check if a hot route ever needs it.
checks.add(Check.pattern(name, "must match " + pattern.regexp(), ref, checks.add(Check.pattern(name, said(pattern.message(), "must match " + pattern.regexp()), ref,
java.util.regex.Pattern.compile(pattern.regexp()))); java.util.regex.Pattern.compile(pattern.regexp())));
} }
} }
@@ -228,6 +232,15 @@ public final class Validator {
return getter.asType(MethodType.methodType(long.class, Object.class)); return getter.asType(MethodType.methodType(long.class, Object.class));
} }
/**
* What a failure says: the constraint's own {@code message} when it sets one, and otherwise a
* plain description of the rule. Jakarta's defaults are bundle keys in braces, and a key is
* not something to put in front of whoever sent the request.
*/
private static String said(String message, String otherwise) {
return message == null || message.isBlank() || message.startsWith("{") ? otherwise : message;
}
private static String sizeMessage(Size size) { private static String sizeMessage(Size size) {
if (size.min() == 0) return "size must be at most " + size.max(); if (size.min() == 0) return "size must be at most " + size.max();
if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min(); if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min();
@@ -114,4 +114,15 @@ class ValidatorTest {
assertTrue(validator.isEmpty()); assertTrue(validator.isEmpty());
assertDoesNotThrow(() -> validator.verify(new Plain(null))); assertDoesNotThrow(() -> validator.verify(new Plain(null)));
} }
record Keyed(@jakarta.validation.constraints.Pattern(regexp = "[a-z.]+",
message = "uses lowercase letters and dots") String key) {}
@org.junit.jupiter.api.Test
void a_constraint_says_what_it_wants_in_its_own_words() {
ValidationException refused = org.junit.jupiter.api.Assertions.assertThrows(
ValidationException.class, () -> Validator.check(new Keyed("Not A Key")));
org.junit.jupiter.api.Assertions.assertEquals("key uses lowercase letters and dots", refused.getMessage());
}
} }
@@ -33,6 +33,18 @@ Every class-based route is documented, annotated or not. Read off the code:
Annotations add what the code cannot say: prose, extra statuses, examples. They never repeat it. Annotations add what the code cannot say: prose, extra statuses, examples. They never repeat it.
## Leaving a route out
```java
@GET("/healthz")
@Undocumented
public final class Health extends RequestHandler { ... }
```
Every route is documented, so a document never lies by omission. `@Undocumented` says a route is
not part of the API — a health check, an internal callback, something on its way out. On a base
class it leaves out every handler written against it.
## Request bodies ## Request bodies
A handler that extends `BodyHandler``JsonHandler` and `XmlHandler`, and anything else that A handler that extends `BodyHandler``JsonHandler` and `XmlHandler`, and anything else that
@@ -65,8 +65,13 @@ public final class OpenApiBuilder {
public OpenApiBuilder description(String description) { this.description = description; return this; } public OpenApiBuilder description(String description) { this.description = description; return this; }
void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; } void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; }
/** Documents one route. {@code op} is optional: a route without it is still an operation. */ /**
* Documents one route. {@code op} is optional: a route without it is still an operation.
* A handler marked {@link Undocumented} is left out entirely.
*/
public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) { public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) {
if (handlerClass.isAnnotationPresent(Undocumented.class)) return;
String path = normalizePath(route.path()); String path = normalizePath(route.path());
String method = route.method().name().toLowerCase(Locale.ROOT); String method = route.method().name().toLowerCase(Locale.ROOT);
@@ -378,53 +383,49 @@ public final class OpenApiBuilder {
// ── Shared responses ────────────────────────────────────────────────────── // ── Shared responses ──────────────────────────────────────────────────────
/** /**
* Whatever answer more than one operation gives identically is written once under * The answer a status is usually given is written once under {@code components.responses} and
* {@code components.responses} and referenced. Authentication and rate limiting say the same * referenced. Authentication and rate limiting say the same thing on every route they guard;
* thing on every route they guard; the document should say it once. * the document should say it once, under that status's own name. A route that answers the same
* status differently keeps its own wording, inline, rather than pushing a second name into the
* components.
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private Map<String, Object> hoistSharedResponses(Map<String, Object> renderedPaths) { private Map<String, Object> hoistSharedResponses(Map<String, Object> renderedPaths) {
Map<Object, Integer> seen = new HashMap<>(); Map<String, Map<Object, Integer>> seen = new LinkedHashMap<>();
for (Object pathItem : renderedPaths.values()) { for (Object pathItem : renderedPaths.values()) {
for (Object operation : ((Map<String, Object>) pathItem).values()) { for (Object operation : ((Map<String, Object>) pathItem).values()) {
Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses"); Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses");
responses.forEach((status, response) -> seen.merge(key(status, response), 1, Integer::sum)); responses.forEach((status, response) ->
seen.computeIfAbsent(status, s -> new LinkedHashMap<>()).merge(response, 1, Integer::sum));
} }
} }
Map<Object, String> names = new LinkedHashMap<>();
Map<String, Object> shared = new LinkedHashMap<>(); Map<String, Object> shared = new LinkedHashMap<>();
Map<String, Object> hoisted = new LinkedHashMap<>(); // status to the one body that is shared
seen.forEach((status, bodies) -> {
Map.Entry<Object, Integer> commonest = bodies.entrySet().stream()
.max(Map.Entry.comparingByValue()).orElseThrow();
if (commonest.getValue() < 2) return;
String name = reasonFor(Integer.parseInt(status)).replace(" ", "");
if (name.isEmpty() || shared.containsKey(name)) name = "Status" + status;
shared.put(name, commonest.getKey());
hoisted.put(status, name);
});
for (Object pathItem : renderedPaths.values()) { for (Object pathItem : renderedPaths.values()) {
for (Object operation : ((Map<String, Object>) pathItem).values()) { for (Object operation : ((Map<String, Object>) pathItem).values()) {
Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses"); Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses");
for (var response : responses.entrySet()) { for (var response : responses.entrySet()) {
Object key = key(response.getKey(), response.getValue()); String name = (String) hoisted.get(response.getKey());
if (seen.getOrDefault(key, 0) < 2) continue; if (name != null && shared.get(name).equals(response.getValue())) {
response.setValue(Map.of("$ref", RESPONSE_REF + name));
String name = names.get(key);
if (name == null) {
name = uniqueName(reasonFor(Integer.parseInt(response.getKey())), shared);
names.put(key, name);
shared.put(name, response.getValue());
} }
response.setValue(Map.of("$ref", RESPONSE_REF + name));
} }
} }
} }
return shared; return shared;
} }
private static Object key(String status, Object response) {
return status + response;
}
private static String uniqueName(String reason, Map<String, Object> taken) {
String base = reason.isEmpty() ? "Response" : reason.replace(" ", "");
String name = base;
for (int i = 2; taken.containsKey(name); i++) name = base + i;
return name;
}
// ── Odds and ends ───────────────────────────────────────────────────────── // ── Odds and ends ─────────────────────────────────────────────────────────
private static Map<String, Object> arrayOf(Map<String, Object> items) { private static Map<String, Object> arrayOf(Map<String, Object> items) {
@@ -0,0 +1,21 @@
package dev.relism.flash.ext.openapi;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Keeps a route out of the published document.
*
* <p>Every class-based route is documented, which is what stops a document from lying by
* omission. Some routes are not part of the API anyway — a health check, an internal callback,
* something on its way out — and this says so, once, where the handler is.
*
* <p>Inherited: on a base class it leaves out every handler written against it.
*/
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Undocumented {}
@@ -220,6 +220,24 @@ class OpenApiBuilderTest {
assertEquals("array", schema.get("type")); assertEquals("array", schema.get("type"));
} }
@GET("/internal")
@Undocumented
static class InternalHandler extends RequestHandler {
@Override public Object handle(Request request, Response response) { return null; }
}
@Test
void a_route_that_says_it_is_not_part_of_the_api_is_left_out() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(InternalHandler.class), null, InternalHandler.class);
b.addOperation(OpenApiBuilder.routeOf(BareHandler.class), null, BareHandler.class);
Map<String, Object> paths = cast(b.build().get("paths"));
assertFalse(paths.containsKey("/internal"));
assertTrue(paths.containsKey("/bare"), "the others are still documented");
}
@Test @Test
void a_route_with_no_annotations_is_still_documented() { void a_route_with_no_annotations_is_still_documented() {
OpenApiBuilder b = new OpenApiBuilder(); OpenApiBuilder b = new OpenApiBuilder();