universal-geocoder
Version:
Universal geocoding abstraction server-side and client-side with multiple built-in providers
701 lines (657 loc) • 20 kB
text/typescript
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;
}
}