Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/check-wiki.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Check wiki matches the API

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
check:
name: Wiki drift check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.14'

- name: Check every endpoint is documented and every documented path exists
run: python3 tools/check_wiki.py
298 changes: 240 additions & 58 deletions API/Architecture.md
Original file line number Diff line number Diff line change
@@ -1,108 +1,290 @@
---
title: API Architecture
description: Architecture Design for MVP Endpoints
description: Endpoint reference, response envelope and error model
published: true
date: 2025-11-19T20:36:00.097Z
tags: api, architecture
date: 2026-08-26T12:00:00.000Z
tags: api, architecture, endpoints, reference
editor: markdown
dateCreated: 2025-11-19T20:13:35.465Z
---

# API Architecture

> **The authoritative reference is the running instance.** Every deployment serves
> interactive documentation at `/docs` and the raw schema at `/openapi.json`, both
> generated from the code. This page describes the shape of the API and lists what it
> serves so you can read it without booting anything — but where the two disagree, `/docs`
> is right and this page is stale. Please fix it.

Item description:
All application endpoints are served under the **`/v1`** prefix. Health endpoints are not
versioned. There is no `/api` prefix — if you see one in browser dev tools it is the
storefront's own Nginx proxying `/api/v1` to `/v1`.

## Service layout

The API is a set of self-contained "mini-API" modules, each owning its models, routes and
persistence:

| Service | Prefix | Purpose |
|---|---|---|
| Item store | `/v1/items` | Product catalogue and product images |
| Customers | `/v1/customers` | Profiles and addresses, always self-scoped |
| Orders | `/v1/orders` | Draft orders, checkout, cancellation, return requests |
| Inventory | `/v1/admin/inventory` | Stock levels and reservations |
| Admin | `/v1/admin/orders` | Back-office fulfillment |
| Returns | `/v1/admin/returns` | Admin decisions on return requests |
| Webhooks | `/v1/webhooks` | Inbound payment provider callbacks |
| Health | `/health` | Liveness and readiness |

Payments and shipments have no public router of their own. They are driven by the order,
webhook and admin endpoints, and are documented under
[Orders and Fulfillment](/Orders-and-Fulfillment).

---

## Endpoints

**Auth** below means a bearer token is required. Which token is not always the same
question — admin endpoints additionally check which client issued it. See
[Authorization](/Authorization).

### Catalogue — `/v1/items`

Reads are public so that shoppers can browse before signing in. Writes are admin-only:
what is listed is what customers see, so anyone able to create, alter or delete a product
controls the shop.

| Method | Path | Purpose | Auth |
|---|---|---|---|
| `GET` | `/v1/items/` | List items | — |
| `GET` | `/v1/items/{item_uuid}` | Get item by UUID | — |
| `GET` | `/v1/items/by-sku/{sku}` | Get item by SKU | — |
| `GET` | `/v1/items/{item_uuid}/image` | Get the product image | — |
| `POST` | `/v1/items/` | Create a new item | admin |
| `PATCH` | `/v1/items/{item_uuid}` | Update item | admin |
| `DELETE` | `/v1/items/{item_uuid}` | Delete item | admin |
| `PUT` | `/v1/items/{item_uuid}/image` | Upload the product image | admin |

Product images are held in MinIO, not in the database. Uploads are capped by
`STORAGE_MAX_IMAGE_BYTES` (5 MB by default) so a single oversized file cannot fill the
object store.

### Customers — `/v1/customers`

Every route here is scoped to the caller. The customer is identified from the verified
`sub` claim on the token, so passing somebody else's id alongside your own valid token
does not get you their data.

| Method | Path | Purpose | Auth |
|---|---|---|---|
| `GET` | `/v1/customers/me` | Get my profile | yes |
| `PATCH` | `/v1/customers/me` | Update my profile | yes |
| `GET` | `/v1/customers/me/addresses` | List my addresses | yes |
| `POST` | `/v1/customers/me/addresses` | Create an address | yes |
| `PATCH` | `/v1/customers/me/addresses/{address_id}` | Update an address | yes |
| `DELETE` | `/v1/customers/me/addresses/{address_id}` | Delete an address | yes |

### Orders — `/v1/orders`

