Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Adding a New Endpoint Checklist

This checklist walks through every file you need to touch when adding a new CRUD endpoint to Alloy. The example assumes a new entity called Widget.


Step 1: Define the Domain Model (crates/alloy-core/src/models.rs)

Add the domain struct and any associated types (ID newtype, create/update params).

#![allow(unused)]
fn main() {
// crates/alloy-core/src/models.rs

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WidgetId(pub Uuid);

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Widget {
    pub id: WidgetId,
    pub org_id: OrgId,
    pub project_id: ProjectId,
    pub name: String,
    pub created_by: UserId,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Debug, Clone)]
pub struct CreateWidgetParams {
    pub name: String,
    pub project_id: ProjectId,
}

#[derive(Debug, Clone)]
pub struct UpdateWidgetParams {
    pub name: Option<String>,
}
}

Re-export the new types from crates/alloy-core/src/lib.rs.


Step 2: Define the Repository Trait (crates/alloy-core/src/repos.rs)

Add an #[async_trait] repository trait with standard CRUD methods.

#![allow(unused)]
fn main() {
// crates/alloy-core/src/repos.rs

#[async_trait]
pub trait WidgetRepository: Send + Sync {
    async fn create(&self, org_id: OrgId, params: CreateWidgetParams, created_by: UserId) -> Result<Widget, RepoError>;
    async fn get_by_id(&self, id: WidgetId, org_id: OrgId) -> Result<Widget, RepoError>;
    async fn list(&self, org_id: OrgId, cursor: Option<String>, limit: i64) -> Result<Page<Widget>, RepoError>;
    async fn update(&self, id: WidgetId, org_id: OrgId, params: UpdateWidgetParams) -> Result<Widget, RepoError>;
    async fn delete(&self, id: WidgetId, org_id: OrgId) -> Result<(), RepoError>;
}
}

Always include org_id in every method signature for tenant isolation.


Step 3: Write the SQLite Implementation (crates/alloy-api/src/repos/sqlite_widget.rs)

Implement the trait using sqlx::SqlitePool. Follow the existing pattern in files like sqlite_project.rs or sqlite_label.rs.

  • Use TEXT for UUIDs and timestamps
  • Include AND org_id = ? in every WHERE clause
  • Register the module in crates/alloy-api/src/repos/mod.rs
