From 492a89145a98ed2e2cae5eea8cb8603b9320e1f2 Mon Sep 17 00:00:00 2001 From: dillonstreator Date: Fri, 24 Jul 2026 15:51:27 -0500 Subject: [PATCH] Rename API to /api/water with wrapped batch and problem details. Drop the product-name path and align request/response/error shapes with common HTTP API conventions. Co-authored-by: Cursor --- .changeset/api-water.md | 5 ++ README.md | 25 +++++++--- src/app.test.ts | 67 +++++++++++++++++++------- src/app.ts | 103 ++++++++++++++++++++++++++++++++-------- src/public/index.html | 7 +-- 5 files changed, 160 insertions(+), 47 deletions(-) create mode 100644 .changeset/api-water.md diff --git a/.changeset/api-water.md b/.changeset/api-water.md new file mode 100644 index 0000000..e1a15f1 --- /dev/null +++ b/.changeset/api-water.md @@ -0,0 +1,5 @@ +--- +"is-on-water": minor +--- + +Rename the API to `/api/water`, wrap batch payloads, and return RFC 9457 problem details. diff --git a/README.md b/README.md index 4417d6a..8e3b5fb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # `is-on-water` -Check whether a geographic coordinate is on water (seas, lakes, and rivers). Exposed via an HTTP API for single coordinate (`GET /api/is-on-water?lat=${lat}&lon=${lon}`) and batch (`POST /api/is-on-water` with an array of `{ lat, lon }` objects) lookups. +Check whether a geographic coordinate is on water (seas, lakes, and rivers). Exposed via an HTTP API for single coordinate (`GET /api/water?lat=${lat}&lon=${lon}`) and batch (`POST /api/water` with `{ "coordinates": [{ lat, lon }, ...] }`) lookups. Built on [Fastify](https://fastify.dev/) with optional OpenTelemetry, Swagger at `/documentation`, and rate limiting (in-memory by default; Redis when `REDIS_URL` is set). @@ -47,10 +47,12 @@ By default the trace exporter writes to standard output. Set `OTEL_EXPORTER_OTLP Interactive docs: `/documentation` -### GET `/api/is-on-water` +Errors use [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details (`application/problem+json`). + +### GET `/api/water` ```sh -curl "http://localhost:3000/api/is-on-water?lat=20.112682&lon=-37.048647" +curl "http://localhost:3000/api/water?lat=20.112682&lon=-37.048647" ``` ```json @@ -59,14 +61,23 @@ curl "http://localhost:3000/api/is-on-water?lat=20.112682&lon=-37.048647" Latitude must be between -90 and 90; longitude between -180 and 180. -### POST `/api/is-on-water` +### POST `/api/water` -Body: JSON array of `{ "lat", "lon" }` (max `MAX_BATCH_SIZE`, default 500). +Body: `{ "coordinates": [{ "lat", "lon" }, ...] }` (max `MAX_BATCH_SIZE`, default 500). A bare JSON array is also accepted. ```sh -curl -X POST http://localhost:3000/api/is-on-water \ +curl -X POST http://localhost:3000/api/water \ -H 'content-type: application/json' \ - -d '[{"lat":20.112682,"lon":-37.048647},{"lat":40.292097,"lon":-98.613164}]' + -d '{"coordinates":[{"lat":20.112682,"lon":-37.048647},{"lat":40.292097,"lon":-98.613164}]}' +``` + +```json +{ + "results": [ + { "water": true, "lat": 20.112682, "lon": -37.048647 }, + { "water": false, "lat": 40.292097, "lon": -98.613164 } + ] +} ``` ## Data diff --git a/src/app.test.ts b/src/app.test.ts index 8acaaf0..66ddbfd 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -53,7 +53,7 @@ tap.test('app', async (t) => { const lon = -37.048647; const response = await client.request({ method: 'GET', - path: `/api/is-on-water?lat=${lat}&lon=${lon}`, + path: `/api/water?lat=${lat}&lon=${lon}`, }); t.equal(response.statusCode, 200); @@ -67,7 +67,7 @@ tap.test('app', async (t) => { const lon = -98.613164; const response = await client.request({ method: 'GET', - path: `/api/is-on-water?lat=${lat}&lon=${lon}`, + path: `/api/water?lat=${lat}&lon=${lon}`, }); t.equal(response.statusCode, 200); @@ -75,34 +75,65 @@ tap.test('app', async (t) => { t.same(body, { lat, lon, water: false }); }); - t.test('should accept numeric zero coordinates on POST', async (t) => { + t.test('should accept wrapped batch body on POST', async (t) => { const response = await client.request({ method: 'POST', - path: '/api/is-on-water', + path: '/api/water', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + coordinates: [{ lat: 0, lon: 0 }], + }), + }); + + t.equal(response.statusCode, 200); + const body = (await response.body.json()) as { + results: Array<{ water: boolean; lat: number; lon: number }>; + }; + t.equal(body.results.length, 1); + t.equal(body.results[0].lat, 0); + t.equal(body.results[0].lon, 0); + t.type(body.results[0].water, 'boolean'); + }); + + t.test('should accept bare array batch body on POST', async (t) => { + const response = await client.request({ + method: 'POST', + path: '/api/water', headers: { 'content-type': 'application/json' }, body: JSON.stringify([{ lat: 0, lon: 0 }]), }); t.equal(response.statusCode, 200); - const body = (await response.body.json()) as Array<{ - water: boolean; - lat: number; - lon: number; - }>; - t.equal(body.length, 1); - t.equal(body[0].lat, 0); - t.equal(body[0].lon, 0); - t.type(body[0].water, 'boolean'); + const body = (await response.body.json()) as { + results: Array<{ water: boolean; lat: number; lon: number }>; + }; + t.equal(body.results.length, 1); + t.equal(body.results[0].lat, 0); + t.equal(body.results[0].lon, 0); + t.type(body.results[0].water, 'boolean'); }); - t.test('should reject invalid latitude', async (t) => { + t.test('should reject invalid latitude with problem details', async (t) => { const response = await client.request({ method: 'GET', - path: '/api/is-on-water?lat=91&lon=0', + path: '/api/water?lat=91&lon=0', }); t.equal(response.statusCode, 400); - await response.body.dump(); + t.match( + String(response.headers['content-type']), + /application\/problem\+json/ + ); + const body = (await response.body.json()) as { + type: string; + title: string; + status: number; + detail: string; + }; + t.equal(body.type, 'about:blank'); + t.equal(body.title, 'Bad Request'); + t.equal(body.status, 400); + t.type(body.detail, 'string'); }); t.test('should serve the landing page', async (t) => { @@ -120,7 +151,7 @@ tap.test('app', async (t) => { const body = await response.body.text(); t.match(body, /Is On Water/); t.match(body, /coord-form/); - t.match(body, /\/api\/is-on-water/); + t.match(body, /\/api\/water/); t.match(body, /osbytes\.io\/badge/); t.match(body, /github\.com\/osbytes\/is-on-water/); t.match(body, /OpenStreetMap/); @@ -150,8 +181,10 @@ tap.test('app', async (t) => { t.equal(response.statusCode, 200); const body = (await response.body.json()) as { info: { title: string; version: string }; + paths: Record; }; t.equal(body.info.title, 'is-on-water'); t.equal(body.info.version, version); + t.ok(body.paths['/api/water']); }); }); diff --git a/src/app.ts b/src/app.ts index a13dba3..e7048e7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { readFileSync } from 'node:fs'; import path from 'node:path'; -import Fastify, { FastifyError } from 'fastify'; +import Fastify, { FastifyError, FastifyReply } from 'fastify'; import pino from 'pino'; import helmet from '@fastify/helmet'; import compression from '@fastify/compress'; @@ -37,12 +37,75 @@ const coordinateSchema = z.object({ lon: z.coerce.number().min(-180).max(180), }); -const isOnWaterResultSchema = z.object({ +const waterResultSchema = z.object({ water: z.boolean(), lat: z.number(), lon: z.number(), }); +const batchRequestSchema = (maxBatchSize: number) => + z.union([ + z.array(coordinateSchema).min(1).max(maxBatchSize), + z.object({ + coordinates: z + .array(coordinateSchema) + .min(1) + .max(maxBatchSize), + }), + ]); + +const batchResponseSchema = z.object({ + results: z.array(waterResultSchema), +}); + +const problemDetailsSchema = z.object({ + type: z.string(), + title: z.string(), + status: z.number(), + detail: z.string(), +}); + +const PROBLEM_JSON = 'application/problem+json'; + +const httpTitle = (statusCode: number): string => { + switch (statusCode) { + case 400: + return 'Bad Request'; + case 404: + return 'Not Found'; + case 413: + return 'Payload Too Large'; + case 429: + return 'Too Many Requests'; + case 503: + return 'Service Unavailable'; + default: + return statusCode >= 500 ? 'Internal Server Error' : 'Error'; + } +}; + +const sendProblem = ( + res: FastifyReply, + statusCode: number, + detail: string +) => { + return res + .status(statusCode) + .type(PROBLEM_JSON) + .send({ + type: 'about:blank', + title: httpTitle(statusCode), + status: statusCode, + detail, + }); +}; + +const normalizeBatchCoordinates = ( + body: + | Array<{ lat: number; lon: number }> + | { coordinates: Array<{ lat: number; lon: number }> } +) => (Array.isArray(body) ? body : body.coordinates); + export const initApp = async (config: Config, logger: pino.Logger) => { const redis = config.redisUrl ? new Redis(config.redisUrl, { @@ -153,10 +216,10 @@ export const initApp = async (config: Config, logger: pino.Logger) => { try { const pong = await redis.ping(); if (pong !== 'PONG') { - return res.status(503).send({ msg: 'Redis unavailable' }); + return sendProblem(res, 503, 'Redis unavailable'); } } catch { - return res.status(503).send({ msg: 'Redis unavailable' }); + return sendProblem(res, 503, 'Redis unavailable'); } } res.status(200).send(); @@ -171,13 +234,16 @@ export const initApp = async (config: Config, logger: pino.Logger) => { }); }); - app.withTypeProvider().route({ + const typed = app.withTypeProvider(); + + typed.route({ method: 'GET', - url: '/api/is-on-water', + url: '/api/water', schema: { querystring: coordinateSchema, response: { - 200: isOnWaterResultSchema, + 200: waterResultSchema, + 400: problemDetailsSchema, }, }, handler(req, res) { @@ -185,24 +251,23 @@ export const initApp = async (config: Config, logger: pino.Logger) => { }, }); - app.withTypeProvider().route({ + typed.route({ method: 'POST', - url: '/api/is-on-water', + url: '/api/water', config: { // Allow larger batches than the default 1kb body limit bodyLimit: 1024 * 100, }, schema: { - body: z - .array(coordinateSchema) - .min(1) - .max(config.maxBatchSize), + body: batchRequestSchema(config.maxBatchSize), response: { - 200: z.array(isOnWaterResultSchema), + 200: batchResponseSchema, + 400: problemDetailsSchema, }, }, handler(req, res) { - res.send(req.body.map(isOnWater)); + const coordinates = normalizeBatchCoordinates(req.body); + res.send({ results: coordinates.map(isOnWater) }); }, }); @@ -214,18 +279,16 @@ export const initApp = async (config: Config, logger: pino.Logger) => { const statusCode = error.statusCode ?? 500; if (statusCode === 429) { - res.status(429).send({ msg: 'Rate limit exceeded' }); + sendProblem(res, 429, 'Rate limit exceeded'); return; } if (statusCode >= 400 && statusCode < 500) { - res.status(statusCode).send({ - msg: error.message || 'Bad request', - }); + sendProblem(res, statusCode, error.message || 'Bad request'); return; } - res.status(500).send({ msg: 'Something went wrong' }); + sendProblem(res, 500, 'Something went wrong'); }); await app.ready(); diff --git a/src/public/index.html b/src/public/index.html index 2ef4094..c9203d4 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -420,7 +420,7 @@

Is this point on water?

Try it

- GET /api/is-on-water?lat=&lon= + GET /api/water?lat=&lon=
@@ -533,7 +533,7 @@

Details

const formatCoord = (n) => String(n); const apiPath = (lat, lon) => - `/api/is-on-water?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`; + `/api/water?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`; const apiUrl = (lat, lon) => `${window.location.origin}${apiPath(lat, lon)}`; @@ -604,7 +604,8 @@

Details

let detail = `Request failed (${res.status})`; try { const errBody = await res.json(); - if (errBody?.msg) detail = errBody.msg; + if (errBody?.detail) detail = errBody.detail; + else if (errBody?.msg) detail = errBody.msg; } catch (_) {} showResult({ status: statusText, error: detail }); return;