Skip to content

Add Avro and Protobuf bindings across SDKs - #191

Open
clemensv wants to merge 13 commits into
masterfrom
feature/avro-protobuf-bindings
Open

clemensv wants to merge 13 commits into
masterfrom
feature/avro-protobuf-bindings

Conversation

@clemensv

Copy link
Copy Markdown
Contributor

Summary

  • define normative JSON Structure mappings for Apache Avro and Protocol Buffers
  • add shared valid and invalid corpora with expected schemas, field numbers, serialized payloads, warnings, and errors
  • add Rust Avro and Protobuf compilers, CLI integration, serialization round trips, and corpus tests
  • add Avro compiler implementations and corpus tests for the .NET and Java SDKs
  • document binding behavior, modes, annotations, units, logical types, constraints, and compatibility guidance

Validation

  • Rust: cargo test --manifest-path rust/Cargo.toml --all-features
  • .NET: dotnet test dotnet/JsonStructure.sln --configuration Release --no-restore (1,093 passed)
  • Java: java/mvnw.cmd test --batch-mode from java (954 passed)
  • git diff --check origin/master...HEAD

Review notes

The commits are intentionally ordered as one cross-SDK feature. The mapping documents and shared corpora establish the contract consumed by the Rust implementation; subsequent commits refine that contract while adding .NET and Java parity.

Clemens Vasters and others added 13 commits August 27, 2026 15:24
JSON Structure becomes the source of truth for both wire formats. Avro is a
runtime concern: the SDK compiles a document to an Avro schema on the fly, so a
developer never writes or ships an .avsc. Protobuf is a build-time concern: the
jstruct CLI emits .proto modules a service definition imports.

Two normative mapping specs under spec/, a Rust reference implementation for
each, and golden conformance corpora under test-assets/ that every future
language port is expected to pass unchanged.

Avro
- json_structure::avro::{schema_from_str, schema_from_value, compile} — a
  document in, an apache_avro::Schema out.
- `any` maps to an empty record. A reader holding the compiled schema resolves
  against whatever concrete record the writer put there and steps over it. At
  that position the compiled schema is a reader schema, which the compiler warns
  about, because writing a non-empty payload through it is rejected.
- Union defaults are placed, not merely emitted: the defaulted branch rotates to
  the front of the union and the JSON Structure tag is consumed. Avro takes the
  default from the first branch, so emitting it anywhere else parses and then
  fails at read time.
- No logical types. Temporals, decimal, and uuid travel as string.

Protobuf
- jstruct proto, plus jstruct consolidate to resolve interdependent $import
  documents into one file for either pipeline.
- Field numbers are positional and pinned by a lock file, so regeneration never
  renumbers a live wire contract.
- A rootless document is valid and expected: it is a library of importable
  types. This is where the Protobuf mapping deliberately diverges from Avro,
  which must yield exactly one schema.

Neither compiler offers a namespace or package override. The document says where
its types live; we only translate.

CI
- rust.yml now builds and tests with --all-features. Without them the Avro
  runtime and the whole CLI suite compiled to zero tests and reported success.
- protoc is installed and JSTRUCT_REQUIRE_PROTOC turns a missing toolchain from
  a silent skip into a failure, so the protobuf validity check cannot quietly
  stop running.
- clippy now covers --all-features --all-targets.

389 tests pass; protoc 29.3 accepts every generated file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 033b3a57-b612-4c5a-b2d7-d9b0a6eab26d
The question that prompted this was fair and the honest answer was
uncomfortable. Avro moved actual bytes in five places, all hand-written;
the 32-case corpus only ever called `Schema::parse_str`. Protobuf moved
none at all - `protoc` proved the generated `.proto` parsed, and nothing
proved the messages could carry the data the source document describes.

That is precisely the gap a blessed golden cannot close. `expected.avsc`
proves every port agrees with the reference implementation; it cannot
prove the reference implementation is right, because it was blessed from
it. A schema that is self-consistent but wrong sails through.

So both corpora now carry hand-written instances, and both harnesses put
them on the wire.

