Skip to content

Commit 85ad72a

Browse files
committed
feat(api): add Producer resource and slim Drink's producer embed
The data feed is producer-keyed, so producer metadata is naturally normalised (once per producer). The previous Drink.producer embed re-duplicated a brewery's location/founding-year/notes across every one of its drinks on the wire. Model Producer as a first-class resource (festivals/{f}/producers/{p}) with Get/List, and replace Drink's full embed with a ProducerReference (producer resource name + display_name denormalised only for cheap card/search rendering). This keeps brewery detail normalised, matches the feed's shape, and enables brewery-directory browsing. buf lint, AIP api-linter, and OpenAPI generation all pass.
1 parent 5553fce commit 85ad72a

5 files changed

Lines changed: 135 additions & 27 deletions

File tree

mise.dev.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ api-linter \
8787
--config .api-linter.yaml \
8888
--descriptor-set-in=/tmp/cambeerfestival.pb \
8989
cambeerfestival/festival/v1alpha/festival.proto \
90+
cambeerfestival/festival/v1alpha/producer.proto \
9091
cambeerfestival/festival/v1alpha/drink.proto \
9192
cambeerfestival/festival/v1alpha/catalog_service.proto \
9293
cambeerfestival/festival/v1alpha/drink_entry.proto \

proto/README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ proto/
5656
├── .api-linter.yaml # AIP linter suppressions (see comments in file)
5757
└── cambeerfestival/festival/v1alpha/
5858
├── festival.proto # Festival — festival metadata (canonical)
59-
├── drink.proto # Drink — catalogue drink (canonical) + Producer
59+
├── producer.proto # Producer — brewery/cidery (canonical)
60+
├── drink.proto # Drink (canonical) + ProducerReference
6061
├── catalog_service.proto # CatalogService — read-only Get/List
6162
├── drink_entry.proto # DrinkEntry — caller personal state per drink
6263
├── drink_summary.proto # DrinkSummary — public aggregates per drink
@@ -65,15 +66,23 @@ proto/
6566

6667
## Catalogue resource model (AIP-121/122)
6768

68-
`CatalogService` defines the shared, read-only catalogue. All four RPCs are
69-
reads — catalogue data is published out-of-band via the festival data feeds, so
70-
there are no create/update/delete methods and every data field is `OUTPUT_ONLY`.
69+
`CatalogService` defines the shared, read-only catalogue. Every RPC is a read —
70+
catalogue data is published out-of-band via the festival data feeds, so there
71+
are no create/update/delete methods and every data field is `OUTPUT_ONLY`.
7172

7273
| Resource | Name pattern | Methods |
7374
| --- | --- | --- |
7475
| `Festival` | `festivals/{f}` | Get, List |
76+
| `Producer` | `festivals/{f}/producers/{p}` | Get, List |
7577
| `Drink` | `festivals/{f}/drinks/{d}` | Get, List (filter, order_by, paginated) |
7678

79+
`Producer` is a first-class resource so producer metadata stays normalised — the
80+
feed is producer-keyed, so a brewery's location/founding-year/notes live once on
81+
the `Producer`, and each `Drink` carries a `ProducerReference` (the producer
82+
resource name plus its `display_name`, denormalised only for cheap card/search
83+
rendering) rather than embedding the full record. Hydrate the producer directory
84+
with `ListProducers`, or fetch one with `GetProducer`.
85+
7786
`Festival` and `Drink` are defined **canonically here**. `DrinkEntry`'s pattern
7887
references them as parent types; because everything shares one package those
7988
references resolve directly, so no `resource_definition` stubs are needed (a type

proto/cambeerfestival/festival/v1alpha/catalog_service.proto

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package cambeerfestival.festival.v1alpha;
55

66
import "cambeerfestival/festival/v1alpha/drink.proto";
77
import "cambeerfestival/festival/v1alpha/festival.proto";
8+
import "cambeerfestival/festival/v1alpha/producer.proto";
89
import "google/api/annotations.proto";
910
import "google/api/client.proto";
1011
import "google/api/field_behavior.proto";
@@ -63,6 +64,24 @@ service CatalogService {
6364
option (google.api.http) = {get: "/v1alpha/{name=festivals/*/drinks/*}"};
6465
option (google.api.method_signature) = "name";
6566
}
67+
68+
// --- Producers ------------------------------------------------------------
69+
// List the producers (breweries, cideries, etc.) at a festival.
70+
//
71+
// One call hydrates the festival's producer directory, e.g. to resolve the
72+
// ProducerReference on each drink or to power A–Z brewery browsing. The
73+
// server default returns all producers in a single page; set page_size to
74+
// paginate.
75+
rpc ListProducers(ListProducersRequest) returns (ListProducersResponse) {
76+
option (google.api.http) = {get: "/v1alpha/{parent=festivals/*}/producers"};
77+
option (google.api.method_signature) = "parent";
78+
}
79+
80+
// Get a single producer.
81+
rpc GetProducer(GetProducerRequest) returns (Producer) {
82+
option (google.api.http) = {get: "/v1alpha/{name=festivals/*/producers/*}"};
83+
option (google.api.method_signature) = "name";
84+
}
6685
}
6786

