Skip to content

zs-dima/prost-protovalidate

Repository files navigation

prost-protovalidate

Crates.io docs.rs License MSRV

Validation for Protocol Buffer messages using buf.validate rules — a runtime engine built on prost-reflect, plus a compile-time validation path via prost-protovalidate-build targeting both prost and buffa message types.

By default, prost-protovalidate evaluates buf.validate constraints (including Common Expression Language / CEL expressions) at runtime through prost-reflect, enforcing rules directly from your single source of truth — your .proto files. For messages with standard (non-CEL) rules, prost-protovalidate-build generates impl Validate at compile time so validation runs through monomorphized direct field access — the fastest path at runtime: no reflection, no CEL interpreter on the hot path. On the buffa backend, CEL-bearing messages can additionally be routed through the runtime engine (runtime_bridge), giving buffa types the full buf.validate rule surface from one library.

Key Features

  • Compile-time validation (fastest path at runtime)prost-protovalidate-build emits impl Validate for messages with standard-only rules; no prost-reflect transcoding, no CEL interpreter, monomorphized direct field access. Combined with default-features = false on prost-protovalidate, the entire cel / chrono / pastey / thiserror 1.x subtree drops out of your build. Trades binary size and build time for hot-path speed; messages with CEL automatically fall back to the runtime Validator (with a cargo:warning= diagnostic, never silently skipped).
  • Runtime validation with CEL — Leverages prost-reflect to dynamically inspect message fields and evaluate complex validation rules, including arbitrary CEL expressions, at runtime.
  • CEL Evaluation — Fully supports compiling and evaluating Common Expression Language (CEL) conditions for cross-field or complex constraints.
  • Proto as single source of truth — Define validation rules inside .proto files using buf.validate and enforce them automatically in Rust.
  • One library for prost and buffa — the same buf.validate rules, the same violation output, whichever protobuf runtime generates your types. Select Builder::backend(Backend::Buffa) for buffa (buffa-build, or a wrapper such as connectrpc-build).
  • Two engines, one rule source — the runtime Validator and the compile-time generator both consume the canonical rule ids/messages in prost-protovalidate-types::rules_meta, so they cannot drift; a descriptor-driven parity sweep asserts identical violations across both engines.
  • Harness-verified conformance — the runtime engine passes the full upstream protovalidate conformance suite: 2872/2872 tests (protovalidate v1.2.2, 0 expected failures).
  • Fail-closed or full-coverage routingfail_on_runtime_only(true) turns any rule the generator cannot cover into a hard build error (for services that ship no runtime validator); on buffa, runtime_bridge(true) instead delegates those messages to the embedded runtime engine for full CEL coverage.
  • Three footprint tiers — full (cel, default) ⊃ reflect (runtime Validator without CEL) ⊃ slim (default-features = false; build-time validation only, no prost-reflect in the graph).
  • gRPC integration — optional tonic feature maps ValidationError to tonic::Status, and tonic-types attaches google.rpc.BadRequest field-level details.
  • Edition 2023 support — Normalizes Edition 2023 descriptors (including DELIMITED group encoding) so prost-reflect 0.16 handles them correctly.
  • Modular Crates — Clear separation between raw protobuf types (prost-protovalidate-types) and the runtime validation engine (prost-protovalidate).

Crates

Crate Purpose Cargo section
prost-protovalidate-types buf.validate proto types with prost and prost-reflect support; hosts rules_meta (canonical rule ids/messages for both engines) [dependencies]
prost-protovalidate Runtime validation engine (CEL parsing, constraint evaluation) [dependencies]
prost-protovalidate-build Compile-time code generator for validation [build-dependencies]

Quick Start

Add the dependencies to your Cargo.toml:

[dependencies]
prost = "0.14"
prost-protovalidate = "0.6"

Feature Flags

Feature Default Description
cel Yes CEL expression evaluation and chrono time support (implies reflect)
reflect Yes (via cel) Runtime reflection: the descriptor-driven Validator, validation filters, Violation rule-path hydration, and the bridge module used by runtime_bridge generated code
tonic No gRPC integration: From<ValidationError> for tonic::Status and a ValidateRequest extension trait (req.validate_inner()? in handlers)
tonic-types No Implies tonic. Attaches google.rpc.BadRequest details to validation-failure statuses so clients can parse field-level errors without scraping the message string

To keep the runtime Validator but disable CEL (removes cel, chrono, pastey, and thiserror 1.x transitive deps):