Avro: `instance.avro.json` for all 32 valid cases, decoded, written with
`apache_avro::Writer`, read back, compared. `apache-avro` has no decoder
for the Avro JSON encoding - `Value::from(json).resolve(&schema)` matches
union branches structurally and reads `{"Circle": {...}}` as a map - so
the harness carries a small schema-driven decoder that implements the
tagging properly.

Protobuf: `instance.txtpb` for 32 of 33 valid cases, driven through
`protoc --encode` / `--decode` / `--encode` with the two binaries compared
byte for byte. No new Rust dependency; the reference implementation does
the work. `root-enum` generates no message at all, so it carries a
`no-instance.md` saying why - silence is not an acceptable way to opt out,
and the harness enforces that.

Both harnesses were mutation-tested rather than trusted for passing on the
first run: a wrong union tag, an untagged union, a bogus field, and an
all-defaults instance each fail as they should. Writing the instances also
caught one of my own misreadings - nested arrays compile straight to
`array<array<double>>` in Avro with no wrapper record, unlike proto.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 033b3a57-b612-4c5a-b2d7-d9b0a6eab26d
"Fast and deterministic" was the goal from the outset. Determinism has had
a test since the corpus existed; speed had a claim and no number. Adds
`examples/avro_bench.rs`, which measures against the conformance corpus
itself so the benchmark cannot drift away from what is actually shipped.

Three findings.

The conversion costs about what parsing its own output costs - 1.16x
`apache_avro::Schema::parse_str` across the corpus, ~20us for a typical
schema. That is the yardstick that matters: a developer hand-maintaining
an `.avsc` still pays the parse, so the conversion is roughly a doubling
of a cost that was already negligible.

It is linear in document size, not quadratic. 2.4-3.7us per declared
property held steady from 10 properties to 10,000, where the corpus - all
~1KB documents - could never have told us. A compiler that is quadratic in
property count looks fine on a conformance suite and falls over on a
generated schema.

The load path was doing two things for no reason, both now gone:
`document.clone()` deep-copied the whole document on the overwhelmingly
common path where it has no `$import` at all, now a `Cow::Borrowed`; and
the compiled schema was serialized to text purely so `parse_str` could
parse it straight back, when `Schema::parse` takes the JSON tree directly.
Neither moved the needle much - the end-to-end path is dominated by
allocation, and 145us once at startup is not worth chasing further - but
both were work nobody needed done.

The right answer to the cost is the one already documented: compile once
and cache the `Schema`. The module docs now carry the measured figure
behind that advice instead of asserting "cheap but not free" on faith.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 033b3a57-b612-4c5a-b2d7-d9b0a6eab26d
Ports the Rust reference compiler to C# as JsonStructure.Avro, so a .NET
application can hand its Avro serializer a .struct.json and stop maintaining
an .avsc. The seam is one line at the call site: wherever Schema.Parse took a
hand-written .avsc, JsonStructureAvro.SchemaFrom takes the JSON Structure
document, and everything downstream is unchanged because what comes back is an
ordinary Avro.Schema.

The port matches all 34 goldens byte for byte. Two things it had to get right
that the Rust implementation never has to think about: JsonArray.Add<T> boxes
even a string into a JsonValueCustomized<T> on .NET 8, which cannot be written
without a reflection-based type-info resolver, and WriteIndented breaks lines
with Environment.NewLine, which is CRLF on Windows and not what the goldens say.
Both are now called out in SDK-GUIDELINES for the next port.

Apache.Avro's JsonDecoder reads the Avro JSON encoding natively, including union
tagging, which makes it an independent check on the hand-written decoder the Rust
harness needs. It cannot be the only decoder: it throws on any schema with a
self-referential type. The harness therefore decodes with its own reader, and
where JsonDecoder also works asserts that both decodes serialize to identical
bytes -- a check that is itself asserted, so it cannot silently degrade to
nothing.

Two corpus cases added, both found by mutating the compiler and watching the
corpus not notice:

- union-namespaced-branches: the only case whose union tag is a fullname rather
  than a bare type name. Without it, a port could use short names throughout and
  stay green.
