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
 
  28 CONTENT_TEXT = 'text/plain; charset=utf-8'
 
  29 CONTENT_XML = 'text/xml; charset=utf-8'
 
  30 CONTENT_HTML = 'text/html; charset=utf-8'
 
  31 CONTENT_JSON = 'application/json; charset=utf-8'
 
  33 CONTENT_TYPE = {'text': CONTENT_TEXT, 'xml': CONTENT_XML, 'debug': CONTENT_HTML}
 
  35 class ASGIAdaptor(abc.ABC):
 
  36     """ Adapter class for the different ASGI frameworks.
 
  37         Wraps functionality over concrete requests and responses.
 
  39     content_type: str = CONTENT_TEXT
 
  42     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  43         """ Return an input parameter as a string. If the parameter was
 
  44             not provided, return the 'default' value.
 
  48     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  49         """ Return a HTTP header parameter as a string. If the parameter was
 
  50             not provided, return the 'default' value.
 
  55     def error(self, msg: str, status: int = 400) -> Exception:
 
  56         """ Construct an appropriate exception from the given error message.
 
  57             The exception must result in a HTTP error with the given status.
 
  62     def create_response(self, status: int, output: str, num_results: int) -> Any:
 
  63         """ Create a response from the given parameters. The result will
 
  64             be returned by the endpoint functions. The adaptor may also
 
  65             return None when the response is created internally with some
 
  68             The response must return the HTTP given status code 'status', set
 
  69             the HTTP content-type headers to the string provided and the
 
  70             body of the response to 'output'.
 
  74     def base_uri(self) -> str:
 
  75         """ Return the URI of the original request.
 
  80     def config(self) -> Configuration:
 
  81         """ Return the current configuration object.
 
  85     def build_response(self, output: str, status: int = 200, num_results: int = 0) -> Any:
 
  86         """ Create a response from the given output. Wraps a JSONP function
 
  87             around the response, if necessary.
 
  89         if self.content_type == CONTENT_JSON and status == 200:
 
  90             jsonp = self.get('json_callback')
 
  92                 if any(not part.isidentifier() for part in jsonp.split('.')):
 
  93                     self.raise_error('Invalid json_callback value')
 
  94                 output = f"{jsonp}({output})"
 
  95                 self.content_type = 'application/javascript; charset=utf-8'
 
  97         return self.create_response(status, output, num_results)
 
 100     def raise_error(self, msg: str, status: int = 400) -> NoReturn:
 
 101         """ Raise an exception resulting in the given HTTP status and
 
 102             message. The message will be formatted according to the
 
 103             output format chosen by the request.
 
 105         if self.content_type == CONTENT_XML:
 
 106             msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
 
 108                         <code>{status}</code>
 
 109                         <message>{msg}</message>
 
 112         elif self.content_type == CONTENT_JSON:
 
 113             msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
 
 114         elif self.content_type == CONTENT_HTML:
 
 115             loglib.log().section('Execution error')
 
 116             loglib.log().var_dump('Status', status)
 
 117             loglib.log().var_dump('Message', msg)
 
 118             msg = loglib.get_and_disable()
 
 120         raise self.error(msg, status)
 
 123     def get_int(self, name: str, default: Optional[int] = None) -> int:
 
 124         """ Return an input parameter as an int. Raises an exception if
 
 125             the parameter is given but not in an integer format.
 
 127             If 'default' is given, then it will be returned when the parameter
 
 128             is missing completely. When 'default' is None, an error will be
 
 129             raised on a missing parameter.
 
 131         value = self.get(name)
 
 134             if default is not None:
 
 137             self.raise_error(f"Parameter '{name}' missing.")
 
 142             self.raise_error(f"Parameter '{name}' must be a number.")
 
 147     def get_float(self, name: str, default: Optional[float] = None) -> float:
 
 148         """ Return an input parameter as a flaoting-point number. Raises an
 
 149             exception if the parameter is given but not in an float format.
 
 151             If 'default' is given, then it will be returned when the parameter
 
 152             is missing completely. When 'default' is None, an error will be
 
 153             raised on a missing parameter.
 
 155         value = self.get(name)
 
 158             if default is not None:
 
 161             self.raise_error(f"Parameter '{name}' missing.")
 
 166             self.raise_error(f"Parameter '{name}' must be a number.")
 
 168         if math.isnan(fval) or math.isinf(fval):
 
 169             self.raise_error(f"Parameter '{name}' must be a number.")
 
 174     def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
 
 175         """ Return an input parameter as bool. Only '0' is accepted as
 
 176             an input for 'false' all other inputs will be interpreted as 'true'.
 
 178             If 'default' is given, then it will be returned when the parameter
 
 179             is missing completely. When 'default' is None, an error will be
 
 180             raised on a missing parameter.
 
 182         value = self.get(name)
 
 185             if default is not None:
 
 188             self.raise_error(f"Parameter '{name}' missing.")
 
 193     def get_accepted_languages(self) -> str:
 
 194         """ Return the accepted languages.
 
 196         return self.get('accept-language')\
 
 197                or self.get_header('accept-language')\
 
 198                or self.config().DEFAULT_LANGUAGE
 
 201     def setup_debugging(self) -> bool:
 
 202         """ Set up collection of debug information if requested.
 
 204             Return True when debugging was requested.
 
 206         if self.get_bool('debug', False):
 
 207             loglib.set_log_output('html')
 
 208             self.content_type = CONTENT_HTML
 
 214     def get_layers(self) -> Optional[napi.DataLayer]:
 
 215         """ Return a parsed version of the layer parameter.
 
 217         param = self.get('layer', None)
 
 221         return cast(napi.DataLayer,
 
 222                     reduce(napi.DataLayer.__or__,
 
 223                            (getattr(napi.DataLayer, s.upper()) for s in param.split(','))))
 
 226     def parse_format(self, result_type: Type[Any], default: str) -> str:
 
 227         """ Get and check the 'format' parameter and prepare the formatter.
 
 228             `result_type` is the type of result to be returned by the function
 
 229             and `default` the format value to assume when no parameter is present.
 
 231         fmt = self.get('format', default=default)
 
 232         assert fmt is not None
 
 234         if not formatting.supports_format(result_type, fmt):
 
 235             self.raise_error("Parameter 'format' must be one of: " +
 
 236                               ', '.join(formatting.list_formats(result_type)))
 
 238         self.content_type = CONTENT_TYPE.get(fmt, CONTENT_JSON)
 
 242     def parse_geometry_details(self, fmt: str) -> Dict[str, Any]:
 
 243         """ Create details strucutre from the supplied geometry parameters.
 
 246         output = napi.GeometryFormat.NONE
 
 247         if self.get_bool('polygon_geojson', False):
 
 248             output |= napi.GeometryFormat.GEOJSON
 
 250         if fmt not in ('geojson', 'geocodejson'):
 
 251             if self.get_bool('polygon_text', False):
 
 252                 output |= napi.GeometryFormat.TEXT
 
 254             if self.get_bool('polygon_kml', False):
 
 255                 output |= napi.GeometryFormat.KML
 
 257             if self.get_bool('polygon_svg', False):
 
 258                 output |= napi.GeometryFormat.SVG
 
 261         if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
 
 262             self.raise_error('Too many polygon output options selected.')
 
 264         return {'address_details': True,
 
 265                 'geometry_simplification': self.get_float('polygon_threshold', 0.0),
 
 266                 'geometry_output': output
 
 270 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 271     """ Server glue for /status endpoint. See API docs for details.
 
 273     result = await api.status()
 
 275     fmt = params.parse_format(napi.StatusResult, 'text')
 
 277     if fmt == 'text' and result.status:
 
 282     return params.build_response(formatting.format_result(result, fmt, {}),
 
 286 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 287     """ Server glue for /details endpoint. See API docs for details.
 
 289     fmt = params.parse_format(napi.DetailedResult, 'json')
 
 290     place_id = params.get_int('place_id', 0)
 
 293         place = napi.PlaceID(place_id)
 
 295         osmtype = params.get('osmtype')
 
 297             params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
 
 298         place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
 
 300     debug = params.setup_debugging()
 
 302     locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
 
 304     result = await api.details(place,
 
 305                                address_details=params.get_bool('addressdetails', False),
 
 306                                linked_places=params.get_bool('linkedplaces', True),
 
 307                                parented_places=params.get_bool('hierarchy', False),
 
 308                                keywords=params.get_bool('keywords', False),
 
 309                                geometry_output = napi.GeometryFormat.GEOJSON
 
 310                                                  if params.get_bool('polygon_geojson', False)
 
 311                                                  else napi.GeometryFormat.NONE,
 
 316         return params.build_response(loglib.get_and_disable())
 
 319         params.raise_error('No place with that OSM ID found.', status=404)
 
 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()
 
 339     details['locales'] = napi.Locales.from_accept_languages(params.get_accepted_languages())
 
 341     result = await api.reverse(coord, **details)
 
 344         return params.build_response(loglib.get_and_disable(), num_results=1 if result else 0)
 
 347         queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
 
 348         zoom = params.get('zoom', None)
 
 350             queryparts['zoom'] = zoom
 
 351         query = urlencode(queryparts)
 
 355     fmt_options = {'query': query,
 
 356                    'extratags': params.get_bool('extratags', False),
 
 357                    'namedetails': params.get_bool('namedetails', False),
 
 358                    'addressdetails': params.get_bool('addressdetails', True)}
 
 360     output = formatting.format_result(napi.ReverseResults([result] if result else []),
 
 363     return params.build_response(output, num_results=1 if result else 0)
 
 366 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 367     """ Server glue for /lookup endpoint. See API docs for details.
 
 369     fmt = params.parse_format(napi.SearchResults, 'xml')
 
 370     debug = params.setup_debugging()
 
 371     details = params.parse_geometry_details(fmt)
 
 372     details['locales'] = napi.Locales.from_accept_languages(params.get_accepted_languages())
 
 375     for oid in (params.get('osm_ids') or '').split(','):
 
 377         if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
 
 378             places.append(napi.OsmID(oid[0].upper(), int(oid[1:])))
 
 380     if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
 
 381         params.raise_error('Too many object IDs.')
 
 384         results = await api.lookup(places, **details)
 
 386         results = napi.SearchResults()
 
 389         return params.build_response(loglib.get_and_disable(), num_results=len(results))
 
 391     fmt_options = {'extratags': params.get_bool('extratags', False),
 
 392                    'namedetails': params.get_bool('namedetails', False),
 
 393                    'addressdetails': params.get_bool('addressdetails', True)}
 
 395     output = formatting.format_result(results, fmt, fmt_options)
 
 397     return params.build_response(output, num_results=len(results))
 
 400 async def _unstructured_search(query: str, api: napi.NominatimAPIAsync,
 
 401                               details: Dict[str, Any]) -> napi.SearchResults:
 
 403         return napi.SearchResults()
 
 405     # Extract special format for coordinates from query.
 
 406     query, x, y = helpers.extract_coords_from_query(query)
 
 409         details['near'] = napi.Point(x, y)
 
 410         details['near_radius'] = 0.1
 
 412     # If no query is left, revert to reverse search.
 
 413     if x is not None and not query:
 
 414         result = await api.reverse(details['near'], **details)
 
 416             return napi.SearchResults()
 
 418         return napi.SearchResults(
 
 419                   [napi.SearchResult(**{f.name: getattr(result, f.name)
 
 420                                         for f in dataclasses.fields(napi.SearchResult)
 
 421                                         if hasattr(result, f.name)})])
 
 423     query, cls, typ = helpers.extract_category_from_query(query)
 
 425         assert typ is not None
 
 426         return await api.search_category([(cls, typ)], near_query=query, **details)
 
 428     return await api.search(query, **details)
 
 431 async def search_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 432     """ Server glue for /search endpoint. See API docs for details.
 
 434     fmt = params.parse_format(napi.SearchResults, 'jsonv2')
 
 435     debug = params.setup_debugging()
 
 436     details = params.parse_geometry_details(fmt)
 
 438     details['countries']  = params.get('countrycodes', None)
 
 439     details['excluded'] = params.get('exclude_place_ids', None)
 
 440     details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
 
 441     details['bounded_viewbox'] = params.get_bool('bounded', False)
 
 442     details['dedupe'] = params.get_bool('dedupe', True)
 
 444     max_results = max(1, min(50, params.get_int('limit', 10)))
 
 445     details['max_results'] = max_results + min(10, max_results) \
 
 446                              if details['dedupe'] else max_results
 
 448     details['min_rank'], details['max_rank'] = \
 
 449         helpers.feature_type_to_rank(params.get('featureType', ''))
 
 450     if params.get('featureType', None) is not None:
 
 451         details['layers'] = napi.DataLayer.ADDRESS
 
 453         details['layers'] = params.get_layers()
 
 455     details['locales'] = napi.Locales.from_accept_languages(params.get_accepted_languages())
 
 457     # unstructured query parameters
 
 458     query = params.get('q', None)
 
 459     # structured query parameters
 
 461     for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
 
 462         details[key] = params.get(key, None)
 
 464             queryparts[key] = details[key]
 
 467         if query is not None:
 
 469                 params.raise_error("Structured query parameters"
 
 470                                    "(amenity, street, city, county, state, postalcode, country)"
 
 471                                    " cannot be used together with 'q' parameter.")
 
 472             queryparts['q'] = query
 
 473             results = await _unstructured_search(query, api, details)
 
 475             query = ', '.join(queryparts.values())
 
 477             results = await api.search_address(**details)
 
 478     except UsageError as err:
 
 479         params.raise_error(str(err))
 
 481     if details['dedupe'] and len(results) > 1:
 
 482         results = helpers.deduplicate_results(results, max_results)
 
 485         return params.build_response(loglib.get_and_disable(), num_results=len(results))
 
 488         helpers.extend_query_parts(queryparts, details,
 
 489                                    params.get('featureType', ''),
 
 490                                    params.get_bool('namedetails', False),
 
 491                                    params.get_bool('extratags', False),
 
 492                                    (str(r.place_id) for r in results if r.place_id))
 
 493         queryparts['format'] = fmt
 
 495         moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
 
 499     fmt_options = {'query': query, 'more_url': moreurl,
 
 500                    'exclude_place_ids': queryparts.get('exclude_place_ids'),
 
 501                    'viewbox': queryparts.get('viewbox'),
 
 502                    'extratags': params.get_bool('extratags', False),
 
 503                    'namedetails': params.get_bool('namedetails', False),
 
 504                    'addressdetails': params.get_bool('addressdetails', False)}
 
 506     output = formatting.format_result(results, fmt, fmt_options)
 
 508     return params.build_response(output, num_results=len(results))
 
 511 async def deletable_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 512     """ Server glue for /deletable endpoint.
 
 513         This is a special endpoint that shows polygons that have been
 
 514         deleted or are broken in the OSM data but are kept in the
 
 515         Nominatim database to minimize disruption.
 
 517     fmt = params.parse_format(RawDataList, 'json')
 
 519     async with api.begin() as conn:
 
 520         sql = sa.text(""" SELECT p.place_id, country_code,
 
 521                                  name->'name' as name, i.*
 
 522                           FROM placex p, import_polygon_delete i
 
 523                           WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
 
 524                                 AND p.class = i.class AND p.type = i.type
 
 526         results = RawDataList(r._asdict() for r in await conn.execute(sql))
 
 528     return params.build_response(formatting.format_result(results, fmt, {}))
 
 531 async def polygons_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 532     """ Server glue for /polygons endpoint.
 
 533         This is a special endpoint that shows polygons that have changed
 
 534         thier size but are kept in the Nominatim database with their
 
 535         old area to minimize disruption.
 
 537     fmt = params.parse_format(RawDataList, 'json')
 
 538     sql_params: Dict[str, Any] = {
 
 539         'days': params.get_int('days', -1),
 
 540         'cls': params.get('class')
 
 542     reduced = params.get_bool('reduced', False)
 
 544     async with api.begin() as conn:
 
 545         sql = sa.select(sa.text("""osm_type, osm_id, class, type,
 
 546                                    name->'name' as name,
 
 547                                    country_code, errormessage, updated"""))\
 
 548                 .select_from(sa.text('import_polygon_error'))
 
 549         if sql_params['days'] > 0:
 
 550             sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
 
 552             sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
 
 553         if sql_params['cls'] is not None:
 
 554             sql = sql.where(sa.text("class = :cls"))
 
 556         sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
 
 558         results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
 
 560     return params.build_response(formatting.format_result(results, fmt, {}))
 
 563 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
 
 566     ('status', status_endpoint),
 
 567     ('details', details_endpoint),
 
 568     ('reverse', reverse_endpoint),
 
 569     ('lookup', lookup_endpoint),
 
 570     ('search', search_endpoint),
 
 571     ('deletable', deletable_endpoint),
 
 572     ('polygons', polygons_endpoint),