1 # SPDX-License-Identifier: GPL-3.0-or-later
 
   3 # This file is part of Nominatim. (https://nominatim.org)
 
   5 # Copyright (C) 2024 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, Dict, cast, Sequence, Tuple
 
  12 from functools import reduce
 
  14 from urllib.parse import urlencode
 
  16 import sqlalchemy as sa
 
  18 from ..errors import UsageError
 
  19 from .. import logging as loglib
 
  20 from ..core import NominatimAPIAsync
 
  21 from .format import RawDataList
 
  22 from ..types import DataLayer, GeometryFormat, PlaceRef, PlaceID, OsmID, Point
 
  23 from ..status import StatusResult
 
  24 from ..results import DetailedResult, ReverseResults, SearchResult, SearchResults
 
  25 from ..localization import Locales
 
  27 from ..server import content_types as ct
 
  28 from ..server.asgi_adaptor import ASGIAdaptor, EndpointFunc
 
  29 from ..sql.async_core_library import PGCORE_ERROR
 
  32 def build_response(adaptor: ASGIAdaptor, output: str, status: int = 200,
 
  33                    num_results: int = 0) -> Any:
 
  34     """ Create a response from the given output. Wraps a JSONP function
 
  35         around the response, if necessary.
 
  37     if adaptor.content_type == ct.CONTENT_JSON and status == 200:
 
  38         jsonp = adaptor.get('json_callback')
 
  40             if any(not part.isidentifier() for part in jsonp.split('.')):
 
  41                 adaptor.raise_error('Invalid json_callback value')
 
  42             output = f"{jsonp}({output})"
 
  43             adaptor.content_type = 'application/javascript; charset=utf-8'
 
  45     return adaptor.create_response(status, output, num_results)
 
  48 def get_accepted_languages(adaptor: ASGIAdaptor) -> str:
 
  49     """ Return the accepted languages.
 
  51     return adaptor.get('accept-language')\
 
  52         or adaptor.get_header('accept-language')\
 
  53         or adaptor.config().DEFAULT_LANGUAGE
 
  56 def setup_debugging(adaptor: ASGIAdaptor) -> bool:
 
  57     """ Set up collection of debug information if requested.
 
  59         Return True when debugging was requested.
 
  61     if adaptor.get_bool('debug', False):
 
  62         loglib.set_log_output('html')
 
  63         adaptor.content_type = ct.CONTENT_HTML
 
  69 def get_layers(adaptor: ASGIAdaptor) -> Optional[DataLayer]:
 
  70     """ Return a parsed version of the layer parameter.
 
  72     param = adaptor.get('layer', None)
 
  76     return cast(DataLayer,
 
  77                 reduce(DataLayer.__or__,
 
  78                        (getattr(DataLayer, s.upper()) for s in param.split(','))))
 
  81 def parse_format(adaptor: ASGIAdaptor, result_type: Type[Any], default: str) -> str:
 
  82     """ Get and check the 'format' parameter and prepare the formatter.
 
  83         `result_type` is the type of result to be returned by the function
 
  84         and `default` the format value to assume when no parameter is present.
 
  86     fmt = adaptor.get('format', default=default)
 
  87     assert fmt is not None
 
  89     formatting = adaptor.formatting()
 
  91     if not formatting.supports_format(result_type, fmt):
 
  92         adaptor.raise_error("Parameter 'format' must be one of: " +
 
  93                             ', '.join(formatting.list_formats(result_type)))
 
  95     adaptor.content_type = formatting.get_content_type(fmt)
 
  99 def parse_geometry_details(adaptor: ASGIAdaptor, fmt: str) -> Dict[str, Any]:
 
 100     """ Create details structure from the supplied geometry parameters.
 
 103     output = GeometryFormat.NONE
 
 104     if adaptor.get_bool('polygon_geojson', False):
 
 105         output |= GeometryFormat.GEOJSON
 
 107     if fmt not in ('geojson', 'geocodejson'):
 
 108         if adaptor.get_bool('polygon_text', False):
 
 109             output |= GeometryFormat.TEXT
 
 111         if adaptor.get_bool('polygon_kml', False):
 
 112             output |= GeometryFormat.KML
 
 114         if adaptor.get_bool('polygon_svg', False):
 
 115             output |= GeometryFormat.SVG
 
 118     if numgeoms > adaptor.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
 
 119         adaptor.raise_error('Too many polygon output options selected.')
 
 121     return {'address_details': True,
 
 122             'geometry_simplification': adaptor.get_float('polygon_threshold', 0.0),
 
 123             'geometry_output': output
 
 127 async def status_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 128     """ Server glue for /status endpoint. See API docs for details.
 
 130     result = await api.status()
 
 132     fmt = parse_format(params, StatusResult, 'text')
 
 134     if fmt == 'text' and result.status:
 
 139     return build_response(params, params.formatting().format_result(result, fmt, {}),
 
 143 async def details_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 144     """ Server glue for /details endpoint. See API docs for details.
 
 146     fmt = parse_format(params, DetailedResult, 'json')
 
 147     place_id = params.get_int('place_id', 0)
 
 150         place = PlaceID(place_id)
 
 152         osmtype = params.get('osmtype')
 
 154             params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
 
 155         place = OsmID(osmtype, params.get_int('osmid'), params.get('class'))
 
 157     debug = setup_debugging(params)
 
 159     result = await api.details(place,
 
 160                                address_details=params.get_bool('addressdetails', False),
 
 161                                entrances=params.get_bool('entrances', False),
 
 162                                linked_places=params.get_bool('linkedplaces', True),
 
 163                                parented_places=params.get_bool('hierarchy', False),
 
 164                                keywords=params.get_bool('keywords', False),
 
 165                                geometry_output=(GeometryFormat.GEOJSON
 
 166                                                 if params.get_bool('polygon_geojson', False)
 
 167                                                 else GeometryFormat.NONE),
 
 171         return build_response(params, loglib.get_and_disable())
 
 174         params.raise_error('No place with that OSM ID found.', status=404)
 
 176     locales = Locales.from_accept_languages(get_accepted_languages(params))
 
 177     locales.localize_results([result])
 
 179     output = params.formatting().format_result(
 
 182          'group_hierarchy': params.get_bool('group_hierarchy', False),
 
 183          'icon_base_url': params.config().MAPICON_URL,
 
 184          'entrances': params.get_bool('entrances', False),
 
 187     return build_response(params, output, num_results=1)
 
 190 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 191     """ Server glue for /reverse endpoint. See API docs for details.
 
 193     fmt = parse_format(params, ReverseResults, 'xml')
 
 194     debug = setup_debugging(params)
 
 195     coord = Point(params.get_float('lon'), params.get_float('lat'))
 
 197     details = parse_geometry_details(params, fmt)
 
 198     details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
 
 199     details['layers'] = get_layers(params)
 
 201     result = await api.reverse(coord, **details)
 
 204         return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
 
 207         queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
 
 208         zoom = params.get('zoom', None)
 
 210             queryparts['zoom'] = zoom
 
 211         query = urlencode(queryparts)
 
 216         Locales.from_accept_languages(get_accepted_languages(params)).localize_results(
 
 219     fmt_options = {'query': query,
 
 220                    'extratags': params.get_bool('extratags', False),
 
 221                    'namedetails': params.get_bool('namedetails', False),
 
 222                    'entrances': params.get_bool('entrances', False),
 
 223                    'addressdetails': params.get_bool('addressdetails', True)}
 
 225     output = params.formatting().format_result(ReverseResults([result] if result else []),
 
 228     return build_response(params, output, num_results=1 if result else 0)
 
 231 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 232     """ Server glue for /lookup endpoint. See API docs for details.
 
 234     fmt = parse_format(params, SearchResults, 'xml')
 
 235     debug = setup_debugging(params)
 
 236     details = parse_geometry_details(params, fmt)
 
 239     for oid in (params.get('osm_ids') or '').split(','):
 
 241         if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
 
 242             places.append(OsmID(oid[0].upper(), int(oid[1:])))
 
 244     if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
 
 245         params.raise_error('Too many object IDs.')
 
 248         results = await api.lookup(places, **details)
 
 250         results = SearchResults()
 
 253         return build_response(params, loglib.get_and_disable(), num_results=len(results))
 
 255     Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
 
 257     fmt_options = {'extratags': params.get_bool('extratags', False),
 
 258                    'namedetails': params.get_bool('namedetails', False),
 
 259                    'entrances': params.get_bool('entrances', False),
 
 260                    'addressdetails': params.get_bool('addressdetails', True)}
 
 262     output = params.formatting().format_result(results, fmt, fmt_options)
 
 264     return build_response(params, output, num_results=len(results))
 
 267 async def _unstructured_search(query: str, api: NominatimAPIAsync,
 
 268                                details: Dict[str, Any]) -> SearchResults:
 
 270         return SearchResults()
 
 272     # Extract special format for coordinates from query.
 
 273     query, x, y = helpers.extract_coords_from_query(query)
 
 276         details['near'] = Point(x, y)
 
 277         details['near_radius'] = 0.1
 
 279     # If no query is left, revert to reverse search.
 
 280     if x is not None and not query:
 
 281         result = await api.reverse(details['near'], **details)
 
 283             return SearchResults()
 
 285         return SearchResults(
 
 286                   [SearchResult(**{f.name: getattr(result, f.name)
 
 287                                    for f in dataclasses.fields(SearchResult)
 
 288                                    if hasattr(result, f.name)})])
 
 290     query, cls, typ = helpers.extract_category_from_query(query)
 
 292         assert typ is not None
 
 293         return await api.search_category([(cls, typ)], near_query=query, **details)
 
 295     return await api.search(query, **details)
 
 298 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 299     """ Server glue for /search endpoint. See API docs for details.
 
 301     fmt = parse_format(params, SearchResults, 'jsonv2')
 
 302     debug = setup_debugging(params)
 
 303     details = parse_geometry_details(params, fmt)
 
 305     details['countries'] = params.get('countrycodes', None)
 
 306     details['entrances'] = params.get_bool('entrances', False)
 
 307     details['excluded'] = params.get('exclude_place_ids', None)
 
 308     details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
 
 309     details['bounded_viewbox'] = params.get_bool('bounded', False)
 
 310     details['dedupe'] = params.get_bool('dedupe', True)
 
 312     max_results = max(1, min(50, params.get_int('limit', 10)))
 
 313     details['max_results'] = (max_results + min(10, max_results)
 
 314                               if details['dedupe'] else max_results)
 
 316     details['min_rank'], details['max_rank'] = \
 
 317         helpers.feature_type_to_rank(params.get('featureType', ''))
 
 318     if params.get('featureType', None) is not None:
 
 319         details['layers'] = DataLayer.ADDRESS
 
 321         details['layers'] = get_layers(params)
 
 323     # unstructured query parameters
 
 324     query = params.get('q', None)
 
 325     # structured query parameters
 
 327     for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
 
 328         details[key] = params.get(key, None)
 
 330             queryparts[key] = details[key]
 
 333         if query is not None:
 
 335                 params.raise_error("Structured query parameters"
 
 336                                    "(amenity, street, city, county, state, postalcode, country)"
 
 337                                    " cannot be used together with 'q' parameter.")
 
 338             queryparts['q'] = query
 
 339             results = await _unstructured_search(query, api, details)
 
 341             query = ', '.join(queryparts.values())
 
 343             results = await api.search_address(**details)
 
 344     except UsageError as err:
 
 345         params.raise_error(str(err))
 
 347     Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
 
 349     if details['dedupe'] and len(results) > 1:
 
 350         results = helpers.deduplicate_results(results, max_results)
 
 353         return build_response(params, loglib.get_and_disable(), num_results=len(results))
 
 356         helpers.extend_query_parts(queryparts, details,
 
 357                                    params.get('featureType', ''),
 
 358                                    params.get_bool('namedetails', False),
 
 359                                    params.get_bool('extratags', False),
 
 360                                    (str(r.place_id) for r in results if r.place_id))
 
 361         queryparts['format'] = fmt
 
 363         moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
 
 367     fmt_options = {'query': query, 'more_url': moreurl,
 
 368                    'exclude_place_ids': queryparts.get('exclude_place_ids'),
 
 369                    'viewbox': queryparts.get('viewbox'),
 
 370                    'extratags': params.get_bool('extratags', False),
 
 371                    'namedetails': params.get_bool('namedetails', False),
 
 372                    'entrances': params.get_bool('entrances', False),
 
 373                    'addressdetails': params.get_bool('addressdetails', False)}
 
 375     output = params.formatting().format_result(results, fmt, fmt_options)
 
 377     return build_response(params, output, num_results=len(results))
 
 380 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 381     """ Server glue for /deletable endpoint.
 
 382         This is a special endpoint that shows polygons that have been
 
 383         deleted or are broken in the OSM data but are kept in the
 
 384         Nominatim database to minimize disruption.
 
 386     fmt = parse_format(params, RawDataList, 'json')
 
 388     results = RawDataList()
 
 389     async with api.begin() as conn:
 
 390         for osm_type in ('N', 'W', 'R'):
 
 391             sql = sa.text(""" SELECT p.place_id, country_code,
 
 392                                      name->'name' as name, i.*
 
 393                               FROM placex p, import_polygon_delete i
 
 394                               WHERE i.osm_type = :osm_type
 
 395                                     AND p.osm_id = i.osm_id AND p.osm_type = :osm_type
 
 396                                     AND p.class = i.class AND p.type = i.type
 
 398             results.extend(r._asdict() for r in await conn.execute(sql, {'osm_type': osm_type}))
 
 400     return build_response(params, params.formatting().format_result(results, fmt, {}))
 
 403 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 404     """ Server glue for /polygons endpoint.
 
 405         This is a special endpoint that shows polygons that have changed
 
 406         their size but are kept in the Nominatim database with their
 
 407         old area to minimize disruption.
 
 409     fmt = parse_format(params, RawDataList, 'json')
 
 410     sql_params: Dict[str, Any] = {
 
 411         'days': params.get_int('days', -1),
 
 412         'cls': params.get('class')
 
 414     reduced = params.get_bool('reduced', False)
 
 416     async with api.begin() as conn:
 
 417         sql = sa.select(sa.text("""osm_type, osm_id, class, type,
 
 418                                    name->'name' as name,
 
 419                                    country_code, errormessage, updated"""))\
 
 420                 .select_from(sa.text('import_polygon_error'))
 
 421         if sql_params['days'] > 0:
 
 422             sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
 
 424             sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
 
 425         if sql_params['cls'] is not None:
 
 426             sql = sql.where(sa.text("class = :cls"))
 
 428         sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
 
 430         results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
 
 432     return build_response(params, params.formatting().format_result(results, fmt, {}))
 
 435 async def get_routes(api: NominatimAPIAsync) -> Sequence[Tuple[str, EndpointFunc]]:
 
 437         ('status', status_endpoint),
 
 438         ('details', details_endpoint),
 
 439         ('reverse', reverse_endpoint),
 
 440         ('lookup', lookup_endpoint),
 
 441         ('deletable', deletable_endpoint),
 
 442         ('polygons', polygons_endpoint),
 
 445     def has_search_name(conn: sa.engine.Connection) -> bool:
 
 446         insp = sa.inspect(conn)
 
 447         return insp.has_table('search_name')
 
 450         async with api.begin() as conn:
 
 451             if await conn.connection.run_sync(has_search_name):
 
 452                 routes.append(('search', search_endpoint))
 
 453     except (PGCORE_ERROR, sa.exc.OperationalError):