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.
105 lines
5.3 KiB
Markdown
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.
|