- union-default-placement: the only case where a declared default names a branch
  that is not already first, so the branch-rotation logic added by the
  adversarial review had no coverage at all.

Also drops the explicit System.Text.Json package reference -- the in-box .NET 8
one is enough once the JsonValueCustomized bug is fixed, and pinning it was
hiding that bug. Makes both Compile overloads return AvroCompileResult so that
discarding warnings is never the shorter thing to write. Extends the .NET CI
trigger to test-assets, which the new tests read. Repairs two doc comments in
the Rust compiler that a bad edit had joined onto the following line.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 033b3a57-b612-4c5a-b2d7-d9b0a6eab26d
Two changes to the Avro story, both about what a schema says rather than
what it encodes.

A `full` mode. The compiler emitted the smallest schema that carries the
data, which is right when the schema is parsed at process start and
wrong when a human or a code generator reads it. `full` adds
`logicalType` annotations for the temporal types and `uuid`, and appends
the constraints Avro cannot express to each field's `doc`. It changes no
base type and therefore no byte on the wire, and the corpus asserts that
directly by compiling every case both ways and comparing encoded bytes.
`compact` stays the default. Rust gets `--mode`, .NET gets
`AvroOptions.Mode`.

The temporal annotations use Avrotize's `rfc3339-*` names. Avro says a
parser must ignore a logical type it does not recognize; `apache-avro`
does, Apache.Avro throws instead, so the .NET SDK registers them with
`LogicalTypeFactory` before parsing. That asymmetry is now in the spec
as a requirement on ports rather than a surprise waiting for them.

`decimal` moves to `bytes` with Avro's own `decimal` logical type in
both modes. It was a string, which is lossless but is not what any Avro
consumer expects to find.

Plain JSON for the corpus instances. Avro's JSON encoding writes binary
as Latin-1 code points, temporals as bare epoch numbers, and every union
value wrapped in an object naming the branch. No ordinary JSON producer
emits that, which makes it a poor way to describe the data a JSON
Structure document is about. The instances now use the encoding
specified in avrotize's avrojson.md: base64 binary, quoted `long` and
`decimal`, RFC 3339 temporals, untagged unions resolved by structure.

That costs the C# harness its second opinion, because Apache.Avro's
`JsonDecoder` can no longer read the instances. Replaced with something
stronger: `expected.avro.b64` pins the bytes each instance must encode
to, so the two hand-written decoders are checked against each other
rather than each against itself. Corrupting one golden fails .NET, which
is the point.

Blessing those bytes immediately turned up a real limit. Avro writes map
entries in iteration order and `apache-avro` uses a `HashMap`, so
`collections` encodes differently on two consecutive runs of the same
binary. Map-bearing cases are exempt, and both harnesses assert that at
least one case is exempt so the exemption cannot rot into dead code.

Testing. Ten mutations against the two decoders, five each. Five
survived the first pass, all of them guards the corpus cannot reach
because every instance in it is meant to decode: ambiguous unions,
omitted required fields, and decimals finer than their scale. Added
direct negative tests for those paths in both SDKs; all ten mutations
now fail. Seven earlier mutations against the full-mode compiler were
already caught in both.

One test was doing nothing at all. `the_two_modes_encode_identical_bytes`
skipped any case whose two modes parsed equal - which, because
`apache-avro` discards an unrecognized `logicalType` at parse time, was
every rfc3339 case; the rest were excluded by a separate `decimal`
guard. Net coverage was zero. Restructured around the two real cases:
annotations the parser drops, where nothing can differ, and `uuid` and
`decimal`, which become distinct schema variants and can be compared as
bytes. Both counted and asserted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 033b3a57-b612-4c5a-b2d7-d9b0a6eab26d
The `avro` module's own documentation still described a single mode. Adds
a Modes section with a running doctest showing `Mode::Full` producing an
`rfc3339-timestamp-micros` annotation, the wire-compatibility guarantee,
and the warning that the `rfc3339-*` names may need registering with a
runtime that does not obey Avro's ignore-unknown-logical-type rule.

