JSON Schema logoQuickstart

One Call, Three Answers: Your First JSON Schema Transform

Every transform call in CoreModels returns three things, and the third one is the reason we built it this way. You get the converted schema. You get the plan that produced it. And you get a ledger of everything the conversion could not carry across exactly — written in English, with a path pointing at the construct it happened to.

One Call, Three Answers: Your First JSON Schema Transform

Every transform call in CoreModels returns three things, and the third one is the reason we built it this way. You get the converted schema. You get the plan that produced it. And you get a ledger of everything the conversion could not carry across exactly — written in English, with a path pointing at the construct it happened to.

This walkthrough runs that call once, end to end, starting from a JSON Schema file. It takes about ten minutes, and by the end you will be able to point at any line of the output and say where it came from.

What you need

  • A CoreModels project id — 32 hexadecimal characters, written below as $PROJECT_ID. The route we use is stateless: nothing is read from or written to the project, and the id only scopes authorization. Viewer access is enough.
  • A bearer token, written as $TOKEN.
  • curl and jq.

The API host below is https://coremodels.example.com — substitute your deployment. Transform routes carry no api/ prefix.

The schema

Save this as product.schema.json. It is small, but deliberately not tidy: the contact property uses anyOf, which is the kind of construct that separates tooling that reports its decisions from tooling that quietly makes them.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Product",
  "type": "object",
  "properties": {
    "sku": { "type": "string", "description": "Stock keeping unit." },
    "name": { "type": "string" },
    "listPrice": { "type": "number" },
    "releasedAt": { "type": "string", "format": "date-time" },
    "tags": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
    "contact": { "anyOf": [ { "type": "string" }, { "type": "integer" } ] }
  },
  "required": ["sku", "name"]
}

The format key for JSON Schema is jsonschema, and sia is accepted as an alias for the same coder. It works in both directions — it can be the source you decode from and the target you encode to.

The call

The route is schema/map: a source schema goes in, a target schema comes out, nothing is persisted. The engine always maps onto something, so the request carries a target hint — the shape you are aligning toward. When you are converting one schema rather than reconciling two, hand it your own schema as the hint. Every property matches itself, and the plan you get back is a straight receipt of that.

Both schema fields travel as JSON strings, so they need escaping. jq --rawfile does that correctly without dragging the file through shell quoting:

jq -n --rawfile schema product.schema.json \
  '{
     sourceFormat: "jsonschema",
     sourceSchema: $schema,
     targetFormat: "avro",
     targetHintFormat: "jsonschema",
     targetHintSchema: $schema,
     mapping: { kind: "inferred", caseInsensitive: true }
   }' > map-request.json

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 @map-request.json

Answer one: the schema

Every transform route returns the same envelope — success, lossiness, errors, a payload key, and on the mapping routes a plan. Here the payload key is schema, and because Avro is a JSON-shaped format it arrives as a JSON object rather than an escaped string:

{
  "type": "record",
  "name": "Product",
  "fields": [
    { "name": "sku", "type": "string" },
    { "name": "name", "type": "string" },
    { "name": "listPrice", "type": ["null", "double"] },
    { "name": "releasedAt", "type": ["null", { "type": "long", "logicalType": "timestamp-millis" }] },
    { "name": "tags", "type": ["null", { "type": "array", "items": "string" }] },
    { "name": "contact", "type": ["null", "string"] }
  ]
}

Read it against the source. required: ["sku", "name"] became two bare fields; everything else became a nullable union, which is how Avro spells optional. The date-time string became a long carrying the timestamp-millis logical type. The array of strings survived as an Avro array. And contact, the awkward one, landed as a nullable string.

Answer two: the ledger

That last decision is not something you should have to notice by reading carefully. It is stated:

{
  "success": true,
  "lossiness": [
    {
      "kind": "TypeApproximation",
      "path": "#/properties/contact",
      "explanation": "Property has no recognized 'type' (none); approximated as String."
    },
    {
      "kind": "SemanticNarrowing",
      "path": "#/properties/contact.anyOf",
      "explanation": "'anyOf' conditional logic cannot be modelled as IR structure; preserved verbatim for re-emit."
    }
  ],
  "errors": []
}

