Hypermedia & HAL
Model ManagementModel ManagementThe part of the platform that defines, versions and stores the data models and integration configuration (the model artifacts) from which the platform is configured. It is implemented by Model Forge, an embedded module of the Portal Backend. 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 ManagementModel ManagementThe part of the platform that defines, versions and stores the data models and integration configuration (the model artifacts) from which the platform is configured. It is implemented by Model Forge, an embedded module of the Portal Backend. 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 URNsCORE URNThe global identity Model Management assigns to every model artifact: a unique, stable and versionable URN that exists independently of storage location, file names or registry technology. 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.