Prevent oversell by conditioning stock updates and locking SKUs in primary-key order, and record the same concurrent-counter rule in the API spec and agent guide. Co-authored-by: Cursor <cursoragent@cursor.com>
106 lines
6.0 KiB
Markdown
106 lines
6.0 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.
|
|
- **Concurrent counters:** any column that concurrent requests decrement (today: `skus.stock`) follows the same “predicate or it did not happen” rule as status. `SELECT … FOR UPDATE` of multiple rows MUST `ORDER BY` the primary key so lock order is global. Decrement MUST be `UPDATE … SET col = col - $qty WHERE id = $1 AND col >= $qty`; `rows_affected = 0` → `Conflict` (do not rely on `CHECK (col >= 0)` as the only signal). Restore with `col = col + n`, never `SET col = $absolute` from a stale read. Merchant overwrite (`SET stock = $n` on SKU upsert) is assignment, not decrement. Remaining shipment qty is not a stored counter: `create` serializes on `orders … FOR UPDATE` and checks remainder in that transaction.
|
|
- **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.
|