# 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` 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, Json(req): Json, ) -> Result<(StatusCode, Json), 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` or `Option>`, 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>, // ← Array type pub limit: usize, } #[utoipa::path( get, path = "/my-endpoint", params( ("sources" = Option>, Query, description = "Filter sources"), ("limit" = usize, Query, description = "Max results"), ), // ... )] pub async fn my_handler( State(state): State, QsQuery(params): QsQuery, // ✅ CORRECT ) -> Result> { // 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, Query(params): Query, // sources will always be None! ) -> Result> { // 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, // All scalars, no arrays } pub async fn simple_handler( State(state): State, Query(params): Query, // ✅ CORRECT ) -> Result> { // ?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` 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