Skip to content

AnyVali Rust SDK Reference

The Rust SDK provides a native, idiomatic API for schema validation with full portable interchange support. Schemas are defined using builder functions, validated against serde_json::Value inputs, and can be exported to or imported from AnyVali's canonical JSON format.

Installation

Add the dependency to your Cargo.toml:

[dependencies]
anyvali = "0.1"
serde_json = "1"

Quick Start

use anyvali::*;
use serde_json::json;

fn main() {
    // Define a schema
    let user_schema = object()
        .field("id", Box::new(int64()))
        .field("name", Box::new(string().min_length(1).max_length(100)))
        .field("email", Box::new(string().format("email")))
        .field("age", Box::new(optional(Box::new(int().min(0.0).max(150.0)))))
        .field("role", Box::new(enum_(vec![
            json!("admin"), json!("user"), json!("guest"),
        ])))
        .required(vec!["id", "name", "email", "role"]);

    // Parse input (returns Result)
    let input = json!({
        "id": 42,
        "name": "Alice",
        "email": "alice@example.com",
        "role": "user"
    });

    match user_schema.parse(&input) {
        Ok(user) => println!("Valid: {}", user),
        Err(e) => {
            for issue in &e.issues {
                println!("[{}] {} at {:?}", issue.code, issue.expected, issue.path);
            }
        }
    }

    // SafeParse returns a ParseResult struct
    let bad_input = json!({
        "id": "not-a-number",
        "name": "",
        "role": "user"
    });

    let result = user_schema.safe_parse(&bad_input);
    if !result.success {
        for issue in &result.issues {
            let path: Vec<String> = issue.path.iter().map(|p| p.to_string()).collect();
            println!("{}: [{}]", path.join("."), issue.code);
        }
    }
}

Type Inference

Rust's type system allows the SDK to provide compile-time type safety through the TypedSchema trait and the parse_as helper function.

TypedSchema Trait

Concrete schema types implement TypedSchema with an associated Output type:

use anyvali::*;
use serde_json::json;

let schema = string().min_length(1);

// parse_typed returns Result<String, ValidationError> -- no cast needed
let name: String = schema.parse_typed(&json!("Alice")).unwrap();

let int_schema = int().min(0.0);
let age: i64 = int_schema.parse_typed(&json!(25)).unwrap();

let num_schema = number().min(0.0);
let score: f64 = num_schema.parse_typed(&json!(98.5)).unwrap();

let bool_schema = bool_();
let active: bool = bool_schema.parse_typed(&json!(true)).unwrap();

Built-in TypedSchema implementations:

Schema Output Type
StringSchema String
BoolSchema bool
NumberSchema f64
IntSchema i64
NullSchema ()
AnySchema serde_json::Value
UnknownSchema serde_json::Value
ArraySchema Vec<serde_json::Value>

parse_as for Dynamic Schemas

When working with dyn Schema trait objects (e.g., after import), use parse_as:

use anyvali::*;
use serde_json::json;
use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct User {
    name: String,
    email: String,
}

let schema = object()
    .field("name", Box::new(string()))
    .field("email", Box::new(string().format("email")))
    .required(vec!["name", "email"]);

let input = json!({"name": "Alice", "email": "alice@test.com"});

// Deserialize validated output directly to a Rust struct
let user: User = parse_as(&schema, &input).unwrap();
println!("{:?}", user);

parse_as<T> works with any type that implements serde::de::DeserializeOwned. It first validates through the schema, then deserializes the resulting Value into T.

Schema Types

Primitives

use anyvali::*;

// String
let s = string();

// Boolean (note trailing underscore to avoid keyword conflict)
let b = bool_();

// Null -- accepts only null
let n = null();

Numeric Types

use anyvali::*;

// Number (float64, the safe cross-language default)
let num = number();

// Explicit float widths
let f64_schema = float64();
let f32_schema = float32();   // enforces float32 range

// Signed integers
let i   = int();       // int64
let i8  = int8();      // int8 range
let i16 = int16();     // int16 range
let i32_schema = int32();     // int32 range
let i64_schema = int64();     // int64 range

// Unsigned integers
let u8_schema  = uint8();     // uint8 range
let u16_schema = uint16();    // uint16 range
let u32_schema = uint32();    // uint32 range
let u64_schema = uint64();    // uint64 range

number() and float64() both produce a NumberSchema. int() and int64() both produce an IntSchema with int64 range. Narrower widths enforce their range automatically.

Special Types

use anyvali::*;

// Any -- accepts any value, passes it through
let a = any();

