The Transform API for proto3: Four Routes, Both Directions
Most schema converters are one-way. You can get *out* of the format or *into* it, rarely both, and the documentation usually leaves you to discover which by trying. So let us answer the direction question for Protocol Buffers first and in one sentence: **proto3 decodes and encodes.** The key `protobuf` (alias `proto`) appears in both the decode list and the encode list, so it is valid as a source and as a target on every route below.
The Transform API for proto3: Four Routes, Both Directions
Most schema converters are one-way. You can get out of the format or into it, rarely both, and
the documentation usually leaves you to discover which by trying. So let us answer the direction
question for Protocol Buffers first and in one sentence: proto3 decodes and encodes. The key
protobuf (alias proto) appears in both the decode list and the encode list, so it is valid as a
source and as a target on every route below.
For contrast, and because honesty about direction is the point: odm decodes only — it is authored
documentation, not a generated artifact — and synapse encodes only, returning an error that tells
you what to do instead: 'synapse' is encode-only: a Synapse schema is plain draft-07 JSON Schema — decode it with the 'jsonschema' format. Protobuf has no such asterisk.
Four routes carry the proto3 work this article covers. All are POST, all live under
{host}/graph/transform/, all take Authorization: Bearer $TOKEN and
Content-Type: application/json, and all are project-scoped:
| Route | Role | What it does with proto3 |
|---|---|---|
schema/map/{projectId} | Viewer | stateless conversion in or out; returns schema + plan |
schema/import/{projectId} | Admin | writes a .proto into a project as governed model |
schema/export/{projectId} | Viewer | emits a project's schema as a .proto |
plan/execute/{projectId} | Viewer | replays a stored plan against the same source |
A fifth route, schema/mapImport/{projectId}, imports through the mapping engine with a dryRun
flag (Admin to write, Viewer for the dry run). And one role exception across the mapping routes:
"mapping": { "kind": "ai" } requires Editor or Admin membership rather than Viewer.
Every response uses the same envelope: success, lossiness, errors, and one payload key
(schema, projectId, or summary), plus plan on the mapping routes. success: true means the
call ran, not that nothing changed — the lossiness array is where the change report lives.
schema/map — conversion without touching anything
The workhorse. It scopes authorization to the project and reads nothing from it; every call is inherently a dry run.
Here is a catalog schema on its way to JSON Schema. The .proto text travels as a JSON string:
curl -sS -X POST \
"https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sourceFormat": "protobuf",
"sourceSchema": "syntax = \"proto3\";\n\npackage acme.catalog;\n\nmessage Product {\n string sku = 1;\n string title = 2;\n optional string description = 3;\n double list_price = 4;\n repeated string tags = 5;\n Availability availability = 6;\n}\n\nenum Availability {\n AVAILABILITY_UNSPECIFIED = 0;\n IN_STOCK = 1;\n BACKORDERED = 2;\n DISCONTINUED = 3;\n}",
"targetFormat": "jsonschema",
"targetHintFormat": "protobuf",
"targetHintSchema": "syntax = \"proto3\";\n\npackage acme.catalog;\n\nmessage Product {\n string sku = 1;\n string title = 2;\n optional string description = 3;\n double list_price = 4;\n repeated string tags = 5;\n Availability availability = 6;\n}\n\nenum Availability {\n AVAILABILITY_UNSPECIFIED = 0;\n IN_STOCK = 1;\n BACKORDERED = 2;\n DISCONTINUED = 3;\n}",
"mapping": { "kind": "inferred" }
}'
The response:
{
"success": true,
"lossiness": [],
"errors": [],
"schema": {
"type": "object",
"properties": {
"sku": { "type": "string" },
"title": { "type": "string" },
"description": { "type": "string" },
"list_price": { "type": "number" },
"tags": { "type": "array", "items": { "type": "string" } },
"availability": {
"enum": ["AVAILABILITYUNSPECIFIED", "INSTOCK", "BACKORDERED", "DISCONTINUED"]
}
},
"required": ["sku", "title", "list_price", "availability"]
},
"plan": { "operations": [ /* one operation per mapped construct */ ] }
}
An empty lossiness array is a real result, not a placeholder: this file uses only constructs that
have exact homes on the other side. Note description sitting outside required — it carried the
explicit optional label — and tags outside it as well, because it is a collection.
targetHintSchema is repeated verbatim here because a straight format conversion maps a schema to
itself. That is the identity case; the interesting case is next.
schema/map with a real target hint
When the hint is a different schema, inferred aligns the two by label and compatible type, and
the plan records which target construct each source construct landed on. Send a telemetry .proto
toward a JSON Schema that describes the same concept, and ask for Postgres DDL out:
{
"sourceFormat": "protobuf",
"sourceSchema": "syntax = \"proto3\";\n\npackage acme.telemetry;\n\nimport \"google/protobuf/timestamp.proto\";\n\nmessage DeviceReading {\n string device_id = 1;\n sint32 temperature_c = 2;\n uint32 battery_pct = 3;\n google.protobuf.Timestamp read_at = 4;\n optional string firmware = 5;\n}",
"targetFormat": "sql",
"vendor": "postgres",
"targetHintFormat": "jsonschema",
"targetHintSchema": "{ \"$id\": \"DeviceReading\", \"type\": \"object\", \"title\": \"DeviceReading\", \"properties\": { \"device_id\": { \"type\": \"string\" }, \"temperature_c\": { \"type\": \"integer\" }, \"battery_pct\": { \"type\": \"integer\" }, \"read_at\": { \"type\": \"string\", \"format\": \"date-time\" }, \"firmware\": { \"type\": \"string\" } }, \"required\": [\"device_id\", \"temperature_c\", \"battery_pct\"] }",
"mapping": { "kind": "inferred" }
}
The schema field comes back as a string for text targets:
CREATE TABLE "DeviceReading" (
"device_id" VARCHAR(255) NOT NULL,
"temperature_c" INTEGER NOT NULL,
"battery_pct" INTEGER NOT NULL,
"read_at" TIMESTAMP,
"firmware" VARCHAR(255)
);
and the plan shows the alignment explicitly — each operation naming the hint's element id:
{
"kind": "ElementMapping",
"origin": "Inferred",
"sourceElementIds": ["DeviceReadingTemperatureC"],
"targetElementIds": ["DeviceReading::temperature_c"]
}
"origin": "Inferred" is the label saying this was a heuristic match. The ledger for this call
carries three records: the unresolved import, and one TypeApproximation each for sint32 and
uint32 — "encoding semantics are not modelled; approximated as Integer with the verbatim type
preserved." Zigzag and unsigned encodings are exactly the facts a table cannot hold, and exactly the
facts most hand conversions never mention.
vendor applies to sql output only (postgres is the default, with mysql and sqlserver also
accepted). Protobuf output takes no format-specific option: the knobs that shape a .proto are the
mapping kind and the hint.
schema/import — a .proto becomes a governed model
This route writes, so it needs Admin:
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": "protobuf",
"schema": "syntax = \"proto3\";\n\npackage acme.catalog;\n\nmessage Product {\n string sku = 1;\n string title = 2;\n optional string description = 3;\n double list_price = 4;\n repeated string tags = 5;\n Availability availability = 6;\n}\n\nenum Availability {\n AVAILABILITY_UNSPECIFIED = 0;\n IN_STOCK = 1;\n BACKORDERED = 2;\n DISCONTINUED = 3;\n}",
"spaces": []
}'
{ "success": true, "lossiness": [], "errors": [], "projectId": "..." }
Each message becomes a Type, each field an Element under its wire name, each enum a Taxonomy with
its values as terms in order. Nested messages become their own Types that remember which message
enclosed them. spaces is optional — an empty array means the project's main space.
One caveat we would rather state than have you discover. The wire-level facts a .proto carries —
field numbers, the package, file and message options, reserved statements, the verbatim scalar
tokens — ride in the transform's annotation channel, which is not persisted into a project's
schema store. Element optionality and cross-standard mappings are persisted; field numbers are not.
So an import followed later by an export gives you a schema-equivalent .proto, not the original
wire contract. When those facts matter, keep the conversion on the stateless routes, where they
survive intact. When what you want governed is the meaning, import is exactly right.
schema/export — a project becomes a .proto
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": "protobuf" }'
For a project holding a Customer type with a required customer_key (Integer) and full_name
(String), an optional signup_date (DateTime), and a status element pointing at a three-term
CustomerStatus taxonomy, the schema string is:
syntax = "proto3";
message Customer {
int64 customer_key = 1;
string full_name = 2;
optional string signup_date = 3;
optional CustomerStatus status = 4;
}
enum CustomerStatus {
draft = 0;
active = 1;
closed = 2;
}
Everything visible here is a rule, not a coincidence. Integer becomes int64, Double double,
Boolean bool, and everything else string. Elements that were never required gain the explicit
optional label so their presence is tracked. Field numbers are minted sequentially in declaration
order — and the minting skips numbers already in use and the implementation-reserved 19000–19999
range, so the output is wire-safe by construction. Enum values are numbered from zero in term order.
The ledger for this export has one entry: TypeApproximation on Element[CustomerSignupDate] —
"proto3 has no date-time scalar; DateTime degrades to string." That is true of proto3 generally,
which is why the ecosystem uses google.protobuf.Timestamp; a definition that arrived from a
.proto carries that token verbatim and re-emits it, while one that never saw proto3 has no token
to re-emit. The ledger tells you which case you are in.
{ "format": "proto" } behaves identically — the alias is resolved at dispatch.
plan/execute — replay, gated the same way
The plan from any mapping call is a reviewable, storable artifact. Replaying it takes the same
source, the plan as a string, and a target format:
{
"sourceFormat": "protobuf",
"sourceSchema": "<the same .proto text>",
"plan": "{\"operations\":[{\"kind\":\"TypeMapping\",\"origin\":\"Inferred\",\"sourceTypeId\":\"ShipmentEvent\",\"targetTypeId\":\"ShipmentEvent\",\"targetLabel\":\"ShipmentEvent\"}]}",
"targetFormat": "protobuf"
}
A stored plan earns no shortcut: it goes through the identical validation gate as a freshly produced one, then executes deterministically. Same plan plus same source produces the same output, every run.
When it says no
The decode side is strict on exactly one axis — the proto3 language — and forgiving everywhere else. Errors arrive in the envelope, never as a stack trace:
{
"success": false,
"lossiness": [],
"errors": [
{ "path": "$", "message": "Only proto3 is supported; the file declares syntax \"proto2\"." }
],
"schema": null
}
Other refusals you may meet: 'required' fields are proto2; only proto3 is supported., The protobuf schema is empty., and parse errors that point at the token — Expected ';' but found '}'. A
misspelled format key answers with the whole list: Unknown schema format 'protobuff'. Use: jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | odm.
What is not a failure: a service block, an extend block, an unresolvable import, a map field,
or a missing syntax statement. Each decodes as far as it can and files a lossiness record instead.
A decode never hard-fails on a construct it can skip.
The transform section of the CoreModels documentation lists every route with its role and a ready-to-paste body for each format.