Adding an Endpoint
Step-by-step guide for adding a new API endpoint to Mailman.
1. Define the Repository Trait (if new data access)
If the endpoint needs new database operations, add a trait method in crates/core/src/repository.rs:
#[async_trait]
pub trait FooRepository: Send + Sync {
async fn get_foo(&self, id: Uuid) -> Result<Option<Foo>, DomainError>;
async fn create_foo(&self, foo: &Foo) -> Result<(), DomainError>;
}Then implement it in crates/adapters-postgres/src/postgres.rs on the PostgresRepository struct.
2. Add Database Migration (if new tables/columns)
Create a new SQL file in crates/adapters-postgres/migrations/:
# Name format: NNN_description.sql (NNN is next sequential number)
touch crates/adapters-postgres/migrations/018_create_foos.sqlCREATE TABLE foos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);3. Create the Route Handler
Add a new file in crates/http/src/routes/:
// crates/http/src/routes/foos.rs
use std::sync::Arc;
use axum::{extract::{Path, State}, Json, Router, routing::get};
use crate::{error::ApiError, state::AppState};
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/foos", get(list_foos))
.route("/foos/{id}", get(get_foo))
}
async fn list_foos(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<FooResponse>>, ApiError> {
// Authentication is handled by middleware — no extractor needed.
// Handler logic here.
}
async fn get_foo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<FooResponse>, ApiError> {
// Handler logic
}Authentication is applied as middleware in
crates/http/src/lib.rs— handlers don't need an auth extractor. See existing handlers incrates/http/src/routes/for patterns.
4. Register the Route
Add the route module and merge it into the router in crates/http/src/lib.rs:
// In routes/mod.rs
pub mod foos;
// In lib.rs, inside build_router()
.merge(routes::foos::routes())5. Add Request/Response Types
Define request and response structs in the route file:
#[derive(Deserialize)]
pub struct CreateFooRequest {
#[garde(length(min = 1))]
pub name: String,
}
#[derive(Serialize)]
pub struct FooResponse {
pub id: Uuid,
pub name: String,
pub created_at: DateTime<Utc>,
}6. Handle Errors
Map domain errors to HTTP responses via AppError in crates/http/src/error.rs. The error type automatically produces the standard JSON envelope:
{
"error": {
"code": "not_found",
"message": "Foo not found"
}
}7. Write Tests
Create an integration test file:
// crates/http/tests/foos_integration.rs
use axum::http::{Method, StatusCode};
use mailman_http::router_with_state;
use mailman_test_utils::*;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
#[sqlx::test]
async fn create_foo_returns_201(pool: PgPool) {
// Arrange
let state = TestAppBuilder::new(pool).build();
let app = router_with_state(state);
// Act
let request = authenticated_json_request(
Method::POST,
"/foos",
&json!({"name": "test"}),
);
let response = app.oneshot(request).await.unwrap();
// Assert
assert_eq!(response.status(), StatusCode::CREATED);
}
#[sqlx::test]
async fn get_foo_returns_404_when_not_found(pool: PgPool) {
// Arrange
let state = TestAppBuilder::new(pool).build();
let app = router_with_state(state);
// Act
let request = authenticated_request(Method::GET, &format!("/foos/{}", uuid::Uuid::new_v4()));
let response = app.oneshot(request).await.unwrap();
// Assert
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}See
crates/test-utils/src/lib.rsfor the fullTestAppBuilderAPI and request helpers.
Common Patterns
Query Parameters with Defaults
#[derive(Deserialize)]
pub struct ListParams {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
pub since: Option<String>,
}
fn default_limit() -> i64 { 20 }Clamp values to spec limits in the handler: let limit = params.limit.min(100);
Timestamp Filtering
Accept ISO 8601 strings in query params, parse in the handler:
let since = params.since
.map(|s| DateTime::parse_from_rfc3339(&s).map(|d| d.with_timezone(&Utc)))
.transpose()
.map_err(|_| AppError::from(DomainError::Validation("Invalid timestamp".into())))?;List Endpoint Response
Include pagination metadata in list responses:
#[derive(Serialize)]
pub struct ListResponse<T> {
pub items: Vec<T>,
pub total: i64,
pub limit: i64,
pub offset: i64,
}ThreadId Parsing
ThreadId uses UUID format. Invalid UUIDs return 404 (not 400) since "thread not found" is correct semantics:
let thread_id: ThreadId = id.parse()
.map_err(|_| AppError::from(DomainError::NotFound("Thread not found".into())))?;8. Update OpenAPI Spec
Add the endpoint to openapi.yaml at the repo root. The spec is the source of truth for the API surface.
Checklist
- [ ] Repository trait in
core/src/repository.rs(if needed) - [ ] Postgres implementation in
adapters-postgres/src/postgres.rs - [ ] Migration in
adapters-postgres/migrations/ - [ ] Route handler in
http/src/routes/ - [ ] Route registered in
http/src/lib.rs - [ ] Integration tests in
http/tests/ - [ ] OpenAPI spec updated in
openapi.yaml