diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 0e7ca18f..b12a565e 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -92,6 +92,64 @@ This endpoint: - Returns them as a sorted array - Caches the result for 1 hour +### "My festival" API (v1) + +Aggregate drink ratings and "would recommend" answers, backed by D1 (SQLite). +The first step towards an online "my festival". The API is resource-oriented +following [Google's AIPs](https://google.aip.dev) — the contract is defined in +`proto/` and an OpenAPI spec is generated from it (see `proto/README.md`). + +Writes are local-first on the client; the server holds the shared aggregate. +Every row and query is scoped by a `bucket` (`test` or `prod`, derived from the +request origin; only `https://cambeerfestival.app` → `prod`) so test traffic +never mixes with production data. A `RATINGS_BUCKET` worker var can pin it. + +Resources (the device is the record id, so a device has one record per drink): + +| Method | Path | Purpose | +| -------- | ------------------------------------------------------------ | ------------------------------- | +| `PATCH` | `/v1/festivals/{f}/drinks/{d}/ratings/{device}` | Upsert a rating (`{value:1-5}`) | +| `GET` | `/v1/festivals/{f}/drinks/{d}/ratings/{device}` | Get a device's rating | +| `DELETE` | `/v1/festivals/{f}/drinks/{d}/ratings/{device}` | Remove a device's rating | +| `GET` | `/v1/festivals/{f}/ratingSummaries/{d}` | Aggregate for one drink | +| `GET` | `/v1/festivals/{f}/ratingSummaries?page_size=&page_token=` | Paginated list of aggregates | + +The `recommendations` / `recommendationSummaries` collections mirror this with +a `{wouldRecommend: bool}` body. `PATCH` is an upsert (AIP-134 `allow_missing`); +`DELETE` takes the id in the path with no body (AIP-135) and is `NOT_FOUND` when +absent. Errors use the structured `google.rpc.Status` shape (AIP-193). + +```bash +# Upsert a rating, get back the Rating resource +curl -X PATCH https://data.cambeerfestival.app/v1/festivals/cbf2025/drinks/beer-1/ratings/dev-1 \ + -H 'Content-Type: application/json' -d '{"value":4}' +# -> {"name":"festivals/cbf2025/drinks/beer-1/ratings/dev-1","value":4,"updateTime":"2026-06-12T20:00:00.000Z"} + +# Aggregate for one drink +curl https://data.cambeerfestival.app/v1/festivals/cbf2025/ratingSummaries/beer-1 +# -> {"name":"festivals/cbf2025/ratingSummaries/beer-1","ratingCount":3,"averageRating":4.0} + +# % would recommend for one drink +curl https://data.cambeerfestival.app/v1/festivals/cbf2025/recommendationSummaries/beer-1 +# -> {"name":"...","responseCount":2,"recommendCount":1,"recommendRate":0.5} +``` + +#### D1 provisioning (one-time, before first deploy) + +The `database_id` in `wrangler.toml` is a placeholder. Local `wrangler dev` and +the vitest test pool use a simulated local D1 and ignore it, so the full test +suite runs with no real database. Before deploying: + +```bash +cd cloudflare-worker +wrangler d1 create cbf-ratings # prints the database_id +# paste the id into wrangler.toml ([[d1_databases]].database_id) +wrangler d1 migrations apply cbf-ratings # applies migrations/*.sql +``` + +The deploy `CLOUDFLARE_API_TOKEN` must include **D1: Edit** in addition to +Workers Scripts: Edit. To wipe test data: `DELETE FROM ratings WHERE bucket='test'`. + ### Health Check - `/health` - Returns `{"status": "ok"}` for monitoring diff --git a/cloudflare-worker/migrations/0001_create_ratings_table.sql b/cloudflare-worker/migrations/0001_create_ratings_table.sql new file mode 100644 index 00000000..5c6fa62d --- /dev/null +++ b/cloudflare-worker/migrations/0001_create_ratings_table.sql @@ -0,0 +1,24 @@ +-- Aggregate drink ratings (first step towards online "my festival"). +-- +-- One row per (bucket, festival, drink, device). The composite primary key +-- gives upsert semantics: a device re-rating a drink updates its existing row +-- rather than inserting a duplicate, so aggregate counts never inflate. +-- +-- `bucket` isolates data by environment ('test' vs 'prod') so we can exercise +-- the system end to end without polluting real festival data. `user_id` is +-- reserved for the sign-in upgrade (phase 3) and stays NULL while anonymous. + +CREATE TABLE IF NOT EXISTS ratings ( + bucket TEXT NOT NULL, + festival_id TEXT NOT NULL, + drink_id TEXT NOT NULL, + device_id TEXT NOT NULL, + user_id TEXT, + rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5), + updated_at INTEGER NOT NULL, + PRIMARY KEY (bucket, festival_id, drink_id, device_id) +); + +-- Aggregate reads always filter by (bucket, festival_id) and group by drink_id. +CREATE INDEX IF NOT EXISTS idx_ratings_aggregate + ON ratings (bucket, festival_id, drink_id); diff --git a/cloudflare-worker/migrations/0002_create_recommendations_table.sql b/cloudflare-worker/migrations/0002_create_recommendations_table.sql new file mode 100644 index 00000000..76dc3007 --- /dev/null +++ b/cloudflare-worker/migrations/0002_create_recommendations_table.sql @@ -0,0 +1,20 @@ +-- "Would recommend" — a yes/no signal per (bucket, festival, drink, device), +-- separate from the star rating so we can surface a "% would recommend". +-- +-- Shares the same upsert/bucket model as the ratings table. `recommend` is +-- stored as 0/1 because SQLite has no native boolean. `user_id` is reserved +-- for the sign-in upgrade and stays NULL while anonymous. + +CREATE TABLE IF NOT EXISTS recommendations ( + bucket TEXT NOT NULL, + festival_id TEXT NOT NULL, + drink_id TEXT NOT NULL, + device_id TEXT NOT NULL, + user_id TEXT, + recommend INTEGER NOT NULL CHECK (recommend IN (0, 1)), + updated_at INTEGER NOT NULL, + PRIMARY KEY (bucket, festival_id, drink_id, device_id) +); + +CREATE INDEX IF NOT EXISTS idx_recommendations_aggregate + ON recommendations (bucket, festival_id, drink_id); diff --git a/cloudflare-worker/ratings.js b/cloudflare-worker/ratings.js new file mode 100644 index 00000000..31f33063 --- /dev/null +++ b/cloudflare-worker/ratings.js @@ -0,0 +1,66 @@ +/** + * Ratings resource family for the /v1 API (AIP resource-oriented). + * + * GET /v1/festivals/{f}/drinks/{d}/ratings/{device} get my rating + * PATCH /v1/festivals/{f}/drinks/{d}/ratings/{device} upsert my rating + * DELETE /v1/festivals/{f}/drinks/{d}/ratings/{device} remove my rating + * GET /v1/festivals/{f}/ratingSummaries/{d} aggregate for a drink + * GET /v1/festivals/{f}/ratingSummaries list (paginated) + * + * Backed by the `ratings` table in D1. Writes are local-first on the client. + */ + +import { handleResourceFamily, rfc3339 } from "./shared.js"; + +function round1(value) { + return Math.round(value * 10) / 10; +} + +export const RATINGS_FAMILY = { + table: "ratings", + valueColumn: "rating", + writeCollection: "ratings", + summaryCollection: "ratingSummaries", + + /** Validate the Rating body { value: 1..5 }. */ + parseValue(body) { + if (body === null || typeof body !== "object") { + return { + ok: false, + reason: "INVALID_BODY", + message: "Body must be a JSON object", + }; + } + const { value } = body; + if (!Number.isInteger(value) || value < 1 || value > 5) { + return { + ok: false, + reason: "RATING_VALUE_OUT_OF_RANGE", + message: "value must be an integer between 1 and 5", + }; + } + return { ok: true, columnValue: value }; + }, + + /** Serialize a Rating resource from a DB row { value, updated_at }. */ + serializeResource(name, row) { + return { name, value: row.value, updateTime: rfc3339(row.updated_at) }; + }, + + // Aggregate columns selected for summary single + list queries. + summaryColumns: "COUNT(*) AS agg_count, AVG(rating) AS agg_average", + + /** Build RatingSummary fields from an aggregate row. */ + summaryFields(row) { + const count = row.agg_count || 0; + return { + ratingCount: count, + averageRating: count ? round1(row.agg_average) : 0, + }; + }, +}; + +/** Route a ratings request, or null if the path is not a ratings path. */ +export function handleRatings(request, url, env, corsHeaders) { + return handleResourceFamily(request, url, env, corsHeaders, RATINGS_FAMILY); +} diff --git a/cloudflare-worker/recommendations.js b/cloudflare-worker/recommendations.js new file mode 100644 index 00000000..4b666927 --- /dev/null +++ b/cloudflare-worker/recommendations.js @@ -0,0 +1,78 @@ +/** + * Recommendations resource family for the /v1 API (AIP resource-oriented). + * + * GET /v1/festivals/{f}/drinks/{d}/recommendations/{device} get my answer + * PATCH /v1/festivals/{f}/drinks/{d}/recommendations/{device} upsert my answer + * DELETE /v1/festivals/{f}/drinks/{d}/recommendations/{device} remove my answer + * GET /v1/festivals/{f}/recommendationSummaries/{d} aggregate for a drink + * GET /v1/festivals/{f}/recommendationSummaries list (paginated) + * + * A yes/no signal separate from the star rating, surfacing a "% would + * recommend". Backed by the `recommendations` table (`recommend` stored as 0/1). + */ + +import { handleResourceFamily, rfc3339 } from "./shared.js"; + +function round2(value) { + return Math.round(value * 100) / 100; +} + +export const RECOMMENDATIONS_FAMILY = { + table: "recommendations", + valueColumn: "recommend", + writeCollection: "recommendations", + summaryCollection: "recommendationSummaries", + + /** Validate the Recommendation body { wouldRecommend: bool }. */ + parseValue(body) { + if (body === null || typeof body !== "object") { + return { + ok: false, + reason: "INVALID_BODY", + message: "Body must be a JSON object", + }; + } + const { wouldRecommend } = body; + if (typeof wouldRecommend !== "boolean") { + return { + ok: false, + reason: "RECOMMENDATION_VALUE_INVALID", + message: "wouldRecommend must be a boolean", + }; + } + return { ok: true, columnValue: wouldRecommend ? 1 : 0 }; + }, + + /** Serialize a Recommendation resource from a DB row { value, updated_at }. */ + serializeResource(name, row) { + return { + name, + wouldRecommend: Boolean(row.value), + updateTime: rfc3339(row.updated_at), + }; + }, + + summaryColumns: "COUNT(*) AS agg_count, SUM(recommend) AS agg_yes", + + /** Build RecommendationSummary fields from an aggregate row. */ + summaryFields(row) { + const count = row.agg_count || 0; + const yes = row.agg_yes != null ? row.agg_yes : 0; + return { + responseCount: count, + recommendCount: yes, + recommendRate: count ? round2(yes / count) : 0, + }; + }, +}; + +/** Route a recommendations request, or null if not a recommendations path. */ +export function handleRecommendations(request, url, env, corsHeaders) { + return handleResourceFamily( + request, + url, + env, + corsHeaders, + RECOMMENDATIONS_FAMILY, + ); +} diff --git a/cloudflare-worker/shared.js b/cloudflare-worker/shared.js new file mode 100644 index 00000000..2dbc6652 --- /dev/null +++ b/cloudflare-worker/shared.js @@ -0,0 +1,426 @@ +/** + * Shared engine for the resource-oriented /v1 "my festival" APIs. + * + * Routes follow AIP resource names. For a "family" (ratings, recommendations) + * with write-collection W and summary-collection S: + * + * GET /v1/festivals/{f}/drinks/{d}/W/{device} get this device's record + * PATCH /v1/festivals/{f}/drinks/{d}/W/{device} upsert (allow_missing) + * DELETE /v1/festivals/{f}/drinks/{d}/W/{device} remove this device's record + * GET /v1/festivals/{f}/S/{d} aggregate for one drink + * GET /v1/festivals/{f}/S list aggregates (paginated) + * + * Errors use the structured google.rpc.Status shape (AIP-193). Lists paginate + * with opaque keyset tokens (AIP-158). + */ + +const MAX_ID_LENGTH = 200; +const DEFAULT_PAGE_SIZE = 100; +const MAX_PAGE_SIZE = 1000; +const ERROR_DOMAIN = "cambeerfestival.app"; + +export function isProductionOrigin(origin) { + return origin === "https://cambeerfestival.app"; +} + +export function resolveBucket(origin, env) { + if (env && typeof env.RATINGS_BUCKET === "string" && env.RATINGS_BUCKET) { + return env.RATINGS_BUCKET; + } + return isProductionOrigin(origin) ? "prod" : "test"; +} + +export function rfc3339(epochMillis) { + return new Date(epochMillis).toISOString(); +} + +function isValidId(value) { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= MAX_ID_LENGTH + ); +} + +// --- Responses (AIP-193) --------------------------------------------------- + +export function jsonResponse(body, status, corsHeaders) { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...corsHeaders, + }, + }); +} + +/** Structured error body per AIP-193 (google.rpc.Status + ErrorInfo). */ +export function errorResponse( + httpCode, + status, + message, + reason, + corsHeaders, + metadata, +) { + const errorInfo = { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason, + domain: ERROR_DOMAIN, + }; + if (metadata) errorInfo.metadata = metadata; + return jsonResponse( + { error: { code: httpCode, message, status, details: [errorInfo] } }, + httpCode, + corsHeaders, + ); +} + +// --- Pagination (AIP-158) -------------------------------------------------- + +/** Encode a keyset cursor (last drink id) as an opaque URL-safe token. */ +export function encodePageToken(drinkId) { + return btoa(unescape(encodeURIComponent(drinkId))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** Decode a page token back to its cursor, or null if absent. */ +export function decodePageToken(token) { + if (!token) return null; + try { + const b64 = token.replace(/-/g, "+").replace(/_/g, "/"); + return decodeURIComponent(escape(atob(b64))); + } catch { + return undefined; // signal "invalid token" + } +} + +/** Resolve an effective page size, or { error } for a bad value. */ +export function resolvePageSize(raw) { + if (raw == null || raw === "") return { value: DEFAULT_PAGE_SIZE }; + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) return { error: true }; + if (n === 0) return { value: DEFAULT_PAGE_SIZE }; + return { value: Math.min(n, MAX_PAGE_SIZE) }; +} + +// --- Routing --------------------------------------------------------------- + +function parseV1Path(pathname) { + if (pathname !== "/v1" && !pathname.startsWith("/v1/")) return null; + return pathname + .slice("/v1/".length) + .split("/") + .filter((s) => s.length > 0) + .map((s) => decodeURIComponent(s)); +} + +/** + * Handle a request for one resource family. Returns a Response if the path + * belongs to this family, otherwise null so the caller can try the next. + * + * `family` provides: table, valueColumn, writeCollection, summaryCollection, + * parseValue(body), serializeResource(name,row), summaryColumns, + * summaryFields(row). + */ +export async function handleResourceFamily( + request, + url, + env, + corsHeaders, + family, +) { + const segments = parseV1Path(url.pathname); + if (!segments || segments[0] !== "festivals" || segments.length < 3) { + return null; + } + + // /v1/festivals/{f}/drinks/{d}/{writeCollection}/{device} + const isWrite = + segments.length === 6 && + segments[2] === "drinks" && + segments[4] === family.writeCollection; + // /v1/festivals/{f}/{summaryCollection}[/{drink}] + const isSummary = + (segments.length === 3 || segments.length === 4) && + segments[2] === family.summaryCollection; + + if (!isWrite && !isSummary) return null; + + if (!env || !env.RATINGS_DB) { + return errorResponse( + 503, + "UNAVAILABLE", + "Storage is not configured", + "STORAGE_UNCONFIGURED", + corsHeaders, + ); + } + + const origin = request.headers.get("Origin") || ""; + const bucket = resolveBucket(origin, env); + const db = env.RATINGS_DB; + + if (isWrite) { + const [, festivalId, , drinkId, , deviceId] = segments; + if (!isValidId(festivalId) || !isValidId(drinkId) || !isValidId(deviceId)) { + return errorResponse( + 400, + "INVALID_ARGUMENT", + "Invalid resource name", + "INVALID_RESOURCE_NAME", + corsHeaders, + ); + } + const ctx = { + db, + bucket, + family, + festivalId, + drinkId, + deviceId, + corsHeaders, + }; + switch (request.method) { + case "GET": + return getRecord(ctx); + case "PATCH": + return upsertRecord(request, ctx); + case "DELETE": + return deleteRecord(ctx); + default: + return methodNotAllowed(corsHeaders); + } + } + + // Summary read / list + if (request.method !== "GET") return methodNotAllowed(corsHeaders); + const festivalId = segments[1]; + if (!isValidId(festivalId)) { + return errorResponse( + 400, + "INVALID_ARGUMENT", + "Invalid resource name", + "INVALID_RESOURCE_NAME", + corsHeaders, + ); + } + if (segments.length === 4) { + return getSummary({ + db, + bucket, + family, + festivalId, + drinkId: segments[3], + corsHeaders, + }); + } + return listSummaries({ db, bucket, family, festivalId, url, corsHeaders }); +} + +function methodNotAllowed(corsHeaders) { + return errorResponse( + 405, + "UNIMPLEMENTED", + "Method not allowed for this resource", + "METHOD_NOT_ALLOWED", + corsHeaders, + ); +} + +function writeResourceName(family, festivalId, drinkId, deviceId) { + return `festivals/${festivalId}/drinks/${drinkId}/${family.writeCollection}/${deviceId}`; +} + +function summaryResourceName(family, festivalId, drinkId) { + return `festivals/${festivalId}/${family.summaryCollection}/${drinkId}`; +} + +async function readRow(ctx) { + const { db, family, bucket, festivalId, drinkId, deviceId } = ctx; + return db + .prepare( + `SELECT ${family.valueColumn} AS value, updated_at FROM ${family.table} ` + + "WHERE bucket = ? AND festival_id = ? AND drink_id = ? AND device_id = ?", + ) + .bind(bucket, festivalId, drinkId, deviceId) + .first(); +} + +async function getRecord(ctx) { + const { family, festivalId, drinkId, deviceId, corsHeaders } = ctx; + const row = await readRow(ctx); + if (!row) { + return errorResponse( + 404, + "NOT_FOUND", + "No such rating", + "NOT_FOUND", + corsHeaders, + ); + } + const name = writeResourceName(family, festivalId, drinkId, deviceId); + return jsonResponse(family.serializeResource(name, row), 200, corsHeaders); +} + +async function upsertRecord(request, ctx) { + const { db, family, bucket, festivalId, drinkId, deviceId, corsHeaders } = + ctx; + + let body; + try { + body = await request.json(); + } catch { + return errorResponse( + 400, + "INVALID_ARGUMENT", + "Invalid JSON body", + "INVALID_BODY", + corsHeaders, + ); + } + + const parsed = family.parseValue(body); + if (!parsed.ok) { + return errorResponse( + 400, + "INVALID_ARGUMENT", + parsed.message, + parsed.reason, + corsHeaders, + ); + } + + await db + .prepare( + `INSERT INTO ${family.table} ` + + `(bucket, festival_id, drink_id, device_id, ${family.valueColumn}, updated_at) ` + + "VALUES (?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (bucket, festival_id, drink_id, device_id) " + + `DO UPDATE SET ${family.valueColumn} = excluded.${family.valueColumn}, ` + + "updated_at = excluded.updated_at", + ) + .bind(bucket, festivalId, drinkId, deviceId, parsed.columnValue, Date.now()) + .run(); + + const row = await readRow(ctx); + const name = writeResourceName(family, festivalId, drinkId, deviceId); + return jsonResponse(family.serializeResource(name, row), 200, corsHeaders); +} + +async function deleteRecord(ctx) { + const { db, family, bucket, festivalId, drinkId, deviceId, corsHeaders } = + ctx; + const result = await db + .prepare( + `DELETE FROM ${family.table} ` + + "WHERE bucket = ? AND festival_id = ? AND drink_id = ? AND device_id = ?", + ) + .bind(bucket, festivalId, drinkId, deviceId) + .run(); + + // AIP-135: deleting a missing resource is NOT_FOUND. + const changes = result.meta ? result.meta.changes : 0; + if (!changes) { + return errorResponse( + 404, + "NOT_FOUND", + "No such rating", + "NOT_FOUND", + ctx.corsHeaders, + ); + } + return jsonResponse({}, 200, corsHeaders); +} + +async function getSummary(ctx) { + const { db, family, bucket, festivalId, drinkId, corsHeaders } = ctx; + const row = await db + .prepare( + `SELECT ${family.summaryColumns} FROM ${family.table} ` + + "WHERE bucket = ? AND festival_id = ? AND drink_id = ?", + ) + .bind(bucket, festivalId, drinkId) + .first(); + + const name = summaryResourceName(family, festivalId, drinkId); + return jsonResponse( + { name, ...family.summaryFields(row || {}) }, + 200, + corsHeaders, + ); +} + +async function listSummaries(ctx) { + const { db, family, bucket, festivalId, url, corsHeaders } = ctx; + + const sizeResult = resolvePageSize(url.searchParams.get("page_size")); + if (sizeResult.error) { + return errorResponse( + 400, + "INVALID_ARGUMENT", + "page_size must be >= 0", + "INVALID_PAGE_SIZE", + corsHeaders, + ); + } + const pageSize = sizeResult.value; + + const cursor = decodePageToken(url.searchParams.get("page_token")); + if (cursor === undefined) { + return errorResponse( + 400, + "INVALID_ARGUMENT", + "Invalid page_token", + "INVALID_PAGE_TOKEN", + corsHeaders, + ); + } + + const where = ["bucket = ?", "festival_id = ?"]; + const binds = [bucket, festivalId]; + if (cursor !== null) { + where.push("drink_id > ?"); + binds.push(cursor); + } + + // Fetch one extra row to detect whether another page follows. + const { results } = await db + .prepare( + `SELECT drink_id, ${family.summaryColumns} FROM ${family.table} ` + + `WHERE ${where.join(" AND ")} GROUP BY drink_id ORDER BY drink_id LIMIT ?`, + ) + .bind(...binds, pageSize + 1) + .all(); + + const page = results.slice(0, pageSize); + const items = page.map((row) => ({ + name: summaryResourceName(family, festivalId, row.drink_id), + ...family.summaryFields(row), + })); + + let nextPageToken = ""; + if (results.length > pageSize) { + nextPageToken = encodePageToken(page[page.length - 1].drink_id); + } + + const totalRow = await db + .prepare( + `SELECT COUNT(DISTINCT drink_id) AS n FROM ${family.table} ` + + "WHERE bucket = ? AND festival_id = ?", + ) + .bind(bucket, festivalId) + .first(); + + return jsonResponse( + { + [family.summaryCollection]: items, + nextPageToken, + totalSize: totalRow ? totalRow.n : 0, + }, + 200, + corsHeaders, + ); +} diff --git a/cloudflare-worker/test/apply-migrations.js b/cloudflare-worker/test/apply-migrations.js new file mode 100644 index 00000000..40c89ef4 --- /dev/null +++ b/cloudflare-worker/test/apply-migrations.js @@ -0,0 +1,5 @@ +import { applyD1Migrations, env } from "cloudflare:test"; + +// Apply the ratings schema to the per-test simulated D1 before any test runs. +// `TEST_MIGRATIONS` is provided by vitest.config.js via readD1Migrations(). +await applyD1Migrations(env.RATINGS_DB, env.TEST_MIGRATIONS); diff --git a/cloudflare-worker/test/cors.test.js b/cloudflare-worker/test/cors.test.js index 4352f587..3c1cdbbe 100644 --- a/cloudflare-worker/test/cors.test.js +++ b/cloudflare-worker/test/cors.test.js @@ -160,7 +160,7 @@ describe("CORS preflight (OPTIONS)", () => { "OPTIONS", ); expect(response.headers.get("Access-Control-Allow-Methods")).toBe( - "GET, OPTIONS", + "GET, PATCH, DELETE, OPTIONS", ); expect(response.headers.get("Access-Control-Allow-Headers")).toBe( "Content-Type", diff --git a/cloudflare-worker/test/ratings.test.js b/cloudflare-worker/test/ratings.test.js new file mode 100644 index 00000000..5f57e490 --- /dev/null +++ b/cloudflare-worker/test/ratings.test.js @@ -0,0 +1,295 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + env, + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import worker from "../worker.js"; +import { + isProductionOrigin, + resolveBucket, + resolvePageSize, + encodePageToken, + decodePageToken, +} from "../shared.js"; +import { RATINGS_FAMILY } from "../ratings.js"; + +const TEST_ORIGIN = "http://localhost:8080"; // non-prod -> 'test' bucket +const PROD_ORIGIN = "https://cambeerfestival.app"; // -> 'prod' bucket + +async function send(method, path, { body, origin = TEST_ORIGIN } = {}) { + const init = { method, headers: { Origin: origin } }; + if (body !== undefined) { + init.headers["Content-Type"] = "application/json"; + init.body = typeof body === "string" ? body : JSON.stringify(body); + } + const request = new Request(`https://worker.example.com${path}`, init); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return response; +} + +const ratingPath = (f, d, device) => + `/v1/festivals/${f}/drinks/${d}/ratings/${device}`; +const upsert = (f, d, device, value, opts) => + send("PATCH", ratingPath(f, d, device), { body: { value }, ...opts }); + +beforeEach(async () => { + await env.RATINGS_DB.prepare("DELETE FROM ratings").run(); +}); + +describe("ratings — pure helpers", () => { + it("resolves the bucket from origin, with override", () => { + expect(isProductionOrigin(PROD_ORIGIN)).toBe(true); + expect(resolveBucket(PROD_ORIGIN, {})).toBe("prod"); + expect(resolveBucket(TEST_ORIGIN, {})).toBe("test"); + expect(resolveBucket(PROD_ORIGIN, { RATINGS_BUCKET: "test" })).toBe("test"); + }); + + it("resolvePageSize applies defaults, caps, and rejects negatives", () => { + expect(resolvePageSize(null).value).toBe(100); + expect(resolvePageSize("").value).toBe(100); + expect(resolvePageSize("0").value).toBe(100); + expect(resolvePageSize("25").value).toBe(25); + expect(resolvePageSize("9999").value).toBe(1000); + expect(resolvePageSize("-1").error).toBe(true); + }); + + it("page tokens round-trip and reject garbage", () => { + expect(decodePageToken(encodePageToken("beer-1"))).toBe("beer-1"); + expect(decodePageToken("")).toBe(null); + expect(decodePageToken(null)).toBe(null); + expect(decodePageToken("!!!not-base64!!!")).toBe(undefined); + }); + + it("RATINGS_FAMILY.parseValue enforces 1..5 integer", () => { + expect(RATINGS_FAMILY.parseValue({ value: 4 })).toMatchObject({ + ok: true, + columnValue: 4, + }); + for (const value of [0, 6, 3.5, "4", null]) { + expect(RATINGS_FAMILY.parseValue({ value }).ok).toBe(false); + } + expect(RATINGS_FAMILY.parseValue(null).ok).toBe(false); + }); + + it("RATINGS_FAMILY.summaryFields handles empty and populated rows", () => { + expect(RATINGS_FAMILY.summaryFields({})).toEqual({ + ratingCount: 0, + averageRating: 0, + }); + expect( + RATINGS_FAMILY.summaryFields({ agg_count: 2, agg_average: 4.5 }), + ).toEqual({ ratingCount: 2, averageRating: 4.5 }); + }); +}); + +describe("ratings — upsert (PATCH)", () => { + it("creates a rating and returns the resource", async () => { + const response = await upsert("cbf2025", "beer-1", "dev-1", 4); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.name).toBe("festivals/cbf2025/drinks/beer-1/ratings/dev-1"); + expect(data.value).toBe(4); + expect(typeof data.updateTime).toBe("string"); + expect(Number.isNaN(Date.parse(data.updateTime))).toBe(false); + }); + + it("re-rating updates in place rather than duplicating", async () => { + await upsert("cbf2025", "beer-1", "dev-1", 2); + await upsert("cbf2025", "beer-1", "dev-1", 5); + const summary = await send( + "GET", + "/v1/festivals/cbf2025/ratingSummaries/beer-1", + ); + const data = await summary.json(); + expect(data.ratingCount).toBe(1); + expect(data.averageRating).toBe(5); + }); + + it("rejects an out-of-range value with a structured error", async () => { + const response = await upsert("cbf2025", "beer-1", "dev-1", 9); + expect(response.status).toBe(400); + const { error } = await response.json(); + expect(error.code).toBe(400); + expect(error.status).toBe("INVALID_ARGUMENT"); + expect(error.details[0].reason).toBe("RATING_VALUE_OUT_OF_RANGE"); + expect(error.details[0].domain).toBe("cambeerfestival.app"); + }); + + it("rejects malformed JSON", async () => { + const response = await send( + "PATCH", + ratingPath("cbf2025", "beer-1", "dev-1"), + { + body: "{not json", + }, + ); + expect(response.status).toBe(400); + expect((await response.json()).error.details[0].reason).toBe( + "INVALID_BODY", + ); + }); +}); + +describe("ratings — get/delete record", () => { + it("gets a device's own rating", async () => { + await upsert("cbf2025", "beer-1", "dev-1", 3); + const response = await send( + "GET", + ratingPath("cbf2025", "beer-1", "dev-1"), + ); + expect(response.status).toBe(200); + expect((await response.json()).value).toBe(3); + }); + + it("returns 404 for a missing rating", async () => { + const response = await send( + "GET", + ratingPath("cbf2025", "beer-1", "ghost"), + ); + expect(response.status).toBe(404); + expect((await response.json()).error.status).toBe("NOT_FOUND"); + }); + + it("deletes a rating, then reads 404", async () => { + await upsert("cbf2025", "beer-1", "dev-1", 3); + const del = await send("DELETE", ratingPath("cbf2025", "beer-1", "dev-1")); + expect(del.status).toBe(200); + expect(await del.json()).toEqual({}); + const after = await send("GET", ratingPath("cbf2025", "beer-1", "dev-1")); + expect(after.status).toBe(404); + }); + + it("deleting a missing rating is 404 (AIP-135)", async () => { + const response = await send( + "DELETE", + ratingPath("cbf2025", "beer-1", "ghost"), + ); + expect(response.status).toBe(404); + }); +}); + +describe("ratings — summaries", () => { + it("aggregates across devices", async () => { + await upsert("cbf2025", "beer-1", "dev-1", 4); + await upsert("cbf2025", "beer-1", "dev-2", 5); + await upsert("cbf2025", "beer-1", "dev-3", 3); + const response = await send( + "GET", + "/v1/festivals/cbf2025/ratingSummaries/beer-1", + ); + const data = await response.json(); + expect(data.name).toBe("festivals/cbf2025/ratingSummaries/beer-1"); + expect(data.ratingCount).toBe(3); + expect(data.averageRating).toBe(4); + }); + + it("returns an empty summary for an unrated drink", async () => { + const response = await send( + "GET", + "/v1/festivals/cbf2025/ratingSummaries/never", + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ratingCount: 0, + averageRating: 0, + }); + }); + + it("lists summaries with total size", async () => { + await upsert("cbf2025", "beer-1", "dev-1", 4); + await upsert("cbf2025", "beer-2", "dev-1", 2); + const response = await send("GET", "/v1/festivals/cbf2025/ratingSummaries"); + const data = await response.json(); + expect(data.totalSize).toBe(2); + expect(data.nextPageToken).toBe(""); + expect(data.ratingSummaries.map((s) => s.name)).toEqual([ + "festivals/cbf2025/ratingSummaries/beer-1", + "festivals/cbf2025/ratingSummaries/beer-2", + ]); + }); + + it("paginates with opaque tokens", async () => { + await upsert("cbf2025", "beer-1", "dev-1", 4); + await upsert("cbf2025", "beer-2", "dev-1", 4); + await upsert("cbf2025", "beer-3", "dev-1", 4); + + const first = await send( + "GET", + "/v1/festivals/cbf2025/ratingSummaries?page_size=2", + ); + const firstData = await first.json(); + expect(firstData.ratingSummaries).toHaveLength(2); + expect(firstData.nextPageToken).not.toBe(""); + + const second = await send( + "GET", + `/v1/festivals/cbf2025/ratingSummaries?page_size=2&page_token=${firstData.nextPageToken}`, + ); + const secondData = await second.json(); + expect(secondData.ratingSummaries).toHaveLength(1); + expect(secondData.ratingSummaries[0].name).toBe( + "festivals/cbf2025/ratingSummaries/beer-3", + ); + expect(secondData.nextPageToken).toBe(""); + }); + + it("rejects a negative page_size", async () => { + const response = await send( + "GET", + "/v1/festivals/cbf2025/ratingSummaries?page_size=-1", + ); + expect(response.status).toBe(400); + expect((await response.json()).error.details[0].reason).toBe( + "INVALID_PAGE_SIZE", + ); + }); +}); + +describe("ratings — routing", () => { + it("returns 405 for an unsupported method on a record", async () => { + const response = await send( + "POST", + ratingPath("cbf2025", "beer-1", "dev-1"), + { + body: { value: 3 }, + }, + ); + expect(response.status).toBe(405); + expect((await response.json()).error.status).toBe("UNIMPLEMENTED"); + }); + + it("returns 404 for an unknown /v1 route", async () => { + const response = await send("GET", "/v1/festivals/cbf2025/bogus/beer-1"); + expect(response.status).toBe(404); + expect((await response.json()).error.details[0].reason).toBe( + "ROUTE_NOT_FOUND", + ); + }); +}); + +describe("ratings — bucket isolation", () => { + it("keeps test and prod traffic separate", async () => { + await upsert("cbf2025", "beer-1", "dev-1", 5, { origin: PROD_ORIGIN }); + await upsert("cbf2025", "beer-1", "dev-1", 1, { origin: TEST_ORIGIN }); + + const prod = await send( + "GET", + "/v1/festivals/cbf2025/ratingSummaries/beer-1", + { + origin: PROD_ORIGIN, + }, + ); + const test = await send( + "GET", + "/v1/festivals/cbf2025/ratingSummaries/beer-1", + { + origin: TEST_ORIGIN, + }, + ); + expect((await prod.json()).averageRating).toBe(5); + expect((await test.json()).averageRating).toBe(1); + }); +}); diff --git a/cloudflare-worker/test/recommendations.test.js b/cloudflare-worker/test/recommendations.test.js new file mode 100644 index 00000000..21ac0284 --- /dev/null +++ b/cloudflare-worker/test/recommendations.test.js @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + env, + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import worker from "../worker.js"; +import { RECOMMENDATIONS_FAMILY } from "../recommendations.js"; + +const TEST_ORIGIN = "http://localhost:8080"; // non-prod -> 'test' bucket +const PROD_ORIGIN = "https://cambeerfestival.app"; // -> 'prod' bucket + +async function send(method, path, { body, origin = TEST_ORIGIN } = {}) { + const init = { method, headers: { Origin: origin } }; + if (body !== undefined) { + init.headers["Content-Type"] = "application/json"; + init.body = typeof body === "string" ? body : JSON.stringify(body); + } + const request = new Request(`https://worker.example.com${path}`, init); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return response; +} + +const recPath = (f, d, device) => + `/v1/festivals/${f}/drinks/${d}/recommendations/${device}`; +const upsert = (f, d, device, wouldRecommend, opts) => + send("PATCH", recPath(f, d, device), { body: { wouldRecommend }, ...opts }); + +beforeEach(async () => { + await env.RATINGS_DB.prepare("DELETE FROM recommendations").run(); +}); + +describe("recommendations — pure helpers", () => { + it("parseValue requires a boolean", () => { + expect( + RECOMMENDATIONS_FAMILY.parseValue({ wouldRecommend: true }), + ).toMatchObject({ + ok: true, + columnValue: 1, + }); + expect( + RECOMMENDATIONS_FAMILY.parseValue({ wouldRecommend: false }), + ).toMatchObject({ + ok: true, + columnValue: 0, + }); + for (const wouldRecommend of [1, 0, "yes", null, undefined]) { + expect(RECOMMENDATIONS_FAMILY.parseValue({ wouldRecommend }).ok).toBe( + false, + ); + } + }); + + it("summaryFields computes a 0..1 rate", () => { + expect(RECOMMENDATIONS_FAMILY.summaryFields({})).toEqual({ + responseCount: 0, + recommendCount: 0, + recommendRate: 0, + }); + expect( + RECOMMENDATIONS_FAMILY.summaryFields({ agg_count: 3, agg_yes: 2 }), + ).toEqual({ responseCount: 3, recommendCount: 2, recommendRate: 0.67 }); + }); +}); + +describe("recommendations — upsert (PATCH)", () => { + it("creates an answer and returns the resource", async () => { + const response = await upsert("cbf2025", "beer-1", "dev-1", true); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.name).toBe( + "festivals/cbf2025/drinks/beer-1/recommendations/dev-1", + ); + expect(data.wouldRecommend).toBe(true); + expect(typeof data.updateTime).toBe("string"); + }); + + it("changing the answer updates in place", async () => { + await upsert("cbf2025", "beer-1", "dev-1", true); + const response = await upsert("cbf2025", "beer-1", "dev-1", false); + expect((await response.json()).wouldRecommend).toBe(false); + }); + + it("rejects a non-boolean with a structured error", async () => { + const response = await send( + "PATCH", + recPath("cbf2025", "beer-1", "dev-1"), + { + body: { wouldRecommend: "yes" }, + }, + ); + expect(response.status).toBe(400); + expect((await response.json()).error.details[0].reason).toBe( + "RECOMMENDATION_VALUE_INVALID", + ); + }); +}); + +describe("recommendations — get/delete record", () => { + it("gets and deletes a device's own answer", async () => { + await upsert("cbf2025", "beer-1", "dev-1", true); + const got = await send("GET", recPath("cbf2025", "beer-1", "dev-1")); + expect((await got.json()).wouldRecommend).toBe(true); + + const del = await send("DELETE", recPath("cbf2025", "beer-1", "dev-1")); + expect(del.status).toBe(200); + const after = await send("GET", recPath("cbf2025", "beer-1", "dev-1")); + expect(after.status).toBe(404); + }); +}); + +describe("recommendations — summaries", () => { + it("aggregates yes/no into a rate", async () => { + await upsert("cbf2025", "beer-1", "dev-1", true); + await upsert("cbf2025", "beer-1", "dev-2", true); + await upsert("cbf2025", "beer-1", "dev-3", false); + const response = await send( + "GET", + "/v1/festivals/cbf2025/recommendationSummaries/beer-1", + ); + const data = await response.json(); + expect(data.name).toBe("festivals/cbf2025/recommendationSummaries/beer-1"); + expect(data.responseCount).toBe(3); + expect(data.recommendCount).toBe(2); + expect(data.recommendRate).toBe(0.67); + }); + + it("returns an empty summary for a drink with no answers", async () => { + const response = await send( + "GET", + "/v1/festivals/cbf2025/recommendationSummaries/never", + ); + expect(await response.json()).toMatchObject({ + responseCount: 0, + recommendCount: 0, + recommendRate: 0, + }); + }); + + it("lists summaries", async () => { + await upsert("cbf2025", "beer-1", "dev-1", true); + await upsert("cbf2025", "beer-2", "dev-1", false); + const response = await send( + "GET", + "/v1/festivals/cbf2025/recommendationSummaries", + ); + const data = await response.json(); + expect(data.totalSize).toBe(2); + expect(data.recommendationSummaries.map((s) => s.name)).toEqual([ + "festivals/cbf2025/recommendationSummaries/beer-1", + "festivals/cbf2025/recommendationSummaries/beer-2", + ]); + }); +}); + +describe("recommendations — bucket isolation", () => { + it("keeps test and prod traffic separate", async () => { + await upsert("cbf2025", "beer-1", "dev-1", true, { origin: PROD_ORIGIN }); + await upsert("cbf2025", "beer-1", "dev-1", false, { origin: TEST_ORIGIN }); + + const prod = await send( + "GET", + "/v1/festivals/cbf2025/recommendationSummaries/beer-1", + { origin: PROD_ORIGIN }, + ); + const test = await send( + "GET", + "/v1/festivals/cbf2025/recommendationSummaries/beer-1", + { origin: TEST_ORIGIN }, + ); + expect((await prod.json()).recommendRate).toBe(1); + expect((await test.json()).recommendRate).toBe(0); + }); +}); diff --git a/cloudflare-worker/vitest.config.js b/cloudflare-worker/vitest.config.js index adce4cc5..6374e7e6 100644 --- a/cloudflare-worker/vitest.config.js +++ b/cloudflare-worker/vitest.config.js @@ -1,11 +1,26 @@ -import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { + cloudflareTest, + readD1Migrations, +} from "@cloudflare/vitest-pool-workers"; import { defineConfig } from "vitest/config"; -export default defineConfig({ - plugins: [ - cloudflareTest({ - wrangler: { configPath: "./wrangler.toml" }, - }), - ], - test: {}, +export default defineConfig(async () => { + // Read the SQL migrations once at config time. They are exposed to tests as + // the TEST_MIGRATIONS binding and applied to the simulated D1 in a setup file + // (see test/apply-migrations.js), so no real database is needed. + const migrations = await readD1Migrations("./migrations"); + + return { + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.toml" }, + miniflare: { + bindings: { TEST_MIGRATIONS: migrations }, + }, + }), + ], + test: { + setupFiles: ["./test/apply-migrations.js"], + }, + }; }); diff --git a/cloudflare-worker/worker.js b/cloudflare-worker/worker.js index 34ae2d4d..9fa46618 100644 --- a/cloudflare-worker/worker.js +++ b/cloudflare-worker/worker.js @@ -15,6 +15,9 @@ // Import festivals data directly - copied from data/festivals.json during build import festivalsData from "./festivals.json"; +import { handleRatings } from "./ratings.js"; +import { handleRecommendations } from "./recommendations.js"; +import { errorResponse } from "./shared.js"; const UPSTREAM_URL = "https://data.cambridgebeerfestival.com"; @@ -65,6 +68,39 @@ export default { }); } + // "My festival" API (/v1/...). Handled before the proxy fall-through so + // these paths are never forwarded upstream. + const ratingsResponse = await handleRatings( + request, + url, + env, + getCorsHeaders(request), + ); + if (ratingsResponse) { + return ratingsResponse; + } + + const recommendationsResponse = await handleRecommendations( + request, + url, + env, + getCorsHeaders(request), + ); + if (recommendationsResponse) { + return recommendationsResponse; + } + + // Any other /v1 path is an unknown API route — don't proxy it upstream. + if (url.pathname === "/v1" || url.pathname.startsWith("/v1/")) { + return errorResponse( + 404, + "NOT_FOUND", + "Unknown API route", + "ROUTE_NOT_FOUND", + getCorsHeaders(request), + ); + } + // Handle dynamic available_beverage_types.json endpoint // Pattern: /{festivalId}/available_beverage_types.json const availableTypesMatch = url.pathname.match( @@ -244,7 +280,7 @@ function handleCorsPreflight(request) { status: 204, headers: { ...getCorsHeaders(request), - "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Methods": "GET, PATCH, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Max-Age": maxAge, }, diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml index 9284424e..fac8e95d 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -7,3 +7,21 @@ compatibility_date = "2024-01-01" [vars] ENVIRONMENT = "production" + +# Aggregate ratings storage (D1). +# +# `database_id` is a placeholder until the database is provisioned in the +# Cloudflare account. Local dev (`wrangler dev`) and the vitest test pool use a +# simulated local D1 and ignore this id, so the whole test suite runs without a +# real database. Before the first `wrangler deploy`, run: +# +# wrangler d1 create cbf-ratings +# +# then paste the returned id below and apply migrations with: +# +# wrangler d1 migrations apply cbf-ratings +[[d1_databases]] +binding = "RATINGS_DB" +database_name = "cbf-ratings" +database_id = "00000000-0000-0000-0000-000000000000" +migrations_dir = "migrations" diff --git a/mise.dev.toml b/mise.dev.toml index 77d48bce..7f8101e5 100644 --- a/mise.dev.toml +++ b/mise.dev.toml @@ -17,6 +17,30 @@ [tools] watchexec = "2.5.1" +# --- Protobuf / OpenAPI (API contract is proto-first; see proto/README.md) --- +# buf is provided by the base mise.toml tools. The proto tasks require network +# access to buf.build (BSR deps + remote OpenAPI plugin). + +[tasks."proto:lint"] +description = "Lint the protobuf API contract" +dir = "proto" +run = "buf lint" + +[tasks."proto:format"] +description = "Format protobuf files in place" +dir = "proto" +run = "buf format -w" + +[tasks."proto:dep-update"] +description = "Refresh buf.lock from BSR dependencies (googleapis)" +dir = "proto" +run = "buf dep update" + +[tasks."proto:generate"] +description = "Generate OpenAPI from the proto contract (BSR remote plugin)" +dir = "proto" +run = "buf generate" + # All tasks moved to mise-tasks/ for better maintainability and shellcheck/shfmt support: # - dev -> mise-tasks/dev.sh # - test:e2e -> mise-tasks/test/e2e.sh diff --git a/proto/README.md b/proto/README.md new file mode 100644 index 00000000..f65ba843 --- /dev/null +++ b/proto/README.md @@ -0,0 +1,50 @@ +# API contract (proto-first) + +The online "my festival" API (ratings + recommendations) is defined here as +Protocol Buffers following [Google's API Improvement Proposals](https://google.aip.dev) +(AIP). The proto is the source of truth; an OpenAPI v3 document is generated +from it for the (hand-written) Cloudflare Worker implementation and any HTTP +clients. + +The transport is plain HTTP/JSON — the `google.api.http` annotations map each +RPC to a REST route. We do **not** run a gRPC server; the proto is the contract +and OpenAPI is the generated artifact. + +## Layout + +``` +proto/ +├── buf.yaml # module + lint/breaking config, BSR deps +├── buf.gen.yaml # codegen: OpenAPI via BSR remote plugin +└── cambeerfestival/myfestival/v1/ + ├── rating.proto # Rating + RatingSummary resources + ├── recommendation.proto # Recommendation + RecommendationSummary + └── my_festival_service.proto # service + request/response messages +``` + +## Resource model (AIP-121/122) + +| Resource | Name pattern | Methods | +| --- | --- | --- | +| `Rating` | `festivals/{f}/drinks/{d}/ratings/{device}` | Get, Update (upsert), Delete | +| `RatingSummary` | `festivals/{f}/ratingSummaries/{d}` | Get, List (paginated) | +| `Recommendation` | `festivals/{f}/drinks/{d}/recommendations/{device}` | Get, Update (upsert), Delete | +| `RecommendationSummary` | `festivals/{f}/recommendationSummaries/{d}` | Get, List (paginated) | + +Writes use **Update with `allow_missing`** (AIP-134 upsert) because the device +assigns the resource id; **Delete** takes the id in the path with no body +(AIP-135). Aggregates are read-only computed resources, listed with pagination +(AIP-158). Errors follow the structured `google.rpc.Status` shape (AIP-193). + +## Generating + +Requires the `buf` toolchain (provided by mise) and network access to +`buf.build` (BSR module deps + the remote OpenAPI plugin). + +```bash +MISE_ENV=dev ./bin/mise run proto:dep-update # writes buf.lock (first time) +MISE_ENV=dev ./bin/mise run proto:lint # AIP-aware lint +MISE_ENV=dev ./bin/mise run proto:generate # -> docs/code/api/openapi/openapi.yaml +``` + +`buf format -w` (via `proto:format`) keeps the files canonically formatted. diff --git a/proto/buf.gen.yaml b/proto/buf.gen.yaml new file mode 100644 index 00000000..9f5e45f0 --- /dev/null +++ b/proto/buf.gen.yaml @@ -0,0 +1,10 @@ +version: v2 +clean: true +plugins: + # OpenAPI v3 generated from the google.api.http annotations, via a BSR + # remote plugin (no local protoc/plugin install needed). + - remote: buf.build/community/google-gnostic-openapi:v0.7.0 + out: ../docs/code/api/openapi + opt: + - enum_type=string + - default_response=false diff --git a/proto/buf.lock b/proto/buf.lock new file mode 100644 index 00000000..84475891 --- /dev/null +++ b/proto/buf.lock @@ -0,0 +1,6 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/googleapis/googleapis + commit: c17df5b2beca46928cc87d5656bd5343 + digest: b5:648a01e0170d4512dea7d564016165decd1ed6e34bef79fe54753e51ad7e27545709ad9157d7551270147d551155c595a2fb0bf5bb33b1c83040ddbce915c604 diff --git a/proto/buf.yaml b/proto/buf.yaml new file mode 100644 index 00000000..76a071bf --- /dev/null +++ b/proto/buf.yaml @@ -0,0 +1,17 @@ +version: v2 +modules: + - path: . +deps: + - buf.build/googleapis/googleapis +lint: + use: + - STANDARD + except: + # AIP-131/134: Get and Update return the resource itself, and Delete + # returns google.protobuf.Empty — both intentionally diverge from buf's + # "Response" / unique-response defaults. Google's own APIs do the same. + - RPC_RESPONSE_STANDARD_NAME + - RPC_REQUEST_RESPONSE_UNIQUE +breaking: + use: + - FILE diff --git a/proto/cambeerfestival/myfestival/v1/my_festival_service.proto b/proto/cambeerfestival/myfestival/v1/my_festival_service.proto new file mode 100644 index 00000000..e304b70c --- /dev/null +++ b/proto/cambeerfestival/myfestival/v1/my_festival_service.proto @@ -0,0 +1,215 @@ +// Online "my festival" API: shared rating and recommendation aggregates. +syntax = "proto3"; + +package cambeerfestival.myfestival.v1; + +import "cambeerfestival/myfestival/v1/rating.proto"; +import "cambeerfestival/myfestival/v1/recommendation.proto"; +import "google/api/annotations.proto"; +import "google/api/client.proto"; +import "google/api/field_behavior.proto"; +import "google/api/resource.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/field_mask.proto"; + +// Stores each device's rating / "would recommend" answer for a drink and +// serves back the bucket-scoped aggregate. Writes are local-first on the +// client; this service is the shared, cross-device aggregate. +service MyFestivalService { + option (google.api.default_host) = "data.cambeerfestival.app"; + + // --- Ratings ------------------------------------------------------------- + + // Get this device's rating for a drink. + rpc GetRating(GetRatingRequest) returns (Rating) { + option (google.api.http) = {get: "/v1/{name=festivals/*/drinks/*/ratings/*}"}; + option (google.api.method_signature) = "name"; + } + + // Create or update this device's rating for a drink (upsert). + rpc UpdateRating(UpdateRatingRequest) returns (Rating) { + option (google.api.http) = { + patch: "/v1/{rating.name=festivals/*/drinks/*/ratings/*}" + body: "rating" + }; + option (google.api.method_signature) = "rating,update_mask"; + } + + // Remove this device's rating for a drink. + rpc DeleteRating(DeleteRatingRequest) returns (google.protobuf.Empty) { + option (google.api.http) = {delete: "/v1/{name=festivals/*/drinks/*/ratings/*}"}; + option (google.api.method_signature) = "name"; + } + + // Get the aggregate rating for a single drink. + rpc GetRatingSummary(GetRatingSummaryRequest) returns (RatingSummary) { + option (google.api.http) = {get: "/v1/{name=festivals/*/ratingSummaries/*}"}; + option (google.api.method_signature) = "name"; + } + + // List aggregate ratings for every rated drink at a festival. + rpc ListRatingSummaries(ListRatingSummariesRequest) returns (ListRatingSummariesResponse) { + option (google.api.http) = {get: "/v1/{parent=festivals/*}/ratingSummaries"}; + option (google.api.method_signature) = "parent"; + } + + // --- Recommendations ----------------------------------------------------- + + // Get this device's "would recommend" answer for a drink. + rpc GetRecommendation(GetRecommendationRequest) returns (Recommendation) { + option (google.api.http) = {get: "/v1/{name=festivals/*/drinks/*/recommendations/*}"}; + option (google.api.method_signature) = "name"; + } + + // Create or update this device's "would recommend" answer (upsert). + rpc UpdateRecommendation(UpdateRecommendationRequest) returns (Recommendation) { + option (google.api.http) = { + patch: "/v1/{recommendation.name=festivals/*/drinks/*/recommendations/*}" + body: "recommendation" + }; + option (google.api.method_signature) = "recommendation,update_mask"; + } + + // Remove this device's "would recommend" answer for a drink. + rpc DeleteRecommendation(DeleteRecommendationRequest) returns (google.protobuf.Empty) { + option (google.api.http) = {delete: "/v1/{name=festivals/*/drinks/*/recommendations/*}"}; + option (google.api.method_signature) = "name"; + } + + // Get the aggregate recommendation for a single drink. + rpc GetRecommendationSummary(GetRecommendationSummaryRequest) returns (RecommendationSummary) { + option (google.api.http) = {get: "/v1/{name=festivals/*/recommendationSummaries/*}"}; + option (google.api.method_signature) = "name"; + } + + // List aggregate recommendations for every drink with an answer. + rpc ListRecommendationSummaries(ListRecommendationSummariesRequest) returns (ListRecommendationSummariesResponse) { + option (google.api.http) = {get: "/v1/{parent=festivals/*}/recommendationSummaries"}; + option (google.api.method_signature) = "parent"; + } +} + +// --- Rating requests ------------------------------------------------------- + +message GetRatingRequest { + // Resource name: festivals/{festival}/drinks/{drink}/ratings/{device}. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "myfestival.cambeerfestival.app/Rating" + ]; +} + +message UpdateRatingRequest { + // The rating to set. Its `name` identifies the resource. + Rating rating = 1 [(google.api.field_behavior) = REQUIRED]; + + // Fields to update; omit to update all populated fields. + google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = OPTIONAL]; + + // If true (the default for this API), create the rating when absent (upsert). + bool allow_missing = 3 [(google.api.field_behavior) = OPTIONAL]; +} + +message DeleteRatingRequest { + // Resource name: festivals/{festival}/drinks/{drink}/ratings/{device}. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "myfestival.cambeerfestival.app/Rating" + ]; +} + +message GetRatingSummaryRequest { + // Resource name: festivals/{festival}/ratingSummaries/{drink}. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "myfestival.cambeerfestival.app/RatingSummary" + ]; +} + +message ListRatingSummariesRequest { + // Parent festival: festivals/{festival}. + string parent = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).child_type = "myfestival.cambeerfestival.app/RatingSummary" + ]; + + // Maximum number to return; the server may return fewer. Defaults applied + // when unset or zero. + int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL]; + + // Page token from a previous response. + string page_token = 3 [(google.api.field_behavior) = OPTIONAL]; +} + +message ListRatingSummariesResponse { + // Aggregate ratings for this page, one per rated drink. + repeated RatingSummary rating_summaries = 1; + + // Token for the next page; empty when there are no more. + string next_page_token = 2; + + // Total number of rated drinks at the festival. + int32 total_size = 3; +} + +// --- Recommendation requests ----------------------------------------------- + +message GetRecommendationRequest { + // festivals/{festival}/drinks/{drink}/recommendations/{device}. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "myfestival.cambeerfestival.app/Recommendation" + ]; +} + +message UpdateRecommendationRequest { + // The answer to set. Its `name` identifies the resource. + Recommendation recommendation = 1 [(google.api.field_behavior) = REQUIRED]; + + // Fields to update; omit to update all populated fields. + google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = OPTIONAL]; + + // If true (the default for this API), create the answer when absent (upsert). + bool allow_missing = 3 [(google.api.field_behavior) = OPTIONAL]; +} + +message DeleteRecommendationRequest { + // festivals/{festival}/drinks/{drink}/recommendations/{device}. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "myfestival.cambeerfestival.app/Recommendation" + ]; +} + +message GetRecommendationSummaryRequest { + // festivals/{festival}/recommendationSummaries/{drink}. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "myfestival.cambeerfestival.app/RecommendationSummary" + ]; +} + +message ListRecommendationSummariesRequest { + // Parent festival: festivals/{festival}. + string parent = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).child_type = "myfestival.cambeerfestival.app/RecommendationSummary" + ]; + + // Maximum number to return; the server may return fewer. + int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL]; + + // Page token from a previous response. + string page_token = 3 [(google.api.field_behavior) = OPTIONAL]; +} + +message ListRecommendationSummariesResponse { + // Aggregate recommendations for this page, one per drink with an answer. + repeated RecommendationSummary recommendation_summaries = 1; + + // Token for the next page; empty when there are no more. + string next_page_token = 2; + + // Total number of drinks with at least one answer. + int32 total_size = 3; +} diff --git a/proto/cambeerfestival/myfestival/v1/rating.proto b/proto/cambeerfestival/myfestival/v1/rating.proto new file mode 100644 index 00000000..0126db79 --- /dev/null +++ b/proto/cambeerfestival/myfestival/v1/rating.proto @@ -0,0 +1,52 @@ +// Aggregate drink ratings for the online "my festival" API. +syntax = "proto3"; + +package cambeerfestival.myfestival.v1; + +import "google/api/field_behavior.proto"; +import "google/api/resource.proto"; +import "google/protobuf/timestamp.proto"; + +// A single device's star rating for one drink at one festival. +// +// The resource id is the device (anonymous now, a signed-in user later), so a +// device has at most one rating per drink — updating it overwrites in place. +message Rating { + option (google.api.resource) = { + type: "myfestival.cambeerfestival.app/Rating" + pattern: "festivals/{festival}/drinks/{drink}/ratings/{device}" + singular: "rating" + plural: "ratings" + }; + + // Resource name: festivals/{festival}/drinks/{drink}/ratings/{device}. + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // The star rating, 1-5 inclusive. + int32 value = 2 [(google.api.field_behavior) = REQUIRED]; + + // When the rating was last set. + google.protobuf.Timestamp update_time = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; +} + +// Computed, read-only aggregate of every device's rating for one drink. +// +// Keyed by drink under the festival so the whole festival can be listed in one +// paginated call for list/grid views. +message RatingSummary { + option (google.api.resource) = { + type: "myfestival.cambeerfestival.app/RatingSummary" + pattern: "festivals/{festival}/ratingSummaries/{drink}" + singular: "ratingSummary" + plural: "ratingSummaries" + }; + + // Resource name: festivals/{festival}/ratingSummaries/{drink}. + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // Number of ratings contributing to the average. + int32 rating_count = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // Mean rating across all devices (1.0-5.0); 0 when there are no ratings. + double average_rating = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; +} diff --git a/proto/cambeerfestival/myfestival/v1/recommendation.proto b/proto/cambeerfestival/myfestival/v1/recommendation.proto new file mode 100644 index 00000000..ac641ebc --- /dev/null +++ b/proto/cambeerfestival/myfestival/v1/recommendation.proto @@ -0,0 +1,52 @@ +// "Would recommend" signal for the online "my festival" API. +syntax = "proto3"; + +package cambeerfestival.myfestival.v1; + +import "google/api/field_behavior.proto"; +import "google/api/resource.proto"; +import "google/protobuf/timestamp.proto"; + +// A single device's "would recommend" answer for one drink at one festival. +// +// Separate from the star rating so a drink can surface a "% would recommend". +// The device is the resource id, so a device has at most one answer per drink. +message Recommendation { + option (google.api.resource) = { + type: "myfestival.cambeerfestival.app/Recommendation" + pattern: "festivals/{festival}/drinks/{drink}/recommendations/{device}" + singular: "recommendation" + plural: "recommendations" + }; + + // Resource name: festivals/{festival}/drinks/{drink}/recommendations/{device}. + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // Whether this device would recommend the drink. + bool would_recommend = 2 [(google.api.field_behavior) = REQUIRED]; + + // When the answer was last set. + google.protobuf.Timestamp update_time = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; +} + +// Computed, read-only aggregate of every device's answer for one drink. +message RecommendationSummary { + option (google.api.resource) = { + type: "myfestival.cambeerfestival.app/RecommendationSummary" + pattern: "festivals/{festival}/recommendationSummaries/{drink}" + singular: "recommendationSummary" + plural: "recommendationSummaries" + }; + + // Resource name: festivals/{festival}/recommendationSummaries/{drink}. + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // Total number of yes/no responses. + int32 response_count = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // Number of responses that would recommend. + int32 recommend_count = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // Fraction (0.0-1.0) of responses that would recommend; 0 when none. + double recommend_rate = 4 [(google.api.field_behavior) = OUTPUT_ONLY]; +}