Compound Documents Plugin
Overview
The Compound Documents Plugin integrates the compound documents resolver into the JsonApi4j request processing pipeline, automatically enriching responses with the included section when the include query parameter is present.
There are two distinct components involved in compound document support, and it’s important to understand the difference:
jsonapi4j-cd-plugin— the plugin for JsonApi4j applications. It hooks into the plugin pipeline and handles compound document resolution as part of the normal request lifecycle. If you’re building a JsonApi4j-based service, this is what you need.jsonapi4j-compound-docs-resolver— the standalone resolver module. It has no dependency on the JsonApi4j plugin system or Servlet API and can be embedded anywhere — for example, at an API Gateway level. See the Compound Documents section for details.
In short: the plugin is for JsonApi4j apps, the resolver is for anything else.
Getting Started
To enable the plugin, add the following dependency:
<dependency>
<groupId>pro.api4</groupId>
<artifactId>jsonapi4j-cd-plugin</artifactId>
<version>${jsonapi4j.version}</version>
</dependency>
If you’re using Spring Boot or Quarkus with jsonapi4j-all-plugins — the plugin is already on the classpath and will be auto-configured.
Enable it via configuration:
jsonapi4j:
cd:
enabled: true
Available Properties
| Property name | Default value | Description |
|---|---|---|
jsonapi4j.cd.enabled |
false |
Enables/disables Compound Documents post-processing. |
jsonapi4j.cd.maxHops |
2 |
The deepest include path supported, in relationships: a.b is 2. A deeper one is handled per unsupportedIncludes. |
jsonapi4j.cd.unsupportedIncludes |
FAIL |
What a request with an unsupported include path gets — one deeper than maxHops, or naming a relationship its resource type doesn’t have, see Unsupported include paths. FAIL: 400 Bad Request naming the path, as the JSON:API spec requires. IGNORE: the path is resolved only as far as supported and listed in meta.includedIncomplete as UNSUPPORTED_INCLUDE. |
jsonapi4j.cd.maxIncludedResources |
100 |
When included reaches this many resources, resolution fetches nothing further, and the include paths left unresolved are listed in meta.includedIncomplete as MAX_INCLUDED_RESOURCES. Checked between hops, so the last hop can take included past it. |
jsonapi4j.cd.errorStrategy |
IGNORE |
What a failed include does — see Error handling. IGNORE: the failed resources are left out of included, listed in meta.includedIncomplete, and the response is marked no-store. FAIL: the request is answered with a JSON:API error document instead. |
jsonapi4j.cd.propagation |
FIELDS,CUSTOM_QUERY_PARAMS,HEADERS |
Request parts propagated to the include calls: FIELDS, CUSTOM_QUERY_PARAMS, HEADERS — see Header propagation for which headers go where. |
jsonapi4j.cd.credentialHeaders |
Authorization,Cookie,Proxy-Authorization,X-Authenticated-User-Id,X-Authenticated-User-Granted-Scopes,X-Authenticated-Client-Entitlements |
Headers carrying the client’s identity, sent only to same-app types and to mappings with propagateCredentials: true. Add your own, e.g. X-Api-Key, or the header names of a custom PrincipalResolver. |
jsonapi4j.cd.deduplication |
DATA_AND_INCLUDED |
How resource objects repeat (by type / id). DATA_AND_INCLUDED: each resource appears once across data and included — spec-compliant. INCLUDED_ONLY: once within included, and a primary resource an include path reaches is repeated there, so a client can resolve every related resource from included alone. NONE: no deduplication. The last two go beyond the spec’s one-resource-object-per-type/id rule. |
jsonapi4j.cd.httpConnectTimeoutMs |
5000 |
Controls how long to wait when establishing TCP connection (in millisecond). Applied to each generated HTTP request. |
jsonapi4j.cd.httpTotalTimeoutMs |
10000 |
Controls total request timeout (in millisecond). Applied to each generated HTTP request. |
jsonapi4j.cd.mapping.<resourceType>.url |
not set | Base URL of the service serving the resource type. Set it only for a type served by a different service; same-app types (including the built-in meta types) need none — see Resolving base URLs. |
jsonapi4j.cd.mapping.<resourceType>.maxBatchSize |
mapping.default.maxBatchSize |
Max filter[id]=... batch size for the type, e.g. when its service enforces a stricter cap. Also applies to a same-app type, with no url. |
jsonapi4j.cd.mapping.<resourceType>.propagateCredentials |
false |
Whether the client’s credentials (credentialHeaders) are sent to the service at url — see Header propagation. Same-app types always get them, so it’s rejected without a url. |
jsonapi4j.cd.mapping.<resourceType>.transport |
mapping.default.transport |
How a same-app type is fetched: IN_PROCESS, or HTTP from mapping.default.url — see Fetching same-app types over HTTP. A type with a url is always fetched over HTTP, so IN_PROCESS is rejected there, and HTTP without a url needs mapping.default.url. |
jsonapi4j.cd.mapping.default.url |
not set | This app’s own base URL, where same-app types with transport HTTP are fetched from. Setting it alone doesn’t switch anything to HTTP. |
jsonapi4j.cd.mapping.default.transport |
IN_PROCESS |
How same-app types without a transport of their own are fetched. HTTP needs mapping.default.url. |
jsonapi4j.cd.mapping.default.maxBatchSize |
20 |
Fallback max number of resource IDs per downstream filter[id]=... request, for every type without a maxBatchSize of its own. Larger ID sets are split into parallel chunks of this size. default is a reserved key that takes url, transport and maxBatchSize but not propagateCredentials — each service is trusted with credentials on its own entry. A resource type named default fails startup. |
Each resource type has one mapping entry holding all its settings:
jsonapi4j:
cd:
mapping:
orders: # served by another service
url: https://orders.internal/jsonapi
maxBatchSize: 50
propagateCredentials: true # the orders service gets the client's Authorization and cookies
rates:
url: https://partner.example.com/jsonapi # no credentials for a partner API
users:
maxBatchSize: 100 # served by this app, fetched in-process
Cache properties
| Property name | Default value | Description |
|---|---|---|
jsonapi4j.cd.cache.enabled |
true |
Enables/disables the built-in resource cache for compound docs resolution. |
jsonapi4j.cd.cache.maxSize |
1000 |
Soft maximum number of cached entries. Eviction uses LRU + TTL expiration. |
How includes are fetched
To assemble the included array, the resolver fetches each included resource type, hop by hop, in batches of
filter[id]. Where each type is fetched from:
- A
jsonapi4j.cd.mapping.<type>.url: over HTTP from that service. Set it only for resource types served by a different service (a distributed / microservice setup), e.g.jsonapi4j.cd.mapping.orders.url=https://orders.internal/jsonapi. - Otherwise the type is served by this app, and is fetched in-process — the read runs through the framework directly, as an HTTP request to the app would, but without one: no socket, no servlet filters, no JSON sent and parsed again, and no second worker thread held while the original request waits.
- Unless its transport is
HTTP: then it’s fetched over HTTP fromjsonapi4j.cd.mapping.default.url, this app’s own address — see Fetching same-app types over HTTP.
This means same-app includes need no mapping and no base URL anywhere in config — including the built-in meta
types (state, plugins, resources, relationships, operations, config), which always resolve once
jsonapi4j.cd.enabled=true. A same-app mapping entry without a url sets only the type’s maxBatchSize and
transport; the app
validates the size of filter[id] of in-process reads the same way as of any other request.
An in-process read goes through what an HTTP request to the app goes through inside the framework: request
validation, plugins such as access control, the operation itself. It runs as the client’s
principal — resolved by the app’s PrincipalResolver — so access control decides exactly as for the client, and
fields[...], custom query parameters and headers are carried over per jsonapi4j.cd.propagation. Resources read
in-process are never stored in the resource cache: reading them is cheap, and they are always fresh.
Error handling
Resolving includes means fetching other resources, and those fetches can fail: a non-2xx response, a timeout, a
connection error, a response that isn’t a JSON:API document, or a resource type with no route to fetch it from.
jsonapi4j.cd.errorStrategy decides what happens then.
IGNORE (default) — a failed include never fails the request. The resources that couldn’t be fetched are left out
of included, everything that did resolve is returned with the primary data’s status, and the failure is logged
(WARN; ERROR for a type with no route, which is a configuration mistake rather than a transient failure). Failures
are isolated per filter[id] batch, so one failed batch doesn’t drop its siblings. A response with an incomplete
included carries Cache-Control: no-store, so no shared cache keeps a partial document for the primary resource’s
full max-age.
The document also says what is missing — see Incomplete included.
FAIL — the request is answered with a JSON:API error document instead of the primary data:
| Failure | Status | Error code |
|---|---|---|
| a downstream call timed out | 504 |
GATEWAY_TIMEOUT |
| any other downstream failure | 502 |
BAD_GATEWAY |
| a resource type with no route to fetch it from | 500 |
internal error |
Error details never name downstream URLs. The documents are rendered through the same error handler registry as every
other error the API returns, so they share its format — and an application that maps these exceptions itself
(DownstreamTimeoutException, ErrorJsonApiResponseException, DomainResolutionException) keeps its own mapping.
Unsupported include paths
The JSON:API spec requires 400 Bad Request for an include path a server can’t identify or doesn’t support. A path is
unsupported when it is deeper than maxHops, or when it names a relationship its resource type doesn’t have — at any
position in the path. Under unsupportedIncludes: FAIL (default) the request is answered with one error per path,
naming it in meta.path:
{ "errors": [ {
"status": "400",
"code": "UNSUPPORTED_INCLUDE",
"detail": "Resource type 'countries' has no relationship 'economy'",
"source": { "parameter": "include" },
"meta": { "path": "placeOfBirth.economy" }
} ] }
Who finds out:
- Depth is known from the request alone, so a path deeper than
maxHopsis rejected before the primary data is even fetched. - An unknown relationship is rejected by the server of its resource type. The framework checks every request —
with or without this plugin — against the relationships of the requested type, so the first relationship of a path
is checked by the app serving the primary data. The following ones are checked by the services the plugin fetches
them from, over the same
includeparameter: their400 UNSUPPORTED_INCLUDEis mapped back to the full path of the original request,placeOfBirth.economyabove. A downstream service that isn’t built on JsonApi4j is recognized only if it answers the same way — an unknown relationship it ignores goes unnoticed.
Under IGNORE an unsupported path is resolved only as far as supported and listed as UNSUPPORTED_INCLUDE — up to
maxHops, or up to the unknown relationship. The request is then served again without the rejected path (by this app,
or by the downstream service that rejected it), so one mistyped include doesn’t cost the rest of the document.
Limits
maxHops is the deepest include path supported — see Unsupported include paths.
maxIncludedResources bounds the size of included, which depends on the data, not the request. Once included
has reached it, nothing further is fetched and the include paths left unresolved are listed as
MAX_INCLUDED_RESOURCES. A hop that has started always finishes, so the last one can take included past the limit —
nothing requested is dropped for it, and no gap is reported when nothing was left to fetch.
Incomplete included
Whenever included misses something the request asked for, the document says what and why. Its top-level meta lists
each gap — merged into the document’s own meta, and present only when included is incomplete:
"meta": {
"includedIncomplete": [
{ "reason": "FETCH_FAILED", "type": "currencies" },
{ "reason": "NO_ROUTE", "type": "regions" },
{ "reason": "MAX_INCLUDED_RESOURCES", "path": "relatives.relatives" },
{ "reason": "UNSUPPORTED_INCLUDE", "path": "relatives.relatives.relatives" }
]
}
| Reason | Names | What the client can do |
|---|---|---|
FETCH_FAILED |
a type | Retry — likely transient. The response is marked no-store. |
NO_ROUTE |
a type | Nothing — a server configuration issue. The response is marked no-store. |
MAX_INCLUDED_RESOURCES |
a path | Ask for less: fewer or shallower includes, smaller pages. Everything below the path is missing too. |
UNSUPPORTED_INCLUDE |
a path | Fix the path: it is deeper than maxHops, or names an unknown relationship. The unsupported part is missing. |
A type-level gap comes from a failed fetch, which is batched per type across paths; a path-level gap comes from a
limit, which cuts the include tree at a depth. Which resources exactly are missing follows from the document: those
linked in relationships with no matching resource object in included. Only failures forbid storing the response —
the limits give the same result on every request, so it may be cached like any other.
Limitations
Servlet filters don’t see same-app includes. An in-process read never passes through the servlet container, so
URL-based security rules (e.g. Spring Security restricting /jsonapi/countries), and logging, metrics or rate limiting
done in filters don’t apply to it — only the framework itself does, including the
access control plugin. Put access rules for resource types that can be included there; or,
when filters have to see include requests, fetch same-app types over HTTP as below.
The executor must tolerate nested waits. Included resources are fetched on the framework’s executor, and an
in-process read resolves relationships on that same executor, then waits for them. The defaults handle that, and so do
virtual threads, ForkJoinPool, and a bounded ThreadPoolExecutor with CallerRunsPolicy. A fixed pool with a task
queue (Executors.newFixedThreadPool(n)) can deadlock under load: every thread waits on work queued behind it, and
in-process reads have no timeout to break the wait. See Tune the Executor for the
safe choices.
Fetching same-app types over HTTP
A same-app type with transport HTTP is fetched over HTTP from jsonapi4j.cd.mapping.default.url — this app’s own
address — like any other service: through every servlet filter, and with the client’s credentials. The transport is
set per type, or for all of them on the reserved default entry:
jsonapi4j:
cd:
mapping:
default:
url: http://127.0.0.1:8080/jsonapi
transport: HTTP # same-app types over HTTP...
users:
transport: IN_PROCESS # ...except users
or the other way round: HTTP only on the types whose includes servlet filters must see. Set the address where the
deployment is configured — in Spring Boot and Quarkus e.g. through environment variables:
JSONAPI4J_CD_MAPPING_DEFAULT_URL=http://127.0.0.1:8080/jsonapi
JSONAPI4J_CD_MAPPING_DEFAULT_TRANSPORT=HTTP
A plain servlet deployment reads its settings from the config file only, so point JSONAPI4J_CONFIG at a
per-environment file that sets mapping.default.url.
Startup fails on a transport that can’t work: HTTP without mapping.default.url, or IN_PROCESS on a type mapped
to another service’s url.
Over HTTP every include is a request of its own, so it brings what an HTTP call costs:
- each include holds a worker thread of its own while the original request waits, so under heavy load a bounded
thread pool can run out and includes time out after
jsonapi4j.cd.httpTotalTimeoutMs; - the URL has to be reachable from the app itself — e.g. one its TLS certificate covers;
- during graceful shutdown the connector stops accepting connections, so includes of in-flight requests fail;
- rate limiters keyed by client address see include calls as coming from the app itself, and so does any rule that
grants trust by source address — which then also matches include calls carrying the original caller’s credentials.
The client’s
Forwarded,X-Forwarded-ForandX-Real-IPheaders are never propagated, so a caller can’t choose the address the app sees on an include call.
default is a reserved key: with the plugin enabled, a resource type named default fails startup with a message
naming the clashing resource — whether or not the key is set, so the clash surfaces in development rather than in the
first deployment that sets it.
For finer control, map types individually with jsonapi4j.cd.mapping.<type>.url, or replace the routing entirely with
a custom DomainSettingsResolver bean. Whatever it returns Optional.empty() for is fetched per its transport. A route of its own can be in-process too, with DomainSettings.inProcess(...):
@Bean
public DomainSettingsResolver domainSettingsResolver() {
URI orders = URI.create("https://orders.internal/jsonapi");
return resourceType -> "orders".equals(resourceType)
? Optional.of(DomainSettings.overHttp(orders, 50, true)) // another service, trusted with credentials
: Optional.of(DomainSettings.inProcess(DomainSettings.DEFAULT_MAX_BATCH_SIZE));
}
The sample apps keep
mappingentries for their same-app types (users,countries,currencies) only to set a batch size; they have nourl, so those types are fetched in-process.
Header propagation
With HEADERS in jsonapi4j.cd.propagation (default), the client’s headers are sent along with every include call
over HTTP, with every value a repeated header has. A few never are, whatever the configuration:
- headers that would change the response away from a complete JSON:API document the resolver can read —
Accept-Encoding(the response would come back compressed), the conditionalIf-None-Match,If-Modified-Since,If-Match,If-Unmodified-Since,If-RangeandRange(a304or a206), andAcceptandContent-Type. Each include call asks forapplication/vnd.api+jsonitself; - hop-by-hop headers, which describe the client’s own connection:
Keep-Alive,Proxy-Connection,TE,Trailer,Transfer-Encoding; Forwarded,X-Forwarded-ForandX-Real-IP— see Fetching same-app types over HTTP.
Credentials go only where they are trusted. The headers in jsonapi4j.cd.credentialHeaders — Authorization,
cookies and the principal headers by default — reach a resource type served by this app itself, which is the same
trust boundary (an in-process read runs as the client’s principal directly), and a type mapped to another service only
with propagateCredentials: true. Any other service is
called without the client’s identity, so a mapping to a partner or third-party API doesn’t leak it:
jsonapi4j:
cd:
mapping:
users:
url: http://users-service/jsonapi
propagateCredentials: true # users-service gets Authorization and cookies
rates:
url: https://partner.example.com/jsonapi # the partner API doesn't
Cookies are sent as one Cookie header, as HTTP/1.1 requires, even when an HTTP/2 client split them across several.
Further Reading
For deeper details on how compound document resolution works — including the multi-stage resolution process, caching internals, Cache-Control aggregation, and the standalone resolver for API Gateway deployments — see the Compound Documents section.