Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

reflectapi is a library and a toolkit for writing web API services in Rust and generating compatible clients, delivering great development experience and efficiency.

Why reflectapi?

https://rustforgeconf.com/talks

Core Philosophy

  1. Rust code first definition of the API interface
  2. Full respect for all serde attributes
  3. Extensible at many places

Ready to Start?

Head over to Quick Start to build your first API with reflectapi!

Architecture

Overview

ReflectAPI has three layers:

  1. Rust types and handler functions define the API surface.
  2. Reflection builds a Schema, which is the interchange format between the server side and code generators.
  3. Codegen backends transform that schema into language-specific clients or an OpenAPI document.

The workspace is split accordingly:

  • reflectapi-schema: raw schema types and raw-schema transforms
  • reflectapi-derive: #[derive(Input, Output)] macros
  • reflectapi: reflection traits, builder, runtime integrations, codegen backends, and the private compiler schema module
  • reflectapi-cli: CLI wrapper around codegen
  • reflectapi-demo: snapshot and integration tests
  • reflectapi-python-runtime: runtime support for generated Python clients

Reflection Model

Reflection starts from the Input and Output traits in reflectapi/src/traits.rs. Derived implementations and hand-written impls register types into a Typespace and return TypeReferences that point at those definitions.

The top-level Schema in reflectapi-schema/src/lib.rs contains:

  • functions: endpoint definitions
  • input_types: types seen in request positions
  • output_types: types seen in response positions

Input and output types stay separate at schema-construction time so the same Rust name can have different request and response shapes. Some backends later consolidate them into a single naming domain.

Schema and IDs

SymbolId and SymbolKind live in the private reflectapi/src/codegen/schema/symbol.rs module. They are compiler identifiers, not part of the serialized JSON contract.

Key points:

  • raw Schema, Function, and type/member definitions do not store symbol IDs
  • build_schema_ids() in reflectapi/src/codegen/schema/ids.rs assigns IDs in a compiler-owned side table
  • the schema root now uses SymbolKind::Schema
  • the schema root path includes the __schema__ sentinel to avoid colliding with a user-defined type of the same name

That keeps reflectapi.json wire-focused while normalization and semantic analysis still get stable identities.

Type Metadata

Every reflected type is one of:

  • Primitive
  • Struct
  • Enum

Primitive.fallback lets a backend substitute a simpler representation when it does not natively model the original Rust type. Examples in the current codebase include pointer-like wrappers falling back to T, and ordered collections falling back to unordered equivalents or vectors.

Language-specific metadata is carried by LanguageSpecificTypeCodegenConfig in reflectapi-schema/src/codegen.rs:

  • Rust metadata is serialized when present, for example extra derives on generated Rust types.
  • Python type mappings are backend-local in reflectapi/src/codegen/python.rs, not schema annotations.

Response enum policy is shared API metadata: Enum.open retains unfamiliar tags and JSON in generated responses, while Variant.other preserves an explicit Serde unit fallback. Both are omitted from JSON when false, and both are carried into the semantic IR. Reflection records these flags only for outputs, so request types stay closed.

The derive rejects unsupported or conflicting policies. Rust, TypeScript and Python codegen also call the shared validate_response_enums() guard before rendering, including for schemas loaded from JSON. OpenAPI describes the currently declared wire variants; it does not add the generated unknown case.

Normalization

Normalization lives in reflectapi/src/codegen/schema/normalize.rs.

There are two parts:

  1. A mutable normalization pipeline over raw Schema
  2. A Normalizer that converts the resulting schema into SemanticSchema

The configurable pipeline is built with PipelineBuilder. The convenience constructors are:

  • NormalizationPipeline::standard() Runs type consolidation, naming resolution, and circular dependency resolution.
  • NormalizationPipeline::for_codegen() Skips consolidation and naming, and only runs circular dependency resolution.

After the pipeline runs, Normalizer performs:

  1. symbol discovery
  2. type resolution
  3. dependency analysis
  4. a semantic validation hook (additional checks are currently a TODO)
  5. semantic IR construction

SemanticSchema provides resolved, deterministic views of functions and types and is defined in reflectapi/src/codegen/schema/semantic.rs.

Backend Behavior

Backends do not all consume the schema in the same way.

TypeScript

The TypeScript backend in reflectapi/src/codegen/typescript.rs consolidates raw schema types and renders directly from the raw schema.

Rust

The Rust backend in reflectapi/src/codegen/rust.rs also works primarily from the raw schema after consolidation.

Python

The Python backend in reflectapi/src/codegen/python.rs uses both representations:

  1. enum-policy validation and hidden-field filtering run before preprocessing
  2. types are consolidated and flattened generic models are monomorphized
  3. validate_type_references() checks raw references
  4. Normalizer::normalize_with_pipeline(...) builds SemanticSchema using a pipeline that skips consolidation and naming
  5. rendering uses semantic ordering and symbol information, while still consulting raw schema details where the backend needs original field/type shapes

Python-specific type support is driven by backend-local mappings keyed by canonical Rust type name. Those mappings are static codegen knowledge, not part of the shared schema contract.

OpenAPI

The OpenAPI backend in reflectapi/src/codegen/openapi.rs walks the raw schema directly.

Runtime-Specific Types

ReflectAPI includes special API-facing types whose semantics matter to codegen:

  • reflectapi::Option<T>: three-state optional value for PATCH-like APIs
  • reflectapi::Empty: explicit empty request/response body type
  • reflectapi::Infallible: explicit “no error payload” type

The Python backend treats these as runtime-provided abstractions rather than generated models. Generated Python clients use real package modules for reflected namespaces; the flat generated.py file is only a compatibility facade over those modules.

Testing and Validation

reflectapi-demo is the main regression suite.

The combined-type and builder snapshot harnesses in reflectapi-demo/src/tests/assert.rs generate five artifacts:

  • raw schema JSON
  • TypeScript client output
  • Rust client output
  • OpenAPI output
  • Python client output

The workspace also contains compile-pass and compile-fail tests driven by trybuild.

reflectapi-demo/tests/enum_compatibility.rs exports a shared enum schema and Serde-produced fallback samples. Separate Rust, Python and TypeScript suites decode newer wire values through those generated clients. The suite also checks consumer-facing TS and Python types, closed request enums, nested and flattened models, errors, headers where supported, and streaming items.

This architecture chapter is intended to describe the code paths that exist in the repository, not an aspirational future design.

Quick Start

This guide will have you up and running with reflectapi in under 5 minutes.

Prerequisites

  • Rust 1.78.0 or later
  • Basic familiarity with Rust and web APIs

Create a New Project

cargo new my-api
cd my-api

Add Dependencies

Add the dependencies used by this example:

cargo add reflectapi --features builder,axum
cargo add serde --features derive
cargo add serde_json
cargo add tokio --features full
cargo add axum

Define Your API

Replace the contents of src/main.rs:

// This is a complete example for src/main.rs

