JSON-LD logoQuickstart

Zero to First Transform: Running a JSON-LD Vocabulary Through CoreModels

By the end of this page you will have made exactly one HTTP call and produced two artifacts: a JSON Schema generated from an RDF vocabulary, and a lossiness ledger that states — in writing — what the conversion could not carry across. Learning to read both is the entire skill. Everything else in the CoreModels transform surface is a variation on this loop.

Zero to First Transform: Running a JSON-LD Vocabulary Through CoreModels

By the end of this page you will have made exactly one HTTP call and produced two artifacts: a JSON Schema generated from an RDF vocabulary, and a lossiness ledger that states — in writing — what the conversion could not carry across. Learning to read both is the entire skill. Everything else in the CoreModels transform surface is a variation on this loop.

We are the CoreModels team at ARAMAI. The inputs and outputs below are real: we ran them through the shipped coders and pasted the results.

What you need

Three things, no installation:

  • A CoreModels host. We write https://coremodels.example.com throughout — substitute yours.
  • A bearer token in $TOKEN, and a project id in $PROJECT_ID. The call we make is stateless and needs only Viewer access; the project scopes authorization and nothing else.
  • curl and jq. The jq part matters more than it looks: schema documents travel as JSON strings inside the request body, and hand-escaping a JSON-LD file is where first attempts usually die.

The vocabulary

Save this as person.jsonld. It is small but not a toy: two classes with an inheritance edge, three properties, two literal datatypes, one object-valued property, and one term borrowed from schema.org rather than minted locally.

{
  "@context": {
    "schema": "https://schema.org/",
    "xsd": "http://www.w3.org/2001/XMLSchema#",
    "ex": "https://example.org/"
  },
  "@graph": [
    { "@id": "ex:Person", "@type": "rdfs:Class", "rdfs:label": "Person" },
    { "@id": "ex:Employee", "@type": "rdfs:Class", "rdfs:label": "Employee",
      "rdfs:subClassOf": { "@id": "ex:Person" } },
    { "@id": "schema:name", "@type": "rdf:Property", "rdfs:label": "name",
      "schema:domainIncludes": { "@id": "ex:Person" },
      "schema:rangeIncludes": { "@id": "xsd:string" } },
    { "@id": "ex:age", "@type": "rdf:Property", "rdfs:label": "age",
      "schema:domainIncludes": { "@id": "ex:Person" },
      "schema:rangeIncludes": { "@id": "xsd:integer" } },
    { "@id": "ex:manager", "@type": "rdf:Property", "rdfs:label": "manager",
      "schema:domainIncludes": { "@id": "ex:Employee" },
      "schema:rangeIncludes": { "@id": "ex:Person" } }
  ]
}

That is the dialect the jsonld format key reads: a vocabulary, published the way schema.org publishes its terms. Nodes typed rdfs:Class are the types. Nodes typed rdf:Property are the fields, attached to their class by schema:domainIncludes and typed by schema:rangeIncludes. rdfs:subClassOf is inheritance. Four prefixes are always understood without being declared — rdf, rdfs, xsd, and schema — which is why rdfs:Class works above even though the @context never mentions rdfs.

The one call

We use schema/map, the stateless mapping endpoint. It runs the full engine — decode the source, produce a plan, validate the plan through the engine's universal gate, execute it, encode the target — and returns everything it did.

One thing will bite you if nobody warns you first: the default inferred strategy matches the source against a target hint, so it needs something to aim at. Omit the hint and the call comes back with The inference resolver requires a target IR to match against. For a straight format conversion the idiom is to hand the document to itself as the hint. Every construct then matches its own twin and the plan comes out as clean identity mappings.

jq -n --rawfile doc person.jsonld '{
  sourceFormat: "jsonld",
  sourceSchema: $doc,
  targetFormat: "jsonschema",
  targetHintFormat: "jsonld",
  targetHintSchema: $doc,
  mapping: { kind: "inferred" }
}' > body.json

curl -s -X POST "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @body.json

Two headers, one body, no setup, no state. schema/map never writes to the project — every call is inherently a dry run.

The schema that comes back

