Protocol Buffers logoQuickstart

Ten Minutes With proto3: Your First CoreModels Transform

You have a `.proto` file. It is the truth for a service, and now somebody outside that service needs the same shape — as JSON Schema for a validation step, as a data contract for a review, as a table for analytics. The usual answer is to retype it. This article replaces the retyping with one HTTP call, and then spends most of its length on the part that matters more: reading what the conversion cost you.

Ten Minutes With proto3: Your First CoreModels Transform

You have a .proto file. It is the truth for a service, and now somebody outside that service needs the same shape — as JSON Schema for a validation step, as a data contract for a review, as a table for analytics. The usual answer is to retype it. This article replaces the retyping with one HTTP call, and then spends most of its length on the part that matters more: reading what the conversion cost you.

Everything below is stateless. The route we use decodes, maps, encodes, and hands the result back; nothing is written to any project. You need a host (https://coremodels.example.com throughout), a bearer token in $TOKEN, a CoreModels project id in $PROJECT_ID (32 hex characters — it scopes authorization only, and Viewer access is enough), plus curl and jq.

The file

Save this as shipment_event.proto. It is a small, ordinary proto3 file that happens to exercise the constructs that behave interestingly in any conversion: a package, a well-known import, an explicit optional, a repeated field, an enum, and two scalars whose wire semantics no other format carries.

syntax = "proto3";

package acme.logistics;

import "google/protobuf/timestamp.proto";

message ShipmentEvent {
  string shipment_id = 1;
  uint64 sequence = 2;
  ShipmentStatus status = 3;
  optional string carrier_note = 4;
  repeated string package_ids = 5;
  google.protobuf.Timestamp occurred_at = 6;
  bytes signature = 7;
}

enum ShipmentStatus {
  SHIPMENT_STATUS_UNSPECIFIED = 0;
  PICKED_UP = 1;
  IN_TRANSIT = 2;
  DELIVERED = 3;
}

The format key is protobuf, with proto as an accepted alias. It works in both directions — the key is valid as a source and as a target — and its scope is a single proto3 file. Proto2 is not accepted, and we say so rather than half-parsing it.

The one rule, then the call

One rule before the call, because it is the most common stumble. The default mapping strategy, inferred, matches your source against a target hint and declines rather than guesses when there is none. Call without a hint and you get success: false with the error The inference resolver requires a target IR to match against.

For a straight format conversion, the hint you want is the file itself. That produces an identity plan — every message, field, and enum maps to its own counterpart — and leaves the format work to the target coder.

The schema travels inside JSON as a string, so build the body with jq --rawfile instead of escaping quotes by hand:

jq -n --rawfile p shipment_event.proto '{
  sourceFormat: "protobuf",
  sourceSchema: $p,
  targetFormat: "jsonschema",
  targetHintFormat: "protobuf",
  targetHintSchema: $p,
  mapping: { kind: "inferred" }
}' > 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 @request.json > result.json

That is the whole transform. The response is one envelope: success, lossiness, errors, the produced schema, and the executed plan.

Read the schema

jq '.schema' result.json. For JSON-shaped targets the field holds a JSON object:

{
  "type": "object",
  "properties": {
    "shipment_id": { "type": "string" },
    "sequence": { "type": "integer" },
    "status": {
      "enum": ["SHIPMENTSTATUSUNSPECIFIED", "PICKEDUP", "INTRANSIT", "DELIVERED"]
    },
    "carrier_note": { "type": "string" },
    "package_ids": { "type": "array", "items": { "type": "string" } },
    "occurred_at": { "type": "string" },
    "signature": { "type": "string" }
  },
  "required": ["shipment_id", "sequence", "status", "signature"]
}

Read it against the input and every decision is visible.

The message became the object; each field became a property under its wire name. repeated became an array. google.protobuf.Timestamp became a date-time value, which JSON Schema carries as a string. The enum became an inline controlled list.

The required list is the interesting part, and it is proto3 semantics being preserved rather than invented. A plain scalar or enum field in proto3 has implicit presence — it always carries a value — so shipment_id, sequence, status, and signature decode as required. The explicitly optional field carrier_note tracks presence and does not. Neither does occurred_at, because message-typed fields (including the well-known ones) track presence too. And package_ids is a collection, which carries its own emptiness. Most hand conversions collapse that distinction; this one keeps it.

One cosmetic surprise deserves an explanation. The enum values arrive as SHIPMENTSTATUSUNSPECIFIED rather than SHIPMENT_STATUS_UNSPECIFIED. Internally each term has an alphanumeric id and a label; the label holds the exact wire spelling, and a JSON Schema target emits ids. Convert to a proto3 target instead and the underscores come back, because that coder emits labels. If the downstream consumer cares about the exact symbols, this is the line to check.

