diff --git a/.gitea/workflows/deploy-api.yml b/.gitea/workflows/deploy-api.yml new file mode 100644 index 0000000..e0f83be --- /dev/null +++ b/.gitea/workflows/deploy-api.yml @@ -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 diff --git a/.gitea/workflows/deploy-ui.yml b/.gitea/workflows/deploy-ui.yml new file mode 100644 index 0000000..6d5fa9e --- /dev/null +++ b/.gitea/workflows/deploy-ui.yml @@ -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 diff --git a/quotesdb/.env.example b/quotesdb/.env.example new file mode 100644 index 0000000..d99eca6 --- /dev/null +++ b/quotesdb/.env.example @@ -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 diff --git a/quotesdb/.nbd/tickets/00aff0.md b/quotesdb/.nbd/tickets/00aff0.md index 4455782..1701c09 100644 --- a/quotesdb/.nbd/tickets/00aff0.md +++ b/quotesdb/.nbd/tickets/00aff0.md @@ -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 = [] +++ diff --git a/quotesdb/.nbd/tickets/03bb91.md b/quotesdb/.nbd/tickets/03bb91.md index a13b2d9..1e99f76 100644 --- a/quotesdb/.nbd/tickets/03bb91.md +++ b/quotesdb/.nbd/tickets/03bb91.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/04f865.md b/quotesdb/.nbd/tickets/04f865.md index 440d8bb..6938965 100644 --- a/quotesdb/.nbd/tickets/04f865.md +++ b/quotesdb/.nbd/tickets/04f865.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/05f8ae.md b/quotesdb/.nbd/tickets/05f8ae.md index 9629cdc..2ca8ae8 100644 --- a/quotesdb/.nbd/tickets/05f8ae.md +++ b/quotesdb/.nbd/tickets/05f8ae.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/08af7a.md b/quotesdb/.nbd/tickets/08af7a.md index 17123c6..94d2a6a 100644 --- a/quotesdb/.nbd/tickets/08af7a.md +++ b/quotesdb/.nbd/tickets/08af7a.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/0d987f.md b/quotesdb/.nbd/tickets/0d987f.md index 030abe2..d2f014e 100644 --- a/quotesdb/.nbd/tickets/0d987f.md +++ b/quotesdb/.nbd/tickets/0d987f.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/175382.md b/quotesdb/.nbd/tickets/175382.md index 18067fd..07bcbe8 100644 --- a/quotesdb/.nbd/tickets/175382.md +++ b/quotesdb/.nbd/tickets/175382.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/1a274d.md b/quotesdb/.nbd/tickets/1a274d.md index 38526b1..2a27a91 100644 --- a/quotesdb/.nbd/tickets/1a274d.md +++ b/quotesdb/.nbd/tickets/1a274d.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/1ba523.md b/quotesdb/.nbd/tickets/1ba523.md index cdf4858..ac73edc 100644 --- a/quotesdb/.nbd/tickets/1ba523.md +++ b/quotesdb/.nbd/tickets/1ba523.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/1e6a09.md b/quotesdb/.nbd/tickets/1e6a09.md index f78ad1c..59e4d7e 100644 --- a/quotesdb/.nbd/tickets/1e6a09.md +++ b/quotesdb/.nbd/tickets/1e6a09.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/28e7d9.md b/quotesdb/.nbd/tickets/28e7d9.md index fbe66a3..ac6d303 100644 --- a/quotesdb/.nbd/tickets/28e7d9.md +++ b/quotesdb/.nbd/tickets/28e7d9.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/2c5a57.md b/quotesdb/.nbd/tickets/2c5a57.md index 3599526..8a40b2c 100644 --- a/quotesdb/.nbd/tickets/2c5a57.md +++ b/quotesdb/.nbd/tickets/2c5a57.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/2ce22e.md b/quotesdb/.nbd/tickets/2ce22e.md index e55cb32..5b7ef2f 100644 --- a/quotesdb/.nbd/tickets/2ce22e.md +++ b/quotesdb/.nbd/tickets/2ce22e.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/2d1371.md b/quotesdb/.nbd/tickets/2d1371.md index 5e14515..00efba3 100644 --- a/quotesdb/.nbd/tickets/2d1371.md +++ b/quotesdb/.nbd/tickets/2d1371.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/372790.md b/quotesdb/.nbd/tickets/372790.md index c16a757..61b93af 100644 --- a/quotesdb/.nbd/tickets/372790.md +++ b/quotesdb/.nbd/tickets/372790.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/3781c9.md b/quotesdb/.nbd/tickets/3781c9.md index 46e3cb4..0f73990 100644 --- a/quotesdb/.nbd/tickets/3781c9.md +++ b/quotesdb/.nbd/tickets/3781c9.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/5137d7.md b/quotesdb/.nbd/tickets/5137d7.md index 8458a70..bb0f7df 100644 --- a/quotesdb/.nbd/tickets/5137d7.md +++ b/quotesdb/.nbd/tickets/5137d7.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/5379eb.md b/quotesdb/.nbd/tickets/5379eb.md index beda484..1018ba7 100644 --- a/quotesdb/.nbd/tickets/5379eb.md +++ b/quotesdb/.nbd/tickets/5379eb.md @@ -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 = [] +++ diff --git a/quotesdb/.nbd/tickets/57fe5e.md b/quotesdb/.nbd/tickets/57fe5e.md index 3a66e3a..c0bdeab 100644 --- a/quotesdb/.nbd/tickets/57fe5e.md +++ b/quotesdb/.nbd/tickets/57fe5e.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/5cdbd9.md b/quotesdb/.nbd/tickets/5cdbd9.md index 3229d6c..f53e698 100644 --- a/quotesdb/.nbd/tickets/5cdbd9.md +++ b/quotesdb/.nbd/tickets/5cdbd9.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/5d9f5a.md b/quotesdb/.nbd/tickets/5d9f5a.md index b425539..a60933e 100644 --- a/quotesdb/.nbd/tickets/5d9f5a.md +++ b/quotesdb/.nbd/tickets/5d9f5a.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/5dbb7d.md b/quotesdb/.nbd/tickets/5dbb7d.md index a330e7f..53720a3 100644 --- a/quotesdb/.nbd/tickets/5dbb7d.md +++ b/quotesdb/.nbd/tickets/5dbb7d.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/5f1112.md b/quotesdb/.nbd/tickets/5f1112.md index 038ef88..45f814c 100644 --- a/quotesdb/.nbd/tickets/5f1112.md +++ b/quotesdb/.nbd/tickets/5f1112.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/657836.md b/quotesdb/.nbd/tickets/657836.md index 1c3c7ff..324ef2a 100644 --- a/quotesdb/.nbd/tickets/657836.md +++ b/quotesdb/.nbd/tickets/657836.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/6e829e.md b/quotesdb/.nbd/tickets/6e829e.md index 3d05a61..edbb4a9 100644 --- a/quotesdb/.nbd/tickets/6e829e.md +++ b/quotesdb/.nbd/tickets/6e829e.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/71b1d4.md b/quotesdb/.nbd/tickets/71b1d4.md index 5912bea..acac51c 100644 --- a/quotesdb/.nbd/tickets/71b1d4.md +++ b/quotesdb/.nbd/tickets/71b1d4.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/75489a.md b/quotesdb/.nbd/tickets/75489a.md index 4d5b354..2815c9f 100644 --- a/quotesdb/.nbd/tickets/75489a.md +++ b/quotesdb/.nbd/tickets/75489a.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/75e3f0.md b/quotesdb/.nbd/tickets/75e3f0.md index 4c3bddc..e1f257f 100644 --- a/quotesdb/.nbd/tickets/75e3f0.md +++ b/quotesdb/.nbd/tickets/75e3f0.md @@ -1,7 +1,7 @@ +++ title = "Write tests/README.md" priority = 3 -status = "todo" +status = "done" ticket_type = "task" dependencies = ["9b581f"] +++ diff --git a/quotesdb/.nbd/tickets/886bfd.md b/quotesdb/.nbd/tickets/886bfd.md index 62625cd..5b36e99 100644 --- a/quotesdb/.nbd/tickets/886bfd.md +++ b/quotesdb/.nbd/tickets/886bfd.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/9c9546.md b/quotesdb/.nbd/tickets/9c9546.md index e0f546d..3ed5ac3 100644 --- a/quotesdb/.nbd/tickets/9c9546.md +++ b/quotesdb/.nbd/tickets/9c9546.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/a23489.md b/quotesdb/.nbd/tickets/a23489.md index afe8b5e..ce72a5e 100644 --- a/quotesdb/.nbd/tickets/a23489.md +++ b/quotesdb/.nbd/tickets/a23489.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/a5049d.md b/quotesdb/.nbd/tickets/a5049d.md index a192e63..098225c 100644 --- a/quotesdb/.nbd/tickets/a5049d.md +++ b/quotesdb/.nbd/tickets/a5049d.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/ae6a82.md b/quotesdb/.nbd/tickets/ae6a82.md index de0ac97..4aecec5 100644 --- a/quotesdb/.nbd/tickets/ae6a82.md +++ b/quotesdb/.nbd/tickets/ae6a82.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/ae886f.md b/quotesdb/.nbd/tickets/ae886f.md index 8363e9c..f420fcc 100644 --- a/quotesdb/.nbd/tickets/ae886f.md +++ b/quotesdb/.nbd/tickets/ae886f.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/af56a7.md b/quotesdb/.nbd/tickets/af56a7.md index 29100a2..4f2a0f0 100644 --- a/quotesdb/.nbd/tickets/af56a7.md +++ b/quotesdb/.nbd/tickets/af56a7.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/b20b5a.md b/quotesdb/.nbd/tickets/b20b5a.md index 381aaaa..65c6317 100644 --- a/quotesdb/.nbd/tickets/b20b5a.md +++ b/quotesdb/.nbd/tickets/b20b5a.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/b3ef98.md b/quotesdb/.nbd/tickets/b3ef98.md index 0b8e9db..659d6cb 100644 --- a/quotesdb/.nbd/tickets/b3ef98.md +++ b/quotesdb/.nbd/tickets/b3ef98.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/bb1514.md b/quotesdb/.nbd/tickets/bb1514.md index 33dd3e2..18f1d78 100644 --- a/quotesdb/.nbd/tickets/bb1514.md +++ b/quotesdb/.nbd/tickets/bb1514.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/d0da0b.md b/quotesdb/.nbd/tickets/d0da0b.md index 2cc342a..314559e 100644 --- a/quotesdb/.nbd/tickets/d0da0b.md +++ b/quotesdb/.nbd/tickets/d0da0b.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/d3d502.md b/quotesdb/.nbd/tickets/d3d502.md index d46a5ad..48244ca 100644 --- a/quotesdb/.nbd/tickets/d3d502.md +++ b/quotesdb/.nbd/tickets/d3d502.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/d5839a.md b/quotesdb/.nbd/tickets/d5839a.md index 9aa3ffe..56cac5f 100644 --- a/quotesdb/.nbd/tickets/d5839a.md +++ b/quotesdb/.nbd/tickets/d5839a.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/d792e2.md b/quotesdb/.nbd/tickets/d792e2.md index 7e2dc0d..b60ef85 100644 --- a/quotesdb/.nbd/tickets/d792e2.md +++ b/quotesdb/.nbd/tickets/d792e2.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/f850c6.md b/quotesdb/.nbd/tickets/f850c6.md index d4130d9..7ccda64 100644 --- a/quotesdb/.nbd/tickets/f850c6.md +++ b/quotesdb/.nbd/tickets/f850c6.md @@ -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"] +++ diff --git a/quotesdb/.nbd/tickets/fc2f51.md b/quotesdb/.nbd/tickets/fc2f51.md index dd521b7..c55b05d 100644 --- a/quotesdb/.nbd/tickets/fc2f51.md +++ b/quotesdb/.nbd/tickets/fc2f51.md @@ -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"] +++ diff --git a/quotesdb/Cargo.lock b/quotesdb/Cargo.lock index bb61fe8..a579f64 100644 --- a/quotesdb/Cargo.lock +++ b/quotesdb/Cargo.lock @@ -1120,6 +1120,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-rusqlite", + "tower", "uuid", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/quotesdb/Cargo.toml b/quotesdb/Cargo.toml index 3d6b95d..8d90e5e 100644 --- a/quotesdb/Cargo.toml +++ b/quotesdb/Cargo.toml @@ -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" diff --git a/quotesdb/docs/LOCAL_DEV.md b/quotesdb/docs/LOCAL_DEV.md new file mode 100644 index 0000000..871c3f3 --- /dev/null +++ b/quotesdb/docs/LOCAL_DEV.md @@ -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/` diff --git a/quotesdb/docs/api/ARCHITECTURE.md b/quotesdb/docs/api/ARCHITECTURE.md new file mode 100644 index 0000000..c0180b4 --- /dev/null +++ b/quotesdb/docs/api/ARCHITECTURE.md @@ -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` 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> + ↓ + 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. diff --git a/quotesdb/docs/api/PLANNING.md b/quotesdb/docs/api/PLANNING.md new file mode 100644 index 0000000..ee01f08 --- /dev/null +++ b/quotesdb/docs/api/PLANNING.md @@ -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. diff --git a/quotesdb/docs/api/README.md b/quotesdb/docs/api/README.md new file mode 100644 index 0000000..ff3689b --- /dev/null +++ b/quotesdb/docs/api/README.md @@ -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`. diff --git a/quotesdb/docs/ui/README.md b/quotesdb/docs/ui/README.md new file mode 100644 index 0000000..a0122bd --- /dev/null +++ b/quotesdb/docs/ui/README.md @@ -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 diff --git a/quotesdb/infra/.gitignore b/quotesdb/infra/.gitignore new file mode 100644 index 0000000..5df4c15 --- /dev/null +++ b/quotesdb/infra/.gitignore @@ -0,0 +1,5 @@ +.terraform/ +*.tfstate +*.tfstate.backup +.terraform.lock.hcl +*.tfvars diff --git a/quotesdb/infra/README.md b/quotesdb/infra/README.md new file mode 100644 index 0000000..0ceaccd --- /dev/null +++ b/quotesdb/infra/README.md @@ -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 | diff --git a/quotesdb/infra/d1.tf b/quotesdb/infra/d1.tf new file mode 100644 index 0000000..3ae2649 --- /dev/null +++ b/quotesdb/infra/d1.tf @@ -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 +} diff --git a/quotesdb/infra/dns.tf b/quotesdb/infra/dns.tf new file mode 100644 index 0000000..a372675 --- /dev/null +++ b/quotesdb/infra/dns.tf @@ -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" +} diff --git a/quotesdb/infra/main.tf b/quotesdb/infra/main.tf index 2649914..26f67e1 100644 --- a/quotesdb/infra/main.tf +++ b/quotesdb/infra/main.tf @@ -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" } } diff --git a/quotesdb/infra/pages.tf b/quotesdb/infra/pages.tf new file mode 100644 index 0000000..165f742 --- /dev/null +++ b/quotesdb/infra/pages.tf @@ -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" +} diff --git a/quotesdb/infra/providers.tf b/quotesdb/infra/providers.tf new file mode 100644 index 0000000..3e6eed4 --- /dev/null +++ b/quotesdb/infra/providers.tf @@ -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 +} diff --git a/quotesdb/infra/schema.sql b/quotesdb/infra/schema.sql new file mode 100644 index 0000000..9a74cab --- /dev/null +++ b/quotesdb/infra/schema.sql @@ -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); diff --git a/quotesdb/infra/variables.tf b/quotesdb/infra/variables.tf new file mode 100644 index 0000000..9224457 --- /dev/null +++ b/quotesdb/infra/variables.tf @@ -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 +} diff --git a/quotesdb/infra/worker.tf b/quotesdb/infra/worker.tf new file mode 100644 index 0000000..ca15f38 --- /dev/null +++ b/quotesdb/infra/worker.tf @@ -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 +} diff --git a/quotesdb/src/bin/api/db/connection.rs b/quotesdb/src/bin/api/db/connection.rs new file mode 100644 index 0000000..14f480a --- /dev/null +++ b/quotesdb/src/bin/api/db/connection.rs @@ -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> { +/// let repo = quotesdb::db::connection::open("quotesdb.sqlite").await?; +/// repo.run_migrations().await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn open(path: &str) -> Result { + 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)) +} diff --git a/quotesdb/src/bin/api/db/d1.rs b/quotesdb/src/bin/api/db/d1.rs new file mode 100644 index 0000000..f72cdba --- /dev/null +++ b/quotesdb/src/bin/api/db/d1.rs @@ -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 { + Err(DbError::Internal("D1 not yet implemented".to_string())) + } + + async fn get_quote(&self, _id: &str) -> Result, DbError> { + Err(DbError::Internal("D1 not yet implemented".to_string())) + } + + async fn get_random_quote(&self) -> Result, 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 { + Err(DbError::Internal("D1 not yet implemented".to_string())) + } + + async fn delete_quote(&self, _id: &str, _auth_code: &str) -> Result { + Err(DbError::Internal("D1 not yet implemented".to_string())) + } +} diff --git a/quotesdb/src/bin/api/db/mod.rs b/quotesdb/src/bin/api/db/mod.rs index 56879d4..e3bb9ed 100644 --- a/quotesdb/src/bin/api/db/mod.rs +++ b/quotesdb/src/bin/api/db/mod.rs @@ -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, + pub quotes: Vec, /// 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, DbError>; + async fn get_quote(&self, id: &str) -> Result, DbError>; /// Return a single random quote. /// /// Returns `Ok(None)` when the database is empty. - async fn get_random_quote(&self) -> Result, DbError>; + async fn get_random_quote(&self) -> Result, 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; + ) -> Result; /// Delete a quote by ID. /// diff --git a/quotesdb/src/bin/api/db/native.rs b/quotesdb/src/bin/api/db/native.rs new file mode 100644 index 0000000..d752265 --- /dev/null +++ b/quotesdb/src/bin/api/db/native.rs @@ -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` of tag values, or an empty vec if none exist. +fn fetch_tags_for_quote( + conn: &rusqlite::Connection, + quote_id: &str, +) -> Result, 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::, _>>()?; + 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) -> Result { + 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 { + 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 = 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> = 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> = 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 = 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::, _>>()?; + + // 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::, 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, 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, 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 { + 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 = self + .conn + .call({ + let id = id.clone(); + move |conn| { + let result: Option = 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 = 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> = 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 { + let id = id.to_owned(); + let auth_code = auth_code.to_owned(); + + self.conn + .call(move |conn| { + let stored: Option = 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); + } +} diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs new file mode 100644 index 0000000..a535ac3 --- /dev/null +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -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; + +// ── 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) -> 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, + /// Filter by tag. + tag: Option, +} + +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, Query(params): Query) -> 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) -> 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, Path(id): Path) -> 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, Json(input): Json) -> 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 { + 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, + Path(id): Path, + headers: HeaderMap, + Json(input): Json, +) -> 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, + Path(id): Path, + 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) -> 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>, + } + + 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 { + let quotes = self.quotes.lock().unwrap(); + let all: Vec = 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, 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, 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 { + 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 { + 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) -> (StatusCode, String) { + let resp = ServiceExt::>::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); + } +} diff --git a/quotesdb/src/bin/api/main.rs b/quotesdb/src/bin/api/main.rs index fef4bf3..c33d98d 100644 --- a/quotesdb/src/bin/api/main.rs +++ b/quotesdb/src/bin/api/main.rs @@ -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 = 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 {} diff --git a/quotesdb/tests/README.md b/quotesdb/tests/README.md new file mode 100644 index 0000000..5ce4de3 --- /dev/null +++ b/quotesdb/tests/README.md @@ -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)