Skip to main content
Version: V2-Next

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.

This is additive

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 an href (and templated for 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 every href, 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.

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

ResourceGETRelations 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/mappingsone 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:

RelationPresent when
selfalways — the current page
firstalways — page=0
lastalways — the final page
prevthe current page is not the first
nextthe current page is not the last
itemalways — 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 _links member.
  • 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 — a Link record and a generic HalResource<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 @JsonUnwrapped generic field unreliable.
  • Root-relative hrefs. Links are /api/v1/…, matching the Location headers 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.