Apache Avro logoAPI

Avro Over HTTP: Four Endpoints and Their Honest Limits

Every transform route in CoreModels answers with the same envelope, so learning the contract once buys you the whole surface:

Avro Over HTTP: Four Endpoints and Their Honest Limits

Every transform route in CoreModels answers with the same envelope, so learning the contract once buys you the whole surface:

{
  "success": true,          // false only if it could NOT proceed — then read "errors"
  "lossiness": [ { "kind": "...", "path": "...", "explanation": "..." } ],
  "errors": [],             // on failure: [{ "path": "...", "message": "..." }]
  "schema": "..."           // or projectId / summary / data, depending on the route
}

For Avro, four routes matter. This article walks all four with real bodies and responses, and states the limits where they exist rather than leaving you to trip over them.

Start with direction, because we publish it per format. The key is avro, and it appears in both lists — decode (jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | odm) and encode (jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | synapse). Some formats are one-way — odm decodes only, synapse encodes only — but Avro round-trips. Its real constraints are structural: the document root must be a record, and an Avro document has exactly one root, so an export produces one record schema rather than a bundle.

#RouteRoleWrites?
1POST graph/transform/schema/import/{projectId}Adminyes
2POST graph/transform/schema/export/{projectId}Viewerno
3POST graph/transform/schema/map/{projectId}Viewer (ai: Editor)no
4POST graph/transform/plan/execute/{projectId}Viewerno

All four sit under https://coremodels.example.com/graph/transform/... (no api/ prefix), take Authorization: Bearer $TOKEN and Content-Type: application/json, and are project-scoped like every other CoreModels graph API.

1. schema/import — an .avsc becomes a governed model

The body is { "format": "avro", "schema": "<the .avsc text>" }, plus an optional spaces array (omit it for the project's main space). Take a record that carries annotations, so the response has something to say:

{
  "type": "record",
  "name": "Claim",
  "namespace": "org.example.claims",
  "fields": [
    { "name": "claimId", "type": "string",
      "x-sia-role": "identifier",
      "x-maps-to": { "acme": "https://data.acme.example/claims/id" } },
    { "name": "filedOn", "type": { "type": "int", "logicalType": "date" } },
    { "name": "status", "type": { "type": "enum", "name": "ClaimStatus",
                                  "symbols": ["open", "settled", "denied"] } }
  ]
}
jq -n --rawfile s Claim.avsc '{ format: "avro", schema: $s }' \
| curl -sS -X POST \
    "https://coremodels.example.com/graph/transform/schema/import/$PROJECT_ID" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    --data-binary @-

The project gains a Type Claim with three Elements: claimId (String, required — a plain Avro field always carries a value), filedOn (DateTime, courtesy of the date logical type), and status, which references a new Taxonomy ClaimStatus whose terms are the enum symbols in order. Element labels keep the original field names; internal node ids are alphanumeric, because CoreModels ids must be.

The response is the envelope with projectId as its payload, and its ledger is the honest part. x-maps-to is carried — it lands as a mapsTo mixin value, the same carrier the rest of the stack reads — but x-sia-role, x-sia-priority, and x-sia-instruction have no CoreModels slot, so each is declared:

{ "kind": "StructuralDrop",
  "path": "Element[Claim.claimId]",
  "explanation": "SIA 'role' has no CoreModels carrier; it was dropped." }

Import aggregates read-side and write-side lossiness into one list. On a schema with no annotations and no exotic constructs, that list is simply [].

2. schema/export — a governed model becomes an .avsc

The reverse needs only the format key. There are no Avro-specific options: vendor belongs to sql output, the synapse* keys to synapse output, and both are ignored here.

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": "avro" }'

Because Avro is a JSON-shaped format, schema in the response is a JSON object, not a string. Three encoder rules govern what you get, and they hold everywhere Avro is the target: required elements emit bare types, optional elements emit ["null", T] unions, and DateTime emits a timestamp-millis logical type unless the model remembers a more specific one. You can watch all three on the stateless route by mapping a JSON Schema into Avro — same encoder, visible input:

{
  "type": "record",
  "name": "Customer",
  "fields": [
    { "name": "customer_key", "type": "long" },
    { "name": "full_name", "type": "string" },
    { "name": "signup_date", "type": ["null", { "type": "long", "logicalType": "timestamp-millis" }] },
    { "name": "loyalty_points", "type": ["null", "long"] }
  ]
}

That is the output for a Customer object type with two required properties, an optional date-time, and an optional integer. Integers emit as long when the model did not originate in Avro; when the source was decoded from Avro in the same call — as on the stateless mapping route in §3 — the original token (int versus long, float versus double, bytes) is preserved and re-emitted exactly.

Now the limit worth planning around. An Avro document has one root record, so this route emits one record: the root type plus every named type reachable from it, nested inline at first use and referenced by name afterwards. Types in the project that nothing references are not part of that document, and no ledger entry announces their absence — so count your types. Two practical answers: scope the export with spaces, or use the project-level Avro export on the machine-to-machine surface, GET v1/{projectId}/exportAvro?spaceId=…&nodeIds=… (Viewer role, ApiResponse envelope with the payload under data), where nodeIds selects exactly the types you want. Agents reach the same export through the export_avro MCP tool.

3. schema/map — Avro through the engine, toward a target

schema/map is the stateless engine surface: decode the source, produce a plan against a target hint, validate it through the universal gate, execute, encode. Nothing touches the project — every call is inherently a dry run — and the response adds plan to the envelope.

The hint is what you map toward. Pass the source as its own hint for a pure format conversion, pass a different schema in any decodable format to map onto something real, or set "useProjectAsTargetHint": true to map toward the project's own schema (that variant reads the project; it still writes nothing). The inferred strategy requires one of the three: with no hint it declines — The inference resolver requires a target IR to match against.

jq -n --rawfile s OrderEvent.avsc '{
  sourceFormat: "avro",
  sourceSchema: $s,
  targetFormat: "sql",
  vendor: "postgres",
  targetHintFormat: "avro",
  targetHintSchema: $s,
  mapping: { kind: "inferred", caseInsensitive: true }
}' \
| 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-result.json

