tomato·bazeldocs v0 · latest
Docs/Reference/Modules/rules_jsonschema

rules_jsonschema

Bazel rules turning JSON Schema into typed code via a per-language plugin contract (Rust, Go, Starlark)

Latest0.4.0
Versions4
CategoryBazel rules
MaintainersMatt Marshall
Registryhttps://registry.tbzl.dev/modules/rules_jsonschema/
Sourcegithub.com/tomato-bazel/rules_jsonschema
MODULE.bazelstarlark
bazel_dep(name = "rules_jsonschema", version = "0.4.0")

View source & releases on GitHub ↗

Bazel rules that turn a JSON Schema document into typed code, with output languages pluggable via Bazel toolchains. The schema is the single source of truth: an unknown field is a build-time decode error, and the set of generated types regenerates on every build.

Architecture

Plugins implement a minimal stdin/argv/stdout contract; per-language Bazel rules wrap them. The host repo only registers toolchains.

//jsonschema:                 language-neutral core
  - toolchain_type definitions per output language
  - JsonschemaCodegenToolchainInfo provider
  - jsonschema_codegen_toolchain rule (register a plugin)
  - jsonschema_plugin_contract_test rule (verify a plugin conforms)
  - plugin_contract.md (authoritative spec)

//rust:                       Rust output
  - jsonschema_rust_library rule
  - default toolchain → //tools/schema_to_rust (Rust, typify-backed)

//go:                         Go output
  - jsonschema_go_library rule
  - default toolchain → //tools/schema_to_go (Go, uses go/format)

//starlark:                   Bazel rule (.bzl) output
  - jsonschema_starlark_codegen rule
  - default toolchain → //tools/schema_to_starlark (Rust)

//util/write_source_files.bzl committed-codegen helper rule
//runtime/helpers.bzl         shared Starlark helpers loaded by emitted .bzl

Adding a new output language is:

  1. Write a plugin binary in that language (it gets to leverage native AST tooling — go/format, quote/syn, ts-morph).
  2. Register a jsonschema_codegen_toolchain pointing at it.
  3. Add a jsonschema_<lang>_library user-facing rule that wraps the target language’s *_library Bazel rule.

The plugin contract

A plugin is any executable that conforms to:

INPUT
  stdin              schema bytes (JSON)
  argv               --key=value pairs

OUTPUT
  stdout             generated file content (single file per invocation)
  stderr             diagnostics

EXIT
  0                  success
  non-zero           failure

Standard argv flags every plugin receives: --schema-name=NAME (schema basename) and --rule-name=NAME (Bazel target name). Rule-specific flags (e.g. --kind=..., --package=...) are passed through by the calling rule.

A 15-line Python plugin is a real plugin:

import json, sys
schema = json.load(sys.stdin.buffer)
# ... generate ...
sys.stdout.write(generated)

See jsonschema/plugin_contract.md for the authoritative spec.

What ships

  • jsonschema_rust_library (in rust/defs.bzl) — Rust struct/enum bindings via the default typify-backed plugin. Emits #[derive(Serialize, Deserialize, Clone, Debug)] plus #[serde(deny_unknown_fields)] where the schema sets additionalProperties: false.
  • jsonschema_go_library (in go/defs.bzl) — Go struct bindings. Optional properties become *T with ,omitempty; required become value types. Default plugin uses go/format for canonical layout.
  • jsonschema_starlark_codegen (in starlark/defs.bzl) — typed Bazel rule() + provider() definitions, one per requested schema definition. Output is committed via write_source_files + gated with diff_test.
  • jsonschema_codegen_toolchain (in jsonschema/toolchains.bzl) — wrap your own plugin binary as a Bazel toolchain to override defaults.
  • jsonschema_plugin_contract_test (in jsonschema/contract_test.bzl) — runs contract scenarios (valid input, malformed input, unknown flag, determinism) against any plugin binary. Plugin authors gate toolchain registration with it.
  • write_source_files (in util/write_source_files.bzl) — the canonical “copy generated outputs back into source” rule. Replaces hand-rolled sh_binary + update.sh patterns.
  • @rules_jsonschema//runtime:helpers.bzlstrip_empty and parse_json_or_none, called by jsonschema_starlark_codegen’s emitted rule impls.

rules_docker_compose is the production consumer.

Install

.bazelrc:

common --registry=https://registry.fastverk.com/
common --registry=https://bcr.bazel.build/

MODULE.bazel:

bazel_dep(name = "rules_jsonschema", version = "0.1.0")

rules_rust, rules_go, a Rust toolchain (1.88+), crates_universe, and a Go SDK are pulled in transitively. Default Rust + Go + Starlark toolchains are registered automatically.

Per-language rules

jsonschema_rust_library

load("@rules_jsonschema//rust:defs.bzl", "jsonschema_rust_library")

jsonschema_rust_library(
    name = "person_types",
    schema = "person.schema.json",
    # When the consumer's binary depends on serde from its own
    # crates_universe, thread the labels through so trait identities
    # match. See "Two crates_universe instances" below.
    serde      = "@my_crates//:serde",
    serde_json = "@my_crates//:serde_json",
    regress    = "@my_crates//:regress",
    # Optional per-plugin flags — empty for the default plugin.
    extra_args = ["--my-custom-flag=value"],
)

jsonschema_go_library

load("@rules_jsonschema//go:defs.bzl", "jsonschema_go_library")

jsonschema_go_library(
    name = "person_types",
    schema = "person.schema.json",
    importpath = "github.com/myorg/person_types",
    package = "person_types",
)

jsonschema_starlark_codegen

load("@rules_jsonschema//starlark:defs.bzl", "jsonschema_starlark_codegen")
load("@rules_jsonschema//util:write_source_files.bzl", "write_source_files")
load("@bazel_skylib//rules:diff_test.bzl", "diff_test")

jsonschema_starlark_codegen(
    name = "compose_rules_gen",
    schema = "compose-spec.json",
    kinds = [
        # (id, schema-pointer, rule_name, provider_name)
        ("service", "#/definitions/service",
         "docker_compose_service", "ComposeServiceInfo"),
        ("volume", "#/definitions/volume",
         "docker_compose_volume",  "ComposeVolumeInfo"),
    ],
)

diff_test(
    name = "compose_rules_up_to_date",
    file1 = "compose_rules.bzl",
    file2 = ":compose_rules_gen",
)

write_source_files(
    name = "update_compose_rules",
    files = {"compose_rules.bzl": ":compose_rules_gen"},
)

The committed compose_rules.bzl is loaded normally. The diff_test gates freshness; bazel run :update_compose_rules refreshes.

Property → attr mapping (Starlark codegen)

SchemaBazel attr
type: "string"attr.string
enum of stringsattr.string(values=...)
type: "integer" / "number"attr.int
type: "boolean"attr.bool
Array of strings (incl. oneOf [string, object] short-form)attr.string_list
Object with string-valued props (incl. patternProperties)attr.string_dict
$ref to string_or_list / list_of_strings / commandattr.string_list
$ref to list_or_dictattr.string_dict
type: [...] multi-type unionspreference: bool > string > int
Everything elseattr.string() taking JSON-encoded text

The full mapping logic lives in tools/schema_to_starlark/src/classifier.rs; each classifier (classify_named_ref, classify_one_of, classify_enum, classify_type_union, classify_single_type) is independently unit-tested.

Swapping a plugin

Each language’s default toolchain points at rules_jsonschema’s in-repo binary. To use a different plugin, register your own toolchain ahead of ours:

# Your MODULE.bazel
register_toolchains(
    "//your/path:my_custom_rust_codegen_toolchain",
)
# Your BUILD.bazel
load("@rules_jsonschema//jsonschema:toolchains.bzl", "jsonschema_codegen_toolchain")

jsonschema_codegen_toolchain(
    name = "my_custom_rust_codegen",
    binary = "//path/to:your_binary",  # any executable conforming to plugin_contract.md
)

toolchain(
    name = "my_custom_rust_codegen_toolchain",
    toolchain = ":my_custom_rust_codegen",
    toolchain_type = "@rules_jsonschema//jsonschema:rust_codegen_toolchain_type",
)

Gate your plugin with the conformance test:

load("@rules_jsonschema//jsonschema:contract_test.bzl",
     "jsonschema_plugin_contract_test")

jsonschema_plugin_contract_test(
    name = "my_plugin_conforms",
    plugin = "//path/to:your_binary",
)

Two crates_universe instances

When both rules_jsonschema (host) and a consumer module use their own crates_universe extensions, the same crate (serde, regress) gets compiled twice. Rust treats the two compilations as distinct types: the generated Service: Serialize trait impl lives in the host’s serde, while the consumer’s code references its own serde. The bound fails with error[E0277].

jsonschema_rust_library’s optional serde / serde_json / regress attrs thread the consumer’s crates through so the generated library compiles against the same serde the consumer links. Defaults point at rules_jsonschema’s own @crates, which works for within-repo callers but not for downstream consumers — explicit threading is required there.

Compatibility

  • Bazel: 7.4+, bzlmod required (tested on 9.1).
  • Rust: 1.88+ (transitive deps need stabilised let-chains).
  • Go: 1.23+ (default SDK pinned in MODULE.bazel).
  • JSON Schema: Draft 2020-12, with the typify-supported subset (refs, oneOf, allOf, enum, additionalProperties, patternProperties).

Testing

bazel test //...
TargetCoverage
//tools/schema_to_starlark:schema_to_starlark_test35 Rust unit tests on classifier + emission helpers
//tools/schema_to_go:schema_to_go_test8 Go unit tests on type mapping + name munging
//tools/schema_to_rust:schema_to_rust_conformsplugin contract conformance
//tools/schema_to_starlark:schema_to_starlark_conformsplugin contract conformance
//tools/schema_to_go:schema_to_go_conformsplugin contract conformance
//examples/smoke:person_types_testRust types decode/encode round-trip
//examples/smoke:person_go_types_testGo types decode/encode round-trip
//examples/smoke:person_rules_up_to_dateStarlark codegen output stays in sync

Design

The architecture pivot from “Rust-binary-per-output-language” to the current contract-based plugin model is captured in docs/RFC-001-codegen-plugin-protocol.md (with the commit history showing the design iterations the RFC went through).

License

MIT.

Usage#

Real usage, taken from the module’s examples/.

examples/smoke/BUILD.bazel

load("@bazel_skylib//rules:diff_test.bzl", "diff_test")
load("@rules_go//go:def.bzl", "go_test")
load("@rules_jsonschema//go:defs.bzl", "jsonschema_go_library")
load("@rules_jsonschema//rust:defs.bzl", "jsonschema_rust_library")
load("@rules_jsonschema//starlark:defs.bzl", "jsonschema_starlark_codegen")
load("@rules_rust//rust:defs.bzl", "rust_test")

# Generate a `person` rust_library from the JSON Schema. Consumers
# `deps = [":person_types"]` and get typed Person / Address structs.
jsonschema_rust_library(
    name = "person_types",
    schema = "person.json",
)

# Round-trip test: a deny_unknown_fields decode + re-serialize through
# the generated types proves the typegen pipeline produces working
# code. The test owns its own input fixture inline so it has nothing
# to do with the schema beyond the type definitions.
rust_test(
    name = "person_types_test",
    srcs = ["roundtrip_test.rs"],
    edition = "2021",
    deps = [
        ":person_types",
        "@crates//:serde_json",
    ],
)

# Regenerate the typed Starlark rules for `person` and `address` from
# the same schema. The output is committed (person_rules.bzl), the
# diff_test below catches drift.
jsonschema_starlark_codegen(
    name = "person_rules_gen",
    kinds = [
        ("person", "#", "smoke_person", "PersonInfo"),
        ("address", "#/$defs/address", "smoke_address", "AddressInfo"),
    ],
    schema = "person.json",
)

diff_test(
    name = "person_rules_up_to_date",
    file1 = "person_rules.bzl",
    file2 = ":person_rules_gen",
)

# Second-language proof for the plugin contract: same schema → typed
# Go bindings via a plugin written in Go (uses go/format for output).
# Round-trip test asserts the generated types decode the same JSON the
# Rust version does.
jsonschema_go_library(
    name = "person_go_types",
    importpath = "github.com/fastverk/rules_jsonschema/examples/smoke/person_go_types",
    package = "person_go_types",
    schema = "person.json",
)

go_test(
    name = "person_go_types_test",
    srcs = ["roundtrip_go_test.go"],
    embed = [":person_go_types"],
)

Rules & providers#

Generated with Stardoc from the module's .bzl sources.

from docs/RFC-001-codegen-plugin-protocol.md

RFC-001 — Codegen Plugin Protocol

Status: draft, revised. Captures the architecture pivot from “Rust-binary-per-output-language” to “per-language plugins reading the schema directly via a minimal stdin/stdout contract”.

Earlier drafts of this RFC proposed a protoc-style architecture with a frontend, a parsed AST proto, and a dual ast / raw plugin mode. That design was abandoned because (a) JSON Schema is already JSON — every plugin language can parse it directly — and (b) most realistic plugins wrap upstream tools (typify, atombender/go-jsonschema, oapi-codegen, …) that have their own parsing anyway. The AST was a small spec language we’d be inventing for marginal benefit. See “Why we abandoned the AST” below for the full reasoning.

Goal

Decouple rules_jsonschema’s user-facing rules from a hardcoded codegen language. After this RFC lands, adding a new output language is:

  1. Write a plugin binary in that language so it leverages native AST tooling — go/format for Go, quote/syn for Rust, ts-morph for TypeScript.
  2. Register a jsonschema_codegen_toolchain pointing at it.
  3. Add a jsonschema_<lang>_library user-facing rule that wraps the target language’s *_library Bazel rule.

The plugin reads the schema bytes from stdin, options from argv, writes the generated file content to stdout, and signals errors via stderr + exit code. No protobuf dep, no AST proto, no frontend binary. Stdlib-only plugins are achievable in any language.

The contract

A plugin is any executable that conforms to:

INPUT
  stdin              the schema file contents (raw bytes)
  argv               --key=value pairs, repeated. Plugin-specific.
                     The rule may also pass standard flags it owns.

OUTPUT
  stdout             the generated file content (raw bytes)
  stderr             diagnostics / error messages

EXIT
  0                  success — stdout is the generated file
  non-zero           failure — stderr explains why

That’s it. A plugin in Go is:

package main

import (
    "encoding/json"
    "io"
    "os"
)

func main() {
    schemaBytes, _ := io.ReadAll(os.Stdin)
    var schema map[string]any
    if err := json.Unmarshal(schemaBytes, &schema); err != nil {
        fmt.Fprintln(os.Stderr, "parse:", err)
        os.Exit(1)
    }
    // ... generate Go source from schema ...
    os.Stdout.Write([]byte(generated))
}

A plugin in Rust is the same thing with serde_json. A plugin in Python wraps json.load(sys.stdin.buffer). There is no contract- specific dep in any language.

Standard argv conventions

The rule passes a fixed set of flags every plugin receives, plus whatever the consumer set in options:

FlagSet byMeaning
--schema-name=NAMEruleOriginal schema file basename (e.g. compose-spec.json). For error messages and stable codegen header comments.
--rule-name=NAMEruleThe Bazel target’s name. Useful for picking output identifiers.
--<consumer-flag>=VALconsumerFree-form per-plugin options from the rule attrs.

Plugins should treat unknown flags as a hard error so misconfigured options don’t silently degrade output.

Bazel output declaration

Bazel rules must declare their outputs at analysis time, before any action runs. Three real options were considered:

ApproachProsCons
A. Single file per rule invocationOutput path known at analysis. Simple. Matches protoc-gen-go in practice.Plugin authors can’t naturally split output.
B. declare_directory (tree artifact)Plugin emits arbitrarily many files.Downstream rust_library / go_library rules have to glob the directory or expand it. Awkward, non-standard.
C. Two-pass: pre-flight + emitPlugin advertises outputs given a schema, then generates.Two plugin invocations per build. Doubles action overhead.

Decision: A. Plugin produces exactly one file (on stdout) per rule invocation. Multi-output needs (types vs validators, client vs server) split into separate rule targets:

jsonschema_go_types(name = "person_types", schema = "person.json")
jsonschema_go_validators(name = "person_validators", schema = "person.json")

Each target is independently cacheable; the build graph is clearer. Tree artifacts (B) remain available as an escape hatch for the rare genuinely-multi-file plugin.

Bazel rule shape

Each per-language user-facing rule has the same structure:

def _jsonschema_rust_codegen_impl(ctx):
    out = ctx.actions.declare_file(ctx.label.name + ".rs")
    tc = ctx.toolchains[_RUST_TOOLCHAIN].codegen_info

    args = [
        "--schema-name=" + ctx.file.schema.basename,
        "--rule-name=" + ctx.label.name,
    ]
    # Plugin-specific options passed through from rule attrs.
    for k, v in ctx.attr.options.items():
        args.append("--{}={}".format(k, v))

    ctx.actions.run_shell(
        inputs = [ctx.file.schema],
        outputs = [out],
        tools = [tc.binary],
        command = '{plugin} {args} < {schema} > {out}'.format(
            plugin = tc.binary.path,
            args = " ".join([shell.quote(a) for a in args]),
            schema = ctx.file.schema.path,
            out = out.path,
        ),
    )
    return [DefaultInfo(files = depset([out]))]

User-facing macro composes that codegen with the target language’s library rule:

def jsonschema_rust_library(name, schema, **kwargs):
    gen_name = name + "_rs_gen"
    _jsonschema_rust_codegen(name = gen_name, schema = schema)
    rust_library(
        name = name,
        srcs = [":" + gen_name],
        edition = "2021",
        deps = [...],
        **kwargs
    )

Same shape per language.

Why we abandoned the AST

The first draft of this RFC proposed a protoc-style architecture: a frontend parses the schema into a canonical AST proto, plugins consume that AST instead of raw bytes. After looking at it harder I think this was the wrong call. Reasons:

  1. The protoc analogy doesn’t transfer. protoc has an AST because .proto files have a grammar nobody else has implemented. Plugin authors would otherwise re-implement parsing. JSON Schema is already JSON — every plugin language has a JSON parser in stdlib or one-line dep. The “no plugin reparses” argument is ~free to ignore for us.

  2. Most plugins wrap upstream tools. typify, atombender/go-jsonschema, oapi-codegen, openapi-generator all take raw schema bytes and have their own parsing. Our AST would be throwaway work for them. The dual mode = "ast" | "raw" we briefly proposed was evidence the AST wasn’t the natural fit.

  3. Cross-plugin consistency was illusory. Different upstream tools interpret edge cases differently (recursive refs, allOf ordering, oneOf discriminator behavior). Putting an AST in front doesn’t unify them — each wrapping plugin still defers to its underlying library.

  4. Maintenance cost is real. Defining Schema / Type / UnionType / IntersectionType is a small spec language we invent and ship. Every JSON Schema feature we don’t model becomes an extra_json escape hatch. We’d end up maintaining a parallel type system that nothing consumes natively.

  5. Plugin author ergonomics matter. “Read stdin, write stdout” is the lowest possible barrier to entry. A Bash script could be a plugin. Adding “deserialise a protobuf request” pushes plugin authors into language-specific toolchain setup before they write the first line of codegen logic.

The toolchain pattern (toolchain types per output language, register your own plugin to override) survives the simplification unchanged.

Why we also abandoned the proto envelope

Even without an AST, we considered keeping a thin proto wrapper: CodeGenRequest{raw_schema, options, version} in, CodeGenResponse{file, error, features} out. Forward-compat without the AST baggage.

The argument against:

  • The structured-options part is the only piece of the proto that isn’t trivially expressible as stdin/argv/stderr/exit-code. argv handles structured options fine.
  • For ~5 plugins over the foreseeable future, “add a field without breaking old plugins” isn’t load-bearing; we can coordinate.
  • Plugin author barrier matters more than abstract evolvability. A one-file Python plugin (15 lines) beats a Rust plugin with protobuf codegen deps for any reasonable measure.
  • We can always add a proto envelope later if we hit a real wall. Migrating plugins is straightforward — only the stdin-parsing changes, the codegen logic doesn’t.

Open questions

  1. Stable JSON Schema spec-version handling. Plugins should probably refuse to operate on schemas whose $schema doesn’t match what they expect. Convention: plugins error with --schema-name=… : unsupported $schema: <value> rather than producing wrong output. Each plugin owns its own version detection.

  2. Cross-plugin shared parsing. If we ever need it (we don’t yet), a future RFC could add an optional sidecar artifact: the rule runs a one-time jsonschema_parse action that emits a normalised JSON form, and plugins opt into reading that instead of the original schema. Backward compatible — old plugins still consume raw.

  3. Diagnostic format. stderr is freeform today. If we ever want structured diagnostics (file:line:col annotations), we’d define a stderr-line format like WARNING:path:line:col:msg. Not v1.

  4. Toolchain attr surface. Currently the toolchain rule just carries binary. Future fields might include: supported_drafts (list of $schema values), default_options (dict), version (for diagnostic banners). All additive.

Decisions to lock in before Phase 1

  1. Plugin contract: stdin = schema bytes, argv = options, stdout = generated file content, stderr + exit code for errors. No proto, no AST.
  2. Bazel outputs: single file per rule invocation. Multi-output needs split into separate targets. Tree-artifact escape hatch for genuine many-file plugins.
  3. Plugin discovery: toolchain types per output language (already in place).
  4. Repo naming: stay rules_jsonschema.

Phases

Phase 1: nail down the contract in code

  • //jsonschema:plugin_contract.md (or similar) — a concise written spec of stdin/argv/stdout/stderr the contract docs reference.
  • Refit the existing Rust + Starlark codegen binaries to the new contract. schema_to_rust already mostly does this (it reads a path from --schema); switch to stdin and the standard argv flags.
  • Update //rust:defs.bzl and //starlark:defs.bzl to invoke plugins via the contract.
  • Existing rules_docker_compose tests should pass byte-identical.

Phase 2: Go plugin (in Go)

  • tools/plugin_go/main.go reads schema bytes from stdin, parses via encoding/json, emits Go types using go/format. Uses rules_go.
  • //go:defs.bzl with jsonschema_go_library.
  • Smoke example: person.json → Go types → round-trip decode test.

This validates the cross-language contract works as cleanly as the RFC claims. If implementing the Go plugin is harder than the “15 lines” pitch, the contract needs tightening.

Phase 3: contract testing

A small integration-test rule that runs an arbitrary plugin against a curated set of “interesting” schemas (compose-spec subset, edge cases, malformed input) and asserts on stdout/stderr/exit behavior. Lets plugin authors verify conformance before registering as a toolchain.

Phase 4: rules_docker_compose migration

Should be a no-op end-user-visibly — the codegen binaries still exist, just invoked through the new contract. Tests pass byte-identical.

from docs/contract_test.md

Plugin conformance test.

jsonschema_plugin_contract_test(name, plugin) runs the contract test driver against any executable that claims to implement the rules_jsonschema plugin contract (see plugin_contract.md). The driver exercises:

  1. Minimum-viable invocation produces non-empty stdout + exit 0.
  2. Malformed JSON input → non-zero exit, stderr explanation, empty stdout (the discipline most likely to be violated by plugins emitting partial output before erroring).
  3. Unknown flags are rejected.
  4. Output is deterministic across identical invocations.

Plugin authors use it to gate their toolchain registration:

load("@rules_jsonschema//jsonschema:contract_test.bzl",
     "jsonschema_plugin_contract_test")

jsonschema_plugin_contract_test(
    name = "my_plugin_conforms",
    plugin = "//my:rust_codegen",
)

jsonschema_plugin_contract_test

load("@rules_jsonschema//jsonschema:contract_test.bzl", "jsonschema_plugin_contract_test")

jsonschema_plugin_contract_test(name, plugin)

Run the rules_jsonschema plugin contract scenarios against a plugin binary.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
pluginThe plugin binary to test. Any executable that claims to implement the rules_jsonschema plugin contract.Labelrequired

from docs/go_defs.md

Go user-facing rules for rules_jsonschema.

jsonschema_go_library is the Go-specific shape of the schema → code pipeline:

  1. Resolves the go_codegen_toolchain_type toolchain.
  2. Runs the toolchain’s binary on the schema (stdin/argv/stdout per //jsonschema/plugin_contract.md), producing a .go file.
  3. Wraps the .go in a go_library from @rules_go.

The default toolchain (registered by rules_jsonschema’s MODULE.bazel) points at the in-repo schema_to_go Go binary. Coverage is minimal — primitives, structs, slices, maps, optional pointers, refs. For fuller JSON-Schema-to-Go support, register your own jsonschema_codegen_toolchain pointing at a different binary (e.g. atombender/go-jsonschema).

jsonschema_go_library

load("@rules_jsonschema//go:defs.bzl", "jsonschema_go_library")

jsonschema_go_library(name, schema, importpath, package, extra_args, visibility,
                      **go_library_kwargs)

Generate a go_library of typed schema bindings.

The emitted package exports one Go type per schema $defs / definitions entry plus a top-level type from the schema’s title (if set). Required properties become value-typed fields; optional properties become pointer-typed with ,omitempty tags.

PARAMETERS

NameDescriptionDefault Value
namego_library target name. Consumers add to deps.none
schemalabel of a .json schema file.none
importpathGo import path for the generated package.none
packageGo package name. Defaults to a sanitised rule name.None
extra_argsextra --key=value flags appended to the plugin’s argv. Use to set plugin-specific options without registering a new toolchain.None
visibilityforwarded to go_library.None
go_library_kwargsforwarded to go_library.none

from docs/helpers.md

Helpers used by schema_to_starlark-generated rule code.

Kept in a separate file (rather than inlined per generated .bzl) so the codegen output stays small and any helper fix benefits every consumer at once. Generated .bzl files load from this module:

load("@rules_jsonschema//runtime:helpers.bzl", "strip_empty", "parse_json_or_none")

parse_json_or_none

load("@rules_jsonschema//runtime:helpers.bzl", "parse_json_or_none")

parse_json_or_none(s)

Return None for empty input, otherwise json.decode(s).

Used for typed schema attrs whose value is a structured object or array. Generated rule callers pass json.encode({...}) (or leave the attr empty); the generated impl invokes this to expand the encoded payload back into a Starlark dict/list that gets merged into the shard.

PARAMETERS

NameDescriptionDefault Value
s

-

none

strip_empty

load("@rules_jsonschema//runtime:helpers.bzl", "strip_empty")

strip_empty(d)

Drop dict entries whose values are absent / zero / empty.

⛔ Do not use this where a false is meaningful: it cannot express one, and the failure is silent. See [strip_unset].

Matches the JSON omitempty convention so generated shards stay terse — Bazel attr.* zero values (0, False, "", [], {}) shouldn’t serialise as explicit overrides. Distinguishing “user set to 0” from “user didn’t set” isn’t possible at the Starlark layer, so we conflate them: every typed schema field that wants to mean something non-default ships a non-zero/-empty value.

⚠ That last sentence is FALSE for anything routed through [parse_json_or_none], which already returns None for an unset attr — so None and False were distinguishable all along. It is retained because it documents what this function still does.

PARAMETERS

NameDescriptionDefault Value
dthe property payload to filter.none

RETURNS

d without absent, zero or empty entries.

DEPRECATED

Generated code now emits [strip_unset], which drops only values the caller never set. This is kept because previously generated .bzl loads it by name, and because dropping []/{} is defensible for hand-written callers.

strip_unset

load("@rules_jsonschema//runtime:helpers.bzl", "strip_unset")

strip_unset(d)

Drop dict entries the caller never set — and ONLY those.

⛔ THE DIFFERENCE FROM [strip_empty] IS A CORRECTNESS ONE, NOT A STYLE ONE. strip_empty also drops False, 0, [] and {}, which makes an explicitly requested false indistinguishable from an omission. That fails OPEN whenever the schema’s own default is truthy: AWS::EKS::Cluster’s ResourcesVpcConfig.EndpointPublicAccess defaults true, so “private endpoint only” is exactly the shape that silently renders as a public endpoint. See tomato-bazel/rules_cloudformation#2, where it was measured.

⭐ AND THE INFORMATION WAS NEVER ACTUALLY LOST. Every generated attr is a STRING; a bool, int, list or dict can only appear in the payload because [parse_json_or_none] decoded one — and that function already returns None for an unset attr. So None means “not set” and False means “set to false”, and they were distinguishable all along. strip_empty’s docstring says the opposite; that claim is wrong for anything routed through parse_json_or_none.

"" is still dropped, and that one IS genuinely ambiguous: an unset attr.string and one set to the empty string are the same value at this layer. Expressing an intentional empty string needs a sentinel default at codegen, which is a larger change than this.

PARAMETERS

NameDescriptionDefault Value
dthe property payload to filter.none

RETURNS

d without the entries the caller never set.

from docs/providers.md

Providers exposed by rules_jsonschema.

JsonschemaCodegenToolchainInfo is the contract every codegen toolchain provides: a single binary File that implements the schema → output-language conversion. Per-language user-facing rules resolve a toolchain by type (@rules_jsonschema//jsonschema:<lang>_codegen_toolchain_type), fetch this provider, and run the binary.

Splitting it out from defs.bzl lets language modules (//rust:, //starlark:, //go:, …) load just the provider without dragging in language-specific BUILD machinery.

JsonschemaCodegenToolchainInfo

load("@rules_jsonschema//jsonschema:providers.bzl", "JsonschemaCodegenToolchainInfo")

JsonschemaCodegenToolchainInfo(binary)

A schema → code codegen tool.

FIELDS

NameDescription
binaryFile: the codegen executable. Invoked with --schema PATH --out PATH and any language-specific flags the calling rule passes through.

from docs/rust_defs.md

Rust user-facing rules for rules_jsonschema.

jsonschema_rust_library is the Rust-specific shape of the schema → code pipeline:

  1. Resolves the rust_codegen_toolchain_type toolchain.
  2. Runs the toolchain’s binary on the schema, producing a .rs.
  3. Wraps the .rs in a rust_library with serde / serde_json / regress threaded as direct deps.

The default toolchain (registered by rules_jsonschema’s MODULE.bazel) points at the in-repo typify-based schema_to_rust binary. Swap by declaring your own jsonschema_codegen_toolchain + registering it ahead of the default.

jsonschema_rust_library

load("@rules_jsonschema//rust:defs.bzl", "jsonschema_rust_library")

jsonschema_rust_library(name, schema, extra_args, serde, serde_json, regress, visibility,
                        **rust_library_kwargs)

Generate a rust_library of typed schema bindings.

The emitted library exports one Rust struct/enum per top-level JSON-Schema definition, with #[derive(Serialize, Deserialize)] plus #[serde(deny_unknown_fields)] wherever the source schema sets additionalProperties: false.

PARAMETERS

NameDescriptionDefault Value
namerust_library target name. Consumers add this to deps.none
schemalabel of a .json schema file.none
extra_argsextra --key=value flags appended to the plugin’s argv. Use to set plugin-specific options without registering a new toolchain. The default plugin (schema_to_rust) accepts no extra flags today; consumers of custom toolchains will.None
serdelabel of the serde crate to use as a direct dep. Defaults to rules_jsonschema’s own @crates//:serde. Consumers whose binary also depends on serde must point this at their own crate repo, otherwise the generated types’ trait impls live in a different compile unit than the consumer’s and Rust treats them as distinct types (error[E0277]: the trait bound Service: serde::Serialize is not satisfied).None
serde_jsonsame story for serde_json.None
regresssame story for regress (typify uses it for pattern-validated string newtypes).None
visibilityforwarded to rust_library.None
rust_library_kwargsforwarded to rust_library (e.g. extra deps).none

from docs/starlark_defs.md

Starlark user-facing rule for rules_jsonschema.

jsonschema_starlark_codegen emits typed Bazel rule() definitions from a JSON Schema:

  1. Resolves the starlark_codegen_toolchain_type toolchain.
  2. Runs the toolchain’s binary on the schema, producing a .bzl.

The default toolchain (registered by rules_jsonschema’s MODULE.bazel) points at the in-repo schema_to_starlark binary. Swap by declaring your own jsonschema_codegen_toolchain and registering it ahead of the default.

The output is meant to be committed in the consumer repo; pair with a diff_test to catch drift (re-runs codegen on every CI build and asserts the committed .bzl matches what the toolchain emits).

jsonschema_starlark_codegen

load("@rules_jsonschema//starlark:defs.bzl", "jsonschema_starlark_codegen")

jsonschema_starlark_codegen(name, schema, kinds, extra_args, **kwargs)

Generate a .bzl of typed rules from a JSON Schema.

PARAMETERS

NameDescriptionDefault Value
nametarget name; output file is <name>.bzl.none
schemalabel of a .json schema document.none
kindslist of (id, pointer, rule_name, provider_name) 4-tuples. - id: short tag used in generated symbol names + the rule-name attr (e.g. service). - pointer: JSON-pointer into the schema for the definition whose properties become attrs (e.g. #/definitions/service). - rule_name: the public Starlark symbol the emitted rule binds to. - provider_name: the public Starlark symbol the rule’s companion provider binds to. Optional — if omitted, extra_args typically enables the plugin’s auto-kinds derivation (e.g. --kinds-pointer-base=... for the default schema_to_starlark toolchain). Leaving both empty produces a preamble-only .bzl (legal but rarely useful).None
extra_argsextra --key=value flags appended to the plugin’s argv. Use to set plugin-specific options without registering a new toolchain.None
kwargsforwarded to the underlying rule (visibility, etc.).none

from docs/toolchains.md

Toolchain rules for rules_jsonschema codegen.

jsonschema_codegen_toolchain wraps a single codegen executable (schema_to_rust, schema_to_starlark, schema_to_go, …) as a Bazel toolchain. The matching toolchain_type lives in //jsonschema:BUILD.bazel — one type per output language so a consumer can independently swap, say, the Rust generator without touching the Starlark or Go ones.

Default toolchains are registered in //rust:BUILD.bazel, //starlark:BUILD.bazel, //go:BUILD.bazel. To swap an implementation, declare your own jsonschema_codegen_toolchain and register_toolchains(...) it ahead of rules_jsonschema’s default in your MODULE.bazel.

jsonschema_codegen_toolchain

load("@rules_jsonschema//jsonschema:toolchains.bzl", "jsonschema_codegen_toolchain")

jsonschema_codegen_toolchain(name, binary)

Declare a schema → code codegen executable as a Bazel toolchain.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
binaryThe codegen executable for this toolchain. Must accept --schema PATH --out PATH plus any language-specific flags.Labelrequired

from docs/write_source_files.md

write_source_files: copy generated outputs back into source.

The canonical Bazel pattern for committed-codegen workflows. A typical setup pairs a codegen rule (whose output sits under bazel-bin/...) with a write_source_files target that copies the output to a path under source control:

jsonschema_starlark_codegen(
    name = "compose_rules_gen",
    schema = "...",
    kinds = [...],
)

write_source_files(
    name = "update_compose_rules",
    files = {
        "compose_rules.bzl": ":compose_rules_gen",
    },
)
  • bazel build //compose:update_compose_rules — no-op.
  • bazel run //compose:update_compose_rules — copies each generated file to its source-tree destination, respecting BUILD_WORKSPACE_DIRECTORY so multi-repo workspaces still work.

Pair with a diff_test to gate freshness:

diff_test(
    name = "compose_rules_up_to_date",
    file1 = "compose_rules.bzl",
    file2 = ":compose_rules_gen",
)

This rule replaces ad-hoc sh_binary + update.sh pairs throughout rules_jsonschema’s consumers. Functionally equivalent to @aspect_bazel_lib//lib:write_source_files.bzl, but in-repo so we don’t take on aspect_bazel_lib as a dep for a single rule.

write_source_files

load("@rules_jsonschema//util:write_source_files.bzl", "write_source_files")

write_source_files(name, files)

bazel run-able target that copies generated files back into source control.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
filesMap of package-relative destination path → label whose single output file should be copied there. Each source label must produce exactly one output file.Dictionary: String -> Labelrequired

Conformance#

5 findings across 2 invariants. 10 contested atoms. See how gating works or the full report.

D2 a non-dev register_toolchains propagates to every transitive consumer why this matters ↗
versiontoolchain
0.4.0//go:default_go_codegen_toolchain
0.4.0//rust:default_rust_codegen_toolchain
0.4.0//starlark:default_starlark_codegen_toolchain
0.4.0@rust_toolchains//:all
D3 a repo name CHOSEN on a SHARED extension must be namespaced why this matters ↗
repoextension
crates@rules_rust//crate_universe:extension.bzl

Contested atoms

Third-party modules where this module resolves a different version than others do. Not a violation of anything this module did — it is the actionable form of a registry-level convergence finding, and the sentence a maintainer can act on.

AtomResolved hereElsewhere
apple_support 1.24.2 2.2.0 ×1
bazel_skylib 1.8.2 1.9.0 ×2
gazelle 0.36.0 0.30.0 ×50.44.0 ×10.51.0 ×3
nlohmann_json 3.6.1 3.12.0.bcr.1 ×1
protobuf 33.4 34.0.bcr.1 ×2
rules_go 0.60.0 0.39.1 ×5
rules_jvm_external 6.7 6.8 ×4
rules_python 1.7.0 2.0.1 ×1
rules_swift 3.1.2 3.6.1 ×1
upb 0.0.0-20220923-a547704 0.0.0-20230516-61a97ef ×1

Dependencies#

rules_jsonschema in the registry graph — what it depends on (left) and what depends on it (right).

Depends on

platforms1.0.0bazel_skylib1.8.2rules_rust0.70.0rules_go0.60.0rules_shell0.6.1devstardoc0.7.2dev

Used by (8 in the registry)

Versions#

4 published versions, newest first. Each resolves to an immutable, integrity-checked archive.

VersionIntegrity (sha256)Source archive
0.4.0 latest +E7j4uc3UnxihzK7… tag archive ↗
0.3.0 GfZ/XEwG+MRa3wPi… tag archive ↗
0.2.0 QAW7aKbrqW8PjiSl… tag archive ↗
0.1.0 BhEBdJsV4qx210EX… tag archive ↗

Changelog#

All notable changes to rules_jsonschema. The format is loosely Keep a Changelog — version headers mirror the published bazel-registry entries.

0.3.0 — auto-kinds for schema_to_starlark

  • schema_to_starlark: synthesize --kind entries from a schema location instead of hand-enumerating them. Six new flags: --kinds-pointer-base=POINTER (required to enable), --kinds-pointer-suffix=SUFFIX, --kinds-key-filter=REGEX, --id-template=TPL, --rule-name-template=TPL, --provider-name-template=TPL. Templates expand {key}, {snake}, {camel}. Motivated by large schemas (AWS CloudFormation: ~1200 resource types) where hand-enumerating every kind is impractical.
  • jsonschema_starlark_codegen macro: kinds is now optional; callers that drive entirely through auto-kinds via extra_args may omit it.
  • jsonschema/plugin_contract.md: documents the new jsonschema_starlark_codegen-forwarded flags.

Backwards-compatible — explicit --kind= continues to work unchanged; auto-kinds are opt-in.

0.2.0 — docs + CI infrastructure

  • Stardoc-generated reference docs for all 8 public-API .bzl files in docs/. bazel run //docs:update regenerates; bazel test //docs:all gates the committed copies via diff_test.
  • GitHub Actions CI: bazel test //... on ubuntu + macos, plus a buildifier lint job.
  • CHANGELOG.md (this file).
  • .gitignore: .claude/ and MODULE.bazel.lock.

No API changes.

0.1.0 — initial release

  • Language-neutral codegen plugin contract documented in jsonschema/plugin_contract.md — stdin = JSON Schema bytes, argv = --key=value, stdout = generated source, stderr + exit code for errors. Single-file output per rule invocation.
  • Toolchain types per (language, use-case): rust_typegen_toolchain_type, starlark_codegen_toolchain_type, go_typegen_toolchain_type.
  • JsonschemaCodegenToolchainInfo provider + jsonschema_codegen_toolchain rule to register plugins.
  • Default in-repo plugins:
    • tools/schema_to_rust — wraps typify to emit serde-typed Rust.
    • tools/schema_to_starlark — emits Bazel attr.* declarations from object schemas (35 unit tests).
    • tools/schema_to_go — Go plugin using go/format (8 unit tests).
  • User-facing rules: jsonschema_rust_library, jsonschema_starlark_codegen, jsonschema_go_library.
  • openapi_plugin_contract_test conformance driver.
  • util/write_source_files Starlark helper for the generate-then-commit workflow (replaces hand-rolled .sh scripts).
  • Runtime helpers in runtime/helpers.bzl shared with downstream rules_* repos.

← All modules