Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions mise.dev.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions proto/README.md
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +1 to +7

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.
10 changes: 10 additions & 0 deletions proto/buf.gen.yaml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions proto/buf.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Generated by buf. DO NOT EDIT.
version: v2
deps:
- name: buf.build/googleapis/googleapis
commit: c17df5b2beca46928cc87d5656bd5343
digest: b5:648a01e0170d4512dea7d564016165decd1ed6e34bef79fe54753e51ad7e27545709ad9157d7551270147d551155c595a2fb0bf5bb33b1c83040ddbce915c604
17 changes: 17 additions & 0 deletions proto/buf.yaml
Original file line number Diff line number Diff line change
@@ -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
# "<Method>Response" / unique-response defaults. Google's own APIs do the same.
- RPC_RESPONSE_STANDARD_NAME
- RPC_REQUEST_RESPONSE_UNIQUE
breaking:
use:
- FILE
215 changes: 215 additions & 0 deletions proto/cambeerfestival/myfestival/v1/my_festival_service.proto
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions proto/cambeerfestival/myfestival/v1/rating.proto
Original file line number Diff line number Diff line change
@@ -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];
}
Loading