## Problem Dashboard sends URL-encoded query parameters: ?sources%5B%5D=rfc&sources%5B%5D=owasp (%5B = '[', %5D = ']') But QsQuery extractor used strict mode, which rejects encoded brackets: Error: "Invalid field contains an encoded bracket" Result: All corpus filters in the dashboard failed silently. ## Solution Changed QsQuery to use serde_qs non-strict mode: Config::new(5, false) // false = non-strict Now accepts BOTH: - Literal brackets: ?sources[]=rfc - Encoded brackets: ?sources%5B%5D=rfc (browsers) ## Verification ✅ URL-encoded query: ?sources%5B%5D=rfc&sources%5B%5D=community Returns: 24 items (was: error) Logs: sources=Some(["rfc", "community"]) ✅ ✅ Literal brackets: ?sources[]=rfc (still works) ✅ All 4 extractor tests pass (added encoded brackets test) ✅ Clippy clean (0 warnings) ## Files Changed - crates/stemedb-api/src/extractors.rs: Use non-strict Config - crates/stemedb-api/README.md: Document QsQuery usage - .claude/guides/backend/api-endpoints.md: Add best practices - CLAUDE.md: Reference extractors documentation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
6.2 KiB
Add an API Endpoint
When to use: You need to add a new HTTP endpoint to the Episteme API.
Prerequisites
- Familiarity with
axumhandler patterns - Understanding of
utoipaderive macros - Read ai-lookup/services/api.md for architecture overview
Quick Start
# After adding endpoint code:
cargo build -p stemedb-api
cargo test -p stemedb-api
cargo clippy -p stemedb-api -- -D warnings
Step-by-Step
1. Define DTO Types
Create request/response types in crates/stemedb-api/src/dto.rs. These derive BOTH serde (for JSON) and utoipa (for OpenAPI docs):
#[derive(Serialize, Deserialize, ToSchema)]
pub struct MyRequest {
/// Description appears in OpenAPI docs.
#[schema(example = "Tesla_Inc")]
pub subject: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct MyResponse {
pub hash: String,
pub status: String,
}
Rules:
- DTOs live in
dto.rs, NOT in handler files - Implement
From<stemedb_core::types::X>for conversions to/from internal types - Use
#[schema(example = "...")]for meaningful OpenAPI examples - Never expose internal rkyv types in the public API
2. Write the Handler
Create handler in crates/stemedb-api/src/handlers/{name}.rs:
#[utoipa::path(
post,
path = "/my-endpoint",
request_body = MyRequest,
responses(
(status = 202, description = "Accepted", body = MyResponse),
(status = 400, description = "Validation failed", body = ApiError),
),
tag = "assertions"
)]
pub async fn my_handler(
State(state): State<AppState>,
Json(req): Json<MyRequest>,
) -> Result<(StatusCode, Json<MyResponse>), ApiError> {
// Convert DTO -> internal type
// Call library code (QueryEngine, Ingestor, etc.)
// Convert result -> DTO
// Return
}
Rules:
- Handlers are thin: convert types, delegate to library code, convert back
- No business logic in handlers
- Always return
Result<_, ApiError>for consistent error responses - Use
tag = "..."to group related endpoints in docs
2.1. Query Parameters with Arrays
CRITICAL: If your query parameter DTO contains Vec<T> or Option<Vec<T>>, you MUST use QsQuery instead of standard Query.
Use QsQuery for Array Parameters ✅
use crate::extractors::QsQuery;
#[derive(Deserialize, ToSchema)]
pub struct FilterRequest {
/// List of sources to filter by
pub sources: Option<Vec<String>>, // ← Array type
pub limit: usize,
}
#[utoipa::path(
get,
path = "/my-endpoint",
params(
("sources" = Option<Vec<String>>, Query, description = "Filter sources"),
("limit" = usize, Query, description = "Max results"),
),
// ...
)]
pub async fn my_handler(
State(state): State<AppState>,
QsQuery(params): QsQuery<FilterRequest>, // ✅ CORRECT
) -> Result<Json<MyResponse>> {
// Dashboard sends: ?sources[]=rfc&sources[]=community&limit=10
// params.sources = Some(vec!["rfc", "community"])
}
Never Use Standard Query with Arrays ❌
// ❌ WRONG - Will silently fail!
pub async fn broken_handler(
State(state): State<AppState>,
Query(params): Query<FilterRequest>, // sources will always be None!
) -> Result<Json<MyResponse>> {
// Dashboard sends: ?sources[]=rfc
// Result: params.sources = None (filter doesn't work!)
}
Use Standard Query for Scalar Parameters Only ✅
use axum::extract::Query;
#[derive(Deserialize, ToSchema)]
pub struct SimpleRequest {
pub limit: usize,
pub offset: usize,
pub category: Option<String>, // All scalars, no arrays
}
pub async fn simple_handler(
State(state): State<AppState>,
Query(params): Query<SimpleRequest>, // ✅ CORRECT
) -> Result<Json<MyResponse>> {
// ?limit=10&offset=0&category=security
}
Why: The StemeDB Dashboard uses JavaScript's URLSearchParams which generates bracket notation for arrays (?filters[]=a&filters[]=b). Standard axum::extract::Query uses serde_urlencoded which doesn't support brackets. QsQuery uses serde_qs which does.
Quick Check:
- DTO has
VecorOption<Vec>field? → UseQsQuery - All fields are scalars? → Use
QueryorAxumQuery
See crates/stemedb-api/src/extractors.rs for detailed documentation.
3. Register the Route
Add to the OpenApiRouter in crates/stemedb-api/src/lib.rs:
let app = OpenApiRouter::new()
.route("/my-endpoint", post(my_handler))
// ...existing routes...
The OpenApiRouter automatically collects the #[utoipa::path] metadata. No separate registration step.
4. Verify
# Build and test
cargo test -p stemedb-api
# Check docs generated correctly
cargo run -p stemedb-api &
curl localhost:18180/api-doc/openapi.json | jq .paths
Error Responses
All error responses use a consistent ApiError type that implements ToSchema:
#[derive(Serialize, ToSchema)]
pub struct ApiError {
pub error: String,
pub code: String,
}
Map internal errors to API errors in the handler layer. Never leak internal error details.
Checklist
- DTO types in
dto.rswithSerialize, Deserialize, ToSchema From<>conversions between DTOs and internal types- Handler annotated with
#[utoipa::path] - Query parameters: Use
QsQueryif DTO hasVecfields, otherwise useQuery - Error responses documented in
responses(...) - Route registered on
OpenApiRouter - Tests cover happy path and error cases
cargo clippypasses- Schema examples are meaningful (not "string")
Troubleshooting
OpenAPI spec missing my endpoint
The handler must have #[utoipa::path] AND be registered on OpenApiRouter (not a plain axum::Router).
Type not showing in schemas
The DTO must derive ToSchema. If it references other types, those must also derive ToSchema.
Swagger UI not updating
Hard refresh the browser. The spec is regenerated on each startup.
Related
- ai-lookup/patterns/api-documentation.md - Toolchain overview
- ai-lookup/services/api.md - Service facts
- Rust Guidelines - General Rust patterns