1 # SPDX-License-Identifier: GPL-2.0-only
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2023 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Generic part of the server implementation of the v1 API.
9 Combine with the scaffolding provided for the various Python ASGI frameworks.
11 from typing import Optional, Any, Type, Callable, NoReturn, Dict, cast
12 from functools import reduce
16 from urllib.parse import urlencode
18 import sqlalchemy as sa
20 from nominatim.errors import UsageError
21 from nominatim.config import Configuration
22 import nominatim.api as napi
23 import nominatim.api.logging as loglib
24 from nominatim.api.v1.format import dispatch as formatting
25 from nominatim.api.v1.format import RawDataList
26 from nominatim.api.v1 import helpers
29 'text': 'text/plain; charset=utf-8',
30 'xml': 'text/xml; charset=utf-8',
31 'debug': 'text/html; charset=utf-8'
34 class ASGIAdaptor(abc.ABC):
35 """ Adapter class for the different ASGI frameworks.
36 Wraps functionality over concrete requests and responses.
38 content_type: str = 'text/plain; charset=utf-8'
41 def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
42 """ Return an input parameter as a string. If the parameter was
43 not provided, return the 'default' value.
47 def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
48 """ Return a HTTP header parameter as a string. If the parameter was
49 not provided, return the 'default' value.
54 def error(self, msg: str, status: int = 400) -> Exception:
55 """ Construct an appropriate exception from the given error message.
56 The exception must result in a HTTP error with the given status.
61 def create_response(self, status: int, output: str, num_results: int) -> Any:
62 """ Create a response from the given parameters. The result will
63 be returned by the endpoint functions. The adaptor may also
64 return None when the response is created internally with some
67 The response must return the HTTP given status code 'status', set
68 the HTTP content-type headers to the string provided and the
69 body of the response to 'output'.
73 def base_uri(self) -> str:
74 """ Return the URI of the original request.
79 def config(self) -> Configuration:
80 """ Return the current configuration object.
84 def build_response(self, output: str, status: int = 200, num_results: int = 0) -> Any:
85 """ Create a response from the given output. Wraps a JSONP function
86 around the response, if necessary.
88 if self.content_type == 'application/json' and status == 200:
89 jsonp = self.get('json_callback')
91 if any(not part.isidentifier() for part in jsonp.split('.')):
92 self.raise_error('Invalid json_callback value')
93 output = f"{jsonp}({output})"
94 self.content_type = 'application/javascript'
96 return self.create_response(status, output, num_results)
99 def raise_error(self, msg: str, status: int = 400) -> NoReturn:
100 """ Raise an exception resulting in the given HTTP status and
101 message. The message will be formatted according to the
102 output format chosen by the request.
104 if self.content_type == 'text/xml; charset=utf-8':
105 msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
107 <code>{status}</code>
108 <message>{msg}</message>
111 elif self.content_type == 'application/json':
112 msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
113 elif self.content_type == 'text/html; charset=utf-8':
114 loglib.log().section('Execution error')
115 loglib.log().var_dump('Status', status)
116 loglib.log().var_dump('Message', msg)
117 msg = loglib.get_and_disable()
119 raise self.error(msg, status)
122 def get_int(self, name: str, default: Optional[int] = None) -> int:
123 """ Return an input parameter as an int. Raises an exception if
124 the parameter is given but not in an integer format.
126 If 'default' is given, then it will be returned when the parameter
127 is missing completely. When 'default' is None, an error will be
128 raised on a missing parameter.
130 value = self.get(name)
133 if default is not None:
136 self.raise_error(f"Parameter '{name}' missing.")
141 self.raise_error(f"Parameter '{name}' must be a number.")
146 def get_float(self, name: str, default: Optional[float] = None) -> float:
147 """ Return an input parameter as a flaoting-point number. Raises an
148 exception if the parameter is given but not in an float format.
150 If 'default' is given, then it will be returned when the parameter
151 is missing completely. When 'default' is None, an error will be
152 raised on a missing parameter.
154 value = self.get(name)
157 if default is not None:
160 self.raise_error(f"Parameter '{name}' missing.")
165 self.raise_error(f"Parameter '{name}' must be a number.")
167 if math.isnan(fval) or math.isinf(fval):
168 self.raise_error(f"Parameter '{name}' must be a number.")
173 def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
174 """ Return an input parameter as bool. Only '0' is accepted as
175 an input for 'false' all other inputs will be interpreted as 'true'.
177 If 'default' is given, then it will be returned when the parameter
178 is missing completely. When 'default' is None, an error will be
179 raised on a missing parameter.
181 value = self.get(name)
184 if default is not None:
187 self.raise_error(f"Parameter '{name}' missing.")
192 def get_accepted_languages(self) -> str:
193 """ Return the accepted languages.
195 return self.get('accept-language')\
196 or self.get_header('accept-language')\
197 or self.config().DEFAULT_LANGUAGE
200 def setup_debugging(self) -> bool:
201 """ Set up collection of debug information if requested.
203 Return True when debugging was requested.
205 if self.get_bool('debug', False):
206 loglib.set_log_output('html')
207 self.content_type = 'text/html; charset=utf-8'
213 def get_layers(self) -> Optional[napi.DataLayer]:
214 """ Return a parsed version of the layer parameter.
216 param = self.get('layer', None)
220 return cast(napi.DataLayer,
221 reduce(napi.DataLayer.__or__,
222 (getattr(napi.DataLayer, s.upper()) for s in param.split(','))))
225 def parse_format(self, result_type: Type[Any], default: str) -> str:
226 """ Get and check the 'format' parameter and prepare the formatter.
227 `result_type` is the type of result to be returned by the function
228 and `default` the format value to assume when no parameter is present.
230 fmt = self.get('format', default=default)
231 assert fmt is not None
233 if not formatting.supports_format(result_type, fmt):
234 self.raise_error("Parameter 'format' must be one of: " +
235 ', '.join(formatting.list_formats(result_type)))
237 self.content_type = CONTENT_TYPE.get(fmt, 'application/json')
241 def parse_geometry_details(self, fmt: str) -> Dict[str, Any]:
242 """ Create details strucutre from the supplied geometry parameters.
245 output = napi.GeometryFormat.NONE
246 if self.get_bool('polygon_geojson', False):
247 output |= napi.GeometryFormat.GEOJSON
249 if fmt not in ('geojson', 'geocodejson'):
250 if self.get_bool('polygon_text', False):
251 output |= napi.GeometryFormat.TEXT
253 if self.get_bool('polygon_kml', False):
254 output |= napi.GeometryFormat.KML
256 if self.get_bool('polygon_svg', False):
257 output |= napi.GeometryFormat.SVG
260 if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
261 self.raise_error('Too many polygon output options selected.')
263 return {'address_details': True,
264 'geometry_simplification': self.get_float('polygon_threshold', 0.0),
265 'geometry_output': output
269 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
270 """ Server glue for /status endpoint. See API docs for details.
272 result = await api.status()
274 fmt = params.parse_format(napi.StatusResult, 'text')
276 if fmt == 'text' and result.status:
281 return params.build_response(formatting.format_result(result, fmt, {}),
285 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
286 """ Server glue for /details endpoint. See API docs for details.
288 fmt = params.parse_format(napi.DetailedResult, 'json')
289 place_id = params.get_int('place_id', 0)
292 place = napi.PlaceID(place_id)
294 osmtype = params.get('osmtype')
296 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
297 place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
299 debug = params.setup_debugging()
301 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
303 result = await api.details(place,
304 address_details=params.get_bool('addressdetails', False),
305 linked_places=params.get_bool('linkedplaces', True),
306 parented_places=params.get_bool('hierarchy', False),
307 keywords=params.get_bool('keywords', False),
308 geometry_output = napi.GeometryFormat.GEOJSON
309 if params.get_bool('polygon_geojson', False)
310 else napi.GeometryFormat.NONE
314 return params.build_response(loglib.get_and_disable())
317 params.raise_error('No place with that OSM ID found.', status=404)
319 result.localize(locales)
321 output = formatting.format_result(result, fmt,
323 'group_hierarchy': params.get_bool('group_hierarchy', False),
324 'icon_base_url': params.config().MAPICON_URL})
326 return params.build_response(output, num_results=1)
329 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
330 """ Server glue for /reverse endpoint. See API docs for details.
332 fmt = params.parse_format(napi.ReverseResults, 'xml')
333 debug = params.setup_debugging()
334 coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
336 details = params.parse_geometry_details(fmt)
337 details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
338 details['layers'] = params.get_layers()
340 result = await api.reverse(coord, **details)
343 return params.build_response(loglib.get_and_disable(), num_results=1 if result else 0)
346 queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
347 zoom = params.get('zoom', None)
349 queryparts['zoom'] = zoom
350 query = urlencode(queryparts)
354 fmt_options = {'query': query,
355 'extratags': params.get_bool('extratags', False),
356 'namedetails': params.get_bool('namedetails', False),
357 'addressdetails': params.get_bool('addressdetails', True)}
360 result.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
362 output = formatting.format_result(napi.ReverseResults([result] if result else []),
365 return params.build_response(output, num_results=1 if result else 0)
368 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
369 """ Server glue for /lookup endpoint. See API docs for details.
371 fmt = params.parse_format(napi.SearchResults, 'xml')
372 debug = params.setup_debugging()
373 details = params.parse_geometry_details(fmt)
376 for oid in (params.get('osm_ids') or '').split(','):
378 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
379 places.append(napi.OsmID(oid[0], int(oid[1:])))
381 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
382 params.raise_error('Too many object IDs.')
385 results = await api.lookup(places, **details)
387 results = napi.SearchResults()
390 return params.build_response(loglib.get_and_disable(), num_results=len(results))
392 fmt_options = {'extratags': params.get_bool('extratags', False),
393 'namedetails': params.get_bool('namedetails', False),
394 'addressdetails': params.get_bool('addressdetails', True)}
396 results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
398 output = formatting.format_result(results, fmt, fmt_options)
400 return params.build_response(output, num_results=len(results))
403 async def _unstructured_search(query: str, api: napi.NominatimAPIAsync,
404 details: Dict[str, Any]) -> napi.SearchResults:
406 return napi.SearchResults()
408 # Extract special format for coordinates from query.
409 query, x, y = helpers.extract_coords_from_query(query)
412 details['near'] = napi.Point(x, y)
413 details['near_radius'] = 0.1
415 # If no query is left, revert to reverse search.
416 if x is not None and not query:
417 result = await api.reverse(details['near'], **details)
419 return napi.SearchResults()
421 return napi.SearchResults(
422 [napi.SearchResult(**{f.name: getattr(result, f.name)
423 for f in dataclasses.fields(napi.SearchResult)
424 if hasattr(result, f.name)})])
426 query, cls, typ = helpers.extract_category_from_query(query)
428 assert typ is not None
429 return await api.search_category([(cls, typ)], near_query=query, **details)
431 return await api.search(query, **details)
434 async def search_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
435 """ Server glue for /search endpoint. See API docs for details.
437 fmt = params.parse_format(napi.SearchResults, 'jsonv2')
438 debug = params.setup_debugging()
439 details = params.parse_geometry_details(fmt)
441 details['countries'] = params.get('countrycodes', None)
442 details['excluded'] = params.get('exclude_place_ids', None)
443 details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
444 details['bounded_viewbox'] = params.get_bool('bounded', False)
445 details['dedupe'] = params.get_bool('dedupe', True)
447 max_results = max(1, min(50, params.get_int('limit', 10)))
448 details['max_results'] = max_results + min(10, max_results) \
449 if details['dedupe'] else max_results
451 details['min_rank'], details['max_rank'] = \
452 helpers.feature_type_to_rank(params.get('featureType', ''))
453 if params.get('featureType', None) is not None:
454 details['layers'] = napi.DataLayer.ADDRESS
456 # unstructured query parameters
457 query = params.get('q', None)
458 # structured query parameters
460 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
461 details[key] = params.get(key, None)
463 queryparts[key] = details[key]
466 if query is not None:
468 params.raise_error("Structured query parameters"
469 "(amenity, street, city, county, state, postalcode, country)"
470 " cannot be used together with 'q' parameter.")
471 queryparts['q'] = query
472 results = await _unstructured_search(query, api, details)
474 query = ', '.join(queryparts.values())
476 results = await api.search_address(**details)
477 except UsageError as err:
478 params.raise_error(str(err))
480 results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
482 if details['dedupe'] and len(results) > 1:
483 results = helpers.deduplicate_results(results, max_results)
486 return params.build_response(loglib.get_and_disable(), num_results=len(results))
489 helpers.extend_query_parts(queryparts, details,
490 params.get('featureType', ''),
491 params.get_bool('namedetails', False),
492 params.get_bool('extratags', False),
493 (str(r.place_id) for r in results if r.place_id))
494 queryparts['format'] = fmt
496 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
500 fmt_options = {'query': query, 'more_url': moreurl,
501 'exclude_place_ids': queryparts.get('exclude_place_ids'),
502 'viewbox': queryparts.get('viewbox'),
503 'extratags': params.get_bool('extratags', False),
504 'namedetails': params.get_bool('namedetails', False),
505 'addressdetails': params.get_bool('addressdetails', False)}
507 output = formatting.format_result(results, fmt, fmt_options)
509 return params.build_response(output, num_results=len(results))
512 async def deletable_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
513 """ Server glue for /deletable endpoint.
514 This is a special endpoint that shows polygons that have been
515 deleted or are broken in the OSM data but are kept in the
516 Nominatim database to minimize disruption.
518 fmt = params.parse_format(RawDataList, 'json')
520 async with api.begin() as conn:
521 sql = sa.text(""" SELECT p.place_id, country_code,
522 name->'name' as name, i.*
523 FROM placex p, import_polygon_delete i
524 WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
525 AND p.class = i.class AND p.type = i.type
527 results = RawDataList(r._asdict() for r in await conn.execute(sql))
529 return params.build_response(formatting.format_result(results, fmt, {}))
532 async def polygons_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
533 """ Server glue for /polygons endpoint.
534 This is a special endpoint that shows polygons that have changed
535 thier size but are kept in the Nominatim database with their
536 old area to minimize disruption.
538 fmt = params.parse_format(RawDataList, 'json')
539 sql_params: Dict[str, Any] = {
540 'days': params.get_int('days', -1),
541 'cls': params.get('class')
543 reduced = params.get_bool('reduced', False)
545 async with api.begin() as conn:
546 sql = sa.select(sa.text("""osm_type, osm_id, class, type,
547 name->'name' as name,
548 country_code, errormessage, updated"""))\
549 .select_from(sa.text('import_polygon_error'))
550 if sql_params['days'] > 0:
551 sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
553 sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
554 if sql_params['cls'] is not None:
555 sql = sql.where(sa.text("class = :cls"))
557 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
559 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
561 return params.build_response(formatting.format_result(results, fmt, {}))
564 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
567 ('status', status_endpoint),
568 ('details', details_endpoint),
569 ('reverse', reverse_endpoint),
570 ('lookup', lookup_endpoint),
571 ('search', search_endpoint),
572 ('deletable', deletable_endpoint),
573 ('polygons', polygons_endpoint),