use reflectapi::{Builder, Input, Output};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Output)]
struct User {
    id: u32,
    name: String,
    email: String,
}

#[derive(Deserialize, Input)]
struct CreateUserRequest {
    name: String,
    email: String,
}

// Handler functions need specific signatures for reflectapi
async fn create_user(_state: (), req: CreateUserRequest, _headers: ()) -> User {
    // In a real app, you'd save to a database
    User { 
        id: 1, 
        name: req.name, 
        email: req.email 
    }
}

async fn get_user(_state: (), id: u32, _headers: ()) -> Option<User> {
    // In a real app, you'd query a database
    if id == 1 {
        Some(User {
            id: 1,
            name: "Alice".to_string(),
            email: "alice@example.com".to_string(),
        })
    } else {
        None
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Build the API schema
    let builder = Builder::new()
        .name("User API")
        .description("A simple user management API")
        .route(create_user, |route| {
            route
                .name("users.create")
                .description("Create a new user")
        })
        .route(get_user, |route| {
            route
                .name("users.get")
                .description("Get a user by ID")
        });

    let (schema, routers) = builder.build()?;
    
    // Save schema for client generation
    let schema_json = serde_json::to_string_pretty(&schema)?;
    std::fs::write("reflectapi.json", schema_json)?;

    println!("✅ API schema generated at reflectapi.json");
    
    // Start the HTTP server
    let app_state = (); // No state needed for this example
    let axum_app = reflectapi::axum::into_router(app_state, routers, |_name, r| r);
    
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    println!("🚀 Server running on http://0.0.0.0:3000");
    println!("📖 Ready to generate clients!");
    
    axum::serve(listener, axum_app).await?;
    
    Ok(())
}

Run Your API Server

User is returned by the server, so it derives Serialize and Output. CreateUserRequest is accepted by the server, so it derives Deserialize and Input. A type used in both directions needs both pairs.

For response enums that may gain variants over time, see enum evolution.

cargo run

You should see:

✅ API schema generated at reflectapi.json
🚀 Server running on http://0.0.0.0:3000
📖 Ready to generate clients!

🎉 Congratulations! You now have a running API server and generated client-ready schema.

Generate a Client

First, install the CLI:

cargo install reflectapi-cli

This installs the reflectapi binary. Then generate a TypeScript client:

mkdir -p clients/typescript
reflectapi codegen --language typescript --schema reflectapi.json --output clients/typescript/

Use Your Generated Client

The generated TypeScript client will be fully typed:

import { client } from "./clients/typescript/generated";

const c = client('http://localhost:3000');

// Create a user. Generated methods take typed input and typed headers.
const created = await c.users.create({
  name: 'Bob',
  email: 'bob@example.com'
}, {});

if (created.is_ok()) {
  console.log(created.unwrap_ok());
}

That's it!

Installation

Get reflectapi up and running in minutes.

Basic Setup

cargo add reflectapi --features builder,axum,json,chrono

CLI Tool

Install the CLI tool to generate client libraries:

cargo install reflectapi-cli

This installs the reflectapi binary.

Next Steps

Attributes Reference

ReflectAPI provides #[reflectapi(...)] attributes that control how Rust types are reflected into the schema and generated clients.

Struct / Enum Level

AttributeDescription
#[reflectapi(derive(...))]Forward additional derive traits to the generated Rust client type.
#[reflectapi(open)]Make a tagged enum open in generated responses: retain unfamiliar tags and raw JSON in an explicit unknown case. Request types stay closed.

Enum evolution

An enum marked #[reflectapi(open)] tells generated clients to accept future response variants while preserving their content:

#[derive(serde::Serialize, reflectapi::Output)]
#[serde(tag = "kind")]
#[reflectapi(open)]
enum PetKind {
    Dog { breed: String },
    Bird,
}

This response-only type needs Serialize to send values and Output to describe them in the schema. Request-only types use Deserialize and Input. Add all four derives only when the same type is used in both directions; an open enum's generated request type still stays closed.

Serde controls the wire representation (kind in this example). ReflectAPI's open attribute controls how generated clients handle future response tags.

A response such as {"kind":"Fox","nocturnal":true} becomes an explicit unknown value. A known Dog missing breed still fails decoding. Rust clients get an Unknown { tag, raw } variant (with a suffix if that variant name is already taken). Python clients get an unknown model with .tag and .raw. TypeScript clients get a branded UnknownEnum, detected with isUnknownEnum; its tag and raw value are available under the exported unknownEnum symbol. The same policy applies to nested models, application errors, and SSE items. See handling unfamiliar enum variants for client-side examples. Rust retains Serde's surrounding container rules: an optional flattened enum can become None when its contents fail validation. Use a required flattened field when invalid content must produce a decoding error.

Open enums support external string/single-key-object tags, internal tags, and adjacent tags. Untagged enums and untagged variants are rejected because an overlapping future shape cannot be reliably distinguished from a known shape. Numeric #[reflectapi(discriminant)] enums and output type overrides cannot be combined with open. Directly flattened open enums must use internal or adjacent tags. Codegen rejects flattened external open enums because a future variant key cannot be distinguished from a newly added parent field. An external open enum used as a regular field inside a flattened struct remains supported. An object with a flattened open or declared-fallback enum cannot flatten another enum. Use named fields for additional enums so each unknown case has its own discriminator, payload, and client representation.

This attribute changes client decoding, not server Serde behavior. Requests use separate closed generated types, and the current wire variants in OpenAPI are unchanged. Existing SDKs need regeneration to acquire the new policy; enabling it does not change already distributed SDKs.

Without open, a declared #[serde(other)] unit variant is preserved in the generated response clients. An unfamiliar valid tag maps to that unit variant, discarding its original tag and content. This is supported for internally and adjacently tagged enums, and for externally tagged enums whose variants are all units, which are plain strings on the wire. Adjacent unit variants accept only absent or null content, and external ones only a bare tag or {"tag": null}, matching Serde. Missing/non-string tags and malformed known variants remain errors for ordinary enum fields. A directly flattened internal fallback also preserves Serde's integer variant identifiers: an index selects the declared variant, or the fallback when it is out of range. Open enums always require string tags.

Declaring both open and #[serde(other)] is rejected. Choose the existing unit fallback when unfamiliar tags should map to that declared variant, or open when clients should retain the unfamiliar tag and raw JSON. Neither policy widens generated request types.

Field Level

Type Override

AttributeDescription
#[reflectapi(type = "T")]Override the reflected type for both input and output schemas.
#[reflectapi(input_type = "T")]Override the reflected type for the input schema only.
#[reflectapi(output_type = "T")]Override the reflected type for the output schema only.

Missing fields and null

Field presence is directional. For a named field value: Option<String> without other Serde attributes, the server accepts a missing request key as None, but serializes a response with "value": null. Generated request types therefore allow omission; generated response types require the key. When these contracts differ, a Rust type used in both directions gets separate input and output models. Tuple elements retain their required positions. Presence inference applies to resolved option wrappers, including Option<T> and standard pointer wrappers; an unresolved generic field value: T keeps its declared contract even if one use substitutes Option<U>.

#[serde(default)] allows a missing input field. A custom deserializer (deserialize_with or with) requires the key unless a default is declared; a custom serializer alone does not change input presence. #[serde(skip_serializing_if = "...")] allows omission on output. A #[serde(flatten)] field has no key of its own, so neither attribute applies to it: its fields are optional as a group exactly when its type is an Option.

For reflectapi::Option<T>, a missing input becomes None without a default; #[serde(default)] instead supplies Undefined. A custom default function supplies whatever that function returns. To preserve all three states on the server, use #[serde(default, skip_serializing_if = "reflectapi::Option::is_undefined")]. Client-side omission and explicit null remain separate wire states.

These rules are resolved before field transforms, so an explicit make_required override remains authoritative. Arbitrary custom codecs may change the wire type; use a reflected type override to describe that type. Previously exported schema JSON keeps its recorded required flags: re-export the schema to obtain the corrected input contract.

Transform

AttributeDescription
#[reflectapi(transform = "path::to::fn")]Apply a field transformation callback for both schemas. Signature: fn(&mut Field, &Typespace).
#[reflectapi(input_transform = "path::to::fn")]Apply a field transformation callback for input only.
#[reflectapi(output_transform = "path::to::fn")]Apply a field transformation callback for output only.

The transform callback receives &mut Field and can modify any field metadata — type, required, hidden, etc. It runs after type references are resolved.

ReflectAPI ships with built-in transforms in the reflectapi::transforms module:

  • reflectapi::transforms::fallback_recursively — unwraps transparent wrappers (e.g. Arc<T> → T)
  • reflectapi::transforms::make_required — makes a field required: Option<T> → required T, reflectapi::Option<T> → required Option<T> (nullable but not omittable). For non-Option types, marks the field as required without changing the type.
  • reflectapi::transforms::make_nonnullable — makes a field non-nullable: Option<T> → T, reflectapi::Option<T> → T (keeps optionality, removes nullability). No-op for non-Option types.
  • reflectapi::transforms::make_required_and_nonnullable — makes a field both required and non-nullable: Option<T> → required T, reflectapi::Option<T> → required T. For non-Option types, marks the field as required without changing the type.

Note: In reflectapi, "required" and "non-nullable" are distinct concepts. A field can be:

  • Required + non-nullable (T, required) — must be present, cannot be null
  • Required + nullable (Option<T>, required) — must be present, can be null
  • Optional + nullable (reflectapi::Option<T>) — can be omitted or null
  • Optional + non-nullable (T, not required) — can be omitted, but if present cannot be null

make_required removes the "can be omitted" (undefined) state. make_nonnullable removes the "can be null" state. make_required_and_nonnullable removes both.

Example: Controlling requiredness and nullability in the schema

A field may use an Option wrapper at the Rust level (e.g., because a middleware populates it before your handler runs) but you want generated clients to see a different contract.

#[derive(serde::Deserialize, reflectapi::Input)]
struct MyRequest {
    /// In Rust this is `Option<String>` (populated by middleware),
    /// but clients see it as a required `String` field.
    #[serde(default)]
    #[reflectapi(input_transform = "reflectapi::transforms::make_required")]
    pub tenant_id: Option<String>,
}

#[derive(serde::Deserialize, reflectapi::Input)]
struct PatchRequest {
    /// In Rust this is `reflectapi::Option<String>` (populated by middleware),
    /// but clients see it as a required `Option<String>` (nullable, not omittable).
    #[serde(default)]
    #[reflectapi(input_transform = "reflectapi::transforms::make_required")]
    pub correlation_id: reflectapi::Option<String>,

    /// In Rust this is `reflectapi::Option<String>`,
    /// but clients see it as an optional `String` (can be omitted, but not null).
    #[serde(default)]
    #[reflectapi(input_transform = "reflectapi::transforms::make_nonnullable")]
    pub display_name: reflectapi::Option<String>,

    /// In Rust this is `reflectapi::Option<String>`,
    /// but clients see it as a required `String` (must be present, cannot be null).
    #[serde(default)]
    #[reflectapi(input_transform = "reflectapi::transforms::make_required_and_nonnullable")]
    pub user_id: reflectapi::Option<String>,
}

Visibility

AttributeDescription
#[reflectapi(skip)]Exclude the field entirely from the schema. The field's type does not need to implement Input/Output. Equivalent to setting both input_skip and output_skip.
#[reflectapi(input_skip)]Exclude the field from the input schema only.
#[reflectapi(output_skip)]Exclude the field from the output schema only.
#[reflectapi(hidden)]Keep the field in the schema (marked "hidden": true) but exclude it from generated clients, documentation, and OpenAPI specs. Useful for header fields that the server needs at runtime but clients should not see.

skip vs hidden

Both attributes remove a field from generated clients. The key difference:

  • skip removes the field from the schema entirely. The field's type is never reflected, so it does not need to implement Input or Output. Use this for internal bookkeeping fields whose types are not part of your API.

  • hidden keeps the field in the schema JSON (marked with "hidden": true) but excludes it from generated clients, documentation, and OpenAPI specs. The type must still implement the relevant trait. Use this for fields that are functionally required by server-side infrastructure — for example, a middleware or a proxy layer that populates the field / header before deserialization — but should not appear in client interfaces.

When to use hidden over skip: The field stays in the schema JSON so that server-side tooling (the axum adapter, middleware, or custom infrastructure) can inspect the full type structure at runtime. If nothing on the server needs the field's schema metadata, prefer skip.

Neither skip nor hidden affects serde serialization. For output types, serde will still serialize a hidden field onto the wire — hidden only controls what generated code and documentation show. If a field must never appear in responses, use #[serde(skip_serializing)] instead.

Please not that neither skip nor hidden prevent a malicious client from sending the fields in a request. A middleware may overwrite it or reject or validate as needed. It is up to the specific implementation of your server.

Example: hidden header field

#[derive(serde::Deserialize, reflectapi::Input)]
pub struct MyHeaders {
    /// Visible to clients — they must provide this
    pub authorization: String,

    /// Not visible to the generated clients and documentation
    /// Expected to be populated by a proxy or server-side middleware.
    #[reflectapi(hidden)]
    #[serde(default)]
    pub x_internal_request_id: String,
}

#[serde(default)] on skipped and hidden fields

When a field is excluded from generated clients (via skip, input_skip, or hidden), clients will not send it. Whether you add #[serde(default)] is your choice and depends on your deployment:

  • With #[serde(default)]: If the field is absent, serde fills the default value. The request succeeds even if no proxy or middleware populates the field. Use this for optional metadata (trace IDs, correlation IDs) where absence is acceptable.

  • Without #[serde(default)]: If the field is absent, deserialization fails with a protocol error. Use this for fields that a proxy or middleware is expected to inject — a missing value means the infrastructure is misconfigured, and you want to reject the request loudly rather than proceed silently with a zero-value.

Deprecation

AttributeDescription
#[reflectapi(deprecated_since = "YYYY-MM-DD")]Date the field was deprecated. Requires #[deprecated].
#[reflectapi(sunset = "YYYY-MM-DD")]Date after which the field may be removed. Requires #[deprecated].

The release in Rust's #[deprecated(since = "..")] is kept as well. See Deprecations.

Restrictions

#[reflectapi(hidden)] cannot be used on unnamed (tuple) struct or enum variant fields. Hiding a positional element would shift indices in generated clients, breaking wire compatibility. Use hidden only on named fields.

Enum Variant Level

AttributeDescription
#[reflectapi(skip)]Exclude the variant from the schema entirely.
#[reflectapi(input_skip)]Exclude the variant from the input schema only.
#[reflectapi(output_skip)]Exclude the variant from the output schema only.
#[reflectapi(deprecated_since = "YYYY-MM-DD")]Date the variant was deprecated. Requires #[deprecated].
#[reflectapi(sunset = "YYYY-MM-DD")]Date after which the variant may be removed. Requires #[deprecated].

Deprecations

Mark a route, field or enum variant deprecated to tell client authors to move off it. Optionally say which release deprecated it, the date it was deprecated, and the date after which it may be removed (its sunset). Use a release, dates, or both, whichever fits your API's deprecation policy. Generated clients flag uses of deprecated items, and the OpenAPI spec carries the release and dates for tooling such as breaking-change checkers.

Declaring

Fields and variants use Rust's #[deprecated], whose since is the release, with dates in #[reflectapi(...)]:

#[derive(serde::Deserialize, reflectapi::Input)]
pub struct DrawRequest {
    #[deprecated(since = "2.3.0", note = "Use `shape` instead.")]
    #[reflectapi(deprecated_since = "2026-10-01", sunset = "2026-11-01")]
    pub radius: Option<f64>,
    pub shape: Shape,
}

#[derive(serde::Deserialize, reflectapi::Input)]
pub enum Shape {
    Circle { radius: f64 },
    #[deprecated(note = "Use `Circle` instead.")]
    #[reflectapi(sunset = "2026-11-01")]
    Ellipse { a: f64, b: f64 },
}

Routes are deprecated on the builder:

builder.route(draw_v1, |b| {
    b.name("shapes.draw_v1")
        .deprecation_note("Use shapes.draw instead.")
        .deprecated_in("2.3.0")
        .deprecated_since("2026-10-01")
        .sunset("2026-11-01")
})

The release is free-form: a crate version, an API version or anything else, kept verbatim. Dates are YYYY-MM-DD, and the sunset can't be before the deprecation date. The derive macros reject a bad date, or a date on an item without #[deprecated], at compile time; Builder::build rejects bad route dates.

#[deprecated(since = "..")] is always the release, even when it looks like a date. Calendar dates come only from #[reflectapi(deprecated_since = "..", sunset = "..")] and the builder. Clippy's deprecated_semver lint, deny by default, rejects a since that isn't a semver version, so a release named another way needs #[allow(clippy::deprecated_semver)].

What clients see

Each target renders one message built from the release, the dates and the note, for example Deprecated in 2.3.0, since 2026-10-01; removal after 2026-11-01. Use shapes.draw instead. Without a release or dates, the message is the note.

TargetRoutesFieldsVariants
OpenAPIdeprecated: true, x-deprecated-in, x-deprecated-since, x-sunset on the operationthe same on the property, header parameter or response headerthe same on the variant's schema
Rust#[deprecated(note = "...")] on the methodon the fieldon the variant
TypeScript@deprecated ... on the methodon the propertyon the union member, and on the tag key of an externally tagged variant with fields
Python@typing_extensions.deprecated(...) on the method, plus a DeprecationWarning when calledField(deprecated=...), so Pydantic warns when the field is readnot marked

A marker doesn't always produce an editor or type-checker warning:

  • TypeScript: editors flag a deprecated method or property. A variant is flagged only when it is externally tagged and has fields, through its tag key: value.Ellipse is struck through. On unit, numeric and internally tagged variants, and on adjacently tagged and untagged ones, the @deprecated comment sits before a union member, which TypeScript doesn't treat as a deprecated symbol, so it's documentation only.
  • Fields: only named fields are marked outside Rust. In OpenAPI, TypeScript and Python, a deprecated field of a tuple struct or newtype, or a deprecated #[serde(flatten)] field, is not marked (TypeScript's comment on a tuple element is documentation only). Deprecate the inner named fields instead.

The Python decorator uses category=None, so it only informs type checkers: pyright (with reportDeprecated) and mypy (with --enable-error-code deprecated) report call sites, and the method body emits the runtime warning.

x-sunset follows the convention oasdiff uses for a deprecated item's earliest removal date.

What a sunset means

A sunset is the date you announce, not a check that removal is safe. reflectapi doesn't know which clients are deployed or whether they still use the item, and clients generated before the deprecation carry no warning at all. Before removing an item, confirm from your own traffic or client inventory that nothing still depends on it.

Client Generation

reflectapi can generate client code from a reflected schema JSON file.

Supported Outputs

OutputStatusNotes
TypeScriptStableTwo generated files: API surface + transport contract
RustStableSingle generated file
PythonExperimentalPackage-style output with real namespace submodules

OpenAPI generation is also supported by the CLI, but it is documented separately as an API description format rather than a client library.

Workflow

  1. Define your API server using reflectapi derives and the builder API.
  2. Write the schema JSON from your Rust application.
  3. Run reflectapi codegen for the target language.
  4. Commit or consume the generated client code from your application.

The CLI defaults to reflectapi.json if --schema is omitted. The demo project uses that filename. If your application writes a different filename such as reflectapi-schema.json, pass that path explicitly.

# Create output directories first. TypeScript and Rust write a single file
# unless the output path already exists as a directory or ends with a slash.
mkdir -p clients/typescript clients/python clients/rust

# Generate TypeScript client -> clients/typescript/generated.ts
#                          and clients/typescript/generated.transport.ts
cargo run --bin reflectapi -- codegen \
  --language typescript \
  --schema reflectapi.json \
  --output clients/typescript/

# Generate Python client -> clients/python/api_client/__init__.py,
# clients/python/api_client/_client.py, and namespace packages such as
# clients/python/api_client/myapi/model/
cargo run --bin reflectapi -- codegen \
  --language python \
  --schema reflectapi.json \
  --output clients/python/api_client/ \
  --python-sync

# Generate Rust client -> clients/rust/generated.rs
cargo run --bin reflectapi -- codegen \
  --language rust \
  --schema reflectapi.json \
  --output clients/rust/

If you installed the CLI separately, replace cargo run --bin reflectapi -- with reflectapi.

Output Shape

The generators do not all emit the same file layout:

OutputFiles written by the generator
TypeScriptgenerated.ts, generated.transport.ts
Rustgenerated.rs
PythonA package directory containing __init__.py, generated.py, _client.py, _rebuild.py, and namespace package files

The demo repository includes extra project scaffolding around some generated clients, but that scaffolding is not produced by reflectapi codegen itself.

Missing fields and explicit null

Input and output types follow the server's directional Serde contract. A plain named Option<T> input field may be omitted, while an output field that is always serialized remains required and nullable. Types used in both directions may therefore generate separate input and output models. See Missing fields and null for defaults, custom codecs and transforms.

Generated Python models with omittable fields preserve omission in direct model_dump() / model_dump_json() calls using ReflectapiPartialModel. Python requests omit fields the caller has not set (exclude_unset=True) and send explicitly supplied None as JSON null. This applies recursively to nested models, including models inside lists and dictionaries, and uses wire aliases. Unset non-null Pydantic defaults are omitted too; pass the value explicitly to send it. For example, an optional nullable field can be left out with Request(), or sent as null with Request(value=None). Generated fields with known non-nullable primitive or container types reject explicit null. Opaque aliases and custom codecs may require server-side validation.

Rust named optional nullable request fields use reflectapi::Option<T>: Undefined omits the key, None sends null, and Some(value) sends the value. After regenerating, update affected field initializers that previously used std::option::Option<T>. Output-only nullable fields keep their existing types.

Language Behavior

TypeScript

  • Emits two files alongside each other: generated.ts (the API surface — types, functions, the client(base) factory) and generated.transport.ts (the transport contract — Request, Response, Headers, Client, RequestOptions, ClientInstance). The split keeps the bare DTO names from shadowing the DOM globals of the same name when imported from generated.ts. Custom transports import from ./generated.transport.

  • Uses generated TypeScript types and function wrappers.

  • Response enums with #[reflectapi(open)] include a branded UnknownEnum. isUnknownEnum(value) narrows it; value[unknownEnum] holds tag and raw without occupying any wire field names. Generated match calls handle it with the exported [unknownEnum] handler key or an otherwise fallback. Known values keep their wire shape. Runtime decoding is enabled for responses/errors that reach an open enum or #[serde(other)], including nested values and SSE items.

  • Uses a fetch-based default client implementation.

  • Ordinary response paths parse JSON without runtime schema validation. Paths that reach an open enum or declared fallback use generated decoders. These check known payload shapes against the generated TypeScript types; they do not validate every Rust-specific constraint, such as UUID syntax for a field represented as a TypeScript string. A known tag is never reclassified as an unknown case when its payload fails those checks.

  • Supports custom client implementations via the generated client interface. A custom transport's Headers.get(name) must match names case-insensitively, as fetch's does: declared response headers are looked up by their lowercase names.

  • Calls return a CallResult, a Result that also carries the HTTP response: status_code(), the API's declared response headers via headers(), and all of them via raw_headers(). The response may come from the server or from a proxy or rate limiter in front of it; all three are undefined for network failures and aborts.

  • Failed calls hold an Err. err.err() holds the endpoint's typed error (non-5xx responses with a JSON body); anything else, such as a 5xx, a non-JSON body or a network failure, is in err.other_err(). Err has the same status_code(), headers() and raw_headers(). Result.unwrap_ok() throws an Error whose cause is the Err, so code that only sees the thrown error, such as a query library's retry callback, can still classify it:

    retry: (failureCount, error) => {
      if (failureCount >= 2 || !(error.cause instanceof Err)) return false;
      const status = error.cause.status_code();
      // no response at all, rate limited, or upstream unavailable
      return status === undefined || status === 429 || status >= 502;
    }
    

Python

  • Generates Pydantic-based models and client code.
  • Generates an async client by default.
  • Adds a sync client only when --python-sync is passed.
  • Emits reflected Rust namespaces as real Python packages. generated.py is kept as a temporary compatibility facade inside the package.
  • Each namespace exposes its types under short, ergonomic names (order.Item). When a namespace defines a type whose short name clashes with a top-level type of the same name (e.g. both a root IfConflictOnUpdate and a nomatches::IfConflictOnUpdate), the namespace keeps the short name bound to the imported top-level type and exposes its own type under a disambiguated, namespace-prefixed name (nomatches.NomatchesIfConflictOnUpdate). This keeps one Python class per logical type so model.<X> resolves consistently.
  • Uses reflectapi_runtime for client base classes and runtime helpers.
  • Open response enum wrappers include a generated unknown model with .tag and .raw. Unknown values serialize to their original wire JSON. Known values still validate normally; #[serde(other)] without open maps to the declared unit fallback instead.

Rust

  • Generates typed async client methods.
  • Integrates with reflectapi::rt::Client. The transport carries the base URL (Client::base_url); the per-request Request DTO carries only path, headers, and body — same shape as TypeScript and Python.
  • Built-in transports: reflectapi::rt::ReqwestClient (a thin wrapper around reqwest::Client + base URL) and the type alias ReqwestMiddlewareClient for reqwest_middleware::ClientWithMiddleware.
  • Generated Interface<C> exposes:
    • Interface::new(client: C) — generic, takes any Client impl.
    • Interface::try_new(reqwest::Client, base_url) -> Result<Self, UrlParseError> — convenience constructor that hides the ReqwestClient adapter for the most common case. Available when the generated crate enables its own reqwest feature (which should re-export reflectapi/reqwest).
  • Supports optional tracing instrumentation through --instrument.
  • Generates serde-compatible types and request helpers for JSON-based transport.
  • Open response enums have a generated Unknown { tag, raw } variant and tag-first deserialization. Ordinary #[serde(other)] output variants keep that attribute. Unknown open error variants serialize to their original JSON for diagnostics.

Handling Unfamiliar Enum Variants

An enum declared with #[reflectapi(open)] keeps known cases typed and adds an explicit unknown case to generated responses. Handle it before reading known variant fields. The following snippets assume the generated PetKind and related types are imported from their generated namespace.

Rust uses a normal enum match:

match pet_kind {
    PetKind::Dog { breed } => println!("Dog: {breed}"),
    PetKind::Bird => println!("Bird"),
    PetKind::Unknown { tag, raw } => println!("New kind {tag}: {raw}"),
}

Python exposes the variant through the generated model's .root:

variant = pet_kind.root
if isinstance(variant, PetKindUnknown):
    print(variant.tag, variant.raw)
elif isinstance(variant, PetKindDog):
    print(variant.breed)
else:
    print("Bird")

TypeScript narrows the union with the helper exported from generated.ts:

import { isUnknownEnum, unknownEnum } from "./generated";

if (isUnknownEnum(petKind)) {
  console.log(petKind[unknownEnum].tag, petKind[unknownEnum].raw);
} else if (petKind.kind === "Dog") {
  console.log(petKind.breed);
} else {
  console.log("Bird");
}

Generated unknown names get a suffix when they would conflict with a declared variant or another generated type. For externally tagged and unit enum unions in TypeScript, match accepts an unknown handler under the exported [unknownEnum] symbol; it is separate from a real wire variant named Unknown. That handler receives { tag, raw } directly. An otherwise handler receives the original value and can use isUnknownEnum to narrow it.

raw is the parsed JSON value, rather than the original response bytes. Python and TypeScript unknown cases serialize back to their retained wire value. Rust does the same when serde::Serialize is requested through #[reflectapi(derive(serde::Serialize))]; responses otherwise derive only Deserialize by default. TypeScript stores its metadata under non-enumerable symbols so JSON.stringify keeps wire fields intact. For a directly flattened enum, Rust and Python expose a typed enum field and retain the remaining enum JSON; TypeScript keeps the flat object and retains the whole original object in value[unknownEnum].raw.

A declared #[serde(other)] uses its unit fallback instead and discards the unfamiliar tag and payload. It cannot be combined with open. Neither policy adds an unknown case to generated requests. See the attribute reference for representation restrictions and migration requirements.

Streaming Endpoints

Endpoints registered with Builder::stream_route produce a stream of items rather than a single response. The wire format is Server-Sent Events: each item is sent as a data: <json>\n\n event. Errors raised before the stream opens are returned as a normal HTTP 4xx/5xx response, not as SSE events; the server does not emit heartbeats or end-of-stream markers, so streams end when the connection closes.

A stream can give its events ids, so that it can be resumed: the handler yields each item wrapped in reflectapi::Event, usually with an id the item already carries, and the event is sent with the standard SSE id: field before its data:. A route that declares the last-event-id request header gets the id of the last event the client received, when the client, or a server or proxy resuming the stream for it, sends one, and continues after it. The schema, and so every generated client, shows only the item:

#[derive(serde::Deserialize, reflectapi::Input)]
struct Headers {
    #[serde(rename = "last-event-id")]
    last_event_id: Option<String>,
}

fn pets_events(
    state: AppState,
    _: reflectapi::Empty,
    headers: Headers,
) -> Result<impl Stream<Item = reflectapi::Event<PetEvent>>, Error> {
    let events = state.events_after(headers.last_event_id.as_deref());
    Ok(events.map(|event: PetEvent| reflectapi::Event::new(event.seq, event)))
}

The id is a reflectapi::EventId. Integers and uuid::Uuid convert to one as is; a string goes through EventId::new, which returns an error if it contains a line break or NUL, as such an id cannot be sent. Generated clients read the ids but do not yet send Last-Event-ID when they reconnect.

OutputStreaming client surface
TypeScriptMethod returns Promise<Result<AsyncIterable<Item>, Err<Error>>>; consume with for await.
RustMethod returns reflectapi::rt::StreamResponse<Item, AppError, NetError>. The outer Result reports init failures (application or network); inner items report per-item transport/decode failures only — application errors cannot occur after the stream is open. Requires the rt-sse Cargo feature on the reflectapi dependency.
PythonMethod returns AsyncApiStream[Item] on the async client and ApiStream[Item] on the sync client: iterators that also expose the response's metadata and declared headers once iteration starts. Init 4xx/5xx raise ApplicationError (with the typed error_model if declared); per-event problems raise NetworkError / TimeoutError / ValidationError and terminate the iterator without a leaked socket.
OpenAPIOperation is described with text/event-stream response content.

Response Headers

Some response headers matter to callers: a request ID to quote in a support ticket, or retry-after from a rate limiter. Declare them on the builder as a struct with one Option<T> field per header. T must be a string on the wire, because the header's value is deserialized into it the same way request headers are: String, a unit-variant enum, a newtype over one, or a type such as uuid::Uuid, chrono::DateTime or url::Url. Numbers, booleans and collections aren't supported. The field's serde name is the header name and must be lowercase:

#[derive(serde::Serialize, reflectapi::Output)]
struct ResponseHeaders {
    /// Request ID to quote when reporting a problem
    #[serde(rename = "x-request-id")]
    request_id: Option<String>,
    /// Seconds, or an HTTP date, after which to retry
    #[serde(rename = "retry-after")]
    retry_after: Option<String>,
}

let builder = reflectapi::Builder::new()
    .response_headers::<ResponseHeaders>()
    .route(handler, |b| b.name("pets.list"));

The headers apply to successful and failed responses alike. Declaring a header doesn't mean the server sends it: it may be added by infrastructure in front of the server, and clients read it from whatever response arrived. Handlers can't set response headers yet, so today these come from that infrastructure.

A builder's declaration applies to every route of the builder that doesn't declare its own, whether added before or after the call, including the routes of builders merged in with nest or extend that have no declaration of their own. Routes that already declare response headers keep them. Use RouteBuilder::response_headers to declare a different set for one route. As with routes, call rename_types after declaring response headers so the rename applies to the headers type.

A header that appears more than once is read as its values joined with , . set-cookie can't be read that way (cookie dates contain commas, and browsers don't expose it to scripts), so don't declare it.

In TypeScript, the declared headers are typed on the CallResult and Err returned by a call. Code that only sees a thrown error, such as a query library's retry callback, gets the Err as cause with its headers type erased, so cast it to the generated headers type:

retryDelay: (failureCount, error) => {
  const headers =
    error.cause instanceof Err
      ? (error.cause as Err<unknown, ResponseHeaders>).headers()
      : undefined;
  const seconds = headers?.["retry-after"] ? Number(headers["retry-after"]) : NaN;
  // Retry-After may also be an HTTP date; fall back to exponential backoff
  return Number.isFinite(seconds)
    ? seconds * 1000
    : Math.min(1000 * 2 ** failureCount, 30_000);
}
OutputResponse headers
TypeScriptheaders() on the CallResult and on its Err returns the declared headers: each has the field's generated type or is null when that header is absent; headers() itself is undefined when no response arrived. Open enum headers decode unfamiliar strings into UnknownEnum; ordinary enum headers retain the existing unvalidated string behavior. raw_headers() returns all response headers, untyped, for ones the API doesn't declare, such as cf-ray from a CDN. result.map(...) keeps the response; map_err returns a plain Result.
PythonApiResponse.headers and ApiError.headers (including ValidationError when the response body is invalid) hold the declared headers validated into the generated model; a field is None when its header is absent or its value doesn't validate. headers is None when the API declares none or no response arrived. All headers, untyped, are in .metadata.headers. Streaming methods return an ApiStream / AsyncApiStream whose headers and metadata are set once iteration starts.
RustThe headers struct is generated as a type, but the client doesn't populate it yet. The generated method signatures of routes with declared headers will change when it does.
OpenAPIDocumented as optional headers on the operation's 200 and default responses.

