Protocol Buffers logoAPI

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:

RouteRoleWhat it does with proto3
schema/map/{projectId}Viewerstateless conversion in or out; returns schema + plan
schema/import/{projectId}Adminwrites a .proto into a project as governed model
schema/export/{projectId}Vieweremits a project's schema as a .proto
plan/execute/{projectId}Viewerreplays 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.