For an OrderEvent record with an id carrying x-maps-to, a double, a nullable string, an enum, a string array, and a timestamp-millis field, the schema string renders as:

CREATE TABLE "OrderEvent" (
  "orderId" VARCHAR(255) NOT NULL,
  "amount" NUMERIC NOT NULL,
  "couponCode" VARCHAR(255),
  "status" VARCHAR(255) NOT NULL,
  "lineItems" VARCHAR(255) NOT NULL,
  "placedAt" TIMESTAMP NOT NULL
);

COMMENT ON COLUMN OrderEvent.orderId IS '{"x-maps-to":{"schema.org":"https://schema.org/orderNumber"}}';

with one ledger entry: ConstraintRelaxation at Element[OrderEvent.status] — "Postgres has no inline enum; emitted as VARCHAR (the allowed-value constraint is not enforced)." Note what traveled and what did not. The nullable union became a nullable column; the logical type became TIMESTAMP; the semantic annotation survived as a column comment; the enum's four allowed values did not, and the ledger says so with the path. The array landed in a single column, which is the other line to check before you fan out to a warehouse.

mapping.kind also accepts explicit (an authored mapping guide, passed as JSON text in mapping.guide) and ai (a server-side Claude proposal that passes the identical validation gate). ai requires Editor or Admin membership plus a server-configured Anthropic key, and it sends schema content to the Anthropic API server-side.

4. plan/execute — replay the stored plan

The plan from the previous call is the point of the previous call. One wire quirk: plan/execute takes it as a string.

jq '.plan' map-result.json > orderevent-plan.json

jq -n --rawfile s OrderEvent.avsc --rawfile p orderevent-plan.json '{
  sourceFormat: "avro",
  sourceSchema: $s,
  plan: $p,
  targetFormat: "sql",
  vendor: "postgres"
}' \
| 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 @-

The parsed plan goes through the same gate as every strategy — a stored plan earns no shortcut — then executes deterministically. Same plan plus same source gives the same output. The hint fields are optional on replay: the plan already names its targets. And if the source has drifted out from under the plan, the gate says so instead of guessing, naming the operation index and the id that no longer resolves.

Records, not just schemas

avro is also a data format on the record endpoints (data/import, data/export, data/map), where it means Avro's JSON encoding rather than plain JSON. Two differences matter: an optional field is either null or a one-key union object, and a date-time is the underlying epoch-milliseconds long.

[
  { "orderId": "A-1001", "amount": 42.5, "couponCode": { "string": "SPRING" },
    "status": "paid", "lineItems": ["sku-1", "sku-2"], "placedAt": 1719792000000 },
  { "orderId": "A-1002", "amount": 9.99, "couponCode": null,
    "status": "placed", "lineItems": ["sku-9"], "placedAt": 1719878400000 }
]

Enum values are bare symbols, collections are JSON arrays. The data-format list is json | csv | jsonld | sql | avro.

Errors, honestly shaped

Failures arrive inside the envelope with a path and a message, not as bare 500s:

  • Wrong format keyUnknown schema format 'avsc'. followed by the accepted list, so the fix is in the message.
  • Malformed payloadCould not parse the 'avro' schema: … with the parser's own detail.
  • Non-record rootThe root of an Avro schema must be a record. at path $. A bare enum or a primitive as the document root is an error, not an approximation: there is no Type to build.
  • Avro output with no root typeThe schema has no root type to encode as an Avro record. This is the one to remember when Avro is the target of a mapping: a plan that renames the root record can leave the encoder with nothing to root on. Keep the record name across the mapping.
  • Inferred mapping without a hint — the message from §3.

The transform API documentation collects all ten endpoints, their roles, and ready-to-paste bodies for every format in one place.