Skip to content

Publish an ontology

This walks through publishing a small ontology for a fictional ExampleOrg. The end state is four files served over HTTPS, discoverable from the domain alone, and a consumer who needs to be told nothing at all.

Two addresses matter, and they can be the same one.

The first is the well-known URI on the domain a consumer would think of first. This is the address nobody has to be told, and it is usually your public site rather than your API:

https://example.org/.well-known/ours.json

The second is where the resources themselves are served. A subdomain keeps them separable from the API they describe:

https://ours.example.org/

Pick a base you control and intend to keep. Every resource’s url is its identity, so moving one later breaks the references pointing at it, in the same way renaming a published API endpoint does.

Four fields describe the publisher, three point at everything else.

ours.example.org/ontology.json
{
"resourceType": "Ontology",
"id": "example-org",
"url": "https://ours.example.org/ontology.json",
"version": "1.0.0",
"publisher": "ExampleOrg",
"models": "https://ours.example.org/models.json",
"vocabularies": "https://ours.example.org/vocabularies.json",
"mappings": "https://ours.example.org/mappings.json"
}

Serve it at exactly the URL in its own url field, and redirect to that from the well-known URI:

GET https://example.org/.well-known/ours.json
-> 302 https://ours.example.org/ontology.json

A consumer that fetched the document from an address it does not claim has no way to know which of the two is the real one. Following a redirect leaves them somewhere that agrees with itself, which is why the redirect target has to be the value of url. Serving the ontology at the well-known URI directly works equally well; then url is that address and there is no redirect.

A model gives a type a stable identity, says what it means in prose, and points at the JSON Schema that gives its structure.

ours.example.org/models.json
{
"resourceType": "Bundle",
"type": "collection",
"entry": [
{
"fullUrl": "https://ours.example.org/models/person.json",
"resource": {
"resourceType": "Model",
"id": "person",
"url": "https://ours.example.org/models/person.json",
"version": "1.0.0",
"system": "internal",
"name": "Person",
"description": "A human individual used across ExampleOrg systems.",
"schema": "https://api.example.org/schemas/User.schema.json",
"relationships": [{ "predicate": "memberOf", "target": "Organization" }]
}
},
{
"fullUrl": "https://ours.example.org/models/organization.json",
"resource": {
"resourceType": "Model",
"id": "organization",
"url": "https://ours.example.org/models/organization.json",
"version": "1.0.0",
"system": "internal",
"name": "Organization",
"description": "A company or team that people at ExampleOrg belong to.",
"schema": "https://api.example.org/schemas/Organization.schema.json"
}
}
]
}

Person is a member of an Organization, so Organization is published beside it. A relationship to a model the ontology does not publish fails validation, because a consumer following it would find nothing at the other end.

schema is optional, because meaning and alignment are publishable before structure is. If your types are defined in another formalism, say what they mean and what they align to now, and add the JSON Schema when you have it.

An alignment is what makes the model useful to somebody who has never seen your API. Add one entry per external system you correspond to.

"mapsTo": [
{
"system": "http://hl7.org/fhir",
"schema": "http://hl7.org/fhir/StructureDefinition/Patient",
"mapping": "https://ours.example.org/mappings/user-to-fhir-patient.json",
"type": "one-to-one"
},
{
"system": "https://schema.org",
"schema": "https://schema.org/Person",
"type": "partial",
"comment": "schema.org has no equivalent for our tenancy fields."
}
]

Be honest in type. A consumer reads it to decide whether a round trip through your alignment is safe, and one-to-one on a mapping that loses fields costs them more than partial ever would.

External code lists are referenced, not republished. Internal ones carry their codes inline.

ours.example.org/vocabularies.json
{
"resourceType": "Bundle",
"type": "collection",
"entry": [
{
"fullUrl": "https://api.example.org/codes/invoice-status",
"resource": {
"resourceType": "Vocabulary",
"id": "exampleorg-invoice-status",
"url": "https://api.example.org/codes/invoice-status",
"version": "1.0.0",
"system": "internal",
"name": "ExampleOrg invoice status",
"codes": [
{ "code": "OPEN", "display": "Open" },
{ "code": "PAID", "display": "Paid" },
{ "code": "VOID", "display": "Void" }
]
}
}
]
}

You will not have all three of models, vocabularies and mappings on day one. Serve an empty collection at the URLs you have nothing for, rather than dropping the pointer:

ours.example.org/mappings.json
{
"resourceType": "Bundle",
"type": "collection",
"entry": []
}

This is the difference between a consumer knowing there are no mappings yet and a consumer guessing whether to keep looking for them.

Parsing tells you a model is well formed. It does not tell you that the schema it points at was published, or that a relationship names a model that exists. Those failures reach a consumer as a broken integration rather than as an error, so check them yourself first.

import { assembleBundle, validateBundle, hasErrors } from "@openhi/ours";
const bundle = assembleBundle({ ontology, models, vocabularies, schemas });
const issues = validateBundle(bundle);
for (const issue of issues) {
console.error(`${issue.level} ${issue.resource} ${issue.message}`);
}
if (hasErrors(issues)) process.exit(1);

Run it in CI against the files you are about to serve. See Validating a bundle for what each check covers.

Five things to get right at the web server:

  • The well-known URI resolves. Either it serves the ontology or it redirects to the address in url. This is the one address a consumer does not have to be told, so it is the one worth checking from outside your network.
  • Content-Type: application/json on every resource.
  • Access-Control-Allow-Origin, so a browser-based consumer can fetch the ontology at all.
  • Stable URLs. Treat them like published API endpoints, because that is what they are to everyone pointing at them.
  • Caching you are happy to live with. A long max-age on a document you revise weekly will serve a stale ontology long after you have fixed it.
  • /.well-known/ours.json serves the ontology or redirects to its url.
  • Every resource is served at the URL in its own url field.
  • models, vocabularies and mappings all resolve, empty bundles included.
  • Every schema URL a model names is fetchable.
  • Every relationship.target names a model in the ontology.
  • mapsTo.type reflects what the mapping actually does.
  • validateBundle reports no errors.