Files
vmall/docs/tech-specs/rust-api.md
T
Chengdong Zhang e10cae5789 docs: API architecture ADRs, tech spec, and marketing capability tracker
Persist the modular-monolith decision (ADR 0001 handler/service/repo,
ADR 0002 keep REST) with the companion tech spec and the api-architecture
OpenSpec capability. Replace the finished mock-migration tracker with
docs/TBD-marketing.md listing the backend-less marketing domains still on
fixtures (coupons, favorites, account stats, seckill, collective, integral,
reviews). README and AGENTS.md point at the new docs.
2026-09-18 16:00:31 +08:00

105 lines
5.3 KiB
Markdown

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<AppState>`, `AuthUser`, path/query/JSON.
2. Handler maps HTTP-only concerns (`StatusCode::CREATED`) after the service returns `ApiResult<Dto>`.
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<sqlx::Error>`: `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/<ctx>` 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.