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.
1. Choose where it lives
Section titled “1. Choose where it lives”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.jsonThe 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.
2. Write the ontology
Section titled “2. Write the ontology”Four fields describe the publisher, three point at everything else.
{ "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.jsonA 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.
3. Describe a model
Section titled “3. Describe a model”A model gives a type a stable identity, says what it means in prose, and points at the JSON Schema that gives its structure.
{ "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.
4. Say what it aligns to
Section titled “4. Say what it aligns to”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.
5. List the vocabularies
Section titled “5. List the vocabularies”External code lists are referenced, not republished. Internal ones carry their codes inline.
{ "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" } ] } } ]}6. Publish nothing, explicitly
Section titled “6. Publish nothing, explicitly”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:
{ "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.
7. Validate before it goes live
Section titled “7. Validate before it goes live”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.
8. Serve the files
Section titled “8. Serve the files”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/jsonon 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-ageon a document you revise weekly will serve a stale ontology long after you have fixed it.
Checklist
Section titled “Checklist”-
/.well-known/ours.jsonserves the ontology or redirects to itsurl. - Every resource is served at the URL in its own
urlfield. -
models,vocabulariesandmappingsall resolve, empty bundles included. - Every
schemaURL a model names is fetchable. - Every
relationship.targetnames a model in the ontology. -
mapsTo.typereflects what the mapping actually does. -
validateBundlereports no errors.