export type PointTuple = [number, number]; export type BoundsTuple = [PointTuple, PointTuple]; export interface LatLng { lat: number; lng: number; } export interface SearchResult { x: number; y: number; label: string; bounds: BoundsTuple | null; raw: TRawResult; } export interface ProviderParams { [key: string]: string | number | boolean; } export interface ProviderOptions { params?: ProviderParams; } export enum RequestType { SEARCH, REVERSE, } export interface EndpointArgument { query: string | { [key: string]: string | number | boolean }; type?: RequestType; } export interface SearchArgument { query: string; /** aborts the request, e.g. when a newer search starts */ signal?: AbortSignal; } export interface ReverseArgument extends LatLng { /** aborts the request */ signal?: AbortSignal; } export interface ParseArgument { data: TData; } export interface Provider { options: ProviderOptions; endpoint(options: EndpointArgument): string; getParamString(params: ProviderParams): string; parse(response: ParseArgument): SearchResult[]; search(options: SearchArgument): Promise[]>; reverse?(options: ReverseArgument): Promise[]>; } export default abstract class AbstractProvider< TRequestResult = any, TRawResult = any, > implements Provider { options: ProviderOptions; constructor(options: ProviderOptions = {}) { this.options = options; } abstract endpoint(options: EndpointArgument): string; abstract parse( response: ParseArgument, ): SearchResult[]; getParamString(params: ProviderParams = {}): string { const set = { ...this.options.params, ...params }; return Object.keys(set) .map( (key) => `${encodeURIComponent(key)}=${encodeURIComponent(set[key])}`, ) .join('&'); } getUrl(url: string, params?: ProviderParams): string { return `${url}?${this.getParamString(params)}`; } async search(options: SearchArgument): Promise[]> { const url = this.endpoint({ query: options.query, type: RequestType.SEARCH, }); const request = await fetch(url, { signal: options.signal }); const json: TRequestResult = await readJson(request); return this.parse({ data: json }); } /** * the query of a reverse request for a position. The names differ per * service. Providers that can't reverse geocode return null */ reverseParams(_position: LatLng): ProviderParams | null { return null; } /** the places at a position, closest first */ async reverse({ lat, lng, signal, }: ReverseArgument): Promise[]> { const query = this.reverseParams({ lat, lng }); if (!query) { throw new Error('This provider does not support reverse geocoding'); } const url = this.endpoint({ query, type: RequestType.REVERSE }); const request = await fetch(url, { signal }); const json: TRequestResult = await readJson(request); return this.parse({ data: json }); } } /** * Reads a JSON body. Error responses with a JSON body are still returned, as * some providers answer "no results" that way. Error responses that aren't * JSON (e.g. an html block page) throw an error that mentions the status. */ export async function readJson(response: Response): Promise { try { return await response.json(); } catch (error) { if (!response.ok) { throw new Error( `Request failed with status ${response.status} ${response.statusText}`.trim(), ); } throw error; } }