mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(search): Nominatim as the last-resort geocoder (#573)
Supersedes #350 by @sendmebits, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: sendmebits <[email protected]>
This commit is contained in:
1 parent
cca6442997
commit
7fa0cfbb26
15 files changed
+1077
-48
No files matched your search
@@ -99,7 +99,7 @@ export const DATA_CREDITS = [
|
||||
{
|
||||
key: 'cockpit-place-osm',
|
||||
html:
|
||||
'Cockpit place context: ' +
|
||||
'Cockpit place context and last-resort place search: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'via Nominatim (ODbL 1.0)',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Map a Nominatim search hit into the result shape the camera framing already
|
||||
* reads (types + viewport). Pure — no network.
|
||||
*
|
||||
* Every numeric field arrives as a string from the upstream JSON and may be
|
||||
* absent, blank, or nonsense. A coordinate that is not a real coordinate must
|
||||
* be refused rather than coerced: `Number(null)` is 0, so a missing pair read
|
||||
* as `(0, 0)` would fly the operator to the Atlantic and look like a hit.
|
||||
*/
|
||||
|
||||
const ADDRESS_TYPE_MAP = Object.freeze({
|
||||
country: ['country', 'political'],
|
||||
nation: ['country', 'political'],
|
||||
state: ['administrative_area_level_1', 'political'],
|
||||
province: ['administrative_area_level_1', 'political'],
|
||||
region: ['administrative_area_level_1', 'political'],
|
||||
county: ['administrative_area_level_2', 'political'],
|
||||
district: ['administrative_area_level_2', 'political'],
|
||||
municipality: ['locality', 'political'],
|
||||
city: ['locality', 'political'],
|
||||
town: ['locality', 'political'],
|
||||
village: ['locality', 'political'],
|
||||
hamlet: ['locality', 'political'],
|
||||
suburb: ['neighborhood', 'political'],
|
||||
neighbourhood: ['neighborhood', 'political'],
|
||||
neighborhood: ['neighborhood', 'political'],
|
||||
quarter: ['neighborhood', 'political'],
|
||||
postcode: ['postal_code'],
|
||||
road: ['route'],
|
||||
highway: ['route'],
|
||||
peak: ['natural_feature'],
|
||||
mountain_range: ['natural_feature'],
|
||||
wood: ['natural_feature'],
|
||||
forest: ['natural_feature'],
|
||||
park: ['park'],
|
||||
nature_reserve: ['park', 'natural_feature'],
|
||||
aerodrome: ['airport'],
|
||||
airport: ['airport'],
|
||||
university: ['university'],
|
||||
stadium: ['stadium'],
|
||||
island: ['natural_feature'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Read a field that must be a finite number inside a range.
|
||||
*
|
||||
* Null, undefined, blank, whitespace, booleans, arrays and anything that does
|
||||
* not parse cleanly all return null. Only a string or number is considered, so
|
||||
* `Number([])` and `Number(true)` cannot become 0 and 1.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function boundedNumber(value, min, max) {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') return null;
|
||||
if (typeof value === 'string' && value.trim() === '') return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return parsed >= min && parsed <= max ? parsed : null;
|
||||
}
|
||||
|
||||
/** A latitude that is really a latitude, or null. */
|
||||
export function nominatimLatitude(value) {
|
||||
return boundedNumber(value, -90, 90);
|
||||
}
|
||||
|
||||
/** A longitude that is really a longitude, or null. */
|
||||
export function nominatimLongitude(value) {
|
||||
return boundedNumber(value, -180, 180);
|
||||
}
|
||||
|
||||
/** Framing types for the camera, derived from the hit's class and type. */
|
||||
export function typesFromNominatim(hit) {
|
||||
const addresstype = String(hit?.addresstype || '').toLowerCase();
|
||||
const osmType = String(hit?.type || '').toLowerCase();
|
||||
const osmClass = String(hit?.class || '').toLowerCase();
|
||||
const mapped =
|
||||
ADDRESS_TYPE_MAP[addresstype] ||
|
||||
ADDRESS_TYPE_MAP[osmType] ||
|
||||
(osmClass === 'highway' ? ['route'] : null) ||
|
||||
(osmClass === 'leisure' ? ['park'] : null) ||
|
||||
(osmClass === 'natural' ? ['natural_feature'] : null) ||
|
||||
(osmClass === 'aeroway' ? ['airport'] : null) ||
|
||||
(osmClass === 'amenity' && osmType === 'university'
|
||||
? ['university']
|
||||
: null);
|
||||
return mapped ? [...mapped] : ['point_of_interest', 'establishment'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The hit's bounding box as a viewport, or null.
|
||||
*
|
||||
* Nominatim gives `[south, north, west, east]` as strings. A box with an edge
|
||||
* outside the real world, an inverted edge, or a missing value is refused —
|
||||
* framing a viewport with a longitude of 999 sends the camera to nowhere.
|
||||
*
|
||||
* @param {object} hit
|
||||
* @returns {{southwest: {lat: number, lng: number}, northeast: {lat: number, lng: number}}|null}
|
||||
*/
|
||||
export function viewportFromNominatim(hit) {
|
||||
const box = Array.isArray(hit?.boundingbox) ? hit.boundingbox : [];
|
||||
if (box.length !== 4) return null;
|
||||
const south = nominatimLatitude(box[0]);
|
||||
const north = nominatimLatitude(box[1]);
|
||||
const west = nominatimLongitude(box[2]);
|
||||
const east = nominatimLongitude(box[3]);
|
||||
if (south === null || north === null || west === null || east === null)
|
||||
return null;
|
||||
if (south > north) return null;
|
||||
return {
|
||||
southwest: { lat: south, lng: west },
|
||||
northeast: { lat: north, lng: east },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one Nominatim hit into a geocoding result, or null.
|
||||
*
|
||||
* @param {object} hit
|
||||
* @returns {{geometry: object, formatted_address: string, types: string[], address_components: object[]}|null}
|
||||
*/
|
||||
export function nominatimToGeocodeResult(hit) {
|
||||
const lat = nominatimLatitude(hit?.lat);
|
||||
const lng = nominatimLongitude(hit?.lon);
|
||||
if (lat === null || lng === null) return null;
|
||||
const label = String(hit?.display_name || hit?.name || '').trim();
|
||||
if (!label) return null;
|
||||
const types = typesFromNominatim(hit);
|
||||
const viewport = viewportFromNominatim(hit);
|
||||
const canonical = String(hit?.name || '').trim();
|
||||
return {
|
||||
geometry: {
|
||||
location: { lat, lng },
|
||||
...(viewport ? { bounds: viewport, viewport } : {}),
|
||||
},
|
||||
formatted_address: label,
|
||||
types,
|
||||
address_components: canonical
|
||||
? [{ long_name: canonical, short_name: canonical, types: [...types] }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a `swLat,swLng|neLat,neLng` bias into Nominatim's
|
||||
* `viewbox=west,north,east,south`, or null when the string is unusable.
|
||||
*
|
||||
* Both edges are range-checked. A viewbox naming a longitude of 999 is not a
|
||||
* hint the upstream can use, and sending it is a malformed request rather than
|
||||
* a soft one.
|
||||
*
|
||||
* @param {string} bounds
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function nominatimViewboxFromBounds(bounds) {
|
||||
const match = String(bounds || '')
|
||||
.trim()
|
||||
.match(
|
||||
/^(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\|(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)$/,
|
||||
);
|
||||
if (!match) return null;
|
||||
const swLat = nominatimLatitude(match[1]);
|
||||
const swLng = nominatimLongitude(match[2]);
|
||||
const neLat = nominatimLatitude(match[3]);
|
||||
const neLng = nominatimLongitude(match[4]);
|
||||
if ([swLat, swLng, neLat, neLng].some((value) => value === null)) return null;
|
||||
if (swLat >= neLat) return null;
|
||||
return `${swLng},${neLat},${neLng},${swLat}`;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// NOMINATIM RESULT MAPPING — what counts as a usable hit.
|
||||
//
|
||||
// Every numeric field arrives as a string and may be absent or nonsense, and
|
||||
// `Number(null)` is 0: a hit with no coordinates read as (0, 0) would fly the
|
||||
// camera to the Atlantic and look like a successful search. These cases pin
|
||||
// every field that must be refused rather than coerced.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
nominatimLatitude,
|
||||
nominatimLongitude,
|
||||
nominatimToGeocodeResult,
|
||||
nominatimViewboxFromBounds,
|
||||
typesFromNominatim,
|
||||
viewportFromNominatim,
|
||||
} from './nominatimGeocode.js';
|
||||
|
||||
test('a coordinate field is a number in range, or nothing', () => {
|
||||
assert.equal(nominatimLatitude('43.1731'), 43.1731);
|
||||
assert.equal(nominatimLatitude(-33.8568), -33.8568);
|
||||
assert.equal(nominatimLatitude('90'), 90);
|
||||
assert.equal(nominatimLongitude('-180'), -180);
|
||||
|
||||
for (const value of [
|
||||
null,
|
||||
undefined,
|
||||
'',
|
||||
' ',
|
||||
'abc',
|
||||
NaN,
|
||||
Infinity,
|
||||
true,
|
||||
false,
|
||||
[],
|
||||
['43'],
|
||||
{},
|
||||
'91',
|
||||
'-90.5',
|
||||
])
|
||||
assert.equal(
|
||||
nominatimLatitude(value),
|
||||
null,
|
||||
`${JSON.stringify(value)} is not a latitude`,
|
||||
);
|
||||
|
||||
for (const value of [null, '', '181', '-180.5', true, []])
|
||||
assert.equal(
|
||||
nominatimLongitude(value),
|
||||
null,
|
||||
`${JSON.stringify(value)} is not a longitude`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a hit without real coordinates produces no result at all', () => {
|
||||
for (const hit of [
|
||||
null,
|
||||
undefined,
|
||||
{},
|
||||
{ lat: null, lon: null, display_name: 'Nowhere' },
|
||||
{ lat: '', lon: '', display_name: 'Nowhere' },
|
||||
{ lat: '43.17', lon: null, display_name: 'Half a place' },
|
||||
{ lat: null, lon: '-79.03', display_name: 'Half a place' },
|
||||
{ lat: '91', lon: '0', display_name: 'Off the pole' },
|
||||
{ lat: '0', lon: '999', display_name: 'Off the world' },
|
||||
// Coordinates but no name: nothing to show the operator.
|
||||
{ lat: '43.17', lon: '-79.03' },
|
||||
{ lat: '43.17', lon: '-79.03', display_name: ' ' },
|
||||
])
|
||||
assert.equal(
|
||||
nominatimToGeocodeResult(hit),
|
||||
null,
|
||||
`${JSON.stringify(hit)} must not become a result`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a real hit carries its point, label, types and box', () => {
|
||||
const result = nominatimToGeocodeResult({
|
||||
lat: '43.1731',
|
||||
lon: '-79.0384',
|
||||
display_name: 'Niagara Falls, Ontario, Canada',
|
||||
name: 'Niagara Falls',
|
||||
addresstype: 'city',
|
||||
boundingbox: ['43.0500', '43.1500', '-79.1500', '-79.0000'],
|
||||
});
|
||||
assert.deepEqual(result.geometry.location, { lat: 43.1731, lng: -79.0384 });
|
||||
assert.equal(result.formatted_address, 'Niagara Falls, Ontario, Canada');
|
||||
assert.deepEqual(result.types, ['locality', 'political']);
|
||||
assert.deepEqual(result.geometry.bounds, {
|
||||
southwest: { lat: 43.05, lng: -79.15 },
|
||||
northeast: { lat: 43.15, lng: -79 },
|
||||
});
|
||||
assert.equal(result.address_components[0].long_name, 'Niagara Falls');
|
||||
});
|
||||
|
||||
test('a box with an impossible or inverted edge is dropped, not framed', () => {
|
||||
for (const boundingbox of [
|
||||
undefined,
|
||||
[],
|
||||
['43.05', '43.15', '-79.15'],
|
||||
['43.05', '43.15', '-79.15', '999'],
|
||||
['43.05', '43.15', '-999', '-79.0'],
|
||||
['-91', '43.15', '-79.15', '-79.0'],
|
||||
['43.05', '91', '-79.15', '-79.0'],
|
||||
[null, '43.15', '-79.15', '-79.0'],
|
||||
['', '43.15', '-79.15', '-79.0'],
|
||||
// South above north is not a box.
|
||||
['43.15', '43.05', '-79.15', '-79.0'],
|
||||
]) {
|
||||
assert.equal(
|
||||
viewportFromNominatim({ boundingbox }),
|
||||
null,
|
||||
`${JSON.stringify(boundingbox)} must not become a viewport`,
|
||||
);
|
||||
const result = nominatimToGeocodeResult({
|
||||
lat: '43.1731',
|
||||
lon: '-79.0384',
|
||||
display_name: 'Somewhere',
|
||||
boundingbox,
|
||||
});
|
||||
assert.ok(result, 'the point itself still stands');
|
||||
assert.equal(
|
||||
result.geometry.bounds,
|
||||
undefined,
|
||||
'a place with no usable box is framed as a point',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('types fall back to a place rather than guessing a category', () => {
|
||||
assert.deepEqual(typesFromNominatim({ addresstype: 'country' }), [
|
||||
'country',
|
||||
'political',
|
||||
]);
|
||||
assert.deepEqual(typesFromNominatim({ class: 'highway', type: 'primary' }), [
|
||||
'route',
|
||||
]);
|
||||
assert.deepEqual(typesFromNominatim({}), [
|
||||
'point_of_interest',
|
||||
'establishment',
|
||||
]);
|
||||
assert.deepEqual(typesFromNominatim(null), [
|
||||
'point_of_interest',
|
||||
'establishment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a viewport hint is sent only when every edge is a real coordinate', () => {
|
||||
assert.equal(
|
||||
nominatimViewboxFromBounds('30.1,-97.95|30.52,-97.55'),
|
||||
'-97.95,30.52,-97.55,30.1',
|
||||
);
|
||||
|
||||
for (const bounds of [
|
||||
null,
|
||||
undefined,
|
||||
'',
|
||||
'not bounds',
|
||||
'30.1,-97.95',
|
||||
// Out of range on each edge in turn.
|
||||
'-91,-97.95|30.52,-97.55',
|
||||
'30.1,-999|30.52,-97.55',
|
||||
'30.1,-97.95|91,-97.55',
|
||||
'30.1,-97.95|30.52,999',
|
||||
// Inverted latitudes are not a box.
|
||||
'30.52,-97.95|30.1,-97.55',
|
||||
'30.1,-97.95|30.1,-97.55',
|
||||
])
|
||||
assert.equal(
|
||||
nominatimViewboxFromBounds(bounds),
|
||||
null,
|
||||
`${JSON.stringify(bounds)} must not become a viewbox`,
|
||||
);
|
||||
});
|
||||
+17
-2
@@ -8,8 +8,8 @@ import { createPresetGeocoder } from './presetGeocoder.js';
|
||||
|
||||
/**
|
||||
* Coordinates and bundled names first — both answer offline and with no key —
|
||||
* then Google when configured, then keyless Photon. Transport stays local to
|
||||
* setup.
|
||||
* then Google when configured, then keyless Photon, then the local Nominatim
|
||||
* route as a last resort. Transport stays local to setup.
|
||||
*
|
||||
* `presets` is the caller's bundled place data. It is passed in rather than
|
||||
* imported so this package keeps reading no application state; with none
|
||||
@@ -43,6 +43,21 @@ export function createDefaultPlaceSearch({
|
||||
},
|
||||
}),
|
||||
createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }),
|
||||
// Last resort: the local Nominatim route, which answers with no key when
|
||||
// neither of the two above did. It speaks the same result shape, so it
|
||||
// rides the existing Google adapter rather than needing its own.
|
||||
createGoogleGeocoder({
|
||||
request(query, { bias, signal }) {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (bias) params.set('bounds', bias);
|
||||
return fetchImpl(
|
||||
`${endpoints.nominatim || '/api/geocode'}?${params}`,
|
||||
{
|
||||
signal,
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
// THE LAST-RESORT PLACE SEARCH ROUTE — what it is allowed to send upstream.
|
||||
//
|
||||
// The public instance permits one request per second. One request per second
|
||||
// and an unbounded queue are incompatible: a burst of searches would keep the
|
||||
// upstream busy long after everyone who asked has given up. These cases pin the
|
||||
// queue bound, the shared answer for identical searches, the cancellation path,
|
||||
// and the chain order that only reaches here when nothing else answered.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
fetchNominatimSearch,
|
||||
geocodeProxy,
|
||||
NOMINATIM_MAX_PENDING,
|
||||
} from '../../server/providers/regional/place.js';
|
||||
import { fetchRegionalJson } from '../../server/providers/regional/http.js';
|
||||
import { localProviderPlugins } from '../../server/providers/local.js';
|
||||
import { createPlaceSearch, createGoogleGeocoder } from '../search/index.js';
|
||||
import { createPhotonGeocoder } from '../keylessGeocoder.js';
|
||||
|
||||
const HIT = [
|
||||
{
|
||||
lat: '43.1731',
|
||||
lon: '-79.0384',
|
||||
display_name: 'Niagara Falls, Ontario, Canada',
|
||||
name: 'Niagara Falls',
|
||||
addresstype: 'city',
|
||||
boundingbox: ['43.05', '43.15', '-79.15', '-79.0'],
|
||||
},
|
||||
];
|
||||
|
||||
/** Mount the geocode route the way the dev server does. */
|
||||
let clientCounter = 0;
|
||||
|
||||
function mountGeocode(
|
||||
install = (server) => geocodeProxy().configureServer(server),
|
||||
) {
|
||||
// A fresh client address per mount: the per-client rate limiter is module
|
||||
// state shared by every case in this file, and one case's burst must not
|
||||
// decide another case's outcome.
|
||||
clientCounter += 1;
|
||||
const remoteAddress = `10.0.0.${clientCounter % 250}`;
|
||||
const routes = new Map();
|
||||
install({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
return async function request(path) {
|
||||
const requested = new URL(path, 'http://localhost');
|
||||
const handler = routes.get('/api/geocode');
|
||||
if (!handler) throw new Error('no /api/geocode route');
|
||||
// connect strips the mount prefix before the handler sees the request.
|
||||
const rest =
|
||||
requested.pathname.slice('/api/geocode'.length) + requested.search;
|
||||
const req = {
|
||||
method: 'GET',
|
||||
url: rest[0] === '/' ? rest : `/${rest}`,
|
||||
headers: {},
|
||||
socket: { remoteAddress },
|
||||
on() {},
|
||||
};
|
||||
return await new Promise((resolve, reject) => {
|
||||
let status = 200;
|
||||
let headers = {};
|
||||
const res = {
|
||||
writableEnded: false,
|
||||
on() {},
|
||||
writeHead(code, sent) {
|
||||
status = code;
|
||||
headers = sent || {};
|
||||
return res;
|
||||
},
|
||||
end(body = '') {
|
||||
res.writableEnded = true;
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = body ? JSON.parse(String(body)) : null;
|
||||
} catch {
|
||||
parsed = String(body);
|
||||
}
|
||||
resolve({ status, headers, body: parsed });
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Answer the upstream from a stub, counting and optionally stalling calls. */
|
||||
async function withUpstream(respond, run) {
|
||||
const previous = globalThis.fetch;
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push({ url: String(url), options });
|
||||
return await respond(String(url), options, calls.length);
|
||||
};
|
||||
try {
|
||||
return await run(calls);
|
||||
} finally {
|
||||
globalThis.fetch = previous;
|
||||
}
|
||||
}
|
||||
|
||||
const jsonResponse = (value) =>
|
||||
new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
test('the route is part of the application on both dev and preview', () => {
|
||||
const plugin = localProviderPlugins().find(
|
||||
(entry) => entry.name === 'geocode-proxy',
|
||||
);
|
||||
assert.ok(plugin, 'the application must compose the place-search route');
|
||||
assert.equal(typeof plugin.configureServer, 'function');
|
||||
assert.equal(typeof plugin.configurePreviewServer, 'function');
|
||||
});
|
||||
|
||||
test('a search reaches the upstream identified, and the answer is cached', async () => {
|
||||
const request = mountGeocode();
|
||||
await withUpstream(
|
||||
async () => jsonResponse(HIT),
|
||||
async (calls) => {
|
||||
const query = `niagara-${Date.now()}`;
|
||||
const first = await request(`/api/geocode?q=${query}`);
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(first.body.status, 'OK');
|
||||
assert.deepEqual(first.body.results[0].geometry.location, {
|
||||
lat: 43.1731,
|
||||
lng: -79.0384,
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
const headers = calls[0].options.headers;
|
||||
assert.match(
|
||||
String(headers['User-Agent']),
|
||||
/gods-eye-view\/\d/,
|
||||
'the policy asks for a User-Agent identifying the application',
|
||||
);
|
||||
assert.ok(headers.Referer, 'a Referer is sent alongside it');
|
||||
assert.equal(
|
||||
calls[0].options.redirect,
|
||||
'error',
|
||||
'a fixed API endpoint may not be redirected elsewhere',
|
||||
);
|
||||
|
||||
// The policy asks that results be cached; a repeat costs no request.
|
||||
const repeat = await request(`/api/geocode?q=${query}`);
|
||||
assert.equal(repeat.status, 200);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
1,
|
||||
'the second identical search is served from cache',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('identical searches in flight share one upstream call', async () => {
|
||||
const request = mountGeocode();
|
||||
const query = `coalesce-${Date.now()}`;
|
||||
await withUpstream(
|
||||
async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
return jsonResponse(HIT);
|
||||
},
|
||||
async (calls) => {
|
||||
const answers = await Promise.all([
|
||||
request(`/api/geocode?q=${query}`),
|
||||
request(`/api/geocode?q=${query}`),
|
||||
request(`/api/geocode?q=${query}`),
|
||||
]);
|
||||
for (const answer of answers) assert.equal(answer.status, 200);
|
||||
assert.equal(calls.length, 1, 'one upstream call serves all three');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a burst past the queue bound is refused rather than queued for minutes', async () => {
|
||||
const request = mountGeocode();
|
||||
const stamp = Date.now();
|
||||
await withUpstream(
|
||||
async () => jsonResponse([]),
|
||||
async (calls) => {
|
||||
const answers = await Promise.all(
|
||||
Array.from({ length: 20 }, (_unused, index) =>
|
||||
request(`/api/geocode?q=burst-${stamp}-${index}`),
|
||||
),
|
||||
);
|
||||
const refused = answers.filter((answer) => answer.status === 429);
|
||||
assert.ok(
|
||||
refused.length > 0,
|
||||
'a burst this size must be refused somewhere',
|
||||
);
|
||||
for (const answer of refused)
|
||||
assert.equal(answer.headers['Retry-After'], '5');
|
||||
assert.ok(
|
||||
calls.length <= NOMINATIM_MAX_PENDING + 1,
|
||||
`the upstream saw ${calls.length} of 20 searches; the queue bound is ${NOMINATIM_MAX_PENDING}`,
|
||||
);
|
||||
assert.equal(
|
||||
answers.length,
|
||||
20,
|
||||
'every caller is answered, none is left hanging',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a query that is missing or too long never reaches the upstream', async () => {
|
||||
const request = mountGeocode();
|
||||
await withUpstream(
|
||||
async () => jsonResponse(HIT),
|
||||
async (calls) => {
|
||||
for (const path of [
|
||||
'/api/geocode',
|
||||
'/api/geocode?q=',
|
||||
'/api/geocode?q=%20%20',
|
||||
`/api/geocode?q=${'x'.repeat(201)}`,
|
||||
]) {
|
||||
const answer = await request(path);
|
||||
assert.equal(answer.status, 400, path);
|
||||
}
|
||||
assert.deepEqual(calls, []);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('an upstream failure is a service answer, not a crash', async () => {
|
||||
const request = mountGeocode();
|
||||
await withUpstream(
|
||||
async () => new Response('upstream down', { status: 502 }),
|
||||
async () => {
|
||||
const answer = await request(`/api/geocode?q=down-${Date.now()}`);
|
||||
assert.equal(answer.status, 503);
|
||||
assert.equal(answer.headers['Cache-Control'], 'no-store');
|
||||
assert.match(String(answer.body.error), /temporarily unavailable/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a search with no hit is a clean empty answer', async () => {
|
||||
const request = mountGeocode();
|
||||
await withUpstream(
|
||||
async () => jsonResponse([]),
|
||||
async () => {
|
||||
const answer = await request(`/api/geocode?q=nowhere-${Date.now()}`);
|
||||
assert.equal(answer.status, 200);
|
||||
assert.equal(answer.body.status, 'ZERO_RESULTS');
|
||||
assert.deepEqual(answer.body.results, []);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('the chain reaches this route only when Google and Photon do not answer', async () => {
|
||||
const asked = [];
|
||||
const request = mountGeocode();
|
||||
await withUpstream(
|
||||
async (url) => {
|
||||
if (url.includes('photon.komoot.io')) {
|
||||
asked.push('photon');
|
||||
return new Response('photon is down', { status: 503 });
|
||||
}
|
||||
asked.push('nominatim');
|
||||
return jsonResponse(HIT);
|
||||
},
|
||||
async () => {
|
||||
const search = createPlaceSearch({
|
||||
providers: [
|
||||
createGoogleGeocoder({
|
||||
request() {
|
||||
asked.push('google');
|
||||
// An unconfigured provider contributes no verdict.
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
createPhotonGeocoder({
|
||||
fetchImpl: (...args) => globalThis.fetch(...args),
|
||||
}),
|
||||
createGoogleGeocoder({
|
||||
request(query) {
|
||||
return request(
|
||||
`/api/geocode?q=${encodeURIComponent(query)}`,
|
||||
).then(
|
||||
(answer) =>
|
||||
new Response(JSON.stringify(answer.body), {
|
||||
status: answer.status,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const outcome = await search.geocode(`chain-${Date.now()}`);
|
||||
assert.deepEqual(
|
||||
asked,
|
||||
['google', 'photon', 'nominatim'],
|
||||
`Google first, then Photon, then this route (saw ${asked.join(' → ')})`,
|
||||
);
|
||||
assert.equal(outcome.place.lat, 43.1731);
|
||||
assert.equal(outcome.fallbackUsed, true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a search whose caller gave up is dropped before it costs a slot', async () => {
|
||||
const abandoned = new AbortController();
|
||||
abandoned.abort();
|
||||
await withUpstream(
|
||||
async () => jsonResponse(HIT),
|
||||
async (calls) => {
|
||||
await assert.rejects(
|
||||
fetchNominatimSearch(`cancelled-${Date.now()}`, null, {
|
||||
signal: abandoned.signal,
|
||||
}),
|
||||
(error) => error.code === 'NOMINATIM_ABANDONED',
|
||||
);
|
||||
assert.deepEqual(calls, [], 'a search nobody is waiting for is not sent');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('the upstream deadline covers the body, not just the headers', async () => {
|
||||
// A response whose headers arrive and whose body then never does. Before the
|
||||
// deadline covered the body, the abort timer was already cleared by the time
|
||||
// the first byte was due, so this read had no deadline at all.
|
||||
const stalled = () => {
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream({
|
||||
pull() {
|
||||
return new Promise(() => {});
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, { status: 200 }),
|
||||
wasCancelled: () => cancelled,
|
||||
};
|
||||
};
|
||||
|
||||
const previous = globalThis.fetch;
|
||||
const { response, wasCancelled } = stalled();
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
// The same signal must reach the body read, or aborting stops nothing.
|
||||
options.signal.addEventListener('abort', () => {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
});
|
||||
return response;
|
||||
};
|
||||
try {
|
||||
const started = Date.now();
|
||||
await assert.rejects(
|
||||
fetchRegionalJson('https://example.test/stalls', { timeoutMs: 300 }),
|
||||
'a stalled body must fail rather than hang',
|
||||
);
|
||||
const elapsed = Date.now() - started;
|
||||
assert.ok(elapsed < 5000, `gave up after ${elapsed} ms`);
|
||||
assert.ok(wasCancelled(), 'the abort reached the body read');
|
||||
} finally {
|
||||
globalThis.fetch = previous;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user