@andreasnicolaou/overpass-client
Version:
A wrapper for the Overpass API to query OpenStreetMap data.
202 lines (201 loc) • 9.6 kB
JavaScript
import axios from 'axios';
import { defer, retry, delay, of, tap, throwError, timer } from 'rxjs';
import { LRUCache } from 'lru-cache';
import { OverpassError } from './errors';
const matchAll = (regex, string) => {
let match;
const matches = [];
while ((match = regex.exec(string))) {
matches.push(match[1]);
}
return matches;
};
/**
* A client for querying the Overpass API, supporting caching, retries, and various query types.
* @class OverpassClient
* @author Andreas Nicolaou <anicolaou66@gmail.com>
*/
export class OverpassClient {
axiosInstance;
endpoint;
format;
lruCache = new LRUCache({
max: 500,
ttl: 5 * 60 * 1000, // 5 minutes
});
maxRetries;
timeout;
/**
* Creates a new instance of the Overpass API client.
* @param endpoint - The Overpass API endpoint to use (default: 'https://overpass-api.de/api/interpreter'). More info at https://wiki.openstreetmap.org/wiki/Overpass_API#Public_Overpass_API_instances
* @param format - The response format ('json' or 'xml', default: 'json').
* @param timeout - Query timeout in seconds (default: 60). If set to `0`, the timeout will not be included in the Overpass query.
* @param maxRetries - Number of automatic retries for failed requests (default: 3).
* @param lruCache - An optional LRU cache instance for storing query results.
* @memberof OverpassClient
*/
constructor(endpoint = 'https://overpass-api.de/api/interpreter', format = 'json', timeout = 60, maxRetries = 3, lruCache) {
this.lruCache =
lruCache ||
new LRUCache({
max: 500,
ttl: 5 * 60 * 1000, // 5 minutes
});
this.endpoint = endpoint;
this.maxRetries = maxRetries;
this.format = format;
this.timeout = timeout;
this.axiosInstance = axios.create({
baseURL: this.endpoint,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
}
/**
* Clears cache entirely
* @memberof OverpassClient
*/
clearCache() {
this.lruCache.clear();
}
/**
* Fetches a specific element (node, way, or relation) by its ID.
* @param type - The type of element (node, way, or relation).
* @param id - The ID of the element.
* @param outputFormat - The Overpass QL output format (default: 'out center;').
* @returns Observable emitting the query result.
* @memberof OverpassClient
*/
getElement(type, id, outputFormat = 'out;') {
const key = `${type}-${id}`;
const cached = this.lruCache.get(key);
if (cached) {
return of(cached).pipe(delay(200));
}
return this.query(`${type}(${id}); ${outputFormat}`, key);
}
/**
* Fetches elements with specified tags within a given bounding box.
* @param tags - A dictionary of tag types and their possible values. Example: { amenity: ['cafe', 'restaurant'], tourism: ['museum'] }
* @param bbox - Bounding box coordinates in the format [minLat, minLon, maxLat, maxLon].
* @param elements - List of element types to include in the query (node, way, relation). Defaults to all elements.
* @param outputFormat - The Overpass QL output format (default: 'out center;').
* @returns Observable emitting the query result.
* @memberof OverpassClient
*/
getElementsByBoundingBox(tags, bbox, elements = ['node', 'way', 'relation'], outputFormat = 'out center;') {
const key = `bbox-${JSON.stringify(tags)}-${bbox.join('-')}-${elements.join('-')}`;
const cached = this.lruCache.get(key);
if (cached) {
return of(cached).pipe(delay(200));
}
const [minLat, minLon, maxLat, maxLon] = bbox;
const tagFilters = Object.entries(tags)
.flatMap(([tag, values]) => values.map((value) => {
const elementsQuery = elements
.map((element) => `${element}["${tag}"="${value}"](${minLat},${minLon},${maxLat},${maxLon});`)
.join(' ');
return `(${elementsQuery});`;
}))
.join(' ');
return this.query(`${tagFilters} ${outputFormat}`, key);
}
/**
* Fetches elements with specified tags within a given radius from a point.
* @param tags - A dictionary of tag types and their possible values. Example: { amenity: ['cafe', 'restaurant'], tourism: ['museum'] }
* @param lat - Latitude of the center point.
* @param lon - Longitude of the center point.
* @param radius - Search radius in meters.
* @param elements - List of element types to include in the query (node, way, relation). Defaults to all elements.
* @param outputFormat - The Overpass QL output format (default: 'out center;').
* @returns Observable emitting the query result.
* @memberof OverpassClient
*/
getElementsByRadius(tags, lat, lon, radius, elements = ['node', 'way', 'relation'], outputFormat = 'out center;') {
const key = `radius-${JSON.stringify(tags)}-${lat}-${lon}-${radius}-${elements.join('-')}`;
const cached = this.lruCache.get(key);
if (cached) {
return of(cached).pipe(delay(200));
}
const tagFilters = Object.entries(tags)
.flatMap(([tag, values]) => values.map((value) => {
const elementsQuery = elements
.map((element) => `${element}(around:${radius},${lat},${lon})["${tag}"="${value}"];`)
.join(' ');
return `(${elementsQuery});`;
}))
.join(' ');
return this.query(`${tagFilters} ${outputFormat}`, key);
}
/**
* Executes an Overpass QL query with automatic retries using RxJS Observables.
* @param query - The Overpass QL query string.
* @param cachedKey - The cache key for storing the query result.
* @returns Observable emitting the query result.
* @memberof OverpassClient
*/
query(query, cachedKey) {
const timeoutEnabled = this.timeout !== 0 ? `[timeout:${this.timeout}]` : '';
const fullQuery = `[out:${this.format}]${timeoutEnabled};${query}`;
return defer(() => this.axiosInstance
.post('', `data=${encodeURIComponent(fullQuery)}`)
.then((response) => response.data)).pipe(tap((data) => this.lruCache.set(cachedKey, data)), retry({
count: this.maxRetries,
delay: (error, attempt) => {
if (axios.isAxiosError(error)) {
this.lruCache.delete(cachedKey);
if (attempt >= this.maxRetries) {
return throwError(() => new OverpassError(`Max retries exceeded. Request failed ${error.response?.statusText ?? ''}`, undefined, query));
}
const response = error.response;
if (response) {
switch (response.status) {
case 400: {
const errors = matchAll(/<\/strong>: ([^<]+) <\/p>/g, response.data).map((err) => err.replace(/"/g, '"'));
return throwError(() => new OverpassError(`Bad Request Error`, errors, query));
}
case 429: {
// Too Many Requests (Rate Limit)
const retryAfter = response.headers?.['retry-after']
? parseInt(response.headers['retry-after'], 10) * 1000
: null;
return this.retryAfter(attempt, retryAfter);
}
case 500: // Internal Server Error
case 502: // Bad Gateway
case 503: // Service Unavailable
case 504: {
return this.retryAfter(attempt);
}
default: {
const status = response.status ? `[${response.status}] - ` : '';
return throwError(() => new OverpassError(`${status}${response.statusText ?? 'Unknown error occured'}`));
}
}
}
else {
return throwError(() => new OverpassError('Something went wrong', undefined, query));
}
}
// Non-retryable error: propagate immediately
return throwError(() => new OverpassError('Unknown error occurred', undefined, query));
},
}));
}
/**
* Calculates the retry delay using exponential backoff with jitter.
* If a `retryAfterMs` value is provided (for 429 errors), it is used directly.
* @param attempt
* @param [retryAfterMs]
* @returns after
*/
retryAfter(attempt, retryAfterMs = null) {
if (retryAfterMs !== null) {
console.warn(`Overpass API rate limit reached! Retrying in ${retryAfterMs / 1000} seconds...`);
return timer(retryAfterMs);
}
const baseDelay = Math.pow(2, attempt) * 1000;
const retryAfter = Math.random() * baseDelay; // Exponential backoff with jitter
console.warn(`Transient error encountered. Retrying in ${retryAfter / 1000} seconds...`);
return timer(retryAfter);
}
}