Skip to content

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:

rust
#[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/:

bash
# Name format: NNN_description.sql (NNN is next sequential number)
touch crates/adapters-postgres/migrations/018_create_foos.sql
sql
CREATE 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/:

rust
// 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 in crates/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:

rust
// 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:

rust
#[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:

json
{
  "error": {
    "code": "not_found",
    "message": "Foo not found"
  }
}

7. Write Tests

Create an integration test file:

rust
// 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.rs for the full TestAppBuilder API and request helpers.

Common Patterns

Query Parameters with Defaults

rust
#[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:

rust
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:

rust
#[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:

rust
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