Shared Characteristics

The generated clients all aim to provide:

  • Types derived from the Rust-reflected schema
  • Function wrappers with generated documentation
  • Structured handling of application errors versus transport/protocol failures
  • Good IDE support through generated type information

They do not currently all provide the same runtime validation guarantees or the same runtime transport abstractions, so those details should be considered language-specific.

Serving with axum

reflectapi::axum::into_router turns the routers of a built API into an axum Router. Use it for a native server on tokio.

Dependencies

cargo add reflectapi --features builder,axum
cargo add axum
cargo add tokio --features full

reflectapi's axum feature does not enable axum's default features. Your own axum dependency enables what your server uses: its defaults include tokio and http1, which axum::serve needs.

Mount the routes

let (schema, routers) = api().build()?;

let app = reflectapi::axum::into_router(app_state, routers, |name, router| {
    // Called once per router: `name` is the name of the builder that defined it,
    // including builders merged with `Builder::extend` and `Builder::nest`.
    router.layer(tower_http::trace::TraceLayer::new_for_http())
});

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
  • Every route is mounted at POST {path}/{name}, and readonly routes at GET {path}/{name} as well (axum also answers HEAD for them, without a body). Another method gets 405 Method Not Allowed with an allow header (POST, or GET,HEAD,POST); an unknown path gets 404.
  • The callback receives each router with its name, so middleware can differ per API module: authentication for one, a different timeout for another.
  • app_state is cloned into every handler call. Use cheap clones, such as an Arc.
  • Stream routes respond with Server-Sent Events, one data: event per item, with an id: for items the route wraps in reflectapi::Event. An error returned before the stream opens is a normal HTTP error response.