The response uses the envelope shared by every transform endpoint: success, lossiness, errors, plus this verb's payload — schema and plan. For JSON-shaped targets, schema is a JSON object, not a string. Here it is, exactly as the engine produced it:

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "x-maps-to": { "schema": "https://schema.org/name" }
    },
    "age": {
      "type": "integer",
      "x-maps-to": { "ex": "https://example.org/age" }
    }
  },
  "x-maps-to": { "ex": "https://example.org/Person" },
  "$defs": {
    "ex:Employee": {
      "allOf": [
        { "$ref": "#/$defs/ex:Person" },
        {
          "type": "object",
          "properties": {
            "manager": {
              "$ref": "#/$defs/ex:Person",
              "x-maps-to": { "ex": "https://example.org/manager" }
            }
          }
        }
      ],
      "x-maps-to": { "ex": "https://example.org/Employee" }
    }
  }
}

Four things happened there, and together they are how CoreModels treats JSON-LD.

The IRIs came through. In a vocabulary, a node's @id is a global identifier, so the coder expands each qname against the @context and lifts it into a cross-standard mapping annotation — the x-maps-to entries you see on the root object and on every property. schema:name became https://schema.org/name; ex:age became https://example.org/age. Push this schema onward to Avro, LinkML, SQL, or OWL later and those identities travel with it. That is why we treat JSON-LD as a carrier of meaning rather than one more syntax.

Inheritance became allOf. ex:Employee rdfs:subClassOf ex:Person is expressed the way JSON Schema expresses extension: an allOf over a $ref to the parent plus the subclass's own properties.

Datatypes landed cleanly. xsd:string became "type": "string", xsd:integer became "type": "integer", and the object-valued range on manager became a real $ref — a reference, not a stringified type name.

Nothing is required. There is no required array, and that is faithful reading, not an omission. RDF is open-world: an rdf:Property never asserts that instances must supply a value, so every decoded property arrives optional. If your downstream contract needs mandatory fields, they have to come from somewhere that can state them.

The ledger

lossiness is the honest change report, and the habit we ask everyone to build is: read it every time. Each entry carries a kindStructuralDrop (no home in the target), TypeApproximation (close but not exact), ConstraintRelaxation (a rule could not be enforced), or SemanticNarrowing (meaning was narrowed or guessed) — plus a path naming the construct and a plain-English explanation.

For this conversion the ledger is empty:

{ "lossiness": [] }

Empty means the target held everything the source said: classes to objects, properties to typed properties, subclassing to allOf, IRIs to x-maps-to. Nothing to review.

The rule that empty ledgers can hide is the one worth memorizing: success: true means "it ran", not "nothing changed." Aim the same vocabulary at a relational target and the ledger stops being empty — an enumeration has no inline representation in Postgres, and the engine says so rather than letting you find out in production. Entries are review items, not errors; a non-empty ledger on a successful call is by design.

The plan, in one paragraph

The response's third artifact is plan — the operations the engine actually executed, each tagged with its origin. For this call it is five operations: two type mappings and three element mappings, all "origin": "Inferred". Store it and you can replay the identical transformation later through the plan/execute endpoint; the same plan against the same source produces the same output. That is what turns a one-off conversion into something a build can repeat, and it is a subject that deserves its own article.

The other direction, in two lines

If the model you care about already lives in a CoreModels project, the mirror-image call publishes it as a vocabulary. Same envelope, same ledger discipline, Viewer role, and the smallest body on the whole API:

curl -s -X POST "https://coremodels.example.com/graph/transform/schema/export/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "format": "jsonld" }'

Types come back as rdfs:Class nodes, inheritance as rdfs:subClassOf, elements as rdf:Property nodes wired by schema:domainIncludes and schema:rangeIncludes, and controlled lists as classes subclassing schema:Enumeration with one node per term. Because jsonld is a JSON-shaped format, the schema field is a JSON object here too.

What to try next

You have run the loop end to end: JSON-LD in, JSON Schema out, ledger read. The same call works in every direction the engine supports — swap targetFormat for linkml, avro, owl, shex, odcs, protobuf, or sql (with "vendor": "postgres" | "mysql" | "sqlserver") and the shape of the response never changes. And because the format decodes as well as encodes, you can write a vocabulary straight into a project with schema/import when you want it governed rather than just converted.

For the full endpoint reference and ready-to-paste request bodies for every format key, see the Schema Transformation API guide in the CoreModels docs.