universal-geocoder
Version:
Universal geocoding abstraction server-side and client-side with multiple built-in providers
641 lines (604 loc) • 17.9 kB
text/typescript
import {
ExternalLoaderBody,
ExternalLoaderHeaders,
ExternalLoaderInterface,
ExternalLoaderParams,
} from "ExternalLoader";
import {
ErrorCallback,
GeocodedResultsCallback,
OpenCageGeocoded,
OpenCageGeocodeQuery,
OpenCageGeocodeQueryObject,
OpenCageReverseQuery,
OpenCageReverseQueryObject,
ProviderHelpers,
ProviderInterface,
ProviderOptionsInterface,
defaultProviderOptions,
} from "provider";
import AdminLevel from "AdminLevel";
import { ResponseError } from "error";
interface OpenCageRequestParams {
[param: string]: string | undefined;
readonly key: string;
readonly q: string;
readonly countrycode?: string;
readonly language?: string;
readonly limit?: string;
readonly bounds?: string;
readonly proximity?: string;
// eslint-disable-next-line camelcase
readonly min_confidence?: string;
// eslint-disable-next-line camelcase
readonly no_record?: string;
readonly jsonpCallback?: string;
}
interface OpenCageCoordinates {
lat: number;
lng: number;
}
interface OpenCageSun {
apparent: number;
astronomical: number;
civil: number;
nautical: number;
}
export interface OpenCageResult {
annotations: {
callingcode: number;
currency: {
// eslint-disable-next-line camelcase
alternate_symbols: string[];
// eslint-disable-next-line camelcase
decimal_mark: string;
// eslint-disable-next-line camelcase
disambiguate_symbol?: string;
// eslint-disable-next-line camelcase
html_entity: string;
// eslint-disable-next-line camelcase
iso_code: string;
// eslint-disable-next-line camelcase
iso_numeric: string;
name: string;
// eslint-disable-next-line camelcase
smallest_denomination: number;
subunit: string;
// eslint-disable-next-line camelcase
subunit_to_unit: number;
symbol: string;
// eslint-disable-next-line camelcase
symbol_first: number;
// eslint-disable-next-line camelcase
thousands_separator: string;
};
DMS: {
lat: string;
lng: string;
};
FIPS?: {
state?: string;
county?: string;
};
flag: string;
geohash?: string;
ITM?: {
easting: string;
northing: string;
};
Maidenhead?: string;
Mercator: {
x: number;
y: number;
};
MGRS?: string;
OSM: {
// eslint-disable-next-line camelcase
edit_url?: string;
// eslint-disable-next-line camelcase
note_url: string;
url: string;
};
qibla: number;
roadinfo: {
// eslint-disable-next-line camelcase
drive_on: "left" | "right";
road?: string;
// eslint-disable-next-line camelcase
road_type?: string;
// eslint-disable-next-line camelcase
road_reference?: string;
// eslint-disable-next-line camelcase
road_reference_intl?: string;
// eslint-disable-next-line camelcase
speed_in: "km/h" | "mph";
};
sun: {
rise: OpenCageSun;
set: OpenCageSun;
};
timezone: {
name: string;
// eslint-disable-next-line camelcase
now_in_dst: number;
// eslint-disable-next-line camelcase
offset_sec: number;
// eslint-disable-next-line camelcase
offset_string: string;
// eslint-disable-next-line camelcase
short_name: string;
};
// eslint-disable-next-line camelcase
UN_M49: {
regions: {
[region: string]: string;
};
// eslint-disable-next-line camelcase
statistical_groupings: ("LDC" | "LEDC" | "LLDC" | "MEDC" | "SIDS")[];
};
what3words?: {
words: string;
};
wikidata?: string;
};
bounds: {
northeast: OpenCageCoordinates;
southwest: OpenCageCoordinates;
};
components: {
"ISO_3166-1_alpha-2"?: string;
"ISO_3166-1_alpha-3"?: string;
_category:
| "agriculture"
| "building"
| "castle"
| "commerce"
| "construction"
| "education"
| "financial"
| "government"
| "health"
| "industrial"
| "military"
| "natural/water"
| "outdoors/recreation"
| "place"
| "place_of_worship"
| "postcode"
| "road"
| "social"
| "transportation"
| "travel/tourism"
| "unknown";
_type: string;
castle?: string;
city?: string;
// eslint-disable-next-line camelcase
city_district?: string;
continent?:
| "Africa"
| "Antarctica"
| "Asia"
| "Europe"
| "Oceania"
| "North America"
| "South America";
country?: string;
// eslint-disable-next-line camelcase
country_code?: string;
county?: string;
// eslint-disable-next-line camelcase
county_code?: string;
croft?: string;
district?: string;
footway?: string;
hamlet?: string;
// eslint-disable-next-line camelcase
house_number?: string;
houses?: string;
locality?: string;
municipality?: string;
neighbourhood?: string;
path?: string;
pedestrian?: string;
// eslint-disable-next-line camelcase
political_union?: string;
postcode?: string;
quarter?: string;
residential?: string;
road?: string;
// eslint-disable-next-line camelcase
road_reference?: string;
// eslint-disable-next-line camelcase
road_reference_intl?: string;
// eslint-disable-next-line camelcase
road_type?: string;
state?: string;
// eslint-disable-next-line camelcase
state_code?: string;
// eslint-disable-next-line camelcase
state_district?: string;
street?: string;
// eslint-disable-next-line camelcase
street_name?: string;
subdivision?: string;
suburb?: string;
town?: string;
village?: string;
};
confidence: number;
formatted: string;
geometry: OpenCageCoordinates;
}
export interface OpenCageResponse {
documentation: string;
licences: {
name: string;
url: string;
}[];
rate: {
limit: number;
remaining: number;
reset: number;
};
results: OpenCageResult[];
status: {
code: 200 | 400 | 401 | 402 | 403 | 404 | 405 | 408 | 410 | 429 | 503;
message: string;
};
// eslint-disable-next-line camelcase
stay_informed: {
blog: string;
twitter: string;
};
thanks: string;
timestamp: {
// eslint-disable-next-line camelcase
created_http: string;
// eslint-disable-next-line camelcase
created_unix: number;
};
// eslint-disable-next-line camelcase
total_results: number;
}
export interface OpenCageProviderOptionsInterface
extends ProviderOptionsInterface {
readonly apiKey: string;
readonly countryCodes?: string[];
}
export const defaultOpenCageProviderOptions = {
...defaultProviderOptions,
apiKey: "",
};
type OpenCageGeocodedResultsCallback =
GeocodedResultsCallback<OpenCageGeocoded>;
export default class OpenCageProvider
implements ProviderInterface<OpenCageGeocoded>
{
private externalLoader: ExternalLoaderInterface;
private options: OpenCageProviderOptionsInterface;
public constructor(
_externalLoader: ExternalLoaderInterface,
options: OpenCageProviderOptionsInterface = defaultOpenCageProviderOptions
) {
this.externalLoader = _externalLoader;
this.options = { ...defaultOpenCageProviderOptions, ...options };
if (!this.options.apiKey) {
throw new Error(
'An API key is required for the OpenCage provider. Please add it in the "apiKey" option.'
);
}
}
public geocode(
query: string | OpenCageGeocodeQuery | OpenCageGeocodeQueryObject
): Promise<OpenCageGeocoded[]>;
public geocode(
query: string | OpenCageGeocodeQuery | OpenCageGeocodeQueryObject,
callback: OpenCageGeocodedResultsCallback,
errorCallback?: ErrorCallback
): void;
public geocode(
query: string | OpenCageGeocodeQuery | OpenCageGeocodeQueryObject,
callback?: OpenCageGeocodedResultsCallback,
errorCallback?: ErrorCallback
): void | Promise<OpenCageGeocoded[]> {
const geocodeQuery = ProviderHelpers.getGeocodeQueryFromParameter(
query,
OpenCageGeocodeQuery
);
if (geocodeQuery.getIp()) {
throw new Error(
"The OpenCage provider does not support IP geolocation, only location geocoding."
);
}
this.externalLoader.setOptions({
protocol: this.options.useSsl ? "https" : "http",
host: "api.opencagedata.com",
pathname: "geocode/v1/json",
});
const params: OpenCageRequestParams = this.withCommonParams(
{
q: geocodeQuery.getText() || "",
bounds: geocodeQuery.getBounds()
? `${geocodeQuery.getBounds()?.longitudeSW},${
geocodeQuery.getBounds()?.latitudeSW
},${geocodeQuery.getBounds()?.longitudeNE},${
geocodeQuery.getBounds()?.latitudeNE
}`
: undefined,
proximity: (<OpenCageGeocodeQuery>geocodeQuery).getProximity()
? `${(<OpenCageGeocodeQuery>geocodeQuery).getProximity()?.latitude},${
(<OpenCageGeocodeQuery>geocodeQuery).getProximity()?.longitude
}`
: undefined,
},
<OpenCageGeocodeQuery>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: OpenCageReverseQuery | OpenCageReverseQueryObject
): Promise<OpenCageGeocoded[]>;
public geodecode(
query: OpenCageReverseQuery | OpenCageReverseQueryObject,
callback: OpenCageGeocodedResultsCallback,
errorCallback?: ErrorCallback
): void;
public geodecode(
latitude: number | string,
longitude: number | string
): Promise<OpenCageGeocoded[]>;
public geodecode(
latitude: number | string,
longitude: number | string,
callback: OpenCageGeocodedResultsCallback,
errorCallback?: ErrorCallback
): void;
public geodecode(
latitudeOrQuery:
| number
| string
| OpenCageReverseQuery
| OpenCageReverseQueryObject,
longitudeOrCallback?: number | string | OpenCageGeocodedResultsCallback,
callbackOrErrorCallback?: OpenCageGeocodedResultsCallback | ErrorCallback,
errorCallback?: ErrorCallback
): void | Promise<OpenCageGeocoded[]> {
const reverseQuery = ProviderHelpers.getReverseQueryFromParameters(
latitudeOrQuery,
longitudeOrCallback,
OpenCageReverseQuery
);
const reverseCallback = ProviderHelpers.getCallbackFromParameters(
longitudeOrCallback,
callbackOrErrorCallback
);
const reverseErrorCallback = ProviderHelpers.getErrorCallbackFromParameters(
longitudeOrCallback,
callbackOrErrorCallback,
errorCallback
);
this.externalLoader.setOptions({
protocol: this.options.useSsl ? "https" : "http",
host: "api.opencagedata.com",
pathname: "geocode/v1/json",
});
const params: OpenCageRequestParams = this.withCommonParams(
{
q: `${reverseQuery.getCoordinates().latitude},${
reverseQuery.getCoordinates().longitude
}`,
},
<OpenCageReverseQuery>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<OpenCageRequestParams, "q" | "bounds" | "proximity">,
query: OpenCageGeocodeQuery | OpenCageReverseQuery
): OpenCageRequestParams {
return {
...params,
key: this.options.apiKey || "",
countrycode: query.getCountryCodes()
? query.getCountryCodes()?.join(",")
: this.options.countryCodes?.join(","),
language: query.getLocale(),
limit: query.getLimit().toString(),
min_confidence: query.getMinPrecision()?.toString(),
no_record: query.getNoRecord()?.toString(),
jsonpCallback: this.options.useJsonp ? "jsonp" : undefined,
};
}
public executeRequest(
params: ExternalLoaderParams,
callback: OpenCageGeocodedResultsCallback,
headers?: ExternalLoaderHeaders,
body?: ExternalLoaderBody,
errorCallback?: ErrorCallback
): void {
this.externalLoader.executeRequest(
params,
(data: OpenCageResponse) => {
callback(
data.results.map((result: OpenCageResult) =>
OpenCageProvider.mapToGeocoded(result)
)
);
},
headers,
body,
(error) => {
const response = <Response>error.getResponse();
response.json().then((data: OpenCageResponse) => {
if (data.status) {
let errorMessage: string;
switch (data.status.code) {
case 400:
errorMessage = `Invalid request (400): ${data.status.message}`;
break;
case 401:
errorMessage = `Unable to authenticate (401): ${data.status.message}`;
break;
case 402:
errorMessage = `Quota exceeded (402): ${data.status.message}`;
break;
case 403:
errorMessage = `Forbidden (403): ${data.status.message}`;
break;
case 404:
errorMessage = `Invalid API endpoint (404): ${data.status.message}`;
break;
case 405:
errorMessage = `Method not allowed (405): ${data.status.message}`;
break;
case 408:
errorMessage = `Timeout (408): ${data.status.message}`;
break;
case 410:
errorMessage = `Request too long (410): ${data.status.message}`;
break;
case 429:
errorMessage = `Too many requests (429): ${data.status.message}`;
break;
case 503:
errorMessage = `Internal server error (503): ${data.status.message}`;
break;
default:
errorMessage = `Error (${data.status.code}): ${data.status.message}`;
}
if (errorCallback) {
errorCallback(new ResponseError(errorMessage, data));
return;
}
setTimeout(() => {
throw new Error(errorMessage);
});
}
});
}
);
}
public static mapToGeocoded(result: OpenCageResult): OpenCageGeocoded {
const latitude = result.geometry.lat;
const longitude = result.geometry.lng;
const formattedAddress = result.formatted;
const streetNumber = result.components.house_number;
const postalCode = result.components.postcode;
const region = result.components.state;
const { country } = result.components;
const countryCode = result.components.country_code;
const timezone = result.annotations.timezone.name;
const callingCode = result.annotations.callingcode;
const { flag } = result.annotations;
const precision = result.confidence;
const mgrs = result.annotations.MGRS;
const maidenhead = result.annotations.Maidenhead;
const { geohash } = result.annotations;
const what3words = result.annotations.what3words?.words;
const streetName =
result.components.road ||
result.components.footway ||
result.components.street ||
result.components.street_name ||
result.components.residential ||
result.components.path ||
result.components.pedestrian ||
result.components.road_reference ||
result.components.road_reference_intl;
const subLocality =
result.components.neighbourhood ||
result.components.suburb ||
result.components.city_district ||
result.components.district ||
result.components.quarter ||
result.components.houses ||
result.components.subdivision;
const locality =
result.components.city ||
result.components.town ||
result.components.municipality ||
result.components.village ||
result.components.hamlet ||
result.components.locality ||
result.components.croft;
let geocoded = OpenCageGeocoded.create({
coordinates: {
latitude,
longitude,
},
formattedAddress,
streetNumber,
streetName,
subLocality,
locality,
postalCode,
region,
country,
countryCode,
timezone,
callingCode,
flag,
precision,
mgrs,
maidenhead,
geohash,
what3words,
});
if (result.bounds) {
geocoded = <OpenCageGeocoded>geocoded.withBounds({
latitudeSW: result.bounds.southwest.lat,
longitudeSW: result.bounds.southwest.lng,
latitudeNE: result.bounds.northeast.lat,
longitudeNE: result.bounds.northeast.lng,
});
}
const adminLevels: {
nameKey: "state" | "county";
codeKey: "state_code" | "county_code";
}[] = [
{ nameKey: "state", codeKey: "state_code" },
{ nameKey: "county", codeKey: "county_code" },
];
adminLevels.forEach(({ nameKey, codeKey }, level) => {
if (result.components[nameKey]) {
geocoded.addAdminLevel(
AdminLevel.create({
level: level + 1,
name: result.components[nameKey] || "",
code: result.components[codeKey] || undefined,
})
);
}
});
return geocoded;
}
}