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_frames, 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 sse_frames(items) 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 the items with
sse_frames, and send each frame 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_event, whichsse_framesapplies to each item, writes the id as the standard SSEid:field. - An
Erritem is a value that failed to serialize: log it and end the response. - There is no end-of-stream marker; the stream ends when the response ends.
- If a proxy closes quiet connections and nothing upstream keeps them open,
wrap the frames in
keep_alivewith the host runtime's timer. It writes an SSE comment whenever the stream has sent nothing for the interval, restarting the interval after every frame:keep_alive(sse_frames(items), Duration::from_secs(15), tokio::time::sleep). The result isSendwhen the timer is, so a single-threaded runtime's timer works too. - Leave
keep_aliveout when the host or a proxy in front of it already sends heartbeats. A timer of the app's own then only keeps the instance busy, and on serverless runtimes it stops the instance from hibernating or being evicted. - A route can set its own interval with
RouteBuilder::sse_keep_alive.dispatcher.sse_keep_alive(path)returns it, orNonewhen the route leaves it to the host:dispatcher.sse_keep_alive(path).unwrap_or(default). A zero interval writes no comments. - 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.