Read the ledger

jq '.lossiness' result.json:

[
  { "kind": "SemanticNarrowing",
    "path": "imports",
    "explanation": "import \"google/protobuf/timestamp.proto\" is preserved verbatim but not resolved; types it defines decode as approximations." },
  { "kind": "TypeApproximation",
    "path": "ShipmentEvent.sequence",
    "explanation": "proto3 uint64 encoding semantics are not modelled; approximated as Integer with the verbatim type preserved." },
  { "kind": "TypeApproximation",
    "path": "ShipmentEvent.signature",
    "explanation": "proto3 bytes has no IR equivalent; approximated as String with the verbatim type preserved." }
]

Three entries, each with a kind, an exact path, and a plain-English explanation.

The two TypeApproximation records are the honest cost of leaving proto3's type system. uint64 became an integer: the unsignedness is not modeled anywhere else. bytes became a string. Note the second half of both explanations — the verbatim type preserved. The original token rides along in the annotation bag, so a conversion back to proto3 re-emits uint64 and bytes exactly rather than guessing. The approximation is real for the JSON Schema consumer and reversible for the proto3 one.

The SemanticNarrowing on imports says the import clause was kept but not resolved: we parse one file, not an include graph. That is why a type from another file decodes as a string with its original name retained, and why google.protobuf.Timestamp — the one well-known message we treat specially — becomes a real date-time.

Form the habit now: success: true means "it ran", never "nothing changed." The four kinds you will meet are StructuralDrop (no home in the target), TypeApproximation (a close-but-not-exact type), ConstraintRelaxation (a rule that could not be enforced), and SemanticNarrowing (meaning narrowed or guessed). Treat the list as a review checklist, not an error report.

Change one word

Only targetFormat changes between targets. Ask for odcs — a Bitol Open Data Contract Standard v3 YAML contract — and the same source comes back as:

apiVersion: v3.1.0
kind: DataContract
id: acmeLogistics
name: acme.logistics
version: 1.0.0
status: active
schema:
  - name: ShipmentEvent
    properties:
      - name: shipment_id
        logicalType: string
        required: true
      - name: sequence
        logicalType: integer
        required: true
      - name: status
        logicalType: string
        required: true
      - name: carrier_note
        logicalType: string
      - name: package_ids
        logicalType: array
        items:
          logicalType: string
      - name: occurred_at
        logicalType: timestamp
      - name: signature
        logicalType: string
        required: true

The ledger grows by two entries, both ConstraintRelaxation: Taxonomy[ShipmentStatus] — "ODCS has no enum/controlled-list construct; the taxonomy is not represented" — and Element[ShipmentEventStatus], whose reference to that taxonomy "was approximated as string." The status field exists in the contract; the guarantee that it only holds one of four values does not. Now you know to state it another way.

The full target list is jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | synapse.

And back again

Because protobuf is also a target format, setting "targetFormat": "protobuf" returns proto3 text in the schema field as a string — read it with jq -r '.schema':

syntax = "proto3";

package acme.logistics;

import "google/protobuf/timestamp.proto";

message ShipmentEvent {
  string shipment_id = 1;
  uint64 sequence = 2;
  ShipmentStatus status = 3;
  optional string carrier_note = 4;
  repeated string package_ids = 5;
  google.protobuf.Timestamp occurred_at = 6;
  bytes signature = 7;
}

enum ShipmentStatus {
  SHIPMENT_STATUS_UNSPECIFIED = 0;
  PICKED_UP = 1;
  IN_TRANSIT = 2;
  DELIVERED = 3;
}

Package, import clause, field numbers, the optional label, the exact scalar tokens, the enum symbols with their numbers — all back where they started. For a comment-free file like this one, already in the encoder's own layout, the round trip is byte-identical; comments are consumed by the tokenizer and are not part of the schema, so they do not come back.

Where to go next

Two continuations. If the schema should become a governed model rather than a converted file, POST /graph/transform/schema/import/{projectId} with {"format": "protobuf", "schema": "<the .proto text>"} writes its messages, fields, and enums into a project — that route writes, so it needs Admin. And if you would rather have an agent do this, the same engine is an MCP tool named transform_schema taking the same fields, with a flat mappingKind: "inferred" argument in place of the mapping object.

The transform section of the CoreModels documentation has the full endpoint list, the role each one needs, and ready-to-paste bodies for every format.