Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/api-water.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"is-on-water": minor
---

Rename the API to `/api/water`, wrap batch payloads, and return RFC 9457 problem details.
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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).

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
67 changes: 50 additions & 17 deletions src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -67,42 +67,73 @@ 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);
const body = await response.body.json();
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) => {
Expand All @@ -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/);
Expand Down Expand Up @@ -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<string, unknown>;
};
t.equal(body.info.title, 'is-on-water');
t.equal(body.info.version, version);
t.ok(body.paths['/api/water']);
});
});
103 changes: 83 additions & 20 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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();
Expand All @@ -171,38 +234,40 @@ export const initApp = async (config: Config, logger: pino.Logger) => {
});
});

app.withTypeProvider<ZodTypeProvider>().route({
const typed = app.withTypeProvider<ZodTypeProvider>();

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) {
res.send(isOnWater(req.query));
},
});

app.withTypeProvider<ZodTypeProvider>().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) });
},
});

Expand All @@ -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();
Expand Down
7 changes: 4 additions & 3 deletions src/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ <h1>Is this point on water?</h1>
<section class="try" aria-labelledby="try-heading">
<div class="section-head">
<h2 id="try-heading">Try it</h2>
<span class="endpoint mono">GET /api/is-on-water?lat=&amp;lon=</span>
<span class="endpoint mono">GET /api/water?lat=&amp;lon=</span>
</div>

<form class="coords" id="coord-form">
Expand Down Expand Up @@ -533,7 +533,7 @@ <h2 class="panel-label" id="details-heading">Details</h2>
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)}`;

Expand Down Expand Up @@ -604,7 +604,8 @@ <h2 class="panel-label" id="details-heading">Details</h2>
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;
Expand Down
Loading