refactor: add configurable Nominatim adapter and portable source helpers

This commit is contained in:
Sameh Khamis committed 2026-09-14 22:08:16 -07:00
1 parent 2b1092bf89
commit 66eb6d6a7c
14 files changed
+646 -152

No files matched your search

+58 -35
View File
@@ -1,3 +1,4 @@
import { createNominatimProvider } from './nominatim.js';
import { createGeospatialServices } from './geospatial.js';
import { createHttpGeospatialProvider } from './http.js';
import { createPlaceSearch } from './placeSearch.js';
@@ -22,54 +23,76 @@ export function createDefaultPlaceSearch({
endpoints = {},
providers = {},
presets = null,
geocoding = null,
} = {}) {
if (geocoding && geocoding.provider !== 'nominatim')
throw new TypeError('Unsupported geocoding provider');
const selected = geocoding
? createNominatimProvider({ ...geocoding, fetchImpl })
: null;
if (selected && !selected.geocode)
throw new TypeError('Nominatim searchEndpoint is required');
const forward = createPlaceSearch({
signal,
providers: providers.geocode || [
createCoordinateGeocoder(),
...(presets ? [createPresetGeocoder({ presets })] : []),
createGoogleGeocoder({
request(query, { bias, signal }) {
const key = resolveApiKey?.();
if (!key) return null;
const url = new URL(
endpoints.geocode ||
'https://maps.googleapis.com/maps/api/geocode/json',
);
url.searchParams.set('address', query);
url.searchParams.set('key', key);
if (bias) url.searchParams.set('bounds', bias);
return fetchImpl(url.toString(), { signal });
},
}),
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,
},
);
},
}),
...(selected
? [selected]
: [
createGoogleGeocoder({
request(query, { bias, signal }) {
const key = resolveApiKey?.();
if (!key) return null;
const url = new URL(
endpoints.geocode ||
'https://maps.googleapis.com/maps/api/geocode/json',
);
url.searchParams.set('address', query);
url.searchParams.set('key', key);
if (bias) url.searchParams.set('bounds', bias);
return fetchImpl(url.toString(), { signal });
},
}),
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,
},
);
},
}),
]),
],
});
const operations = createHttpGeospatialProvider({
fetchImpl,
resolveApiKey,
endpoints,
});
return {
...forward,
...createGeospatialServices({
signal,
providers: {
...createHttpGeospatialProvider({
fetchImpl,
resolveApiKey,
endpoints,
}),
...operations,
...(selected
? {
reverseGeocode: selected.reverseGeocode,
attribution: {
...operations.attribution,
...selected.attribution,
},
}
: {}),
...providers,
},
}),
+6
View File
@@ -13,3 +13,9 @@ export {
parseCoordinateQuery,
formatCoordinateLabel,
} from './coordinateParser.js';
export {
createNominatimProvider,
createNominatimClient,
normalizeNominatimResult,
normalizeNominatimReverse,
} from './nominatim.js';
+158
View File
@@ -0,0 +1,158 @@
import {
nominatimToGeocodeResult,
nominatimViewboxFromBounds,
} from '../nominatimGeocode.js';
import { normalizeGooglePlace } from './google.js';
import { validCoordinate } from './geospatial.js';
import { normalizeRegionalPlace } from '../data/regionalModel.js';
import { readResponseJsonCapped } from '../sources/httpBody.js';
/** Retain the established framing/type rules for JSON and JSONv2 results. */
export function normalizeNominatimResult(hit) {
return nominatimToGeocodeResult(
hit && { ...hit, class: hit.class || hit.category },
);
}
export function normalizeNominatimReverse(hit) {
const result = normalizeNominatimResult(hit);
const region = normalizeRegionalPlace(hit);
if (!result || !region) return null;
const clean = (value) =>
String(value || '')
.replace(/[\x00-\x1f\x7f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
const street = clean(hit.address?.road || hit.address?.pedestrian);
return {
formattedAddress: result.formatted_address,
locality: region.locality,
region: region.region,
country: region.country,
types: result.types,
labels: [clean(result.formatted_address)].filter(Boolean),
streetLabels: street ? [street] : [],
};
}
/**
* Nominatim JSONv2 transport for explicitly configured endpoints. Callers own
* scheduling and request headers; this does not select a public instance.
*/
export function createNominatimClient({
searchEndpoint,
reverseEndpoint,
fetchImpl = (...args) => fetch(...args),
} = {}) {
for (const endpoint of [searchEndpoint, reverseEndpoint]) {
if (endpoint == null) continue;
if (typeof endpoint !== 'string' || !endpoint || /[?#]/.test(endpoint))
throw new TypeError('A query-free Nominatim endpoint is required');
const url = new URL(endpoint, 'http://localhost');
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
!(
(endpoint.startsWith('/') && !endpoint.startsWith('//')) ||
/^https?:\/\//.test(endpoint)
)
)
throw new TypeError('Invalid Nominatim endpoint');
}
async function request(endpoint, params, signal) {
signal?.throwIfAborted();
const response = await fetchImpl(
`${endpoint}?${new URLSearchParams({
format: 'jsonv2',
addressdetails: '1',
...params,
})}`,
{ signal, redirect: 'error' },
);
signal?.throwIfAborted();
if (!response.ok) {
await response.body?.cancel?.().catch(() => {});
throw new Error('Geocoding service unavailable');
}
return readResponseJsonCapped(response, 512 * 1024, signal);
}
return {
...(searchEndpoint
? {
async search(query, { bias, signal } = {}) {
const viewbox = nominatimViewboxFromBounds(bias);
const rows = await request(
searchEndpoint,
{
q: query,
limit: '1',
...(viewbox ? { viewbox, bounded: '0' } : {}),
},
signal,
);
if (!Array.isArray(rows))
throw new Error('Invalid geocoding response');
if (rows.length && !normalizeNominatimResult(rows[0]))
throw new Error('Invalid geocoding result');
return rows;
},
}
: {}),
...(reverseEndpoint
? {
async reverse(latitude, longitude, { signal } = {}) {
if (!validCoordinate([longitude, latitude])) return null;
const row = await request(
reverseEndpoint,
{ lat: latitude, lon: longitude },
signal,
);
// A successful, explicit no-result response is distinct from malformed data.
if (row?.error === 'Unable to geocode') return null;
if (!normalizeNominatimReverse(row))
throw new Error('Invalid reverse geocoding result');
return row;
},
}
: {}),
};
}
/** Forward and reverse operations only; feature queries and routing are separate. */
export function createNominatimProvider(options = {}) {
const client = createNominatimClient(options);
return {
attribution: {
geocode: 'OpenStreetMap / Nominatim',
reverseGeocode: 'OpenStreetMap / Nominatim',
},
...(client.search
? {
async geocode(query, options = {}) {
try {
const rows = await client.search(query, options);
return {
place: rows.length
? normalizeGooglePlace(normalizeNominatimResult(rows[0]))
: null,
answered: true,
};
} catch {
options.signal?.throwIfAborted();
return { place: null, answered: false };
}
},
}
: {}),
...(client.reverse
? {
async reverseGeocode(latitude, longitude, options) {
const row = await client.reverse(latitude, longitude, options);
return row ? normalizeNominatimReverse(row) : null;
},
}
: {}),
};
}
+194
View File
@@ -0,0 +1,194 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNominatimProvider } from './nominatim.js';
import { createDefaultPlaceSearch } from './defaults.js';
import { createPlaceSearch } from './placeSearch.js';
import { createGeospatialServices } from './geospatial.js';
import { readResponseTextCapped } from '../sources/httpBody.js';
const hit = {
lat: '51.5',
lon: '-0.12',
name: 'London',
display_name: 'London, England',
category: 'place',
type: 'city',
addresstype: 'city',
boundingbox: ['51', '52', '-1', '0'],
address: {
city: 'London',
state: 'England',
country: 'United Kingdom',
road: 'Whitehall',
},
};
const endpoints = {
searchEndpoint: 'https://places.example/search',
reverseEndpoint: 'https://places.example/reverse',
};
test('Nominatim normalizes framing and reverse context through independent configured endpoints', async () => {
const calls = [];
const adapter = createNominatimProvider({
...endpoints,
fetchImpl: async (url, init) => {
calls.push({ url: new URL(url), init });
return Response.json(url.includes('/search?') ? [hit] : hit);
},
});
const answer = await adapter.geocode('London', { bias: '50,-2|53,1' });
assert.equal(answer.answered, true);
assert.equal(answer.place.name, 'London');
assert.deepEqual(answer.place.types, ['locality', 'political']);
assert.deepEqual(answer.place.viewport, {
southwest: { lat: 51, lng: -1 },
northeast: { lat: 52, lng: 0 },
});
const reverse = await adapter.reverseGeocode(51.5, -0.12);
assert.equal(reverse.locality, 'London');
assert.deepEqual(reverse.streetLabels, ['Whitehall']);
assert.equal(calls[0].url.searchParams.get('viewbox'), '-2,53,1,50');
assert.equal(calls[0].url.searchParams.get('bounded'), '0');
assert.equal(calls[0].url.searchParams.get('format'), 'jsonv2');
assert.equal(calls[1].url.pathname, '/reverse');
assert.equal(calls[1].init.redirect, 'error');
assert.equal(calls[1].init.headers, undefined);
assert.deepEqual(
createGeospatialServices({ providers: adapter }).capabilities,
{ reverseGeocode: true, textSearch: false, nearby: false, route: false },
);
});
test('explicit Nominatim choice retains offline coordinates and bypasses the default geocoder chain', async () => {
const calls = [];
const search = createDefaultPlaceSearch({
geocoding: { provider: 'nominatim', ...endpoints },
resolveApiKey: () => {
throw new Error('must not request a Google key');
},
fetchImpl: async (url) => {
calls.push(url);
return Response.json(url.includes('/search?') ? [hit] : hit);
},
});
assert.equal((await search.geocode('London')).place.name, 'London');
await search.reverseGeocode(51.5, -0.12);
assert.equal(calls.length, 2);
assert(calls.every((url) => new URL(url).hostname === 'places.example'));
assert.equal(search.attribution.reverseGeocode, 'OpenStreetMap / Nominatim');
assert.equal(search.attribution.route, 'OpenStreetMap / OSRM');
await search.geocode('51.5, -0.12');
assert.equal(calls.length, 2);
assert.throws(() =>
createDefaultPlaceSearch({ geocoding: { provider: 'unknown' } }),
);
assert.throws(() =>
createDefaultPlaceSearch({ geocoding: { provider: 'nominatim' } }),
);
});
test('outages and malformed answers are retryable; only an explicit empty array is a cached miss', async () => {
for (const makeResponse of [
() => Response.json({}, { status: 429 }),
() => Response.json({ error: 'bad' }),
() => Response.json([{ ...hit, lat: '' }]),
() => new Response('{'),
() =>
Response.json([hit], { headers: { 'content-length': String(600000) } }),
]) {
let count = 0;
const adapter = createNominatimProvider({
...endpoints,
fetchImpl: async () => {
count++;
return makeResponse();
},
});
const service = createPlaceSearch({ providers: [adapter] });
assert.equal((await service.geocode('missing')).place, null);
assert.equal((await service.geocode('missing')).place, null);
assert.equal(count, 2);
}
let count = 0;
const adapter = createNominatimProvider({
...endpoints,
fetchImpl: async () => {
count++;
return Response.json([]);
},
});
const service = createPlaceSearch({ providers: [adapter] });
await service.geocode('missing');
await service.geocode('missing');
assert.equal(count, 1);
});
test('aborting a pending Nominatim body cancels its stream and rejects instead of caching a miss', async () => {
const controller = new AbortController();
let cancelled = false;
const adapter = createNominatimProvider({
...endpoints,
fetchImpl: async () =>
new Response(
new ReadableStream({
start() {
setTimeout(() => controller.abort(), 10);
},
cancel() {
cancelled = true;
},
}),
),
});
await assert.rejects(
adapter.geocode('London', { signal: controller.signal }),
{ name: 'AbortError' },
);
assert.equal(cancelled, true);
});
test('portable response cap counts UTF-8 bytes and cancels oversized streams', async () => {
await assert.rejects(
readResponseTextCapped(
{ headers: new Headers(), text: async () => 'éé' },
3,
),
{ code: 'RESPONSE_TOO_LARGE' },
);
let cancelled = false;
const response = new Response(
new ReadableStream({
start(c) {
c.enqueue(new Uint8Array(4));
},
cancel() {
cancelled = true;
},
}),
);
await assert.rejects(readResponseTextCapped(response, 3), {
code: 'RESPONSE_TOO_LARGE',
});
assert.equal(cancelled, true);
});
test('unconfigured operations stay absent and invalid coordinates do not make requests', async () => {
assert.equal(createNominatimProvider().geocode, undefined);
let calls = 0;
const adapter = createNominatimProvider({
...endpoints,
fetchImpl: async () => {
calls++;
return Response.json(hit);
},
});
assert.equal(await adapter.reverseGeocode(100, 0), null);
assert.equal(calls, 0);
for (const searchEndpoint of [
'https://u:[email protected]/search',
'https://example.com/search?key=x',
'//example.com/search',
'file:///search',
])
assert.throws(() => createNominatimProvider({ searchEndpoint }));
});