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) -> 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'.
 
  74     def config(self) -> Configuration:
 
  75         """ Return the current configuration object.
 
  79     def build_response(self, output: str, status: int = 200) -> Any:
 
  80         """ Create a response from the given output. Wraps a JSONP function
 
  81             around the response, if necessary.
 
  83         if self.content_type == 'application/json' and status == 200:
 
  84             jsonp = self.get('json_callback')
 
  86                 if any(not part.isidentifier() for part in jsonp.split('.')):
 
  87                     self.raise_error('Invalid json_callback value')
 
  88                 output = f"{jsonp}({output})"
 
  89                 self.content_type = 'application/javascript'
 
  91         return self.create_response(status, output)
 
  94     def raise_error(self, msg: str, status: int = 400) -> NoReturn:
 
  95         """ Raise an exception resulting in the given HTTP status and
 
  96             message. The message will be formatted according to the
 
  97             output format chosen by the request.
 
  99         if self.content_type == 'text/xml; charset=utf-8':
 
 100             msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
 
 102                         <code>{status}</code>
 
 103                         <message>{msg}</message>
 
 106         elif self.content_type == 'application/json':
 
 107             msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
 
 108         elif self.content_type == 'text/html; charset=utf-8':
 
 109             loglib.log().section('Execution error')
 
 110             loglib.log().var_dump('Status', status)
 
 111             loglib.log().var_dump('Message', msg)
 
 112             msg = loglib.get_and_disable()
 
 114         raise self.error(msg, status)
 
 117     def get_int(self, name: str, default: Optional[int] = None) -> int:
 
 118         """ Return an input parameter as an int. Raises an exception if
 
 119             the parameter is given but not in an integer format.
 
 121             If 'default' is given, then it will be returned when the parameter
 
 122             is missing completely. When 'default' is None, an error will be
 
 123             raised on a missing parameter.
 
 125         value = self.get(name)
 
 128             if default is not None:
 
 131             self.raise_error(f"Parameter '{name}' missing.")
 
 136             self.raise_error(f"Parameter '{name}' must be a number.")
 
 141     def get_float(self, name: str, default: Optional[float] = None) -> float:
 
 142         """ Return an input parameter as a flaoting-point number. Raises an
 
 143             exception if the parameter is given but not in an float format.
 
 145             If 'default' is given, then it will be returned when the parameter
 
 146             is missing completely. When 'default' is None, an error will be
 
 147             raised on a missing parameter.
 
 149         value = self.get(name)
 
 152             if default is not None:
 
 155             self.raise_error(f"Parameter '{name}' missing.")
 
 160             self.raise_error(f"Parameter '{name}' must be a number.")
 
 162         if math.isnan(fval) or math.isinf(fval):
 
 163             self.raise_error(f"Parameter '{name}' must be a number.")
 
 168     def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
 
 169         """ Return an input parameter as bool. Only '0' is accepted as
 
 170             an input for 'false' all other inputs will be interpreted as 'true'.
 
 172             If 'default' is given, then it will be returned when the parameter
 
 173             is missing completely. When 'default' is None, an error will be
 
 174             raised on a missing parameter.
 
 176         value = self.get(name)
 
 179             if default is not None:
 
 182             self.raise_error(f"Parameter '{name}' missing.")
 
 187     def get_accepted_languages(self) -> str:
 
 188         """ Return the accepted languages.
 
 190         return self.get('accept-language')\
 
 191                or self.get_header('accept-language')\
 
 192                or self.config().DEFAULT_LANGUAGE
 
 195     def setup_debugging(self) -> bool:
 
 196         """ Set up collection of debug information if requested.
 
 198             Return True when debugging was requested.
 
 200         if self.get_bool('debug', False):
 
 201             loglib.set_log_output('html')
 
 202             self.content_type = 'text/html; charset=utf-8'
 
 208     def get_layers(self) -> Optional[napi.DataLayer]:
 
 209         """ Return a parsed version of the layer parameter.
 
 211         param = self.get('layer', None)
 
 215         return cast(napi.DataLayer,
 
 216                     reduce(napi.DataLayer.__or__,
 
 217                            (getattr(napi.DataLayer, s.upper()) for s in param.split(','))))
 
 220     def parse_format(self, result_type: Type[Any], default: str) -> str:
 
 221         """ Get and check the 'format' parameter and prepare the formatter.
 
 222             `result_type` is the type of result to be returned by the function
 
 223             and `default` the format value to assume when no parameter is present.
 
 225         fmt = self.get('format', default=default)
 
 226         assert fmt is not None
 
 228         if not formatting.supports_format(result_type, fmt):
 
 229             self.raise_error("Parameter 'format' must be one of: " +
 
 230                               ', '.join(formatting.list_formats(result_type)))
 
 232         self.content_type = CONTENT_TYPE.get(fmt, 'application/json')
 
 236     def parse_geometry_details(self, fmt: str) -> Dict[str, Any]:
 
 237         """ Create details strucutre from the supplied geometry parameters.
 
 240         output = napi.GeometryFormat.NONE
 
 241         if self.get_bool('polygon_geojson', False):
 
 242             output |= napi.GeometryFormat.GEOJSON
 
 244         if fmt not in ('geojson', 'geocodejson'):
 
 245             if self.get_bool('polygon_text', False):
 
 246                 output |= napi.GeometryFormat.TEXT
 
 248             if self.get_bool('polygon_kml', False):
 
 249                 output |= napi.GeometryFormat.KML
 
 251             if self.get_bool('polygon_svg', False):
 
 252                 output |= napi.GeometryFormat.SVG
 
 255         if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
 
 256             self.raise_error('Too many polygon output options selected.')
 
 258         return {'address_details': True,
 
 259                 'geometry_simplification': self.get_float('polygon_threshold', 0.0),
 
 260                 'geometry_output': output
 
 264 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 265     """ Server glue for /status endpoint. See API docs for details.
 
 267     result = await api.status()
 
 269     fmt = params.parse_format(napi.StatusResult, 'text')
 
 271     if fmt == 'text' and result.status:
 
 276     return params.build_response(formatting.format_result(result, fmt, {}),
 
 280 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 281     """ Server glue for /details endpoint. See API docs for details.
 
 283     fmt = params.parse_format(napi.DetailedResult, 'json')
 
 284     place_id = params.get_int('place_id', 0)
 
 287         place = napi.PlaceID(place_id)
 
 289         osmtype = params.get('osmtype')
 
 291             params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
 
 292         place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
 
 294     debug = params.setup_debugging()
 
 296     locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
 
 298     result = await api.details(place,
 
 299                                address_details=params.get_bool('addressdetails', False),
 
 300                                linked_places=params.get_bool('linkedplaces', False),
 
 301                                parented_places=params.get_bool('hierarchy', False),
 
 302                                keywords=params.get_bool('keywords', False),
 
 303                                geometry_output = napi.GeometryFormat.GEOJSON
 
 304                                                  if params.get_bool('polygon_geojson', False)
 
 305                                                  else napi.GeometryFormat.NONE
 
 309         return params.build_response(loglib.get_and_disable())
 
 312         params.raise_error('No place with that OSM ID found.', status=404)
 
 314     result.localize(locales)
 
 316     output = formatting.format_result(result, fmt,
 
 318                   'group_hierarchy': params.get_bool('group_hierarchy', False),
 
 319                   'icon_base_url': params.config().MAPICON_URL})
 
 321     return params.build_response(output)
 
 324 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 325     """ Server glue for /reverse endpoint. See API docs for details.
 
 327     fmt = params.parse_format(napi.ReverseResults, 'xml')
 
 328     debug = params.setup_debugging()
 
 329     coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
 
 331     details = params.parse_geometry_details(fmt)
 
 332     details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
 
 333     details['layers'] = params.get_layers()
 
 335     result = await api.reverse(coord, **details)
 
 338         return params.build_response(loglib.get_and_disable())
 
 341         queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
 
 342         zoom = params.get('zoom', None)
 
 344             queryparts['zoom'] = zoom
 
 345         query = urlencode(queryparts)
 
 349     fmt_options = {'query': query,
 
 350                    'extratags': params.get_bool('extratags', False),
 
 351                    'namedetails': params.get_bool('namedetails', False),
 
 352                    'addressdetails': params.get_bool('addressdetails', True)}
 
 355         result.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
 
 357     output = formatting.format_result(napi.ReverseResults([result] if result else []),
 
 360     return params.build_response(output)
 
 363 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 364     """ Server glue for /lookup endpoint. See API docs for details.
 
 366     fmt = params.parse_format(napi.SearchResults, 'xml')
 
 367     debug = params.setup_debugging()
 
 368     details = params.parse_geometry_details(fmt)
 
 371     for oid in (params.get('osm_ids') or '').split(','):
 
 373         if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
 
 374             places.append(napi.OsmID(oid[0], int(oid[1:])))
 
 376     if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
 
 377         params.raise_error('Too many object IDs.')
 
 380         results = await api.lookup(places, **details)
 
 382         results = napi.SearchResults()
 
 385         return params.build_response(loglib.get_and_disable())
 
 387     fmt_options = {'extratags': params.get_bool('extratags', False),
 
 388                    'namedetails': params.get_bool('namedetails', False),
 
 389                    'addressdetails': params.get_bool('addressdetails', True)}
 
 391     results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
 
 393     output = formatting.format_result(results, fmt, fmt_options)
 
 395     return params.build_response(output)
 
 398 async def _unstructured_search(query: str, api: napi.NominatimAPIAsync,
 
 399                               details: Dict[str, Any]) -> napi.SearchResults:
 
 401         return napi.SearchResults()
 
 403     # Extract special format for coordinates from query.
 
 404     query, x, y = helpers.extract_coords_from_query(query)
 
 407         details['near'] = napi.Point(x, y)
 
 408         details['near_radius'] = 0.1
 
 410     # If no query is left, revert to reverse search.
 
 411     if x is not None and not query:
 
 412         result = await api.reverse(details['near'], **details)
 
 414             return napi.SearchResults()
 
 416         return napi.SearchResults(
 
 417                   [napi.SearchResult(**{f.name: getattr(result, f.name)
 
 418                                         for f in dataclasses.fields(napi.SearchResult)
 
 419                                         if hasattr(result, f.name)})])
 
 421     query, cls, typ = helpers.extract_category_from_query(query)
 
 423         assert typ is not None
 
 424         return await api.search_category([(cls, typ)], near_query=query, **details)
 
 426     return await api.search(query, **details)
 
 429 async def search_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 430     """ Server glue for /search endpoint. See API docs for details.
 
 432     fmt = params.parse_format(napi.SearchResults, 'jsonv2')
 
 433     debug = params.setup_debugging()
 
 434     details = params.parse_geometry_details(fmt)
 
 436     details['countries']  = params.get('countrycodes', None)
 
 437     details['excluded'] = params.get('exclude_place_ids', None)
 
 438     details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
 
 439     details['bounded_viewbox'] = params.get_bool('bounded', False)
 
 440     details['dedupe'] = params.get_bool('dedupe', True)
 
 442     max_results = max(1, min(50, params.get_int('limit', 10)))
 
 443     details['max_results'] = max_results + min(10, max_results) \
 
 444                              if details['dedupe'] else max_results
 
 446     details['min_rank'], details['max_rank'] = \
 
 447         helpers.feature_type_to_rank(params.get('featureType', ''))
 
 448     if params.get('featureType', None) is not None:
 
 449         details['layers'] = napi.DataLayer.ADDRESS
 
 451     query = params.get('q', None)
 
 454         if query is not None:
 
 455             queryparts['q'] = query
 
 456             results = await _unstructured_search(query, api, details)
 
 458             for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
 
 459                 details[key] = params.get(key, None)
 
 461                     queryparts[key] = details[key]
 
 462             query = ', '.join(queryparts.values())
 
 464             results = await api.search_address(**details)
 
 465     except UsageError as err:
 
 466         params.raise_error(str(err))
 
 468     results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
 
 470     if details['dedupe'] and len(results) > 1:
 
 471         results = helpers.deduplicate_results(results, max_results)
 
 474         return params.build_response(loglib.get_and_disable())
 
 477         helpers.extend_query_parts(queryparts, details,
 
 478                                    params.get('featureType', ''),
 
 479                                    params.get_bool('namedetails', False),
 
 480                                    params.get_bool('extratags', False),
 
 481                                    (str(r.place_id) for r in results if r.place_id))
 
 482         queryparts['format'] = fmt
 
 484         moreurl = urlencode(queryparts)
 
 488     fmt_options = {'query': query, 'more_url': moreurl,
 
 489                    'exclude_place_ids': queryparts.get('exclude_place_ids'),
 
 490                    'viewbox': queryparts.get('viewbox'),
 
 491                    'extratags': params.get_bool('extratags', False),
 
 492                    'namedetails': params.get_bool('namedetails', False),
 
 493                    'addressdetails': params.get_bool('addressdetails', False)}
 
 495     output = formatting.format_result(results, fmt, fmt_options)
 
 497     return params.build_response(output)
 
 500 async def deletable_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 501     """ Server glue for /deletable endpoint.
 
 502         This is a special endpoint that shows polygons that have been
 
 503         deleted or are broken in the OSM data but are kept in the
 
 504         Nominatim database to minimize disruption.
 
 506     fmt = params.parse_format(RawDataList, 'json')
 
 508     async with api.begin() as conn:
 
 509         sql = sa.text(""" SELECT p.place_id, country_code,
 
 510                                  name->'name' as name, i.*
 
 511                           FROM placex p, import_polygon_delete i
 
 512                           WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
 
 513                                 AND p.class = i.class AND p.type = i.type
 
 515         results = RawDataList(r._asdict() for r in await conn.execute(sql))
 
 517     return params.build_response(formatting.format_result(results, fmt, {}))
 
 520 async def polygons_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 521     """ Server glue for /polygons endpoint.
 
 522         This is a special endpoint that shows polygons that have changed
 
 523         thier size but are kept in the Nominatim database with their
 
 524         old area to minimize disruption.
 
 526     fmt = params.parse_format(RawDataList, 'json')
 
 527     sql_params: Dict[str, Any] = {
 
 528         'days': params.get_int('days', -1),
 
 529         'cls': params.get('class')
 
 531     reduced = params.get_bool('reduced', False)
 
 533     async with api.begin() as conn:
 
 534         sql = sa.select(sa.text("""osm_type, osm_id, class, type,
 
 535                                    name->'name' as name,
 
 536                                    country_code, errormessage, updated"""))\
 
 537                 .select_from(sa.text('import_polygon_error'))
 
 538         if sql_params['days'] > 0:
 
 539             sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
 
 541             sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
 
 542         if sql_params['cls'] is not None:
 
 543             sql = sql.where(sa.text("class = :cls"))
 
 545         sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
 
 547         results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
 
 549     return params.build_response(formatting.format_result(results, fmt, {}))
 
 552 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
 
 555     ('status', status_endpoint),
 
 556     ('details', details_endpoint),
 
 557     ('reverse', reverse_endpoint),
 
 558     ('lookup', lookup_endpoint),
 
 559     ('search', search_endpoint),
 
 560     ('deletable', deletable_endpoint),
 
 561     ('polygons', polygons_endpoint),