JSON Schema logoAPI

Four Routes and a Round Trip: JSON Schema Over HTTP

A couple of format keys in the CoreModels transform surface are honest about being one-way. `odm` decodes only, because entity documentation is authored, not generated. `synapse` encodes only, and says so out loud when you try the other direction: *"'synapse' is encode-only: a Synapse schema is plain draft-07 JSON Schema — decode it with the 'jsonschema' format."*

Four Routes and a Round Trip: JSON Schema Over HTTP

A couple of format keys in the CoreModels transform surface are honest about being one-way. odm decodes only, because entity documentation is authored, not generated. synapse encodes only, and says so out loud when you try the other direction: "'synapse' is encode-only: a Synapse schema is plain draft-07 JSON Schema — decode it with the 'jsonschema' format."

jsonschema is not one of those. It appears in both lists the dispatcher publishes:

decode: jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | odm
encode: jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | synapse

It reads JSON Schema and it writes JSON Schema, with the same coder on both sides, and sia is an alias for the same key in both directions. This article walks the four routes that matter for it: export, import, map, and replay.

Everything below assumes a host of https://coremodels.example.com (substitute yours), a bearer token in $TOKEN, and a 32-hex project id in $PROJECT_ID. Transform routes carry no api/ prefix, every route is authorized and project-scoped, and every response uses one envelope: success, lossiness, errors, and a payload key.

PurposeRouteRole
Export the project's schemaPOST graph/transform/schema/export/{projectId}Viewer
Import a schema into the projectPOST graph/transform/schema/import/{projectId}Admin
Map source onto a target (stateless)POST graph/transform/schema/map/{projectId}Viewer (ai: Editor)
Map onto the project's own schemaPOST graph/transform/schema/mapImport/{projectId}Admin (dry-run: Viewer; ai: Editor)
Replay a stored plan (stateless)POST graph/transform/plan/execute/{projectId}Viewer

Export: the project as a JSON Schema document

The smallest useful body on the whole surface:

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

The response carries the document under schema, as a JSON object:

{
  "success": true,
  "lossiness": [],
  "errors": [],
  "schema": {
    "type": "object",
    "properties": {
      "customer_key": { "type": "integer" },
      "full_name": {
        "type": "string",
        "x-sia-role": "identifier",
        "x-sia-priority": 1,
        "x-maps-to": { "schema.org": "https://schema.org/name" }
      },
      "signup_date": { "type": "string" },
      "orders": { "type": "array", "items": { "$ref": "#/$defs/Order" } }
    },
    "required": ["customer_key", "full_name"],
    "x-maps-to": { "schema.org": "https://schema.org/Person" },
    "$defs": {
      "Order": {
        "type": "object",
        "properties": {
          "order_id": { "type": "string" },
          "status": { "enum": ["open", "shipped"] }
        },
        "required": ["order_id"]
      }
    }
  }
}

Three rules are visible there, and all three matter before you diff an export against a hand-written file.

One type becomes the root document; the rest go under $defs. A reference between them is emitted as {"$ref": "#/$defs/Order"}. When the schema was originally imported from JSON Schema, the coder remembers which type was the root and restores it; otherwise the first type takes that seat.

Property names come from element labels, not from node ids. CoreModels node ids must be alphanumeric, so customer_key lives internally under a camelCase id while the label — the name your source used — is what gets emitted.

Meaning rides along. Cross-standard assertions come back as x-maps-to, and the annotation keywords x-sia-role, x-sia-priority, and x-sia-instruction come back where they were set. That is the same carrier the LinkML, OWL, and vendor exports read, which is why an equivalence recorded once shows up everywhere.

Note signup_date: a governed date-time exports as {"type": "string"}. The format: "date-time" keyword is preserved when it arrived from a JSON Schema source — unmodeled keywords ride verbatim — but when the definition came from elsewhere there is nothing to restore. An empty lossiness array means the exporter found a JSON Schema home for everything it was asked to write.

Import: a schema into the project

Import is the same envelope with the arrow reversed: format plus schema, where schema is the document as a string.

curl -sS -X POST \
  "https://coremodels.example.com/graph/transform/schema/import/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "format": "jsonschema",
        "schema": "{ \"$id\": \"Person\", \"type\": \"object\", \"title\": \"Person\", \"properties\": { \"name\": { \"type\": \"string\" }, \"age\": { \"type\": \"integer\" }, \"active\": { \"type\": \"boolean\" } }, \"required\": [\"name\"] }"
      }'
{ "success": true, "lossiness": [], "errors": [], "projectId": "…" }

This route requires Admin and it writes: object becomes Type, property becomes Element, enum becomes a controlled list, $ref becomes a reference, and allOf: [{ "$ref": … }, { … }] becomes inheritance plus the inline properties. An optional spaces array targets specific spaces; omitting it uses the project's main space. A body missing either field is refused before anything is touched, with Body must include 'format' and 'schema'.

Map: aligning one JSON Schema onto another

schema/map is the stateless engine route — the project scopes authorization and nothing else. It takes a source, a target format, an optional target hint, and a mapping strategy. The example below is the realistic case: a legacy Order schema carrying a field the canonical model no longer has, aligned onto the canonical shape through the x-maps-to annotations both sides already carry.