| Method | Path | Purpose | Auth |
|---|---|---|---|
| `POST` | `/v1/orders/` | Create a draft order | yes |
| `GET` | `/v1/orders/{order_id}` | Get order by ID | yes |
| `DELETE` | `/v1/orders/{order_id}` | Cancel a draft order | yes |
| `POST` | `/v1/orders/{order_id}/checkout` | Start checkout | yes |
| `POST` | `/v1/orders/{order_id}/returns` | Request a return | yes |

`POST /checkout` is the interesting one: it reserves stock and creates the payment intent.
See [Orders and Fulfillment](/Orders-and-Fulfillment).

### Back office — `/v1/admin`

| Method | Path | Purpose | Auth |
|---|---|---|---|
| `GET` | `/v1/admin/orders/` | List all orders | admin |
| `GET` | `/v1/admin/orders/pick-list` | Batch pick list | admin |
| `GET` | `/v1/admin/orders/{order_id}` | Get order detail | admin |
| `PATCH` | `/v1/admin/orders/{order_id}/status` | Override order status | admin |
| `POST` | `/v1/admin/orders/{order_id}/shipments` | Create shipment | admin |
| `POST` | `/v1/admin/orders/{order_id}/label` | Trigger DHL label job | admin |
| `GET` | `/v1/admin/orders/{order_id}/label` | Download carrier label | admin |
| `GET` | `/v1/admin/orders/{order_id}/packing-slip` | Packing slip | admin |
| `POST` | `/v1/admin/orders/{order_id}/ship` | Mark order as shipped | admin |
| `PATCH` | `/v1/admin/returns/{return_id}` | Approve, reject or complete a return | admin |

### Inventory — `/v1/admin/inventory`

Stock is deliberately separate from the catalogue. The `inventory` block on an item is
catalogue metadata for display; authoritative stock lives here and is what checkout
reserves against.

| Method | Path | Purpose | Auth |
|---|---|---|---|
| `POST` | `/v1/admin/inventory/` | Create inventory record | admin |
| `GET` | `/v1/admin/inventory/` | List inventory items | admin |
| `GET` | `/v1/admin/inventory/by-sku/{sku}` | Get inventory item by SKU | admin |
| `GET` | `/v1/admin/inventory/{inventory_id}` | Get inventory item by UUID | admin |
| `PATCH` | `/v1/admin/inventory/{inventory_id}` | Update stock | admin |
| `DELETE` | `/v1/admin/inventory/{inventory_id}` | Delete inventory record | admin |

### Webhooks and health

| Method | Path | Purpose | Auth |
|---|---|---|---|
| `POST` | `/v1/webhooks/stripe` | Stripe payment webhook | signature |
| `GET` | `/health` | Liveness check | — |
| `GET` | `/health/ready` | Readiness check | — |

The webhook is unauthenticated in the bearer-token sense but is not open: it verifies the
Stripe signature and rejects anything that fails, and it is idempotent — a replayed event
is a no-op returning `200`.

`/health` answers as soon as the process is up. `/health/ready` additionally probes
PostgreSQL and Redis and reports the latency and error for each, which is the one you want
behind a load balancer.

---

## The response envelope

Every JSON response shares a common envelope rather than returning a bare object:

```json
{
"uuid": "0b9e2c50-5e3b-4cc1-9a6a-2b3e9a0b1234",
"sku": "CHAIR-RED-001", // Stock Keeping Unit (like uuid just human readable)
"status": "active", // draft | active | archived
"success": true,
"message": "Items retrieved successfully",
"timestamp": "2026-08-26T11:54:20.530614Z",
"request_id": null,
"metadata": null,
"items": [ ... ]
}
```

"name": "Red Wooden Chair",
"slug": "red-wooden-chair", // shown in the url instead of uuid
The payload key is named for what it carries (`items`, `order`, `customer`, …), so a
client can tell a list response from a single-object one without inspecting types.

`request_id` carries the correlation ID. Every request passes through a middleware that
assigns one and threads it through the logs, so a customer's report of "it broke at
14:32" can be traced to the exact request across the API and the worker.

## Errors

Errors use the same envelope with `success: false` plus error fields:

```json
{
"success": false,
"message": "Item not found",
"timestamp": "2026-08-26T11:54:20.530614Z",
"request_id": "3f9c…",
"status_code": 404,
"error_code": "NOT_FOUND",
"error_category": "not_found",
"details": null
}
```

