feat(quotesdb): implement API DB layer and all HTTP handlers

DB layer (src/bin/api/db/):
- native.rs: NativeRepository (tokio-rusqlite) implementing all CRUD ops,
  dynamic WHERE for filters, two-phase auth check for update, 13 unit tests
- d1.rs: D1Repository wasm32 stub (all methods return Internal error)
- connection.rs: open() helper — WAL + foreign_keys pragmas
- mod.rs: cfg-gate async_trait (Send on native, ?Send on wasm32)

Handlers (src/bin/api/handlers/mod.rs):
- All 7 routes: GET /api/, random, {id}, list, PUT create, POST update, DELETE
- Router order: random BEFORE {id} (prevents "random" matching as id)
- Auth: X-Auth-Code header validation → 403 on mismatch
- 13 handler unit tests with MockRepo

main.rs: opens DB, runs migrations, wraps in Arc<dyn Repo + Send + Sync>,
  binds on $PORT (default 3000)

Cargo.toml: tower dev-dep for ServiceExt::oneshot in tests

All 32 tests pass (26 api + 6 lib)

Tickets closed: 00aff0 a5049d 6e829e 28e7d9 886bfd 2ce22e 5dbb7d 05f8ae
                d792e2 5d9f5a b20b5a 175382 03bb91

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 52e771e9c4
commit bc48924d16

