Transparent envelope unwrapping
@UnwrapEnvelope lets a declarative Micronaut @Client declare the inner payload type — Account, List<Account>, Optional<Account> — instead of SingleEnvelope<Account>, and have the envelope’s content lifted out transparently when reading the response.
It is opt-in and lenient: a response that is not an envelope passes through untouched.
Dependency
The client conventions ship in their own module, conta-http-client-kit:
dependencies {
implementation 'no.conta.http:conta-http-client-kit:0.+'
}
Requirement: lenient deserialization
Unwrapping requires the consuming app to deserialize leniently — jackson.deserialization.failOnUnknownProperties must be false (the micronaut-jackson-databind default).
Micronaut eagerly decodes the 2xx body into the declared inner type before the response filter runs, so the filter cannot pre-empt that decode — it can only replace the already-decoded response.
On the unwrap path that eager decode binds the full envelope JSON (content, tenantId, cmdUuid, …) against the inner type (Account), whose fields do not include the envelope keys.
With failOnUnknownProperties = false those extra keys are ignored and the throwaway decode is harmless; the filter then re-decodes the envelope and substitutes the unwrapped content.
If the app sets failOnUnknownProperties = true, that eager decode throws CodecException on the envelope keys and the call fails with HttpClientResponseException before the filter can unwrap.
Do not enable strict deserialization on a client that relies on @UnwrapEnvelope.
|
Enabling it
Annotate the @Client interface (every method unwraps) or a single method:
@Client("/ledger")
@UnwrapEnvelope
public interface LedgerClient {
@Get("/accounts/{id}")
Account get(String id); // server returns SingleEnvelope<Account> → content
@Get("/accounts")
List<Account> list(); // server returns SliceEnvelope<Account> → content list
@Get("/accounts/{id}/full")
SingleEnvelope<Account> getFull(String id); // declares the envelope → never unwrapped
@Get("/health")
@UnwrapEnvelope(enabled = false)
Status health(); // not enveloped → unwrapping disabled
}
A method whose declared return type is an Envelope (after peeling Mono/Flux/Optional/HttpResponse) is never unwrapped — you asked for the wrapper, you get it.
Each 2xx application/json response on an annotated client costs one extra decode (the envelope check), on top of Micronaut’s own body decode.
On a hot client where only some endpoints are enveloped, annotate the enveloping methods rather than the whole interface, so non-enveloping endpoints skip the check.
|
Members
| Member | Effect |
|---|---|
|
Defaults to |
|
Extra top-level keys that must all be present (alongside |
|
Free-text note, surfaced in the unwrap |
What gets unwrapped
The filter unwraps when the response is a buffered application/json 2xx body that decodes to a SingleEnvelope with a non-null content.
The content payload is decoded straight into the declared inner type, so BigDecimal precision and scale are preserved.
Return-type handling:
-
T,Optional<T>,List<E>— decoded directly. -
Mono<T>,Flux<T>,CompletableFuture<T>,HttpResponse<T>— peeled to the body type, then unwrapped. -
A
SliceEnvelopeunwraps toList<E>, droppingpageNumber/pageSize.
The replacement response keeps the original 2xx status and headers.
Unwrapping a SliceEnvelope to a List<E> silently discards the paging metadata.
The caller is responsible for understanding that a returned list may be one slice of several.
A caller that needs paging declares SliceEnvelope<E> (auto-skipped) and reads it directly.
|
When it passes through
The response is returned untouched — never an error — when it is not an envelope:
-
non-2xx (a
problem+jsonerror already throwsHttpClientResponseException), -
not
application/json, -
no
contentkey, or acontentthat isnull, -
a top-level array or a body that does not decode as an object,
-
a required
requireskey is absent, -
enabled = false, or the declared return type is itself anEnvelope.
Because binding is lenient, a misconfiguration (annotating a client whose endpoint does not envelope, or a payload that collides with the content key) can surface as a silently empty object rather than an error.
On no.conta.http.client.mn.UnwrapEnvelopeClientFilter, DEBUG logs each successful unwrap and TRACE logs why a candidate response was skipped — enable TRACE when an expected unwrap silently produced an empty object. Use requires to disambiguate content-key collisions.
|