v0.4.9 (31 July 2026)

v0.4.9 on GitHub

This release completes the @GenerateMapping mapper programme: every wire shape a REST boundary throws at a record domain now maps through one spec convention, under one null doctrine, rendering as one 422 response.

The full mapper (#628, #645, #625, #624, #623)

  • Bean-shaped wires: a mutable getter/setter class or a builder-constructed immutable one (JAXB getter-only collections included) maps like a record, with the full feature set (renames, leaves, derived fields, container lifting, nesting both ways) and a domain Optional<T> bridging to a nullable property. asIso() stays truthful: an all-primitive bean earns it, a reference-reading one does not. See Bean-shaped wire targets.
  • Sparse PATCH write-back: a spec extending UpdateSpec<D, W> (bean wire only) generates updateFrom(Wire) : Edits.Accumulated<Domain>, the REST PATCH contract as a validated fold: null means keep, present values are set or parsed, every invalid present field reports at once, and shapes that cannot honour null-as-absent (record wires, primitives, Optional bridges, sealed hierarchies) are rejected. The example app gains a worked PATCH /api/users/{id} endpoint (#647). See Sparse PATCH write-back.
  • The validated patch tier: a projection carrying fallible correspondences swaps asLens() for a dense patch(domain, wire) : Validated<NonEmptyList<FieldError>, Domain> write-back, validating every projected component at once while unprojected ones survive by construction. Deliberately the opposite of the sparse tier: patch treats a missing value as an error, updateFrom as keep-the-current-one. See Leaf-carrying projections.
  • Generic records, three ways, all nestable: concrete instantiations (MappingSpec<Page<User>, PageDto<UserDto>>), threaded specs (one generic Impl behind instance()), and element-mapped specs (an abstract ValidatedPrism<TDto, T> leaf supplied through a generated of(...) factory), with use sites resolving by type-argument unification and composing of(...) in place. Record-to-record only; raw and wildcard uses are diagnosed. See Generic records.
  • Shared vocabulary: specs may extend plain mix-in interfaces carrying renames, leaves and derived fields, collected with Java's own precedence and diagnosed with the declaring interface named. See Shared vocabulary.
  • Every tier is law-checked through MappingLaws (including located-validation clauses on both write-backs) and pinned by per-tier golden Impls, so generator drift fails the build.
  • Build tooling: every annotation processor registers with Gradle's incremental annotation processing (nineteen in hkj-processor, plus the Spring client processor; #677), so consuming source sets keep incremental compilation. Lombok interop is covered by test: a @Data class works as a bean-shaped wire, provided Lombok is listed before hkj-processor on the processor path (the reverse order is diagnosed). See Manual setup.
  • Injection and testing documented (#678): register the surface you consume as a bean (ValidatedPrism for parse-capable mappings; method references for build, patch and updateFrom), and fake it as a value with ValidatedPrism.of(...) (the interface is sealed, so mocking is impossible by design). The Spring example app demonstrates the seam end to end. See Injecting and testing generated mappings.

One null doctrine (#653, #659, #660)

A JSON binder leaves a missing property null on a record component just as on an unset bean property, so every reference-typed read on every accumulating surface (parse on both wire shapes, patch, fallible @GenerateMerge legs, ValidatedPrism.parseAll/parseValues) is guarded into a located, accumulating FieldError (must not be null), never an exception; failures locate through nesting and inside containers by index or key (customer.name, emails.1), identity-copied containers included. The caller-contract boundaries: a null wire, source argument or map key stays requireNonNull, and the total directions (build, asLens().set, asIso().reverseGet) stay unguarded by declaration. A lossless record mapping keeps asIso(), its guards covering hostile bindings only.

Behaviour changes against v0.4.8, all in the friendly direction but observable:

  • parse and fallible assemble on null-carrying wires previously threw NullPointerException; they now return located Invalid (a 422 instead of a 500 at a Spring boundary). This includes null elements inside containers, which previously threw from the bulk forms or passed through identity copies unexamined.
  • List-element failures gain their index segment: emails: not an email address becomes emails.1: not an email address in 422 payloads (bracketed rendering stays deferred to the future path-segment model).
  • The generator is stricter about silent mistakes: a locally declared leaf naming no domain component is now a compile error with a nearest-name hint (Did you mean 'email()'?) instead of silently validating nothing; spec methods colliding with generated members are rejected (#654); and a leaf on a projected component now takes effect (selecting the patch tier) instead of being silently ignored.

Spring: the 422 leg (#627)

A controller returning any all-located-FieldError Invalid (a mapper parse, patch or updateFrom result, a Path.fields() assembly, an @GenerateAssembly result) renders as a single 422 Unprocessable Content response listing every bad field, each with its display path, lossless segments and message. The leg is chosen by payload shape alone; the status is configurable via hkj.web.validation-field-error-status. Paths use domain component names, renames included. See The 422 leg.

Behaviour change: a pre-existing endpoint returning an all-FieldError payload moves from 400 to 422 on upgrade; set hkj.web.validation-field-error-status: 400 to restore it. Tests and examples adopt Spring Framework 7's HttpStatus.UNPROCESSABLE_CONTENT (the RFC 9110 rename).

hkj-spring hardening (#642)

A review pass fixed a security fail-open in the JWT converter, async-handler lifecycle defects, a Jackson contextual-binding bug, and dead configuration.

Breaking changes / migration:

  • SSE responses commit after the first stream element resolves, so a stream failing at its start returns the configured hkj.web.vstream-failure-status instead of a broken 200; EventSource.onopen fires on the first element, so long-idle streams should emit an early heartbeat.
  • Removed dead @ConfigurationProperties surface (source-breaking for programmatic-config consumers): HkjProperties.Jackson.SerializationFormat and the *-format accessors, HkjProperties.Validation (hkj.validation.*), the hkj.async.executor-* fields, EffectConfig.startupValidation/interpreterSelection, and EitherAuthorizationManager.AuthorizationSuccess. Delete the keys; none altered behaviour.
  • hkj.security.validated-user-details defaults to false and starts empty (sample accounts moved to ValidatedUserDetailsService.withSampleUsers()); enabling it without registering accounts fails every login.
  • A JWT with a missing or malformed authorities claim is rejected (401) instead of authenticating with empty authorities; set hkj.security.reject-missing-authorities-claim: false for lenient missing-claim handling.
  • Effect-boundary interpreter resolution fails fast at startup on ambiguous or profile-gated-away matches, previously silent scan-order selections.

Previous: v0.4.10 Next: v0.4.8