v0.4.9 (31 July 2026)
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) generatesupdateFrom(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,Optionalbridges, sealed hierarchies) are rejected. The example app gains a workedPATCH /api/users/{id}endpoint (#647). See Sparse PATCH write-back. - The validated
patchtier: a projection carrying fallible correspondences swapsasLens()for a densepatch(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:patchtreats a missing value as an error,updateFromas 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 behindinstance()), and element-mapped specs (an abstractValidatedPrism<TDto, T>leaf supplied through a generatedof(...)factory), with use sites resolving by type-argument unification and composingof(...)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@Dataclass works as a bean-shaped wire, provided Lombok is listed beforehkj-processoron the processor path (the reverse order is diagnosed). See Manual setup. - Injection and testing documented (#678): register the surface you consume as a bean (
ValidatedPrismfor parse-capable mappings; method references forbuild,patchandupdateFrom), and fake it as a value withValidatedPrism.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:
parseand fallibleassembleon null-carrying wires previously threwNullPointerException; they now return locatedInvalid(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 addressbecomesemails.1: not an email addressin 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 thepatchtier) 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-statusinstead of a broken200;EventSource.onopenfires on the first element, so long-idle streams should emit an early heartbeat. - Removed dead
@ConfigurationPropertiessurface (source-breaking for programmatic-config consumers):HkjProperties.Jackson.SerializationFormatand the*-formataccessors,HkjProperties.Validation(hkj.validation.*), thehkj.async.executor-*fields,EffectConfig.startupValidation/interpreterSelection, andEitherAuthorizationManager.AuthorizationSuccess. Delete the keys; none altered behaviour. hkj.security.validated-user-detailsdefaults tofalseand starts empty (sample accounts moved toValidatedUserDetailsService.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: falsefor lenient missing-claim handling. - Effect-boundary interpreter resolution fails fast at startup on ambiguous or profile-gated-away matches, previously silent scan-order selections.