Skip to content

Commit c1e868e

Browse files
committed
fix(geocoder): report a Photon URL left on the Nominatim provider
Reproduced against a live deployment. A webhook that inherits its URL from Poracle and sets no geocoderProvider returned a single blank result, with nothing in the log to explain it. node-geocoder never gets far enough to receive a FeatureCollection, so the previous check could not fire. It calls /search, which Photon does not serve, and _forceParams puts format and addressdetails on every request, which Photon rejects outright. A live instance answers: GET /search?q=..&format=json&addressdetails=1 404 {"title":"Endpoint GET /search not found","status":404,...} GET /reverse?lat=..&lon=..&format=json&addressdetails=1 400 {"message":"Unknown query parameter 'format'. Allowed parameters are: [include, debug, dedupe, ...]"} node-geocoder ignores the status and parses the body regardless, so both reach _formatResult as an object with no address and format into an empty string. Both shapes are now recognised, and the error names the setting that fixes it. The regression server enforces Photon's actual contract rather than accepting anything: it serves /api and /reverse only, answers 404 elsewhere with Javalin's body, and rejects any parameter outside the allow list. Separately, locality reached no consumer. node-geocoder emits `neighbourhood`, the GraphQL schema exposes `neighborhood` and formatter() templates on `neighborhoods`, so the value was invisible to raw clients and to configured address formats alike. Both providers now carry the alias and formatter accepts either spelling, which also makes the field work for Nominatim deployments, where it had been dead for the same reason.
1 parent 0e168b6 commit c1e868e

3 files changed

Lines changed: 204 additions & 17 deletions

File tree