// Unknown -- accepts any value (semantically "not yet validated")
let u = unknown();

// Never -- rejects all values
let n = never();

Literal and Enum

use anyvali::*;
use serde_json::json;

// Literal -- matches a single exact value
let lit = literal(json!("active"));
let lit2 = literal(json!(42));
let lit3 = literal(json!(true));

// Enum -- matches one of several allowed values
let role = enum_(vec![json!("admin"), json!("user"), json!("guest")]);
let status = enum_(vec![json!(1), json!(2), json!(3)]);

Array and Tuple

use anyvali::*;

// Array -- all items must match the given schema
let tags = array(Box::new(string()));

// Array with length constraints
let scores = array(Box::new(number())).min_items(1).max_items(100);

// Tuple -- fixed-length array with per-position schemas
let pair = tuple(vec![Box::new(string()), Box::new(int())]);

Object

Objects use a builder pattern with .field() and .required():

use anyvali::*;
use serde_json::json;

// All fields listed in required() are required; others are optional
let user = object()
    .field("name", Box::new(string().min_length(1)))
    .field("email", Box::new(string().format("email")))
    .field("age", Box::new(int().min(0.0)))
    .required(vec!["name", "email"]);

// Field with a default value
let config = object()
    .field("host", Box::new(string()))
    .field_with_default("port", Box::new(int()), json!(8080))
    .required(vec!["host"]);

Record

use anyvali::*;

// Record -- validates a map where all values match a schema
let headers = record(Box::new(string()));

// Accepts: {"Content-Type": "text/html", "Accept": "application/json"}

Composition

use anyvali::*;

// Union -- value must match at least one variant
let str_or_num = union(vec![Box::new(string()), Box::new(number())]);

// Intersection -- value must match all schemas
let base = object()
    .field("id", Box::new(int64()))
    .required(vec!["id"]);
let named = object()
    .field("name", Box::new(string()))
    .required(vec!["name"]);
let entity = intersection(vec![Box::new(base), Box::new(named)]);

Modifiers

use anyvali::*;
use serde_json::json;

// Optional -- absent/null values are accepted
let opt = optional(Box::new(string()));

// Nullable -- null values are accepted
let nul = nullable(Box::new(string()));

// Ref -- reference to a named definition (for recursion)
let r = ref_("#/definitions/Node");

Constraints

String Constraints

let s = string()
    .min_length(1)           // minimum byte length
    .max_length(255)         // maximum byte length
    .pattern(r"^\w+$")      // regex pattern
    .starts_with("hello")   // must start with prefix
    .ends_with(".com")      // must end with suffix
    .includes("@")          // must contain substring
    .format("email");       // built-in format validator

Supported format values: "email", "url", "uuid", "ipv4", "ipv6", "date", "date-time".

Numeric Constraints

All numeric schemas (number, float64, float32, int variants) accept f64 constraint values:

// Float schemas
let price = number()
    .min(0.0)                // value >= 0
    .max(999.99)             // value <= 999.99
    .exclusive_min(0.0)      // value > 0
    .exclusive_max(1000.0)   // value < 1000
    .multiple_of(0.01);      // must be a multiple of 0.01

// Int schemas also use f64 for constraint values
let age = int()
    .min(0.0)                // value >= 0
    .max(150.0)              // value <= 150
    .exclusive_min(-1.0)     // value > -1
    .exclusive_max(200.0)    // value < 200
    .multiple_of(1.0);       // must be a whole number multiple of 1

Array Constraints

let items = array(Box::new(string()))
    .min_items(1)            // at least 1 item
    .max_items(50);          // at most 50 items

Object Unknown Key Handling

The unknown_keys option controls how keys not declared in the schema are handled:

Mode Behavior
UnknownKeyMode::Reject (default) Produces an unknown_key issue for each extra key
UnknownKeyMode::Strip Silently removes extra keys from the output
UnknownKeyMode::Allow Passes extra keys through to the output
use anyvali::*;

// Reject unknown keys (default)
let strict = object()
    .field("name", Box::new(string()))
    .required(vec!["name"])
    .unknown_keys(UnknownKeyMode::Reject);

// Strip unknown keys silently
let stripped = object()
    .field("name", Box::new(string()))
    .required(vec!["name"])
    .unknown_keys(UnknownKeyMode::Strip);

// Allow unknown keys to pass through
let loose = object()
    .field("name", Box::new(string()))
    .required(vec!["name"])
    .unknown_keys(UnknownKeyMode::Allow);

Coercion and Defaults