6887
// Request message for ListFestivals.
@@ -112,7 +131,8 @@ message ListDrinksRequest {
112131
string page_token = 3 [(google.api.field_behavior) = OPTIONAL];
113132

114133
// AIP-160 filter expression. Supported fields: category, style, dispense,
115-
// abv, vegan, producer.producer_id. Example: category = "beer" AND abv < 5.0.
134+
// abv, vegan, producer.producer (the producer resource name). Example:
135+
// category = "beer" AND abv < 5.0.
116136
string filter = 4 [(google.api.field_behavior) = OPTIONAL];
117137

118138
// AIP-132 ordering, e.g. "display_name" or "abv desc". Supported fields:
@@ -140,3 +160,40 @@ message GetDrinkRequest {
140160
(google.api.resource_reference).type = "api.cambeerfestival.app/Drink"
141161
];
142162
}
163+
164+
// Request message for ListProducers.
165+
message ListProducersRequest {
166+
// Parent festival: festivals/{festival}.
167+
string parent = 1 [
168+
(google.api.field_behavior) = REQUIRED,
169+
(google.api.resource_reference).child_type = "api.cambeerfestival.app/Producer"
170+
];
171+
172+
// Maximum number of producers to return. The server default returns all
173+
// producers for the festival in a single page. Set explicitly to paginate.
174+
int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL];
175+
176+
// Page token from a previous ListProducers response.
177+
string page_token = 3 [(google.api.field_behavior) = OPTIONAL];
178+
}
179+
180+
// Response message for ListProducers.
181+
message ListProducersResponse {
182+
// The producers for this page.
183+
repeated Producer producers = 1;
184+
185+
// Token for the next page; empty when there are no more results.
186+
string next_page_token = 2;
187+
188+
// Total number of producers at this festival.
189+
int32 total_size = 3;
190+
}
191+
192+
// Request message for GetProducer.
193+
message GetProducerRequest {
194+
// Resource name: festivals/{festival}/producers/{producer}.
195+
string name = 1 [
196+
(google.api.field_behavior) = REQUIRED,
197+
(google.api.resource_reference).type = "api.cambeerfestival.app/Producer"
198+
];
199+
}

proto/cambeerfestival/festival/v1alpha/drink.proto

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import "google/api/field_behavior.proto";
77
import "google/api/resource.proto";
88

99
// A single beverage available at a festival, flattened from the underlying
10-
// data feed's producer/product structure into the composite the app consumes
11-
// (product details plus the producing brewery/cidery).
10+
// data feed's producer/product structure. Product details live here; the
11+
// producing brewery/cidery is referenced by resource name (see
12+
// ProducerReference) rather than embedded, so producer metadata is not
13+
// duplicated across every one of its drinks.
1214
//
1315
// This is the canonical definition of the `Drink` resource that the "my
1416
// festival" API's DrinkEntry (festivals/{f}/drinks/{d}/entry) and DrinkSummary
@@ -67,29 +69,26 @@ message Drink {
6769
// Whether the drink is vegan. Absent when the producer did not declare it.
6870
optional bool vegan = 12 [(google.api.field_behavior) = OUTPUT_ONLY];
6971

70-
// The brewery/cidery/meadery that makes this drink.
71-
Producer producer = 13 [(google.api.field_behavior) = OUTPUT_ONLY];
72+
// The brewery/cidery/meadery that makes this drink, as a reference to the
73+
// Producer resource plus its display name for cheap list rendering.
74+
ProducerReference producer = 13 [(google.api.field_behavior) = OUTPUT_ONLY];
7275
}
7376