The schema

build() returns the schema next to the routes. Write it out for client generation, or serve it, for example as OpenAPI:

let openapi = reflectapi::codegen::openapi::Spec::from(&schema); // `codegen` feature
let app = app.route("/openapi.json", axum::routing::get(|| async { axum::Json(openapi) }));

A server that doesn't need the schema at runtime can build the routes alone: api::<reflectapi::DispatchOnly>().build()? returns the same routers, without reflecting any type, and into_router accepts them. Generate the schema at build time instead. See build modes.

Serving with celld and Cloudflare Workers

A reflectapi API can run in a Cloudflare-compatible Worker, on Cloudflare Workers or on celld, written in Rust with workers-rs and compiled to WebAssembly. The Worker runtime is the HTTP server, so axum is not used: the reflectapi::dispatch::Dispatcher routes requests, and the adapter on this page connects it to workers-rs.

Dependencies

[lib]
crate-type = ["cdylib", "rlib"] # rlib: the native build step below links the crate

[dependencies]
reflectapi = { version = "...", default-features = false, features = ["builder", "json"] }
worker = "0.8"
http = "1"
futures-util = "0.3"

# Generating the schema and clients happens natively, at build time.
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
reflectapi = { version = "...", features = ["builder", "json", "codegen"] }

[profile.release]
lto = true
codegen-units = 1
# Not `strip = true` (the same as "symbols"): wasm-bindgen then fails with
# "externref table required for catch wrappers". `strip = "debuginfo"` is fine.