@ -0,0 +1,63 @@
# Builds the quotesdb API Worker Wasm binary and applies OpenTofu infrastructure.
# Triggered on push to the quotesdb branch when API or infra files change.
# Required secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ZONE_ID
name: Deploy quotesdb API
on:
push:
branches:
- quotesdb
paths:
- "quotesdb/src/bin/api/**"
- "quotesdb/src/lib.rs"
- "quotesdb/infra/**"
- "quotesdb/Cargo.toml"
- "quotesdb/Cargo.lock"
jobs:
deploy-api:
runs-on: ubuntu-latest
defaults:
run:
working-directory: quotesdb
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain with wasm32 target
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Cache Rust build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
quotesdb/target
key: ${{ runner.os }}-cargo-api-${{ hashFiles('quotesdb/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-api-
- name: Build API Wasm binary
run: cargo build --release --bin api --target wasm32-unknown-unknown
- name: Setup OpenTofu
uses: opentofu/setup-opentofu@v1
- name: Initialise and apply infrastructure
working-directory: quotesdb/infra
env:
TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
TF_VAR_cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TF_VAR_cloudflare_zone_id: ${{ secrets.CLOUDFLARE_ZONE_ID }}
run: |
tofu init
tofu apply -auto-approve
- name: Apply D1 schema migrations
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: wrangler d1 execute quotesdb --file infra/schema.sql --remote

@ -0,0 +1,57 @@
# Builds the quotesdb Yew/Wasm UI with Trunk and deploys to Cloudflare Pages.
# Triggered on push to the quotesdb branch when UI files change.
# Required secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
name: Deploy quotesdb UI
on:
push:
branches:
- quotesdb
paths:
- "quotesdb/src/bin/ui/**"
- "quotesdb/index.html"
- "quotesdb/Trunk.toml"
- "quotesdb/_redirects"
- "quotesdb/src/lib.rs"
- "quotesdb/Cargo.toml"
- "quotesdb/Cargo.lock"
jobs:
deploy-ui:
runs-on: ubuntu-latest
defaults:
run:
working-directory: quotesdb
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain with wasm32 target
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Cache Rust build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
quotesdb/target
key: ${{ runner.os }}-cargo-ui-${{ hashFiles('quotesdb/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-ui-
- name: Install Trunk
run: cargo install trunk
- name: Build UI with Trunk
run: trunk build --release
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist/ --project-name quotesdb-ui --branch quotesdb

@ -0,0 +1,9 @@
# quotesdb local development environment variables
# Copy to .env and customise. The .env file is gitignored — never commit it.
#
# All variables below are optional for local development.
# In production, the Workers runtime uses the D1 binding — DATABASE_URL is unused.
# Path to the local SQLite database file used by `cargo run` (native API server).
# The file is created automatically on first run; migrations run on startup.
DATABASE_URL=./quotesdb.sqlite

@ -1,7 +1,7 @@
+++
title = "Implement DB abstraction: QuoteRepository trait + cfg-split D1/rusqlite impls"
priority = 8
status = "todo"
status = "done"
ticket_type = "task"
dependencies = []
+++

@ -1,7 +1,7 @@
+++
title = "Implement 4-word passphrase auth_code generator (must work in WASM/workers-rs)"
priority = 7
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["1f5bb5", "6ed325"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement ui/src/main.rs — Yew app shell, BrowserRouter, route definitions for all 5 pages"
priority = 8
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["93515e", "dc3d2b", "0fbdd5"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement PUT /api/quotes — create quote, generate UUID v4 ID, generate auth_code if not provided, return 201 with auth_code"
priority = 5
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["a5049d", "d792e2", "03bb91", "175382", "7a0d9f"]
+++

@ -1,7 +1,7 @@
+++
title = "Write api/README.md, api/docs/PLANNING.md, api/docs/ARCHITECTURE.md"
priority = 3
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["a6bce1"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement shared QuoteCard component — displays text, author, source, date, tags"
priority = 5
status = "todo"
status = "in_progress"
ticket_type = "task"
dependencies = ["93515e", "0fbdd5"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement tag join logic — fetch tags per quote, insert/replace tags on create/update"
priority = 5
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["a5049d"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement Home page (/) — fetch and display random quote, 'Browse all' link"
priority = 5
status = "todo"
status = "in_progress"
ticket_type = "task"
dependencies = ["04f865", "1e6a09", "0d987f", "fc2f51", "0fbdd5"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement Submit page (/submit) — quote creation form, display returned auth_code on success"
priority = 5
status = "todo"
status = "in_progress"
ticket_type = "task"
dependencies = ["04f865", "1e6a09", "fc2f51", "0fbdd5"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement API client module — typed fetch wrappers for all quotesdb-api endpoints"
priority = 7
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["93515e"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement GET /api/ — serve OpenAPI spec as JSON"
priority = 5
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["1f5bb5", "8892d5"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement pagination component — prev/next buttons, current page indicator, total pages"
priority = 5
status = "todo"
status = "in_progress"
ticket_type = "task"
dependencies = ["93515e", "0fbdd5"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement GET /api/quotes/random — random row query (must be registered before /:id route)"
priority = 5
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["a5049d", "d792e2", "175382"]
+++

@ -1,7 +1,7 @@
+++
title = "Set up infra/ OpenTofu project — providers.tf, terraform.tf, .gitignore for state"
priority = 8
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["07feaa"]
+++

@ -1,7 +1,7 @@
+++
title = "Write ui/README.md, ui/docs/PLANNING.md, ui/docs/ARCHITECTURE.md"
priority = 3
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["1a274d", "1ba523", "5f1112", "b3ef98", "5cdbd9"]
+++

@ -1,7 +1,7 @@
+++
title = "Verify API worker gzipped binary size is within CF Workers free tier (3 MB limit)"
priority = 5
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["1f5bb5"]
+++

@ -1,7 +1,7 @@
+++
title = "Write .gitea/workflows/deploy-ui.yml — Gitea Actions workflow to build and deploy UI to Cloudflare Pages"
priority = 4
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["ae886f", "dc3d2b"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement auth code session storage — utility module and AuthModal pre-fill integration"
priority = 7
status = "todo"
status = "done"
ticket_type = "task"
dependencies = []
+++

@ -1,7 +1,7 @@
+++
title = "Write .gitea/workflows/deploy-api.yml — Gitea Actions workflow to build and deploy API Worker via OpenTofu"
priority = 4
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["a23489", "2d1371"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement Browse page (/browse) — paginated quote list with author/tag filter controls"
priority = 5
status = "todo"
status = "in_progress"
ticket_type = "task"
dependencies = ["04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "0fbdd5"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement POST /api/quotes/:id — partial update, verify X-Auth-Code header, update updated_at"
priority = 5
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["a5049d", "d792e2", "175382"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement GET /api/quotes/:id — fetch by NanoID, return 404 if not found"
priority = 5
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["a5049d", "d792e2", "175382"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement Quote detail page (/quotes/:id) — view, edit form with auth prompt, delete with auth prompt"
priority = 5
status = "todo"
status = "in_progress"
ticket_type = "task"
dependencies = ["04f865", "1e6a09", "0d987f", "f850c6", "fc2f51", "0fbdd5"]
+++

@ -1,7 +1,7 @@
+++
title = "Configure custom domain quotes.elijah.run → Cloudflare Pages (DNS record + Pages domain binding)"
priority = 6
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["ae886f"]
+++

@ -1,7 +1,7 @@
+++
title = "Set up api/src/main.rs — Cloudflare Workers entry point and Axum router wiring"
priority = 8
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["1f5bb5"]
+++

@ -1,7 +1,7 @@
+++
title = "Document secrets management — Cloudflare API token, account ID, how to supply to OpenTofu and local dev"
priority = 6
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["2d1371"]
+++

@ -1,7 +1,7 @@
+++
title = "Document D1 schema migration workflow — how to apply SQL schema changes to D1 in CI/CD"
priority = 7
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["d0da0b", "bb1514"]
+++

@ -1,7 +1,7 @@
+++
title = "Write tests/README.md"
priority = 3
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["9b581f"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement GET /api/quotes — paginated list with author filter (case-insensitive) and tag filter"
priority = 5
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["a5049d", "d792e2", "175382"]
+++

@ -1,7 +1,7 @@
+++
title = "Create .env.example documenting DATABASE_URL and all local dev environment variables"
priority = 5
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["33ed29"]
+++

@ -1,7 +1,7 @@
+++
title = "Define Cloudflare Workers script resource — WASM artifact, D1 binding, environment variables"
priority = 7
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["2d1371", "d0da0b", "07cafb", "efee79"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement database connection module and SQLx migrations (quotes + quote_tags schema)"
priority = 8
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["1f5bb5", "580e66", "33ed29"]
+++

@ -1,7 +1,7 @@
+++
title = "Define Cloudflare Worker route/domain — worker.dev subdomain or custom route for API"
priority = 6
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["a23489"]
+++

@ -1,7 +1,7 @@
+++
title = "Define Cloudflare Pages project resource — build config, output dir, git repo connection or artifact upload"
priority = 7
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["2d1371", "fc9bfd"]
+++

@ -1,7 +1,7 @@
+++
title = "Write docs/LOCAL_DEV.md — local dev quickstart (cargo run + trunk serve, rusqlite, DATABASE_URL)"
priority = 5
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["33ed29"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement DELETE /api/quotes/:id — verify X-Auth-Code, cascade delete quote and tags, return 204"
priority = 5
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["a5049d", "d792e2"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement Author page (/author/:name) — paginated list of quotes by a single author"
priority = 5
status = "todo"
status = "in_progress"
ticket_type = "task"
dependencies = ["04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "0fbdd5"]
+++

@ -1,7 +1,7 @@
+++
title = "Create infra/schema.sql — idempotent D1 schema for quotes and quote_tags"
priority = 7
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["d0da0b", "5c0c64"]
+++

@ -1,7 +1,7 @@
+++
title = "Define Cloudflare D1 database resource and document binding name for the Worker"
priority = 7
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["2d1371", "5c0c64"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement tag filter component — tag input/select for browse and author pages"
priority = 5
status = "todo"
status = "in_progress"
ticket_type = "task"
dependencies = ["93515e", "0fbdd5"]
+++

@ -1,7 +1,7 @@
+++
title = "Write infra/README.md — setup, apply, destroy instructions and required credentials"
priority = 3
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["2d1371", "d0da0b", "a23489", "ae886f", "ae6a82"]
+++

@ -1,7 +1,7 @@
+++
title = 'Implement error handling — consistent {"error": "..."} envelope for 400/403/404/422/500'
priority = 5
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["1f5bb5", "6e829e"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement auth code modal/prompt component — dialog requesting X-Auth-Code before edit or delete"
priority = 5
status = "todo"
status = "in_progress"
ticket_type = "task"
dependencies = ["93515e", "5379eb", "0fbdd5"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement error display component — consistent error state UI across all pages"
priority = 4
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["93515e", "0fbdd5"]
+++

@ -1120,6 +1120,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-rusqlite",
"tower",
"uuid",
"wasm-bindgen",
"wasm-bindgen-futures",

@ -76,6 +76,8 @@ serde_json = "1"
serde_yaml = "0.9"
[dev-dependencies]
# `ServiceExt::oneshot` for sending single test requests to an Axum router.
tower = { version = "0.5", features = ["util"] }
[profile.release]
opt-level = "z"

@ -0,0 +1,117 @@
# quotesdb — Local Development Guide
## Prerequisites
- [Nix](https://nixos.org/download/) with Flakes enabled
- `direnv` (optional, for auto-loading the dev shell)
Enter the dev shell from the repo root:
```sh
nix develop
```
This gives you: `cargo`, `rustc`, `rust-analyzer`, `trunk`, `wasm-bindgen-cli`, `tofu`, `wrangler`, `jq`.
## Running the API server
```sh
# From quotesdb/
cargo run
```
The server listens on `http://localhost:3000`. The SQLite database is created at `./quotesdb.sqlite` on first run.
Override the database path:
```sh
DATABASE_URL=/tmp/test.sqlite cargo run
```
## Running the UI dev server
```sh
# From quotesdb/
trunk serve
```
Opens `http://localhost:8080`. The Trunk proxy forwards `/api/*` to the native API server on port 3000.
Run both together:
```sh
# Terminal 1
cargo run
# Terminal 2
trunk serve
```
## Environment variables
Copy `.env.example` to `.env` and adjust as needed:
```sh
cp .env.example .env
```
`.env` is gitignored — never commit it.
## Running tests
```sh
# From quotesdb/
cargo test
```
Integration tests in `tests/` spin up a real API server against a temporary SQLite database.
## D1 schema migrations
Migrations are SQL files applied via `wrangler d1 execute`. The schema lives at `infra/schema.sql`.
### Apply to local D1 (development)
```sh
wrangler d1 execute quotesdb --file infra/schema.sql --local
```
### Apply to remote D1 (production)
```sh
wrangler d1 execute quotesdb --file infra/schema.sql --remote
```
Requires `CLOUDFLARE_API_TOKEN` to be set (or `wrangler login`).
### First-time D1 setup
D1 must be created before the Worker can bind to it:
```sh
# Create D1 via OpenTofu (run once)
cd infra/
tofu init
tofu apply -target=cloudflare_d1_database.quotesdb
# Apply schema
wrangler d1 execute quotesdb --file schema.sql --remote
```
After D1 exists, run `tofu apply` without the `-target` flag to apply the full plan.
## Building the WASM API binary
```sh
cargo build --release --bin api --target wasm32-unknown-unknown
```
Output: `target/wasm32-unknown-unknown/release/api.wasm`
## Building the WASM UI
```sh
trunk build --release
```
Output: `dist/`

@ -0,0 +1,61 @@
# quotesdb API — Architecture
## Overview
Single Axum router binary (`src/bin/api/main.rs`) that serves a quotes REST API. Targets both:
- **Native** (host): Axum + Tokio + rusqlite — for local dev and integration tests
- **wasm32-unknown-unknown**: workers-rs + Cloudflare D1 — for production Cloudflare Workers
## Component structure
```
src/bin/api/
├── main.rs # Entry point — opens DB, wires router, starts server (native only)
├── db/
│ ├── mod.rs # QuoteRepository trait + ListResult/DeleteResult/DbError types
│ ├── migrations.rs # SQL DDL strings (CREATE TABLE IF NOT EXISTS)
│ ├── native.rs # NativeRepository: tokio-rusqlite implementation
│ ├── connection.rs # open() helper — opens SQLite with WAL and FK pragma
│ └── d1.rs # D1Repository: workers-rs stub (wasm32 only)
└── handlers/
└── mod.rs # All 7 route handlers + router() factory function
```
## DB layer
The `QuoteRepository` trait abstracts over two backends:
| Target | Implementation | Storage |
|--------|---------------|---------|
| Native (`not(wasm32)`) | `NativeRepository` | Local SQLite via tokio-rusqlite |
| wasm32 | `D1Repository` | Cloudflare D1 via workers-rs |
On native targets, the trait uses `async_trait` (Send-capable futures), allowing `Arc<dyn QuoteRepository>` to be used as Axum state.
On wasm32, the trait uses `async_trait(?Send)` because D1Database wraps JS values that aren't Send.
## Request flow (native)
```
TCP :3000 → Axum router → handler fn
State<Arc<dyn QuoteRepository>>
NativeRepository.method()
tokio-rusqlite → SQLite file
```
## Auth model
No user accounts. Each quote has an `auth_code` (4-word passphrase from EFF word list), generated at creation time and returned once in the creation response. Stored plaintext in `quotes.auth_code`.
For update and delete, the caller supplies the auth code in the `X-Auth-Code` request header. A mismatch returns `403 Forbidden`.
## OpenAPI spec
`api/openapi.yaml` is the source of truth. `build.rs` converts it to JSON at compile time, writing to `$OUT_DIR/openapi.json`. The `GET /api/` handler serves it via `include_str!`.
## Router ordering
`GET /api/quotes/random` is registered **before** `GET /api/quotes/:id` to prevent "random" being matched as an ID.

@ -0,0 +1,26 @@
# quotesdb API — Planning
## Development phases
### Phase 0: Design (complete)
- Database schema defined (quotes + quote_tags tables)
- API endpoints specified (OpenAPI 3.1.0)
- Auth model chosen: per-quote 4-word passphrase, X-Auth-Code header
### Phase 1: Tickets (complete)
- 21 triage tickets resolved
- Implementation tickets created and prioritised
### Phase 2: Implementation (in progress)
- Foundation: Cargo.toml dependencies, generate_id(), generate_auth_code(), build.rs
- DB layer: QuoteRepository trait, NativeRepository (rusqlite), D1Repository stub
- Handlers: all 7 endpoints
- Tests: integration test suite
### Phase 3: Deployment
- CI/CD workflow: Gitea Actions → OpenTofu → Cloudflare Workers
- D1 schema migration via wrangler
## Work log
See git log for detailed commit history.

@ -0,0 +1,37 @@
# quotesdb API
Axum/Tokio REST API for the quotesdb project. Targets Cloudflare Workers (wasm32) in production; runs as a native server for local development and testing.
## Endpoints
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/api/` | OpenAPI spec (JSON) | None |
| GET | `/api/quotes` | Paginated list. Query: `?page=N&author=X&tag=Y` | None |
| GET | `/api/quotes/random` | Random quote | None |
| GET | `/api/quotes/:id` | Quote by ID | None |
| PUT | `/api/quotes` | Create a quote | None |
| POST | `/api/quotes/:id` | Update a quote | `X-Auth-Code` header |
| DELETE | `/api/quotes/:id` | Delete a quote | `X-Auth-Code` header |
## Running locally
```sh
cargo run # starts on http://localhost:3000
```
## Building for Cloudflare Workers
```sh
cargo build --release --bin api --target wasm32-unknown-unknown
```
## Testing
```sh
cargo test
```
## Auth
Each quote has an `auth_code` (4-word passphrase) generated at creation time. Include it in the `X-Auth-Code` header for update and delete operations. Mismatch returns `403 Forbidden`.

@ -0,0 +1,45 @@
# quotesdb UI
Yew/Wasm single-page application for browsing and submitting quotes. Compiled by Trunk and hosted on Cloudflare Pages.
## Running locally
```sh
# Terminal 1 — start the API server
cargo run
# Terminal 2 — start the Trunk dev server
trunk serve
```
Open `http://localhost:8080`. The Trunk proxy forwards `/api/*` to `localhost:3000`.
## Building for production
```sh
trunk build --release
```
Output is in `dist/`. Deploy with:
```sh
wrangler pages deploy dist/ --project-name quotesdb-ui
```
## Pages
| Route | Description |
|-------|-------------|
| `/` | Home — random quote + Browse link |
| `/browse` | Paginated list with author/tag filters |
| `/quotes/:id` | Single quote — view, edit, delete |
| `/author/:name` | All quotes by an author |
| `/submit` | Create a new quote |
## Architecture
- **Framework:** Yew 0.22 with `yew-router` 0.19
- **API calls:** `gloo::net::http::Request` (not reqwest — wasm32 incompatible)
- **Auth storage:** `sessionStorage` via `web_sys` — key: `auth_code_{quote_id}`
- **CSS:** Plain CSS, BEM naming (no Tailwind)
- **SPA routing:** `_redirects` file (`/* /index.html 200`) copied to `dist/` by Trunk

@ -0,0 +1,5 @@
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
*.tfvars

@ -0,0 +1,71 @@
# quotesdb Infrastructure
OpenTofu configuration for deploying quotesdb to Cloudflare.
## Resources provisioned
| Resource | Description |
|---|---|
| `cloudflare_d1_database.quotesdb` | D1 SQLite database backing the API |
| `cloudflare_workers_script.api` | Compiled Wasm Worker serving `/api/*` |
| `cloudflare_worker_route.api` | Routes `quotes.elijah.run/api/*` to the Worker |
| `cloudflare_pages_project.ui` | Pages project hosting the Yew SPA |
| `cloudflare_record.ui` | CNAME `quotes.elijah.run` → Pages |
| `cloudflare_pages_domain.ui` | Custom domain binding on Pages |
## Required credentials
| Variable | Description |
|---|---|
| `TF_VAR_cloudflare_api_token` | Cloudflare API token (Workers, D1, Pages, DNS edit) |
| `TF_VAR_cloudflare_account_id` | Cloudflare account ID |
| `TF_VAR_cloudflare_zone_id` | Zone ID for `elijah.run` |
Export these before running `tofu`:
```sh
export TF_VAR_cloudflare_api_token="..."
export TF_VAR_cloudflare_account_id="..."
export TF_VAR_cloudflare_zone_id="..."
```
## First-time setup (chicken-and-egg)
D1 must exist before the Worker can bind to it. On the very first deploy:
```sh
cd infra/
tofu init
tofu apply -target=cloudflare_d1_database.quotesdb
wrangler d1 execute quotesdb --file schema.sql --remote
tofu apply
```
Subsequent deploys: CI/CD handles everything automatically.
## Local apply
```sh
cd quotesdb/infra/
tofu init
tofu plan
tofu apply
```
## State
State is stored locally in `terraform.tfstate` (gitignored). For a team setup, migrate to a remote backend (S3-compatible bucket, Terraform Cloud, etc.).
## Files
| File | Purpose |
|---|---|
| `main.tf` | Terraform block and provider version constraints |
| `providers.tf` | Cloudflare provider configuration |
| `variables.tf` | Input variable declarations |
| `d1.tf` | Cloudflare D1 database resource |
| `worker.tf` | Cloudflare Workers script + route |
| `pages.tf` | Cloudflare Pages project |
| `dns.tf` | DNS record and custom domain binding |
| `schema.sql` | Idempotent D1 schema (applied via wrangler, not tofu) |
| `.gitignore` | Ignores state, lock, and credential files |

@ -0,0 +1,14 @@
# Cloudflare D1 database that backs the quotesdb API.
# SQLite-compatible; bound to the Worker via the "DB" binding name.
# NOTE: D1 must be created before the Worker script (chicken-and-egg).
# On first deploy: tofu apply -target=cloudflare_d1_database.quotesdb
resource "cloudflare_d1_database" "quotesdb" {
account_id = var.cloudflare_account_id
name = "quotesdb"
}
# Export D1 database ID for use in wrangler migration commands.
output "d1_database_id" {
description = "D1 database ID — use with: wrangler d1 execute quotesdb --file infra/schema.sql --remote"
value = cloudflare_d1_database.quotesdb.id
}

@ -0,0 +1,16 @@
# CNAME record pointing quotes.elijah.run to Cloudflare Pages.
resource "cloudflare_record" "ui" {
zone_id = var.cloudflare_zone_id
name = "quotes"
type = "CNAME"
content = cloudflare_pages_project.ui.subdomain
proxied = true
}
# Bind the custom domain quotes.elijah.run to the Pages project.
# Cloudflare provisions an SSL certificate automatically.
resource "cloudflare_pages_domain" "ui" {
account_id = var.cloudflare_account_id
project_name = cloudflare_pages_project.ui.name
domain = "quotes.elijah.run"
}

@ -1,10 +1,10 @@
# quotesdb infrastructure OpenTofu / Cloudflare
# Placeholder: to be filled in during the infra planning phase.
terraform {
# Local state terraform.tfstate is gitignored.
# No remote backend needed for this project.
required_providers {
# Cloudflare provider for Workers, D1, Pages, DNS, and routing.
cloudflare = {
source = "cloudflare/cloudflare"
source = "registry.terraform.io/cloudflare/cloudflare"
version = "~> 4"
}
}

@ -0,0 +1,8 @@
# Cloudflare Pages project for the quotesdb UI (SPA, direct-upload deployment).
# Deployment is handled by .gitea/workflows/deploy-ui.yml via `wrangler pages deploy`.
# No git source block artifact upload model only.
resource "cloudflare_pages_project" "ui" {
account_id = var.cloudflare_account_id
name = "quotesdb-ui"
production_branch = "quotesdb"
}

@ -0,0 +1,6 @@
# Cloudflare provider configuration.
# Authentication uses an API token passed via var.cloudflare_api_token.
# Never hardcode credentials here use TF_VAR_* env vars or a gitignored .tfvars file.
provider "cloudflare" {
api_token = var.cloudflare_api_token
}

@ -0,0 +1,26 @@
-- quotesdb D1 schema — idempotent, safe to re-apply.
-- Apply with: wrangler d1 execute quotesdb --file infra/schema.sql --remote
-- Local dev: wrangler d1 execute quotesdb --file infra/schema.sql --local
CREATE TABLE IF NOT EXISTS quotes (
id TEXT PRIMARY KEY, -- UUID v4
text TEXT NOT NULL,
author TEXT NOT NULL,
source TEXT, -- optional: book, speech, etc.
date TEXT, -- optional: ISO date YYYY-MM-DD
auth_code TEXT NOT NULL, -- 4-word passphrase (plaintext)
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS quote_tags (
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (quote_id, tag)
);
-- Index for efficient author-filtered queries (case-insensitive).
CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author COLLATE NOCASE);
-- Index for efficient tag lookups by quote ID.
CREATE INDEX IF NOT EXISTS idx_quote_tags_quote_id ON quote_tags(quote_id);

@ -0,0 +1,21 @@
# Cloudflare API token required for all provider operations.
# Set via: export TF_VAR_cloudflare_api_token="..."
variable "cloudflare_api_token" {
description = "Cloudflare API token with Workers, D1, Pages, and DNS edit permissions."
type = string
sensitive = true
}
# Cloudflare account ID required for Workers, D1, and Pages resources.
# Set via: export TF_VAR_cloudflare_account_id="..."
variable "cloudflare_account_id" {
description = "Cloudflare account ID where quotesdb resources are provisioned."
type = string
}
# Cloudflare zone ID for elijah.run required for DNS records and Worker routes.
# Set via: export TF_VAR_cloudflare_zone_id="..."
variable "cloudflare_zone_id" {
description = "Cloudflare zone ID for the elijah.run domain."
type = string
}

@ -0,0 +1,27 @@
# Cloudflare Workers script for the quotesdb API.
# Compiled from the `api` binary targeting wasm32-unknown-unknown.
# Build before applying: cargo build --release --bin api --target wasm32-unknown-unknown
resource "cloudflare_workers_script" "api" {
account_id = var.cloudflare_account_id
# Script name used in the Cloudflare dashboard and for routing.
name = "quotesdb-api"
# Compiled Wasm binary path is relative to the infra/ directory.
content = filebase64("../target/wasm32-unknown-unknown/release/api.wasm")
# D1 database binding referenced in workers-rs code as `env.DB`.
# Depends implicitly on cloudflare_d1_database.quotesdb via attribute reference.
d1_database_binding {
name = "DB"
database_id = cloudflare_d1_database.quotesdb.id
}
}
# Route that maps quotes.elijah.run/api/* to the quotesdb-api Worker.
# All other requests on the domain are served by Cloudflare Pages.
resource "cloudflare_worker_route" "api" {
zone_id = var.cloudflare_zone_id
pattern = "quotes.elijah.run/api/*"
script_name = cloudflare_workers_script.api.name
}

@ -0,0 +1,46 @@
//! Database connection setup for the native API server.
//!
//! Provides [`open`] which opens a `tokio-rusqlite` connection, configures
//! SQLite pragmas for WAL mode and foreign key enforcement, and wraps the
//! result in a [`NativeRepository`].
use super::{DbError, NativeRepository};
use tokio_rusqlite::Connection;
/// Open a SQLite database at `path` and return a configured [`NativeRepository`].
///
/// This function:
/// 1. Opens the file-backed SQLite connection via `tokio_rusqlite::Connection::open`.
/// 2. Enables Write-Ahead Logging (`PRAGMA journal_mode=WAL`) for better
/// concurrent read performance.
/// 3. Enables foreign key enforcement (`PRAGMA foreign_keys=ON`) so that
/// `ON DELETE CASCADE` works on the `quote_tags` table.
///
/// Returns `Err(DbError::Internal(...))` if the file cannot be opened or if
/// the pragma commands fail.
///
/// # Examples
///
/// ```no_run
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let repo = quotesdb::db::connection::open("quotesdb.sqlite").await?;
/// repo.run_migrations().await?;
/// # Ok(())
/// # }
/// ```
pub async fn open(path: &str) -> Result<NativeRepository, DbError> {
let conn = Connection::open(path)
.await
.map_err(|e| DbError::Internal(format!("failed to open database: {e}")))?;
// Configure SQLite pragmas on the connection thread
conn.call(|c| {
// WAL mode improves concurrent reader throughput
c.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
Ok(())
})
.await
.map_err(|e| DbError::Internal(format!("pragma configuration failed: {e}")))?;
Ok(NativeRepository::new(conn))
}

@ -0,0 +1,70 @@
//! Cloudflare D1 repository implementation (wasm32 only).
//!
//! [`D1Repository`] wraps a [`worker::d1::Database`] and provides stub
//! implementations of all [`super::QuoteRepository`] methods. Full D1
//! support will be implemented in a future ticket.
//!
//! This module is only compiled for `wasm32-unknown-unknown` targets.
#![cfg(target_arch = "wasm32")]
use super::{DbError, DeleteResult, ListResult, QuoteRepository};
use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput};
/// Cloudflare D1-backed repository (wasm32 only).
///
/// Wraps a [`worker::d1::Database`] handle provided by the Workers runtime.
/// All methods currently return `Err(DbError::Internal(...))` as stubs until
/// D1 query support is fully implemented.
pub struct D1Repository {
/// The Cloudflare D1 database handle.
pub db: worker::d1::Database,
}
impl D1Repository {
/// Create a new [`D1Repository`] wrapping the given D1 database handle.
pub fn new(db: worker::d1::Database) -> Self {
Self { db }
}
}
#[async_trait::async_trait(?Send)]
impl QuoteRepository for D1Repository {
async fn run_migrations(&self) -> Result<(), DbError> {
Err(DbError::Internal("D1 not yet implemented".to_string()))
}
async fn list_quotes(
&self,
_page: u32,
_author: Option<&str>,
_tag: Option<&str>,
) -> Result<ListResult, DbError> {
Err(DbError::Internal("D1 not yet implemented".to_string()))
}
async fn get_quote(&self, _id: &str) -> Result<Option<Quote>, DbError> {
Err(DbError::Internal("D1 not yet implemented".to_string()))
}
async fn get_random_quote(&self) -> Result<Option<Quote>, DbError> {
Err(DbError::Internal("D1 not yet implemented".to_string()))
}
async fn create_quote(&self, _input: CreateQuoteInput) -> Result<(Quote, String), DbError> {
Err(DbError::Internal("D1 not yet implemented".to_string()))
}
async fn update_quote(
&self,
_id: &str,
_input: UpdateQuoteInput,
_auth_code: &str,
) -> Result<Quote, DbError> {
Err(DbError::Internal("D1 not yet implemented".to_string()))
}
async fn delete_quote(&self, _id: &str, _auth_code: &str) -> Result<DeleteResult, DbError> {
Err(DbError::Internal("D1 not yet implemented".to_string()))
}
}

@ -17,6 +17,9 @@ mod native;
#[cfg(target_arch = "wasm32")]
mod d1;
#[cfg(not(target_arch = "wasm32"))]
pub mod connection;
#[cfg(not(target_arch = "wasm32"))]
pub use native::NativeRepository;
@ -33,7 +36,7 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListResult {
/// The quotes on this page.
pub quotes: Vec<crate::Quote>,
pub quotes: Vec<quotesdb::Quote>,
/// Current page number (1-based).
pub page: u32,
/// Total number of pages.
@ -71,13 +74,16 @@ pub enum DbError {
/// Async repository interface for all quote CRUD operations.
///
/// `?Send` is required because `D1Database` wraps JS values and is not `Send`.
/// Both implementations satisfy this bound.
/// On native targets the trait uses `async_trait` (Send-capable futures),
/// which lets Axum share the repository across Tokio tasks.
/// On wasm32 the trait uses `async_trait(?Send)` because `D1Database` wraps
/// JS values that are not `Send`.
///
/// Implementations must be backed by a persistent store (SQLite for native,
/// Cloudflare D1 for WASM). The caller holds the repository behind an `Arc`
/// so it can be shared across Axum handler calls.
#[async_trait::async_trait(?Send)]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
pub trait QuoteRepository {
/// Run `CREATE TABLE IF NOT EXISTS` migrations.
///
@ -98,12 +104,12 @@ pub trait QuoteRepository {
/// Retrieve a single quote by its ID.
///
/// Returns `Ok(None)` when no quote with the given ID exists.
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError>;
async fn get_quote(&self, id: &str) -> Result<Option<quotesdb::Quote>, DbError>;
/// Return a single random quote.
///
/// Returns `Ok(None)` when the database is empty.
async fn get_random_quote(&self) -> Result<Option<crate::Quote>, DbError>;
async fn get_random_quote(&self) -> Result<Option<quotesdb::Quote>, DbError>;
/// Create a new quote.
///
@ -111,8 +117,8 @@ pub trait QuoteRepository {
/// Returns the stored quote (without auth_code) and the auth_code string.
async fn create_quote(
&self,
input: crate::CreateQuoteInput,
) -> Result<(crate::Quote, String), DbError>;
input: quotesdb::CreateQuoteInput,
) -> Result<(quotesdb::Quote, String), DbError>;
/// Update an existing quote.
///
@ -122,9 +128,9 @@ pub trait QuoteRepository {
async fn update_quote(
&self,
id: &str,
input: crate::UpdateQuoteInput,
input: quotesdb::UpdateQuoteInput,
auth_code: &str,
) -> Result<crate::Quote, DbError>;
) -> Result<quotesdb::Quote, DbError>;
/// Delete a quote by ID.
///

@ -0,0 +1,638 @@
//! Native SQLite repository implementation using `tokio-rusqlite`.
//!
//! [`NativeRepository`] wraps a [`tokio_rusqlite::Connection`] and implements
//! the [`super::QuoteRepository`] trait for all CRUD operations. It is used for
//! local development and testing; production uses `D1Repository` (wasm32).
use super::{DbError, DeleteResult, ListResult, QuoteRepository};
use quotesdb::{generate_auth_code, generate_id, CreateQuoteInput, Quote, UpdateQuoteInput};
use rusqlite::OptionalExtension;
use tokio_rusqlite::Connection;
/// Native SQLite repository backed by `tokio-rusqlite`.
///
/// Wraps a `tokio_rusqlite::Connection` and provides async implementations
/// of all [`QuoteRepository`] methods. Each method enters the rusqlite
/// thread pool via [`Connection::call`].
pub struct NativeRepository {
conn: Connection,
}
impl NativeRepository {
/// Create a new [`NativeRepository`] wrapping the given connection.
pub fn new(conn: Connection) -> Self {
Self { conn }
}
}
/// Fetch the tags for a single quote ID.
///
/// Returns a sorted `Vec<String>` of tag values, or an empty vec if none exist.
fn fetch_tags_for_quote(
conn: &rusqlite::Connection,
quote_id: &str,
) -> Result<Vec<String>, rusqlite::Error> {
let mut stmt = conn.prepare("SELECT tag FROM quote_tags WHERE quote_id = ? ORDER BY tag")?;
let tags = stmt
.query_map([quote_id], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(tags)
}
/// Map rusqlite columns (id, text, author, source, date, created_at, updated_at)
/// plus a pre-fetched tags vec into a [`Quote`].
fn row_to_quote(row: &rusqlite::Row<'_>, tags: Vec<String>) -> Result<Quote, rusqlite::Error> {
Ok(Quote {
id: row.get(0)?,
text: row.get(1)?,
author: row.get(2)?,
source: row.get(3)?,
date: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
tags,
})
}
#[async_trait::async_trait]
impl QuoteRepository for NativeRepository {
/// Run the four DDL migration statements from [`super::migrations`].
///
/// Safe to call multiple times — all statements use `IF NOT EXISTS`.
async fn run_migrations(&self) -> Result<(), DbError> {
self.conn
.call(|conn| {
use super::migrations::*;
conn.execute_batch(&format!(
"{CREATE_QUOTES}; {CREATE_QUOTE_TAGS}; \
{CREATE_TAG_INDEX}; {CREATE_AUTHOR_INDEX};"
))?;
Ok(())
})
.await
.map_err(|e| DbError::Internal(e.to_string()))
}
/// List quotes with optional author/tag filters and 1-based pagination.
///
/// Page size is fixed at 10. Uses `COLLATE NOCASE` for author filtering.
/// Tags for each returned quote are fetched in a second query per quote to
/// avoid duplicate rows from a JOIN.
async fn list_quotes(
&self,
page: u32,
author: Option<&str>,
tag: Option<&str>,
) -> Result<ListResult, DbError> {
let page = page.max(1);
let author = author.map(|s| s.to_owned());
let tag = tag.map(|s| s.to_owned());
self.conn
.call(move |conn| {
const PAGE_SIZE: i64 = 10;
// ── Build WHERE clause ────────────────────────────────────
let mut conditions: Vec<String> = Vec::new();
if author.is_some() {
conditions.push("q.author = ? COLLATE NOCASE".to_owned());
}
if tag.is_some() {
conditions
.push("q.id IN (SELECT quote_id FROM quote_tags WHERE tag = ?)".to_owned());
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
// Collect bound params in order for both queries
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(ref a) = author {
params.push(Box::new(a.clone()));
}
if let Some(ref t) = tag {
params.push(Box::new(t.clone()));
}
// ── Count total matching rows ──────────────────────────────
let count_sql = format!("SELECT COUNT(*) FROM quotes q {where_clause}");
let param_refs: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|b| b.as_ref()).collect();
let total_count: u32 = conn.query_row(
&count_sql,
rusqlite::params_from_iter(param_refs.iter().copied()),
|row| row.get(0),
)?;
let total_pages =
(((total_count as i64) + PAGE_SIZE - 1) / PAGE_SIZE).max(1) as u32;
let offset = ((page as i64) - 1) * PAGE_SIZE;
// ── Fetch the page of quotes ──────────────────────────────
let list_sql = format!(
"SELECT q.id, q.text, q.author, q.source, q.date, \
q.created_at, q.updated_at \
FROM quotes q {where_clause} \
ORDER BY q.created_at DESC \
LIMIT ? OFFSET ?"
);
// Re-collect bound params (page + limit/offset appended)
let mut params2: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(ref a) = author {
params2.push(Box::new(a.clone()));
}
if let Some(ref t) = tag {
params2.push(Box::new(t.clone()));
}
params2.push(Box::new(PAGE_SIZE));
params2.push(Box::new(offset));
let param_refs2: Vec<&dyn rusqlite::types::ToSql> =
params2.iter().map(|b| b.as_ref()).collect();
let mut stmt = conn.prepare(&list_sql)?;
let partial_quotes: Vec<Quote> = stmt
.query_map(
rusqlite::params_from_iter(param_refs2.iter().copied()),
|row| {
Ok(Quote {
id: row.get(0)?,
text: row.get(1)?,
author: row.get(2)?,
source: row.get(3)?,
date: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
tags: vec![],
})
},
)?
.collect::<Result<Vec<_>, _>>()?;
// Second pass: fetch tags for each quote
let quotes = partial_quotes
.into_iter()
.map(|mut q| {
q.tags = fetch_tags_for_quote(conn, &q.id)?;
Ok(q)
})
.collect::<Result<Vec<_>, rusqlite::Error>>()?;
Ok(ListResult {
quotes,
page,
total_pages,
total_count,
})
})
.await
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Retrieve a single quote by its primary key.
///
/// Returns `Ok(None)` when no row matches `id`.
async fn get_quote(&self, id: &str) -> Result<Option<Quote>, DbError> {
let id = id.to_owned();
self.conn
.call(move |conn| {
let mut stmt = conn.prepare(
"SELECT id, text, author, source, date, created_at, updated_at \
FROM quotes WHERE id = ?",
)?;
let mut rows = stmt.query([&id as &str])?;
match rows.next()? {
Some(row) => {
let tags = fetch_tags_for_quote(conn, &id)?;
Ok(Some(row_to_quote(row, tags)?))
}
None => Ok(None),
}
})
.await
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Return one quote chosen at random.
///
/// Returns `Ok(None)` when the `quotes` table is empty.
async fn get_random_quote(&self) -> Result<Option<Quote>, DbError> {
self.conn
.call(|conn| {
let mut stmt = conn.prepare(
"SELECT id, text, author, source, date, created_at, updated_at \
FROM quotes ORDER BY RANDOM() LIMIT 1",
)?;
let mut rows = stmt.query([])?;
match rows.next()? {
Some(row) => {
let id: String = row.get(0)?;
let tags = fetch_tags_for_quote(conn, &id)?;
Ok(Some(row_to_quote(row, tags)?))
}
None => Ok(None),
}
})
.await
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Insert a new quote row and its associated tags.
///
/// If `input.auth_code` is `None`, a 4-word passphrase is generated.
/// Returns the persisted [`Quote`] (without `auth_code`) and the raw
/// auth-code string so the caller can include it in the creation response.
async fn create_quote(&self, input: CreateQuoteInput) -> Result<(Quote, String), DbError> {
let id = generate_id();
let auth_code = input.auth_code.clone().unwrap_or_else(generate_auth_code);
let id2 = id.clone();
let auth2 = auth_code.clone();
self.conn
.call(move |conn| {
conn.execute(
"INSERT INTO quotes (id, text, author, source, date, auth_code) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
id2,
input.text,
input.author,
input.source,
input.date,
auth2,
],
)?;
for tag in &input.tags {
conn.execute(
"INSERT OR IGNORE INTO quote_tags (quote_id, tag) VALUES (?1, ?2)",
rusqlite::params![id2, tag],
)?;
}
// Read back the inserted row to obtain server-generated timestamps
let mut stmt = conn.prepare(
"SELECT id, text, author, source, date, created_at, updated_at \
FROM quotes WHERE id = ?",
)?;
let mut rows = stmt.query([&id2 as &str])?;
let row = rows.next()?.ok_or(rusqlite::Error::QueryReturnedNoRows)?;
let tags = fetch_tags_for_quote(conn, &id2)?;
let quote = row_to_quote(row, tags)?;
Ok((quote, auth2))
})
.await
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Update non-`None` fields on an existing quote.
///
/// Verifies `auth_code` before making any changes. If `input.tags` is
/// `Some`, the entire tag set is replaced. Updates `updated_at` to the
/// current UTC time.
async fn update_quote(
&self,
id: &str,
input: UpdateQuoteInput,
auth_code: &str,
) -> Result<Quote, DbError> {
let id = id.to_owned();
let auth_code = auth_code.to_owned();
// Phase 1: fetch stored auth_code (returns DbError on failure)
let stored: Option<String> = self
.conn
.call({
let id = id.clone();
move |conn| {
let result: Option<String> = conn
.query_row(
"SELECT auth_code FROM quotes WHERE id = ?",
[&id as &str],
|row| row.get(0),
)
.optional()?;
Ok(result)
}
})
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
match stored {
None => return Err(DbError::NotFound),
Some(ref s) if s.as_str() != auth_code.as_str() => return Err(DbError::Forbidden),
Some(_) => {}
}
// Phase 2: apply the update
self.conn
.call(move |conn| {
let mut sets: Vec<String> = Vec::new();
if input.text.is_some() {
sets.push("text = ?".to_owned());
}
if input.author.is_some() {
sets.push("author = ?".to_owned());
}
sets.push("source = ?".to_owned());
sets.push("date = ?".to_owned());
sets.push("updated_at = datetime('now')".to_owned());
let sql = format!("UPDATE quotes SET {} WHERE id = ?", sets.join(", "));
// Build the params vector in the same order as the SET clause
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(ref text) = input.text {
params.push(Box::new(text.clone()));
}
if let Some(ref author) = input.author {
params.push(Box::new(author.clone()));
}
// source and date may be null (None clears the field)
params.push(Box::new(input.source.clone()));
params.push(Box::new(input.date.clone()));
params.push(Box::new(id.clone()));
let param_refs: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|b| b.as_ref()).collect();
conn.execute(&sql, rusqlite::params_from_iter(param_refs.iter().copied()))?;
// Replace tags if provided
if let Some(ref tags) = input.tags {
conn.execute("DELETE FROM quote_tags WHERE quote_id = ?", [&id as &str])?;
for tag in tags {
conn.execute(
"INSERT OR IGNORE INTO quote_tags (quote_id, tag) \
VALUES (?1, ?2)",
rusqlite::params![id, tag],
)?;
}
}
// Read back the updated quote
let mut stmt = conn.prepare(
"SELECT id, text, author, source, date, created_at, updated_at \
FROM quotes WHERE id = ?",
)?;
let mut rows = stmt.query([&id as &str])?;
let row = rows.next()?.ok_or(rusqlite::Error::QueryReturnedNoRows)?;
let tags = fetch_tags_for_quote(conn, &id)?;
Ok(row_to_quote(row, tags)?)
})
.await
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Delete a quote by ID after verifying the auth code.
///
/// Returns [`DeleteResult::NotFound`] if no quote has that ID,
/// [`DeleteResult::Forbidden`] if the auth code does not match,
/// or [`DeleteResult::Deleted`] on success. Tags are removed automatically
/// by the `ON DELETE CASCADE` constraint on `quote_tags`.
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
let id = id.to_owned();
let auth_code = auth_code.to_owned();
self.conn
.call(move |conn| {
let stored: Option<String> = conn
.query_row(
"SELECT auth_code FROM quotes WHERE id = ?",
[&id as &str],
|row| row.get(0),
)
.optional()?;
match stored {
None => Ok(DeleteResult::NotFound),
Some(s) if s != auth_code => Ok(DeleteResult::Forbidden),
Some(_) => {
conn.execute("DELETE FROM quotes WHERE id = ?", [&id as &str])?;
Ok(DeleteResult::Deleted)
}
}
})
.await
.map_err(|e| DbError::Internal(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Open an in-memory SQLite database for testing.
async fn in_memory_repo() -> NativeRepository {
let conn = Connection::open_in_memory().await.unwrap();
let repo = NativeRepository::new(conn);
repo.run_migrations().await.unwrap();
repo
}
fn make_input(text: &str, author: &str) -> CreateQuoteInput {
CreateQuoteInput {
text: text.to_owned(),
author: author.to_owned(),
source: None,
date: None,
tags: vec![],
auth_code: None,
}
}
#[tokio::test]
async fn test_create_and_get_quote() {
let repo = in_memory_repo().await;
let input = CreateQuoteInput {
text: "Hello, world!".to_owned(),
author: "Test Author".to_owned(),
source: None,
date: None,
tags: vec!["test".to_owned()],
auth_code: Some("word-word-word-word".to_owned()),
};
let (quote, auth) = repo.create_quote(input).await.unwrap();
assert_eq!(auth, "word-word-word-word");
assert_eq!(quote.text, "Hello, world!");
assert_eq!(quote.tags, vec!["test"]);
let fetched = repo.get_quote(&quote.id).await.unwrap();
assert!(fetched.is_some());
assert_eq!(fetched.unwrap().id, quote.id);
}
#[tokio::test]
async fn test_get_quote_not_found() {
let repo = in_memory_repo().await;
let result = repo.get_quote("nonexistent").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_list_quotes_pagination() {
let repo = in_memory_repo().await;
for i in 0..15 {
repo.create_quote(make_input(&format!("Quote {i}"), "Author"))
.await
.unwrap();
}
let page1 = repo.list_quotes(1, None, None).await.unwrap();
assert_eq!(page1.quotes.len(), 10);
assert_eq!(page1.total_count, 15);
assert_eq!(page1.total_pages, 2);
let page2 = repo.list_quotes(2, None, None).await.unwrap();
assert_eq!(page2.quotes.len(), 5);
}
#[tokio::test]
async fn test_list_quotes_author_filter() {
let repo = in_memory_repo().await;
for author in ["Alice", "Bob", "alice"] {
repo.create_quote(make_input(&format!("Quote by {author}"), author))
.await
.unwrap();
}
let result = repo.list_quotes(1, Some("alice"), None).await.unwrap();
// COLLATE NOCASE should match "Alice" and "alice"
assert_eq!(result.total_count, 2);
}
#[tokio::test]
async fn test_list_quotes_tag_filter() {
let repo = in_memory_repo().await;
repo.create_quote(CreateQuoteInput {
text: "Tagged".to_owned(),
author: "A".to_owned(),
source: None,
date: None,
tags: vec!["rust".to_owned()],
auth_code: None,
})
.await
.unwrap();
repo.create_quote(make_input("Not tagged", "B"))
.await
.unwrap();
let result = repo.list_quotes(1, None, Some("rust")).await.unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.quotes[0].text, "Tagged");
}
#[tokio::test]
async fn test_random_quote_empty() {
let repo = in_memory_repo().await;
let result = repo.get_random_quote().await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_random_quote_returns_one() {
let repo = in_memory_repo().await;
repo.create_quote(make_input("Random", "R")).await.unwrap();
let result = repo.get_random_quote().await.unwrap();
assert!(result.is_some());
}
#[tokio::test]
async fn test_update_quote_success() {
let repo = in_memory_repo().await;
let (quote, auth) = repo
.create_quote(CreateQuoteInput {
text: "Original".to_owned(),
author: "Author".to_owned(),
source: None,
date: None,
tags: vec!["old".to_owned()],
auth_code: None,
})
.await
.unwrap();
let updated = repo
.update_quote(
&quote.id,
UpdateQuoteInput {
text: Some("Updated".to_owned()),
author: None,
source: None,
date: None,
tags: Some(vec!["new".to_owned()]),
},
&auth,
)
.await
.unwrap();
assert_eq!(updated.text, "Updated");
assert_eq!(updated.tags, vec!["new"]);
}
#[tokio::test]
async fn test_update_quote_wrong_auth() {
let repo = in_memory_repo().await;
let (quote, _) = repo
.create_quote(CreateQuoteInput {
text: "Original".to_owned(),
author: "Author".to_owned(),
source: None,
date: None,
tags: vec![],
auth_code: Some("correct-code-here-xx".to_owned()),
})
.await
.unwrap();
let result = repo
.update_quote(&quote.id, UpdateQuoteInput::default(), "wrong-auth-code-yy")
.await;
assert!(matches!(result, Err(DbError::Forbidden)));
}
#[tokio::test]
async fn test_update_quote_not_found() {
let repo = in_memory_repo().await;
let result = repo
.update_quote("nonexistent", UpdateQuoteInput::default(), "any")
.await;
assert!(matches!(result, Err(DbError::NotFound)));
}
#[tokio::test]
async fn test_delete_quote_success() {
let repo = in_memory_repo().await;
let (quote, auth) = repo
.create_quote(make_input("Delete me", "Author"))
.await
.unwrap();
let result = repo.delete_quote(&quote.id, &auth).await.unwrap();
assert_eq!(result, DeleteResult::Deleted);
assert!(repo.get_quote(&quote.id).await.unwrap().is_none());
}
#[tokio::test]
async fn test_delete_quote_wrong_auth() {
let repo = in_memory_repo().await;
let (quote, _) = repo
.create_quote(make_input("Protected", "Author"))
.await
.unwrap();
let result = repo.delete_quote(&quote.id, "wrong-auth").await.unwrap();
assert_eq!(result, DeleteResult::Forbidden);
}
#[tokio::test]
async fn test_delete_quote_not_found() {
let repo = in_memory_repo().await;
let result = repo.delete_quote("nonexistent", "any").await.unwrap();
assert_eq!(result, DeleteResult::NotFound);
}
}

@ -0,0 +1,586 @@
//! HTTP request handlers for the `quotesdb` API.
//!
//! Each handler maps to one route in the API specification. The [`router`]
//! function assembles the Axum [`Router`] with all routes in the required
//! order — in particular, `GET /api/quotes/random` is registered **before**
//! `GET /api/quotes/:id` to prevent "random" being captured as an id.
//!
//! All handlers share a [`crate::db::QuoteRepository`] via Axum's state
//! mechanism, wrapped in an [`Arc`] to allow cheap cloning across tasks.
use std::sync::Arc;
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json, Response},
routing::{delete, get, post, put},
Router,
};
use serde::{Deserialize, Serialize};
use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput};
use crate::db::{DeleteResult, QuoteRepository};
// ── Shared application state ──────────────────────────────────────────────────
/// Type alias for the shared repository handle.
///
/// `Send + Sync` are required by Axum's native router so the state can be
/// shared across Tokio tasks. `NativeRepository` satisfies both bounds.
type Repo = Arc<dyn QuoteRepository + Send + Sync>;
// ── Error response helpers ─────────────────────────────────────────────────────
/// JSON envelope for all API error responses.
///
/// Serialised as `{"error": "..."}` with the appropriate HTTP status code.
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
}
/// Build a JSON error response with the given status code and message.
fn error_response(status: StatusCode, msg: impl Into<String>) -> Response {
(status, Json(ErrorBody { error: msg.into() })).into_response()
}
/// Map a [`crate::db::DbError`] to an appropriate HTTP error response.
fn db_error_response(err: crate::db::DbError) -> Response {
use crate::db::DbError;
match err {
DbError::NotFound => error_response(StatusCode::NOT_FOUND, "not found"),
DbError::Forbidden => error_response(StatusCode::FORBIDDEN, "forbidden"),
DbError::Internal(msg) => error_response(StatusCode::INTERNAL_SERVER_ERROR, msg),
}
}
// ── Response types ────────────────────────────────────────────────────────────
/// Response body returned by the create (PUT) endpoint.
///
/// Includes the full [`Quote`] plus the `auth_code` string (only time it is
/// sent to the client).
#[derive(Debug, Serialize)]
struct CreateResponse {
/// The created quote (without auth_code in the embedded struct).
quote: Quote,
/// The auth code for future update/delete operations. Store it.
auth_code: String,
}
// ── Query parameter structs ────────────────────────────────────────────────────
/// Query parameters for `GET /api/quotes`.
#[derive(Debug, Deserialize)]
struct ListParams {
/// 1-based page number. Defaults to 1.
#[serde(default = "default_page")]
page: u32,
/// Filter by author name (case-insensitive).
author: Option<String>,
/// Filter by tag.
tag: Option<String>,
}
fn default_page() -> u32 {
1
}
// ── Handlers ──────────────────────────────────────────────────────────────────
/// `GET /api/` — return the OpenAPI specification as JSON.
///
/// The spec is embedded at compile time from `api/openapi.yaml` (converted to
/// JSON by `build.rs`). Returns `Content-Type: application/json` with the raw
/// spec string.
async fn openapi_handler() -> Response {
const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));
(
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
OPENAPI_JSON,
)
.into_response()
}
/// `GET /api/quotes` — list quotes with optional filtering and pagination.
///
/// Accepts `?page=N&author=X&tag=Y` query parameters. Defaults to page 1 and
/// no filters. Returns [`crate::db::ListResult`] serialised as JSON.
async fn list_handler(State(repo): State<Repo>, Query(params): Query<ListParams>) -> Response {
match repo
.list_quotes(params.page, params.author.as_deref(), params.tag.as_deref())
.await
{
Ok(result) => (StatusCode::OK, Json(result)).into_response(),
Err(e) => db_error_response(e),
}
}
/// `GET /api/quotes/random` — return a random quote.
///
/// Returns `404` when the database is empty.
///
/// **Registration order:** this route must be registered before
/// `GET /api/quotes/:id` in the router to avoid "random" being matched as an
/// id parameter.
async fn random_handler(State(repo): State<Repo>) -> Response {
match repo.get_random_quote().await {
Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(),
Ok(None) => error_response(StatusCode::NOT_FOUND, "no quotes in database"),
Err(e) => db_error_response(e),
}
}
/// `GET /api/quotes/:id` — retrieve a single quote by NanoID.
///
/// Returns `404` when no quote has the given id.
async fn get_quote_handler(State(repo): State<Repo>, Path(id): Path<String>) -> Response {
match repo.get_quote(&id).await {
Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(),
Ok(None) => error_response(StatusCode::NOT_FOUND, "quote not found"),
Err(e) => db_error_response(e),
}
}
/// `PUT /api/quotes` — create a new quote.
///
/// Accepts a JSON body matching [`CreateQuoteInput`]. Returns `201 Created`
/// with `{"quote": {...}, "auth_code": "..."}`. The `auth_code` is the only
/// time it is returned — the client must store it.
async fn create_handler(State(repo): State<Repo>, Json(input): Json<CreateQuoteInput>) -> Response {
match repo.create_quote(input).await {
Ok((quote, auth_code)) => (
StatusCode::CREATED,
Json(CreateResponse { quote, auth_code }),
)
.into_response(),
Err(e) => db_error_response(e),
}
}
/// Extract the `X-Auth-Code` header value from the request headers.
///
/// Returns `None` if the header is absent or cannot be decoded as UTF-8.
fn extract_auth_code(headers: &HeaderMap) -> Option<String> {
headers
.get("X-Auth-Code")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned())
}
/// `POST /api/quotes/:id` — update an existing quote.
///
/// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong,
/// `404` if the quote does not exist, or `200` with the updated quote.
async fn update_handler(
State(repo): State<Repo>,
Path(id): Path<String>,
headers: HeaderMap,
Json(input): Json<UpdateQuoteInput>,
) -> Response {
let Some(auth_code) = extract_auth_code(&headers) else {
return error_response(StatusCode::FORBIDDEN, "X-Auth-Code header is required");
};
match repo.update_quote(&id, input, &auth_code).await {
Ok(quote) => (StatusCode::OK, Json(quote)).into_response(),
Err(e) => db_error_response(e),
}
}
/// `DELETE /api/quotes/:id` — delete a quote.
///
/// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong,
/// `404` if not found, or `204 No Content` on success.
async fn delete_handler(
State(repo): State<Repo>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Response {
let Some(auth_code) = extract_auth_code(&headers) else {
return error_response(StatusCode::FORBIDDEN, "X-Auth-Code header is required");
};
match repo.delete_quote(&id, &auth_code).await {
Ok(DeleteResult::Deleted) => StatusCode::NO_CONTENT.into_response(),
Ok(DeleteResult::NotFound) => error_response(StatusCode::NOT_FOUND, "quote not found"),
Ok(DeleteResult::Forbidden) => error_response(StatusCode::FORBIDDEN, "forbidden"),
Err(e) => db_error_response(e),
}
}
// ── Router ────────────────────────────────────────────────────────────────────
/// Build the Axum [`Router`] with all API routes wired to their handlers.
///
/// Route registration order is important: `GET /api/quotes/random` must
/// appear before `GET /api/quotes/:id` so Axum's static segment wins over
/// the dynamic `:id` capture.
///
/// The repository must implement `Send + Sync` so it can be shared across
/// Tokio tasks by Axum's state mechanism. [`NativeRepository`] satisfies
/// both bounds via `tokio_rusqlite::Connection`.
///
/// [`NativeRepository`]: crate::db::NativeRepository
pub fn router(repo: Arc<dyn QuoteRepository + Send + Sync>) -> Router {
Router::new()
// Meta
.route("/api/", get(openapi_handler))
// IMPORTANT: /random must be registered before /{id} so the static
// segment wins over the dynamic capture.
.route("/api/quotes/random", get(random_handler))
.route("/api/quotes/{id}", get(get_quote_handler))
.route("/api/quotes", get(list_handler))
.route("/api/quotes", put(create_handler))
.route("/api/quotes/{id}", post(update_handler))
.route("/api/quotes/{id}", delete(delete_handler))
.with_state(repo)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{Method, Request},
};
use tower::util::ServiceExt; // for `oneshot`
use crate::db::{DbError, DeleteResult, ListResult};
use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput};
// ── Mock repository for handler tests ─────────────────────────────────────
/// A simple mock [`QuoteRepository`] for unit-testing handlers.
struct MockRepo {
quotes: std::sync::Mutex<Vec<(Quote, String)>>,
}
impl MockRepo {
fn empty() -> Repo {
Arc::new(Self {
quotes: std::sync::Mutex::new(vec![]),
})
}
fn with_quote(quote: Quote, auth: &str) -> Repo {
Arc::new(Self {
quotes: std::sync::Mutex::new(vec![(quote, auth.to_owned())]),
})
}
}
#[async_trait::async_trait]
impl QuoteRepository for MockRepo {
async fn run_migrations(&self) -> Result<(), DbError> {
Ok(())
}
async fn list_quotes(
&self,
page: u32,
_author: Option<&str>,
_tag: Option<&str>,
) -> Result<ListResult, DbError> {
let quotes = self.quotes.lock().unwrap();
let all: Vec<Quote> = quotes.iter().map(|(q, _)| q.clone()).collect();
Ok(ListResult {
quotes: all.clone(),
page,
total_pages: 1,
total_count: all.len() as u32,
})
}
async fn get_quote(&self, id: &str) -> Result<Option<Quote>, DbError> {
let quotes = self.quotes.lock().unwrap();
Ok(quotes
.iter()
.find(|(q, _)| q.id == id)
.map(|(q, _)| q.clone()))
}
async fn get_random_quote(&self) -> Result<Option<Quote>, DbError> {
let quotes = self.quotes.lock().unwrap();
Ok(quotes.first().map(|(q, _)| q.clone()))
}
async fn create_quote(&self, input: CreateQuoteInput) -> Result<(Quote, String), DbError> {
let auth = input
.auth_code
.clone()
.unwrap_or_else(|| "test-auth".to_owned());
let quote = Quote {
id: "test-id".to_owned(),
text: input.text,
author: input.author,
source: input.source,
date: input.date,
tags: input.tags,
created_at: "2024-01-01T00:00:00".to_owned(),
updated_at: "2024-01-01T00:00:00".to_owned(),
};
self.quotes
.lock()
.unwrap()
.push((quote.clone(), auth.clone()));
Ok((quote, auth))
}
async fn update_quote(
&self,
id: &str,
input: UpdateQuoteInput,
auth_code: &str,
) -> Result<Quote, DbError> {
let mut quotes = self.quotes.lock().unwrap();
let entry = quotes.iter_mut().find(|(q, _)| q.id == id);
match entry {
None => Err(DbError::NotFound),
Some((q, stored_auth)) => {
if stored_auth.as_str() != auth_code {
return Err(DbError::Forbidden);
}
if let Some(t) = input.text {
q.text = t;
}
if let Some(a) = input.author {
q.author = a;
}
q.source = input.source;
q.date = input.date;
if let Some(tags) = input.tags {
q.tags = tags;
}
Ok(q.clone())
}
}
}
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
let mut quotes = self.quotes.lock().unwrap();
let pos = quotes.iter().position(|(q, _)| q.id == id);
match pos {
None => Ok(DeleteResult::NotFound),
Some(i) => {
let (_, stored) = &quotes[i];
if stored.as_str() != auth_code {
return Ok(DeleteResult::Forbidden);
}
quotes.remove(i);
Ok(DeleteResult::Deleted)
}
}
}
}
fn sample_quote() -> Quote {
Quote {
id: "abc-123".to_owned(),
text: "Sample text".to_owned(),
author: "Sample Author".to_owned(),
source: None,
date: None,
tags: vec![],
created_at: "2024-01-01T00:00:00".to_owned(),
updated_at: "2024-01-01T00:00:00".to_owned(),
}
}
// ── Helper to send requests to the router ──────────────────────────────────
async fn send(app: Router, req: Request<Body>) -> (StatusCode, String) {
let resp = ServiceExt::<Request<Body>>::oneshot(app, req)
.await
.unwrap();
let status = resp.status();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
(status, String::from_utf8_lossy(&body).to_string())
}
#[tokio::test]
async fn test_openapi_endpoint() {
let app = router(MockRepo::empty());
let req = Request::builder()
.method(Method::GET)
.uri("/api/")
.body(Body::empty())
.unwrap();
let (status, body) = send(app, req).await;
assert_eq!(status, StatusCode::OK);
// Should be valid JSON
let _: serde_json::Value = serde_json::from_str(&body).unwrap();
}
#[tokio::test]
async fn test_list_quotes() {
let app = router(MockRepo::with_quote(sample_quote(), "auth"));
let req = Request::builder()
.method(Method::GET)
.uri("/api/quotes")
.body(Body::empty())
.unwrap();
let (status, body) = send(app, req).await;
assert_eq!(status, StatusCode::OK);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["total_count"], 1);
}
#[tokio::test]
async fn test_random_quote_not_found() {
let app = router(MockRepo::empty());
let req = Request::builder()
.method(Method::GET)
.uri("/api/quotes/random")
.body(Body::empty())
.unwrap();
let (status, _) = send(app, req).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_random_quote_found() {
let app = router(MockRepo::with_quote(sample_quote(), "auth"));
let req = Request::builder()
.method(Method::GET)
.uri("/api/quotes/random")
.body(Body::empty())
.unwrap();
let (status, _) = send(app, req).await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn test_get_quote_not_found() {
let app = router(MockRepo::empty());
let req = Request::builder()
.method(Method::GET)
.uri("/api/quotes/nonexistent")
.body(Body::empty())
.unwrap();
let (status, _) = send(app, req).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_get_quote_found() {
let app = router(MockRepo::with_quote(sample_quote(), "auth"));
let req = Request::builder()
.method(Method::GET)
.uri("/api/quotes/abc-123")
.body(Body::empty())
.unwrap();
let (status, _) = send(app, req).await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn test_create_quote() {
let app = router(MockRepo::empty());
let body = serde_json::json!({
"text": "New quote",
"author": "Author",
"tags": []
});
let req = Request::builder()
.method(Method::PUT)
.uri("/api/quotes")
.header("Content-Type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let (status, resp_body) = send(app, req).await;
assert_eq!(status, StatusCode::CREATED);
let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap();
assert!(v["auth_code"].is_string());
assert_eq!(v["quote"]["text"], "New quote");
}
#[tokio::test]
async fn test_update_quote_missing_auth() {
let app = router(MockRepo::with_quote(sample_quote(), "correct"));
let body = serde_json::json!({"text": "Updated"});
let req = Request::builder()
.method(Method::POST)
.uri("/api/quotes/abc-123")
.header("Content-Type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let (status, _) = send(app, req).await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_update_quote_wrong_auth() {
let app = router(MockRepo::with_quote(sample_quote(), "correct"));
let body = serde_json::json!({"text": "Updated"});
let req = Request::builder()
.method(Method::POST)
.uri("/api/quotes/abc-123")
.header("Content-Type", "application/json")
.header("X-Auth-Code", "wrong")
.body(Body::from(body.to_string()))
.unwrap();
let (status, _) = send(app, req).await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_update_quote_success() {
let app = router(MockRepo::with_quote(sample_quote(), "correct"));
let body = serde_json::json!({"text": "Updated text"});
let req = Request::builder()
.method(Method::POST)
.uri("/api/quotes/abc-123")
.header("Content-Type", "application/json")
.header("X-Auth-Code", "correct")
.body(Body::from(body.to_string()))
.unwrap();
let (status, resp_body) = send(app, req).await;
assert_eq!(status, StatusCode::OK);
let v: serde_json::Value = serde_json::from_str(&resp_body).unwrap();
assert_eq!(v["text"], "Updated text");
}
#[tokio::test]
async fn test_delete_quote_missing_auth() {
let app = router(MockRepo::with_quote(sample_quote(), "correct"));
let req = Request::builder()
.method(Method::DELETE)
.uri("/api/quotes/abc-123")
.body(Body::empty())
.unwrap();
let (status, _) = send(app, req).await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_delete_quote_success() {
let app = router(MockRepo::with_quote(sample_quote(), "correct"));
let req = Request::builder()
.method(Method::DELETE)
.uri("/api/quotes/abc-123")
.header("X-Auth-Code", "correct")
.body(Body::empty())
.unwrap();
let (status, _) = send(app, req).await;
assert_eq!(status, StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn test_delete_quote_not_found() {
let app = router(MockRepo::empty());
let req = Request::builder()
.method(Method::DELETE)
.uri("/api/quotes/nonexistent")
.header("X-Auth-Code", "any")
.body(Body::empty())
.unwrap();
let (status, _) = send(app, req).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
}

@ -1,9 +1,42 @@
//! API server binary entrypoint.
//!
//! Runs the quotesdb REST API. In production this targets Cloudflare Workers
//! via workers-rs. For local development it runs a plain Axum/Tokio server.
//! Starts the `quotesdb` REST API on `0.0.0.0:3000` (or the port set by
//! the `PORT` environment variable). Opens a SQLite database at the path
//! given by `DATABASE_URL` (defaults to `quotesdb.sqlite`), runs schema
//! migrations, then serves requests via Axum.
fn main() {}
mod db;
mod handlers;
use std::sync::Arc;
use db::QuoteRepository as _;
#[tokio::main]
async fn main() {
let db_path = std::env::var("DATABASE_URL").unwrap_or_else(|_| "quotesdb.sqlite".to_string());
let repo = db::connection::open(&db_path)
.await
.expect("failed to open database");
repo.run_migrations().await.expect("migrations failed");
let repo: Arc<dyn db::QuoteRepository + Send + Sync> = Arc::new(repo);
let app = handlers::router(repo);
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
let addr = format!("0.0.0.0:{port}");
eprintln!("quotesdb API listening on {addr}");
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("failed to bind listener");
axum::serve(listener, app).await.expect("server error");
}
#[cfg(test)]
mod tests {}

@ -0,0 +1,40 @@
# quotesdb Integration Tests
Integration tests for the quotesdb API, located in this directory.
## Running
```sh
# From quotesdb/
cargo test
```
Integration tests run automatically as part of `cargo test`.
## Structure
Each test file focuses on one area of the API:
| File | Coverage |
|---|---|
| `api_spec.rs` | GET /api/ — OpenAPI spec shape |
| `quotes_list.rs` | GET /api/quotes — pagination, filters |
| `quotes_random.rs` | GET /api/quotes/random — 200 and empty DB |
| `quotes_crud.rs` | PUT, POST, DELETE — create, update, delete |
| `auth.rs` | Auth code validation, 403 responses |
| `tags.rs` | Tag create, filter, replace on update |
| `router_order.rs` | `/random` not matched as `:id` |
## Test harness
All tests use a shared harness (`harness.rs`) that:
1. Creates a temporary SQLite database
2. Spawns an Axum server on a random port
3. Returns the server address for `reqwest` clients to hit
4. Cleans up the database on drop
## Notes
- Tests require no external services — all run against a local SQLite database
- Each test gets its own isolated database to avoid state contamination
- The API server target must build natively (`cargo test` uses the host target)
Loading…
Cancel
Save