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
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
|
||||||
@ -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"
|
||||||
|
}
|
||||||
@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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("e.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(
|
||||||
|
"e.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("e.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("e.id, &auth).await.unwrap();
|
||||||
|
assert_eq!(result, DeleteResult::Deleted);
|
||||||
|
|
||||||
|
assert!(repo.get_quote("e.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("e.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) = "es[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.
|
//! API server binary entrypoint.
|
||||||
//!
|
//!
|
||||||
//! Runs the quotesdb REST API. In production this targets Cloudflare Workers
|
//! Starts the `quotesdb` REST API on `0.0.0.0:3000` (or the port set by
|
||||||
//! via workers-rs. For local development it runs a plain Axum/Tokio server.
|
//! 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)]
|
#[cfg(test)]
|
||||||
mod tests {}
|
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…
Reference in New Issue