diff --git a/server/src/nest/maps/community-overlay.ts b/server/src/nest/maps/community-overlay.ts index e89b57eab..1c433b76b 100644 --- a/server/src/nest/maps/community-overlay.ts +++ b/server/src/nest/maps/community-overlay.ts @@ -1,6 +1,7 @@ import { buildOsmDetails } from './maps.helpers'; +import type { TrekCommunityValues } from './trek-community.schema'; import { communityKeyOf } from './trek-community.service'; -import { trekCommunityByKeys, type TrekCommunityValues } from './trek-places.client'; +import { trekCommunityByKeys } from './trek-places.client'; import { normalizePlaceWebsite } from '@trek/shared'; /** diff --git a/server/src/nest/maps/trek-community.schema.ts b/server/src/nest/maps/trek-community.schema.ts new file mode 100644 index 000000000..d92bf1f57 --- /dev/null +++ b/server/src/nest/maps/trek-community.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +/** What the service publishes for a place found elsewhere (Photon, Overpass): the overlay of its own rows. */ +export const trekCommunityValuesSchema = z.object({ + contact: z + .object({ website: z.string().nullish(), phone: z.string().nullish(), email: z.string().nullish() }) + .nullish(), + hours: z.object({ osm: z.string().nullish() }).nullish(), + description: z.object({ text: z.string().nullish() }).nullish(), + images: z.array(z.object({ url: z.string().optional(), cover: z.boolean().optional() })).nullish(), + community: z.object({ version: z.number().int().optional(), fields: z.array(z.string()).optional() }).nullish(), +}); + +export type TrekCommunityValues = z.infer; diff --git a/server/src/nest/maps/trek-osm-element.ts b/server/src/nest/maps/trek-osm-element.ts index a7ed354ca..6d7145d8c 100644 --- a/server/src/nest/maps/trek-osm-element.ts +++ b/server/src/nest/maps/trek-osm-element.ts @@ -1,12 +1,17 @@ -import { getJson, type TrekCommunityValues } from './trek-places.client'; +import { trekCommunityValuesSchema } from './trek-community.schema'; +import { getJson } from './trek-places.client'; + +import { z } from 'zod'; /** An OSM element as the TREK Places API's details layer answers it: the tags, the point, and the community values. */ -interface TrekOsmElement extends TrekCommunityValues { - name: string; - lat: number; - lng: number; - tags: Record; -} +const trekOsmElementSchema = trekCommunityValuesSchema.extend({ + name: z.string().nullish(), + lat: z.number(), + lng: z.number(), + tags: z.record(z.string(), z.string()), +}); + +type TrekOsmElement = z.infer; /** The details layer answers in well under a second or not at all; Overpass is the fallback, not this. */ const TIMEOUT_MS = 3_000; @@ -19,13 +24,14 @@ const TIMEOUT_MS = 3_000; */ export async function trekOsmElement(osmType: string, osmId: string): Promise { try { - const body = await getJson<{ place?: TrekOsmElement }>( + const body = await getJson<{ place?: unknown } | null>( `/v1/osm/${encodeURIComponent(osmType)}/${encodeURIComponent(osmId)}`, {}, { timeoutMs: TIMEOUT_MS }, ); - const place = body.place; - return place && typeof place.lat === 'number' && typeof place.lng === 'number' && place.tags ? place : null; + // An answer outside the published shape is no element: Overpass is asked instead. + const place = trekOsmElementSchema.safeParse(body?.place); + return place.success ? place.data : null; } catch { return null; } diff --git a/server/src/nest/maps/trek-places.client.ts b/server/src/nest/maps/trek-places.client.ts index 81d7ac5e5..aad21469b 100644 --- a/server/src/nest/maps/trek-places.client.ts +++ b/server/src/nest/maps/trek-places.client.ts @@ -14,6 +14,7 @@ */ import { readEnv } from '../../app-config'; import { toApiLang } from './maps.helpers'; +import { trekCommunityValuesSchema, type TrekCommunityValues } from './trek-community.schema'; import { identifiedFetch } from './trek-places.fetch'; import { normalizePlaceWebsite, @@ -638,14 +639,7 @@ export async function trekPlacesById(gers: string, opts: { lang?: string } = {}) } } -/** What the service publishes for a place found elsewhere (Photon, Overpass): the overlay of its own rows. */ -export interface TrekCommunityValues { - contact?: { website?: string | null; phone?: string | null; email?: string | null } | null; - hours?: { osm?: string | null } | null; - description?: { text?: string | null } | null; - images?: { url?: string; cover?: boolean }[] | null; - community?: { version?: number; fields?: string[] } | null; -} +const communityAnswerSchema = z.object({ places: z.record(z.string(), z.unknown()).optional() }); /** * The published community values of places that are no rows of the index, by @@ -655,10 +649,16 @@ export interface TrekCommunityValues { export async function trekCommunityByKeys(keys: string[]): Promise> { if (keys.length === 0) return {}; try { - const body = await getJson<{ places?: Record }>('/v1/community', { - keys: keys.slice(0, 50).join(','), - }); - return body.places ?? {}; + const body = await getJson('/v1/community', { keys: keys.slice(0, 50).join(',') }); + const places = communityAnswerSchema.safeParse(body); + if (!places.success) return {}; + // A place whose values are not in the published shape is left out, the others still count. + const out: Record = {}; + for (const [key, raw] of Object.entries(places.data.places ?? {})) { + const values = trekCommunityValuesSchema.safeParse(raw); + if (values.success) out[key] = values.data; + } + return out; } catch { return {}; } diff --git a/server/tests/unit/nest/osm.photon.test.ts b/server/tests/unit/nest/osm.photon.test.ts index b6923398c..3b4fbb4a9 100644 --- a/server/tests/unit/nest/osm.photon.test.ts +++ b/server/tests/unit/nest/osm.photon.test.ts @@ -300,6 +300,15 @@ it('OSM-PHOTON-009: a lookup by id is Overpass for the tags and the place, Photo expect(hosts).toEqual(['places.liketrek.com', 'overpass-api.de', 'photon.liketrek.com']); }); +it('OSM-PHOTON-022: an answer of the details layer outside its shape is no element, and Overpass is asked', async () => { + const place = { name: 'Brandenburger Tor', lat: 52.5163, lng: 13.3777, tags: { level: 2 } }; + placesReply = () => + new Response(JSON.stringify({ place }), { status: 200, headers: { 'content-type': 'application/json' } }); + const details = await osm.elementDetails('way:518071791', 'en'); + expect(details).toMatchObject({ name: 'Brandenburg Gate', website: 'https://example.org' }); + expect(hosts).toEqual(['places.liketrek.com', 'overpass-api.de', 'photon.liketrek.com']); +}); + it("OSM-PHOTON-021: an element in TREK's own details layer asks no Overpass, and carries the community values", async () => { placesReply = (url) => { expect(url.pathname).toBe('/v1/osm/way/518071791'); diff --git a/server/tests/unit/nest/trek-places.client.test.ts b/server/tests/unit/nest/trek-places.client.test.ts index c2ed497b6..9f8653825 100644 --- a/server/tests/unit/nest/trek-places.client.test.ts +++ b/server/tests/unit/nest/trek-places.client.test.ts @@ -1219,6 +1219,14 @@ describe('trekCommunityByKeys', () => { expect(url.searchParams.get('keys')).toBe('osm:relation/3389505,osm:node/1'); }); + it('leaves out a place whose values are not in the published shape, and keeps the others', async () => { + const good = { contact: { phone: '+49 30 1' }, community: { version: 1 } }; + stubFetch({ places: { 'osm:node/1': good, 'osm:node/2': { hours: { osm: 7 } } } }); + expect(await trekCommunityByKeys(['osm:node/1', 'osm:node/2'])).toEqual({ 'osm:node/1': good }); + stubFetch({ places: ['not', 'a', 'map'] }); + expect(await trekCommunityByKeys(['osm:node/1'])).toEqual({}); + }); + it('asks nothing without keys, and a failing service is an empty map', async () => { stubFetch({ places: {} }); expect(await trekCommunityByKeys([])).toEqual({});