mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor: add configurable Nominatim adapter and portable source helpers
This commit is contained in:
1 parent
2b1092bf89
commit
66eb6d6a7c
14 files changed
+646
-152
No files matched your search
+58
-35
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -13,3 +13,9 @@ export {
|
||||
parseCoordinateQuery,
|
||||
formatCoordinateLabel,
|
||||
} from './coordinateParser.js';
|
||||
export {
|
||||
createNominatimProvider,
|
||||
createNominatimClient,
|
||||
normalizeNominatimResult,
|
||||
normalizeNominatimReverse,
|
||||
} from './nominatim.js';
|
||||
@@ -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;
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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 }));
|
||||
});
|
||||
Reference in new issue
Block a user