Coercion

Coercions transform values before validation. They are specified as string identifiers.

use anyvali::*;
use serde_json::json;

// Coerce strings before validation
let name = string().coerce(vec!["trim".to_string()]);
let tag = string().coerce(vec!["lower".to_string()]);
let code = string().coerce(vec!["upper".to_string()]);

// Multiple coercions in sequence
let normalized = string()
    .coerce(vec!["trim".to_string(), "lower".to_string()]);

Available coercion strings:

Coercion Effect
"trim" Trim whitespace from string
"lower" Lowercase string
"upper" Uppercase string
"string->number" Parse string to number
"string->int" Parse string to integer

Defaults

Defaults fill in missing (absent) values. They run after coercion and before validation. Call .default(value) on any schema, passing a serde_json::Value. The default only applies when the value is absent -- if a value is present, it is validated normally.

Defaults must be static values (for portability across SDKs). For computed defaults, see the Common Patterns section below.

use anyvali::*;
use serde_json::json;

let role = enum_(vec![json!("admin"), json!("user")])
    .default(json!("user"));

let active = bool_().default(json!(true));
let name = string().default(json!("Anonymous"));

// Object fields with defaults
let config = object()
    .field("host", Box::new(string()))
    .field_with_default("port", Box::new(int()), json!(8080))
    .field_with_default("tls", Box::new(bool_()), json!(false))
    .required(vec!["host"]);

If the default value itself fails validation, a default_invalid issue is produced.

Export and Import

Export

Convert a schema to the portable AnyVali JSON document:

use anyvali::*;
use std::collections::HashMap;

let schema = object()
    .field("name", Box::new(string().min_length(1)))
    .field("email", Box::new(string().format("email")))
    .required(vec!["name", "email"]);

// Export to AnyValiDocument
let definitions: HashMap<String, Box<dyn Schema>> = HashMap::new();
let doc = export(&schema, ExportMode::Portable, &definitions).unwrap();

// Serialize to JSON string
let json_str = serde_json::to_string_pretty(&doc).unwrap();
println!("{}", json_str);

Output:

{
  "anyvaliVersion": "1.0",
  "schemaVersion": "1",
  "root": {
    "kind": "object",
    "properties": {
      "name": { "kind": "string", "minLength": 1 },
      "email": { "kind": "string", "format": "email" }
    },
    "required": ["name", "email"],
    "unknownKeys": "reject"
  },
  "definitions": {},
  "extensions": {}
}

Export modes:

Mode Variant Behavior
Portable ExportMode::Portable Fails if schema uses non-portable features
Extended ExportMode::Extended Includes language-specific extensions

Import

Reconstruct a schema from a JSON document:

use anyvali::*;
use serde_json::json;

// Import from JSON string
let json_str = r#"{
    "anyvaliVersion": "1.0",
    "schemaVersion": "1",
    "root": { "kind": "string", "minLength": 1 },
    "definitions": {},
    "extensions": {}
}"#;

let (schema, ctx) = import(json_str).unwrap();

// Import from serde_json::Value
let doc_value = json!({
    "anyvaliVersion": "1.0",
    "schemaVersion": "1",
    "root": { "kind": "int", "min": 0 },
    "definitions": {},
    "extensions": {}
});
let (schema, ctx) = import_value(&doc_value).unwrap();

// Use the imported schema -- pass ctx for ref resolution
let result = schema.parse_with_context(&json!(42), &ctx).unwrap();

The import functions return a tuple of (Box<dyn Schema>, ParseContext). The ParseContext carries resolved definitions needed for ref schemas. Use parse_with_context or safe_parse_with_context when working with imported schemas that may contain references.

Error Handling

parse (returning Result)

parse returns Result<Value, ValidationError>:

use anyvali::*;
use serde_json::json;

let schema = int().min(0.0);

match schema.parse(&json!(-5)) {
    Ok(value) => println!("Valid: {}", value),
    Err(e) => {
        println!("Validation failed with {} issue(s)", e.issues.len());
        for issue in &e.issues {
            let path: Vec<String> = issue.path.iter().map(|p| p.to_string()).collect();
            println!(
                "  [{}] expected={}, received={} at {}",
                issue.code, issue.expected, issue.received,
                path.join(".")
            );
        }
    }
}

safe_parse (returning ParseResult)

safe_parse returns a ParseResult struct:

use anyvali::*;
use serde_json::json;

let schema = string().format("email");