(One wrinkle of the self-hint pattern: the hint is decoded independently of the source, so the raw response lists this pair twice — once from the source's decode and once from the identical hint's. Two distinct facts, four lines.)

The habit worth forming on this surface: success: true does not mean "nothing changed" — it means "it ran." The ledger is the honest account of what changed. Each record has three parts: a kind, a path that points at the exact construct, and an explanation written for a person.

There are four kinds:

  • StructuralDrop — something had no home in the target and was left out.
  • TypeApproximation — a value was represented by a close-but-not-exact target type.
  • ConstraintRelaxation — a rule (required, an enum, a length) could not be enforced and was relaxed.
  • SemanticNarrowing — meaning was narrowed or guessed.

Here the ledger says two precise things about one property. contact declares no type of its own — its types live inside the anyOf branches — so it was approximated as a string. And anyOf is a condition ("one of these shapes") where a modeled property holds one shape; that condition cannot become structure, so it is recorded rather than absorbed.

Note the tail of the second explanation: preserved verbatim for re-emit. The raw anyOf stays attached to the element, so a JSON-Schema-to-JSON-Schema conversion reproduces it exactly. Lossy never means deleted.

The other five properties produced no ledger entries at all. That is the point of having a ledger: those two records are the complete list of things to double-check.

Answer three: the plan

The response also carries the plan the engine executed — seven operations for this schema, one for the type and one per property. Two of them:

{
  "plan": {
    "operations": [
      {
        "kind": "TypeMapping",
        "origin": "Inferred",
        "sourceTypeId": "Product",
        "targetTypeId": "Product",
        "targetLabel": "Product"
      },
      {
        "kind": "ElementMapping",
        "origin": "Inferred",
        "sourceElementIds": ["Product::releasedAt"],
        "targetElementIds": ["Product::releasedAt"]
      }
    ]
  }
}

origin: "Inferred" marks each operation as a heuristic match rather than an instruction you wrote — guesses are labeled as guesses. Element ids are Type::property, which is how you address one field when you want to say something specific about it later.

Store this object. It is reviewable (you can diff it), and it is replayable: submit it back with the same source and you get the same output.

Same source, a different target

Nothing in that call was Avro-specific. Change two fields and the same file becomes Postgres DDL:

jq -n --rawfile schema product.schema.json \
  '{
     sourceFormat: "jsonschema",
     sourceSchema: $schema,
     targetFormat: "sql",
     vendor: "postgres",
     targetHintFormat: "jsonschema",
     targetHintSchema: $schema,
     mapping: { kind: "inferred" }
   }' > sql-request.json

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 @sql-request.json | jq -r '.schema'

Text-shaped targets arrive as a string in schema, so jq -r prints them ready to run:

CREATE TABLE "Product" (
  "sku" VARCHAR(255) NOT NULL,
  "name" VARCHAR(255) NOT NULL,
  "listPrice" NUMERIC,
  "releasedAt" TIMESTAMP,
  "tags" VARCHAR(255),
  "contact" VARCHAR(255)
);

mysql and sqlserver are the other SQL dialects. The same request shape reaches LinkML, ShEx, OWL, proto3, ODCS contracts, Ossie semantic models, and JSON-LD; the format key is the only thing that changes.

One honest note before you try this on your own file: if a property carries an enum, label-and-type inference alone will not carry the controlled list across, and the call comes back with errors containing something like target:Element[Order::status].ValueType: Referenced taxonomy 'Order::status::enum' does not resolve. Enums need one line in an explicit mapping guide — a taxonomyDirective naming the list — which the mapping documentation covers.

What actually ran

Five stages happened inside that single request. Your JSON Schema was decoded into a neutral model. A strategy produced a plan. The plan passed a validation gate that every plan passes — inferred, hand-written, or AI-proposed, no exceptions. The engine executed it deterministically. The result was encoded into the target format. Lossiness from all five stages was collected into the one list you read.

That is the whole contract, and you have now used it. When you want the model to live somewhere instead of passing through — so that JSON Schema, DDL, and Avro all become regenerated outputs of a single governed definition — the import and export routes take the same format key and return the same envelope.

For the full route list, roles, and ready-to-paste request bodies for every format, see the schema transformation guide in the CoreModels documentation.