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-derive:#[derive(Input, Output)]macrosreflectapi: reflection traits, builder, runtime integrations, codegen backends, and the private compiler schema modulereflectapi-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 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()inreflectapi/src/codegen/schema/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.
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:
- 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
- a semantic validation hook (additional checks are currently a TODO)
- 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:
- enum-policy validation and hidden-field filtering run before preprocessing
- types are consolidated and flattened generic models are monomorphized
validate_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 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
- 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. |
#[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
| 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. |
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
| Attribute | Description |
|---|---|
#[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>→ requiredT,reflectapi::Option<T>→ requiredOption<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>→ requiredT,reflectapi::Option<T>→ requiredT. 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_requiredremoves the "can be omitted" (undefined) state.make_nonnullableremoves the "can be null" state.make_required_and_nonnullableremoves 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
| 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.
Deprecation
| Attribute | Description |
|---|---|
#[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
| 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. |
#[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.
| Target | Routes | Fields | Variants |
|---|---|---|---|
| OpenAPI | deprecated: true, x-deprecated-in, x-deprecated-since, x-sunset on the operation | the same on the property, header parameter or response header | the same on the variant's schema |
| Rust | #[deprecated(note = "...")] on the method | on the field | on the variant |
| TypeScript | @deprecated ... on the method | on the property | on 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 called | Field(deprecated=...), so Pydantic warns when the field is read | not 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.Ellipseis struck through. On unit, numeric and internally tagged variants, and on adjacently tagged and untagged ones, the@deprecatedcomment 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
| 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.
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, 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.
-
Response enums with
#[reflectapi(open)]include a brandedUnknownEnum.isUnknownEnum(value)narrows it;value[unknownEnum]holdstagandrawwithout occupying any wire field names. Generatedmatchcalls handle it with the exported[unknownEnum]handler key or anotherwisefallback. 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, asfetch's does: declared response headers are looked up by their lowercase names. -
Calls return a
CallResult, aResultthat also carries the HTTP response:status_code(), the API's declared response headers viaheaders(), and all of them viaraw_headers(). The response may come from the server or from a proxy or rate limiter in front of it; all three areundefinedfor 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 inerr.other_err().Errhas the samestatus_code(),headers()andraw_headers().Result.unwrap_ok()throws anErrorwhosecauseis theErr, 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-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. - Open response enum wrappers include a generated unknown model with
.tagand.raw. Unknown values serialize to their original wire JSON. Known values still validate normally;#[serde(other)]withoutopenmaps 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-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.
- 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.
| 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 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. |
| OpenAPI | Operation 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);
}
| Output | Response headers |
|---|---|
| TypeScript | headers() 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. |
| Python | ApiResponse.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. |
| Rust | The 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. |
| OpenAPI | Documented 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 atGET {path}/{name}as well (axum also answersHEADfor them, without a body). Another method gets405 Method Not Allowedwith anallowheader (POST, orGET,HEAD,POST); an unknown path gets404. - The callback receives each router with its name, so middleware can differ per API module: authentication for one, a different timeout for another.
app_stateis cloned into every handler call. Use cheap clones, such as anArc.- Stream routes respond with Server-Sent Events, one
data:event per item, with anid:for items the route wraps inreflectapi::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:
| Mode | build() returns | Use |
|---|---|---|
WithSchema (default) | (Schema, Vec<Router<S>>) | Build step: write the schema, generate clients, check them in CI. A server that also serves its schema |
DispatchOnly | Vec<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:
Nonewhen no route has the path. Respond404, or pass the request on to the server's other routes.- A
405 Method Not Allowedreply when the route exists but not for this method, with anallowheader:POST, orGET,POSTfor a readonly route. This includesHEAD: axum answers it for readonly routes by running theGEThandler and dropping the body, and saysGET,HEAD,POST;dispatchdoes not. - For
HEAD, theGETreply'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-typeandtraceparent. - 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
200withcontent-type: text/event-streamandcache-control: no-cache. - Frame each
Okitem withsse_event, and send it as soon as it arrives: do not buffer the response. An item is anEvent<String>: the serialized value, and the event id if the route gave the item one by wrapping it inreflectapi::Event;sse_eventwrites the id as the standard SSEid:field. - An
Erritem 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.