let result = schema.safe_parse(&json!("not-an-email"));
if result.success {
    println!("Valid: {:?}", result.value);
} else {
    for issue in &result.issues {
        println!("[{}] {}", issue.code, issue.expected);
    }
}

ValidationIssue

Each issue has a consistent structure:

pub struct ValidationIssue {
    pub code: String,              // machine-readable issue code
    pub path: Vec<PathSegment>,    // location in the input
    pub expected: String,          // what was expected
    pub received: String,          // what was received
    pub meta: Option<Value>,       // optional additional metadata
}

pub enum PathSegment {
    Key(String),    // object field key
    Index(usize),   // array index
}

Issue Codes

Constant Code String Meaning
INVALID_TYPE "invalid_type" Value has wrong type
REQUIRED "required" Required field is missing
UNKNOWN_KEY "unknown_key" Object has an undeclared key
TOO_SMALL "too_small" Below minimum (length, value, items)
TOO_LARGE "too_large" Above maximum (length, value, items)
INVALID_STRING "invalid_string" String constraint failed (pattern, format, etc.)
INVALID_NUMBER "invalid_number" Numeric constraint failed (multipleOf, range)
INVALID_LITERAL "invalid_literal" Literal/enum value mismatch
INVALID_UNION "invalid_union" No union variant matched
COERCION_FAILED "coercion_failed" Coercion could not convert the value
DEFAULT_INVALID "default_invalid" Default value fails validation
UNSUPPORTED_SCHEMA_KIND "unsupported_schema_kind" Unknown or unresolved schema kind
UNSUPPORTED_EXTENSION "unsupported_extension" Non-portable extension encountered

Common Patterns

Validating Environment Variables

Use UnknownKeyMode::Strip when parsing objects that contain many extra keys you don't care about, like environment variables:

use anyvali::*;
use std::env;

let env_schema = object()
    .field("DATABASE_URL", Box::new(string()))
    .required(vec!["DATABASE_URL"])
    .unknown_keys(UnknownKeyMode::Strip);

Without Strip, parse would fail with unknown_key issues for every other variable in the environment (PATH, HOME, etc.) because the default mode is Reject.

Eagerly Evaluated vs Lazy Defaults

.default() accepts any serde_json::Value. Expressions like std::env::current_dir() are evaluated immediately when the schema is created and stored as a static value -- this works fine. What AnyVali does not support is lazy closure defaults that re-evaluate on each parse call. If you need a fresh value on every parse, apply it after:

use anyvali::*;
use serde_json::json;

let config_schema = object()
    .field("profile", Box::new(string().default(json!("default"))))
    .field("app_dir", Box::new(optional(Box::new(string()))))
    .required(vec!["profile"])
    .unknown_keys(UnknownKeyMode::Strip);

let mut config = config_schema.parse(&input).unwrap();
if config.get("app_dir").map_or(true, |v| v.is_null()) {
    let cwd = std::env::current_dir().unwrap();
    config["app_dir"] = json!(cwd.to_string_lossy());
}

This keeps the schema fully portable -- the same JSON document can be imported in Go, Python, or any other SDK without relying on language-specific function calls.

API Reference

Builder Functions

Function Returns Description
string() StringSchema String validator
number() NumberSchema Float64 validator (alias: number)
float64() NumberSchema Float64 validator
float32() NumberSchema Float32 validator with range check
int() IntSchema Int64 validator (alias: int)
int8() IntSchema Int8-range validator
int16() IntSchema Int16-range validator
int32() IntSchema Int32-range validator
int64() IntSchema Int64-range validator
uint8() IntSchema Uint8-range validator
uint16() IntSchema Uint16-range validator
uint32() IntSchema Uint32-range validator
uint64() IntSchema Uint64-range validator
bool_() BoolSchema Boolean validator
null() NullSchema Null validator
any() AnySchema Accepts any value
unknown() UnknownSchema Accepts any value (semantically unvalidated)
never() NeverSchema Rejects all values
literal(value) LiteralSchema Exact value match (Value)
enum_(values) EnumSchema One of allowed values (Vec<Value>)
array(items) ArraySchema Homogeneous array (Box<dyn Schema>)
tuple(elements) TupleSchema Fixed-length typed array (Vec<Box<dyn Schema>>)
object() ObjectSchema Structured object (use .field() to add properties)
record(values) RecordSchema String-keyed map with uniform values (Box<dyn Schema>)
union(variants) UnionSchema Matches any one variant (Vec<Box<dyn Schema>>)
intersection(all_of) IntersectionSchema Matches all schemas (Vec<Box<dyn Schema>>)
optional(schema) OptionalSchema Allows absent/null values (Box<dyn Schema>)
nullable(schema) NullableSchema Allows null values (Box<dyn Schema>)
ref_(path) RefSchema Reference to a definition (&str)

