Skip to content

Commit 501bb2d

Browse files
committed
refactor(worker)!: resource-oriented AIP API for ratings/recommendations
Rework the /v1 API to conform to the proto contract and Google's AIPs. BREAKING CHANGE: replaces the flat POST/DELETE /v1/ratings endpoints with resource-oriented routes. Nothing consumes them yet (no client, placeholder DB), so this is a safe pre-launch change. - Resource names: PATCH/GET/DELETE on /v1/festivals/{f}/drinks/{d}/ratings/{device} (and .../recommendations/...). - Upsert via PATCH with allow_missing semantics (AIP-134); bodyless DELETE that is NOT_FOUND when absent (AIP-135). - Read aggregates as RatingSummary / RecommendationSummary resources: GET .../{f}/ratingSummaries/{d} and a paginated list GET .../{f}/ratingSummaries (page_size/page_token/next_page_token + total_size, keyset cursor) (AIP-158). - Structured google.rpc.Status errors with ErrorInfo reason+domain (AIP-193). - RFC3339 update_time; camelCase resource fields matching the proto JSON mapping; dropped redundant your_* (client is local-first and knows its own). - Generic family engine in shared.js drives both resources; CORS now allows GET/PATCH/DELETE; unknown /v1 routes 404 instead of proxying upstream. - 87 worker tests pass.
1 parent 02c1e81 commit 501bb2d

8 files changed

Lines changed: 841 additions & 1053 deletions

File tree

cloudflare-worker/README.md

Lines changed: 33 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -92,59 +92,46 @@ This endpoint:
9292
- Returns them as a sorted array
9393
- Caches the result for 1 hour
9494

95-
### Ratings API (v1)
96-
97-
Aggregate drink ratings backed by a D1 (SQLite) database. This is the first
98-
step towards an online "my festival". Writes are local-first on the client; the
99-
server holds the shared aggregate. Every row and query is scoped by a `bucket`
100-
(`test` or `prod`) so test traffic never mixes with production data.
101-
102-
| Method | Path | Purpose |
103-
| -------- | ------------------------------------- | -------------------------------------------------- |
104-
| `POST` | `/v1/ratings` | Upsert a device's rating (1–5) |
105-
| `DELETE` | `/v1/ratings` | Remove a device's rating |
106-
| `GET` | `/v1/ratings/{festivalId}/{drinkId}` | Aggregate for one drink |
107-
| `GET` | `/v1/ratings/{festivalId}` | Aggregate for every rated drink (batch, keyed map) |
108-
109-
`POST`/`DELETE` take a JSON body `{ festivalId, drinkId, deviceId, rating }`
110-
(`rating` omitted for `DELETE`). `GET` requests accept an optional
111-
`?deviceId=` to include the caller's own `yourRating`. The bucket is derived
112-
from the request origin (only `https://cambeerfestival.app``prod`; everything
113-
else → `test`) and can be pinned with a `RATINGS_BUCKET` worker var.
95+
### "My festival" API (v1)
11496

