diff --git a/.agents/features/assertj-integration.md b/.agents/features/assertj-integration.md new file mode 100644 index 0000000..3b30859 --- /dev/null +++ b/.agents/features/assertj-integration.md @@ -0,0 +1,185 @@ +# Feature: AssertJ Integration + +`javalidation-assertj` provides fluent AssertJ assertions for all javalidation types, +making test code readable and providing helpful failure messages. + +**Source:** `javalidation-assertj/src/main/java/io/github/raniagus/javalidation/assertj/` + +--- + +## Static Import + +Always use the javalidation-specific entry point, not AssertJ's standard one: + +```java +import static io.github.raniagus.javalidation.assertj.JavalidationAssertions.assertThat; +``` + +> In `JakartaValidationsTest`, two `assertThat` imports coexist — one from `JavalidationAssertions` +> and one from `CompilationSubject`. Both must be kept; do not collapse them. + +--- + +## `Result` Assertions + +```java +// Assert Ok and continue asserting on the unwrapped value +assertThat(result).isOk().isEqualTo(expectedValue); +assertThat(result).isOk().isNotNull(); + +// Assert Err and continue asserting on errors +assertThat(result).isErr() + .hasErrorCount(2) + .hasFieldError("name", "io.github.raniagus.javalidation.constraints.NotNull.message") + .hasFieldError("age", "io.github.raniagus.javalidation.constraints.Min.message", 18); + +assertThat(result).isErr().hasNoRootErrors(); +assertThat(result).isErr().isEmpty(); // no errors at all (always false for Err — use hasErrorCount(0) guard) +``` + +--- + +## `ValidationErrors` Assertions + +Obtained directly from `assertThat(ValidationErrors)`, or transitively from `.isErr()`. + +### Emptiness + +```java +assertThat(errors).isEmpty(); // no root AND no field errors +assertThat(errors).isNotEmpty(); +``` + +### Error Counts + +```java +assertThat(errors).hasErrorCount(3); // total root + all field +assertThat(errors).hasRootErrorCount(1); +assertThat(errors).hasFieldErrorCount(2); // sum across all field keys +assertThat(errors).hasFieldErrorCountAt(FieldKey.of("email"), 2); +``` + +### Root Errors + +```java +assertThat(errors).hasNoRootErrors(); +assertThat(errors).hasRootError("some.message.key"); +assertThat(errors).hasRootError("must be at least {0}", 18); +``` + +### Field Errors — Named Field + +```java +assertThat(errors).hasFieldError("email", "some.message.key"); +assertThat(errors).hasFieldError("email", "must be at least {0}", 18); +``` + +### Field Errors — Indexed Field + +```java +assertThat(errors).hasFieldError(0, "some.message.key"); // key is [0] +``` + +### Field Errors — Property Path String (complex paths) + +```java +// Parses "items[0].price" into FieldKey([StringKey("items"), IntKey(0), StringKey("price")]) +assertThat(errors).hasFieldErrorAt("items[0].price", "some.message.key"); +assertThat(errors).hasFieldErrorAt("user.address.street", "not.blank"); +``` + +### Field Errors — Explicit `FieldKey` + +```java +assertThat(errors).hasFieldErrorAt(FieldKey.of("user", "age"), "some.message.key"); +assertThat(errors).hasFieldErrorAt(FieldKey.of("items", 0, "price"), "some.message.key"); +``` + +### Key Presence Only + +```java +assertThat(errors).hasFieldKey("email"); // key exists, any message +assertThat(errors).hasFieldKey("items", 0, "price"); // composite key +assertThat(errors).doesNotHaveFieldKey("email"); +``` + +--- + +## `Validation` (mutable builder) Assertions + +Calls `finish()` internally — read-only snapshot, does not affect the builder. + +```java +Validation validation = Validation.create() + .addError("not.null") + .addErrorAt("email", "invalid.format"); + +assertThat(validation) + .hasRootError("not.null") + .hasFieldError("email", "invalid.format"); +``` + +--- + +## `PartialResult` Assertions + +```java +// Assert errors present and continue with error assertions +assertThat(partial).hasErrors() + .hasFieldError("price", "must.be.positive"); + +// Assert no errors and continue with success value assertions +assertThat(partial).hasNoErrors() + .success().isEqualTo(expectedList); +``` + +--- + +## Chaining Rules + +- `.hasErrorCount(N)` must come **immediately after `assertThat(...)`**, before any + `.hasFieldError()` / `.hasRootError()` calls. +- `.isEmpty()` is used for the no-error case — no `hasErrorCount` needed. +- Assertions are chainable: each method returns `this` (`ValidationErrorsAssert` or `ResultAssert`). + +### Example — correct chaining order + +```java +assertThat(validator.validate(record)) + .hasErrorCount(2) // ← immediately after assertThat + .hasFieldError("name", "not.null.key") // ← then field errors + .hasFieldError("email", "invalid.format.key"); + +assertThat(validator.validate(validRecord)) + .isEmpty(); // ← no-error case +``` + +--- + +## Failure Messages + +Failure messages include the full `ValidationErrors` state to aid debugging: + +``` +Expected Result to be Err but it was Ok with value: Alice + +Expected ValidationErrors to be empty but found 2 error(s): + root=[], field={email=[TemplateString{message='invalid.format', args=[]}]} + +Expected field errors to contain key + but found keys: [FieldKey{parts=[StringKey(name)]}] +``` + +--- + +## Dependency + +To use `JavalidationAssertions` in a test module, add to `pom.xml`: + +```xml + + io.github.raniagus + javalidation-assertj + test + +``` diff --git a/.agents/features/field-key.md b/.agents/features/field-key.md new file mode 100644 index 0000000..74a0ebd --- /dev/null +++ b/.agents/features/field-key.md @@ -0,0 +1,215 @@ +# Feature: FieldKey — Internal Path Representation + +`FieldKey` is the internal representation of a field path used as a map key in `ValidationErrors`. +It is a **record wrapping a `FieldKeyPart[]` array** — understanding this is essential for +reasoning about equality, prefix operations, and how paths are constructed across all APIs. + +**Sources:** +- `javalidation/src/main/java/io/github/raniagus/javalidation/FieldKey.java` +- `javalidation/src/main/java/io/github/raniagus/javalidation/FieldKeyPart.java` +- `javalidation/src/main/java/io/github/raniagus/javalidation/ValidationErrors.java` (holds `Map`) +- `javalidation/src/main/java/io/github/raniagus/javalidation/Validation.java` (prefix stack) +- `javalidation/src/main/java/io/github/raniagus/javalidation/format/` (rendering) + +--- + +## Internal Structure + +``` +FieldKey + └─ FieldKeyPart[] (ordered array of segments) + ├─ FieldKeyPart.StringKey(String key) — a named field: "address", "items" + └─ FieldKeyPart.IntKey(int key) — a numeric index: 0, 1, 2 +``` + +`FieldKey` is a `record`, but **`equals` and `hashCode` are manually overridden** to use +`Arrays.equals` / `Arrays.hashCode` on the parts array. Java's record default would use +object identity for arrays, which would break map lookups. + +```java +// Correct — two independently constructed keys with same segments are equal: +FieldKey.of("items", 0, "price").equals(FieldKey.of("items", 0, "price")); // true +``` + +--- + +## Construction + +```java +// String segments only +FieldKey.of("address", "street") // [StringKey("address"), StringKey("street")] + +// Numeric segments only +FieldKey.of(0, 1) // [IntKey(0), IntKey(1)] + +// Mixed: Number → IntKey, everything else → StringKey via toString() +FieldKey.of("items", 0, "price") // [StringKey("items"), IntKey(0), StringKey("price")] + +// Explicit parts +FieldKey.of(new FieldKeyPart.StringKey("x"), new FieldKeyPart.IntKey(0)) + +// From prefix collection + extra parts (used internally) +FieldKey.of(deque, extraParts) +``` + +--- + +## Ordering (used in `TreeMap`-like contexts) + +`FieldKey` implements `Comparable`. Comparison is **lexicographic segment by segment**: +- At the same position, `StringKey < IntKey` always. +- Among two `StringKey` values: alphabetical string comparison. +- Among two `IntKey` values: numeric comparison. +- Shorter key is less than a longer key with the same prefix. + +This ordering is relevant for the `Comparable` implementation but the `Map` in +`ValidationErrors` is a `HashMap`, so ordering is not guaranteed for iteration. + +--- + +## Prefixing — The Core Operation + +`FieldKey.withPrefix(FieldKeyPart... prefix)` **creates a new array** by copying the prefix +segments first, then the existing parts. This is an O(n+m) array allocation — it never mutates. + +``` +FieldKey.of("street").withPrefix(StringKey("address")) + → new FieldKeyPart[]{ StringKey("address"), StringKey("street") } + → FieldKey representing "address.street" +``` + +--- + +## How FieldKey is Used Across APIs + +### Functional Style — `Result.withPrefix` and `ValidationErrors.withPrefix` + +Both delegate to `ValidationErrors.withPrefix(FieldKeyPart... prefix)`, which: +1. Converts each root error: root error list → becomes field errors at exactly the prefix key +2. Converts each field error key: calls `key.withPrefix(prefix)` → prepends segments to existing array + +```java +// Before: +// root=["invalid"], fieldErrors={"street": ["req"], "zip": ["invalid"]} + +ValidationErrors prefixed = errors.withPrefix("address"); + +// After: +// root=[], fieldErrors={ +// "address": ["invalid"], ← root became field at prefix key +// "address.street": ["req"], ← original key prepended +// "address.zip": ["invalid"] +// } +``` + +If the result is `Ok`, `Result.withPrefix` is a no-op (the Ok instance is returned unchanged). + +### Imperative Style — `Validation` Prefix Stack + +`Validation` maintains an internal `Deque` that acts as a **live prefix stack**. +Methods that push/pop from this stack: + +| Method | Stack operation | +|--------|----------------| +| `withField(String, Runnable)` | push `StringKey(field)`, run, pop | +| `withField(Number, Runnable)` | push `IntKey(field)`, run, pop | +| `withEach(items, consumer)` | for each item: push `IntKey(index)`, run, pop | + +When an error is added, the current deque contents are combined with the immediate field to build the final `FieldKey`: + +```java +// prefix stack: [StringKey("person")] +validation.addErrorAt("name", "not.null"); +// → FieldKey.of(prefix, StringKey("name")) +// → FieldKey([StringKey("person"), StringKey("name")]) +// → renders as "person.name" +``` + +```java +// prefix stack: [StringKey("items"), IntKey(0)] +validation.addError("not.null"); +// addError sees non-empty prefix → stores under FieldKey([StringKey("items"), IntKey(0)]) +// → renders as "items[0]" +``` + +Nesting scopes accumulate segments: +```java +validation.withField("order", () -> // push StringKey("order") + validation.withField("address", () -> // push StringKey("address") + validation.addErrorAt("street", "req") // → FieldKey(["order","address","street"]) + ) // pop StringKey("address") +); // pop StringKey("order") +``` + +### Stream Collectors — `withIndex` and `withPrefix` + +`ResultCollectorWrapper` passes an `outerPrefix: FieldKeyPart[]` to the inner collector when +adding each result: + +- **`withIndex(collector)`**: for element `i`, calls `inner.add(result, new FieldKeyPart[]{ IntKey(i) })` +- **`withPrefix(String, collector)`**: always calls `inner.add(result, new FieldKeyPart[]{ StringKey(prefix) })` +- **`withPrefix(int, collector)`**: always calls `inner.add(result, new FieldKeyPart[]{ IntKey(prefix) })` + +The inner collector calls `errors.withPrefix(outerPrefix)` before storing, so keys become +`[outerPrefix…][originalKey…]`. + +Wrappers compose by prepending their own segment before passing down: +```java +withPrefix("order", withPrefix("items", withIndex(toResultList()))) +// outer-most wraps last → errors have "order" prepended last +// effective prefix order on final keys: [StringKey("order"), StringKey("items"), IntKey(i), ...original...] +``` + +### `ValidationErrors` Map Key + +`fieldErrors` is a `Map>`. Because `FieldKey.equals` uses +`Arrays.equals`, two paths with the same segment sequence **hash and compare as equal**, +so merging from different call sites with the same field name correctly appends to the same list. + +```java +// These two produce the same FieldKey and map to the same bucket: +validation.addErrorAt("email", "not.null"); +validation.addErrorAt("email", "invalid.format"); +// → fieldErrors = { FieldKey([StringKey("email")]): ["not.null", "invalid.format"] } +``` + +### AssertJ Assertions + +Assertions convert string paths to `FieldKey` for map lookup: + +| Assertion method | FieldKey construction | +|------------------|-----------------------| +| `hasFieldError("email", …)` | `FieldKey.of("email")` → single `StringKey` | +| `hasFieldError(0, …)` | `FieldKey.of(0)` → single `IntKey` | +| `hasFieldErrorAt("items[0].price", …)` | `PropertyPathNotationParser.parse("items[0].price")` → `[StringKey("items"), IntKey(0), StringKey("price")]` | +| `hasFieldErrorAt(FieldKey key, …)` | uses the key directly | +| `hasFieldKey(Object... path)` | `FieldKey.of(path)` — mixed | + +--- + +## Rendering (Formatters) + +`FieldKeyFormatter` converts a `FieldKey` to a string for JSON keys, error messages, etc. + +| Formatter | `StringKey` | `IntKey` | Example path `items[0].price` | +|-----------|-------------|---------|-------------------------------| +| `PropertyPathNotationFormatter` (default) | `.name` prefix | `[n]` inline | `items[0].price` | +| `DotNotationFormatter` | `.name` prefix | `.n` prefix | `items.0.price` | +| `BracketNotationFormatter` | `[name]` bracket | `[n]` bracket | `[items][0][price]` | + +The formatters are pure rendering functions — they do not affect how `FieldKey` is stored or compared. + +--- + +## Non-Obvious Consequences + +**Root errors always live in the root list, never in the field map.** A `FieldKey` is only ever +a map key. Root errors have no key. `withPrefix` is the only operation that moves root errors +into the field map (by creating a `FieldKey` from the prefix and placing them there). + +**`addError` inside `withField` becomes a field error, not a root error.** When the prefix stack +is non-empty, `addError` adds to `fieldErrors` under the current stack key, not to `rootErrors`. + +**Segment type matters for rendering, not for identity.** `FieldKey.of("0")` (StringKey) and +`FieldKey.of(0)` (IntKey) are **different keys** even though they look similar. The string `"0"` +as a field name and the integer index `0` are distinct. diff --git a/.agents/features/functional-style.md b/.agents/features/functional-style.md new file mode 100644 index 0000000..34175ec --- /dev/null +++ b/.agents/features/functional-style.md @@ -0,0 +1,199 @@ +# Feature: Functional (Monadic) Style + +The functional API is centred on `Result` — a sealed interface with two variants: +- `Result.Ok(T value)` — validation passed, carries the success value +- `Result.Err(ValidationErrors errors)` — validation failed, carries accumulated errors + +**Source:** `javalidation/src/main/java/io/github/raniagus/javalidation/Result.java` + +--- + +## Creating a Result + +```java +// Success +Result ok = Result.ok("Alice"); +Result ok = Result.ok(null); // null is a valid value + +// Failure — root error +Result err = Result.error("user.not.found"); +Result err = Result.error("must be at least {0}", 18); + +// Failure — field error +Result err = Result.error(ValidationErrors.at("email", "invalid.format")); + +// From existing ValidationErrors +Result err = Result.error(validationErrors); + +// Wrap a supplier that may throw JavalidationException +Result result = Result.of(() -> service.findOrThrow(id)); +``` + +--- + +## Transforming the Happy Path + +### `map` — transform the value, pass errors through + +```java +Result age = Result.ok("25").map(Integer::parseInt); // Ok(25) +Result err = Result.error("invalid").map(s -> s + "!"); // Err(...) +``` + +`map` catches `JavalidationException` and converts it to `Err`. All other exceptions propagate normally (fail-fast). + +### `flatMap` — chain to another validation (monadic bind) + +```java +Result user = validateEmail(email) + .flatMap(e -> findUserByEmail(e)) // may throw JavalidationException + .flatMap(u -> validateUserStatus(u)); +``` + +### `peek` — side effect on success, no transformation + +```java +result.peek(u -> logger.info("Valid user: {}", u.name())); +``` + +--- + +## Filtering on the Happy Path + +### `ensure` — fail with a root error if predicate is false + +```java +Result age = Result.ok(15) + .ensure(a -> a >= 18, "must.be.adult"); // Err +Result age = Result.ok(25) + .ensure(a -> a >= 18, "must.be.adult"); // Ok(25) +``` + +### `ensureAt` — fail with a field error + +```java +Result result = Result.ok(user) + .ensureAt(u -> u.name() != null, "name", "not.null") + .ensureAt(u -> u.name().length() >= 2, "name", "too.short"); +// These chain: stops at first failure (sequential for the same field) +``` + +> **Note:** chaining `ensure`/`ensureAt` is fail-fast per chain. Use `and/combine` to accumulate +> errors from independent fields simultaneously. + +--- + +## Handling the Error Path + +### `mapErr` — transform errors, pass value through + +```java +result.mapErr(errors -> errors.withPrefix("request")); +``` + +### `flatMapErr` — recover from or transform errors + +```java +Result result = findInCache(id) + .flatMapErr(errors -> findInDatabase(id)) // try database on cache miss + .flatMapErr(errors -> Result.ok(defaultUser)); // final fallback +``` + +### `peekErr` — side effect on errors, no transformation + +```java +result.peekErr(errors -> logger.warn("{} errors", errors.count())); +``` + +### `or` — eager or lazy fallback + +```java +// Lazy (preferred — avoids evaluating if not needed) +result.or(() -> fallback()); + +// Eager +result.or(fallbackResult); +``` + +--- + +## Bifunctor Operations + +### `bimap` — transform both paths at once + +```java +Result r = validateAge(age).bimap( + a -> "Valid age: " + a, + es -> es.withPrefix("user") +); +``` + +--- + +## Combining Multiple Results (Applicative Style) + +Use `and(…).combine(…)` to accumulate errors from **independent** validations simultaneously. +All errors are collected even if some results fail. + +```java +// 2 results +Result person = validateName(name) + .and(validateAge(age)) + .combine((n, a) -> new Person(n, a)); + +// 3 results +Result
address = validateStreet(street) + .and(validateCity(city)) + .and(validateZip(zip)) + .combine((s, c, z) -> new Address(s, c, z)); + +// Up to 10 results (ResultCombiner2 through ResultCombiner10) +``` + +--- + +## Eliminating a Result + +### `fold` — handle both variants, return a single type + +```java +String msg = result.fold( + value -> "Valid: " + value, + errors -> "Errors: " + errors.count() +); +``` + +### `getOrThrow` — unwrap or throw + +```java +User user = result.getOrThrow(); // throws JavalidationException if Err +``` + +### `getOrElse` — unwrap or return default + +```java +String value = result.getOrElse("default"); +String value = result.getOrElse(() -> computeDefault()); +``` + +--- + +## Pattern Matching (Java 21+) + +`Result` is a sealed interface — exhaustive switch is possible: + +```java +String message = switch (result) { + case Result.Ok(var value) -> "Success: " + value; + case Result.Err(var errors) -> "Errors: " + errors; +}; +``` + +--- + +## Error Channel Design + +`map`, `flatMap`, `flatMapErr` catch **only** `JavalidationException` and convert it to `Err`. +All other exceptions (NPE, ISE, IOException, etc.) propagate normally. This distinguishes: +- **Expected validation failures** (`JavalidationException`) → `Err`, safe to return to clients +- **Programming errors / bugs** (other exceptions) → propagate, log at boundary, return 500 diff --git a/.agents/features/imperative-style.md b/.agents/features/imperative-style.md new file mode 100644 index 0000000..d443075 --- /dev/null +++ b/.agents/features/imperative-style.md @@ -0,0 +1,210 @@ +# Feature: Imperative Style + +The imperative API is centred on `Validation` — a mutable builder that accumulates errors in place, +then converts to `ValidationErrors`, `Result`, or throws. + +**Source:** `javalidation/src/main/java/io/github/raniagus/javalidation/Validation.java` + +--- + +## Creating a Validation + +```java +Validation validation = Validation.create(); +``` + +--- + +## Adding Errors + +### Root errors (not tied to a specific field) + +```java +validation.addError("request.invalid"); +validation.addError("must be at least {0} characters", 8); +``` + +### Field errors (tied to a named field or index) + +```java +validation.addErrorAt("email", "not.null"); +validation.addErrorAt("email", "invalid.format"); // multiple errors per field OK +validation.addErrorAt("age", "must.be.at.least", 18); + +// Numeric index (for collection elements) +validation.addErrorAt(0, "must.not.be.null"); +``` + +--- + +## Scoped Field Contexts + +`withField` is the primary building block for structured validation. It manages a field-path +prefix stack so that any errors added within the runnable are automatically scoped to the field. + +### `withField(String, Runnable)` — named field scope + +Root errors added within the runnable become errors for that field; +field errors become nested (dot-notation). + +```java +validation.withField("person", () -> { + if (request.person() == null) { + validation.addError("not.null"); // → "person": ["not.null"] + } else { + if (request.person().name() == null) { + validation.addErrorAt("name", "not.null"); // → "person.name": ["not.null"] + } + if (request.person().age() < 18) { + validation.addErrorAt("age", "min", 18); // → "person.age": ["min"] + } + } +}); +``` + +### `withField(Number, Runnable)` — indexed scope (renders as `[n]`) + +```java +validation.withField(0, () -> { + validation.addError("not.null"); // → "[0]": ["not.null"] + validation.addErrorAt("name", "not.null"); // → "[0].name": ["not.null"] +}); +``` + +### Nesting — `withField` inside `withField` + +```java +validation.withField("order", () -> { + validation.withField("address", () -> { + validation.addErrorAt("street", "not.blank"); // → "order.address.street" + }); +}); +``` + +--- + +## Collection Iteration + +### `withEach(Iterable, Consumer)` — iterate with auto index prefix + +```java +validation.withEach(request.tags(), tag -> { + if (tag.name() == null) { + validation.addError("not.null"); // → "[0]", "[1]", etc. + } else if (tag.name().isBlank()) { + validation.addErrorAt("name", "not.blank"); // → "[0].name", etc. + } +}); +``` + +### `withEach(Iterable, BiConsumer)` — with index exposed + +```java +validation.withEach(request.tags(), (tag, index) -> { + if (duplicates.contains(index)) { + validation.addError("duplicate.at.index", index); + } +}); +``` + +--- + +## Merging External Errors + +### `addAll(Validation)` — merge another mutable builder + +```java +Validation sub = validateAddress(address); +validation.addAll(sub); // root and field errors merged as-is +``` + +### `addAll(ValidationErrors)` — merge an immutable snapshot + +```java +ValidationErrors addressErrors = validateAddress(address); +validation.addAll(addressErrors); +``` + +### `addAllAt(FieldKey, ValidationErrors)` — merge with prefix + +```java +ValidationErrors addressErrors = validateAddress(user.address()); +validation.addAllAt(FieldKey.of("address"), addressErrors); +// root errors from addressErrors → "address": [...] +// field errors "street" → "address.street": [...] +``` + +--- + +## Terminating the Builder + +### `finish()` — snapshot to `ValidationErrors` + +Returns an immutable `ValidationErrors` backed by the builder's current state. +Do not mutate the `Validation` after calling `finish()`. + +```java +ValidationErrors errors = validation.finish(); +if (errors.isNotEmpty()) { … } +``` + +### `asResult(T value)` → `Result` + +```java +Result result = validation.asResult(user); +// Ok(user) if no errors; Err(errors) otherwise +``` + +### `asResult(Supplier)` — lazy value (defers construction until validation passes) + +```java +Result result = validation.asResult(() -> buildResponse(data)); +// Supplier is only called if no errors. JavalidationException in supplier → Err. +``` + +### `check()` — throw if errors + +```java +validation.check(); // throws JavalidationException if errors exist +// proceed with valid state... +``` + +### `checkAndGet(Supplier)` — throw or return value + +```java +User valid = validation.checkAndGet(() -> user); +``` + +--- + +## Typical Full Pattern + +```java +public Result createOrder(CreateOrderRequest request) { + Validation validation = Validation.create(); + + validation.withField("customer", () -> { + if (request.customer() == null) { + validation.addError("not.null"); + } else { + validation.addAll(validateCustomer(request.customer())); + } + }); + + validation.withEach(request.items(), item -> { + if (item.quantity() <= 0) { + validation.addErrorAt("quantity", "must.be.positive"); + } + }); + + return validation.asResult(() -> orderService.create(request)); +} +``` + +--- + +## Generated Validator Pattern + +The annotation processor generates code that follows this pattern internally. Each record field +becomes a `validation.withField(fieldName, () -> { … })` block, with constraint checks inside. +The `validate(Validation validation, T root)` method signature matches `Validator`. diff --git a/.agents/features/jackson-integration.md b/.agents/features/jackson-integration.md new file mode 100644 index 0000000..11832e8 --- /dev/null +++ b/.agents/features/jackson-integration.md @@ -0,0 +1,217 @@ +# Feature: Jackson Integration + +`javalidation-jackson` integrates javalidation types with Jackson 3.x (`tools.jackson`). +It covers **two distinct serialization scenarios** with different audiences and wire formats. + +**Source:** `javalidation-jackson/src/main/java/io/github/raniagus/javalidation/jackson/` + +> ⚠️ Jackson groupId is **`tools.jackson`**, not `com.fasterxml.jackson`. + +--- + +## Two Serialization Use Cases + +| | `Result` serialization | `ValidationErrors` serialization | +|---|---|---| +| **Audience** | Internal backend-to-backend traffic | Frontends / BFFs | +| **Messages** | Opaque `{code, args}` — not yet formatted | Already formatted strings | +| **Round-trip** | ✓ Full deserialize → `Result` | ✗ Read-only / one-way | +| **FieldKey format** | Raw array `["items", 0, "price"]` | Rendered string (`items[0].price`) | +| **Layout options** | Fixed (`ok`, `value`/`errors`) | Structured (default) or Flattened | +| **Notation options** | N/A | Property-path, Dots, Brackets | + +--- + +## Use Case 1: `Result` — Internal Backend Traffic + +### Purpose + +`Result` serialization is designed for service-to-service calls where: +- The receiving backend needs to **reconstruct a `Result` Java object** (deserialize round-trip) +- Message formatting is deferred — each side resolves keys in its own locale / `MessageSource` +- The `FieldKey` path must survive as typed data (string vs integer segment distinction preserved) +- The receiving backend may **cache `Result.Err` responses** — e.g. a "not found" result for a + given ID — to avoid redundant calls to the real API on subsequent requests + +### Wire Format + +`Result.Ok`: +```json +{ + "ok": true, + "value": {"name": "Alice", "age": 30} +} +``` + +`Result.Err`: +```json +{ + "ok": false, + "errors": { + "rootErrors": [ + {"code": "io.github.raniagus.javalidation.constraints.NotNull.message", "args": []} + ], + "fieldErrors": [ + { + "key": ["email"], + "errors": [{"code": "io.github.raniagus.javalidation.constraints.Email.message", "args": []}] + }, + { + "key": ["items", 0, "price"], + "errors": [{"code": "io.github.raniagus.javalidation.constraints.Min.message", "args": [1]}] + } + ] + } +} +``` + +Key observations: +- Discriminator field `"ok": boolean` identifies Ok vs Err (not just presence of `value` or `errors`) +- Errors use `"code"` (not `"message"`) — preserving the raw `TemplateString.message()` key +- `fieldErrors` is a **list** of `{key: Object[], errors: [...]}`, not a map — the `key` array + preserves the typed structure (`String` for named fields, `Integer` for numeric indices) + +### Deserialization + +`StructuredResultDeserializer` reconstructs `Result` from this format: +- Reads the `"ok"` discriminator +- On `true`: reads `"value"` as `T` using the declared `JavaType` +- On `false`: reads `"errors"` as `StructuredValidationErrorsDto` → converts to `ValidationErrors` + +The deserializer is registered as a type-parametric `Deserializers` resolver, so `Result`, +`Result`, etc. each get a correctly-typed deserializer instance. + +### Round-Trip Requirement + +If you replace the `resultSerializer` with a custom one, you **must** also replace the +`resultDeserializerFactory` to maintain round-trip compatibility. The `withTemplateStringFormatter` +builder method is intentionally excluded from affecting the `Result` serializer — error codes must +stay opaque for round-trip. + +--- + +## Use Case 2: `ValidationErrors` — Frontend / BFF Exposure + +### Purpose + +`ValidationErrors` serialization is designed for exposing validation results to clients (React, +Vue, mobile apps, BFFs) where: +- Messages must be **already formatted** human-readable strings +- Clients need a simple map structure (no Java-specific `{code, args}` objects) +- Key notation must match the client-side form library's expected format + +### Layout: Structured (default) + +`ValidationErrors` is serialized as a JSON object with separate `rootErrors` and `fieldErrors` +sections. Empty sections are omitted. + +```json +{ + "rootErrors": ["Invalid request"], + "fieldErrors": { + "email": ["Must be a valid email address"], + "items[0].price": ["Must be at least 1"] + } +} +``` + +Field keys are rendered strings using the configured `FieldKeyFormatter`. + +### Layout: Flattened (`withFlattenedErrors()`) + +All errors are merged into a single flat map. Root errors use the empty-string key `""`. + +```json +{ + "": ["Invalid request"], + "email": ["Must be a valid email address"], + "items[0].price": ["Must be at least 1"] +} +``` + +Activated via: +- Builder: `JavalidationModule.builder().withFlattenedErrors()` +- Spring Boot property: `io.github.raniagus.javalidation.flatten-errors: true` + +### Key Notation Options + +Controls how `FieldKey` is rendered as a JSON key string. Applies to both layouts. + +| Notation | Builder method | Spring property value | `items[0].price` | Compatible with | +|----------|---------------|----------------------|------------------|-----------------| +| Property-path (default) | `.withPropertyPathNotation()` | `property_path` | `items[0].price` | [conform](https://conform.guide/) | +| Dots | `.withDotNotation()` | `dots` | `items.0.price` | [react-hook-form](https://react-hook-form.com/) | +| Brackets | `.withBracketNotation()` | `brackets` | `items[0][price]` | [qs](https://github.com/ljharb/qs) | + +### Message Formatting + +`TemplateString` values are formatted to plain strings before serialization using +`TemplateStringFormatter`. Without a configured formatter, the default +`MessageFormatTemplateStringFormatter` is used (formats via `java.text.MessageFormat`). + +```java +TemplateStringFormatter myFormatter = ts -> + messageSource.getMessage(ts.message(), ts.args(), locale); + +JavalidationModule module = JavalidationModule.builder() + .withTemplateStringFormatter(myFormatter) + .build(); +``` + +In Spring Boot, the `MessageSourceTemplateStringFormatter` bean is auto-configured and wired in +automatically when `use-message-source: true` (default). + +--- + +## Registration + +### Standalone (without Spring) + +```java +import tools.jackson.databind.json.JsonMapper; + +JsonMapper mapper = JsonMapper.builder() + .addModule(JavalidationModule.getDefault()) + .build(); +``` + +### Spring Boot (automatic) + +The starter (`javalidation-spring-boot-starter`) auto-registers the module when both +`tools.jackson.databind.json.JsonMapper` and `javalidation-jackson` are on the classpath. +No manual registration needed. Controlled via Spring Boot properties: + +```yaml +io.github.raniagus.javalidation: + key-notation: property_path # property_path | dots | brackets + use-message-source: true # true (default) | false + flatten-errors: false # false (default) | true +``` + +--- + +## Builder Reference + +```java +JavalidationModule.builder() + // Key notation for ValidationErrors (mutually exclusive) + .withPropertyPathNotation() // items[0].price (default, compatible with conform) + .withDotNotation() // items.0.price (compatible with react-hook-form) + .withBracketNotation() // [items][0][price] + .withFieldKeyFormatter(FieldKeyFormatter) // custom formatter + + // Message formatting for ValidationErrors + .withTemplateStringFormatter(formatter) // resolves TemplateString keys to strings + + // ValidationErrors layout + .withFlattenedErrors() // flat {"": [], "field": []} + + // Low-level overrides + .withFieldKeySerializer(ValueSerializer) + .withTemplateStringSerializer(ValueSerializer) + .withValidationErrorsSerializer(ValueSerializer) + .withResultSerializer(ValueSerializer>) // ⚠️ also update deserializer + .withResultDeserializerFactory(Function>>) + + .build() +``` diff --git a/.agents/features/jakarta-validator.md b/.agents/features/jakarta-validator.md new file mode 100644 index 0000000..6b5692d --- /dev/null +++ b/.agents/features/jakarta-validator.md @@ -0,0 +1,186 @@ +# Feature: Jakarta Validator (Annotation-Driven Validation) + +This feature covers end-to-end use of Jakarta validation annotations (`jakarta.validation.constraints.*`) +to generate type-safe validators via the APT annotation processor. + +**Sources:** +- `javalidation-jakarta-validator/src/main/java/io/github/raniagus/javalidation/validator/` +- `javalidation-jakarta-validator-processor/src/main/java/io/github/raniagus/javalidation/validator/processor/` + +--- + +## How It Works + +1. You annotate your `record` with standard Jakarta constraint annotations. +2. The annotation processor (`ValidatorProcessor`) runs at compile-time and generates: + - `MyRecordValidator.java` — implements `InitializableValidator` + - `Validators.java` — a static registry replacing the stub +3. At runtime, call `Validators.validate(myRecord)` → `ValidationErrors`. + +--- + +## Annotating Records + +```java +import jakarta.validation.constraints.*; + +public record CreateUserRequest( + @NotNull @NotBlank String name, + @NotNull @Email String email, + @NotNull @Min(18) @Max(120) Integer age, + @Valid Address address // @Valid triggers nested validator generation +) {} + +public record Address( + @NotNull @NotBlank String street, + @NotNull @Pattern(regexp = "\\d{5}") String zipCode +) {} +``` + +### Supported Constraint Annotations + +All 22 built-in `jakarta.validation.constraints.*` are supported: + +| Constraint | Notes | +|-----------|-------| +| `@NotNull` | any object | +| `@Null` | any object | +| `@NotEmpty` | `CharSequence`, `Collection`, `Map`, array | +| `@NotBlank` | `CharSequence` | +| `@Size(min, max)` | `CharSequence`, `Collection`, `Map`, array | +| `@Min(value)` | numeric types and `CharSequence` | +| `@Max(value)` | numeric types and `CharSequence` | +| `@DecimalMin(value, inclusive)` | numeric | +| `@DecimalMax(value, inclusive)` | numeric | +| `@Positive` | numeric | +| `@PositiveOrZero` | numeric | +| `@Negative` | numeric | +| `@NegativeOrZero` | numeric | +| `@Digits(integer, fraction)` | numeric, `CharSequence` | +| `@Pattern(regexp)` | `CharSequence` — generates `static final Pattern FIELDNAME_PATTERN` | +| `@Email` | `CharSequence` — generates `static final Pattern FIELDNAME_PATTERN` | +| `@Past` | temporal types | +| `@PastOrPresent` | temporal types | +| `@Future` | temporal types | +| `@FutureOrPresent` | temporal types | +| `@AssertTrue` | `boolean`/`Boolean` | +| `@AssertFalse` | `boolean`/`Boolean` | + +--- + +## `@Valid` for Nested Records + +`@Valid` on a record component generates: +- A `initialize(ValidatorsHolder holder)` call that wires the nested validator at startup. +- In `validate(...)`, the field is delegated to `holder.validate(field)` with errors prefixed. + +```java +public record OrderRequest( + @Valid CustomerRequest customer, // nested record + @Valid List items // list of records (each element validated) +) {} +``` + +### Sealed Interface Support + +`@Valid` on a sealed interface field generates a validator that pattern-matches permitted subtypes: + +```java +public record PaymentRequest( + @Valid PaymentMethod method // sealed interface +) {} + +public sealed interface PaymentMethod permits CreditCard, BankTransfer {} +public record CreditCard(…) implements PaymentMethod {} +public record BankTransfer(…) implements PaymentMethod {} +``` + +All permitted subtypes must be records. Non-record subtypes cause a compile error. + +--- + +## Runtime Usage (without Spring) + +```java +// Static registry (after processor runs) +ValidationErrors errors = Validators.validate(myRecord); +boolean canValidate = Validators.hasValidator(MyRecord.class); +Validator v = Validators.getValidator(MyRecord.class); + +// Manual wiring with ValidatorsHolder (for tests or DI) +ValidatorsHolder holder = new ValidatorsHolder(Map.of( + MyRecord.class, new MyRecordValidator(), + MyRecord.Nested.class, new MyRecord$NestedValidator() +)); +holder.initialize(); // must call before validate +ValidationErrors errors = holder.validate(myRecord); +``` + +--- + +## Message Keys + +Generated validators use constraint message keys of the form: +``` +io.github.raniagus.javalidation.constraints..message +``` + +Examples: +- `io.github.raniagus.javalidation.constraints.NotNull.message` +- `io.github.raniagus.javalidation.constraints.Email.message` +- `io.github.raniagus.javalidation.constraints.Min.message` (with arg: min value) + +These are **opaque keys** at validation time. They are resolved to human-readable strings by +`TemplateStringFormatter`. The Spring Boot starter provides default English strings automatically +via a bundled `messages.properties`. + +--- + +## Limitations + +- **Records only** — plain classes and non-sealed interfaces are not supported. +- **No validation groups** — `groups` attribute is silently ignored. +- **No custom/composed constraints** — only built-in `jakarta.validation.constraints.*`. +- **Sealed interfaces** — all permitted subtypes must be records. + +See `.agents/known-limitations.md` for full details. + +--- + +## Generated Class Conventions + +```java +@NullMarked +@Generated("io.github.raniagus.javalidation.validator.processor.ValidatorProcessor") +public class MyRecordValidator implements InitializableValidator { + // @Pattern fields get: + static final Pattern FIELDNAME_PATTERN = Pattern.compile("regexp"); + + @Override + public void initialize(ValidatorsHolder holder) { + // empty unless @Valid nested fields exist + } + + @Override + public void validate(Validation validation, MyRecord root) { + validation.withField("fieldName", () -> { + var value = root.fieldName(); + if (value == null) { + validation.addError("io.github.raniagus.javalidation.constraints.NotNull.message"); + return; + } + // constraint checks... + }); + } +} +``` + +Nested validator class names use `$` separator: +- Inner record `Foo.Bar` → `Foo$BarValidator` + +--- + +## Tests + +See `.agents/validator-processor-tests.md` for the full guide on adding code-generation tests +and validator logic tests. diff --git a/.agents/features/result-merging.md b/.agents/features/result-merging.md new file mode 100644 index 0000000..8eebc67 --- /dev/null +++ b/.agents/features/result-merging.md @@ -0,0 +1,109 @@ +# Feature: Result Merging + +This feature covers how validation errors from multiple sources are combined into one. +There are two styles: **imperative merging** (via `Validation.addAll`) and +**applicative combining** (via `Result.and(…).combine(…)`). + +> For how field paths are prefixed and how `FieldKey` is built internally, see +> `.agents/features/field-key.md`. + +**Sources:** +- `javalidation/src/main/java/io/github/raniagus/javalidation/ValidationErrors.java` +- `javalidation/src/main/java/io/github/raniagus/javalidation/Validation.java` (`addAll`, `addAllAt`) +- `javalidation/src/main/java/io/github/raniagus/javalidation/combiner/` (applicative combining) + +--- + +## `ValidationErrors.mergeWith` + +Combines two `ValidationErrors` into one. Root errors are concatenated; field errors for the +same key are concatenated. + +```java +ValidationErrors a = ValidationErrors.at("email", "not.null"); +ValidationErrors b = ValidationErrors.at("email", "invalid.format"); +ValidationErrors merged = a.mergeWith(b); +// merged.fieldErrors() = {"email": ["not.null", "invalid.format"]} +``` + +--- + +## Merging into `Validation` + +### `validation.addAll(ValidationErrors)` — merge flat (no prefix) + +Root and field errors are added as-is. + +```java +ValidationErrors addressErrors = validateAddress(user.address()); +Validation validation = Validation.create(); +validation.addAll(addressErrors); +``` + +### `validation.addAll(Validation)` — merge another builder + +Same as above but from a mutable builder. + +```java +Validation sub = validateAddress(user.address()); +Validation validation = Validation.create(); +validation.addAll(sub); +``` + +### `validation.addAllAt(FieldKey, ValidationErrors)` — merge with prefix + +Root errors from `errors` become field errors at `prefix`. Field errors are nested under `prefix`. + +```java +ValidationErrors addressErrors = validateAddress(user.address()); +validation.addAllAt(FieldKey.of("address"), addressErrors); +// root errors → "address": [...] +// "street" → "address.street": [...] +// "zip.code" → "address.zip.code": [...] +``` + +### Using `withField` as a prefix scope + +`withField` is the idiomatic way to prefix errors when using `Validation`. Any errors added +within the runnable are automatically scoped. + +```java +validation.withField("address", () -> { + validation.addAll(validateAddress(user.address())); + // OR manually: + // validation.addError("invalid"); → "address": ["invalid"] + // validation.addErrorAt("street", "req"); → "address.street": ["required"] +}); +``` + +--- + +## Applicative Combining (Accumulate Across Multiple Results) + +When you have independent validations that should all run simultaneously (accumulating all errors), +use `Result.and(…).combine(…)`. + +```java +Result person = validateName(name) + .and(validateAge(age)) + .and(validateEmail(email)) + .combine((n, a, e) -> new Person(n, a, e)); + +// If name is null AND age < 18: Err with both errors +// If all valid: Ok(new Person(...)) +``` + +Combiners are available from 2 (`ResultCombiner2`) up to 10 (`ResultCombiner10`). + +--- + +## Error Accumulation Semantics + +| Operation | Accumulates all | Fail-fast | +|-----------|----------------|-----------| +| `Validation.addError*` + `finish/check` | ✓ | ✗ | +| `Result.and(…).combine(…)` | ✓ | ✗ | +| `Result.map` / `flatMap` chain | ✗ | ✓ (stops at first Err) | +| `Result.ensure` chain | ✗ | ✓ (stops at first failed predicate) | +| `ResultCollector.toResultList()` | ✓ | ✗ | +| `Result.or(…)` | merges errors | tries fallback | diff --git a/.agents/features/spring-boot-starter.md b/.agents/features/spring-boot-starter.md new file mode 100644 index 0000000..3875bd6 --- /dev/null +++ b/.agents/features/spring-boot-starter.md @@ -0,0 +1,166 @@ +# Feature: Spring Boot Starter + +`javalidation-spring-boot-starter` provides zero-configuration Spring Boot 4.x integration. +When present on the classpath, it auto-configures all beans needed for validation, serialization, +and `MessageSource`-backed i18n. + +**Source:** `javalidation-spring-boot-starter/src/main/java/io/github/raniagus/javalidation/spring/` + +--- + +## What Gets Auto-Configured + +Three `@AutoConfiguration` classes are registered: + +| Class | Activated when | +|-------|---------------| +| `JavalidationAutoConfiguration` | always | +| `JavalidationJacksonAutoConfiguration` | `JsonMapper` + `javalidation-jackson` on classpath | +| `JavalidationValidatorAutoConfiguration` | `Validators` + Spring MVC on classpath | + +--- + +## Configuration Properties + +```yaml +io.github.raniagus.javalidation: + key-notation: property_path # property_path (default) | dots | brackets + use-message-source: true # true (default) | false + flatten-errors: false # false (default) | true +``` + +### `key-notation` + +Controls how `FieldKey` paths are serialized to JSON keys. + +| Value | Example | +|-------|---------| +| `property_path` (default) | `items[0].price` | +| `dots` | `items.0.price` | +| `brackets` | `items[0][price]` | + +### `use-message-source` + +When `true` (default), error message keys (e.g. `io.github.raniagus.javalidation.constraints.NotNull.message`) +are resolved via Spring `MessageSource`. The library's built-in English messages are injected automatically +as a parent `MessageSource`. + +When `false`, raw keys are passed through `MessageFormat.format(key, args)`. Library keys will appear +as opaque strings. + +### `flatten-errors` + +When `false` (default), `ValidationErrors` serializes as: +```json +{"rootErrors": [...], "fieldErrors": {"field": [...]}} +``` + +When `true`, it serializes as: +```json +{"": [...], "field": [...]} +``` + +--- + +## Spring MVC Integration + +`JavalidationSpringValidator` is registered as a `@Primary` Spring MVC `Validator`. It bridges: +- `Validators.hasValidator(clazz)` → `supports(clazz)` +- `Validators.validate(target)` → fills `Errors` + +It is also wired into `WebMvcConfigurer.getValidator()` so that Spring MVC uses it for `@Valid`-annotated +controller method parameters automatically. + +### Manual usage + +```java +@RestController +public class UserController { + private final JavalidationSpringValidator validator; + + @PostMapping("/users") + public ResponseEntity create(@RequestBody CreateUserRequest request, BindingResult result) { + validator.validate(request, result); + if (result.hasErrors()) { + ValidationErrors errors = JavalidationSpringValidator.toValidationErrors(result); + return ResponseEntity.badRequest().body(errors); + } + // ... + } +} +``` + +### Reverse conversion + +```java +// Spring Errors → ValidationErrors (for bridging from Spring MVC to javalidation) +ValidationErrors errors = JavalidationSpringValidator.toValidationErrors(bindingResult); +``` + +--- + +## MessageSource Integration Details + +The `javalidationMessageSourceParentConfigurer` bean is a `BeanFactoryPostProcessor` that: +1. Finds the application's `messageSource` bean +2. Walks to the bottom of the `HierarchicalMessageSource` chain +3. Injects a `ResourceBundleMessageSource` pointing to + `io/github/raniagus/javalidation/messages.properties` as the parent + +This means all 22 library constraint keys have default English messages without any user configuration. +To **override** a key, define it in your own `messages.properties` (it takes precedence). + +`MessageSourceTemplateStringFormatter` behavior: +1. Try `messageSource.getMessage(key, args, locale)` +2. If `NoSuchMessageException`, fall back to `MessageFormat.format(key, args)` — so custom `MessageFormat` + patterns (e.g. `"Hello {0}!"`) work even without a `messages.properties` entry. + +--- + +## `@EnableJavalidation` for Test Slices + +Test slices like `@WebMvcTest` disable Spring Boot auto-configuration. Use `@EnableJavalidation` +to re-import the three auto-configuration classes: + +```java +@WebMvcTest(MyController.class) +@EnableJavalidation +class MyControllerTest { + @Autowired + MockMvc mockMvc; + + // Javalidation MessageSource, Jackson module, and Spring validator are all configured +} +``` + +Without `@EnableJavalidation`, the Jackson module won't be applied and validation won't run. + +--- + +## Exclusions + +To disable specific auto-configurations: + +```properties +spring.autoconfigure.exclude=\ + io.github.raniagus.javalidation.spring.JavalidationJacksonAutoConfiguration,\ + io.github.raniagus.javalidation.spring.JavalidationValidatorAutoConfiguration +``` + +--- + +## Testing Auto-Configuration + +See `.agents/spring-boot-starter-tests.md` for the full guide. + +Key pattern: all test classes **extend `AutoConfigurationTest`** and use nested static classes +with `@SpringBootTest(classes = TestApplication.class)`. + +```bash +# All starter tests +./mvnw test -pl javalidation-spring-boot-starter + +# One test class +./mvnw test -pl javalidation-spring-boot-starter \ + -Dtest=TemplateStringFormatterAutoConfigurationTest +``` diff --git a/.agents/features/stream-collectors.md b/.agents/features/stream-collectors.md new file mode 100644 index 0000000..a3e0a72 --- /dev/null +++ b/.agents/features/stream-collectors.md @@ -0,0 +1,188 @@ +# Feature: Stream Collectors + +`ResultCollector` provides `java.util.stream.Collector` factories for processing streams of +`Result` elements. All collectors accumulate **all** errors before deciding outcome (no fail-fast). + +**Source:** `javalidation/src/main/java/io/github/raniagus/javalidation/ResultCollector.java` + +--- + +## Collector Types + +### `toListOrThrow()` — collect to `List` or throw + +Gathers all success values into a list. If any element is `Err`, throws `JavalidationException` +with all accumulated errors after the full stream is consumed. + +```java +try { + List users = items.stream() + .map(this::validateUser) + .collect(ResultCollector.toListOrThrow()); +} catch (JavalidationException e) { + ValidationErrors errors = e.getErrors(); +} + +// With initial capacity hint (avoids ArrayList resizing) +.collect(ResultCollector.toListOrThrow(items.size())) +``` + +### `toResultList()` — collect to `Result>` + +Like `toListOrThrow()` but returns `Err` instead of throwing. + +```java +Result> result = items.stream() + .map(this::validateUser) + .collect(ResultCollector.toResultList()); + +// With initial capacity hint +.collect(ResultCollector.toResultList(items.size())) +``` + +### `toPartialResult()` — collect successes and errors simultaneously + +Returns `PartialResult>` — holds both valid items and all accumulated errors. +Use when you want to process what succeeded even if some items failed. + +```java +PartialResult> partial = items.stream() + .map(this::validateUser) + .collect(ResultCollector.toPartialResult()); + +List validUsers = partial.success(); // items that passed +ValidationErrors errors = partial.errors(); // all collected errors +boolean anyErrors = partial.hasErrors(); +Result> asResult = partial.toResult(); +``` + +### `toValidation()` — collect errors into a new `Validation` + +Success values are **discarded**. Returns a `Validation` (mutable builder) with all accumulated errors. + +```java +Validation validation = items.stream() + .map(this::validateItem) + .collect(withIndex(ResultCollector.toValidation())); + +validation.check(); // throw if any errors +``` + +### `addErrorsTo(Validation)` — collect errors into an existing `Validation` + +Like `toValidation()` but mutates and returns the provided `Validation`. Success values discarded. + +```java +Validation validation = Validation.create(); + +users.stream() + .map(this::validateUser) + .collect(ResultCollector.withPrefix("users", ResultCollector.addErrorsTo(validation))); + +orders.stream() + .map(this::validateOrder) + .collect(ResultCollector.withPrefix("orders", ResultCollector.addErrorsTo(validation))); + +validation.check(); +``` + +--- + +## Wrapper Collectors + +Wrappers modify how errors are indexed or prefixed within the inner collector. +They work at the `FieldKey` level: each wrapper prepends one or more `FieldKeyPart` segments +to every `FieldKey` in the element's errors before the inner collector stores them. The string +representations shown below are just the default property-path notation rendering of those keys. + +### `withIndex(collector)` — automatic `IntKey(i)` error prefix + +Each stream element is assigned a 0-based index. Errors from element `i` are prefixed with an +`IntKey(i)` segment — rendered as `[i]` in property-path notation. + +```java +// Without indexing: +// fieldErrors key for "field" error: FieldKey([StringKey("field")]) +// rendered: "field" + +// With indexing: +Result> result = users.stream() + .map(this::validateUser) + .collect(ResultCollector.withIndex(ResultCollector.toResultList())); +// fieldErrors keys: FieldKey([IntKey(0), StringKey("field")]) +// rendered (property-path): "[0].field", "[2].field" +``` + +### `withPrefix(String, collector)` — `StringKey` prefix + +Prepends a single `StringKey` segment to every error's `FieldKey`. All errors produced by the +inner collector are namespaced under the given field name. + +```java +Result> items = order.getItems().stream() + .map(this::validateItem) + .collect(ResultCollector.withPrefix("items", ResultCollector.toResultList())); +// Before: FieldKey([StringKey("price")]) → rendered: "price" +// After: FieldKey([StringKey("items"), StringKey("price")]) → rendered: "items.price" +``` + +### `withPrefix(int, collector)` — `IntKey` prefix + +Prepends a single `IntKey` segment to every error's `FieldKey`. Useful for a fixed position +in a parent collection. + +```java +.collect(ResultCollector.withPrefix(0, ResultCollector.toResultList())); +// Before: FieldKey([StringKey("price")]) → rendered: "price" +// After: FieldKey([IntKey(0), StringKey("price")]) → rendered: "[0].price" +``` + +--- + +## Combining Wrappers + +Wrappers compose by prepending their segment outermost-last. Each wrapper adds its own +`FieldKeyPart` at the front of the key after the inner wrappers have already applied. + +```java +// Effective FieldKey for element i's "price" error: +// FieldKey([StringKey("order"), StringKey("items"), IntKey(i), StringKey("price")]) +// Rendered (property-path): "order.items[0].price" + +Result> items = order.getItems().stream() + .map(this::validateItem) + .collect( + ResultCollector.withPrefix("order", + ResultCollector.withPrefix("items", + ResultCollector.withIndex( + ResultCollector.toResultList() + ) + ) + ) + ); +``` + +--- + +## Static Import Pattern + +```java +import static io.github.raniagus.javalidation.ResultCollector.*; + +Result> result = users.stream() + .map(this::validateUser) + .collect(withIndex(toResultList())); +``` + +--- + +## When to Use Which Collector + +| Need | Collector | +|------|-----------| +| Get list or throw (exception boundary) | `toListOrThrow()` | +| Get `Result>` (functional) | `toResultList()` | +| Partial success + errors side-by-side | `toPartialResult()` | +| Accumulate errors, discard successes | `toValidation()` or `addErrorsTo(validation)` | +| Add position info to errors | wrap with `withIndex(…)` | +| Namespace errors under a path | wrap with `withPrefix(…)` | diff --git a/AGENTS.md b/AGENTS.md index 631903d..353ee43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,24 +1,61 @@ # Agent Guide +This is the entrypoint for agentic coding agents working in this repository. It provides a roadmap to all other documentation. + ## Environment - Java 21 via sdkman. Run `sdk use java 21.0.9-tem` or rely on `.sdkmanrc` auto-env. - Maven wrapper: `./mvnw` -## Module layout (build order matters) +## Repository Structure + +`javalidation` is a multi-module Maven library that provides Railway-Oriented Programming (ROP) validation for Java 21+. It accumulates validation errors instead of failing fast, and offers both functional (monadic) and imperative APIs. -| Module | artifact | depends on | +| Module | Artifact | Depends on | |--------------------------------------------|-------------------------------------------|----------------------------| -| `javalidation` | core (zero deps) | — | +| `javalidation` | Core — zero deps | — | | `javalidation-assertj` | AssertJ assertions for `Result` etc. | core | -| `javalidation-jackson` | Jackson 3.x integration | core | +| `javalidation-jackson` | Jackson 3.x serialization/deserialization | core | | `javalidation-jakarta-validator` | `Validators` stub + `Validator` interface | core | | `javalidation-jakarta-validator-processor` | APT annotation processor | jakarta-validator | | `javalidation-spring-boot-starter` | Spring Boot 4.x auto-config | jackson, jakarta-validator | **Jackson uses groupId `tools.jackson` (not `com.fasterxml.jackson`).** -## Common commands +## Documentation Structure + +### Module-Specific Guidance + +Each module has its own `AGENTS.md` with source file index, public API, and patterns: + +- **`javalidation/AGENTS.md`** — core types (`Result`, `Validation`, `ValidationErrors`, `FieldKey`, `TemplateString`, combiners, collectors, formatters) +- **`javalidation-assertj/AGENTS.md`** — AssertJ assertion classes and usage patterns +- **`javalidation-jackson/AGENTS.md`** — `JavalidationModule`, serializers, deserializers, DTO types +- **`javalidation-jakarta-validator/AGENTS.md`** — `Validator`, `Validators` stub, `ValidatorsHolder`, `InitializableValidator` +- **`javalidation-jakarta-validator-processor/AGENTS.md`** — annotation processor internals, class writers, supported constraints +- **`javalidation-spring-boot-starter/AGENTS.md`** — auto-configuration classes, beans, `@EnableJavalidation`, properties + +### Feature Documentation + +Deep-dive guides for each cross-cutting feature live in `.agents/features/`: + +- **`.agents/features/functional-style.md`** — `Result` monadic API: `map`, `flatMap`, `fold`, `ensure`, `and/combine`, `or`, `bimap`, `peek` +- **`.agents/features/imperative-style.md`** — `Validation` builder API: `addError`, `addErrorAt`, `withField`, `withEach`, `addAll`, `check`, `asResult` +- **`.agents/features/stream-collectors.md`** — `ResultCollector` stream API: `toListOrThrow`, `toResultList`, `toPartialResult`, `toValidation`, `addErrorsTo`, `withIndex`, `withPrefix` +- **`.agents/features/result-merging.md`** — `ValidationErrors.mergeWith`, `Validation.addAll`, `addAllAt`, applicative combining +- **`.agents/features/field-key.md`** — `FieldKey` array internals, `withPrefix` mechanics, prefix stack in `Validation`, `withIndex`/`withPrefix` in collectors, rendering +- **`.agents/features/jackson-integration.md`** — Two use cases: `Result` (internal traffic, round-trip, opaque `{code,args}`) vs `ValidationErrors` (frontend/BFF, formatted, two layouts × three notations with client-library compat) +- **`.agents/features/assertj-integration.md`** — `JavalidationAssertions.assertThat(...)`, `ResultAssert`, `ValidationErrorsAssert`, `PartialResultAssert` +- **`.agents/features/jakarta-validator.md`** — `@Valid` annotation processing, `Validator` interface, `Validators.validate(...)`, `ValidatorsHolder` +- **`.agents/features/spring-boot-starter.md`** — auto-config beans, `JavalidationProperties`, `@EnableJavalidation`, `MessageSource` integration, `JavalidationSpringValidator` + +### Existing Sub-guides + +- **`.agents/validator-processor-tests.md`** — how to add tests in `javalidation-jakarta-validator-processor` +- **`.agents/spring-boot-starter-tests.md`** — how to add tests in `javalidation-spring-boot-starter` +- **`.agents/known-limitations.md`** — known limitations and future work + +## Common Commands ```bash # Build and test all modules @@ -36,20 +73,49 @@ -Dtest="JakartaValidationsTest\$EmailRecordValidatorTest" ``` -## Non-obvious behaviours +## How to Navigate + +**Working on core validation logic?** Start with `javalidation/AGENTS.md`, then read either +`.agents/features/functional-style.md` or `.agents/features/imperative-style.md` depending on +which API style you're modifying. + +**Working on stream/collection validation?** Read `.agents/features/stream-collectors.md` and +`javalidation/AGENTS.md`. + +**Working on error merging or prefixing?** Read `.agents/features/result-merging.md` for combining strategies, and `.agents/features/field-key.md` for how paths are built and prefixed. + +**Working on Jackson serialization?** Start with `javalidation-jackson/AGENTS.md`, then +`.agents/features/jackson-integration.md`. + +**Working on AssertJ assertions?** Read `javalidation-assertj/AGENTS.md` and +`.agents/features/assertj-integration.md`. + +**Working on the annotation processor?** Read `javalidation-jakarta-validator-processor/AGENTS.md` +and `.agents/validator-processor-tests.md`. + +**Working on Spring Boot integration?** Read `javalidation-spring-boot-starter/AGENTS.md`, +`.agents/features/spring-boot-starter.md`, and `.agents/spring-boot-starter-tests.md`. -**Processor compiles with `none`** — the processor module does not process its own annotations. Do not remove this flag. +**Need to understand a specific feature end-to-end?** Go directly to the relevant file in +`.agents/features/`. -**Fixture files are classpath resources, not compiled sources.** The `src/test/java/test/` trees under the processor module are copied as resources via `maven-resources-plugin`. They are loaded in tests via `JavaFileObjects.forResource(...)` and compared with `hasSourceEquivalentTo()` (whitespace-flexible source diff). Do not expect IDEs to compile them as part of the test source set. +## Cross-Cutting Concerns -**Surefire `--add-opens`** for `jdk.compiler` internals (required by `compile-testing`) are configured in the parent POM. `mvn test` picks them up automatically; running tests directly in an IDE requires copying those flags to the run configuration. +- **Error key format** — All constraint message keys follow `io.github.raniagus.javalidation.constraints..message`. The Spring Boot starter injects library defaults from `io/github/raniagus/javalidation/messages.properties` as the parent `MessageSource`. +- **`FieldKey` path segments** — String segments render as `field.nested`; integer segments render as `[0]` in property-path notation (default). Dot and bracket notation are also available. +- **`TemplateString`** — Stores the raw message key and `Object[]` args separately for deferred formatting. Never format at validation time; always format at serialization time. +- **`jspecify` null-safety** — All public APIs are annotated with `@NullMarked` / `@Nullable` from `org.jspecify`. The `@Nullable` annotation on type parameters (e.g., `Result`) means `null` is a valid success value. -**`Validators.java` stub replacement.** The `javalidation-jakarta-validator` artifact ships a stub `Validators` that throws `IllegalStateException`. The processor generates a class with the same FQN (`io.github.raniagus.javalidation.validator.Validators`) into `target/generated-sources/annotations`; `javac` treats it as the authoritative definition, replacing the stub at compile time. +## Working Rules -**Validation error messages are opaque keys**, not resolved strings. The key `io.github.raniagus.javalidation.constraints.NotNull.message` is baked into generated validators. Resolution happens at runtime via `TemplateStringFormatter` (Spring `MessageSource`-backed by default). A user who does not configure this will receive raw keys. +**Step-by-step workflow for any task:** -## Sub-guides +1. Read the root `AGENTS.md` (this file) to understand the structure and find the right starting point. +2. Read the relevant module's `AGENTS.md` for its source file index and API surface. +3. Read the relevant feature doc in `.agents/features/` for deep-dive patterns and examples. +4. Read the actual source files to verify your understanding before making changes. +5. Implement the change following the patterns in the feature doc and module `AGENTS.md`. +6. Run `./mvnw test -pl ` to verify your changes. +7. If you change the public API surface, update the corresponding `AGENTS.md` and feature doc. -- [`.agents/validator-processor-tests.md`](.agents/validator-processor-tests.md) — how to add tests in `javalidation-jakarta-validator-processor` -- [`.agents/spring-boot-starter-tests.md`](.agents/spring-boot-starter-tests.md) — how to add tests in `javalidation-spring-boot-starter` -- [`.agents/known-limitations.md`](.agents/known-limitations.md) — known limitations and future work +**Docs-in-sync rule:** When you add a new public type or method, update the module `AGENTS.md` source file index. When you add a new cross-cutting feature, create a feature doc in `.agents/features/` and link it from this file and the relevant module `AGENTS.md`. diff --git a/javalidation-assertj/AGENTS.md b/javalidation-assertj/AGENTS.md new file mode 100644 index 0000000..326007e --- /dev/null +++ b/javalidation-assertj/AGENTS.md @@ -0,0 +1,123 @@ +# javalidation-assertj — AssertJ Integration + +**Package:** `io.github.raniagus.javalidation.assertj` +**Dependencies:** `javalidation` (core), `org.assertj:assertj-core` + +Provides fluent AssertJ assertions for `Result`, `ValidationErrors`, `Validation`, and `PartialResult`. + +## Source File Index + +| File | Role | +|------|------| +| `JavalidationAssertions.java` | Entry-point. Static `assertThat(...)` overloads for all four types. | +| `ResultAssert.java` | Assertions for `Result`. `.isOk()` → `OkResultAssert`; `.isErr()` → `ValidationErrorsAssert`. | +| `OkResultAssert.java` | Assertions on the unwrapped success value (delegates to standard AssertJ). | +| `ValidationErrorsAssert.java` | Assertions for `ValidationErrors` (and transitively for `Validation` and `Result.Err`). | +| `PartialResultAssert.java` | Assertions for `PartialResult`. | +| `PropertyPathNotationParser.java` | Parses property-path strings like `"items[0].price"` into `FieldKey` for use in assertions. | + +## Public API + +### Entry point + +```java +import static io.github.raniagus.javalidation.assertj.JavalidationAssertions.assertThat; + +assertThat(Result actual) → ResultAssert +assertThat(ValidationErrors actual) → ValidationErrorsAssert +assertThat(Validation actual) → ValidationErrorsAssert // calls actual.finish() +assertThat(PartialResult actual) → PartialResultAssert +``` + +### `ResultAssert` + +``` +.isOk() → OkResultAssert (fails if Err) +.isErr() → ValidationErrorsAssert (fails if Ok) +``` + +### `OkResultAssert` + +Extends `AbstractObjectAssert, T>`. All standard AssertJ object assertions are available: +``` +.isEqualTo(expected) +.isNull() / .isNotNull() +// …and any other AbstractObjectAssert methods +``` + +### `ValidationErrorsAssert` + +``` +// Emptiness +.isEmpty() // no root errors AND no field errors +.isNotEmpty() + +// Error counts +.hasErrorCount(n) // total root + field +.hasRootErrorCount(n) +.hasFieldErrorCount(n) // sum across all field keys +.hasFieldErrorCountAt(FieldKey, n) + +// Root errors +.hasNoRootErrors() +.hasRootError(message, args...) + +// Field errors +.hasNoFieldErrors() +.hasFieldError(String field, message, args...) // single-segment string key +.hasFieldError(int index, message, args...) // single-segment int key +.hasFieldErrorAt(String path, message, args...) // property-path notation: "items[0].price" +.hasFieldErrorAt(FieldKey key, message, args...) // explicit FieldKey +.hasFieldKey(Object... path) // key is present (any message) +.doesNotHaveFieldKey(Object... path) // key is absent +``` + +### `PartialResultAssert` + +``` +.hasErrors() → ValidationErrorsAssert (fails if no errors) +.hasNoErrors() → PartialResultAssert (fails if errors present) +.success() → AbstractObjectAssert (for the success value field) +``` + +## Usage Patterns + +```java +// In processor tests — two assertThat imports coexist: +import static io.github.raniagus.javalidation.assertj.JavalidationAssertions.assertThat; +import static com.google.testing.compile.CompilationSubject.assertThat; +// Use unambiguous call site syntax + +// Fluent chain for a single field error +assertThat(validator.validate(record)) + .hasErrorCount(1) + .hasFieldError("email", "io.github.raniagus.javalidation.constraints.Email.message"); + +// Fluent chain for multiple errors +assertThat(validator.validate(record)) + .hasErrorCount(2) + .hasFieldError("name", "io.github.raniagus.javalidation.constraints.NotNull.message") + .hasFieldError("age", "io.github.raniagus.javalidation.constraints.Min.message", 18); + +// No-error case +assertThat(validator.validate(record)).isEmpty(); + +// Result assertions +assertThat(result).isOk().isEqualTo(expectedValue); +assertThat(result).isErr() + .hasRootError("some.message.key") + .hasNoFieldErrors(); + +// Nested path assertion +assertThat(errors) + .hasFieldErrorAt("items[0].price", "io.github.raniagus.javalidation.constraints.Min.message", 0); +``` + +## Key Rules (from `.agents/validator-processor-tests.md`) + +- `.hasErrorCount(N)` must come **immediately after `assertThat(...)`** on the same chain, before any `.hasFieldError()` / `.hasRootError()` calls. +- `.isEmpty()` is used for the no-error case — no `hasErrorCount` needed there. + +## Feature Deep-Dive + +- `.agents/features/assertj-integration.md` diff --git a/javalidation-jackson/AGENTS.md b/javalidation-jackson/AGENTS.md new file mode 100644 index 0000000..4ed5603 --- /dev/null +++ b/javalidation-jackson/AGENTS.md @@ -0,0 +1,105 @@ +# javalidation-jackson — Jackson 3.x Integration + +**Package:** `io.github.raniagus.javalidation.jackson` +**Dependencies:** `javalidation` (core), `tools.jackson.databind` (Jackson 3.x) + +Provides a `JavalidationModule` for registering serializers/deserializers that handle `Result`, +`ValidationErrors`, `FieldKey`, and `TemplateString` in Jackson `ObjectMapper`/`JsonMapper`. + +> ⚠️ Jackson groupId is **`tools.jackson`**, not `com.fasterxml.jackson`. + +## Source File Index + +| File | Role | +|------|------| +| `JavalidationModule.java` | Main `SimpleModule` subclass. Entry point for all registrations. Has a `Builder`. | +| `JavalidationModule.Builder` | Fluent builder: notation, formatter, flattened/structured errors, custom serializers. | +| `StructuredResultSerializer.java` | Serializes `Result` as `{"value": …}` (Ok) or `{"errors": …}` (Err). | +| `StructuredResultDeserializer.java` | Deserializes `{"value": …}` → `Ok`, `{"errors": …}` → `Err`. | +| `StructuredResultDeserializerResolver.java` | `Deserializers` impl that wires the deserializer per `JavaType`. | +| `FieldKeySerializer.java` | Serializes `FieldKey` as a JSON key (string). Delegates to `FieldKeyFormatter`. | +| `TemplateStringSerializer.java` | Serializes `TemplateString` as a formatted string. Delegates to `TemplateStringFormatter`. | +| `FlattenedErrorsSerializer.java` | Serializes `ValidationErrors` as `{"": […], "field": […]}` (flat map). | +| `ValidationErrorsMixIn.java` | Default mixin for `ValidationErrors` when no custom serializer is configured. | +| `StructuredErrorDto.java` | DTO for the structured errors representation during deserialization. | +| `StructuredFieldErrorDto.java` | DTO for individual field error entries during deserialization. | +| `StructuredValidationErrorsDto.java` | DTO combining root errors and field errors for deserialization. | + +## Key Public API + +### `JavalidationModule` + +```java +// Default (structured Result format + property-path notation + MessageFormat formatter) +JavalidationModule module = JavalidationModule.getDefault(); + +// Custom builder +JavalidationModule module = JavalidationModule.builder() + .withTemplateStringFormatter(myFormatter) // custom i18n formatter (for ValidationErrors) + .withFlattenedErrors() // flat {"": [], "field": []} for ValidationErrors + .withDotNotation() // dots: items.0.price (react-hook-form compat) + .withBracketNotation() // brackets: [items][0][price] + .withFieldKeyFormatter(formatter) // custom FieldKeyFormatter + .withFieldKeySerializer(serializer) // custom ValueSerializer + .withTemplateStringSerializer(serializer) // custom ValueSerializer + .withValidationErrorsSerializer(serializer) // custom ValidationErrors serializer + .withResultSerializer(serializer) // custom Result serializer (⚠️ also update deserializer) + .withResultDeserializerFactory(factory) // custom Result deserializer factory + .build(); + +// Register with ObjectMapper +JsonMapper mapper = JsonMapper.builder() + .addModule(module) + .build(); +``` + +### Two Wire Formats + +**`Result` (internal backend traffic, round-trip):** +```json +// Ok +{"ok": true, "value": {"name": "Alice"}} + +// Err — opaque code/args, fieldErrors as typed array +{ + "ok": false, + "errors": { + "rootErrors": [{"code": "io.github...NotNull.message", "args": []}], + "fieldErrors": [ + {"key": ["email"], "errors": [{"code": "io.github...Email.message", "args": []}]}, + {"key": ["items", 0, "price"], "errors": [{"code": "io.github...Min.message", "args": [1]}]} + ] + } +} +``` + +**`ValidationErrors` (frontend/BFF, formatted messages):** +```json +// Structured layout (default) +{ + "rootErrors": ["Invalid request"], + "fieldErrors": { + "email": ["Must be a valid email address"], + "items[0].price": ["Must be at least 1"] + } +} + +// Flattened layout (withFlattenedErrors()) +{ + "": ["Invalid request"], + "email": ["Must be a valid email address"], + "items[0].price": ["Must be at least 1"] +} +``` + +### Key Notation Options for `ValidationErrors` + +| Method | Example `items[0].price` | Compatible with | +|--------|--------------------------|-----------------| +| `.withPropertyPathNotation()` (default) | `items[0].price` | [conform](https://conform.guide/) | +| `.withDotNotation()` | `items.0.price` | [react-hook-form](https://react-hook-form.com/) | +| `.withBracketNotation()` | `[items][0][price]` | — | + +## Feature Deep-Dive + +- `.agents/features/jackson-integration.md` diff --git a/javalidation-jakarta-validator-processor/AGENTS.md b/javalidation-jakarta-validator-processor/AGENTS.md new file mode 100644 index 0000000..640cc9c --- /dev/null +++ b/javalidation-jakarta-validator-processor/AGENTS.md @@ -0,0 +1,133 @@ +# javalidation-jakarta-validator-processor — Annotation Processor + +**Package:** `io.github.raniagus.javalidation.validator.processor` +**Dependencies:** `javalidation-jakarta-validator`, `jakarta.validation-api`, `jspecify` + +APT annotation processor that generates `*Validator` classes and a `Validators` registry from +records annotated with `jakarta.validation.constraints.*` and `@Valid`. + +> ⚠️ This module compiles with `none` — it does NOT process its own annotations. + +## Source File Index + +### Processor + +| File | Role | +|------|------| +| `ValidatorProcessor.java` | Main `AbstractProcessor`. Entry point. Discovers all classes in source roots, orchestrates writers, persists class names across incremental rounds via `META-INF/.../validators.list`. | + +### Class Writers + +| File | Role | +|------|------| +| `ValidatorClassWriter.java` | Abstract base for all class writers. Holds `fullName()`, `simpleName()`, `packageName()`, `generate()`. | +| `RecordValidatorClassWriter.java` | Generates `*Validator` for a `record` type. Implements `initialize(...)` and `validate(...)`. | +| `SealedValidatorClassWriter.java` | Generates `*Validator` for a `sealed interface` whose permitted subtypes are all records — delegates to each subtype's validator. | +| `ValidatorsClassWriter.java` | Generates the `Validators.java` registry (`hasValidator`, `validate`, `getValidator`). | +| `ClassWriter.java` | Utility for writing Java source text (indentation helpers, import management). | + +### Field Writers + +| File | Role | +|------|------| +| `ValidationWriter.java` | Abstract base for writing individual field validation blocks. | +| `NullSafeWriter.java` | Generates null-check code (`if (value == null) { addError(...); return; }`). | +| `NullUnsafeWriter.java` | Generates code for primitive/non-nullable fields (no null check). | +| `FieldWriter.java` | Composes `NullSafeWriter`/`NullUnsafeWriter` with constraint writers for a single record component. | +| `WithNestedObjectWriters.java` | Mixin for writing `initialize(holder)` and `@Valid`-nested field delegation. | + +### Parsing and Type Handling + +| File | Role | +|------|------| +| `JakartaAnnotationParser.java` | Parses Jakarta constraint annotations on record components into internal models. | +| `TypeAdapter.java` | Maps `TypeMirror` to the correct writer strategy (numeric kind, temporal kind, nullable, etc.). | +| `NumericKind.java` | Enum: `INT`, `LONG`, `DOUBLE`, `BIG_DECIMAL`, `BIG_INTEGER`, `CHAR_SEQUENCE`. | +| `TemporalKind.java` | Enum: `INSTANT`, `LOCAL_DATE`, `LOCAL_DATE_TIME`, `LOCAL_TIME`, `OFFSET_DATE_TIME`, `ZONED_DATE_TIME`, `YEAR`, `YEAR_MONTH`, `MONTH_DAY`, `CALENDAR`, `DATE`. | +| `ValidationOutput.java` | Value type carrying the generated `validate(...)` method body for a single component. | + +## Supported Jakarta Constraints + +The processor recognises all 22 built-in `jakarta.validation.constraints.*` annotations: + +| Constraint | Applicable Types | +|-----------|-----------------| +| `@NotNull` | any object | +| `@Null` | any object | +| `@NotEmpty` | `CharSequence`, `Collection`, `Map`, `array` | +| `@NotBlank` | `CharSequence` | +| `@Size(min, max)` | `CharSequence`, `Collection`, `Map`, `array` | +| `@Min(value)` | numeric (int, long, BigInteger, BigDecimal, CharSequence) | +| `@Max(value)` | numeric | +| `@DecimalMin(value, inclusive)` | numeric | +| `@DecimalMax(value, inclusive)` | numeric | +| `@Positive` | numeric | +| `@PositiveOrZero` | numeric | +| `@Negative` | numeric | +| `@NegativeOrZero` | numeric | +| `@Digits(integer, fraction)` | numeric, `CharSequence` | +| `@Pattern(regexp)` | `CharSequence` | +| `@Email` | `CharSequence` | +| `@Past` | temporal types | +| `@PastOrPresent` | temporal types | +| `@Future` | temporal types | +| `@FutureOrPresent` | temporal types | +| `@AssertTrue` | `boolean`/`Boolean` | +| `@AssertFalse` | `boolean`/`Boolean` | + +**See `.agents/known-limitations.md` for what is not supported.** + +## Generated Code Conventions + +- Class annotation order: `@NullMarked` first, then `@Generated("io.github.raniagus.javalidation.validator.processor.ValidatorProcessor")` +- Imports sorted alphabetically +- `@Pattern`/`@Email` generate a `static final Pattern FIELDNAME_PATTERN` field +- `@Digits` on `CharSequence` generates `FIELDNAME_DIGITS_PATTERN` +- `initialize(ValidatorsHolder holder)` is empty unless record has `@Valid` fields +- Nested record validators are named `OuterRecord$InnerRecordValidator` (dollar-separated) + +## Test Fixture Conventions + +Test fixtures live in `src/test/java/test/` (copied as classpath resources, not compiled): +- `test/jakarta/` — fixtures for individual constraint annotations +- `test/collection/` — fixtures for `@Valid` on `Iterable`/`Map` fields + +Each fixture pair is: +- `FooRecord.java` — input record with annotations +- `FooRecordValidator.java` — expected generated output + +**See `.agents/validator-processor-tests.md` for the full guide.** + +## Test Commands + +```bash +# All processor tests +./mvnw test -pl javalidation-jakarta-validator-processor + +# One test class +./mvnw test -pl javalidation-jakarta-validator-processor -Dtest=JakartaValidationsTest + +# One nested class +./mvnw test -pl javalidation-jakarta-validator-processor \ + -Dtest="JakartaValidationsTest\$EmailRecordValidatorTest" +``` + +## Non-Obvious Behaviours + +**Surefire `--add-opens` for `jdk.compiler`.** The parent POM configures `--add-opens` flags +required by the `compile-testing` library (which accesses `jdk.compiler` internals). Running +`./mvnw test` picks them up automatically. If you run tests directly in an IDE, copy those +`-J--add-opens` JVM flags from the Surefire plugin configuration into the IDE's run configuration, +or the tests will fail with `InaccessibleObjectException`. + +**Generated validators bake in opaque message keys**, not human-readable strings. The key +`io.github.raniagus.javalidation.constraints.NotNull.message` is written literally into the +generated source. Resolution to a human-readable string happens at runtime via +`TemplateStringFormatter` (backed by `MessageSource` in Spring Boot). Without a configured +formatter, users will see raw keys in serialized errors. + +## Feature Deep-Dive + +- `.agents/features/jakarta-validator.md` +- `.agents/validator-processor-tests.md` +- `.agents/known-limitations.md` diff --git a/javalidation-jakarta-validator/AGENTS.md b/javalidation-jakarta-validator/AGENTS.md new file mode 100644 index 0000000..d546ba6 --- /dev/null +++ b/javalidation-jakarta-validator/AGENTS.md @@ -0,0 +1,105 @@ +# javalidation-jakarta-validator — Validator API + +**Package:** `io.github.raniagus.javalidation.validator` +**Dependencies:** `javalidation` (core), `jakarta.validation-api` + +Provides the `Validator` interface and supporting types. The `Validators` class here is a **stub** +that is replaced at compile-time by the annotation processor (see `javalidation-jakarta-validator-processor`). + +## Source File Index + +| File | Role | +|------|------| +| `Validator.java` | Core interface. Implement to validate a type `T`. | +| `InitializableValidator.java` | Extended interface for validators with `@Valid`-annotated nested fields. Has `initialize(ValidatorsHolder)`. | +| `Validators.java` | **Stub** — static registry. Replaced by generated class at compile-time. Throws `IllegalStateException` if the processor didn't run. | +| `ValidatorsHolder.java` | Runtime container mapping `Class` → `InitializableValidator`. Used in tests and DI wiring. | + +## Key Public API + +### `Validator` interface + +```java +public interface Validator { + // Override this for validation logic: + void validate(Validation validation, T value); + + // Default — creates Validation, calls validate(validation, value), returns finish(): + default ValidationErrors validate(T value); +} +``` + +### `InitializableValidator` interface + +```java +public interface InitializableValidator extends Validator { + // Called once during wiring to inject cross-validator references: + void initialize(ValidatorsHolder holder); +} +``` + +Generated validators always implement `InitializableValidator`. If the record has no `@Valid` nested +fields, `initialize(holder)` is a no-op. + +### `Validators` (generated static registry) + +```java +// Checks if a generated validator exists for this class +Validators.hasValidator(Class clazz) → boolean + +// Runs the validator for any annotated record type +Validators.validate(T instance) → ValidationErrors + +// Gets the typed validator instance +Validators.getValidator(Class type) → Validator +``` + +### `ValidatorsHolder` (used in tests and DI) + +```java +new ValidatorsHolder(Map, InitializableValidator> validators) + +holder.initialize() // calls initialize(this) on all validators +holder.hasValidator(Class clazz) → boolean +holder.validate(T instance) → ValidationErrors +holder.getValidator(Class clazz) → Validator +``` + +## Usage — Programmatic (without Spring) + +```java +// Wire validators manually (test or standalone) +ValidatorsHolder holder = new ValidatorsHolder(Map.of( + MyRecord.class, new MyRecordValidator(), + MyRecord.Nested.class, new MyRecord$NestedValidator() +)); +holder.initialize(); + +ValidationErrors errors = holder.validate(new MyRecord(...)); +``` + +## Usage — With Processor (compile-time) + +Annotate your records with `jakarta.validation.constraints.*` and add `@Valid` for nested records. The processor generates: +- `MyRecordValidator.java` — implements `InitializableValidator` +- `Validators.java` — static registry replacing the stub + +```java +// After processor runs, this works: +ValidationErrors errors = Validators.validate(myRecord); +``` + +## Non-Obvious Behaviours + +**`Validators.java` is a stub that is silently replaced at compile-time.** When the annotation +processor runs, it generates a new class with the exact same FQN +(`io.github.raniagus.javalidation.validator.Validators`) into `target/generated-sources/annotations`. +`javac` treats the generated class as the authoritative definition and the stub is never linked. +If you call `Validators.validate(...)` and the processor was not enabled (e.g., missing +`-processor` flag or wrong Maven configuration), you will get `IllegalStateException` at runtime +from the stub, not a compile error. + +## Feature Deep-Dive + +- `.agents/features/jakarta-validator.md` +- `.agents/validator-processor-tests.md` diff --git a/javalidation-spring-boot-starter/AGENTS.md b/javalidation-spring-boot-starter/AGENTS.md new file mode 100644 index 0000000..726c6bf --- /dev/null +++ b/javalidation-spring-boot-starter/AGENTS.md @@ -0,0 +1,109 @@ +# javalidation-spring-boot-starter — Spring Boot 4.x Auto-Configuration + +**Package:** `io.github.raniagus.javalidation.spring` +**Dependencies:** `javalidation-jackson`, `javalidation-jakarta-validator`, Spring Boot 4.x + +Provides zero-configuration Spring Boot integration: auto-configures all beans needed to use +javalidation in a Spring MVC application. + +## Source File Index + +| File | Role | +|------|------| +| `JavalidationAutoConfiguration.java` | Core auto-config. Registers `FieldKeyFormatter`, `TemplateStringFormatter`, and `MessageSource` parent injection. | +| `JavalidationJacksonAutoConfiguration.java` | Jackson auto-config. Registers `JavalidationModule` and its serializer components. | +| `JavalidationValidatorAutoConfiguration.java` | Spring MVC validator auto-config. Registers `JavalidationSpringValidator` as `@Primary` and wires it into `WebMvcConfigurer`. | +| `JavalidationProperties.java` | `@ConfigurationProperties(prefix = "io.github.raniagus.javalidation")`. Three properties: `key-notation`, `use-message-source`, `flatten-errors`. | +| `JavalidationSpringValidator.java` | Implements Spring's `Validator` interface. Bridges `Validators.validate(...)` → Spring `Errors`. Also provides `toValidationErrors(Errors)` for the reverse. | +| `MessageSourceTemplateStringFormatter.java` | `TemplateStringFormatter` backed by Spring `MessageSource`. Falls back to `MessageFormat` if key not found. | +| `KeyNotation.java` | Enum: `PROPERTY_PATH` (default), `DOTS`, `BRACKETS`. | +| `EnableJavalidation.java` | `@ImportAutoConfiguration` annotation for test slices (`@WebMvcTest`, etc.) that disable auto-config. | + +## Auto-Configuration Beans + +### `JavalidationAutoConfiguration` + +| Bean | Condition | Type | +|------|-----------|------| +| `propertyPathNotationFieldKeyFormatter` | `key-notation=property_path` (default) | `FieldKeyFormatter` | +| `dotNotationFieldKeyFormatter` | `key-notation=dots` | `FieldKeyFormatter` | +| `bracketNotationFieldKeyFormatter` | `key-notation=brackets` | `FieldKeyFormatter` | +| `defaultTemplateStringFormatter` | `use-message-source=false` | `TemplateStringFormatter` | +| `messageSourceTemplateStringFormatter` | `MessageSource` bean present + `use-message-source=true` (default) | `TemplateStringFormatter` | +| `javalidationMessageSourceParentConfigurer` | `messageSource` bean present + `use-message-source=true` | `BeanFactoryPostProcessor` | + +### `JavalidationJacksonAutoConfiguration` + +| Bean | Type | +|------|------| +| `javalidationModule` | `JavalidationModule` | +| `fieldKeySerializer` | `ValueSerializer` | +| `templateStringValueSerializer` | `ValueSerializer` | +| `flattenedErrorsSerializer` | `ValueSerializer` (only if `flatten-errors=true`) | + +### `JavalidationValidatorAutoConfiguration` + +| Bean | Type | +|------|------| +| `javalidationSpringValidator` | `JavalidationSpringValidator` (`@Primary`) | +| `javalidationMvcConfigurer` | `WebMvcConfigurer` (wires validator into Spring MVC) | + +## `JavalidationProperties` Reference + +```yaml +io.github.raniagus.javalidation: + key-notation: property_path # property_path (default) | dots | brackets + use-message-source: true # true (default) | false + flatten-errors: false # false (default) | true +``` + +## `JavalidationSpringValidator` + +```java +// Implements Spring's org.springframework.validation.Validator +validator.supports(Class clazz) → boolean (delegates to Validators.hasValidator) +validator.validate(Object target, Errors) → void (calls Validators.validate, fills Errors) + +// Static helper: Spring Errors → ValidationErrors +JavalidationSpringValidator.toValidationErrors(Errors errors) → ValidationErrors +``` + +## `@EnableJavalidation` + +Use on test classes (e.g., `@WebMvcTest`) to import all three auto-configuration classes: +```java +@WebMvcTest(MyController.class) +@EnableJavalidation +class MyControllerTest { … } +``` + +Imports: `JavalidationAutoConfiguration`, `JavalidationJacksonAutoConfiguration`, `JavalidationValidatorAutoConfiguration`. + +## MessageSource Integration + +When `use-message-source=true` (default), the starter: +1. Registers a `BeanFactoryPostProcessor` that walks to the bottom of the `MessageSource` hierarchy +2. Injects `io/github/raniagus/javalidation/messages.properties` as a parent `ResourceBundleMessageSource` +3. This provides all 22 constraint keys with default English strings out of the box +4. User's own `messages.properties` takes precedence (it is higher in the hierarchy) +5. `MessageSourceTemplateStringFormatter` tries `MessageSource` first; falls back to raw `MessageFormat.format(key, args)` if the key is not found + +## Test Conventions + +**See `.agents/spring-boot-starter-tests.md` for the full guide.** + +All test classes extend `AutoConfigurationTest` and use `@SpringBootTest(classes = TestApplication.class)` on nested static classes. + +```bash +# All starter tests +./mvnw test -pl javalidation-spring-boot-starter + +# One test class +./mvnw test -pl javalidation-spring-boot-starter \ + -Dtest=TemplateStringFormatterAutoConfigurationTest +``` + +## Feature Deep-Dive + +- `.agents/features/spring-boot-starter.md` +- `.agents/spring-boot-starter-tests.md` diff --git a/javalidation/AGENTS.md b/javalidation/AGENTS.md new file mode 100644 index 0000000..44ca4a0 --- /dev/null +++ b/javalidation/AGENTS.md @@ -0,0 +1,157 @@ +# javalidation — Core Module + +**Package:** `io.github.raniagus.javalidation` +**Dependencies:** zero (only `org.jspecify` annotations at compile-time) + +This module is the foundation of the library. Every other module depends on it. + +## Source File Index + +### Root package — `io.github.raniagus.javalidation` + +| File | Role | +|------|------| +| `Result.java` | Sealed interface: `Ok` / `Err`. Railway-oriented success/failure monad. | +| `Validation.java` | Mutable builder for accumulating errors imperatively. | +| `ValidationErrors.java` | Immutable record holding `List` root errors and `Map>` field errors. | +| `JavalidationException.java` | Unchecked exception wrapping `ValidationErrors`. Thrown by `getOrThrow()`, `check()`, `checkAndGet()`. | +| `TemplateString.java` | Record holding a message-key `String` + `Object[]` args; formatting is deferred. | +| `FieldKey.java` | Ordered array of `FieldKeyPart` segments representing a field path (e.g. `items[0].price`). | +| `FieldKeyPart.java` | Sealed interface: `StringKey(String)` for named fields, `IntKey(int)` for numeric indices. | +| `PartialResult.java` | Record `(T success, ValidationErrors errors)` — holds both partial successes and errors side-by-side. | +| `ResultCollector.java` | Interface + static factory methods for `Collector, ?, R>` stream collectors. | +| `ResultCollectorWrapper.java` | Internal `WithIndex` and `WithPrefix` wrappers used by `ResultCollector`. | +| `ListResultCollector.java` | Internal implementation for `toListOrThrow` and `toResultList` collectors. | +| `ValidationCollector.java` | Internal implementation for `toValidation` and `addErrorsTo` collectors. | + +### `combiner` sub-package — `io.github.raniagus.javalidation.combiner` + +| File | Role | +|------|------| +| `ResultCombiner2.java` … `ResultCombiner10.java` | Applicative-style combiners for 2–10 `Result` values. Obtained via `Result.and(...)`. | + +### `format` sub-package — `io.github.raniagus.javalidation.format` + +| File | Role | +|------|------| +| `TemplateStringFormatter.java` | `@FunctionalInterface` — formats `TemplateString` to `String`. Default: `MessageFormatTemplateStringFormatter`. | +| `MessageFormatTemplateStringFormatter.java` | Implementation using `java.text.MessageFormat`. | +| `FieldKeyFormatter.java` | `@FunctionalInterface` — formats `FieldKey` to `String`. Default: `PropertyPathNotationFormatter`. | +| `PropertyPathNotationFormatter.java` | Renders `items[0].price` (dots for strings, brackets for ints). **Default.** | +| `DotNotationFormatter.java` | Renders `items.0.price` (all dots). | +| `BracketNotationFormatter.java` | Renders `[items][0][price]` (all brackets). | + +### `function` sub-package — `io.github.raniagus.javalidation.function` + +| File | Role | +|------|------| +| `TriFunction.java` … `DecaFunction.java` | `@FunctionalInterface` types for 3–10 arguments. Used by `ResultCombiner3`–`ResultCombiner10`. | + +## Key Public API + +### `Result` + +``` +Result.ok(value) → Ok +Result.error(message, args...) → Err +Result.error(ValidationErrors) → Err +Result.of(Supplier) → Ok or Err +Result.combine(Supplier, results…) → internal — used by combiners + +result.map(fn) → Result +result.flatMap(fn) → Result +result.mapErr(fn) → Result +result.flatMapErr(fn) → Result +result.bimap(onSuccess, onError) → Result +result.ensure(predicate, msg, args…) → Result +result.ensureAt(predicate, field, msg) → Result +result.and(other) → ResultCombiner2 +result.or(supplier) → Result +result.or(other) → Result +result.fold(onSuccess, onFailure) → U +result.getOrThrow() → T | throws JavalidationException +result.getOrElse(default) → T +result.getOrElse(supplier) → T +result.peek(action) → Result +result.peekErr(action) → Result +result.withPrefix(parts…) → Result +result.errors() → ValidationErrors +``` + +### `Validation` + +``` +Validation.create() → Validation (factory) + +validation.addError(msg, args…) → Validation +validation.addErrorAt(field, msg, args…) → Validation (String or Number field) +validation.withField(field, runnable) → Validation (String or Number field) +validation.withEach(items, consumer) → Validation (Consumer or BiConsumer) +validation.addAll(Validation) → Validation +validation.addAll(ValidationErrors) → Validation +validation.addAllAt(FieldKey, errors) → Validation + +validation.finish() → ValidationErrors +validation.asResult(value) → Result +validation.asResult(supplier) → Result +validation.check() → void | throws JavalidationException +validation.checkAndGet(supplier) → T | throws JavalidationException +``` + +### `ValidationErrors` + +``` +ValidationErrors.empty() +ValidationErrors.of(msg, args…) +ValidationErrors.at(field, msg, args…) (String, Number, or FieldKey) + +errors.mergeWith(other) → ValidationErrors +errors.withPrefix(parts…) → ValidationErrors +errors.rootErrors() → List +errors.fieldErrors() → Map> +errors.isEmpty() → boolean +errors.isNotEmpty() → boolean +errors.count() → int +``` + +### `ResultCollector` (stream collectors) + +```java +// Collector factories (static methods on ResultCollector) +ResultCollector.toListOrThrow() // → List or throw +ResultCollector.toListOrThrow(initialCapacity) +ResultCollector.toResultList() // → Result> +ResultCollector.toResultList(initialCapacity) +ResultCollector.toPartialResult() // → PartialResult> +ResultCollector.toValidation() // → Validation (errors only) +ResultCollector.addErrorsTo(validation) // → Validation (mutates existing) + +// Wrappers +ResultCollector.withIndex(collector) // adds [0], [1]… prefix +ResultCollector.withPrefix(String, collector) // adds string prefix +ResultCollector.withPrefix(int, collector) // adds int prefix +``` + +### `FieldKey` / `FieldKeyPart` + +``` +FieldKey.of(String...) → FieldKey +FieldKey.of(Number...) → FieldKey +FieldKey.of(Object...) → FieldKey (mixed; Number → IntKey, else → StringKey) +FieldKey.of(FieldKeyPart...) → FieldKey +fieldKey.withPrefix(parts) → FieldKey + +FieldKeyPart.StringKey(String value) +FieldKeyPart.IntKey(int value) +FieldKeyPart.ofPath(String[]) → FieldKeyPart[] +FieldKeyPart.ofPath(Number[]) → FieldKeyPart[] +FieldKeyPart.ofPath(Object[]) → FieldKeyPart[] +``` + +## Feature Deep-Dives + +- **Functional (monadic) style:** `.agents/features/functional-style.md` +- **Imperative style:** `.agents/features/imperative-style.md` +- **Stream collectors:** `.agents/features/stream-collectors.md` +- **Result / error merging:** `.agents/features/result-merging.md` +- **FieldKey internals and prefix mechanics:** `.agents/features/field-key.md`