74-
// The producer of a drink (brewery, cidery, meadery, etc.). Embedded in Drink
75-
// rather than modelled as its own resource for now; it may be promoted to a
76-
// top-level catalogue resource (festivals/{festival}/producers/{producer})
77-
// if brewery-scoped browsing needs stable resource names.
78-
message Producer {
79-
// The producer's id within the festival data feed.
80-
string producer_id = 1 [(google.api.field_behavior) = OUTPUT_ONLY];
81-
82-
// Producer name, e.g. "Milton Brewery".
77+
// A lightweight pointer from a Drink to its Producer: the producer's resource
78+
// name plus its display name denormalised for cheap list rendering (drink
79+
// cards and search). The authoritative producer record — location, founding
80+
// year, notes — is the Producer resource; fetch it with GetProducer or hydrate
81+
// the festival's producers in one call via ListProducers.
82+
message ProducerReference {
83+
// Resource name of the producer: festivals/{festival}/producers/{producer}.
84+
string producer = 1 [
85+
(google.api.field_behavior) = OUTPUT_ONLY,
86+
(google.api.resource_reference).type = "api.cambeerfestival.app/Producer"
87+
];
88+
89+
// Producer name, e.g. "Milton Brewery". Denormalised from the Producer
90+
// resource so drink cards and search need no second lookup.
8391
string display_name = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
84-
85-
// Where the producer is based, e.g. "Cambridge". Empty when unknown.
86-
string location = 3 [(google.api.field_behavior) = OUTPUT_ONLY];
87-
88-
// Year the producer was founded. Absent when unknown.
89-
optional int32 founding_year = 4 [(google.api.field_behavior) = OUTPUT_ONLY];
90-
91-
// Free-text notes about the producer. Empty when unset.
92-
string notes = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
9392
}
9493

9594
// Availability state for a drink, ordered from most to least available.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Producer catalogue resource for the read-only "festival" API.
2+
syntax = "proto3";
3+
4+
package cambeerfestival.festival.v1alpha;
5+
6+
import "google/api/field_behavior.proto";
7+
import "google/api/resource.proto";
8+
9+
// A producer (brewery, cidery, meadery, etc.) at a festival.
10+
//
11+
// The underlying data feed is producer-keyed — each producer carries its
12+
// metadata once, with its drinks nested underneath. Modelling Producer as a
13+
// first-class resource keeps that normalisation: a producer's name, location,
14+
// founding year and notes live here exactly once, and each Drink references its
15+
// producer by resource name instead of embedding a copy. The catalogue is
16+
// read-only: every field except the resource name is server-populated
17+
// (OUTPUT_ONLY).
18+
//
19+
// Note: producer ids are NOT guaranteed stable across festival years.
20+
message Producer {
21+
option (google.api.resource) = {
22+
type: "api.cambeerfestival.app/Producer"
23+
pattern: "festivals/{festival}/producers/{producer}"
24+
singular: "producer"
25+
plural: "producers"
26+
};
27+
28+
// Resource name: festivals/{festival}/producers/{producer}.
29+
string name = 1 [(google.api.field_behavior) = IDENTIFIER];
30+
31+
// Producer name, e.g. "Milton Brewery".
32+
string display_name = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
33+
34+
// Where the producer is based, e.g. "Cambridge". Empty when unknown.
35+
string location = 3 [(google.api.field_behavior) = OUTPUT_ONLY];
36+
37+
// Year the producer was founded. Absent when unknown.
38+
optional int32 founding_year = 4 [(google.api.field_behavior) = OUTPUT_ONLY];
39+
40+
// Free-text notes about the producer. Empty when unset.
41+
string notes = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
42+
}

0 commit comments

Comments
 (0)