curl -sS -X POST \
  "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "sourceFormat": "jsonschema",
  "sourceSchema": "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"title\":\"Order\",\"type\":\"object\",\"x-maps-to\":{\"schema.org\":\"https://schema.org/Order\"},\"properties\":{\"orderId\":{\"type\":\"string\",\"x-maps-to\":{\"schema.org\":\"https://schema.org/orderNumber\"}},\"placedAt\":{\"type\":\"string\",\"format\":\"date-time\",\"x-maps-to\":{\"schema.org\":\"https://schema.org/orderDate\"}},\"status\":{\"enum\":[\"open\",\"shipped\",\"closed\"],\"x-maps-to\":{\"schema.org\":\"https://schema.org/orderStatus\"}},\"legacyRegionCode\":{\"type\":\"string\"}},\"required\":[\"orderId\"]}",
  "targetFormat": "jsonschema",
  "targetHintFormat": "jsonschema",
  "targetHintSchema": "{\"title\":\"Order\",\"type\":\"object\",\"x-maps-to\":{\"schema.org\":\"https://schema.org/Order\"},\"properties\":{\"orderId\":{\"type\":\"string\",\"x-maps-to\":{\"schema.org\":\"https://schema.org/orderNumber\"}},\"placedAt\":{\"type\":\"string\",\"format\":\"date-time\",\"x-maps-to\":{\"schema.org\":\"https://schema.org/orderDate\"}},\"status\":{\"enum\":[\"open\",\"shipped\",\"closed\"],\"x-maps-to\":{\"schema.org\":\"https://schema.org/orderStatus\"}}},\"required\":[\"orderId\"]}",
  "mapping": {
    "kind": "explicit",
    "guide": "{\"autoMatchByMapsTo\":true,\"taxonomyDirectives\":[{\"sourceTaxonomyId\":\"Order::status::enum\",\"targetTaxonomyId\":\"Order::status::enum\",\"treatment\":\"InlineEnum\"}],\"drops\":[\"Order::legacyRegionCode\"]}"
  }
}
JSON

The legacy field is gone, the controlled list survived, and the removal is on the record rather than in your memory:

{
  "success": true,
  "lossiness": [
    {
      "kind": "StructuralDrop",
      "path": "Element[Order::legacyRegionCode]",
      "explanation": "Dropped by the SIA guide."
    }
  ],
  "errors": [],
  "schema": {
    "type": "object",
    "properties": {
      "orderId": { "type": "string", "x-maps-to": { "schema.org": "https://schema.org/orderNumber" } },
      "placedAt": { "type": "string", "x-maps-to": { "schema.org": "https://schema.org/orderDate" }, "format": "date-time" },
      "status": { "enum": ["open", "shipped", "closed"], "x-maps-to": { "schema.org": "https://schema.org/orderStatus" } }
    },
    "required": ["orderId"],
    "x-maps-to": { "schema.org": "https://schema.org/Order" },
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "title": "Order"
  }
}

(A drop's ledger record is declared on the plan operation itself, and both the validation gate and the executor report declared lossiness, so the raw response lists this entry twice — one fact, stated twice.)

The response also carries plan — six operations here: the drop, a taxonomy mapping, a type mapping, and three element mappings, each stamped "origin": "Explicit". Nothing was written to the project, so every schema/map call is inherently a dry run. To write the aligned result into CoreModels instead, schema/mapImport takes the same mapping object, uses the project's current schema as the target, and honors dryRun: true so you can read the ledger before committing.

Two details save time here. The guide is strict on purpose — an unknown key is refused with its path, as in guide.fieldMapings: Unknown mapping-guide key 'fieldMapings'. Known keys: autoMatchByMapsTo, fieldMappings, taxonomyDirectives, drops. — because a silently ignored typo would execute a plan you did not intend. And autoMatchByMapsTo aligns exactly the elements that carry a matching x-maps-to on both sides; anything else needs an entry of its own, or it leaves the target with a StructuralDrop record saying so.

Replay: the plan as the artifact

Take the plan object from that response, stringify it, and post it to plan/execute with the same source:

jq -n --rawfile source order.json --rawfile plan plan.json \
  '{ sourceFormat: "jsonschema", sourceSchema: $source,
     plan: $plan, targetFormat: "jsonschema" }' > replay.json

curl -sS -X POST \
  "https://coremodels.example.com/graph/transform/plan/execute/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @replay.json

plan is a string containing the plan JSON — that trips people up exactly once. The response is the familiar { success, lossiness, errors, schema }.

Two properties make this worth doing rather than decorative. The same plan and the same source produce the same output, byte for byte. And a stored plan earns no shortcut: it passes through the identical validation gate as a freshly produced one, so an edited plan is judged on its merits rather than trusted because it arrived in a file. An unknown operation kind comes back as plan.operations[0].kind: Unknown operation kind 'Rename'. Use: TypeMapping | ElementMapping | TaxonomyMapping | RelationMapping | ComponentMapping | Drop.

Errors worth recognizing

  • $: The root of a JSON Schema must be an object. — the payload parsed as JSON but was a string, number, or array.
  • Could not parse the 'jsonschema' schema: … — it was not valid JSON at all.
  • Unknown schema format 'json-schema'. Use: … — the key is jsonschema (or sia).
  • The schema has no root type to encode. — a JSON Schema document needs one root object; this shows up when a mapping renamed the type the source had marked as its root.
  • errors mentioning Referenced taxonomy '…::enum' does not resolve. — an enum reached the target without a taxonomyDirective to carry it.

Read lossiness on every call, including the successful ones. success: true means the call ran, not that nothing changed — the ledger is the difference between a conversion you can defend and one you merely executed. For the full route table, role requirements, and ready-to-paste bodies for the other formats, see the schema transformation guide in the CoreModels documentation.