Build with worker-build --release, as for any workers-rs Worker.

Define the API

Write the definition once, generic over the build mode: the Worker builds it with DispatchOnly, so no schema is built or linked into the wasm, and the build step builds it with WithSchema.

pub fn api<M: reflectapi::BuildMode>() -> reflectapi::Builder<Ctx, M> {
    reflectapi::Builder::default()
        .name("Routing agent")
        .stream_route(prompt, |b| b.name("agent.prompt"))
}

Handlers, their futures and their streams must be Send, but values backed by JavaScript objects (storage, bindings, fetch responses) are not. A Worker runs on one thread, so marking them Send is sound: wrap state in worker::send::SendWrapper, futures in worker::send::SendFuture, and streams in the SendStream from the adapter below.

pub type Ctx = worker::send::SendWrapper<std::rc::Rc<SessionState>>;

fn prompt(
    ctx: Ctx,
    request: PromptRequest,
    headers: SessionHeaders,
) -> Result<SendStream<impl Stream<Item = AgentEvent>>, PromptError> {
    let session = ctx.0.clone();
    Ok(SendStream::new(async_stream::stream! {
        // read and write `session`, await fetches and timers, yield events
    }))
}

The adapter

Copy this module into your Worker crate.

//! Serves a reflectapi `Dispatcher` from a Cloudflare-compatible Worker or Durable Object.