[dependencies]
prost-protovalidate = { version = "0.6", default-features = false, features = ["reflect"] }

Without cel, standard rules (range checks, string constraints, format validators, etc.) work normally. Messages with CEL expressions or predefined CEL rules will produce a CompilationError at validation time.

For build-time-only validation (generated impl Validate from prost-protovalidate-build), drop features entirely — the slim build keeps only the Validate trait, Violation/ValidationError, and the validators helpers, with no prost-reflect in the dependency graph:

[dependencies]
prost-protovalidate = { version = "0.6", default-features = false }

Usage

Annotate your .proto files with buf.validate rules:

syntax = "proto3";

import "buf/validate/validate.proto";

message CreateUserRequest {
  string name = 1 [(buf.validate.field).string.min_len = 1];
  int32 age = 2 [(buf.validate.field).int32.gte = 18];
}

In your Rust code, run the validator against a generated prost message using the provided validate shortcut:

use prost_protovalidate::validate;

let request = CreateUserRequest {
    name: "Alice".to_string(),
    age: 25,
};

match validate(&request) {
    Ok(_) => println!("Validation passed!"),
    Err(e) => println!("Validation failed: {}", e),
}

Or cache a Validator instance if you need to perform multiple validations efficiently:

use prost_protovalidate::Validator;

// Creating a Validator parses and caches rules under the hood
let validator = Validator::new();
validator.validate(&request)?;

Compatibility

prost-protovalidate prost prost-reflect MSRV
0.6.x 0.14 0.16 1.86
0.5.x 0.14 0.16 1.86
0.4.x 0.14 0.16 1.86

Validation Modes

Three validation modes cover different use cases:

Compile-time validation (fastest at runtime, no CEL)

For messages with only standard rules (no CEL). Validators are generated at compile time and run through monomorphized direct field access — no prost-reflect transcoding, no CEL interpreter, no dynamic dispatch on the hot path. Combined with default-features = false on prost-protovalidate, the entire cel / chrono / pastey / thiserror 1.x subtree drops out of your build. Requires prost-protovalidate-build in [build-dependencies].

Generated code targets prost message types by default; select Builder::backend(Backend::Buffa) when your types come from buffa (buffa-build or a wrapper such as connectrpc-build). With Builder::fail_on_runtime_only(true), any message the generator cannot cover fails the build instead of falling back — for consumers that ship no runtime validator at all.

On the buffa backend, Builder::runtime_bridge(true) is the third option: messages the generator cannot cover (CEL rules, unsupported shapes) receive a generated impl Validate that encodes the message to wire bytes and validates it through an embedded runtime engine — so every message gets a uniform msg.validate(), standard rules inline and everything else through the same conformance-tested Validator. The routed messages require prost-protovalidate with reflect/cel enabled and run at interpreter speed; keep fail_on_runtime_only when you want the slim, CEL-free guarantee instead.

use prost_protovalidate::Validate;

msg.validate()?;

Runtime validation with CEL (full conformance)

For messages with CEL expressions or mixed rules. Uses prost-reflect dynamic dispatch and the CEL interpreter at runtime.

use prost_protovalidate::Validator;

let validator = Validator::new();
validator.validate(&msg)?;

Runtime validation without CEL (lightweight)

For services that don't use CEL. Disabling the cel feature removes cel, chrono, pastey, and transitive thiserror 1.x deps. Pairs naturally with compile-time validators above for a CEL-free dependency tree end to end.

[dependencies]
prost-protovalidate = { version = "0.4", default-features = false }

Standard rules work normally; messages with CEL rules produce a CompilationError.

Which mode for which message

Proto rules Validate trait generated? How to validate
Standard only (min_len, gte, email, etc.) Yes msg.validate() (compile-time, fastest)
Time-relative timestamp (lt_now, gt_now, within) Yes (reads SystemTime::now()) msg.validate() (compile-time, fastest)
repeated.unique on float/double Yes (canonical IEEE-754 bits) msg.validate() (compile-time, fastest)
CEL only (expressions) No¹ validator.validate(&msg) (runtime)
Mixed (standard + CEL) No¹ validator.validate(&msg) (runtime)
Nested runtime-only dependencies No¹ validator.validate(&msg) (runtime)
No rules No

¹ On the buffa backend with runtime_bridge(true), these messages do get a generated impl Validate that delegates to the embedded runtime engine, so msg.validate() works uniformly across the whole schema.

