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.
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 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
Attributes Reference
ReflectAPI provides #[reflectapi(...)] attributes that control how Rust types are reflected into the schema and generated clients.
Struct / Enum Level
| Attribute | Description |
|---|---|
#[reflectapi(derive(...))] | Forward additional derive traits to the generated Rust client type. |
Field Level
Type Override
| Attribute | Description |
|---|---|
#[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. |
Transform
| Attribute | Description |
|---|---|
#[reflectapi(transform = "path::to::fn")] | Apply a type transformation callback for both schemas. |
#[reflectapi(input_transform = "path::to::fn")] | Apply a type transformation callback for input only. |
#[reflectapi(output_transform = "path::to::fn")] | Apply a type transformation callback for output only. |
Visibility
| Attribute | Description |
|---|---|
#[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:
-
skipremoves the field from the schema entirely. The field's type is never reflected, so it does not need to implementInputorOutput. Use this for internal bookkeeping fields whose types are not part of your API. -
hiddenkeeps 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.
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
| Attribute | Description |
|---|---|
#[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. |
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 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
- 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/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:
| Output | Files written by the generator |
|---|---|
| TypeScript | generated.ts, generated.transport.ts |
| Rust | generated.rs |
| Python | A 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.
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. - Emits reflected Rust namespaces as real Python packages.
generated.pyis 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 rootIfConflictOnUpdateand anomatches::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 somodel.<X>resolves consistently. - 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.