The doctest is the point: it fails if the mapping ever changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 033b3a57-b612-4c5a-b2d7-d9b0a6eab26d
`full` mode followed Avrotize and appended constraints to the `doc`
string as `[minimum: 0, scale: 2]`. That is a display string: it cannot
round-trip, it collides with prose, and nothing parses it back. Avro
schemas are extensible - a parser must ignore an attribute it does not
recognize - so the constraints now travel as a `jsonStructure` attribute
beside `doc`, with their original JSON types intact.

    {
      "name": "total",
      "type": {"type": "bytes", "logicalType": "decimal", ...},
      "doc": "Order total",
      "jsonStructure": {"minimum": 0}
    }

A number stays a number and a pattern stays something a regex engine can
compile, at no cost to a reader that has never heard of JSON Structure.
The key set and its emission order are unchanged and still fixed; the
corpus case now scrambles the source order so that the fixed order is
actually being tested rather than coincidentally matched.

Two consequences fall out of the move, and both are specified and
tested rather than left to be discovered:

`emitDoc` no longer suppresses constraints. It governs `doc`, which is
prose for a human; constraints are metadata for a program. Coupling them
made one option mean two things. New case `full-constraints-no-doc`.

`precision` and `scale` are not repeated when Avro's own decimal logical
type already carries them. Two copies of the same fact in one schema can
only ever disagree. When a decimal falls back to a lexical string - no
precision, or a scale above it - whichever is present is annotated,
because nothing else is carrying it. New case
`full-decimal-constraints`, which covers all three shapes.

Renamed `full-doc-annotations` to `full-constraint-annotations`, since
`doc` is no longer where they live.

Ten mutations, five per SDK: drop a keyword, reorder two, emit in
compact mode, duplicate the decimal constraints, re-couple to `emitDoc`.
All ten caught.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 033b3a57-b612-4c5a-b2d7-d9b0a6eab26d
The attribute names its producer rather than its contents. It holds
annotations - `minimum`, `pattern`, `maxLength` - so it is now called
`annotations`, matching the section that specifies it (6.4.1,
"Constraint annotations") and the corpus case that covers it.

Purely a rename: the key set, the fixed emission order, the placement
beside `doc`, the `emitDoc` independence, and the no-duplication rule
for `decimal` are all unchanged, and no byte on the wire moves. Renaming
now is free; the attribute has not shipped.

Also corrects two enum doc comments that still said constraints ride on
`doc`, which the previous commit made untrue.

Mutating the compiler back to the old name fails two corpus tests, so
the goldens pin the name rather than having drifted alongside it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 033b3a57-b612-4c5a-b2d7-d9b0a6eab26d
"Golden" is jargon that carries no meaning to a reader who has not met
it before, and the files it described are already named `expected.avsc`,
`expected-numbers.json`, and `expected-error.txt`. The prose now matches
what is on disk.