The HTTP status is derived from the error category, so a category is never reported under
an inconsistent status:

| `error_category` | HTTP status |
|---|---|
| `not_found` | 404 |
| `validation` | 422 |
| `authentication` | 401 |
| `authorization` | 403 |
| `business_rule` | 400 |
| `database` | 500 |
| `external_service` | 502 |
| `internal` | 500 |

Request validation failures (wrong types, missing fields) return `422` with an extra
`validation_errors` array, each entry carrying `loc`, `msg` and `type`. This is
FastAPI's own validation output remapped into the envelope, so the response actually
matches the `422` schema documented in `/docs` — which the raw Pydantic format does not.

Unhandled exceptions are caught by a catch-all handler, logged with the correlation ID and
returned as a generic `500`. Internal detail is never leaked to the client.

## Rate limiting

Rate limiting is applied per client IP via SlowAPI and is **opt-in per route** rather than
global. Two settings control it: `RATE_LIMIT_ENABLED` turns it off entirely (useful in
tests and local development) and `RATE_LIMIT_PER_MINUTE` sets the budget, 60 by default.
Exceeding it returns `429`.

Behind a load balancer the limiter needs to read `X-Forwarded-For` instead of the socket
address, or every request will look like it came from the proxy. See
[Deployment](/Deployment).

---

## The item shape

An item as the API returns it. Prices are integer minor units — cents, never floats:

```json
{
"uuid": "0b9e2c50-5e3b-4cc1-9a6a-2b3e9a0b1234",
"sku": "CHAIR-RED-001",
"status": "active",
"name": "Red Wooden Chair",
"slug": "red-wooden-chair",
"short_description": "Comfortable red wooden chair for dining rooms.",
"description": "Long HTML/Markdown description here...",

"categories": [
"2f61e8db-bb70-4b22-9aa0-4d7fa3b7aa11" // category UUIDs
],
"categories": ["furniture", "chairs"],
"brand": "Acme Furniture",

"price": {
"amount": 9999, // store as integer cents
"amount": 9999,
"currency": "EUR",
"includes_tax": true,
"original_amount": 12999, // optional (for discounts)
"tax_class": "standard" // e.g. standard | reduced | none
"original_amount": 12999,
"tax_class": "standard"
},

"media": {
"main_image": "https://cdn.example.com/items/chair-main.jpg",
"gallery": [
"https://cdn.example.com/items/chair-side.jpg",
"https://cdn.example.com/items/chair-back.jpg"
]
"gallery": []
},

"inventory": {
"stock_quantity": 25,
"stock_status": "in_stock", // in_stock | out_of_stock | preorder | backorder
"stock_status": "in_stock",
"allow_backorder": false
},

"shipping": {
"is_physical": true,
"weight": {
"value": 7.5,
"unit": "kg"
},
"dimensions": {
"width": 45.0,
"height": 90.0,
"length": 50.0,
"unit": "cm"
},
"shipping_class": "standard" // e.g. standard, bulky, letter
"weight": { "value": 7.5, "unit": "kg" },
"dimensions": { "width": 45.0, "height": 90.0, "length": 50.0, "unit": "cm" },
"shipping_class": "standard"
},

"attributes": {
"color": "red",
"material": "wood"
},
"attributes": { "color": "red", "material": "wood" },

"identifiers": {
"barcode": "4006381333931",
"manufacturer_part_number": "AC-CHAIR-RED-01",
"country_of_origin": "DE"
},

"custom": {
"any_plugin_can_put": "whatever_here"
},

"system": {
"log_table": "uuid_to_conversation_in_different_table",
}
"custom": { "any_plugin_can_put": "whatever_here" },
"system": {}
}
```

`status` is one of `draft`, `active` or `archived`. `stock_status` is one of `in_stock`,
`out_of_stock`, `preorder` or `backorder`.

Two fields are traps worth naming:

- **`inventory` is display metadata, not truth.** Authoritative stock lives in the
inventory service and is what checkout reserves against. An item claiming
`stock_quantity: 25` will still fail checkout if the inventory record says otherwise.
- **`custom` is free-form** and belongs to whatever plugin writes it. Nothing in the API
validates its contents.
















The nested blocks are stored as `JSONB` columns rather than normalised into separate
tables — see [Database Architecture](/Database/Architecture) for why, and for what that
costs.
Loading
Loading