use std::pin::Pin;
use std::task::{Context, Poll};

use futures_util::{Stream, StreamExt};
use reflectapi::dispatch::{sse_event, Dispatcher, Reply};
use worker::{send::SendWrapper, Error, Headers, Request, Response, Result};

/// Dispatches one Worker request and converts the reply to a Worker response.
pub async fn serve<S>(dispatcher: &Dispatcher<S>, state: S, mut req: Request) -> Result<Response>
where
    S: Send + Clone + 'static,
{
    let method = http::Method::from_bytes(req.method().as_ref().as_bytes())
        .map_err(|err| Error::RustError(err.to_string()))?;
    let mut headers = http::HeaderMap::new();
    for (name, value) in req.headers().entries() {
        let name = http::HeaderName::from_bytes(name.as_bytes())
            .map_err(|err| Error::RustError(err.to_string()))?;
        let value =
            http::HeaderValue::from_str(&value).map_err(|err| Error::RustError(err.to_string()))?;
        headers.append(name, value);
    }
    let body = req.bytes().await?;

    match dispatcher
        .dispatch(state, &method, &req.path(), &headers, body.into())
        .await
    {
        None => Response::error("Not Found", 404),
        Some(Reply::Complete {
            status,
            headers,
            body,
        }) => Ok(Response::from_bytes(body.to_vec())?
            .with_status(status.as_u16())
            .with_headers(worker_headers(&headers)?)),
        Some(Reply::Stream(items)) => {
            let events = items.map(|item| {
                item.map(|event| sse_event(&event).into_bytes())
                    .map_err(Error::RustError)
            });
            let headers = Headers::new();
            headers.set("content-type", "text/event-stream")?;
            headers.set("cache-control", "no-cache")?;
            Ok(Response::from_stream(events)?.with_headers(headers))
        }
    }
}

fn worker_headers(headers: &http::HeaderMap) -> Result<Headers> {
    let out = Headers::new();
    for (name, value) in headers {
        let value = value
            .to_str()
            .map_err(|err| Error::RustError(err.to_string()))?;
        out.append(name.as_str(), value)?;
    }
    Ok(out)
}

/// Marks a stream `Send` for reflectapi, like `worker::send::SendWrapper` does for
/// values. Sound in a Worker, which runs on one thread.
pub struct SendStream<St>(SendWrapper<Pin<Box<St>>>);

impl<St> SendStream<St> {
    pub fn new(stream: St) -> Self {
        Self(SendWrapper::new(Box::pin(stream)))
    }
}