server/src/services/geocoder.js

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const { photonGeocoder } = require('./photonGeocoder')
1212
function formatter(addressFormat, result) {
1313
return addressFormat
1414
.replace(
15-
/{{(streetNumber|streetName|city|state|country|zipcode|latitude|longitude|countryCode|neighborhoods|suburb|town|village)}}/g,
15+
/{{(streetNumber|streetName|city|state|country|zipcode|latitude|longitude|countryCode|neighborhoods|neighborhood|neighbourhood|suburb|town|village)}}/g,
1616
(a, b) => result[b] || '',
1717
)
1818
.trim()
@@ -38,9 +38,33 @@ function formatter(addressFormat, result) {
3838
*/
3939
function assertNominatimResponse(results, url) {
4040
const raw = results?.raw
41-
if (raw && !Array.isArray(raw) && raw.type === 'FeatureCollection') {
41+
if (!raw || Array.isArray(raw) || typeof raw !== 'object') return
42+
43+
// Photon rarely gets far enough to answer with GeoJSON here, because
44+
// node-geocoder asks for routes and parameters it does not serve. Verified
45+
// against a live instance:
46+
//
47+
// GET /search?q=..&format=json&addressdetails=1
48+
// 404 {"title":"Endpoint GET /search not found","status":404,...}
49+
// Photon serves /api, not /search.
50+
//
51+
// GET /reverse?lat=..&lon=..&format=json&addressdetails=1
52+
// 400 {"message":"Unknown query parameter 'format'. Allowed parameters
53+
// are: [include, debug, dedupe, ...]"}
54+
// Photon rejects anything outside its allow list, and format and
55+
// addressdetails are forced onto every request by node-geocoder.
56+
//
57+
// node-geocoder ignores the status and parses the body regardless, so both
58+
// arrive here as an object with no address and format into a blank result.
59+
const isPhoton =
60+
raw.type === 'FeatureCollection' ||
61+
(typeof raw.title === 'string' && typeof raw.status === 'number') ||
62+
(typeof raw.message === 'string' &&
63+
raw.message.includes('Unknown query parameter'))
64+
65+
if (isPhoton) {
4266
throw new Error(
43-
`${url} answered with GeoJSON, which is Photon's format rather than Nominatim's. Set "geocoderProvider": "photon" on this webhook, or point the URL at a Nominatim instance.`,
67+
`${url} answered as a Photon instance rather than a Nominatim one. Set "geocoderProvider": "photon" on this webhook, or point the URL at a Nominatim instance.`,
4468
)
4569
}
4670
}
@@ -57,12 +81,20 @@ async function nominatimGeocoder(url, search, isReverse) {
5781
osmServer: url,
5882
timeout: 5000,
5983
})
60-
stockGeocoder._geocoder._formatResult = ((original) => (result) => ({
61-
...original(result),
62-
suburb: result.address?.suburb || '',
63-
town: result.address?.town || '',
64-
village: result.address?.village || '',
65-
}))(stockGeocoder._geocoder._formatResult)
84+
stockGeocoder._geocoder._formatResult = ((original) => (result) => {
85+
const formatted = original(result)
86+
return {
87+
...formatted,
88+
suburb: result.address?.suburb || '',
89+
town: result.address?.town || '',
90+
village: result.address?.village || '',
91+
// node-geocoder emits the British spelling. The GraphQL schema exposes
92+
// `neighborhood` and formatter() templates on `neighborhoods`, so the
93+
// value reached neither consumer. Carrying the alias is what makes it
94+
// visible without changing what node-geocoder itself produces.
95+
neighborhood: formatted.neighbourhood || '',
96+
}
97+
})(stockGeocoder._geocoder._formatResult)
6698
// Awaited rather than returned so the shape check runs here. A throw inside
6799
// _formatResult would not reach geocoder()'s catch at all: node-geocoder
68100
// resolves through bluebird's asCallback, so it surfaces as an uncaught
@@ -117,4 +149,4 @@ async function geocoder(nominatimUrl, search, reverse, format, provider) {
117149
}
118150
}
119151

120-
module.exports = { geocoder, formatter }
152+
module.exports = { geocoder, formatter, nominatimGeocoder }

server/src/services/photonGeocoder.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,11 @@ function formatPhotonFeature(feature) {
247247
// same building, Photon's locality and district carry the values Nominatim
248248
// returns as quarter and suburb.
249249
neighbourhood: properties.locality || '',
250+
// The same value under the spelling the GraphQL schema and formatter use.
251+
// Geocoder.neighborhood is American and formatter() templates on
252+
// neighborhoods, while node-geocoder emits neighbourhood, so without the
253+
// alias the mapped value reaches no consumer at all.
254+
neighborhood: properties.locality || '',
250255
suburb: properties.district || '',
251256
town: town || '',
252257
village: village || '',

server/test/geocoder.test.js

Lines changed: 157 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ const NodeGeocoder = require('node-geocoder')
66
const http = require('node:http')
77

88
const { PoracleAPI } = require('../src/services/Poracle')
9-
const { geocoder } = require('../src/services/geocoder')
9+
const {
10+
formatter,
11+
geocoder,
12+
nominatimGeocoder,
13+
} = require('../src/services/geocoder')
1014
const {
1115
formatPhotonFeature,
1216
joinComponents,
@@ -59,6 +63,7 @@ test('maps a Photon city onto the geocoder entry shape', () => {
5963
streetNumber: undefined,
6064
countryCode: 'US',
6165
neighbourhood: '',
66+
neighborhood: '',
6267
suburb: '',
6368
town: '',
6469
village: '',
@@ -374,12 +379,18 @@ test('the Photon path emits the same keys as the Nominatim path', () => {
374379
osmServer: 'http://127.0.0.1:0',
375380
timeout: 5000,
376381
})
377-
stockGeocoder._geocoder._formatResult = ((original) => (result) => ({
378-
...original(result),
379-
suburb: result.address.suburb || '',
380-
town: result.address.town || '',
381-
village: result.address.village || '',
382-
}))(stockGeocoder._geocoder._formatResult.bind(stockGeocoder._geocoder))
382+
// Mirrors the patch in geocoder.js, alias included. The duplication is the
383+
// point: if the two drift apart, this test stops proving parity.
384+
stockGeocoder._geocoder._formatResult = ((original) => (result) => {
385+
const formatted = original(result)
386+
return {
387+
...formatted,
388+
suburb: result.address?.suburb || '',
389+
town: result.address?.town || '',
390+
village: result.address?.village || '',
391+
neighborhood: formatted.neighbourhood || '',
392+
}
393+
})(stockGeocoder._geocoder._formatResult.bind(stockGeocoder._geocoder))
383394

384395
// The same place as STREET_ADDRESS, in Nominatim's response shape.
385396
const fromNominatim = stockGeocoder._geocoder._formatResult({
@@ -808,3 +819,142 @@ test('a transient upstream failure keeps the shape the schema expects', async ()
808819
})
809820
}
810821
})
822+
823+
// Behaves like a real Photon instance rather than accepting anything: it serves
824+
// /api and /reverse only, and rejects any parameter outside its allow list.
825+
// node-geocoder forces format and addressdetails onto every request and calls
826+
// /search, so a Photon URL left on the Nominatim provider never reaches a
827+
// FeatureCollection at all. Captured from a live instance.
828+
const servePhoton = async () => {
829+
const ALLOWED = new Set([
830+
'include',
831+
'debug',
832+
'dedupe',
833+
'query_string_filter',
834+
'lon',
835+
'layer',
836+
'limit',
837+
'osm_tag',
838+
'distance_sort',
839+
'geometry',
840+
'exclude',
841+
'lang',
842+
'radius',
843+
'lat',
844+
'q',
845+
])
846+
const server = http.createServer((req, res) => {
847+
const url = new URL(req.url, 'http://127.0.0.1')
848+
const json = (code, body) => {
849+
res.writeHead(code, { 'Content-Type': 'application/json' })
850+
res.end(JSON.stringify(body))
851+
}
852+
if (url.pathname !== '/api' && url.pathname !== '/reverse') {
853+
json(404, {
854+
title: `Endpoint ${req.method} ${url.pathname} not found`,
855+
status: 404,
856+
type: 'https://javalin.io/documentation#endpointnotfound',
857+
details: {},
858+
})
859+
return
860+
}
861+
const bad = [...url.searchParams.keys()].find((k) => !ALLOWED.has(k))
862+
if (bad) {
863+
json(400, {
864+
message: `Unknown query parameter '${bad}'. Allowed parameters are: [${[...ALLOWED].join(', ')}]`,
865+
})
866+
return
867+
}
868+
json(200, { type: 'FeatureCollection', features: [] })
869+
})
870+
await new Promise((resolve) => {
871+
server.listen(0, '127.0.0.1', resolve)
872+
})
873+
return {
874+
url: `http://127.0.0.1:${server.address().port}`,
875+
close: () =>
876+
new Promise((resolve) => {
877+
server.close(resolve)
878+
}),
879+
}
880+
}
881+
882+
// The exact production misconfiguration: a Photon URL inherited from Poracle
883+
// with no geocoderProvider set. It used to format into a single blank result.
884+
test('a Photon URL on the Nominatim provider is reported on the forward path', async () => {
885+
const server = await servePhoton()
886+
try {
887+
await assert.rejects(
888+
() => nominatimGeocoder(server.url, 'Denver', false),
889+
/answered as a Photon instance/,
890+
)
891+
} finally {
892+
await server.close()
893+
}
894+
})
895+
896+
test('a Photon URL on the Nominatim provider is reported on the reverse path', async () => {
897+
const server = await servePhoton()
898+
try {
899+
await assert.rejects(
900+
() =>
901+
nominatimGeocoder(server.url, { lat: 39.7392, lon: -104.9903 }, true),
902+
/answered as a Photon instance/,
903+
)
904+
} finally {
905+
await server.close()
906+
}
907+
})
908+
909+
// The value has to reach the two consumers that actually exist: the GraphQL
910+
// field is `neighborhood` and formatter() templates on `neighborhoods`, while
911+
// node-geocoder produces `neighbourhood`.
912+
test('locality reaches the public neighborhood contract', () => {
913+
const got = formatPhotonFeature(
914+
feature(WICKER_PARK_REVERSE, [-87.6796, 41.9088]),
915+
)
916+
assert.equal(got.neighbourhood, 'Wicker Park')
917+
assert.equal(got.neighborhood, 'Wicker Park')
918+
assert.equal(formatter('{{neighborhood}}', got), 'Wicker Park')
919+
assert.equal(formatter('{{neighbourhood}}', got), 'Wicker Park')
920+
})
921+
922+
// The alias has to exist on the Nominatim path too. node-geocoder emits
923+
// neighbourhood there, and the GraphQL field and formatter token both use the
924+
// American spellings, so a Nominatim deployment had the same invisible value.
925+
test('the Nominatim path also carries the neighborhood alias', async () => {
926+
const server = http.createServer((_, res) => {
927+
res.writeHead(200, { 'Content-Type': 'application/json' })
928+
res.end(
929+
JSON.stringify([
930+
{
931+
lat: '41.9088',
932+
lon: '-87.6796',
933+
display_name: 'Wicker Park, Chicago',
934+
address: {
935+
neighbourhood: 'Wicker Park',
936+
city: 'Chicago',
937+
country_code: 'us',
938+
},
939+
},
940+
]),
941+
)
942+
})
943+
await new Promise((resolve) => {
944+
server.listen(0, '127.0.0.1', resolve)
945+
})
946+
try {
947+
const [entry] = await nominatimGeocoder(
948+
`http://127.0.0.1:${server.address().port}`,
949+
'Wicker Park',
950+
false,
951+
)
952+
assert.equal(entry.neighbourhood, 'Wicker Park')
953+
assert.equal(entry.neighborhood, 'Wicker Park')
954+
assert.equal(formatter('{{neighborhood}}', entry), 'Wicker Park')
955+
} finally {
956+
await new Promise((resolve) => {
957+
server.close(resolve)
958+
})
959+
}
960+
})

0 commit comments

Comments
 (0)