1 # SPDX-License-Identifier: GPL-3.0-or-later
 
   3 # This file is part of Nominatim. (https://nominatim.org)
 
   5 # Copyright (C) 2025 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),
 
 168                                query_stats=params.query_stats()
 
 172         return build_response(params, loglib.get_and_disable())
 
 175         params.raise_error('No place with that OSM ID found.', status=404)
 
 177     locales = Locales.from_accept_languages(get_accepted_languages(params))
 
 178     locales.localize_results([result])
 
 180     output = params.formatting().format_result(
 
 183          'group_hierarchy': params.get_bool('group_hierarchy', False),
 
 184          'icon_base_url': params.config().MAPICON_URL,
 
 185          'entrances': params.get_bool('entrances', False),
 
 188     return build_response(params, output, num_results=1)
 
 191 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 192     """ Server glue for /reverse endpoint. See API docs for details.
 
 194     fmt = parse_format(params, ReverseResults, 'xml')
 
 195     debug = setup_debugging(params)
 
 196     coord = Point(params.get_float('lon'), params.get_float('lat'))
 
 198     details = parse_geometry_details(params, fmt)
 
 199     details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
 
 200     details['layers'] = get_layers(params)
 
 201     details['query_stats'] = params.query_stats()
 
 203     result = await api.reverse(coord, **details)
 
 206         return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
 
 209         queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
 
 210         zoom = params.get('zoom', None)
 
 212             queryparts['zoom'] = zoom
 
 213         query = urlencode(queryparts)
 
 218         Locales.from_accept_languages(get_accepted_languages(params)).localize_results(
 
 221     fmt_options = {'query': query,
 
 222                    'extratags': params.get_bool('extratags', False),
 
 223                    'namedetails': params.get_bool('namedetails', False),
 
 224                    'entrances': params.get_bool('entrances', False),
 
 225                    'addressdetails': params.get_bool('addressdetails', True)}
 
 227     output = params.formatting().format_result(ReverseResults([result] if result else []),
 
 230     return build_response(params, output, num_results=1 if result else 0)
 
 233 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 234     """ Server glue for /lookup endpoint. See API docs for details.
 
 236     fmt = parse_format(params, SearchResults, 'xml')
 
 237     debug = setup_debugging(params)
 
 238     details = parse_geometry_details(params, fmt)
 
 239     details['query_stats'] = params.query_stats()
 
 242     for oid in (params.get('osm_ids') or '').split(','):
 
 244         if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
 
 245             places.append(OsmID(oid[0].upper(), int(oid[1:])))
 
 247     if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
 
 248         params.raise_error('Too many object IDs.')
 
 251         results = await api.lookup(places, **details)
 
 253         results = SearchResults()
 
 256         return build_response(params, loglib.get_and_disable(), num_results=len(results))
 
 258     Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
 
 260     fmt_options = {'extratags': params.get_bool('extratags', False),
 
 261                    'namedetails': params.get_bool('namedetails', False),
 
 262                    'entrances': params.get_bool('entrances', False),
 
 263                    'addressdetails': params.get_bool('addressdetails', True)}
 
 265     output = params.formatting().format_result(results, fmt, fmt_options)
 
 267     return build_response(params, output, num_results=len(results))
 
 270 async def _unstructured_search(query: str, api: NominatimAPIAsync,
 
 271                                details: Dict[str, Any]) -> SearchResults:
 
 273         return SearchResults()
 
 275     # Extract special format for coordinates from query.
 
 276     query, x, y = helpers.extract_coords_from_query(query)
 
 279         details['near'] = Point(x, y)
 
 280         details['near_radius'] = 0.1
 
 282     # If no query is left, revert to reverse search.
 
 283     if x is not None and not query:
 
 284         result = await api.reverse(details['near'], **details)
 
 286             return SearchResults()
 
 288         return SearchResults(
 
 289                   [SearchResult(**{f.name: getattr(result, f.name)
 
 290                                    for f in dataclasses.fields(SearchResult)
 
 291                                    if hasattr(result, f.name)})])
 
 293     query, cls, typ = helpers.extract_category_from_query(query)
 
 295         assert typ is not None
 
 296         return await api.search_category([(cls, typ)], near_query=query, **details)
 
 298     return await api.search(query, **details)
 
 301 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 302     """ Server glue for /search endpoint. See API docs for details.
 
 304     fmt = parse_format(params, SearchResults, 'jsonv2')
 
 305     debug = setup_debugging(params)
 
 306     details = parse_geometry_details(params, fmt)
 
 308     details['query_stats'] = params.query_stats()
 
 309     details['countries'] = params.get('countrycodes', None)
 
 310     details['entrances'] = params.get_bool('entrances', False)
 
 311     details['excluded'] = params.get('exclude_place_ids', None)
 
 312     details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
 
 313     details['bounded_viewbox'] = params.get_bool('bounded', False)
 
 314     details['dedupe'] = params.get_bool('dedupe', True)
 
 316     max_results = max(1, min(50, params.get_int('limit', 10)))
 
 317     details['max_results'] = (max_results + min(10, max_results)
 
 318                               if details['dedupe'] else max_results)
 
 320     details['min_rank'], details['max_rank'] = \
 
 321         helpers.feature_type_to_rank(params.get('featureType', ''))
 
 322     if params.get('featureType', None) is not None:
 
 323         details['layers'] = DataLayer.ADDRESS
 
 325         details['layers'] = get_layers(params)
 
 327     # unstructured query parameters
 
 328     query = params.get('q', None)
 
 329     # structured query parameters
 
 331     for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
 
 332         details[key] = params.get(key, None)
 
 334             queryparts[key] = details[key]
 
 337         if query is not None:
 
 339                 params.raise_error("Structured query parameters"
 
 340                                    "(amenity, street, city, county, state, postalcode, country)"
 
 341                                    " cannot be used together with 'q' parameter.")
 
 342             queryparts['q'] = query
 
 343             results = await _unstructured_search(query, api, details)
 
 345             query = ', '.join(queryparts.values())
 
 347             results = await api.search_address(**details)
 
 348     except UsageError as err:
 
 349         params.raise_error(str(err))
 
 351     Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
 
 353     if details['dedupe'] and len(results) > 1:
 
 354         results = helpers.deduplicate_results(results, max_results)
 
 357         return build_response(params, loglib.get_and_disable(), num_results=len(results))
 
 360         helpers.extend_query_parts(queryparts, details,
 
 361                                    params.get('featureType', ''),
 
 362                                    params.get_bool('namedetails', False),
 
 363                                    params.get_bool('extratags', False),
 
 364                                    (str(r.place_id) for r in results if r.place_id))
 
 365         queryparts['format'] = fmt
 
 367         moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
 
 371     fmt_options = {'query': query, 'more_url': moreurl,
 
 372                    'exclude_place_ids': queryparts.get('exclude_place_ids'),
 
 373                    'viewbox': queryparts.get('viewbox'),
 
 374                    'extratags': params.get_bool('extratags', False),
 
 375                    'namedetails': params.get_bool('namedetails', False),
 
 376                    'entrances': params.get_bool('entrances', False),
 
 377                    'addressdetails': params.get_bool('addressdetails', False)}
 
 379     output = params.formatting().format_result(results, fmt, fmt_options)
 
 381     return build_response(params, output, num_results=len(results))
 
 384 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 385     """ Server glue for /deletable endpoint.
 
 386         This is a special endpoint that shows polygons that have been
 
 387         deleted or are broken in the OSM data but are kept in the
 
 388         Nominatim database to minimize disruption.
 
 390     fmt = parse_format(params, RawDataList, 'json')
 
 392     results = RawDataList()
 
 393     async with api.begin() as conn:
 
 394         for osm_type in ('N', 'W', 'R'):
 
 395             sql = sa.text(""" SELECT p.place_id, country_code,
 
 396                                      name->'name' as name, i.*
 
 397                               FROM placex p, import_polygon_delete i
 
 398                               WHERE i.osm_type = :osm_type
 
 399                                     AND p.osm_id = i.osm_id AND p.osm_type = :osm_type
 
 400                                     AND p.class = i.class AND p.type = i.type
 
 402             results.extend(r._asdict() for r in await conn.execute(sql, {'osm_type': osm_type}))
 
 404     return build_response(params, params.formatting().format_result(results, fmt, {}))
 
 407 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
 
 408     """ Server glue for /polygons endpoint.
 
 409         This is a special endpoint that shows polygons that have changed
 
 410         their size but are kept in the Nominatim database with their
 
 411         old area to minimize disruption.
 
 413     fmt = parse_format(params, RawDataList, 'json')
 
 414     sql_params: Dict[str, Any] = {
 
 415         'days': params.get_int('days', -1),
 
 416         'cls': params.get('class')
 
 418     reduced = params.get_bool('reduced', False)
 
 420     async with api.begin() as conn:
 
 421         sql = sa.select(sa.text("""osm_type, osm_id, class, type,
 
 422                                    name->'name' as name,
 
 423                                    country_code, errormessage, updated"""))\
 
 424                 .select_from(sa.text('import_polygon_error'))
 
 425         if sql_params['days'] > 0:
 
 426             sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
 
 428             sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
 
 429         if sql_params['cls'] is not None:
 
 430             sql = sql.where(sa.text("class = :cls"))
 
 432         sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
 
 434         results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
 
 436     return build_response(params, params.formatting().format_result(results, fmt, {}))
 
 439 async def get_routes(api: NominatimAPIAsync) -> Sequence[Tuple[str, EndpointFunc]]:
 
 441         ('status', status_endpoint),
 
 442         ('details', details_endpoint),
 
 443         ('reverse', reverse_endpoint),
 
 444         ('lookup', lookup_endpoint),
 
 445         ('deletable', deletable_endpoint),
 
 446         ('polygons', polygons_endpoint),
 
 449     def has_search_name(conn: sa.engine.Connection) -> bool:
 
 450         insp = sa.inspect(conn)
 
 451         return insp.has_table('search_name')
 
 454         async with api.begin() as conn:
 
 455             if await conn.connection.run_sync(has_search_name):
 
 456                 routes.append(('search', search_endpoint))
 
 457     except (PGCORE_ERROR, sa.exc.OperationalError):