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: schema types, symbol IDs, normalization pipeline, semantic IR
  • reflectapi-derive: #[derive(Input, Output)] macros
  • reflectapi: reflection traits, builder, runtime integrations, codegen backends
  • 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 reflectapi-schema/src/symbol.rs. They are internal compiler identifiers, not part of the stable JSON contract.

Key points:

  • Schema.id, Function.id, and member/type IDs are marked skip_serializing
  • ensure_symbol_ids() in reflectapi-schema/src/ids.rs assigns IDs after deserialization
  • 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 means reflectapi.json stays 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 metadata is attached in memory during schema construction and consumed by the Python backend. It is intentionally not serialized today, so CLI codegen from reflectapi.json still uses shared default mappings as a compatibility fallback.

Normalization

Normalization lives in reflectapi-schema/src/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. semantic validation
  5. semantic IR construction

SemanticSchema provides resolved, deterministic views of functions and types and is defined in reflectapi-schema/src/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. schema.consolidate_types() runs first
  2. validate_type_references() checks raw references
  3. Normalizer::normalize_with_pipeline(...) builds SemanticSchema using a pipeline that skips consolidation and naming
  4. 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 first by per-type metadata attached during reflection. When that metadata is absent, the backend falls back to shared default mappings by canonical Rust type name so serialized schemas still work.

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.

Testing and Validation

reflectapi-demo is the main regression suite.

The snapshot harness in reflectapi-demo/src/tests/assert.rs generates five artifacts per test:

  • 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.

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, Deserialize, Input, Output)]
struct User {
    id: u32,
    name: String,
    email: String,
}

#[derive(Serialize, 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

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

Client Generation

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

Supported Outputs

OutputStatusNotes
TypeScriptStableSingle generated file
RustStableSingle generated file
PythonExperimentalPackage-style output with __init__.py and generated.py

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
cargo run --bin reflectapi -- codegen \
  --language typescript \
  --schema reflectapi.json \
  --output clients/typescript/

# Generate Python client -> clients/python/__init__.py and generated.py
cargo run --bin reflectapi -- codegen \
  --language python \
  --schema reflectapi.json \
  --output clients/python/ \
  --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
Rustgenerated.rs
Python__init__.py, generated.py

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

Language Behavior

TypeScript

  • Uses generated TypeScript types and function wrappers.
  • Uses a fetch-based default client implementation.
  • Parses JSON responses, but does not generate runtime schema validators today.
  • Supports custom client implementations via the generated client interface.

Python

  • Generates Pydantic-based models and client code.
  • Generates an async client by default.
  • Adds a sync client only when --python-sync is passed.
  • Uses reflectapi_runtime for client base classes and runtime helpers.

Rust

  • Generates typed async client methods.
  • Integrates with reflectapi::rt::Client.
  • Supports optional tracing instrumentation through --instrument.
  • Generates serde-compatible types and request helpers for JSON-based transport.

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.