115-
```bash
116-
# Submit a rating, get back the aggregate
117-
curl -X POST https://data.cambeerfestival.app/v1/ratings \
118-
-H 'Content-Type: application/json' \
119-
-d '{"festivalId":"cbf2025","drinkId":"beer-1","deviceId":"dev-1","rating":4}'
120-
# -> {"festivalId":"cbf2025","drinkId":"beer-1","count":1,"average":4,"yourRating":4}
121-
122-
# Read the aggregate for one drink
123-
curl https://data.cambeerfestival.app/v1/ratings/cbf2025/beer-1?deviceId=dev-1
124-
```
97+
Aggregate drink ratings and "would recommend" answers, backed by D1 (SQLite).
98+
The first step towards an online "my festival". The API is resource-oriented
99+
following [Google's AIPs](https://google.aip.dev) — the contract is defined in
100+
`proto/` and an OpenAPI spec is generated from it (see `proto/README.md`).
125101

126-
### Would-recommend API (v1)
102+
Writes are local-first on the client; the server holds the shared aggregate.
103+
Every row and query is scoped by a `bucket` (`test` or `prod`, derived from the
104+
request origin; only `https://cambeerfestival.app``prod`) so test traffic
105+
never mixes with production data. A `RATINGS_BUCKET` worker var can pin it.
127106

128-
A yes/no "would recommend" signal, separate from the star rating, surfacing a
129-
`% would recommend` per drink. Same D1 database, shape and bucket rules as the
130-
ratings API, in a `recommendations` table.
107+
Resources (the device is the record id, so a device has one record per drink):
131108

132-
| Method | Path | Purpose |
133-
| -------- | --------------------------------------------- | -------------------------------- |
134-
| `POST` | `/v1/recommendations` | Upsert a device's yes/no answer |
135-
| `DELETE` | `/v1/recommendations` | Remove a device's answer |
136-
| `GET` | `/v1/recommendations/{festivalId}/{drinkId}` | Aggregate for one drink |
137-
| `GET` | `/v1/recommendations/{festivalId}` | Aggregate for every drink (batch) |
109+
| Method | Path | Purpose |
110+
| -------- | ------------------------------------------------------------ | ------------------------------- |
111+
| `PATCH` | `/v1/festivals/{f}/drinks/{d}/ratings/{device}` | Upsert a rating (`{value:1-5}`) |
112+
| `GET` | `/v1/festivals/{f}/drinks/{d}/ratings/{device}` | Get a device's rating |
113+
| `DELETE` | `/v1/festivals/{f}/drinks/{d}/ratings/{device}` | Remove a device's rating |
114+
| `GET` | `/v1/festivals/{f}/ratingSummaries/{d}` | Aggregate for one drink |
115+
| `GET` | `/v1/festivals/{f}/ratingSummaries?page_size=&page_token=` | Paginated list of aggregates |
138116

139-
`POST` takes `{ festivalId, drinkId, deviceId, recommend }` where `recommend`
140-
is a JSON boolean (`DELETE` omits it). Responses report total responses, the
141-
"yes" count, the percentage, and the caller's own answer:
117+
The `recommendations` / `recommendationSummaries` collections mirror this with
118+
a `{wouldRecommend: bool}` body. `PATCH` is an upsert (AIP-134 `allow_missing`);
119+
`DELETE` takes the id in the path with no body (AIP-135) and is `NOT_FOUND` when
120+
absent. Errors use the structured `google.rpc.Status` shape (AIP-193).
142121

143122
```bash
144-
curl -X POST https://data.cambeerfestival.app/v1/recommendations \
145-
-H 'Content-Type: application/json' \
146-
-d '{"festivalId":"cbf2025","drinkId":"beer-1","deviceId":"dev-1","recommend":true}'
147-
# -> {"festivalId":"cbf2025","drinkId":"beer-1","count":1,"recommendCount":1,"recommendPercent":100,"youRecommend":true}
123+
# Upsert a rating, get back the Rating resource
124+
curl -X PATCH https://data.cambeerfestival.app/v1/festivals/cbf2025/drinks/beer-1/ratings/dev-1 \
125+
-H 'Content-Type: application/json' -d '{"value":4}'
126+
# -> {"name":"festivals/cbf2025/drinks/beer-1/ratings/dev-1","value":4,"updateTime":"2026-06-12T20:00:00.000Z"}
127+
128+
# Aggregate for one drink
129+
curl https://data.cambeerfestival.app/v1/festivals/cbf2025/ratingSummaries/beer-1
130+
# -> {"name":"festivals/cbf2025/ratingSummaries/beer-1","ratingCount":3,"averageRating":4.0}
131+
132+
# % would recommend for one drink
133+
curl https://data.cambeerfestival.app/v1/festivals/cbf2025/recommendationSummaries/beer-1
134+
# -> {"name":"...","responseCount":2,"recommendCount":1,"recommendRate":0.5}
148135
```
149136

150137
#### D1 provisioning (one-time, before first deploy)

cloudflare-worker/ratings.js

Lines changed: 53 additions & 195 deletions
Original file line numberDiff line numberDiff line change
@@ -1,208 +1,66 @@
11
/**
2-
* Aggregate drink ratings API (v1).
2+
* Ratings resource family for the /v1 API (AIP resource-oriented).
33
*
4-
* Endpoints (all under /v1/ratings, served by the same worker as the proxy):
5-
* POST /v1/ratings upsert a device's rating
6-
* DELETE /v1/ratings remove a device's rating
7-
* GET /v1/ratings/{festivalId}/{drinkId} aggregate for one drink
8-
* GET /v1/ratings/{festivalId} aggregate for every rated drink
4+
* GET /v1/festivals/{f}/drinks/{d}/ratings/{device} get my rating
5+
* PATCH /v1/festivals/{f}/drinks/{d}/ratings/{device} upsert my rating
6+
* DELETE /v1/festivals/{f}/drinks/{d}/ratings/{device} remove my rating
7+
* GET /v1/festivals/{f}/ratingSummaries/{d} aggregate for a drink
8+
* GET /v1/festivals/{f}/ratingSummaries list (paginated)
99
*
10-
* Writes are local-first on the client; the server is the shared aggregate.
11-
* Shared bucket/validation/routing plumbing lives in shared.js.
10+
* Backed by the `ratings` table in D1. Writes are local-first on the client.
1211
*/
1312

14-
import {
15-
validateIds,
16-
jsonResponse,
17-
parseJsonBody,
18-
routeResource,
19-
} from "./shared.js";
13+
import { handleResourceFamily, rfc3339 } from "./shared.js";
2014

21-
/**
22-
* Validate a write payload. POST requires `rating`; DELETE only needs ids.
23-
* Returns { ok: true, value } or { ok: false, error }.
24-
*/
25-
export function validateWritePayload(body, { requireRating }) {
26-
const ids = validateIds(body);
27-
if (!ids.ok) return ids;
28-
29-
const { rating } = body;
30-
if (requireRating) {
31-
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
32-
return { ok: false, error: "rating must be an integer between 1 and 5" };
33-
}
34-
}
35-
return { ok: true, value: { ...ids.value, rating } };
36-
}
37-
38-
/** Round an average to one decimal place, or null when there are no ratings. */
39-
export function formatAverage(average, count) {
40-
if (!count || average == null) return null;
41-
return Math.round(average * 10) / 10;
42-
}
43-
44-
/** Aggregate (count + average) for a single drink in a bucket. */
45-
async function readAggregate(db, bucket, festivalId, drinkId, deviceId) {
46-
const agg = await db
47-
.prepare(
48-
"SELECT COUNT(*) AS count, AVG(rating) AS average " +
49-
"FROM ratings WHERE bucket = ? AND festival_id = ? AND drink_id = ?",
50-
)
51-
.bind(bucket, festivalId, drinkId)
52-
.first();
53-
54-
let yourRating = null;
55-
if (deviceId) {
56-
const own = await db
57-
.prepare(
58-
"SELECT rating FROM ratings " +
59-
"WHERE bucket = ? AND festival_id = ? AND drink_id = ? AND device_id = ?",
60-
)
61-
.bind(bucket, festivalId, drinkId, deviceId)
62-
.first();
63-
yourRating = own ? own.rating : null;
64-
}
65-
66-
const count = agg ? agg.count : 0;
67-
return {
68-
festivalId,
69-
drinkId,
70-
count,
71-
average: formatAverage(agg ? agg.average : null, count),
72-
yourRating,
73-
};
15+
function round1(value) {
16+
return Math.round(value * 10) / 10;
7417
}
7518

76-
async function handlePost(request, db, bucket, corsHeaders) {
77-
const parsed = await parseJsonBody(request);
78-
if (!parsed.ok) {
79-
return jsonResponse({ error: "Invalid JSON body" }, 400, corsHeaders);
80-
}
81-
82-
const result = validateWritePayload(parsed.body, { requireRating: true });
83-
if (!result.ok) {
84-
return jsonResponse({ error: result.error }, 400, corsHeaders);
85-
}
86-
87-
const { festivalId, drinkId, deviceId, rating } = result.value;
88-
await db
89-
.prepare(
90-
"INSERT INTO ratings (bucket, festival_id, drink_id, device_id, rating, updated_at) " +
91-
"VALUES (?, ?, ?, ?, ?, ?) " +
92-
"ON CONFLICT (bucket, festival_id, drink_id, device_id) " +
93-
"DO UPDATE SET rating = excluded.rating, updated_at = excluded.updated_at",
94-
)
95-
.bind(bucket, festivalId, drinkId, deviceId, rating, Date.now())
96-
.run();
97-
98-
const aggregate = await readAggregate(
99-
db,
100-
bucket,
101-
festivalId,
102-
drinkId,
103-
deviceId,
104-
);
105-
return jsonResponse(aggregate, 200, corsHeaders);
106-
}
107-
108-
async function handleDelete(request, db, bucket, corsHeaders) {
109-
const parsed = await parseJsonBody(request);
110-
if (!parsed.ok) {
111-
return jsonResponse({ error: "Invalid JSON body" }, 400, corsHeaders);
112-
}
113-
114-
const result = validateWritePayload(parsed.body, { requireRating: false });
115-
if (!result.ok) {
116-
return jsonResponse({ error: result.error }, 400, corsHeaders);
117-
}
118-
119-
const { festivalId, drinkId, deviceId } = result.value;
120-
await db
121-
.prepare(
122-
"DELETE FROM ratings " +
123-
"WHERE bucket = ? AND festival_id = ? AND drink_id = ? AND device_id = ?",
124-
)
125-
.bind(bucket, festivalId, drinkId, deviceId)
126-
.run();
127-
128-
const aggregate = await readAggregate(
129-
db,
130-
bucket,
131-
festivalId,
132-
drinkId,
133-
deviceId,
134-
);
135-
return jsonResponse(aggregate, 200, corsHeaders);
136-
}
137-
138-
async function handleGetSingle(
139-
db,
140-
bucket,
141-
festivalId,
142-
drinkId,
143-
deviceId,
144-
corsHeaders,
145-
) {
146-
const aggregate = await readAggregate(
147-
db,
148-
bucket,
149-
festivalId,
150-
drinkId,
151-
deviceId,
152-
);
153-
return jsonResponse(aggregate, 200, corsHeaders);
154-
}
155-
156-
/** Batch: every rated drink for a festival, keyed by drink id. */
157-
async function handleGetFestival(
158-
db,
159-
bucket,
160-
festivalId,
161-
deviceId,
162-
corsHeaders,
163-
) {
164-
const { results } = await db
165-
.prepare(
166-
"SELECT drink_id, COUNT(*) AS count, AVG(rating) AS average " +
167-
"FROM ratings WHERE bucket = ? AND festival_id = ? GROUP BY drink_id",
168-
)
169-
.bind(bucket, festivalId)
170-
.all();
171-
172-
const own = new Map();
173-
if (deviceId) {
174-
const ownRows = await db
175-
.prepare(
176-
"SELECT drink_id, rating FROM ratings " +
177-
"WHERE bucket = ? AND festival_id = ? AND device_id = ?",
178-
)
179-
.bind(bucket, festivalId, deviceId)
180-
.all();
181-
for (const row of ownRows.results) {
182-
own.set(row.drink_id, row.rating);
19+
export const RATINGS_FAMILY = {
20+
table: "ratings",
21+
valueColumn: "rating",
22+
writeCollection: "ratings",
23+
summaryCollection: "ratingSummaries",
24+
25+
/** Validate the Rating body { value: 1..5 }. */
26+
parseValue(body) {
27+
if (body === null || typeof body !== "object") {
28+
return {
29+
ok: false,
30+
reason: "INVALID_BODY",
31+
message: "Body must be a JSON object",
32+
};
18333
}
184-
}
185-
186-
const aggregates = {};
187-
for (const row of results) {
188-
aggregates[row.drink_id] = {
189-
count: row.count,
190-
average: formatAverage(row.average, row.count),
191-
yourRating: own.has(row.drink_id) ? own.get(row.drink_id) : null,
34+
const { value } = body;
35+
if (!Number.isInteger(value) || value < 1 || value > 5) {
36+
return {
37+
ok: false,
38+
reason: "RATING_VALUE_OUT_OF_RANGE",
39+
message: "value must be an integer between 1 and 5",
40+
};
41+
}
42+
return { ok: true, columnValue: value };
43+
},
44+
45+
/** Serialize a Rating resource from a DB row { value, updated_at }. */
46+
serializeResource(name, row) {
47+
return { name, value: row.value, updateTime: rfc3339(row.updated_at) };
48+
},
49+
50+
// Aggregate columns selected for summary single + list queries.
51+
summaryColumns: "COUNT(*) AS agg_count, AVG(rating) AS agg_average",
52+
53+
/** Build RatingSummary fields from an aggregate row. */
54+
summaryFields(row) {
55+
const count = row.agg_count || 0;
56+
return {
57+
ratingCount: count,
58+
averageRating: count ? round1(row.agg_average) : 0,
19259
};
193-
}
194-
195-
return jsonResponse({ festivalId, aggregates }, 200, corsHeaders);
196-
}
60+
},
61+
};
19762

198-
/** Route and handle a /v1/ratings request, or null if not a ratings path. */
63+
/** Route a ratings request, or null if the path is not a ratings path. */
19964
export function handleRatings(request, url, env, corsHeaders) {
200-
return routeResource(request, url, env, corsHeaders, {
201-
basePath: "/v1/ratings",
202-
db: "RATINGS_DB",
203-
post: handlePost,
204-
del: handleDelete,
205-
getSingle: handleGetSingle,
206-
getFestival: handleGetFestival,
207-
});
65+
return handleResourceFamily(request, url, env, corsHeaders, RATINGS_FAMILY);
20866
}

0 commit comments

Comments
 (0)