Release 1.12.0: Compound Documents rework: in-process includes, finer-grained config, stronger security and descriptive meta #222
MoonWorm
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Overview
1.12.0 is a rework of the Compound Documents plugin and the standalone resolver behind it. Four headline changes:
mappingentry: the serviceurl,maxBatchSize, whether the service is trusted with the client's credentials (propagateCredentials), and — for types the app serves itself — thetransport,IN_PROCESSorHTTP. New switches decide how strict the plugin is:deduplication,unsupportedIncludes,credentialHeaders. All of it is validated at startup, with errors pointing at the exact key.Hostheader.meta. A response whoseincludedis missing resources now says so:meta.includedIncompletenames each gap and why — a failed fetch, a type with no route, a limit reached, an unsupported path. And an include path the server can't serve is a400whosemeta.pathnames it, instead of being dropped silently.Underneath, includes now follow the spec: only what the client asked for ends up in
included, and a primary resource is never repeated there.Beyond the plugin: a Swagger UI next to Redoc on the live OpenAPI sample, a new Showcase page listing projects built with JsonApi4j, and JSON:API member names used through constants across the codebase.
This release contains breaking changes. They are listed first.
Breaking Changes
Configuration: one
mappingentry per resource typePer-type settings were spread over
mappingandbatchSizeMapping. They now live in one entry per type:jsonapi4j.cd.mapping.<type>=<url>jsonapi4j.cd.mapping.<type>.url=<url>jsonapi4j.cd.batchSizeMapping.<type>=<n>jsonapi4j.cd.mapping.<type>.maxBatchSize=<n>jsonapi4j.cd.deduplicateResources=truejsonapi4j.cd.deduplication=DATA_AND_INCLUDED(the default)jsonapi4j.cd.deduplicateResources=falsejsonapi4j.cd.deduplication=NONESame-app includes no longer pass through servlet filters
Read in-process, an include of a type the app serves itself is subject to validation and the Access Control plugin — but not to servlet filters: URL-based security rules, request logging or metrics no longer see include requests. Where they must, fetch those types over HTTP from the app's own address:
Credentials reach other services only when you say so
In 1.11.0 the client's
Authorization,CookieandX-Authenticated-*headers were copied onto every include call — partner and third-party APIs included. Now a type mapped to another service receives them only withpropagateCredentials: true:Types served by the app itself always get the client's identity.
Responses change
includedholds only what was requested. Includes were merged per resource type rather than per path: withinclude=citizenships,placeOfBirth.currencies, the citizenship countries' currencies were fetched and returned too. Clients relying on those extra resources must ask for them.includedwhen an include path cycles back to it (/users/1?include=relatives.relatives). Setdeduplication: INCLUDED_ONLYto repeat reached primary resources inincluded, so every relationship resolves fromincludedalone.400—include=petson a type without apetsrelationship — in every JsonApi4j app, with or without the CD plugin.maxHopsis a400instead of being cut short silently.unsupportedIncludes: IGNORErestores the lenient behaviour, now with the cut reported inmeta.Headers no longer forwarded downstream
The client's
Accept,Accept-Encoding, conditional headers (If-None-Match,If-Modified-Since,If-Match,If-Unmodified-Since,If-Range),Range, hop-by-hop headers, andForwarded/X-Forwarded-For/X-Real-IPare no longer copied onto include calls. Each of them either broke the fetch — a gzipped body, a304, a206— or let a caller choose what the downstream service sees.Error handling moved to
jsonapi4j-baseThe error handling API moved from
pro.api4.jsonapi4j.servlet.response.errorhandlingtopro.api4.jsonapi4j.errorhandling. Update the imports of any customErrorHandlerFactory.Plugins must be registered (plain Servlet API)
In a plain servlet setup, a CD or OpenAPI plugin enabled in the config file but missing from the
PluginRegistryused to run anyway, from a second copy of its configuration. It is now inactive, with a warning naming the cause. Spring Boot and Quarkus always register the plugin.Standalone resolver API
For those embedding
jsonapi4j-compound-docs-resolverdirectly, e.g. in an API gateway:new DomainSettings(url, maxBatchSize),DomainSettings.of(url)DomainSettings.overHttp(url, maxBatchSize, propagateCredentials),DomainSettings.inProcess(maxBatchSize)DefaultDomainSettingsResolver.from(...)new DefaultDomainSettingsResolver(Map<String, DomainSettings>)CompoundDocsRequestheaders:Map<String, String>Map<String, List<String>>, case-insensitiveCompoundDocsResolverConfigcredentialHeadersCachingCompoundDocsFetcher,BatchFetchResult,HttpFetchResultBatchFetcherdecorators,FetchResult— see Standalone resolverExecutorServiceargumentsExecutor— existing callers still compileConstants on relationship documents
ToOneRelationshipDocandToManyRelationshipsDocno longer declareINCLUDED_FIELDandJSONAPI_FIELD; useBaseDoc.INCLUDED_FIELDandBaseDoc.JSONAPI_FIELD, which now also holdsDATA_FIELD.Compound Documents
How includes are fetched
Each included resource type is fetched hop by hop, in
filter[id]batches:url: over HTTP from that service. Set it only for types another service serves.transportisHTTP: then it's fetched over HTTP frommapping.default.url, the app's own address, through every servlet filter and with the client's credentials.transportis set per type, or for all same-app types on the reserveddefaultentry — so either side can be the exception:Or through environment variables, e.g.
JSONAPI4J_CD_MAPPING_DEFAULT_URLandJSONAPI4J_CD_MAPPING_DEFAULT_TRANSPORT=HTTP. Startup fails on a transport that can't work:HTTPwithoutmapping.default.url, orIN_PROCESSon a type mapped to another service'surl.Why in-process is the default — and the better choice wherever it applies:
RANDOM_PORTIn a rough local benchmark — Spring Boot sample, in-memory data, include cache off — a 3-level include was ~1.8× faster per request in-process (0.90 ms vs 1.64 ms) at ~1.6× the throughput; without includes both performed the same. With a real database behind your operations the relative gain shrinks; the threads not taken remain.
Two things to know:
One
mappingentry per resource typeValidation at startup points at the exact key: an entry needs a
url, amaxBatchSizeor atransport; aurlmust be an absolutehttp(s)URL;maxBatchSizemust be positive;propagateCredentialswithout aurlis rejected; anddefaultis reserved — it takes onlyurlandtransport, and a resource type nameddefaultfails startup.Header propagation
With
propagation: HEADERS(the default), include calls carry the client's headers — but now only those that are safe to forward, with every value of a repeated header (cookies split across HTTP/2 lines are joined into oneCookieheader).Credentials are a separate list,
jsonapi4j.cd.credentialHeaders. The default coversAuthorization,Cookie,Proxy-Authorizationand theX-Authenticated-*principal headers. If your clients identify themselves another way — anX-Api-Key, or the headers a customPrincipalResolverreads — add them, so they are treated as credentials too:Unsupported include paths
JSON:API requires
400 Bad Requestfor an include path the server can't serve. JsonApi4j now checks every read request against the relationships of the requested type. Each server checks its own types, so no shared schema is needed: when a later segment names a relationship that doesn't exist, the service owning that type rejects it, and the resolver maps the name back to the client's full path.{ "errors": [ { "status": "400", "code": "UNSUPPORTED_INCLUDE", "detail": "Resource type 'countries' has no relationship 'economy'", "source": { "parameter": "include" }, "meta": { "path": "placeOfBirth.economy" } } ] }jsonapi4j.cd.unsupportedIncludesdecides what the CD plugin does with such paths — unknown names and paths deeper thanmaxHopsalike:FAIL(default): the400above, one error object per path.IGNORE: a200with everything that could be resolved, each unsupported path listed inmeta.includedIncomplete.Failed includes and partial responses
jsonapi4j.cd.errorStrategynow behaves consistently across every kind of failure: a non-2xx response, a timeout, a connection error, a response that isn't JSON:API, a type with no route.IGNORE(default): failures are isolated perfilter[id]batch, and the response tells the client what's missing and why:FETCH_FAILEDNO_ROUTEMAX_INCLUDED_RESOURCESincludedreachedmaxIncludedResourcesbefore the path was resolvedUNSUPPORTED_INCLUDEmaxHops(withunsupportedIncludes: IGNORE)A response missing resources because a fetch failed or a type has no route is marked
Cache-Control: no-store, so no shared cache keeps it for the fullmax-age. Limits and unsupported paths give the same result on every request, so those responses stay cacheable.FAIL: a JSON:API error document —504for a timeout,502for another downstream failure,500for a type with no route — with no downstream URLs in the details.Errors go through the same error handler registry as every other API error. Plugins now contribute their own mappings through
JsonApi4jPlugin#errorHandlerFactory(), which never override yours.Standalone resolver
The resolver now fetches through a chain of small
BatchFetcherdecorators, one concern each:DomainSettingshas two kinds of route,DomainSettings.overHttp(url, maxBatchSize, propagateCredentials)andDomainSettings.inProcess(maxBatchSize). Routing comes fromjsonapi4j.cd.mappingby default; replace it with your ownDomainSettingsResolverwhen routes live elsewhere — for example in a service registry such as Consul:Whatever it returns
Optional.empty()for is treated as served by the app itself and fetched per itstransport.The include cache is in-memory by default (
cache.enabled,cache.maxSize); for a cache shared between instances, provide aCompoundDocsResourceCache— for example one backed by Redis. A gateway can also reject an unsupported include path before proxying anything, withIncludesChecker.from(config).Also fixed
500, and a decoded&or=could inject parameters such as a differentfilter[id]. Multi-valued parameters are sent as repeated parameters, and propagated custom query parameters are part of the cache key.getWriter()flushed before reading, and an exactContent-Lengthon the rewritten body.Hostheader. In 1.11.0 the app's own base URL was built from it, so a caller could point include requests at another host, and the response could be cached for later callers. Upgrading is recommended for anyone running the CD plugin.maxIncludedResourcesstops resolution only whenincludedhas reached the limit and resources are still left to fetch; a hop that has started always finishes.relativesXis no longer treated as being underrelatives.JsonApi4j, not from a second copy read from servlet-context attributes.Other Changes
BaseDocfor the top-level members.included, error handling and performance.Migration Checklist
mapping.<type>→mapping.<type>.url,batchSizeMapping.<type>→mapping.<type>.maxBatchSize,deduplicateResources→deduplication.propagateCredentials: trueon every mapped service that needs the caller's identity — your own services, typically not partners.mapping.default.urlandtransport: HTTP.credentialHeaders.included, no repeated primary resources,400for unknown or too deep include paths.pro.api4.jsonapi4j.errorhandling.PluginRegistry.DomainSettings.overHttp(...)/inProcess(...)and the other API changes above.Resources
All reactions