From 9ead9f7ed139ed2848d40be0735c2259ccd235ec Mon Sep 17 00:00:00 2001 From: Keenan Johnson Date: Mon, 1 Jun 2026 14:04:49 -0500 Subject: [PATCH] Add basic docs page --- README.md | 100 ++++++------- internal/docs/docs.go | 52 +++++++ internal/docs/openapi.yaml | 293 +++++++++++++++++++++++++++++++++++++ main.go | 30 ++++ 4 files changed, 419 insertions(+), 56 deletions(-) create mode 100644 internal/docs/docs.go create mode 100644 internal/docs/openapi.yaml diff --git a/README.md b/README.md index 5b9e8e3..4e7712c 100644 --- a/README.md +++ b/README.md @@ -2,70 +2,40 @@ A public API for global CO2 measurements, powered by the [Ribbit Network](https://ribbitnetwork.org) — an open-source network of citizen-operated CO2 sensors. -## Endpoints +## 📖 Documentation -### `GET /` +Interactive API reference (try requests in the browser): +**[ribbit-api.fly.dev/docs](https://ribbit-api.fly.dev/docs)** -Health check. Returns `🐸`. +Machine-readable OpenAPI spec: +**[ribbit-api.fly.dev/openapi.yaml](https://ribbit-api.fly.dev/openapi.yaml)** ---- +The spec also lives in this repo at [`internal/docs/openapi.yaml`](internal/docs/openapi.yaml) and is the source of truth — use it to generate client SDKs (`openapi-generator`, `oapi-codegen`, etc.) or to import into Postman / Insomnia / Bruno. -### `GET /data` +## Quickstart -Returns CO2, temperature, humidity, and location measurements from the sensor network for a given time range. +Once you have an [API key](#api-keys), fetch the last day of CO2 readings: -Requires an API key passed as `Authorization: Bearer ` or `X-API-Key: `. - -#### Query parameters - -| Parameter | Required | Description | -|------------|----------|-------------| -| `start` | yes | Start of time range (RFC 3339, e.g. `2024-01-01T00:00:00Z`) | -| `stop` | no | End of time range (RFC 3339). Omit to query through the present. | -| `hosts` | no | Comma-separated list of sensor IDs to filter by | -| `fields` | no | Comma-separated list of fields to return. Available fields: `co2`, `lat`, `lon`, `humidity`, `baro_pressure`, `baro_temperature`, `alt`. Omit to return all fields. | -| `interval` | no | Aggregate readings into windows of this duration (e.g. `5m`, `1h`). Uses mean aggregation. Omit for raw data. | - -#### JSON response - -``` -GET /data?start=2024-01-01T00:00:00Z&stop=2024-01-02T00:00:00Z&fields=co2,lat,lon&interval=1h -``` - -```json -{ - "data": [ - { - "time": "2024-01-01T00:00:00Z", - "host": "a3f2...", - "co2": 412.5, - "lat": 37.77, - "lon": -122.41 - }, - ... - ] -} +```sh +curl -H "Authorization: Bearer $RIBBIT_API_KEY" \ + "https://ribbit-api.fly.dev/data?start=2024-01-01T00:00:00Z&stop=2024-01-02T00:00:00Z&fields=co2,lat,lon&interval=1h" ``` ---- - -### `GET /sensors` +Endpoints at a glance: -Returns the list of sensor IDs known to the network (over roughly the last 30 days, per InfluxDB's `schema.tagValues` default). +| Endpoint | Auth | Description | +|-----------------|------|-------------| +| `GET /` | — | Health banner (`🐸`) | +| `GET /healthz` | — | Liveness check (`ok`) | +| `GET /docs` | — | Interactive API reference | +| `GET /data` | ✅ | Sensor measurements over a time range | +| `GET /sensors` | ✅ | List of known sensor IDs | -Requires an API key passed as `Authorization: Bearer ` or `X-API-Key: `. +See **[/docs](https://ribbit-api.fly.dev/docs)** for full parameter, response, and error documentation. -#### JSON response - -``` -GET /sensors -``` +## Rate limits -```json -{ - "sensors": ["a3f2...", "b91c...", "..."] -} -``` +Each API key is limited to **1 request per second** with a burst of **60**. Exceeding the limit returns `429 Too Many Requests`. ## Running locally @@ -83,12 +53,22 @@ GET /sensors 3. Run: ```sh - go run main.go + go run . ``` -The API will be available at `http://localhost:`. +The API will be available at `http://localhost:8080`, and the interactive docs at `http://localhost:8080/docs`. + +### Previewing just the docs -## Environment variables +If you only want to render the OpenAPI page (no InfluxDB or API-key store needed), run: + +```sh +go run . docs +``` + +This serves the embedded spec and Scalar reference at `http://localhost:8080`. Handy when iterating on [`internal/docs/openapi.yaml`](internal/docs/openapi.yaml). + +### Environment variables | Variable | Description | |-----------------------|-------------| @@ -101,7 +81,7 @@ The API will be available at `http://localhost:`. ## API keys -Access to `/data` requires an API key. Keys live in a SQLite file at `API_KEY_DB_PATH`; only the SHA-256 of each key is stored. +Access to `/data` and `/sensors` requires an API key. Keys live in a SQLite file at `API_KEY_DB_PATH`; only the SHA-256 of each key is stored. Key management is built into the API binary as a `keygen` subcommand. @@ -133,6 +113,14 @@ curl -H "Authorization: Bearer rbnt_..." "$API_URL/data?start=2024-01-01T00:00:0 curl -H "X-API-Key: rbnt_..." "$API_URL/data?start=2024-01-01T00:00:00Z" ``` +## Updating the docs + +The OpenAPI spec at [`internal/docs/openapi.yaml`](internal/docs/openapi.yaml) is embedded into the binary at build time. When you add or change an endpoint: + +1. Edit the spec to match. +2. `go build ./...` to verify it still compiles (the spec is `go:embed`-ed). +3. Visit `/docs` locally to spot-check the rendered output. + ## Contributing Feel free to open an issue or PR! We also have enabled the [Github discussion board](https://github.com/Ribbit-Network/api/discussions) if you prefer that. diff --git a/internal/docs/docs.go b/internal/docs/docs.go new file mode 100644 index 0000000..65d4306 --- /dev/null +++ b/internal/docs/docs.go @@ -0,0 +1,52 @@ +// Package docs serves the OpenAPI specification and a Scalar-rendered API +// reference page. +package docs + +import ( + _ "embed" + "net/http" +) + +//go:embed openapi.yaml +var openAPISpec []byte + +const referenceHTML = ` + + + + + Ribbit Network API — Reference + + + + + + + + +` + +// HandleSpec serves the embedded OpenAPI document. +func HandleSpec(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/yaml; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=300") + _, _ = w.Write(openAPISpec) +} + +// HandleReference serves the Scalar-rendered API reference page. +func HandleReference(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=300") + _, _ = w.Write([]byte(referenceHTML)) +} diff --git a/internal/docs/openapi.yaml b/internal/docs/openapi.yaml new file mode 100644 index 0000000..a0a1367 --- /dev/null +++ b/internal/docs/openapi.yaml @@ -0,0 +1,293 @@ +openapi: 3.1.0 +info: + title: Ribbit Network API + version: "1.0.0" + summary: Public CO2 measurements from the Ribbit Network sensor fleet. + description: | + The Ribbit Network API exposes CO2, temperature, humidity, and location + readings from the global network of citizen-operated CO2 sensors at + [ribbitnetwork.org](https://ribbitnetwork.org). + + ## Authentication + + All data endpoints require an API key. Send it in **either** header: + + ``` + Authorization: Bearer + X-API-Key: + ``` + + To request a key, open an issue on + [GitHub](https://github.com/Ribbit-Network/api/issues). + + ## Rate limits + + Each API key is limited to **1 request per second**, with a burst of **60**. + Exceeding the limit returns `429 Too Many Requests`. + + ## Errors + + Errors are returned as `text/plain` with a short human-readable message and + a meaningful HTTP status code. + license: + name: MIT + url: https://github.com/Ribbit-Network/api/blob/main/LICENSE + contact: + name: Ribbit Network + url: https://github.com/Ribbit-Network/api + +servers: + - url: https://ribbit-api.fly.dev + description: Production + - url: http://localhost:8080 + description: Local development + +tags: + - name: Data + description: CO2 and environmental measurements + - name: Sensors + description: Sensor fleet metadata + - name: Health + description: Service liveness + +security: + - bearerAuth: [] + - apiKeyAuth: [] + +paths: + /: + get: + tags: [Health] + summary: Service banner + description: Returns a frog emoji. Useful as a no-auth liveness check. + security: [] + responses: + "200": + description: OK + content: + text/plain: + schema: + type: string + example: "🐸\n" + + /healthz: + get: + tags: [Health] + summary: Health check + description: Returns `ok` when the service is up. No authentication required. + security: [] + responses: + "200": + description: OK + content: + text/plain: + schema: + type: string + example: "ok\n" + + /data: + get: + tags: [Data] + summary: Query sensor measurements + description: | + Returns CO2, temperature, humidity, and location measurements for the + requested time range. Results may be filtered by sensor and aggregated + into time windows. + parameters: + - name: start + in: query + required: true + description: Start of the time range, RFC 3339 timestamp. + schema: + type: string + format: date-time + example: "2024-01-01T00:00:00Z" + - name: stop + in: query + required: false + description: End of the time range, RFC 3339 timestamp. Omit to query through the present. + schema: + type: string + format: date-time + example: "2024-01-02T00:00:00Z" + - name: hosts + in: query + required: false + description: Comma-separated list of sensor IDs to filter by. See `/sensors` for the available IDs. + schema: + type: string + example: "a3f2...,b91c..." + - name: fields + in: query + required: false + description: | + Comma-separated list of fields to return. Omit to return all + available fields. Valid values: `co2`, `lat`, `lon`, `humidity`, + `baro_pressure`, `baro_temperature`, `alt`. + schema: + type: string + example: "co2,lat,lon" + - name: interval + in: query + required: false + description: | + Aggregate readings into windows of this duration using mean + aggregation. Accepts Go duration syntax (e.g. `30s`, `5m`, `1h`). + Omit for raw data. + schema: + type: string + example: "1h" + responses: + "200": + description: Measurements for the requested range. + content: + application/json: + schema: + $ref: "#/components/schemas/DataResponse" + example: + data: + - time: "2024-01-01T00:00:00Z" + host: "a3f2..." + co2: 412.5 + lat: 37.77 + lon: -122.41 + - time: "2024-01-01T01:00:00Z" + host: "a3f2..." + co2: 415.1 + lat: 37.77 + lon: -122.41 + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/ServerError" + + /sensors: + get: + tags: [Sensors] + summary: List sensor IDs + description: | + Returns the set of sensor IDs that have reported data recently + (approximately the last 30 days, per InfluxDB's default tag-value + lookback). + responses: + "200": + description: Sensor IDs. + content: + application/json: + schema: + $ref: "#/components/schemas/SensorsResponse" + example: + sensors: ["a3f2...", "b91c...", "c7d4..."] + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + "500": + $ref: "#/components/responses/ServerError" + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: "Send your API key as `Authorization: Bearer `." + apiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: "Send your API key in the `X-API-Key` header." + + schemas: + DataPoint: + type: object + description: A single measurement from one sensor at one timestamp. Fields omitted from `fields` are not returned. + required: [time, host] + properties: + time: + type: string + format: date-time + description: Measurement timestamp (UTC). + host: + type: string + description: Sensor ID. + co2: + type: number + format: double + description: CO2 concentration (ppm). + lat: + type: number + format: double + description: Sensor latitude (degrees). + lon: + type: number + format: double + description: Sensor longitude (degrees). + alt: + type: number + format: double + description: Sensor altitude (meters). + humidity: + type: number + format: double + description: Relative humidity (%). + baro_pressure: + type: number + format: double + description: Barometric pressure (hPa). + baro_temperature: + type: number + format: double + description: Temperature from the barometric sensor (°C). + + DataResponse: + type: object + required: [data] + properties: + data: + type: array + items: + $ref: "#/components/schemas/DataPoint" + + SensorsResponse: + type: object + required: [sensors] + properties: + sensors: + type: array + items: + type: string + description: Sensor IDs. + + responses: + BadRequest: + description: Invalid query parameters (e.g. missing `start`, malformed RFC 3339 time, unparseable `interval`). + content: + text/plain: + schema: + type: string + example: 'missing required parameter: "start"' + Unauthorized: + description: Missing or invalid API key. + content: + text/plain: + schema: + type: string + example: "missing api key" + RateLimited: + description: Rate limit exceeded for this API key (1 req/s, burst 60). + content: + text/plain: + schema: + type: string + example: "rate limit exceeded" + ServerError: + description: Internal error while querying the time-series store. + content: + text/plain: + schema: + type: string + example: "query failed" diff --git a/main.go b/main.go index d59e7c7..4e0b388 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "github.com/Ribbit-Network/api/internal/auth" "github.com/Ribbit-Network/api/internal/data" + "github.com/Ribbit-Network/api/internal/docs" "github.com/Ribbit-Network/api/internal/ratelimit" "github.com/Ribbit-Network/api/internal/sensors" "github.com/joho/godotenv" @@ -30,9 +31,36 @@ func main() { runKeygen(os.Args[2:]) return } + if len(os.Args) > 1 && os.Args[1] == "docs" { + runDocsServer() + return + } runServer() } +// runDocsServer serves only the OpenAPI reference. Useful for previewing docs +// changes locally without configuring InfluxDB or the API-key store. +func runDocsServer() { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/docs" { + http.NotFound(w, r) + return + } + docs.HandleReference(w, r) + }) + mux.HandleFunc("/openapi.yaml", docs.HandleSpec) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + log.Println("docs preview running at http://localhost:" + port) + if err := http.ListenAndServe(":"+port, corsMiddleware(mux)); err != nil { + log.Fatal(err) + } +} + func runServer() { store, err := openKeyStore() if err != nil { @@ -46,6 +74,8 @@ func runServer() { mux := http.NewServeMux() mux.HandleFunc("/", handleRoot) mux.HandleFunc("/healthz", handleHealthz) + mux.HandleFunc("/docs", docs.HandleReference) + mux.HandleFunc("/openapi.yaml", docs.HandleSpec) mux.Handle("/data", requireKey(limiter.Middleware(http.HandlerFunc(data.Handle)))) mux.Handle("/sensors", requireKey(limiter.Middleware(http.HandlerFunc(sensors.Handle))))