Mechanical throughout, with three renames worth noting:

  every_valid_case_matches_its_golden_output
    -> every_valid_case_matches_its_expected_output   (Rust, both corpora)
  CompilesToTheGoldenSchema
    -> CompilesToTheExpectedSchema                    (C#)
  let mut goldens
    -> let mut expected_files                         (proto corpus)

Section headings become "Expected-output match" and the two corpus
harness headers become "Conformance-corpus harness", which says what
they are rather than what colour they are.

No behaviour changes; both suites and clippy are green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 033b3a57-b612-4c5a-b2d7-d9b0a6eab26d
The `annotations` attribute held only value constraints, all of which
land on a property. So although the compiler had emission sites on the
record and enum type objects, no corpus case ever reached them: every
`annotations` in the corpus sat on a field. Dead paths that two ports
could silently disagree about.

Units and semantic annotations are what makes those paths real, and they
belong in the bucket on their own merits. A unit is a stronger case for
carrying than a constraint, not a weaker one. A constraint is a rule you
can re-check against the source document, and the data means the same
thing either way. A unit is identity - a `double` that lost "metres" is a
number that means nothing, and nothing recovers it from the bytes.

Added, after the constraints and in fixed order:

  symbol, symbols, unit, ucumUnit, currency
  concepts, observedProperty, semanticRole, derivation, statistic,
  phenomenonTimeRelation, supportPeriod, cadence, codedValues,
  measurementConditioning

`concepts` and `observedProperty` annotate a type, so the record and enum
type objects now carry the attribute for real, pinned by
`full-semantic-annotations`.

Nine semantic keywords are deliberately NOT carried. They bind property
names: `coordinateReferenceSystem` has a `coordinates` array naming the
properties that form a coordinate, and the frame, colour, audio, spectral
and temporal keywords do the same. Those are JSON Structure names, and
that spec is explicit that an alternate name does not change the identity
an annotation binds. Avro is the renamed world - `altnames.avro` and the
name rules of section 6 mean a property can arrive under a different
name. A verbatim copy would name fields that do not exist, silently,
which is worse than not carrying it at all. They are dropped with a
warning instead, in both modes, because unlike a constraint `full` cannot
rescue them. `full-name-binding-dropped` renames its properties through
`altnames` so the case shows exactly what a copy would have dangled
against.

Three new corpus cases, 39 -> 42. The warning's mode-independence cannot
live in the corpus - a case carries one options file - so it is a unit
test in both SDKs.

Eight mutations, four per SDK. Seven caught; "reorder unit before symbol"
survived, because no case carried both keywords on one declaration and
the units group's order was therefore untested. `full-units` now does,
and the mutation is caught in both SDKs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 033b3a57-b612-4c5a-b2d7-d9b0a6eab26d
The Java SDK gets the same deal Rust and .NET already have: hand it a JSON
Structure document, get back an `org.apache.avro.Schema`. The `.avsc` stays an
implementation detail.

`JsonStructureAvro.schemaFrom` is the seam -- it goes exactly where
`new Schema.Parser().parse()` used to. `AvroCompiler` sits underneath and
returns the schema as a Jackson tree plus the warnings, and needs nothing but
Jackson; Avro itself is an optional dependency, so nobody who only wants the
validator pulls it in.

Three things Java made different from the .NET port:

- Jackson has a real `NullNode` where System.Text.Json has a C# `null`, so
  `"default": null` reads as a present default rather than an absent one. The
  accessor folds them together to match, and the compiler reads raw nodes
  wherever the difference is load-bearing.
- The JSON writer is hand-rolled. Jackson's pretty printer writes `"key" :
  value` and indents arrays on its own scheme, and byte-exactness against the
  Rust reference is the whole contract.
- No logical types to register. Java's Avro runtime ignores a `logicalType` it
  does not recognize, so the `rfc3339-*` family parses without touching Avro's
  static registry -- unlike Apache.Avro, which rejects it.

The corpus harness ports whole: 42 valid and 10 invalid cases, seven checks,
265 assertions. It passed on the first green run, which the guidelines say to
distrust, so five mutations went in to check -- a reordered annotation table, a
dropped record-level `annotations`, a suppressed warning, a decimal scaled by
ten, an off-by-one `long`. All five were caught. 954 tests green on JDK 21.

Also extends the Java CI path filter to `test-assets/**` and `spec/**`, which
the tests now depend on and the filter would otherwise skip.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 033b3a57-b612-4c5a-b2d7-d9b0a6eab26d
Register the Semantic Annotations extension in the Rust validator and map JSON Structure dates to Avro's standard date logical type across Rust, .NET, and Java. Normalize date defaults through references and unions while retaining offset-bearing temporal values as lossless strings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@clemensv

clemensv commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Added a conformance follow-up based on validation of the Puget Sound ontology:

  • register JSONStructureSemanticAnnotations in the Rust validator so schemas using the inclusive semantic profile validate without a false unknown-extension warning
  • map JSON Structure date to Avro's standard int + date logical type in Rust, .NET, and Java
  • convert RFC 3339 date defaults to epoch days, including defaults reached through $ref and non-string unions
  • extend the shared corpus to pin direct, referenced, union, and defaulted date behavior across all three implementations
  • keep time, datetime, and duration lexical where Avro/Protobuf native types would lose offsets or calendar-duration semantics
  • update the normative Avro mapping text accordingly

The rebuilt Rust jstruct validates the graph schema with valid: true and an empty diagnostics array.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant