proto-build-kit 0.1.0

Generic build-helper primitives for proto-source services: stage embedded .proto bytes to a protoc-relative tempdir, compile via protox, extract MethodOptions extension values, and drive tonic-prost-build with type_attribute injection.
Documentation

proto-build-kit

crates.io docs.rs CI License: MIT

The build-script glue every proto-source Rust project ends up rewriting — staging embedded .proto bytes onto a protoc include path, compiling them via protox, reading custom MethodOptions extension values that FileDescriptorSet encoding drops, and driving tonic-prost-build with annotation-driven type_attribute(...) injection. Schema-agnostic; ~200 lines of focused infrastructure.

What's in the box

Primitive What it does
Stager Accumulate (relative_path, &'static [u8]) pairs; write them to a fresh tempdir at protoc-relative paths. Duplicate-path detection. The bridge between sibling *-protos byte-crates and protoc/buf/protox consumers.
compile_protos protox wrapper that returns BOTH the prost-reflect descriptor pool (preserves MethodOptions extension VALUES — the FDS-encode path drops them) AND the FDS bytes ready for tonic-build / prost-build / custom codegen.
extract_method_string_extension Walk every method in a descriptor pool, read a string-typed MethodOptions extension by FQN, return a map keyed by the response-message FQN. The generic version of "find (my.envelope.etag_field) = "..." annotations and tell me which response types declared them."
tonic_prost_build_with_attrs Drive tonic-prost-build codegen with a per-type-FQN attribute map (gated on the tonic feature, default-on). Inject #[derive(...)] and arbitrary #[my_attr(...)] from values you extracted via the previous primitive.

Quick start — typical consumer build.rs

use proto_build_kit::Stager;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Stage canonical proto shapes shipped by upstream crates onto a
    // protoc-relative tempdir. Each *-protos crate exposes its bytes
    // through a `files()` iterator (zero dependencies of their own).
    let staged = Stager::new()
        .with(api_bones_protos::files())          // bones/v1/*
        .with(my_internal_protos::files())        // my/internal/v1/*
        .stage()?;

    // Drive your codegen of choice. Here: connectrpc-build.
    connectrpc_build::Config::new()
        .files(&["proto/myservice/v1/svc.proto"])
        .includes(&["proto/", staged.path()])
        .include_file("_connectrpc.rs")
        .compile()?;
    Ok(())
}

Hold the returned tempfile::TempDir until codegen completes — drop deletes the staged files.

The *-protos crate pattern

A proto-publishing repo ships a tiny sibling crate (typically ~30–80 lines) that embeds its .proto bytes and exposes one accessor:

// myservice-protos/src/lib.rs

const HELLO: &[u8] = include_bytes!("../proto/myservice/v1/hello.proto");
const TYPES: &[u8] = include_bytes!("../proto/myservice/v1/types.proto");

pub fn files() -> impl Iterator<Item = (&'static str, &'static [u8])> {
    [
        ("myservice/v1/hello.proto", HELLO),
        ("myservice/v1/types.proto", TYPES),
    ]
    .into_iter()
}

The *-protos crate has zero runtime dependencies — not even proto-build-kit. It just exposes the bytes. Consumers compose any number of *-protos crates via Stager:

# use proto_build_kit::Stager;
let staged = Stager::new()
    .with(a_protos::files())
    .with(b_protos::files())
    .with(c_protos::files())
    .stage()?;
# Ok::<(), Box<dyn std::error::Error>>(())

Stager::stage() errors loudly on duplicate relative paths — protects against silent shadowing when two *-protos crates collide.

Annotation-driven codegen

For services that derive Rust attributes from custom proto annotations (envelope semantics, validation rules, audit hooks):

use proto_build_kit::{compile_protos, extract_method_string_extension,
                      tonic_prost_build_with_attrs, Stager};
use std::collections::BTreeMap;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let staged = Stager::new()
        .with(my_protos::files())                  // your schema
        .with(my_annotations_protos::files())      // your option declarations
        .stage()?;

    let out = compile_protos(
        &["proto/my/v1/svc.proto"],
        &["proto/", staged.path()],
    )?;

    // For each method declaring `(my.opts.etag_field) = "<field>"`,
    // map the response-message FQN to the field name.
    let etag_fields =
        extract_method_string_extension(&out.pool, "my.opts.etag_field");

    // Build a per-type-FQN attribute injection map.
    let mut attrs: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for (response_fqn, etag) in etag_fields {
        attrs.entry(response_fqn).or_default().push(format!(
            "#[derive(::my_crate::Envelope)] #[envelope(etag = \"{etag}\")]"
        ));
    }

    // Drive tonic-prost-build with the injection map applied.
    tonic_prost_build_with_attrs(&out.fds_bytes, &attrs, |b| {
        b.build_server(true).build_client(true)
    })?;
    Ok(())
}

Runnable examples

# Stage two embedded .protos and print compiled descriptor info:
cargo run --example stage_compile

# Extract custom MethodOptions annotations from a fake service:
cargo run --example extract_annotations

See examples/ in the repo for the source.

Features

  • tonic (default-on) — enables tonic_prost_build_with_attrs. Disable if you drive your own codegen (connectrpc-build, prost-build, hand-rolled) and only need stage / compile / extract:

    [build-dependencies]
    proto-build-kit = { version = "0.1", default-features = false }
    

When to use this — and when not to

Use proto-build-kit when:

  • You publish or consume .proto files across multiple Rust crates and want to avoid git submodule / hand-vendored .proto copies.
  • You read custom MethodOptions extension values at build time (envelope semantics, validation hints, audit metadata, retry policies).
  • You drive tonic-prost-build or connectrpc-build from a build script and find yourself rewriting the protox → descriptor-pool → type_attribute plumbing.

Don't use it when:

  • You're a single-crate proto consumer with one .proto file. Just call tonic-build directly.
  • You don't have custom MethodOptions extensions to read at build time.
  • You want a buf-CLI-driven workflow. Pair with buf generate from a Makefile instead.

Minimum Supported Rust Version (MSRV)

Rust 1.88+.

Versioning

Pre-1.0: minor versions may include API breakage with migration notes in CHANGELOG. Post-1.0: standard semver.

License

MIT.

Contributing

Issues and PRs welcome. The crate aims to stay small (~200 lines) and unopinionated — new primitives need to clear a "every proto-build script reimplements this" bar before getting added.