mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(search): compose Google and Photon through an explicit provider service
This commit is contained in:
commit
57d7f9fc23
30 files changed
+1621
-151
No files matched your search
@@ -0,0 +1,66 @@
|
||||
/** Normalize a Google forward-geocode result without leaking its address shape. */
|
||||
export function normalizeGooglePlace(result) {
|
||||
const lat = result?.geometry?.location?.lat;
|
||||
const lng = result?.geometry?.location?.lng;
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lng) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lng) > 180
|
||||
)
|
||||
return null;
|
||||
const types = Array.isArray(result.types) ? result.types : [];
|
||||
const resultTypes = new Set(types.map((type) => String(type).toLowerCase()));
|
||||
const components = Array.isArray(result.address_components)
|
||||
? result.address_components
|
||||
: [];
|
||||
// Address-only landmark results must retain the requested landmark identity.
|
||||
const canonical = components.find(
|
||||
(component) =>
|
||||
Array.isArray(component.types) &&
|
||||
component.types.some(
|
||||
(type) =>
|
||||
type !== 'political' && resultTypes.has(String(type).toLowerCase()),
|
||||
),
|
||||
);
|
||||
return {
|
||||
lat,
|
||||
lng,
|
||||
name: canonical?.long_name || '',
|
||||
label: result.formatted_address || '',
|
||||
types,
|
||||
viewport: result.geometry.bounds || result.geometry.viewport || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Construct Google geocoding with caller-owned transport and key selection. */
|
||||
export function createGoogleGeocoder({ request }) {
|
||||
return {
|
||||
async geocode(query, { bias = null, signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const response = await request(query, { bias, signal });
|
||||
signal?.throwIfAborted();
|
||||
// An unconfigured provider did not contribute a negative verdict.
|
||||
if (!response) return { place: null, answered: true };
|
||||
if (response.ok === false) return { place: null, answered: false };
|
||||
const data = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
data?.status === 'ZERO_RESULTS' &&
|
||||
Array.isArray(data.results) &&
|
||||
data.results.length === 0
|
||||
)
|
||||
return { place: null, answered: true };
|
||||
const place =
|
||||
data?.status === 'OK'
|
||||
? normalizeGooglePlace(data.results?.[0])
|
||||
: null;
|
||||
return { place, answered: Boolean(place) };
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
return { place: null, answered: false };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createPlaceSearch, unavailablePlaceSearch } from './placeSearch.js';
|
||||
export { createGoogleGeocoder, normalizeGooglePlace } from './google.js';
|
||||
export { createPhotonGeocoder } from '../keylessGeocoder.js';
|
||||
@@ -0,0 +1,65 @@
|
||||
/** A missing geocoder returns an unavailable outcome without making requests. */
|
||||
export const unavailablePlaceSearch = Object.freeze({
|
||||
async geocode(_query, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
return { place: null, answered: false };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Compose ordered geocoders into one bounded service. Each returns
|
||||
* { place: { lat, lng, name, label, types, viewport } | null, answered: boolean }.
|
||||
* Only definitive results are cached. Caller cancellation stops the whole chain.
|
||||
*/
|
||||
export function createPlaceSearch({
|
||||
providers,
|
||||
signal: lifetime,
|
||||
timeoutMs = 12_000,
|
||||
now = Date.now,
|
||||
}) {
|
||||
const cache = new Map();
|
||||
return {
|
||||
async geocode(query, { bias = null, signal } = {}) {
|
||||
const combined = AbortSignal.any(
|
||||
[lifetime, signal, AbortSignal.timeout(timeoutMs)].filter(Boolean),
|
||||
);
|
||||
combined.throwIfAborted();
|
||||
const text = String(query ?? '').trim();
|
||||
if (!text) return { place: null, answered: true };
|
||||
const key = `${bias || ''}\n${text.toLowerCase()}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached && cached.expires > now()) return cached.result;
|
||||
cache.delete(key);
|
||||
let result = { place: null, answered: providers.length > 0 };
|
||||
try {
|
||||
for (const [index, provider] of providers.entries()) {
|
||||
combined.throwIfAborted();
|
||||
const next = await provider.geocode(text, { bias, signal: combined });
|
||||
combined.throwIfAborted();
|
||||
result = {
|
||||
place: next.place,
|
||||
answered: Boolean(next.place) || (result.answered && next.answered),
|
||||
};
|
||||
if (result.place) {
|
||||
result.fallbackUsed = index > 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
lifetime?.throwIfAborted();
|
||||
signal?.throwIfAborted();
|
||||
if (!combined.aborted) throw error;
|
||||
return { place: null, answered: false };
|
||||
}
|
||||
combined.throwIfAborted();
|
||||
if (result.answered) {
|
||||
cache.set(key, {
|
||||
result,
|
||||
expires: now() + (result.place ? 300_000 : 30_000),
|
||||
});
|
||||
while (cache.size > 64) cache.delete(cache.keys().next().value);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPlaceSearch, createGoogleGeocoder, createPhotonGeocoder } from './index.js';
|
||||
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
|
||||
import { photonExtentToBounds } from '../keylessGeocoder.js';
|
||||
const feature = { geometry: { type: 'Point', coordinates: [105.85, 21.03] }, properties: { name: 'Hà Nội', type: 'city' } };
|
||||
const hit = () => Response.json({ features: [feature] });
|
||||
|
||||
test('malformed successful Photon responses remain retryable', async () => {
|
||||
for (const malformed of [{ error: 'temporary failure' }, { features: {} }, { features: [{ geometry: { coordinates: [null, 1] } }] }]) {
|
||||
let calls = 0;
|
||||
const provider = createPhotonGeocoder({ fetchImpl: async () => ++calls === 1 ? Response.json(malformed) : hit() });
|
||||
assert.deepEqual(await provider.geocode('Hanoi'), { place: null, answered: false });
|
||||
assert.equal((await provider.geocode('Hanoi')).place.name, 'Hà Nội');
|
||||
assert.equal(calls, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('standalone geocoding falls back after Google connection, JSON and refusal failures', async () => {
|
||||
for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) {
|
||||
const urls = [];
|
||||
const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => {
|
||||
urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit();
|
||||
} });
|
||||
const result = await service.geocode('Hanoi');
|
||||
assert.equal(result.place.name, 'Hà Nội');
|
||||
assert.equal(result.fallbackUsed, true);
|
||||
assert.equal(urls.length, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('a keyless service does not issue any Google request', async () => {
|
||||
const service = createStandalonePlaceSearch({ fetchImpl: async (url) => {
|
||||
assert.equal(new URL(url).hostname, 'photon.komoot.io'); return hit();
|
||||
} });
|
||||
assert.equal((await service.geocode('Hanoi')).place.lat, 21.03);
|
||||
});
|
||||
|
||||
test('cancellation before lookup makes no request', async () => {
|
||||
const controller = new AbortController(); controller.abort();
|
||||
let calls = 0;
|
||||
const provider = createPhotonGeocoder({ fetchImpl: async () => { calls++; return hit(); } });
|
||||
await assert.rejects(provider.geocode('Hanoi', { signal: controller.signal }), { name: 'AbortError' });
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('cancellation stops Photon retries and does not cache a late response', async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
const provider = createPhotonGeocoder({ fetchImpl: async () => {
|
||||
calls++; if (calls === 1) controller.abort(); return hit();
|
||||
} });
|
||||
await assert.rejects(provider.geocode('Hanoi', { bias: '1,1|2,2', signal: controller.signal }), { name: 'AbortError' });
|
||||
assert.equal(calls, 1);
|
||||
assert.equal((await provider.geocode('Hanoi')).place.name, 'Hà Nội');
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('a Google request cancelled by its caller never starts the fallback', async () => {
|
||||
const controller = new AbortController(); let fallbackCalls = 0;
|
||||
const service = createPlaceSearch({ providers: [
|
||||
createGoogleGeocoder({ request: async () => { controller.abort(); return Response.json({ status: 'ZERO_RESULTS', results: [] }); } }),
|
||||
{ geocode: async () => { fallbackCalls++; return { place: null, answered: true }; } },
|
||||
] });
|
||||
await assert.rejects(service.geocode('missing', { signal: controller.signal }), { name: 'AbortError' });
|
||||
assert.equal(fallbackCalls, 0);
|
||||
});
|
||||
|
||||
test('service lifetime cancellation also rejects cache hits', async () => {
|
||||
const controller = new AbortController();
|
||||
const service = createPlaceSearch({ signal: controller.signal, providers: [createPhotonGeocoder({ fetchImpl: async () => hit() })] });
|
||||
assert.ok((await service.geocode('Hanoi')).place);
|
||||
controller.abort();
|
||||
await assert.rejects(service.geocode('Hanoi'), { name: 'AbortError' });
|
||||
});
|
||||
|
||||
test('one cancelled caller cannot cancel another caller of the same service', async () => {
|
||||
const controller = new AbortController();
|
||||
const service = createPlaceSearch({ providers: [{ async geocode(_query, { signal }) {
|
||||
await new Promise((resolve) => setImmediate(resolve)); signal.throwIfAborted();
|
||||
return { place: { lat: 1, lng: 2 }, answered: true };
|
||||
} }] });
|
||||
const first = service.geocode('same', { signal: controller.signal });
|
||||
const second = service.geocode('same');
|
||||
controller.abort();
|
||||
await assert.rejects(first, { name: 'AbortError' });
|
||||
assert.equal((await second).place.lat, 1);
|
||||
});
|
||||
|
||||
test('definitive misses expire while outage outcomes are never cached', async () => {
|
||||
let now = 0, calls = 0, answered = true;
|
||||
const service = createPlaceSearch({ now: () => now, providers: [{ async geocode() { calls++; return { place: null, answered }; } }] });
|
||||
await service.geocode('miss'); await service.geocode('miss'); assert.equal(calls, 1);
|
||||
now = 30_001; answered = false;
|
||||
await service.geocode('miss'); await service.geocode('miss'); assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('wrapped and nonnumeric Photon bounds are omitted', () => {
|
||||
assert.equal(photonExtentToBounds([170, 10, -170, -10]), null);
|
||||
assert.equal(photonExtentToBounds([null, 10, 20, -10]), null);
|
||||
});
|
||||
Reference in new issue
Block a user