For messages with time-relative timestamp rules, the generated Validate::validate(&self) impl reads SystemTime::now() once per call — identical to the runtime Validator's default behaviour. Tests that need a deterministic clock must use the runtime Validator and override now_fn on ValidatorOptions; the trait signature has no place to inject a clock source.

gRPC integration (tonic)

Enable the tonic feature to map validation errors to gRPC statuses:

[dependencies]
prost-protovalidate = { version = "0.6", features = ["tonic"] }
tonic = "0.14"

In your service handler:

use prost_protovalidate::tonic::ValidateRequest;

#[tonic::async_trait]
impl my_proto::greeter_server::Greeter for GreeterImpl {
    async fn say_hello(
        &self,
        req: tonic::Request<my_proto::HelloRequest>,
    ) -> Result<tonic::Response<my_proto::HelloReply>, tonic::Status> {
        req.validate_inner()?;
        // ... handler body ...
    }
}

ValidateRequest is implemented for unary and server-streaming handlers (tonic::Request<T>). For client-streaming and bidirectional handlers (tonic::Request<tonic::Streaming<T>>), validate each message inside the per-message loop: msg.validate().map_err(tonic::Status::from)?.

ValidationError maps to Code::InvalidArgument. CompilationError / RuntimeError (and the unified Error::Compilation / Error::Runtime variants) map to Code::Internal with a fixed, generic message — the underlying cause strings can contain proto field names and CEL internals that should not be exposed to untrusted clients. Log the original error server-side before converting if you need the full cause.

Enabling the additional tonic-types feature attaches a google.rpc.BadRequest detail to validation-failure statuses, with one FieldViolation per violation, so clients can parse field-level errors programmatically:

[dependencies]
prost-protovalidate = { version = "0.6", features = ["tonic-types"] }

Build-Time Code Generation

Add prost-protovalidate-build to your build dependencies:

[dependencies]
prost = "0.14"
prost-protovalidate = "0.6"

[build-dependencies]
prost-build = "0.14"
prost-protovalidate-build = "0.6"

In your build.rs:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let descriptor_path = std::path::PathBuf::from(std::env::var("OUT_DIR")?)
        .join("file_descriptor_set.bin");

    prost_build::Config::new()
        .file_descriptor_set_path(&descriptor_path)
        .compile_protos(&["proto/service.proto"], &["proto/"])?;

    prost_protovalidate_build::Builder::new()
        .file_descriptor_set_path(&descriptor_path)?
        .compile()?;

    Ok(())
}

Include the generated validators alongside your prost-generated code:

include!(concat!(env!("OUT_DIR"), "/validate_impl.rs"));

Messages the generator cannot cover (CEL or predefined CEL rules, and messages that transitively depend on runtime-only nested validation) are routed by one of three modes:

  • Default — skipped with a cargo:warning explaining why; validate them with the runtime Validator.
  • fail_on_runtime_only(true) — the build fails instead, so a rule can never be silently dropped (for consumers that ship no runtime validator).
  • runtime_bridge(true) (buffa backend) — a generated impl Validate delegates them to an embedded runtime engine, giving the whole schema a uniform msg.validate() including full CEL support.

Development

Building from source requires protoc (the prost-protovalidate-types build script compiles the vendored buf.validate schema). Install it via your package manager or set the PROTOC environment variable to the binary path.

make pre-commit     # fmt-check + lint + test (run before committing)
make conformance    # conformance suite (requires Go)

Conformance

Full conformance with the bufbuild protovalidate test suite (v1.2.2): 2872/2872 tests pass (0 expected failures).

Conformance uses a pinned upstream harness from github.com/bufbuild/protovalidate/tools/protovalidate-conformance.

Pinned versions are defined in the repository root Makefile:

  • PROTOVALIDATE_TOOLS_VERSION
  • PROTOVALIDATE_SCHEMA_REF

Upgrade playbook

  1. Bump PROTOVALIDATE_TOOLS_VERSION and PROTOVALIDATE_SCHEMA_REF in Makefile.
  2. Sync validate.proto using the workflow in crates/prost-protovalidate-types/SYNC.md.
  3. Run conformance and update expected_failures.yaml with any new failures:
    • make conformance
  4. Run regression tests:
    • cargo test --all-features
  5. Commit version bump and expected_failures.yaml changes together.

License

MIT OR Apache-2.0

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages