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

enabled

Defaults to true. Set false to disable unwrapping for one method, overriding a type-level @UnwrapEnvelope.

requires

Extra top-level keys that must all be present (alongside content) for a body to be treated as an envelope. Default {} — detect on the content key alone. Use it to harden against a plain payload that happens to carry a content field, e.g. @UnwrapEnvelope(requires = "cmdUuid").

reason

Free-text note, surfaced in the unwrap DEBUG log. Documentary only.

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 SliceEnvelope unwraps to List<E>, dropping pageNumber/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+json error already throws HttpClientResponseException),

  • not application/json,

  • no content key, or a content that is null,

  • a top-level array or a body that does not decode as an object,

  • a required requires key is absent,

  • enabled = false, or the declared return type is itself an Envelope.

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.

Scope

Unwrapping targets application/json only; vendor +json media types are not unwrapped. Streaming and Server-Sent-Events responses are out of scope.