diff --git a/.gitignore b/.gitignore index ca2fdc83..2f5c1f5e 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ flutter_*.png *.freezed.dart *.mocks.dart +# Generated client packages (regenerated from OpenAPI via proto:clients tasks) +packages/ + # Android related **/android/**/gradle-wrapper.jar .gradle/ diff --git a/cloudflare-worker/.gitignore b/cloudflare-worker/.gitignore index 1c46170d..54b8e72e 100644 --- a/cloudflare-worker/.gitignore +++ b/cloudflare-worker/.gitignore @@ -1,6 +1,9 @@ # Generated during build - source is at data/festivals.json festivals.json +# Generated from proto via proto:clients:types — regenerate with: MISE_ENV=dev ./bin/mise run proto:clients:types +src/ + # Node.js node_modules/ diff --git a/cloudflare-worker/package-lock.json b/cloudflare-worker/package-lock.json index f1ac5a3e..bb0998a7 100644 --- a/cloudflare-worker/package-lock.json +++ b/cloudflare-worker/package-lock.json @@ -9,10 +9,36 @@ "version": "1.0.0", "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.16.13", + "openapi-typescript": "^7.13.0", "vitest": "^4.1.8", "wrangler": "^4.88.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -1208,6 +1234,52 @@ "dev": true, "license": "MIT" }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.15", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", + "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -1648,6 +1720,33 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1658,6 +1757,13 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1665,6 +1771,16 @@ "dev": true, "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1675,6 +1791,13 @@ "node": ">=18" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/cjs-module-lexer": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", @@ -1682,6 +1805,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1703,6 +1833,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1792,6 +1940,13 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1825,6 +1980,70 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -2127,6 +2346,26 @@ "node": ">=22.0.0" } }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -2157,6 +2396,45 @@ ], "license": "MIT" }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -2191,6 +2469,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2220,6 +2508,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -2408,6 +2706,34 @@ "license": "0BSD", "optional": true }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici": { "version": "7.24.8", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", @@ -2428,6 +2754,13 @@ "pathe": "^2.0.3" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", @@ -2691,6 +3024,23 @@ } } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", diff --git a/cloudflare-worker/package.json b/cloudflare-worker/package.json index e7a56aa9..e936b55f 100644 --- a/cloudflare-worker/package.json +++ b/cloudflare-worker/package.json @@ -11,6 +11,7 @@ }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.16.13", + "openapi-typescript": "^7.13.0", "vitest": "^4.1.8", "wrangler": "^4.88.0" } diff --git a/docs/code/api/.gitignore b/docs/code/api/.gitignore new file mode 100644 index 00000000..db81ad51 --- /dev/null +++ b/docs/code/api/.gitignore @@ -0,0 +1,2 @@ +# Generated from proto via proto:generate — regenerate with: MISE_ENV=dev ./bin/mise run proto:generate +openapi/ diff --git a/mise.dev.lock b/mise.dev.lock index 653475ba..3b4ea72a 100644 --- a/mise.dev.lock +++ b/mise.dev.lock @@ -1,5 +1,39 @@ # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html +[[tools."github:googleapis/api-linter"]] +version = "2.3.1" +backend = "github:googleapis/api-linter" + +[tools."github:googleapis/api-linter"."platforms.linux-x64"] +checksum = "sha256:c81a07f4d37a61081071f9a8b33553d4db2f9bb058a77db760d1aaf525bbf0eb" +url = "https://github.com/googleapis/api-linter/releases/download/v2.3.1/api-linter-2.3.1-linux-amd64.tar.gz" +url_api = "https://api.github.com/repos/googleapis/api-linter/releases/assets/375893821" +github_attestations = "unavailable" + +[tools."github:googleapis/api-linter"."platforms.linux-x64-musl"] +checksum = "sha256:c81a07f4d37a61081071f9a8b33553d4db2f9bb058a77db760d1aaf525bbf0eb" +url = "https://github.com/googleapis/api-linter/releases/download/v2.3.1/api-linter-2.3.1-linux-amd64.tar.gz" +url_api = "https://api.github.com/repos/googleapis/api-linter/releases/assets/375893821" +github_attestations = "unavailable" + +[tools."github:googleapis/api-linter"."platforms.macos-arm64"] +checksum = "sha256:09b7a81c3cc8c07e0b6d22a5975c245571a42eb6345787722ea992147ae20c59" +url = "https://github.com/googleapis/api-linter/releases/download/v2.3.1/api-linter-2.3.1-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/googleapis/api-linter/releases/assets/375893868" +github_attestations = "unavailable" + +[tools."github:googleapis/api-linter"."platforms.macos-x64"] +checksum = "sha256:569019fce994f4b2a1689271c6e932857089222a63105f2ee877fc33851d9dc8" +url = "https://github.com/googleapis/api-linter/releases/download/v2.3.1/api-linter-2.3.1-darwin-amd64.tar.gz" +url_api = "https://api.github.com/repos/googleapis/api-linter/releases/assets/375893843" +github_attestations = "unavailable" + +[tools."github:googleapis/api-linter"."platforms.windows-x64"] +checksum = "sha256:da8e2154f96d9ec60c86fdb61b12e43e593a5ba17cbaab19a186f2f066bc796a" +url = "https://github.com/googleapis/api-linter/releases/download/v2.3.1/api-linter-2.3.1-windows-amd64.tar.gz" +url_api = "https://api.github.com/repos/googleapis/api-linter/releases/assets/375893870" +github_attestations = "unavailable" + [[tools.watchexec]] version = "2.5.1" backend = "aqua:watchexec/watchexec" diff --git a/mise.dev.toml b/mise.dev.toml index 77d48bce..4e94ea14 100644 --- a/mise.dev.toml +++ b/mise.dev.toml @@ -16,6 +16,82 @@ [tools] watchexec = "2.5.1" +buf = "latest" + +# --- Protobuf / OpenAPI (API contract is proto-first; see proto/README.md) --- +# buf and api-linter live here (dev-only) while the API design is still in flux. +# Move both to base mise.toml when the API stabilises and proto tasks enter CI. +# Move it to base mise.toml once the resource shapes and method signatures +# have stabilised and the linter output is expected to stay clean in CI. +"github:googleapis/api-linter" = "latest" + +[tasks."proto:lint"] +description = "Lint the protobuf API contract (buf STANDARD ruleset)" +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" + +[tasks."proto:clients"] +description = "Generate Worker TS types and Flutter Dart client from OpenAPI spec" +depends = ["proto:clients:types", "proto:clients:dart"] + +[tasks."proto:clients:types"] +description = "Generate TypeScript types for the Cloudflare Worker from OpenAPI" +run = """ +npx --prefix cloudflare-worker openapi-typescript \ + docs/code/api/openapi/openapi.yaml \ + -o cloudflare-worker/src/api-types.ts +""" + +[tasks."proto:clients:dart"] +description = "Generate Dart/Dio client for Flutter from OpenAPI (requires Java)" +env = { OPENAPI_GENERATOR_JAR = "${HOME}/.cache/openapi-generator/openapi-generator-cli-7.13.0.jar" } +run = """ +mkdir -p "${HOME}/.cache/openapi-generator" +if [ ! -f "${OPENAPI_GENERATOR_JAR}" ]; then + echo "Downloading openapi-generator-cli 7.13.0..." + curl -sSfL \ + "https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.13.0/openapi-generator-cli-7.13.0.jar" \ + -o "${OPENAPI_GENERATOR_JAR}" +fi +java -jar "${OPENAPI_GENERATOR_JAR}" generate \ + --input-spec docs/code/api/openapi/openapi.yaml \ + --generator-name dart-dio \ + --output packages/myfestival_client \ + --additional-properties=pubName=myfestival_client,pubAuthor=Cambridge Beer Festival,browserClient=false,nullSafe=true,dateLibrary=core +cd packages/myfestival_client +dart pub get +dart run build_runner build +""" + +[tasks."proto:api-lint"] +description = "Lint proto files against Google AIP design guidelines (googleapis/api-linter)" +dir = "proto" +run = """ +buf build -o /tmp/cambeerfestival.pb +api-linter \ + --config .api-linter.yaml \ + --descriptor-set-in=/tmp/cambeerfestival.pb \ + cambeerfestival/myfestival/v1alpha/my_festival_service.proto \ + cambeerfestival/myfestival/v1alpha/bookmark.proto \ + cambeerfestival/myfestival/v1alpha/note.proto \ + cambeerfestival/myfestival/v1alpha/review.proto \ + cambeerfestival/myfestival/v1alpha/tasting.proto +""" # All tasks moved to mise-tasks/ for better maintainability and shellcheck/shfmt support: # - dev -> mise-tasks/dev.sh diff --git a/mise.toml b/mise.toml index b721b62b..02d3fb04 100644 --- a/mise.toml +++ b/mise.toml @@ -16,7 +16,6 @@ experimental = true _.path = ["./bin"] [tools] -buf = "latest" flutter = "3.44.0" node = "22" # For http_server and Playwright e2e tests shellcheck = "0.9.0" diff --git a/proto/.api-linter.yaml b/proto/.api-linter.yaml new file mode 100644 index 00000000..bed0c4a0 --- /dev/null +++ b/proto/.api-linter.yaml @@ -0,0 +1,20 @@ +# api-linter (https://linter.aip.dev) configuration. +# +# Suppressed rules — each suppression is intentional: +# +# 0191 java-*: Not building Java clients; Java file options are irrelevant. +# +# 0156 forbidden-methods: Review singleton exposes Delete because a review is +# absent until the caller writes one — it is not an always-present +# singleton. See AIP-156 §absent-singletons. +# +# 0123 resource-pattern-singular: ReviewSummary uses {drink} as the final URL +# segment (not {review_summary}) so the path reads as a natural key: +# .../reviewSummaries/{drinkId}. The drink ID is the lookup key; the +# resource singular would obscure this. +- disabled_rules: + - core::0191::java-package + - core::0191::java-multiple-files + - core::0191::java-outer-classname + - core::0156::forbidden-methods + - core::0123::resource-pattern-singular 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..fb09314c --- /dev/null +++ b/proto/buf.gen.yaml @@ -0,0 +1,16 @@ +version: v2 +clean: true +managed: + enabled: true + override: + # go_package injected at generation time only; not written to .proto files. + - file_option: go_package_prefix + value: github.com/cambeerfestival/api/gen/go +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/v1alpha/bookmark.proto b/proto/cambeerfestival/myfestival/v1alpha/bookmark.proto new file mode 100644 index 00000000..5020e36e --- /dev/null +++ b/proto/cambeerfestival/myfestival/v1alpha/bookmark.proto @@ -0,0 +1,28 @@ +// Caller bookmark ("favourite") signals for the online "my festival" API. +syntax = "proto3"; + +package cambeerfestival.myfestival.v1alpha; + +import "google/api/field_behavior.proto"; +import "google/api/resource.proto"; +import "google/protobuf/timestamp.proto"; + +// A drink the caller has bookmarked at a festival. +// +// Singleton resource — one per (caller, drink). The resource's mere existence +// means the drink is bookmarked; deleting it removes the bookmark. The caller +// is implicit in the auth context. +message Bookmark { + option (google.api.resource) = { + type: "api.cambeerfestival.app/Bookmark" + pattern: "festivals/{festival}/drinks/{drink}/bookmark" + singular: "bookmark" + plural: "bookmarks" + }; + + // Resource name: festivals/{festival}/drinks/{drink}/bookmark. + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // When the bookmark was created. + google.protobuf.Timestamp create_time = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; +} diff --git a/proto/cambeerfestival/myfestival/v1alpha/my_festival_service.proto b/proto/cambeerfestival/myfestival/v1alpha/my_festival_service.proto new file mode 100644 index 00000000..8b6a6e42 --- /dev/null +++ b/proto/cambeerfestival/myfestival/v1alpha/my_festival_service.proto @@ -0,0 +1,478 @@ +// Online "my festival" API: personal bookmarks, notes, tastings, reviews, and shared aggregates. +syntax = "proto3"; + +package cambeerfestival.myfestival.v1alpha; + +import "cambeerfestival/myfestival/v1alpha/bookmark.proto"; +import "cambeerfestival/myfestival/v1alpha/note.proto"; +import "cambeerfestival/myfestival/v1alpha/review.proto"; +import "cambeerfestival/myfestival/v1alpha/tasting.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 caller's personal festival state (bookmarks, notes, tastings, +// reviews) and serves back bucket-scoped aggregates. Writes are local-first on +// the client; this service holds the shared, cross-device state. +// +// All personal resources are singleton resources — one per (caller, drink). +// The caller's identity is resolved from the auth context; it never appears in +// resource names, keeping device IDs private and making the sign-in upgrade +// transparent to existing clients. +service MyFestivalService { + option (google.api.default_host) = "api.cambeerfestival.app"; + + // --- Bookmarks (caller-scoped singletons) --------------------------------- + // Get the caller's bookmark for a drink. + rpc GetBookmark(GetBookmarkRequest) returns (Bookmark) { + option (google.api.http) = {get: "/v1alpha/{name=festivals/*/drinks/*/bookmark}"}; + option (google.api.method_signature) = "name"; + } + + // Create or update the caller's bookmark for a drink (upsert). + rpc UpdateBookmark(UpdateBookmarkRequest) returns (Bookmark) { + option (google.api.http) = { + patch: "/v1alpha/{bookmark.name=festivals/*/drinks/*/bookmark}" + body: "bookmark" + }; + option (google.api.method_signature) = "bookmark,update_mask"; + } + + // Remove the caller's bookmark for a drink. + rpc DeleteBookmark(DeleteBookmarkRequest) returns (google.protobuf.Empty) { + option (google.api.http) = {delete: "/v1alpha/{name=festivals/*/drinks/*/bookmark}"}; + option (google.api.method_signature) = "name"; + } + + // List all drinks the caller has bookmarked at a festival. + // + // Intended for pre-loading "my festival" state on app open. + rpc ListBookmarks(ListBookmarksRequest) returns (ListBookmarksResponse) { + option (google.api.http) = {get: "/v1alpha/{parent=festivals/*}/bookmarks"}; + option (google.api.method_signature) = "parent"; + } + + // --- Tasting notes (caller-scoped singletons) ----------------------------- + // Get the caller's tasting note for a drink. + rpc GetNote(GetNoteRequest) returns (Note) { + option (google.api.http) = {get: "/v1alpha/{name=festivals/*/drinks/*/note}"}; + option (google.api.method_signature) = "name"; + } + + // Create or update the caller's tasting note for a drink (upsert). + rpc UpdateNote(UpdateNoteRequest) returns (Note) { + option (google.api.http) = { + patch: "/v1alpha/{note.name=festivals/*/drinks/*/note}" + body: "note" + }; + option (google.api.method_signature) = "note,update_mask"; + } + + // Remove the caller's tasting note for a drink. + rpc DeleteNote(DeleteNoteRequest) returns (google.protobuf.Empty) { + option (google.api.http) = {delete: "/v1alpha/{name=festivals/*/drinks/*/note}"}; + option (google.api.method_signature) = "name"; + } + + // List all tasting notes the caller has written at a festival. + rpc ListNotes(ListNotesRequest) returns (ListNotesResponse) { + option (google.api.http) = {get: "/v1alpha/{parent=festivals/*}/notes"}; + option (google.api.method_signature) = "parent"; + } + + // --- Tasting log (caller-scoped singletons) ------------------------------- + // Get the caller's tasting record for a drink. + rpc GetTasting(GetTastingRequest) returns (Tasting) { + option (google.api.http) = {get: "/v1alpha/{name=festivals/*/drinks/*/tasting}"}; + option (google.api.method_signature) = "name"; + } + + // Create or update the caller's tasting record for a drink (upsert). + // + // Use `update_mask` with `pours` to increment the pour count without + // affecting other fields. + rpc UpdateTasting(UpdateTastingRequest) returns (Tasting) { + option (google.api.http) = { + patch: "/v1alpha/{tasting.name=festivals/*/drinks/*/tasting}" + body: "tasting" + }; + option (google.api.method_signature) = "tasting,update_mask"; + } + + // Remove the caller's tasting record for a drink. + rpc DeleteTasting(DeleteTastingRequest) returns (google.protobuf.Empty) { + option (google.api.http) = {delete: "/v1alpha/{name=festivals/*/drinks/*/tasting}"}; + option (google.api.method_signature) = "name"; + } + + // List all tasting records the caller has logged at a festival. + rpc ListTastings(ListTastingsRequest) returns (ListTastingsResponse) { + option (google.api.http) = {get: "/v1alpha/{parent=festivals/*}/tastings"}; + option (google.api.method_signature) = "parent"; + } + + // --- Personal reviews (caller-scoped singletons) -------------------------- + // Get the caller's review for a drink. + rpc GetReview(GetReviewRequest) returns (Review) { + option (google.api.http) = {get: "/v1alpha/{name=festivals/*/drinks/*/review}"}; + option (google.api.method_signature) = "name"; + } + + // Create or update the caller's review for a drink (upsert). + // + // Use `update_mask` to update a single signal (e.g. only `star_rating`) + // without clearing the other. + rpc UpdateReview(UpdateReviewRequest) returns (Review) { + option (google.api.http) = { + patch: "/v1alpha/{review.name=festivals/*/drinks/*/review}" + body: "review" + }; + option (google.api.method_signature) = "review,update_mask"; + } + + // Remove the caller's review for a drink. + rpc DeleteReview(DeleteReviewRequest) returns (google.protobuf.Empty) { + option (google.api.http) = {delete: "/v1alpha/{name=festivals/*/drinks/*/review}"}; + option (google.api.method_signature) = "name"; + } + + // List all reviews the caller has left for drinks at a festival. + // + // Only the caller's own reviews are returned; caller identity is implicit in + // the auth context. Intended for pre-loading "my festival" state on app open. + rpc ListReviews(ListReviewsRequest) returns (ListReviewsResponse) { + option (google.api.http) = {get: "/v1alpha/{parent=festivals/*}/reviews"}; + option (google.api.method_signature) = "parent"; + } + + // --- Aggregates (public, not caller-scoped) -------------------------------- + // Get the aggregate review signals for a single drink. + rpc GetReviewSummary(GetReviewSummaryRequest) returns (ReviewSummary) { + option (google.api.http) = {get: "/v1alpha/{name=festivals/*/reviewSummaries/*}"}; + option (google.api.method_signature) = "name"; + } + + // List aggregate review signals for every reviewed drink at a festival. + rpc ListReviewSummaries(ListReviewSummariesRequest) returns (ListReviewSummariesResponse) { + option (google.api.http) = {get: "/v1alpha/{parent=festivals/*}/reviewSummaries"}; + option (google.api.method_signature) = "parent"; + } + + // Get tasting counts for a single drink. + rpc GetTastingSummary(GetTastingSummaryRequest) returns (TastingSummary) { + option (google.api.http) = {get: "/v1alpha/{name=festivals/*/tastingSummaries/*}"}; + option (google.api.method_signature) = "name"; + } + + // List tasting counts for every tried drink at a festival. + rpc ListTastingSummaries(ListTastingSummariesRequest) returns (ListTastingSummariesResponse) { + option (google.api.http) = {get: "/v1alpha/{parent=festivals/*}/tastingSummaries"}; + option (google.api.method_signature) = "parent"; + } +} + +// Request message for GetBookmark. +message GetBookmarkRequest { + // Resource name: festivals/{festival}/drinks/{drink}/bookmark. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "api.cambeerfestival.app/Bookmark" + ]; +} + +// Request message for UpdateBookmark. +message UpdateBookmarkRequest { + // The bookmark to write. Its `name` field identifies the resource. + Bookmark bookmark = 1 [(google.api.field_behavior) = REQUIRED]; + + // Fields to update. Omit to replace all writable fields. + google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = OPTIONAL]; +} + +// Request message for DeleteBookmark. +message DeleteBookmarkRequest { + // Resource name: festivals/{festival}/drinks/{drink}/bookmark. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "api.cambeerfestival.app/Bookmark" + ]; +} + +// Request message for ListBookmarks. +message ListBookmarksRequest { + // Parent festival: festivals/{festival}. + string parent = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).child_type = "api.cambeerfestival.app/Bookmark" + ]; + + // Maximum number of bookmarks to return. The server default returns all of + // the caller's bookmarks for the festival in a single page (festival drink + // counts are bounded). Set explicitly to paginate. + int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL]; + + // Page token from a previous ListBookmarks response. + string page_token = 3 [(google.api.field_behavior) = OPTIONAL]; +} + +// Response message for ListBookmarks. +message ListBookmarksResponse { + // The caller's bookmarks for this page, one per bookmarked drink. + repeated Bookmark bookmarks = 1; + + // Token for the next page; empty when there are no more results. + string next_page_token = 2; + + // Total number of drinks the caller has bookmarked at this festival. + int32 total_size = 3; +} + +// Request message for GetNote. +message GetNoteRequest { + // Resource name: festivals/{festival}/drinks/{drink}/note. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "api.cambeerfestival.app/Note" + ]; +} + +// Request message for UpdateNote. +message UpdateNoteRequest { + // The note to write. Its `name` field identifies the resource. + Note note = 1 [(google.api.field_behavior) = REQUIRED]; + + // Fields to update. Omit to replace all writable fields. + google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = OPTIONAL]; +} + +// Request message for DeleteNote. +message DeleteNoteRequest { + // Resource name: festivals/{festival}/drinks/{drink}/note. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "api.cambeerfestival.app/Note" + ]; +} + +// Request message for ListNotes. +message ListNotesRequest { + // Parent festival: festivals/{festival}. + string parent = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).child_type = "api.cambeerfestival.app/Note" + ]; + + // Maximum number of notes to return. The server default returns all of the + // caller's notes for the festival in a single page (festival drink counts + // are bounded). Set explicitly to paginate. + int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL]; + + // Page token from a previous ListNotes response. + string page_token = 3 [(google.api.field_behavior) = OPTIONAL]; +} + +// Response message for ListNotes. +message ListNotesResponse { + // The caller's notes for this page, one per noted drink. + repeated Note notes = 1; + + // Token for the next page; empty when there are no more results. + string next_page_token = 2; + + // Total number of drinks the caller has notes for at this festival. + int32 total_size = 3; +} + +// Request message for GetTasting. +message GetTastingRequest { + // Resource name: festivals/{festival}/drinks/{drink}/tasting. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "api.cambeerfestival.app/Tasting" + ]; +} + +// Request message for UpdateTasting. +message UpdateTastingRequest { + // The tasting record to write. Its `name` field identifies the resource. + Tasting tasting = 1 [(google.api.field_behavior) = REQUIRED]; + + // Fields to update. Omit to replace all writable fields. Specify `pours` + // to update the pour count without affecting other fields. + google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = OPTIONAL]; +} + +// Request message for DeleteTasting. +message DeleteTastingRequest { + // Resource name: festivals/{festival}/drinks/{drink}/tasting. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "api.cambeerfestival.app/Tasting" + ]; +} + +// Request message for ListTastings. +message ListTastingsRequest { + // Parent festival: festivals/{festival}. + string parent = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).child_type = "api.cambeerfestival.app/Tasting" + ]; + + // Maximum number of tastings to return. The server default returns all of + // the caller's tastings for the festival in a single page (festival drink + // counts are bounded). Set explicitly to paginate. + int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL]; + + // Page token from a previous ListTastings response. + string page_token = 3 [(google.api.field_behavior) = OPTIONAL]; +} + +// Response message for ListTastings. +message ListTastingsResponse { + // The caller's tasting records for this page, one per tried drink. + repeated Tasting tastings = 1; + + // Token for the next page; empty when there are no more results. + string next_page_token = 2; + + // Total number of drinks the caller has tried at this festival. + int32 total_size = 3; +} + +// Request message for GetReview. +message GetReviewRequest { + // Resource name: festivals/{festival}/drinks/{drink}/review. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "api.cambeerfestival.app/Review" + ]; +} + +// Request message for UpdateReview. +message UpdateReviewRequest { + // The review to write. Its `name` field identifies the resource. + Review review = 1 [(google.api.field_behavior) = REQUIRED]; + + // Fields to update. Omit to replace all writable fields. Specify + // `star_rating` or `would_recommend` individually to update one signal + // without affecting the other. + google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = OPTIONAL]; +} + +// Request message for DeleteReview. +message DeleteReviewRequest { + // Resource name: festivals/{festival}/drinks/{drink}/review. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "api.cambeerfestival.app/Review" + ]; +} + +// Request message for ListReviews. +message ListReviewsRequest { + // Parent festival: festivals/{festival}. + string parent = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).child_type = "api.cambeerfestival.app/Review" + ]; + + // Maximum number of reviews to return. The server default returns all of the + // caller's reviews for the festival in a single page (festival drink counts + // are bounded). Set explicitly to paginate. + int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL]; + + // Page token from a previous ListReviews response. + string page_token = 3 [(google.api.field_behavior) = OPTIONAL]; +} + +// Response message for ListReviews. +message ListReviewsResponse { + // The caller's reviews for this page, one per reviewed drink. + repeated Review reviews = 1; + + // Token for the next page; empty when there are no more results. + string next_page_token = 2; + + // Total number of drinks the caller has reviewed at this festival. + int32 total_size = 3; +} + +// Request message for GetReviewSummary. +message GetReviewSummaryRequest { + // Resource name: festivals/{festival}/reviewSummaries/{drink}. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "api.cambeerfestival.app/ReviewSummary" + ]; +} + +// Request message for ListReviewSummaries. +message ListReviewSummariesRequest { + // Parent festival: festivals/{festival}. + string parent = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).child_type = "api.cambeerfestival.app/ReviewSummary" + ]; + + // Maximum number of summaries to return. The server default returns all + // summaries for the festival in a single page (drink counts are bounded). + // Set explicitly to paginate. + int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL]; + + // Page token from a previous ListReviewSummaries response. + string page_token = 3 [(google.api.field_behavior) = OPTIONAL]; +} + +// Response message for ListReviewSummaries. +message ListReviewSummariesResponse { + // Aggregate review signals for this page, one per reviewed drink. + repeated ReviewSummary review_summaries = 1; + + // Token for the next page; empty when there are no more results. + string next_page_token = 2; + + // Total number of drinks with at least one review at this festival. + int32 total_size = 3; +} + +// Request message for GetTastingSummary. +message GetTastingSummaryRequest { + // Resource name: festivals/{festival}/tastingSummaries/{drink}. + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).type = "api.cambeerfestival.app/TastingSummary" + ]; +} + +// Request message for ListTastingSummaries. +message ListTastingSummariesRequest { + // Parent festival: festivals/{festival}. + string parent = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference).child_type = "api.cambeerfestival.app/TastingSummary" + ]; + + // Maximum number of summaries to return. The server default returns all + // summaries for the festival in a single page (drink counts are bounded). + // Set explicitly to paginate. + int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL]; + + // Page token from a previous ListTastingSummaries response. + string page_token = 3 [(google.api.field_behavior) = OPTIONAL]; +} + +// Response message for ListTastingSummaries. +message ListTastingSummariesResponse { + // Tasting counts for this page, one per tried drink. + repeated TastingSummary tasting_summaries = 1; + + // Token for the next page; empty when there are no more results. + string next_page_token = 2; + + // Total number of drinks tried by at least one caller at this festival. + int32 total_size = 3; +} diff --git a/proto/cambeerfestival/myfestival/v1alpha/note.proto b/proto/cambeerfestival/myfestival/v1alpha/note.proto new file mode 100644 index 00000000..b7d8f866 --- /dev/null +++ b/proto/cambeerfestival/myfestival/v1alpha/note.proto @@ -0,0 +1,31 @@ +// Caller tasting note for the online "my festival" API. +syntax = "proto3"; + +package cambeerfestival.myfestival.v1alpha; + +import "google/api/field_behavior.proto"; +import "google/api/resource.proto"; +import "google/protobuf/timestamp.proto"; + +// The caller's free-text tasting note for one drink at one festival. +// +// Singleton resource — one per (caller, drink). The caller is implicit in the +// auth context. A note is independent of a Review: you can note without rating, +// or rate without noting. +message Note { + option (google.api.resource) = { + type: "api.cambeerfestival.app/Note" + pattern: "festivals/{festival}/drinks/{drink}/note" + singular: "note" + plural: "notes" + }; + + // Resource name: festivals/{festival}/drinks/{drink}/note. + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // The caller's note text. Max 2000 Unicode characters. + string content = 2 [(google.api.field_behavior) = REQUIRED]; + + // When this note was last written. + google.protobuf.Timestamp update_time = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; +} diff --git a/proto/cambeerfestival/myfestival/v1alpha/review.proto b/proto/cambeerfestival/myfestival/v1alpha/review.proto new file mode 100644 index 00000000..57c1c774 --- /dev/null +++ b/proto/cambeerfestival/myfestival/v1alpha/review.proto @@ -0,0 +1,70 @@ +// Caller review signals for the online "my festival" API. +syntax = "proto3"; + +package cambeerfestival.myfestival.v1alpha; + +import "google/api/field_behavior.proto"; +import "google/api/resource.proto"; +import "google/protobuf/timestamp.proto"; + +// The caller's review of one drink at one festival: a star rating (1-5) and/or +// a "would recommend" answer. +// +// Singleton resource — one per (caller, drink). The caller is implicit in the +// auth context; their identity never appears in the resource name, keeping +// device IDs private and making the sign-in upgrade transparent to clients. +// +// Both signals are optional and independent: a caller can rate without +// answering the recommendation question, or vice versa. +message Review { + option (google.api.resource) = { + type: "api.cambeerfestival.app/Review" + pattern: "festivals/{festival}/drinks/{drink}/review" + singular: "review" + plural: "reviews" + }; + + // Resource name: festivals/{festival}/drinks/{drink}/review. + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // Star rating, 1–5 inclusive. Absent if the caller has not set a star rating. + optional int32 star_rating = 2 [(google.api.field_behavior) = OPTIONAL]; + + // Whether the caller would recommend this drink. Absent if not answered. + optional bool would_recommend = 3 [(google.api.field_behavior) = OPTIONAL]; + + // When this review was last written. + google.protobuf.Timestamp update_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY]; +} + +// Computed, read-only aggregate of all callers' reviews for one drink. +// +// Keyed by drink under the festival so the whole festival can be fetched in +// one paginated call for list/grid views. +message ReviewSummary { + option (google.api.resource) = { + type: "api.cambeerfestival.app/ReviewSummary" + pattern: "festivals/{festival}/reviewSummaries/{drink}" + singular: "reviewSummary" + plural: "reviewSummaries" + }; + + // Resource name: festivals/{festival}/reviewSummaries/{drink}. + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // Number of callers who have submitted a star rating. + int32 rating_count = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // Mean star rating across all callers (1.0–5.0); 0 when rating_count is 0. + double average_rating = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // Number of callers who have answered the "would recommend" question. + int32 response_count = 4 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // Number of callers who answered "yes" to the recommendation question. + int32 recommend_count = 5 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // Fraction of responses (0.0–1.0) that would recommend; 0 when + // response_count is 0. + double recommend_rate = 6 [(google.api.field_behavior) = OUTPUT_ONLY]; +} diff --git a/proto/cambeerfestival/myfestival/v1alpha/tasting.proto b/proto/cambeerfestival/myfestival/v1alpha/tasting.proto new file mode 100644 index 00000000..739b1040 --- /dev/null +++ b/proto/cambeerfestival/myfestival/v1alpha/tasting.proto @@ -0,0 +1,57 @@ +// Caller tasting log and festival-wide tasting counts for the online "my festival" API. +syntax = "proto3"; + +package cambeerfestival.myfestival.v1alpha; + +import "google/api/field_behavior.proto"; +import "google/api/resource.proto"; +import "google/protobuf/timestamp.proto"; + +// A record that the caller has tried a drink at a festival. +// +// Singleton resource — one per (caller, drink). The caller is implicit in the +// auth context. `pours` tracks how many times the caller has had this drink at +// the festival (e.g. returned for a second half-pint); absent means one pour. +message Tasting { + option (google.api.resource) = { + type: "api.cambeerfestival.app/Tasting" + pattern: "festivals/{festival}/drinks/{drink}/tasting" + singular: "tasting" + plural: "tastings" + }; + + // Resource name: festivals/{festival}/drinks/{drink}/tasting. + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // How many times the caller has had this drink. Absent means one pour. + // Must be >= 1 when present. + optional int32 pours = 2 [(google.api.field_behavior) = OPTIONAL]; + + // When the caller first tried this drink. + google.protobuf.Timestamp create_time = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // When this record was last updated. + google.protobuf.Timestamp update_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY]; +} + +// Computed, read-only aggregate of how many callers have tried a drink. +// +// Useful for social discovery ("N people have tried this"). Keyed by drink +// under the festival, matching the ReviewSummary pattern. +message TastingSummary { + option (google.api.resource) = { + type: "api.cambeerfestival.app/TastingSummary" + pattern: "festivals/{festival}/tastingSummaries/{drink}" + singular: "tastingSummary" + plural: "tastingSummaries" + }; + + // Resource name: festivals/{festival}/tastingSummaries/{drink}. + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // Number of distinct callers who have logged a tasting for this drink. + int32 taster_count = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // Total pours logged across all callers. + int32 total_pours = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; +}