## 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>
222 lines
6.2 KiB
Markdown
222 lines
6.2 KiB
Markdown
# Add an API Endpoint
|
|
|
|
**When to use:** You need to add a new HTTP endpoint to the Episteme API.
|
|
|
|
## Prerequisites
|
|
|
|
- Familiarity with `axum` handler patterns
|
|
- Understanding of `utoipa` derive macros
|
|
- Read [ai-lookup/services/api.md](../../../ai-lookup/services/api.md) for architecture overview
|
|
|
|
## Quick Start
|
|
|
|
```bash
|
|
# 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):
|
|
|
|
```rust
|
|
#[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`:
|
|
|
|
```rust
|
|
#[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 ✅
|
|
|
|
```rust
|
|
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 ❌
|
|
|
|
```rust
|
|
// ❌ 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 ✅
|
|
|
|
```rust
|
|
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 `Vec` or `Option<Vec>` field? → Use `QsQuery`
|
|
- All fields are scalars? → Use `Query` or `AxumQuery`
|
|
|
|
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`:
|
|
|
|
```rust
|
|
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
|
|
|
|
```bash
|
|
# 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`:
|
|
|
|
```rust
|
|
#[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.rs` with `Serialize, Deserialize, ToSchema`
|
|
- [ ] `From<>` conversions between DTOs and internal types
|
|
- [ ] Handler annotated with `#[utoipa::path]`
|
|
- [ ] **Query parameters:** Use `QsQuery` if DTO has `Vec` fields, otherwise use `Query`
|
|
- [ ] Error responses documented in `responses(...)`
|
|
- [ ] Route registered on `OpenApiRouter`
|
|
- [ ] Tests cover happy path and error cases
|
|
- [ ] `cargo clippy` passes
|
|
- [ ] 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](../../../ai-lookup/patterns/api-documentation.md) - Toolchain overview
|
|
- [ai-lookup/services/api.md](../../../ai-lookup/services/api.md) - Service facts
|
|
- [Rust Guidelines](./rust-guidelines.md) - General Rust patterns
|