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
- Rust code first definition of the API interface
- Full respect for all serde attributes
- 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:
- Rust types and handler functions define the API surface.
- Reflection builds a
Schema, which is the interchange format between the server side and code generators. - 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 transformsreflectapi-schema-codegen: compiler-owned IDs, normalization pipeline, semantic IRreflectapi-derive:#[derive(Input, Output)]macrosreflectapi: reflection traits, builder, runtime integrations, codegen backendsreflectapi-cli: CLI wrapper around codegenreflectapi-demo: snapshot and integration testsreflectapi-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 definitionsinput_types: types seen in request positionsoutput_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-codegen/src/symbol.rs. They are compiler identifiers, not part of the stable JSON contract.
Key points:
- raw
Schema,Function, and type/member definitions do not store symbol IDs build_schema_ids()inreflectapi-schema-codegen/src/ids.rsassigns 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:
PrimitiveStructEnum
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.
Normalization
Normalization lives in reflectapi-schema-codegen/src/normalize.rs.
There are two parts:
- A mutable normalization pipeline over raw
Schema - A
Normalizerthat converts the resulting schema intoSemanticSchema
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:
- symbol discovery
- type resolution
- dependency analysis
- semantic validation
- semantic IR construction
SemanticSchema provides resolved, deterministic views of functions and types and is defined in reflectapi-schema-codegen/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:
schema.consolidate_types()runs firstvalidate_type_references()checks raw referencesNormalizer::normalize_with_pipeline(...)buildsSemanticSchemausing a pipeline that skips consolidation and naming- 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 APIsreflectapi::Empty: explicit empty request/response body typereflectapi::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
- New users: Follow the Quick Start guide
Client Generation
reflectapi can generate client code from a reflected schema JSON file.
Supported Outputs
| Output | Status | Notes |
|---|---|---|
| TypeScript | Stable | Two generated files: API surface + transport contract |
| Rust | Stable | Single generated file |
| Python | Experimental | Package-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
- Define your API server using
reflectapiderives and the builder API. - Write the schema JSON from your Rust application.
- Run
reflectapi codegenfor the target language. - 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/__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:
| Output | Files written by the generator |
|---|---|
| TypeScript | generated.ts, generated.transport.ts |
| Rust | generated.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
- Emits two files alongside each other:
generated.ts(the API surface — types, functions, theclient(base)factory) andgenerated.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 fromgenerated.ts. Custom transports import from./generated.transport. - 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-syncis passed. - Uses
reflectapi_runtimefor client base classes and runtime helpers.
Rust
- Generates typed async client methods.
- Integrates with
reflectapi::rt::Client. The transport carries the base URL (Client::base_url); the per-requestRequestDTO carries onlypath,headers, andbody— same shape as TypeScript and Python. - Built-in transports:
reflectapi::rt::ReqwestClient(a thin wrapper aroundreqwest::Client+ base URL) and the type aliasReqwestMiddlewareClientforreqwest_middleware::ClientWithMiddleware. - Generated
Interface<C>exposes:Interface::new(client: C)— generic, takes anyClientimpl.Interface::try_new(reqwest::Client, base_url) -> Result<Self, UrlParseError>— convenience constructor that hides theReqwestClientadapter for the most common case. Available when the generated crate enables its ownreqwestfeature (which should re-exportreflectapi/reqwest).
- Supports optional tracing instrumentation through
--instrument. - Generates serde-compatible types and request helpers for JSON-based transport.
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.
| Output | Streaming client surface |
|---|---|
| TypeScript | Method returns Promise<Result<AsyncIterable<Item>, Err<Error>>>; consume with for await. |
| Rust | Method 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. |
| Python | Method returns AsyncIterator[Item] on the async client and Iterator[Item] on the sync client. 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. |
| OpenAPI | Operation is described with text/event-stream response content. |
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.