Hypermedia & HAL
Model Management responses are hypermedia-enabled: a single resource tells a client
where to go next. Instead of hard-coding URL templates, a client reads the
_links object on a response and follows the relation it needs — self,
schema, dependencies, source, target, the next page, and so on.
The wire format is HAL (Hypertext
Application Language): the JSON object every client already parses, plus a reserved
_links member for navigation.
HAL is layered on top of the existing JSON representation. The entity's own
fields stay exactly where they were, at the top level; _links simply appears
beside them. A client that does not care about hypermedia ignores the
underscore-prefixed members and keeps working unchanged.
What HATEOAS and HAL are
HATEOAS (Hypermedia As The Engine Of Application State) is the REST principle that a response carries the links describing what a client can do next, so the client navigates by following links rather than by assembling URLs from out-of-band knowledge.
HAL is a small, widely supported convention for expressing those links in JSON. A HAL document is an ordinary JSON object with two optional reserved members:
_links— a map of link relation → link object. Each link object carries anhref(andtemplatedfor URI templates). The relation name is the map key._embedded— a map of relation → full embedded resource(s), for inlining related data when a round-trip would be wasteful. Model Management does not emit_embedded; when a client wants related data inlined, that is the job of/resolve.
{
"id": "urn:core:platform:civitas:mapping:common:sensor-to-observation:1.0.0",
"title": "Sensor to Observation",
"source": "urn:core:platform:civitas:datastructure:common:Sensor:1.0.0",
"target": "urn:core:platform:civitas:datastructure:common:Observation:1.0.0",
"_links": {
"self": { "href": "/api/v1/mappings?id=urn%3Acore%3A…%3Asensor-to-observation%3A1.0.0" },
"collection": { "href": "/api/v1/mappings" },
"source": { "href": "/api/v1/datastructures?id=urn%3Acore%3A…%3ASensor%3A1.0.0" },
"target": { "href": "/api/v1/datastructures?id=urn%3Acore%3A…%3AObservation%3A1.0.0" }
}
}
Why Model Management uses it
The API is graph-shaped: DataStructures depend on DataStructures, mappings join a source and a target, pipelines wire sources, sinks and mappings together, datasets aggregate all of them. HAL lets the server hand the client those edges directly, which:
- removes URL-template duplication in clients — the frontend no longer rebuilds
…/dependencies?id=or…/datastructures?id=<source>strings itself; - encodes identities correctly — CORE URNs contain colons and travel in the
?id=query parameter; the server already URL-encodes them into everyhref, so clients never have to; - makes the API discoverable — start at one URL and follow links (see Entry point).
The entry point
GET /api/v1 returns a links-only HAL document — the one URL a client needs to
know. From here every top-level collection and cross-cutting endpoint is one link
away:
{
"_links": {
"self": { "href": "/api/v1" },
"datastructures": { "href": "/api/v1/datastructures" },
"datasets": { "href": "/api/v1/datasets" },
"mappings": { "href": "/api/v1/mappings" },
"pipelines": { "href": "/api/v1/pipelines" },
"datasources": { "href": "/api/v1/datasources" },
"datasinks": { "href": "/api/v1/datasinks" },
"core-ir": { "href": "/api/v1/core-ir/meta-schemas" },
"resolve": { "href": "/api/v1/datastructures/resolve{?ids,include,depth}", "templated": true },
"xrepository": { "href": "/api/v1/xrepository/search{?q,page,size}", "templated": true },
"released": { "href": "/api/v1/artifacts/released{?id}", "templated": true }
}
}
The entry point lives under /api/v1, so — like every other /api/v1/* endpoint —
it requires the X-API-Key header. The released and diagnostics links are
conditional: released appears only when the (opt-in) stage workflow is enabled via
MODELFORGE_WORKFLOW_ENABLED=true, and diagnostics only when the (opt-in)
diagnostics endpoint is enabled via MODELFORGE_DIAGNOSTICS_ENABLED=true, so the
entry point never advertises a relation that would resolve to 404.
Where links appear
Hypermedia is attached to the navigational read surface: every single-resource
GET and every paged-collection GET. Write responses (POST/PUT/PATCH)
already point onward through their Location header and standard status codes, so
they are not wrapped.
Single resources
| Resource | GET | Relations in _links |
|---|---|---|
| DataStructure | …/datastructures?id= | self, collection, schema, envelope, dependencies, dependents, impact, transitive-dependencies, maps-to, mapped-from, versions, dereferenced, bundled, json-schema |
| DataStructure envelope | …/datastructures/envelope?id= | self, entity, collection, dependencies, dependents, maps-to, mapped-from, versions |
| Mapping | …/mappings?id= | self, collection, and source/target (each a DataStructure link, when set) |
| Pipeline | …/pipelines?id= | self, collection, and datasources/datasinks/mappings — one link per referenced resource |
| DataSource / DataSink | …/datasources?id= · …/datasinks?id= | self, collection |
| DataSet | …/datasets?id= | self, collection, and datastructures/datasources/datasinks/mappings/pipelines — one link per member reference |
A relation whose underlying reference is absent is omitted (a mapping with no
target has no target link; a pipeline with no sinks has no datasinks
relation).
Collections
Every paged list (PagedResponse<T>) carries pagination links derived from the
served page:
| Relation | Present when |
|---|---|
self | always — the current page |
first | always — page=0 |
last | always — the final page |
prev | the current page is not the first |
next | the current page is not the last |
item | always — a templated link, …{?id}, for fetching a single member |
{
"items": ["urn:core:…:A:1.0.0", "urn:core:…:B:1.0.0"],
"total": 137,
"page": 0,
"size": 50,
"_links": {
"self": { "href": "/api/v1/datastructures?page=0&size=50" },
"first": { "href": "/api/v1/datastructures?page=0&size=50" },
"last": { "href": "/api/v1/datastructures?page=2&size=50" },
"next": { "href": "/api/v1/datastructures?page=1&size=50" },
"item": { "href": "/api/v1/datastructures{?id}", "templated": true }
}
}
Content negotiation
The HAL body is returned regardless of the requested media type, so the _links
are always available. The Content-Type is negotiated:
- default (
Accept: application/json,*/*, or no header) →application/json. Existing clients see no change beyond the extra_linksmember. - opt-in (
Accept: application/hal+json) →application/hal+json, the media type that explicitly signals a HAL document.
# plain JSON (with _links)
curl -H "X-API-Key: $KEY" "http://localhost:8000/api/v1/mappings?id=$URN"
# signalled HAL
curl -H "X-API-Key: $KEY" -H "Accept: application/hal+json" \
"http://localhost:8000/api/v1/mappings?id=$URN"
Relationship to /resolve ($expand)
HAL _links answer “where is the related resource?” with a one-line reference.
When a client instead wants the related data inlined in a single round-trip —
HAL's _embedded territory, and the equivalent of OData $expand or JSON:API
include — that is what
GET /api/v1/datastructures/resolve already provides:
it resolves many URNs at once with configurable include projections
(schema, bundled, dereferenced, dependencies, transitive).
The two compose: navigate by _links, expand by resolve.
Design notes
The implementation follows the project's minimalism (and the same reasoning that kept heavyweight, non-Spring-native frameworks out elsewhere):
- Hand-rolled, not Spring HATEOAS. The published client library
(
model-forge-api) is deliberately Spring-free. The HAL primitives — aLinkrecord and a genericHalResource<T>wrapper — are plain Java records in that module, so library consumers (including non-Spring ones) can read HAL responses with nothing but Jackson on the classpath. Spring HATEOAS would have pulled Spring types into that library. - Inline rendering via a Jackson 3 serializer.
HalResource<T>renders the wrapped entity's fields at the top level and then writes_links. This is done by resolving the serializer for the content's concrete runtime type and using its unwrapping variant — which sidesteps the type erasure that makes a@JsonUnwrappedgeneric field unreliable. - Root-relative hrefs. Links are
/api/v1/…, matching theLocationheaders the API already emits; no host resolution is involved. - Custom relation names. Domain relations (
dependencies,source,datasources, …) use plain, readable rel names rather than CURIEs, mapping one-to-one to the endpoints documented in the artifact relations graph concept.