Schema Trait

pub trait Schema: SchemaClone + std::fmt::Debug + Send + Sync {
    fn kind(&self) -> &str;
    fn parse_value(&self, input: &Value, path: &[PathSegment], ctx: &ParseContext)
        -> Result<Value, Vec<ValidationIssue>>;
    fn parse(&self, input: &Value) -> Result<Value, ValidationError>;
    fn parse_with_context(&self, input: &Value, ctx: &ParseContext)
        -> Result<Value, ValidationError>;
    fn safe_parse(&self, input: &Value) -> ParseResult;
    fn safe_parse_with_context(&self, input: &Value, ctx: &ParseContext) -> ParseResult;
    fn export_node(&self) -> Value;
    fn has_custom_validators(&self) -> bool;
}

TypedSchema Trait

pub trait TypedSchema: Schema {
    type Output: DeserializeOwned;
    fn parse_typed(&self, input: &Value) -> Result<Self::Output, ValidationError>;
}

Generic Helper

pub fn parse_as<T: DeserializeOwned>(
    schema: &dyn Schema,
    input: &Value,
) -> Result<T, ValidationError>;

Constraint Methods

StringSchema -- all consume self and return Self for chaining:

Method Parameter Description
.min_length(n) usize Minimum byte length
.max_length(n) usize Maximum byte length
.pattern(p) &str Regex pattern
.starts_with(s) &str Required prefix
.ends_with(s) &str Required suffix
.includes(s) &str Required substring
.format(f) &str Built-in format check
.default(v) Value Default value
.coerce(c) Vec<String> Coercion list

NumberSchema -- all consume self and return Self for chaining:

Method Parameter Description
.min(n) f64 Inclusive minimum
.max(n) f64 Inclusive maximum
.exclusive_min(n) f64 Exclusive minimum
.exclusive_max(n) f64 Exclusive maximum
.multiple_of(n) f64 Divisibility constraint
.default(v) Value Default value
.coerce(c) Vec<String> Coercion list

IntSchema -- all consume self and return Self for chaining:

Method Parameter Description
.min(n) f64 Inclusive minimum
.max(n) f64 Inclusive maximum
.exclusive_min(n) f64 Exclusive minimum
.exclusive_max(n) f64 Exclusive maximum
.multiple_of(n) f64 Divisibility constraint
.default(v) Value Default value
.coerce(c) Vec<String> Coercion list

ArraySchema -- all consume self and return Self for chaining:

Method Parameter Description
.min_items(n) usize Minimum item count
.max_items(n) usize Maximum item count

ObjectSchema -- all consume self and return Self for chaining:

Method Parameter Description
.field(name, schema) &str, Box<dyn Schema> Add a property
.field_with_default(name, schema, default) &str, Box<dyn Schema>, Value Add a property with a default value
.required(fields) Vec<&str> Set which fields are required
.unknown_keys(mode) UnknownKeyMode How to handle undeclared keys

Export/Import Functions

pub fn export(
    schema: &dyn Schema,
    mode: ExportMode,
    definitions: &HashMap<String, Box<dyn Schema>>,
) -> Result<AnyValiDocument, String>;

pub fn import(json_str: &str) -> Result<(Box<dyn Schema>, ParseContext), String>;
pub fn import_value(value: &Value) -> Result<(Box<dyn Schema>, ParseContext), String>;

Core Types

pub struct ParseResult {
    pub success: bool,
    pub value: Option<Value>,
    pub issues: Vec<ValidationIssue>,
}

pub struct ValidationIssue {
    pub code: String,
    pub path: Vec<PathSegment>,
    pub expected: String,
    pub received: String,
    pub meta: Option<Value>,
}

pub struct ValidationError {
    pub issues: Vec<ValidationIssue>,
}

pub enum PathSegment {
    Key(String),
    Index(usize),
}

pub struct AnyValiDocument {
    pub anyvali_version: String,
    pub schema_version: String,
    pub root: Value,
    pub definitions: serde_json::Map<String, Value>,
    pub extensions: serde_json::Map<String, Value>,
}

pub enum ExportMode {
    Portable,
    Extended,
}

pub enum UnknownKeyMode {
    Reject,   // default
    Strip,
    Allow,
}

pub struct ParseContext {
    pub definitions: HashMap<String, Box<dyn Schema>>,
}