mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
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:
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({});
|
||||
|
||||
Reference in New Issue
Block a user