| --- |
| title: "Adding API Endpoints" |
| description: "All JSON API endpoints in World Monitor must use sebuf. This guide walks through adding a new RPC to an existing service and adding an entirely new service." |
| --- |
| All JSON API endpoints in World Monitor **must** use sebuf. Do not create standalone `api/*.js` or `api/*.ts` files for new data APIs — the legacy pattern is deprecated and being removed. |
|
|
| This guide walks through adding a new RPC to an existing service and adding an entirely new service. |
|
|
| > **Enforcement:** `npm run lint:api-contract` runs in CI (see `.github/workflows/lint-code.yml`). It walks every file under `api/`, pairs each sebuf gateway (`api/<domain>/v<N>/[rpc].ts`) with a generated service under `src/generated/server/worldmonitor/`, and rejects any file that is neither a gateway nor listed in `api/api-route-exceptions.json`. The manifest is the only escape hatch for endpoints that genuinely cannot be proto — OAuth callbacks, binary responses, upstream proxies, operator plumbing — and every entry is pinned to @SebastienMelki via `.github/CODEOWNERS`. Expect reviewer pushback on new entries. |
| > |
| > **Generation freshness:** After modifying any `.proto` file, run `make generate` before pushing. The generated TypeScript in `src/generated/` is checked in and must stay in sync; `.github/workflows/proto-check.yml` fails the PR if it drifts. |
|
|
| ## Prerequisites |
|
|
| You need **Go 1.21+** and **Node.js 18+** installed. Everything else is installed automatically: |
|
|
| ```bash |
| make install # one-time: installs buf, sebuf plugins, npm deps, proto deps |
| ``` |
|
|
| This installs: |
|
|
| - **buf** — proto linting, dependency management, and code generation orchestrator |
| - **protoc-gen-ts-client** — generates TypeScript client classes (from [sebuf](https: |
| - **protoc-gen-ts-server** — generates TypeScript server handler interfaces (from sebuf) |
| - **protoc-gen-openapiv3** — generates OpenAPI v3 specs (from sebuf) |
| - **npm dependencies** — all Node.js packages |
|
|
| Run code generation from the repo root: |
|
|
| ```bash |
| make generate # regenerate all TypeScript + OpenAPI from protos |
| ``` |
|
|
| This produces three outputs per service: |
|
|
| - `src/generated/client/{domain}/v1/service_client.ts` — typed fetch client for the frontend |
| - `src/generated/server/{domain}/v1/service_server.ts` — handler interface + route factory for the backend |
| - `docs/api/{Domain}Service.openapi.yaml` + `.json` — OpenAPI v3 documentation |
|
|
| ## Adding an RPC to an existing service |
|
|
| Example: adding `GetEarthquakeDetails` to `SeismologyService`. |
|
|
| ### 1. Define the request/response messages |
|
|
| Create `proto/worldmonitor/seismology/v1/get_earthquake_details.proto`: |
|
|
| ```protobuf |
| syntax = "proto3"; |
| package worldmonitor.seismology.v1; |
| |
| import "buf/validate/validate.proto"; |
| import "worldmonitor/seismology/v1/earthquake.proto"; |
| |
| // GetEarthquakeDetailsRequest specifies which earthquake to retrieve. |
| message GetEarthquakeDetailsRequest { |
| // USGS event identifier (e.g., "us7000abcd"). |
| string earthquake_id = 1 [ |
| (buf.validate.field).required = true, |
| (buf.validate.field).string.min_len = 1, |
| (buf.validate.field).string.max_len = 100 |
| ]; |
| } |
| |
| // GetEarthquakeDetailsResponse contains the full earthquake record. |
| message GetEarthquakeDetailsResponse { |
| // The earthquake matching the requested ID. |
| Earthquake earthquake = 1; |
| } |
| ``` |
|
|
| ### 2. Add the RPC to the service definition |
|
|
| Edit `proto/worldmonitor/seismology/v1/service.proto`: |
|
|
| ```protobuf |
| import "worldmonitor/seismology/v1/get_earthquake_details.proto"; |
| |
| service SeismologyService { |
| // ... existing RPCs ... |
| |
| // GetEarthquakeDetails retrieves a single earthquake by its USGS event ID. |
| rpc GetEarthquakeDetails(GetEarthquakeDetailsRequest) returns (GetEarthquakeDetailsResponse) { |
| option (sebuf.http.config) = {path: "/get-earthquake-details"}; |
| } |
| } |
| ``` |
|
|
| ### 3. Lint and generate |
|
|
| ```bash |
| make check # lint + generate in one step |
| ``` |
|
|
| At this point, `npx tsc --noEmit` will **fail** because the handler doesn't implement the new method yet. This is by design — the compiler enforces the contract. |
| |
| ### 4. Implement the handler |
| |
| Create `server/worldmonitor/seismology/v1/get-earthquake-details.ts`: |
| |
| ```typescript |
| import type { |
| SeismologyServiceHandler, |
| ServerContext, |
| GetEarthquakeDetailsRequest, |
| GetEarthquakeDetailsResponse, |
| } from '../../../../src/generated/server/worldmonitor/seismology/v1/service_server'; |
| |
| export const getEarthquakeDetails: SeismologyServiceHandler['getEarthquakeDetails'] = async ( |
| _ctx: ServerContext, |
| req: GetEarthquakeDetailsRequest, |
| ): Promise<GetEarthquakeDetailsResponse> => { |
| const response = await fetch( |
| `https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/${req.earthquakeId}.geojson`, |
| ); |
| if (!response.ok) { |
| throw new Error(`USGS API error: ${response.status}`); |
| } |
| const f: any = await response.json(); |
| return { |
| earthquake: { |
| id: f.id, |
| place: f.properties.place || '', |
| magnitude: f.properties.mag ?? 0, |
| depthKm: f.geometry.coordinates[2] ?? 0, |
| location: { |
| latitude: f.geometry.coordinates[1], |
| longitude: f.geometry.coordinates[0], |
| }, |
| occurredAt: f.properties.time, |
| sourceUrl: f.properties.url || '', |
| }, |
| }; |
| }; |
| ``` |
| |
| ### 5. Wire it into the handler re-export |
| |
| Edit `server/worldmonitor/seismology/v1/handler.ts`: |
| |
| ```typescript |
| import type { SeismologyServiceHandler } from '../../../../src/generated/server/worldmonitor/seismology/v1/service_server'; |
| |
| import { listEarthquakes } from './list-earthquakes'; |
| import { getEarthquakeDetails } from './get-earthquake-details'; |
| |
| export const seismologyHandler: SeismologyServiceHandler = { |
| listEarthquakes, |
| getEarthquakeDetails, |
| }; |
| ``` |
| |
| ### 6. Verify |
| |
| ```bash |
| npx tsc --noEmit # should pass with zero errors |
| ``` |
| |
| The route is already live through the domain gateway in `api/seismology/v1/[rpc].ts`. `createSeismologyServiceRoutes()` picks up the new RPC automatically — no route-table or `vite.config.ts` edits are needed. |
| |
| ### 7. Check the generated docs |
| |
| Open `docs/api/SeismologyService.openapi.yaml` — the new endpoint should appear with all validation constraints from your proto annotations. |
| |
| ## Adding a new service |
| |
| Example: adding a hypothetical `WeatherService`. (No `weather` domain exists in this repo — the example below is purely illustrative; copy-pasting any path from this section will hit a 404.) |
| |
| ### 1. Create the proto directory |
| |
| ``` |
| proto/worldmonitor/weather/v1/ |
| ``` |
| |
| ### 2. Define entity messages |
| |
| Create `proto/worldmonitor/weather/v1/weather_station.proto`: |
| |
| ```protobuf |
| syntax = "proto3"; |
| package worldmonitor.weather.v1; |
| |
| import "buf/validate/validate.proto"; |
| import "sebuf/http/annotations.proto"; |
| |
| // WeatherStation represents a single ground-based observation station. |
| message WeatherStation { |
| // Unique identifier (e.g., WMO station number). |
| string id = 1 [ |
| (buf.validate.field).required = true, |
| (buf.validate.field).string.min_len = 1 |
| ]; |
| // Human-readable station name. |
| string name = 2; |
| // Operating network (e.g., "NWS", "WMO", "NOAA"). |
| string network = 3; |
| // ISO 3166-1 alpha-2 country code where the station is located. |
| string country_code = 4; |
| // Date the station first reported observations, as Unix epoch milliseconds. |
| int64 first_seen_at = 5 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; |
| } |
| ``` |
| |
| ### 3. Define request/response messages |
| |
| Create `proto/worldmonitor/weather/v1/list_weather_stations.proto`: |
| |
| ```protobuf |
| syntax = "proto3"; |
| package worldmonitor.weather.v1; |
| |
| import "buf/validate/validate.proto"; |
| import "worldmonitor/core/v1/pagination.proto"; |
| import "worldmonitor/weather/v1/weather_station.proto"; |
| |
| // ListWeatherStationsRequest specifies filters for weather station data. |
| message ListWeatherStationsRequest { |
| // Filter by operating network (e.g., "NWS"). Empty returns all. |
| string network = 1; |
| // Filter by country code. |
| string country_code = 2 [(buf.validate.field).string.max_len = 2]; |
| // Pagination parameters. |
| worldmonitor.core.v1.PaginationRequest pagination = 3; |
| } |
| |
| // ListWeatherStationsResponse contains the matching stations. |
| message ListWeatherStationsResponse { |
| // The list of weather stations. |
| repeated WeatherStation stations = 1; |
| // Pagination metadata. |
| worldmonitor.core.v1.PaginationResponse pagination = 2; |
| } |
| ``` |
| |
| ### 4. Define the service |
| |
| Create `proto/worldmonitor/weather/v1/service.proto`: |
| |
| ```protobuf |
| syntax = "proto3"; |
| package worldmonitor.weather.v1; |
| |
| import "sebuf/http/annotations.proto"; |
| import "worldmonitor/weather/v1/list_weather_stations.proto"; |
| |
| // WeatherService provides APIs for weather observation stations. |
| service WeatherService { |
| option (sebuf.http.service_config) = {base_path: "/api/weather/v1"}; |
| |
| // ListWeatherStations retrieves stations matching the given filters. |
| rpc ListWeatherStations(ListWeatherStationsRequest) returns (ListWeatherStationsResponse) { |
| option (sebuf.http.config) = {path: "/list-weather-stations"}; |
| } |
| } |
| ``` |
| |
| ### 5. Generate |
| |
| ```bash |
| make check # lint + generate in one step |
| ``` |
| |
| ### 6. Implement the handler |
| |
| Create the handler directory and files: |
| |
| ``` |
| server/worldmonitor/weather/v1/ |
| ├── handler.ts # thin re-export |
| └── list-weather-stations.ts # RPC implementation |
| ``` |
| |
| `server/worldmonitor/weather/v1/list-weather-stations.ts`: |
| ```typescript |
| import type { |
| WeatherServiceHandler, |
| ServerContext, |
| ListWeatherStationsRequest, |
| ListWeatherStationsResponse, |
| } from '../../../../src/generated/server/worldmonitor/weather/v1/service_server'; |
| |
| export const listWeatherStations: WeatherServiceHandler['listWeatherStations'] = async ( |
| _ctx: ServerContext, |
| req: ListWeatherStationsRequest, |
| ): Promise<ListWeatherStationsResponse> => { |
| // Your implementation here — fetch from upstream API, transform to proto shape |
| return { stations: [], pagination: undefined }; |
| }; |
| ``` |
| |
| `server/worldmonitor/weather/v1/handler.ts`: |
| ```typescript |
| import type { WeatherServiceHandler } from '../../../../src/generated/server/worldmonitor/weather/v1/service_server'; |
| |
| import { listWeatherStations } from './list-weather-stations'; |
| |
| export const weatherHandler: WeatherServiceHandler = { |
| listWeatherStations, |
| }; |
| ``` |
| |
| ### 7. Add the per-domain edge gateway |
| |
| Create `api/weather/v1/[rpc].ts` as the thin Edge entry point for this service: |
| |
| ```typescript |
| export const config = { runtime: 'edge' }; |
| |
| import { createDomainGateway, serverOptions } from '../../../server/gateway'; |
| import { createWeatherServiceRoutes } from '../../../src/generated/server/worldmonitor/weather/v1/service_server'; |
| import { weatherHandler } from '../../../server/worldmonitor/weather/v1/handler'; |
| |
| export default createDomainGateway( |
| createWeatherServiceRoutes(weatherHandler, serverOptions), |
| ); |
| ``` |
| |
| There is no repository-wide catch-all gateway file or shared route array to edit. Each service owns its `api/<domain>/v1/[rpc].ts` gateway, and the generated `create<Service>Routes(...)` function enforces the RPC path names and HTTP annotations for that domain. |
| |
| ### 8. Register in the Vite dev server |
| |
| Edit `vite.config.ts` — add the lazy import and route mount inside the `sebufApiPlugin()` function. Follow the existing pattern (search for any other service to see the exact locations). |
| |
| ### 9. Create the frontend service wrapper |
| |
| Create `src/services/weather.ts`: |
| |
| ```typescript |
| import { |
| WeatherServiceClient, |
| type WeatherStation, |
| type ListWeatherStationsResponse, |
| } from '@/generated/client/worldmonitor/weather/v1/service_client'; |
| import { createCircuitBreaker } from '@/utils'; |
| |
| export type { WeatherStation }; |
| |
| const client = new WeatherServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); |
| const breaker = createCircuitBreaker<ListWeatherStationsResponse>({ name: 'Weather' }); |
| |
| const emptyFallback: ListWeatherStationsResponse = { stations: [] }; |
| |
| export async function fetchWeatherStations(network?: string): Promise<WeatherStation[]> { |
| const response = await breaker.execute(async () => { |
| return client.listWeatherStations({ network: network ?? '', countryCode: '', pagination: undefined }); |
| }, emptyFallback); |
| return response.stations; |
| } |
| ``` |
| |
| ### 10. Verify |
| |
| ```bash |
| npx tsc --noEmit # zero errors |
| ``` |
| |
| ## MCP exposure decision |
| |
| Every new public OpenAPI operation needs an explicit MCP decision before review. MCP is a curated agent surface, not a 1:1 mirror of REST: expose operations that are safe, predictable, and useful as tools; keep REST-only operations documented when they mutate state, spend per-call LLM or upstream budget, or need manual cache-key review. |
| |
| Use this checklist for each new or changed RPC: |
| |
| - [ ] Decide whether this operation should be exposed to MCP. |
| - [ ] If yes, identify the owning MCP tool and add the exact `METHOD /api/...` entry to that tool's `_apiPaths`. |
| - [ ] If cache-backed, confirm the tool has the right `_cacheKeys` / `_coverageKeys`, freshness metadata, and `seed-meta:<key>` health coverage. |
| - [ ] If no, add or update the `tests/mcp-api-parity.test.mjs` exclusion with the matching category prefix and a concrete reason. |
| - [ ] If `fetch-on-miss`, include one enforced secondary signal (`high-cardinality-input`, `paid-upstream`, or `llm-cost`) and name the upstream cost, cardinality, and tier policy that makes open MCP exposure unsafe for now. |
| - [ ] If `mutating` or `llm-passthrough`, document the separate threat/cost model before proposing an MCP wrapper. |
| - [ ] Run `./node_modules/.bin/tsx --test tests/mcp-api-parity.test.mjs` and include the result in the PR. |
|
|
| A `covered` operation is declared in a tool `_apiPaths` entry. For REST-only operations, the parity test accepts these exclusion categories: |
|
|
| | Category | Use when | |
| |----------|----------| |
| | `mutating` | The handler writes state, queues work, refreshes caches, records webhooks, or has another persistent side effect. | |
| | `llm-passthrough` | The operation invokes per-call LLM work and should not be opened as a generic MCP tool without a cost model. | |
| | `fetch-on-miss` | The operation can call a paid, rate-limited, high-cardinality, or otherwise expensive upstream when the cache is cold. Include one enforced secondary signal in the reason: `high-cardinality-input`, `paid-upstream`, or `llm-cost`. | |
| | `admin` | The operation is internal-only and protected by an explicit admin boundary, such as an admin key, internal-only middleware, or cron-only path. | |
| | `manual-mapping` | The operation uses parameterized cache keys, inline Redis/Convex shapes, or another mapping the static parity walker cannot prove automatically. | |
| | `deferred-to-future-tool` | The operation is pure-read and agent-useful, but belongs in a future MCP tool or expanded bundle rather than today's registry. | |
| |
| The MCP reference docs render the current `_apiPaths` coverage table in [MCP Overview](/mcp-overview#api-coverage). The parity test is canonical for the current covered/excluded split, so do not rely on stale counts in a PR description. |
| |
| ## Proto conventions |
| |
| These conventions are enforced across the codebase. Follow them for consistency. |
| |
| ### File naming |
| |
| - One file per message type: `earthquake.proto`, `weather_station.proto` |
| - One file per RPC pair: `list_earthquakes.proto`, `get_earthquake_details.proto` |
| - Service definition: `service.proto` |
| - Use `snake_case` for file names and field names |
| |
| ### Time fields |
| |
| Always use `int64` with Unix epoch milliseconds. Never use `google.protobuf.Timestamp`. |
| |
| Always add the `INT64_ENCODING_NUMBER` annotation so TypeScript gets `number` instead of `string`: |
| |
| ```protobuf |
| int64 occurred_at = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; |
| ``` |
| |
| ### Validation annotations |
| |
| Import `buf/validate/validate.proto` and annotate fields at the proto level. These constraints flow through to the generated OpenAPI spec automatically. |
| |
| Common patterns: |
| |
| ```protobuf |
| // Required string with length bounds |
| string id = 1 [ |
| (buf.validate.field).required = true, |
| (buf.validate.field).string.min_len = 1, |
| (buf.validate.field).string.max_len = 100 |
| ]; |
| |
| // Numeric range (e.g., score 0-100) |
| double risk_score = 2 [ |
| (buf.validate.field).double.gte = 0, |
| (buf.validate.field).double.lte = 100 |
| ]; |
| |
| // Non-negative value |
| double min_magnitude = 3 [(buf.validate.field).double.gte = 0]; |
| |
| // Coordinate bounds (prefer using core.v1.GeoCoordinates instead) |
| double latitude = 1 [ |
| (buf.validate.field).double.gte = -90, |
| (buf.validate.field).double.lte = 90 |
| ]; |
| ``` |
| |
| ### Shared core types |
| |
| Reuse these instead of redefining: |
| |
| | Type | Import | Use for | |
| |------|--------|---------| |
| | `GeoCoordinates` | `worldmonitor/core/v1/geo.proto` | Any lat/lon location (has built-in -90/90 and -180/180 bounds) | |
| | `BoundingBox` | `worldmonitor/core/v1/geo.proto` | Spatial filtering | |
| | `TimeRange` | `worldmonitor/core/v1/time.proto` | Time-based filtering (has `INT64_ENCODING_NUMBER`) | |
| | `PaginationRequest` | `worldmonitor/core/v1/pagination.proto` | Request pagination (has page_size 1-100 constraint) | |
| | `PaginationResponse` | `worldmonitor/core/v1/pagination.proto` | Response pagination metadata | |
| |
| ### Comments |
| |
| buf lint enforces comments on all messages, fields, services, RPCs, and enum values. Every proto element must have a `//` comment. This is not optional — `buf lint` will fail without them. |
| |
| ### Route paths |
| |
| - Service base path: `/api/{domain}/v1` |
| - RPC path: `/{verb}-{noun}` in kebab-case (e.g., `/list-earthquakes`, `/get-vessel-snapshot`) |
| |
| ### Handler typing |
| |
| Always type the handler function against the generated interface using indexed access: |
| |
| ```typescript |
| export const listWeatherStations: WeatherServiceHandler['listWeatherStations'] = async ( |
| _ctx: ServerContext, |
| req: ListWeatherStationsRequest, |
| ): Promise<ListWeatherStationsResponse> => { |
| // ... |
| }; |
| ``` |
| |
| This ensures the compiler catches any mismatch between your implementation and the proto contract. |
| |
| ### Client construction |
| |
| Always pass `{ fetch: (...args) => globalThis.fetch(...args) }` when creating clients: |
| |
| ```typescript |
| const client = new WeatherServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); |
| ``` |
| |
| The empty string base URL works because both Vite dev server and Vercel serve the API on the same origin. The arrow-function wrapper around `globalThis.fetch` is required for Tauri compatibility AND for the runtime fetch interceptor — `fetch.bind(globalThis)` is **banned** because it freezes a reference to the global `fetch` at module-init time, which bypasses any later interceptor (auth headers, request logging, retry shims) installed on `globalThis.fetch`. The arrow-function wrapper resolves `globalThis.fetch` on every call. |
| |
| ## Generated documentation |
| |
| Every time you run `make generate`, OpenAPI v3 specs are generated for each service: |
| |
| - `docs/api/{Domain}Service.openapi.yaml` — human-readable YAML |
| - `docs/api/{Domain}Service.openapi.json` — machine-readable JSON |
| |
| These specs include: |
| |
| - All endpoints with request/response schemas |
| - Validation constraints from `buf.validate` annotations (min/max, required fields, ranges) |
| - Field descriptions from proto comments |
| - Error response schemas (400 validation errors, 500 server errors) |
| |
| You do not need to write or maintain OpenAPI specs by hand. They are generated artifacts. If you need to change the API documentation, change the proto and regenerate. |
| |