maps: validate the community values and OSM elements the TREK Places API answers

Both answers were taken as typed. A value outside the published shape, such
as hours that are no text, reached the hours parser and failed the details
request. A place outside the shape is now left out of /v1/community, and an
element outside it sends the lookup to Overpass.
This commit is contained in:
Maurice
2026-10-30 23:14:32 +00:00
parent 6945b53c10
commit 41edbcf67a
6 changed files with 61 additions and 23 deletions
+2 -1
View File
@@ -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';
/**
@@ -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<typeof trekCommunityValuesSchema>;
+16 -10
View File
@@ -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<string, string>;
}
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<typeof trekOsmElementSchema>;
/** 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<TrekOsmElement | null> {
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;
}
+12 -12
View File
@@ -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<Record<string, TrekCommunityValues>> {
if (keys.length === 0) return {};
try {
const body = await getJson<{ places?: Record<string, TrekCommunityValues> }>('/v1/community', {
keys: keys.slice(0, 50).join(','),
});
return body.places ?? {};
const body = await getJson<unknown>('/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<string, TrekCommunityValues> = {};
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 {};
}
@@ -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');
@@ -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({});