impl<St: Stream> Stream for SendStream<St> {
    type Item = St::Item;

    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<St::Item>> {
        self.get_mut().0.as_mut().poll_next(cx)
    }
}

Serve from a Worker or a Durable Object

A stateless API serves from the Worker's fetch handler. An API with state per entity (a session, a job) serves from a Durable Object, and the Worker forwards each request to the object it is for. Declare the routing key as a request header, so the schema and the generated clients carry it:

#[derive(serde::Deserialize, reflectapi::Input)]
pub struct SessionHeaders {
    #[serde(rename = "x-session-id")]
    pub session_id: String,
}

#[event(fetch)]
async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
    match req.headers().get("x-session-id")? {
        Some(id) if !id.is_empty() => {
            env.durable_object("SESSION")?.get_by_name(&id)?.fetch_with_request(req).await
        }
        _ => Response::error("missing x-session-id", 400),
    }
}

#[durable_object]
pub struct Session {
    ctx: Ctx,
    dispatcher: std::result::Result<Dispatcher<Ctx>, String>,
}

impl DurableObject for Session {
    fn new(state: State, _env: Env) -> Self {
        Self {
            ctx: SendWrapper::new(Rc::new(SessionState::new(state))),
            dispatcher: api::<reflectapi::DispatchOnly>()
                .build()
                .map(Dispatcher::new)
                .map_err(|errors| errors.to_string()),
        }
    }

    async fn fetch(&self, req: Request) -> Result<Response> {
        match &self.dispatcher {
            Ok(dispatcher) => reflectapi_worker::serve(dispatcher, self.ctx.clone(), req).await,
            Err(errors) => Response::error(format!("invalid API definition: {errors}"), 500),
        }
    }
}

The Dispatcher holds no request state: the state is passed to each dispatch call. A Durable Object builds it when it starts; to build it once for every object in an isolate instead, keep it in a thread_local!.

Generate the schema and clients at build time

The crate also builds natively, so a native test (or binary) can build the API WithSchema and write the schema and the clients:

#[cfg(all(test, not(target_arch = "wasm32")))]
#[test]
fn generate_clients() -> Result<(), Box<dyn std::error::Error>> {
    let (schema, _) = api::<reflectapi::WithSchema>().build()?;
    std::fs::write("clients/reflectapi.json", serde_json::to_string_pretty(&schema)?)?;
    let rust = reflectapi::codegen::rust::generate(schema.clone(), &Default::default())?;
    std::fs::write("clients/rust/src/generated.rs", rust)?;
    for (file, content) in reflectapi::codegen::typescript::generate(schema, &Default::default())? {
        std::fs::write(format!("clients/typescript/{file}"), content)?;
    }
    Ok(())
}

Commit the output, and have CI regenerate it and fail on a difference, so an API change cannot merge without its regenerated clients. The default codegen configurations run no formatter, so the output is the same on every machine; enabling formatting makes it depend on the formatter version installed.

Integrating Other Servers

Any HTTP server can serve a reflectapi API through reflectapi::dispatch::Dispatcher. It needs only the builder feature, and it is what the axum and Worker integrations are built on. This page is the contract an integration implements.

Build modes

A server needs the routes; the schema is for generating clients and documentation. Builder<S, M> takes a build mode:

Modebuild() returnsUse
WithSchema (default)(Schema, Vec<Router<S>>)Build step: write the schema, generate clients, check them in CI. A server that also serves its schema
DispatchOnlyVec<Router<S>>Serving. Only request-headers types are reflected, for the header names, so startup does almost no schema work and the reflection code of every other type is not linked

Write the definition once, generic over the mode, starting from Builder::default() (Builder::new() is always WithSchema):

pub fn api<M: reflectapi::BuildMode>() -> reflectapi::Builder<AppState, M> {
    reflectapi::Builder::default()
        .name("Pets")
        .route(pets_list, |b| b.name("pets.list").readonly(true))
        .stream_route(pets_events, |b| b.name("pets.events"))
}

let (schema, _) = api::<reflectapi::WithSchema>().build()?; // build step
let routers = api::<reflectapi::DispatchOnly>().build()?;   // server

Schema validation (custom validators, response headers, redundant renames) runs only in the WithSchema build, so run that build in CI. Errors that affect serving, such as an invalid request header name, fail both builds.

Dispatch a request

use reflectapi::dispatch::{sse_event, Dispatcher, Reply};

let dispatcher = Dispatcher::new(routers);

// per request
match dispatcher.dispatch(state.clone(), &method, path, &headers, body).await {
    None => { /* no such route */ }
    Some(Reply::Complete { status, headers, body }) => { /* send as is */ }
    Some(Reply::Stream(items)) => { /* send as Server-Sent Events */ }
}

dispatch takes the request method, its path without the query string, all its headers, and the complete body.

The contract

Routing. Every route is at POST {path}/{name}; a readonly route also accepts GET. This is what the axum adapter serves, and dispatch answers the same way:

  • None when no route has the path. Respond 404, or pass the request on to the server's other routes.
  • A 405 Method Not Allowed reply when the route exists but not for this method, with an allow header: POST, or GET,POST for a readonly route. This includes HEAD: axum answers it for readonly routes by running the GET handler and dropping the body, and says GET,HEAD,POST; dispatch does not.
  • For HEAD, the GET reply's status and headers, without a body.

Requests.

  • Pass all request headers; the dispatcher forwards to each handler only the headers it declares, plus content-type and traceparent.
  • Pass the complete body. Enforce a body size limit before dispatching.
  • The state is cloned into every call: make it cheap to clone (Arc, handles).

Complete replies. Send status, headers and body unchanged. The reply has no content-length: the length is transport metadata, which the server sets when it writes the body (axum does, and so do Worker runtimes).

Stream replies.

  • Respond 200 with content-type: text/event-stream and cache-control: no-cache.
  • Frame each Ok item with sse_event, and send it as soon as it arrives: do not buffer the response. An item is an Event<String>: the serialized value, and the event id if the route gave the item one by wrapping it in reflectapi::Event; sse_event writes the id as the standard SSE id: field.
  • An Err item is a value that failed to serialize: log it and end the response.
  • reflectapi sends no heartbeats and no end-of-stream marker; the stream ends when the response ends. Add SSE comment lines (:\n\n) as heartbeats if a proxy or runtime closes quiet connections.
  • An error that the handler returns before the stream opens arrives as a complete reply, not as a stream.

Send. Handlers, their futures and their streams must be Send. On a single-threaded runtime, wrap values that are not, as the Worker integration does.

Test the integration

The responses must be identical to the axum adapter's. The reflectapi demo checks this by replaying recorded requests through both and comparing status, headers and body (reflectapi-demo/tests/run), and does the same for unknown paths and unaccepted methods (reflectapi-demo/tests/dispatch.rs); do the same with your adapter. The expected differences are content-length, which axum sets itself, and HEAD on readonly routes, described above.