stemedb/.claude/guides/backend/api-endpoints.md
jml 4012791e7e fix(api): enable non-strict mode for URL-encoded bracket notation
## 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>
2026-02-09 16:11:25 +00:00

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 axum handler patterns
  • Understanding of utoipa derive 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 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:

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.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.