UNPKG

universal-geocoder

Version:

Universal geocoding abstraction server-side and client-side with multiple built-in providers

701 lines (657 loc) 20 kB
import { ExternalLoaderBody, ExternalLoaderHeaders, ExternalLoaderInterface, ExternalLoaderParams, } from "ExternalLoader"; import { ErrorCallback, GeocodedResultsCallback, GoogleMapsGeocoded, GoogleMapsGeocodeQuery, GoogleMapsGeocodeQueryObject, GoogleMapsReverseQuery, GoogleMapsReverseQueryObject, ProviderHelpers, ProviderInterface, ProviderOptionsInterface, defaultProviderOptions, } from "provider"; import AdminLevel from "AdminLevel"; import { ResponseError } from "error"; import { decodeBase64, decodeUrlSafeBase64, encodeUrlSafeBase64, filterUndefinedObjectValues, getRequireFunc, isBrowser, } from "utils"; interface GoogleMapsRequestParams { [param: string]: string | undefined; readonly key?: string; readonly client?: string; readonly signature?: string; readonly channel?: string; readonly region?: string; readonly language?: string; readonly limit?: string; readonly address?: string; readonly components?: string; readonly bounds?: string; readonly latlng?: string; // eslint-disable-next-line camelcase readonly result_type?: string; // eslint-disable-next-line camelcase readonly location_type?: string; } interface GoogleMapsLatLng { lat: number; lng: number; } type GoogleMapsPlaceType = | "airport" | "administrative_area_level_1" | "administrative_area_level_2" | "administrative_area_level_3" | "administrative_area_level_4" | "administrative_area_level_5" | "archipelago" | "bus_station" | "colloquial_area" | "continent" | "country" | "establishment" | "finance" | "floor" | "food" | "general_contractor" | "geocode" | "health" | "intersection" | "locality" | "natural_feature" | "neighborhood" | "park" | "parking" | "place_of_worship" | "plus_code" | "point_of_interest" | "political" | "post_box" | "postal_code" | "postal_code_prefix" | "postal_code_suffix" | "postal_town" | "premise" | "room" | "route" | "street_address" | "street_number" | "sublocality" | "sublocality_level_1" | "sublocality_level_2" | "sublocality_level_3" | "sublocality_level_4" | "sublocality_level_5" | "subpremise" | "town_square" | "train_station" | "transit_station" | "ward"; export type GoogleMapsPrecision = | "ROOFTOP" | "RANGE_INTERPOLATED" | "GEOMETRIC_CENTER" | "APPROXIMATE"; export interface GoogleMapsResult { geometry: { location: GoogleMapsLatLng; // eslint-disable-next-line camelcase location_type: GoogleMapsPrecision; viewport: { northeast: GoogleMapsLatLng; southwest: GoogleMapsLatLng; }; bounds?: { northeast: GoogleMapsLatLng; southwest: GoogleMapsLatLng; }; }; // eslint-disable-next-line camelcase formatted_address: string; // eslint-disable-next-line camelcase address_components: { types: GoogleMapsPlaceType[]; // eslint-disable-next-line camelcase long_name: string; // eslint-disable-next-line camelcase short_name: string; }[]; // eslint-disable-next-line camelcase place_id: string; // eslint-disable-next-line camelcase plus_code?: { // eslint-disable-next-line camelcase global_code: string; // eslint-disable-next-line camelcase compound_code?: string; }; types: GoogleMapsPlaceType[]; // eslint-disable-next-line camelcase postcode_localities?: string[]; // eslint-disable-next-line camelcase partial_match?: boolean; } export interface GoogleMapsResponse { results: GoogleMapsResult[]; status: | "OK" | "ZERO_RESULTS" | "OVER_DAILY_LIMIT" | "OVER_QUERY_LIMIT" | "REQUEST_DENIED" | "INVALID_REQUEST" | "UNKNOWN_ERROR"; // eslint-disable-next-line camelcase error_message?: string; } export interface GoogleMapsProviderOptionsInterface extends ProviderOptionsInterface { readonly apiKey?: string; readonly secret?: string; readonly clientId?: string; readonly countryCodes?: string[]; } type GoogleMapsGeocodedResultsCallback = GeocodedResultsCallback<GoogleMapsGeocoded>; export default class GoogleMapsProvider implements ProviderInterface<GoogleMapsGeocoded> { private externalLoader: ExternalLoaderInterface; private options: GoogleMapsProviderOptionsInterface; public constructor( _externalLoader: ExternalLoaderInterface, options: GoogleMapsProviderOptionsInterface = defaultProviderOptions ) { this.externalLoader = _externalLoader; this.options = { ...defaultProviderOptions, ...options }; if (!this.options.apiKey && !this.options.clientId) { throw new Error( 'An API key or a client ID is required for the Google Maps provider. Please add it in the "apiKey" or the "clientId" option.' ); } if (this.options.clientId && !this.options.secret) { throw new Error( 'An URL signing secret is required if you use a client ID (Premium only). Please add it in the "secret" option.' ); } if (this.options.secret && isBrowser()) { throw new Error( 'The "secret" option cannot be used in a browser environment.' ); } if (this.options.countryCodes && this.options.countryCodes.length !== 1) { throw new Error( 'The "countryCodes" option must have only one country code top-level domain.' ); } } public geocode( query: string | GoogleMapsGeocodeQuery | GoogleMapsGeocodeQueryObject ): Promise<GoogleMapsGeocoded[]>; public geocode( query: string | GoogleMapsGeocodeQuery | GoogleMapsGeocodeQueryObject, callback: GoogleMapsGeocodedResultsCallback, errorCallback?: ErrorCallback ): void; public geocode( query: string | GoogleMapsGeocodeQuery | GoogleMapsGeocodeQueryObject, callback?: GoogleMapsGeocodedResultsCallback, errorCallback?: ErrorCallback ): void | Promise<GoogleMapsGeocoded[]> { const geocodeQuery = ProviderHelpers.getGeocodeQueryFromParameter( query, GoogleMapsGeocodeQuery ); if (geocodeQuery.getIp()) { throw new Error( "The GoogleMaps provider does not support IP geolocation, only location geocoding." ); } this.externalLoader.setOptions({ protocol: this.options.useSsl ? "https" : "http", host: "maps.googleapis.com", pathname: "maps/api/geocode/json", }); const params: GoogleMapsRequestParams = this.withCommonParams( { address: geocodeQuery.getText(), bounds: geocodeQuery.getBounds() ? `${geocodeQuery.getBounds()?.latitudeSW},${ geocodeQuery.getBounds()?.longitudeSW }|${geocodeQuery.getBounds()?.latitudeNE},${ geocodeQuery.getBounds()?.longitudeNE }` : undefined, components: (<GoogleMapsGeocodeQuery>geocodeQuery).getComponents() ? (<GoogleMapsGeocodeQuery>geocodeQuery) .getComponents() ?.map((component) => `${component.name}:${component.value}`) .join("|") : undefined, region: (<GoogleMapsGeocodeQuery>geocodeQuery).getCountryCodes() ? (<GoogleMapsGeocodeQuery>geocodeQuery).getCountryCodes()?.join(",") : this.options.countryCodes?.join(","), }, <GoogleMapsGeocodeQuery>geocodeQuery ); if (!callback) { return new Promise((resolve, reject) => this.executeRequest( params, (results) => resolve(results), {}, {}, (error) => reject(error) ) ); } return this.executeRequest(params, callback, {}, {}, errorCallback); } public geodecode( query: GoogleMapsReverseQuery | GoogleMapsReverseQueryObject ): Promise<GoogleMapsGeocoded[]>; public geodecode( query: GoogleMapsReverseQuery | GoogleMapsReverseQueryObject, callback: GoogleMapsGeocodedResultsCallback, errorCallback?: ErrorCallback ): void; public geodecode( latitude: number | string, longitude: number | string ): Promise<GoogleMapsGeocoded[]>; public geodecode( latitude: number | string, longitude: number | string, callback: GoogleMapsGeocodedResultsCallback, errorCallback?: ErrorCallback ): void; public geodecode( latitudeOrQuery: | number | string | GoogleMapsReverseQuery | GoogleMapsReverseQueryObject, longitudeOrCallback?: number | string | GoogleMapsGeocodedResultsCallback, callbackOrErrorCallback?: GoogleMapsGeocodedResultsCallback | ErrorCallback, errorCallback?: ErrorCallback ): void | Promise<GoogleMapsGeocoded[]> { const reverseQuery = ProviderHelpers.getReverseQueryFromParameters( latitudeOrQuery, longitudeOrCallback, GoogleMapsReverseQuery ); const reverseCallback = ProviderHelpers.getCallbackFromParameters( longitudeOrCallback, callbackOrErrorCallback ); const reverseErrorCallback = ProviderHelpers.getErrorCallbackFromParameters( longitudeOrCallback, callbackOrErrorCallback, errorCallback ); this.externalLoader.setOptions({ protocol: this.options.useSsl ? "https" : "http", host: "maps.googleapis.com", pathname: "maps/api/geocode/json", }); const params: GoogleMapsRequestParams = this.withCommonParams( { latlng: `${reverseQuery.getCoordinates().latitude},${ reverseQuery.getCoordinates().longitude }`, result_type: (<GoogleMapsReverseQuery>reverseQuery).getTypes() ? (<GoogleMapsReverseQuery>reverseQuery).getTypes()?.join("|") : undefined, location_type: (<GoogleMapsReverseQuery>reverseQuery).getPrecisions() ? (<GoogleMapsReverseQuery>reverseQuery).getPrecisions()?.join("|") : undefined, }, <GoogleMapsReverseQuery>reverseQuery ); if (!reverseCallback) { return new Promise((resolve, reject) => this.executeRequest( params, (results) => resolve(results), {}, {}, (error) => reject(error) ) ); } return this.executeRequest( params, reverseCallback, {}, {}, reverseErrorCallback ); } private withCommonParams( params: Pick< GoogleMapsRequestParams, | "address" | "bounds" | "components" | "latlng" | "location_type" | "region" | "result_type" >, query: GoogleMapsGeocodeQuery | GoogleMapsReverseQuery ): GoogleMapsRequestParams { let withCommonParams: GoogleMapsRequestParams = { ...params, key: this.options.apiKey, client: this.options.clientId, channel: query.getChannel(), language: query.getLocale(), limit: query.getLimit().toString(), }; if (this.options.secret) { withCommonParams = { ...withCommonParams, signature: GoogleMapsProvider.signQuery( this.options.secret, this.externalLoader.getOptions().pathname || "", withCommonParams ), }; } return withCommonParams; } public executeRequest( params: ExternalLoaderParams, callback: GoogleMapsGeocodedResultsCallback, headers?: ExternalLoaderHeaders, body?: ExternalLoaderBody, errorCallback?: ErrorCallback ): void { const { limit, ...externalLoaderParams } = params; this.externalLoader.executeRequest( externalLoaderParams, (data: GoogleMapsResponse) => { let errorMessage: undefined | string; switch (data.status) { case "REQUEST_DENIED": errorMessage = "Request has been denied"; if (data.error_message) { errorMessage += `: ${data.error_message}`; } break; case "OVER_QUERY_LIMIT": errorMessage = "Exceeded daily quota when attempting geocoding request"; if (data.error_message) { errorMessage += `: ${data.error_message}`; } break; case "OVER_DAILY_LIMIT": errorMessage = "API usage has been limited"; if (data.error_message) { errorMessage += `: ${data.error_message}`; } break; case "INVALID_REQUEST": errorMessage = "The request is invalid"; if (data.error_message) { errorMessage += `: ${data.error_message}`; } break; case "UNKNOWN_ERROR": errorMessage = "Unknown error"; if (data.error_message) { errorMessage += `: ${data.error_message}`; } break; default: // Intentionnaly left empty } if (errorMessage && errorCallback) { errorCallback(new ResponseError(errorMessage, data)); return; } if (errorMessage) { setTimeout(() => { throw new Error(errorMessage); }); return; } const { results } = data; const resultsToRemove = results.length - parseInt(limit || results.length.toString(), 10); if (resultsToRemove > 0) { results.splice(-resultsToRemove); } callback( results.map((result: GoogleMapsResult) => GoogleMapsProvider.mapToGeocoded(result) ) ); }, headers, body, errorCallback ); } public static mapToGeocoded(result: GoogleMapsResult): GoogleMapsGeocoded { const latitude = result.geometry.location.lat; const longitude = result.geometry.location.lng; const formattedAddress = result.formatted_address; let streetNumber; let streetName; let subLocality; let locality; let postalCode; let region; let country; let countryCode; const adminLevels: AdminLevel[] = []; const placeId = result.place_id; const partialMatch = result.partial_match; const { types } = result; const precision = result.geometry.location_type; let streetAddress; let intersection; let political; let colloquialArea; let ward; let neighborhood; let premise; let subpremise; let naturalFeature; let airport; let park; let pointOfInterest; let establishment; let postalCodeSuffix; const subLocalityLevels: AdminLevel[] = []; result.address_components.forEach((addressComponent) => { addressComponent.types.forEach((type) => { switch (type) { case "street_number": streetNumber = addressComponent.long_name; break; case "route": streetName = addressComponent.long_name; break; case "sublocality": subLocality = addressComponent.long_name; break; case "locality": case "postal_town": locality = addressComponent.long_name; break; case "postal_code": postalCode = addressComponent.long_name; break; case "administrative_area_level_1": case "administrative_area_level_2": case "administrative_area_level_3": case "administrative_area_level_4": case "administrative_area_level_5": if (type === "administrative_area_level_1") { region = addressComponent.long_name; } adminLevels.push( AdminLevel.create({ level: parseInt(type.substr(-1), 10), name: addressComponent.long_name, code: addressComponent.short_name, }) ); break; case "sublocality_level_1": case "sublocality_level_2": case "sublocality_level_3": case "sublocality_level_4": case "sublocality_level_5": subLocalityLevels.push( AdminLevel.create({ level: parseInt(type.substr(-1), 10), name: addressComponent.long_name, code: addressComponent.short_name, }) ); break; case "country": country = addressComponent.long_name; countryCode = addressComponent.short_name; break; case "street_address": streetAddress = addressComponent.long_name; break; case "intersection": intersection = addressComponent.long_name; break; case "political": political = addressComponent.long_name; break; case "colloquial_area": colloquialArea = addressComponent.long_name; break; case "ward": ward = addressComponent.long_name; break; case "neighborhood": neighborhood = addressComponent.long_name; break; case "premise": premise = addressComponent.long_name; break; case "subpremise": subpremise = addressComponent.long_name; break; case "natural_feature": naturalFeature = addressComponent.long_name; break; case "airport": airport = addressComponent.long_name; break; case "park": park = addressComponent.long_name; break; case "point_of_interest": pointOfInterest = addressComponent.long_name; break; case "establishment": establishment = addressComponent.long_name; break; case "postal_code_suffix": postalCodeSuffix = addressComponent.long_name; break; default: } }); }); let geocoded = GoogleMapsGeocoded.create({ coordinates: { latitude, longitude, }, formattedAddress, streetNumber, streetName, subLocality, locality, postalCode, region, country, countryCode, adminLevels, placeId, partialMatch, types, precision, streetAddress, intersection, political, colloquialArea, ward, neighborhood, premise, subpremise, naturalFeature, airport, park, pointOfInterest, establishment, postalCodeSuffix, subLocalityLevels, }); if (result.geometry.bounds) { const { bounds } = result.geometry; geocoded = <GoogleMapsGeocoded>geocoded.withBounds({ latitudeSW: bounds.southwest.lat, longitudeSW: bounds.southwest.lng, latitudeNE: bounds.northeast.lat, longitudeNE: bounds.northeast.lng, }); } else if (result.geometry.viewport) { const { viewport } = result.geometry; geocoded = <GoogleMapsGeocoded>geocoded.withBounds({ latitudeSW: viewport.southwest.lat, longitudeSW: viewport.southwest.lng, latitudeNE: viewport.northeast.lat, longitudeNE: viewport.northeast.lng, }); } else if (precision === "ROOFTOP") { // Fake bounds geocoded = <GoogleMapsGeocoded>geocoded.withBounds({ latitudeSW: latitude, longitudeSW: longitude, latitudeNE: latitude, longitudeNE: longitude, }); } return geocoded; } private static signQuery( secret: string, pathname: string, params: GoogleMapsRequestParams ): string { const crypto = getRequireFunc()("crypto"); const filteredRequestParams = filterUndefinedObjectValues(params); const safeSecret = decodeBase64(decodeUrlSafeBase64(secret)); const toSign = `${pathname}?${new URLSearchParams( filteredRequestParams ).toString()}`; const hashedSignature = encodeUrlSafeBase64( crypto.createHmac("sha1", safeSecret).update(toSign).digest("base64") ); return hashedSignature; } }