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):