Adding a REST Route
July 31, 2026 · View on GitHub
axon-web owns REST/OpenAPI/SSE and browser web-panel transport — the Axum
router, route registration, OpenAPI export, SSE progress streams, and HTTP
auth middleware, all mapping into axon-services. This guide describes the
real handler + router + OpenAPI pattern in crates/axon-web/src/.
See also: crate guide crates/axon-web/src/CLAUDE.md, behavior contract
docs/pipeline-unification/surfaces/rest-contract.md.
The core rule: every REST route maps to a shared service request/result —
axon-web is a thin transport over axon-services. Route handlers never
reach into a domain crate's internals directly.
Step 1: Write the handler
Handlers live under crates/axon-web/src/server/handlers/, one file per
functional group (sources.rs, discovery.rs, rag.rs, exploration.rs,
admin.rs, memory.rs, jobs.rs, …), declared in handlers.rs via
#[path = "handlers/<file>.rs"] pub mod <name>;.
crates/axon-web/src/server/handlers/sources.rs's index_source is the
reference example — POST /v1/sources, the canonical source-indexing
entrypoint that all the legacy per-family routes fold into:
#[utoipa::path(
post,
path = "/v1/sources",
request_body = SourceRequest,
responses(
(status = 200, description = "Source indexing result", body = SourceResult),
(status = 400, description = "Invalid source request", body = crate::server::error::ErrorBody),
(status = 403, description = "Source not authorized for caller scopes", body = crate::server::error::ErrorBody),
(status = 502, description = "Upstream service unavailable", body = crate::server::error::ErrorBody)
),
tag = "sources"
)]
pub(crate) async fn index_source(
State((state, _cfg)): State<WebState>,
auth: Option<Extension<AuthContext>>,
Json(request): Json<SourceRequest>,
) -> Result<Json<SourceResult>, HttpError> {
// validate -> per-source authorize -> axon_services::index_source -> Json(result)
}
Notes on the shape:
- The
#[utoipa::path(...)]macro is both the OpenAPI doc source and required for the route to appear in the generated OpenAPI spec — every route needs one with an accurateresponses(...)table (including error statuses, usingcrate::server::error::ErrorBody). - Request/response bodies are
axon-apiDTOs (SourceRequest,SourceResulthere) — never invent a REST-only request/response shape. Route behavior must stay aligned with the equivalent MCP action and CLI command using the same underlying DTOs. - Validation failures return the contract
ApiError(viaHttpError:: from_api_error), not a bespoke REST error shape — the error envelope is shared across transports. - The handler calls into
axon_services::*(here,axon_services::source::classify::classify_source_inputfor per-source auth classification, then the sharedaxon_services::index_source— see the handler file's full body) — it does not call a domain crate's internal::ops::*modules directly. - If a per-source or per-action authorization boundary is finer than the
route's broad scope gate (see Step 3), do that check inside the handler,
as
index_sourcedoes viaScopeSecurityPolicy::authorize_source. - If the underlying service future is not
Send(e.g. holds a non-Senderror type across an.await), run it viatokio::task::spawn_blocking+Handle::block_on, whoseJoinHandleisSend— see the comment at the top ofsources.rsfor the concrete pattern used elsewhere in this crate (admin::run_watchdoes the same thing).
Step 2: Register the route in the router
crates/axon-web/src/server/routing.rs builds the router from four scope
tiers — read_routes, write_routes, large_write_routes, and
admin_routes — each a Router::new().route("/v1/...", get(...)/post(...))
chain. Add your route to the tier matching its required scope:
Router::new()
.route("/v1/sources", post(handlers::sources::index_source))
// ...
Each tier is wrapped with protect_routes(..., &auth_policy, ScopeRequirement::{Read,Write,Admin}) and merged into the final router.
Picking the wrong tier either over-exposes a mutating route on read scope or
needlessly locks a read-only route behind write/admin scope — match the
handler's actual side effects, not its HTTP verb alone (a POST search
route can legitimately be Read scope if it doesn't mutate state).
Step 3: Wire OpenAPI
crates/axon-web/src/server/openapi.rs's paths(...) macro invocation must
list your handler function so it's included in the generated OpenAPI
document:
paths(
// ...
handlers::sources::index_source,
)
Missing this means the route works at runtime but silently disappears from
/v1/openapi.json and generated clients — this is a common, easy-to-miss
step.
Step 4: SSE streams (if applicable)
For long-running or progress-emitting operations, use the _stream handler
pattern (ask_stream.rs, chat_stream.rs, exploration_stream.rs,
jobs_stream.rs) — SSE events must use the StreamEvent/
SourceProgressEvent envelopes from axon-observe's event schema, not an
ad hoc SSE payload shape.
Step 5: Tests
Add a sidecar _tests.rs per the repo convention (e.g.
server_tests.rs/server_dedupe_tests.rs at the server-module level, or a
handler-specific _tests.rs such as ask_tests.rs,
chat_stream_tests.rs, artifacts_tests.rs). Test both the route's happy
path and its scope-gating behavior (unauthorized caller → 403).
cargo test -p axon-web
Boundary reminders
- No source pipeline domain logic or provider/store/domain internals in this
crate — route through
axon-services. - No CLI rendering (clap types) or MCP server types here.
- No legacy/compat route aliases. Per the Phase 10 surface cutover
(
crates/axon-web/src/CLAUDE.md), direct verb/family routes like/v1/scrape,/v1/crawl,/v1/embed,/v1/ingestwere removed in favor of/v1/sources— do not add a new alias route for something/v1/sources(or another canonical end-state route) already covers. - Allowed dependencies:
axon-api,axon-error,axon-core,axon-authz,axon-observe,axon-services, Axum/Tower/OpenAPI/static-asset crates. Forbidden: domain internals bypassing services, provider clients, CLI clap types, MCP server types — enforced bycargo xtask check-layering. - OpenAPI output must stay deterministic; removed/compat routes must be absent from the router, OpenAPI, and generated clients — not just hidden.