Rust API tech spec ================== Companion to [ADR 0001](../adr/0001-rust-api-modular-monolith.md) and [ADR 0002](../adr/0002-keep-http-rest-not-graphql.md). This document describes the **current** `vmall-api` layout and the rules new code must follow. HTTP behavior for each domain remains in `openspec/specs/` (auth, catalog, cart, order, …). Context and motivation ---------------------- Handlers previously mixed Axum extracts, business rules, and sqlx. The crate is now a modular monolith so checkout, fulfillment, and identity can be shared across the three role surfaces without changing REST URLs or JSON. Goals: - One crate, one process, Postgres + Redis. - Handler → service → repository (or store) per bounded context. - Stable REST contract for `@vmall/shared` and HTTP integration tests. Non-goals: - GraphQL as the primary API ([ADR 0002](../adr/0002-keep-http-rest-not-graphql.md)). - Hexagonal ports/adapters, a generic Repository trait, or a second web framework. - Changing error envelope, money representation, or RBAC roles. Implementation considerations ----------------------------- - **Crate:** `apps/api`, package `vmall-api`, Axum 0.8, sqlx 0.8, Redis connection manager. - **Migrations:** `apps/api/migrations/`, append-only; run on boot against a **single** `PgPool` shared with the server (see `main.rs` + `state::assemble`). - **Money:** `i64` minor units + ISO code; `money::convert_minor` is a pure function, not a repository. - **SQL:** `sqlx::query*` / `query_as` with explicit binds. Prefer column lists over `SELECT *` on `users` (use `USER_COLUMNS` + `User` row vs `UserPublic` JSON). - **State machines:** `UPDATE … WHERE status = …`; zero rows → `ApiError::Conflict`, never a silent no-op success. - **RBAC:** `AuthUser::require`, `require_customer`, `require_admin`, `require_shop` / `own_shop`. Cross-shop resource access is 404, not 403. High-level request flow ----------------------- ``` Nuxt + @vmall/shared → Handler (Axum) → Service (use case) → Repo / cart store → Postgres | Redis ``` 1. Handler extracts `State`, `AuthUser`, path/query/JSON. 2. Handler maps HTTP-only concerns (`StatusCode::CREATED`) after the service returns `ApiResult`. 3. Service opens transactions when more than one write must commit together (checkout, default address, shipment create). 4. Repository functions take `&PgPool`, `&mut PgConnection`, or `&mut Transaction` so a service can compose them. Module map ---------- | Module | Bounded context | Typical routes | |--------|-----------------|----------------| | `identity` | Auth, users, role assignment | `/auth/*`, `/admin/users` | | `catalog` | Products, SKUs, categories, brands | `/products`, `/shop/products`, `/brands` | | `cart` | Redis cart + purchasable snapshots | `/cart` | | `order` | Checkout, pay, cancel, lists by scope | `/orders`, `/shop/orders`, `/admin/orders` | | `fulfillment` | Shipments, delivery confirmation | `/shipments`, `/shop/shipments` | | `billing` | Invoice request / issue | `/invoices`, `/shop/invoices` | | `address` | Customer address book | `/addresses` | | `shop` | Shop CRUD, profiles, `/shop/profile` | `/shops`, `/admin/shops` | | `content` | Home banners / promos / links | `/content/home`, `/admin/content` | | `currency` | Rates and convert | `/currencies` | | `health` | Liveness / readiness | `/health`, `/ready` | Each module typically contains `mod.rs`, `handlers.rs`, `service.rs`, and optionally `repo.rs` / `dto.rs` / `store.rs`. Merge routers in `modules::api_router()`. Shared types live in `models.rs` (sqlx `FromRow` + enums). API-facing user JSON is `UserPublic` (no `password_hash`). Error handling -------------- `ApiError` serializes as `{"error":{"code","message"}}`: | Variant | HTTP | `code` | |---------|------|--------| | `NotFound` | 404 | `NOT_FOUND` | | `BadRequest` | 400 | `BAD_REQUEST` | | `Unauthorized` | 401 | `UNAUTHORIZED` | | `Forbidden` | 403 | `FORBIDDEN` | | `Conflict` | 409 | `CONFLICT` | | `Internal` | 500 | `INTERNAL` (generic message; details in logs) | `From`: `RowNotFound` → 404; unique / check violations → 409. Domain-specific unique messages use `unique_conflict(err, "email already registered")`. Future-proofing --------------- - New capabilities get a new or existing `modules/` plus OpenSpec; they do not add SQL to handlers. - A later read-only GraphQL surface would sit beside REST and call the same services. - Compile-time `query_as!` may replace string SQL incrementally; it is not required for new queries. Testing approach ---------------- - **HTTP contract:** `apps/api/tests/*.rs` via `tests/common/mod.rs` (`spawn_app`, unique slugs). These tests are the REST regression gate. - **Service:** `tests/order_service.rs` (and similar) call services with `spawn_state()` — empty cart, stock 409, split-by-shop, illegal status transitions. - **Pure functions:** `money`, `http::pagination` unit tests in-module. Acceptance criteria ------------------- - New write use cases live in a service; handlers do not embed sqlx except trivial reads if a service would be empty ceremony. - `cargo test -p vmall-api` stays green and repeatable against `vmall_test` + Redis. - REST paths and JSON shapes used by `@vmall/shared` do not change without an OpenSpec change.