#![allow(unused)]
fn main() {
// crates/alloy-api/src/repos/sqlite_widget.rs

pub struct SqliteWidgetRepo {
    pool: SqlitePool,
}

#[async_trait]
impl WidgetRepository for SqliteWidgetRepo {
    async fn create(&self, org_id: OrgId, params: CreateWidgetParams, created_by: UserId) -> Result<Widget, RepoError> {
        let id = Uuid::new_v4();
        sqlx::query("INSERT INTO widgets (id, org_id, project_id, name, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
            // ...bind and execute...
    }
    // ... other methods with AND org_id = ? in every query ...
}
}

Step 4: Write the PostgreSQL Implementation (crates/alloy-api/src/repos/pg_widget.rs)

Same trait, using sqlx::PgPool. PostgreSQL uses RLS via SET LOCAL app.tenant_id but also include explicit AND org_id = $N as defense-in-depth.

  • Use TIMESTAMPTZ for timestamps, UUID for IDs
  • Register the module in crates/alloy-api/src/repos/mod.rs
#![allow(unused)]
fn main() {
// crates/alloy-api/src/repos/pg_widget.rs

pub struct PgWidgetRepo {
    pool: PgPool,
}
}

Step 5: Write Database Migrations

Create matching migration files in both directories with the same version number. Migrations are embedded at compile time via refinery embed_migrations!.

PostgreSQLSQLite
Pathmigrations/postgres/V{N}__create_widgets.sqlmigrations/sqlite/V{N}__create_widgets.sql
IDsUUIDTEXT
TimestampsTIMESTAMPTZTEXT
StringsTEXT (not VARCHAR)TEXT
Auto-incrementBIGSERIALINTEGER PRIMARY KEY

For PostgreSQL, add an RLS policy:

-- migrations/postgres/V{N}__create_widgets.sql
CREATE TABLE widgets ( ... );
ALTER TABLE widgets ENABLE ROW LEVEL SECURITY;
CREATE POLICY widgets_tenant_isolation ON widgets
    USING (org_id = current_setting('app.tenant_id')::UUID);

Step 6: Add the Service Method (crates/alloy-core/src/services/widget.rs)

Create a service struct generic over WidgetRepository and AuditLogRepository. All business logic — ownership checks, role gates, validation, and audit logging — goes here. See Service Layer Patterns for the full pattern.

#![allow(unused)]
fn main() {
// crates/alloy-core/src/services/widget.rs

pub struct WidgetService<W: WidgetRepository, A: AuditLogRepository> {
    pub widget_repo: W,
    pub audit_repo: A,
}

impl<W: WidgetRepository, A: AuditLogRepository> WidgetService<W, A> {
    pub async fn create_widget(&self, actor: &ActorContext, params: CreateWidgetParams) -> Result<Widget, ServiceError> {
        actor.require_member()?;
        let widget = self.widget_repo.create(actor.org_id, params, actor.user_id).await?;
        record_audit(&self.audit_repo, actor, "widget.created", &widget.id.0.to_string()).await;
        Ok(widget)
    }
    // ... get, list, update, delete with ownership/role checks ...
}
}

Register the module in crates/alloy-core/src/services/mod.rs and re-export from crates/alloy-core/src/lib.rs.


Step 7: Add the HTTP Handler (crates/alloy-api/src/handlers/widget.rs)

Create the Axum handler module. Each handler converts AuthContext to ActorContext, calls the service, and maps ServiceError to ApiError.

Define routes with role-based grouping:

#![allow(unused)]
fn main() {
// crates/alloy-api/src/handlers/widget.rs

pub fn widget_routes<W: WidgetRepository + 'static, A: AuditLogRepository + 'static>(
    state: Arc<WidgetAppState<W, A>>,
) -> Router {
    let read_routes = Router::new()
        .route("/api/v1/widgets", get(list_widgets::<W, A>))
        .route("/api/v1/widgets/{id}", get(get_widget::<W, A>))
        .with_state(state.clone())
        .layer(middleware::from_fn(require_role(OrgRole::Viewer)));

    let write_routes = Router::new()
        .route("/api/v1/widgets", post(create_widget::<W, A>))
        .route("/api/v1/widgets/{id}", patch(update_widget::<W, A>))
        .route("/api/v1/widgets/{id}", delete(delete_widget::<W, A>))
        .with_state(state)
        .layer(middleware::from_fn(require_role(OrgRole::Member)));

    read_routes.merge(write_routes)
}
}

Register the module in crates/alloy-api/src/handlers/mod.rs.


Step 8: Wire the Route into the App (crates/alloy-api/src/lib.rs)

  1. Add a widget: Option<Router> field to the Routers struct
  2. Include routers.widget in the optional_routers array inside fn app()
  3. Construct the WidgetAppState and call widget_routes() in your server startup code (e.g., main.rs or build_routers())

Step 9: Add the CLI Command (crates/alloy-cli/src/commands/widget.rs)

Add a Clap subcommand module with create, get, list, update, and delete subcommands. Follow the pattern in crates/alloy-cli/src/commands/project.rs.

#![allow(unused)]
fn main() {
// crates/alloy-cli/src/commands/widget.rs

#[derive(Subcommand)]
pub enum WidgetCmd {
    Create { ... },
    Get { id: String },
    List { ... },
    Update { id: String, ... },
    Delete { id: String },
}
}

Register the module in crates/alloy-cli/src/commands/mod.rs and wire it into the top-level CLI enum.


Step 10: Add the MCP Tool (crates/alloy-mcp/src/lib.rs)

Add #[tool] annotated methods to the MCP server for each operation. Follow the existing pattern for tools like create_project or list_tickets.

#![allow(unused)]
fn main() {
// crates/alloy-mcp/src/lib.rs

#[tool(description = "Create a new widget")]
async fn create_widget(&self, name: String, project_id: String) -> Result<CallToolResult, McpError> {
    // POST /api/v1/widgets with JSON body
}

#[tool(description = "List widgets")]
async fn list_widgets(&self, cursor: Option<String>, limit: Option<i64>) -> Result<CallToolResult, McpError> {
    // GET /api/v1/widgets
}
}

Step 11: Add TUI Support (crates/alloy-tui/src/api.rs)

Add API client methods for the new endpoints. Follow the pattern in api.rs for existing entities.

#![allow(unused)]
fn main() {
// crates/alloy-tui/src/api.rs

pub async fn list_widgets(&self) -> Result<Vec<Widget>> { ... }
pub async fn create_widget(&self, params: CreateWidgetParams) -> Result<Widget> { ... }
}

Wire the new data into the appropriate TUI views if there is a natural place for it (e.g., a project detail screen).


Step 12: Update Documentation and Seed Data

Update all affected documentation in the same commit:

FileWhat to update
docs/api-reference.mdEndpoint docs, field tables, curl examples
docs/mcp-tools-reference.mdNew MCP tool parameters and behavior
docs/getting-started.mdIf the entity is part of the onboarding flow
docs/guides/*.mdAny concept guide that now covers widgets
scripts/seed-demo.shAdd seed data for the new entity

Quick-Reference File Map

StepFile(s)
Domain modelcrates/alloy-core/src/models.rs, crates/alloy-core/src/lib.rs
Repo traitcrates/alloy-core/src/repos.rs
SQLite repocrates/alloy-api/src/repos/sqlite_widget.rs, crates/alloy-api/src/repos/mod.rs
PostgreSQL repocrates/alloy-api/src/repos/pg_widget.rs, crates/alloy-api/src/repos/mod.rs
Migrationsmigrations/postgres/V{N}__*.sql, migrations/sqlite/V{N}__*.sql
Servicecrates/alloy-core/src/services/widget.rs, crates/alloy-core/src/services/mod.rs
Handler + routescrates/alloy-api/src/handlers/widget.rs, crates/alloy-api/src/handlers/mod.rs
App wiringcrates/alloy-api/src/lib.rs (Routers struct + app() fn)
CLIcrates/alloy-cli/src/commands/widget.rs, crates/alloy-cli/src/commands/mod.rs
MCPcrates/alloy-mcp/src/lib.rs
TUIcrates/alloy-tui/src/api.rs
Docsdocs/api-reference.md, docs/mcp-tools-reference.md, scripts/seed-demo.sh