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, Callable, NoReturn, Dict, cast
12 from functools import reduce
16 from urllib.parse import urlencode
18 import sqlalchemy as sa
20 from nominatim_core.errors import UsageError
21 from nominatim_core.config import Configuration
22 from .. import logging as loglib
23 from ..core import NominatimAPIAsync
24 from .format import dispatch as formatting
25 from .format import RawDataList
26 from ..types import DataLayer, GeometryFormat, PlaceRef, PlaceID, OsmID, Point
27 from ..status import StatusResult
28 from ..results import DetailedResult, ReverseResults, SearchResult, SearchResults
29 from ..localization import Locales
32 CONTENT_TEXT = 'text/plain; charset=utf-8'
33 CONTENT_XML = 'text/xml; charset=utf-8'
34 CONTENT_HTML = 'text/html; charset=utf-8'
35 CONTENT_JSON = 'application/json; charset=utf-8'
37 CONTENT_TYPE = {'text': CONTENT_TEXT, 'xml': CONTENT_XML, 'debug': CONTENT_HTML}
39 class ASGIAdaptor(abc.ABC):
40 """ Adapter class for the different ASGI frameworks.
41 Wraps functionality over concrete requests and responses.
43 content_type: str = CONTENT_TEXT
46 def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
47 """ Return an input parameter as a string. If the parameter was
48 not provided, return the 'default' value.
52 def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
53 """ Return a HTTP header parameter as a string. If the parameter was
54 not provided, return the 'default' value.
59 def error(self, msg: str, status: int = 400) -> Exception:
60 """ Construct an appropriate exception from the given error message.
61 The exception must result in a HTTP error with the given status.
66 def create_response(self, status: int, output: str, num_results: int) -> Any:
67 """ Create a response from the given parameters. The result will
68 be returned by the endpoint functions. The adaptor may also
69 return None when the response is created internally with some
72 The response must return the HTTP given status code 'status', set
73 the HTTP content-type headers to the string provided and the
74 body of the response to 'output'.
78 def base_uri(self) -> str:
79 """ Return the URI of the original request.
84 def config(self) -> Configuration:
85 """ Return the current configuration object.
89 def build_response(self, output: str, status: int = 200, num_results: int = 0) -> Any:
90 """ Create a response from the given output. Wraps a JSONP function
91 around the response, if necessary.
93 if self.content_type == CONTENT_JSON and status == 200:
94 jsonp = self.get('json_callback')
96 if any(not part.isidentifier() for part in jsonp.split('.')):
97 self.raise_error('Invalid json_callback value')
98 output = f"{jsonp}({output})"
99 self.content_type = 'application/javascript; charset=utf-8'
101 return self.create_response(status, output, num_results)
104 def raise_error(self, msg: str, status: int = 400) -> NoReturn:
105 """ Raise an exception resulting in the given HTTP status and
106 message. The message will be formatted according to the
107 output format chosen by the request.
109 if self.content_type == CONTENT_XML:
110 msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
112 <code>{status}</code>
113 <message>{msg}</message>
116 elif self.content_type == CONTENT_JSON:
117 msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
118 elif self.content_type == CONTENT_HTML:
119 loglib.log().section('Execution error')
120 loglib.log().var_dump('Status', status)
121 loglib.log().var_dump('Message', msg)
122 msg = loglib.get_and_disable()
124 raise self.error(msg, status)
127 def get_int(self, name: str, default: Optional[int] = None) -> int:
128 """ Return an input parameter as an int. Raises an exception if
129 the parameter is given but not in an integer format.
131 If 'default' is given, then it will be returned when the parameter
132 is missing completely. When 'default' is None, an error will be
133 raised on a missing parameter.
135 value = self.get(name)
138 if default is not None:
141 self.raise_error(f"Parameter '{name}' missing.")
146 self.raise_error(f"Parameter '{name}' must be a number.")
151 def get_float(self, name: str, default: Optional[float] = None) -> float:
152 """ Return an input parameter as a flaoting-point number. Raises an
153 exception if the parameter is given but not in an float format.
155 If 'default' is given, then it will be returned when the parameter
156 is missing completely. When 'default' is None, an error will be
157 raised on a missing parameter.
159 value = self.get(name)
162 if default is not None:
165 self.raise_error(f"Parameter '{name}' missing.")
170 self.raise_error(f"Parameter '{name}' must be a number.")
172 if math.isnan(fval) or math.isinf(fval):
173 self.raise_error(f"Parameter '{name}' must be a number.")
178 def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
179 """ Return an input parameter as bool. Only '0' is accepted as
180 an input for 'false' all other inputs will be interpreted as 'true'.
182 If 'default' is given, then it will be returned when the parameter
183 is missing completely. When 'default' is None, an error will be
184 raised on a missing parameter.
186 value = self.get(name)
189 if default is not None:
192 self.raise_error(f"Parameter '{name}' missing.")
197 def get_accepted_languages(self) -> str:
198 """ Return the accepted languages.
200 return self.get('accept-language')\
201 or self.get_header('accept-language')\
202 or self.config().DEFAULT_LANGUAGE
205 def setup_debugging(self) -> bool:
206 """ Set up collection of debug information if requested.
208 Return True when debugging was requested.
210 if self.get_bool('debug', False):
211 loglib.set_log_output('html')
212 self.content_type = CONTENT_HTML
218 def get_layers(self) -> Optional[DataLayer]:
219 """ Return a parsed version of the layer parameter.
221 param = self.get('layer', None)
225 return cast(DataLayer,
226 reduce(DataLayer.__or__,
227 (getattr(DataLayer, s.upper()) for s in param.split(','))))
230 def parse_format(self, result_type: Type[Any], default: str) -> str:
231 """ Get and check the 'format' parameter and prepare the formatter.
232 `result_type` is the type of result to be returned by the function
233 and `default` the format value to assume when no parameter is present.
235 fmt = self.get('format', default=default)
236 assert fmt is not None
238 if not formatting.supports_format(result_type, fmt):
239 self.raise_error("Parameter 'format' must be one of: " +
240 ', '.join(formatting.list_formats(result_type)))
242 self.content_type = CONTENT_TYPE.get(fmt, CONTENT_JSON)
246 def parse_geometry_details(self, fmt: str) -> Dict[str, Any]:
247 """ Create details structure from the supplied geometry parameters.
250 output = GeometryFormat.NONE
251 if self.get_bool('polygon_geojson', False):
252 output |= GeometryFormat.GEOJSON
254 if fmt not in ('geojson', 'geocodejson'):
255 if self.get_bool('polygon_text', False):
256 output |= GeometryFormat.TEXT
258 if self.get_bool('polygon_kml', False):
259 output |= GeometryFormat.KML
261 if self.get_bool('polygon_svg', False):
262 output |= GeometryFormat.SVG
265 if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
266 self.raise_error('Too many polygon output options selected.')
268 return {'address_details': True,
269 'geometry_simplification': self.get_float('polygon_threshold', 0.0),
270 'geometry_output': output
274 async def status_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
275 """ Server glue for /status endpoint. See API docs for details.
277 result = await api.status()
279 fmt = params.parse_format(StatusResult, 'text')
281 if fmt == 'text' and result.status:
286 return params.build_response(formatting.format_result(result, fmt, {}),
290 async def details_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
291 """ Server glue for /details endpoint. See API docs for details.
293 fmt = params.parse_format(DetailedResult, 'json')
294 place_id = params.get_int('place_id', 0)
297 place = PlaceID(place_id)
299 osmtype = params.get('osmtype')
301 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
302 place = OsmID(osmtype, params.get_int('osmid'), params.get('class'))
304 debug = params.setup_debugging()
306 locales = Locales.from_accept_languages(params.get_accepted_languages())
308 result = await api.details(place,
309 address_details=params.get_bool('addressdetails', False),
310 linked_places=params.get_bool('linkedplaces', True),
311 parented_places=params.get_bool('hierarchy', False),
312 keywords=params.get_bool('keywords', False),
313 geometry_output = GeometryFormat.GEOJSON
314 if params.get_bool('polygon_geojson', False)
315 else GeometryFormat.NONE,
320 return params.build_response(loglib.get_and_disable())
323 params.raise_error('No place with that OSM ID found.', status=404)
325 output = formatting.format_result(result, fmt,
327 'group_hierarchy': params.get_bool('group_hierarchy', False),
328 'icon_base_url': params.config().MAPICON_URL})
330 return params.build_response(output, num_results=1)
333 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
334 """ Server glue for /reverse endpoint. See API docs for details.
336 fmt = params.parse_format(ReverseResults, 'xml')
337 debug = params.setup_debugging()
338 coord = Point(params.get_float('lon'), params.get_float('lat'))
340 details = params.parse_geometry_details(fmt)
341 details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
342 details['layers'] = params.get_layers()
343 details['locales'] = Locales.from_accept_languages(params.get_accepted_languages())
345 result = await api.reverse(coord, **details)
348 return params.build_response(loglib.get_and_disable(), num_results=1 if result else 0)
351 queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
352 zoom = params.get('zoom', None)
354 queryparts['zoom'] = zoom
355 query = urlencode(queryparts)
359 fmt_options = {'query': query,
360 'extratags': params.get_bool('extratags', False),
361 'namedetails': params.get_bool('namedetails', False),
362 'addressdetails': params.get_bool('addressdetails', True)}
364 output = formatting.format_result(ReverseResults([result] if result else []),
367 return params.build_response(output, num_results=1 if result else 0)
370 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
371 """ Server glue for /lookup endpoint. See API docs for details.
373 fmt = params.parse_format(SearchResults, 'xml')
374 debug = params.setup_debugging()
375 details = params.parse_geometry_details(fmt)
376 details['locales'] = Locales.from_accept_languages(params.get_accepted_languages())
379 for oid in (params.get('osm_ids') or '').split(','):
381 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
382 places.append(OsmID(oid[0].upper(), int(oid[1:])))
384 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
385 params.raise_error('Too many object IDs.')
388 results = await api.lookup(places, **details)
390 results = SearchResults()
393 return params.build_response(loglib.get_and_disable(), num_results=len(results))
395 fmt_options = {'extratags': params.get_bool('extratags', False),
396 'namedetails': params.get_bool('namedetails', False),
397 'addressdetails': params.get_bool('addressdetails', True)}
399 output = formatting.format_result(results, fmt, fmt_options)
401 return params.build_response(output, num_results=len(results))
404 async def _unstructured_search(query: str, api: NominatimAPIAsync,
405 details: Dict[str, Any]) -> SearchResults:
407 return SearchResults()
409 # Extract special format for coordinates from query.
410 query, x, y = helpers.extract_coords_from_query(query)
413 details['near'] = Point(x, y)
414 details['near_radius'] = 0.1
416 # If no query is left, revert to reverse search.
417 if x is not None and not query:
418 result = await api.reverse(details['near'], **details)
420 return SearchResults()
422 return SearchResults(
423 [SearchResult(**{f.name: getattr(result, f.name)
424 for f in dataclasses.fields(SearchResult)
425 if hasattr(result, f.name)})])
427 query, cls, typ = helpers.extract_category_from_query(query)
429 assert typ is not None
430 return await api.search_category([(cls, typ)], near_query=query, **details)
432 return await api.search(query, **details)
435 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
436 """ Server glue for /search endpoint. See API docs for details.
438 fmt = params.parse_format(SearchResults, 'jsonv2')
439 debug = params.setup_debugging()
440 details = params.parse_geometry_details(fmt)
442 details['countries'] = params.get('countrycodes', None)
443 details['excluded'] = params.get('exclude_place_ids', None)
444 details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
445 details['bounded_viewbox'] = params.get_bool('bounded', False)
446 details['dedupe'] = params.get_bool('dedupe', True)
448 max_results = max(1, min(50, params.get_int('limit', 10)))
449 details['max_results'] = max_results + min(10, max_results) \
450 if details['dedupe'] else max_results
452 details['min_rank'], details['max_rank'] = \
453 helpers.feature_type_to_rank(params.get('featureType', ''))
454 if params.get('featureType', None) is not None:
455 details['layers'] = DataLayer.ADDRESS
457 details['layers'] = params.get_layers()
459 details['locales'] = Locales.from_accept_languages(params.get_accepted_languages())
461 # unstructured query parameters
462 query = params.get('q', None)
463 # structured query parameters
465 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
466 details[key] = params.get(key, None)
468 queryparts[key] = details[key]
471 if query is not None:
473 params.raise_error("Structured query parameters"
474 "(amenity, street, city, county, state, postalcode, country)"
475 " cannot be used together with 'q' parameter.")
476 queryparts['q'] = query
477 results = await _unstructured_search(query, api, details)
479 query = ', '.join(queryparts.values())
481 results = await api.search_address(**details)
482 except UsageError as err:
483 params.raise_error(str(err))
485 if details['dedupe'] and len(results) > 1:
486 results = helpers.deduplicate_results(results, max_results)
489 return params.build_response(loglib.get_and_disable(), num_results=len(results))
492 helpers.extend_query_parts(queryparts, details,
493 params.get('featureType', ''),
494 params.get_bool('namedetails', False),
495 params.get_bool('extratags', False),
496 (str(r.place_id) for r in results if r.place_id))
497 queryparts['format'] = fmt
499 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
503 fmt_options = {'query': query, 'more_url': moreurl,
504 'exclude_place_ids': queryparts.get('exclude_place_ids'),
505 'viewbox': queryparts.get('viewbox'),
506 'extratags': params.get_bool('extratags', False),
507 'namedetails': params.get_bool('namedetails', False),
508 'addressdetails': params.get_bool('addressdetails', False)}
510 output = formatting.format_result(results, fmt, fmt_options)
512 return params.build_response(output, num_results=len(results))
515 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
516 """ Server glue for /deletable endpoint.
517 This is a special endpoint that shows polygons that have been
518 deleted or are broken in the OSM data but are kept in the
519 Nominatim database to minimize disruption.
521 fmt = params.parse_format(RawDataList, 'json')
523 async with api.begin() as conn:
524 sql = sa.text(""" SELECT p.place_id, country_code,
525 name->'name' as name, i.*
526 FROM placex p, import_polygon_delete i
527 WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
528 AND p.class = i.class AND p.type = i.type
530 results = RawDataList(r._asdict() for r in await conn.execute(sql))
532 return params.build_response(formatting.format_result(results, fmt, {}))
535 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
536 """ Server glue for /polygons endpoint.
537 This is a special endpoint that shows polygons that have changed
538 their size but are kept in the Nominatim database with their
539 old area to minimize disruption.
541 fmt = params.parse_format(RawDataList, 'json')
542 sql_params: Dict[str, Any] = {
543 'days': params.get_int('days', -1),
544 'cls': params.get('class')
546 reduced = params.get_bool('reduced', False)
548 async with api.begin() as conn:
549 sql = sa.select(sa.text("""osm_type, osm_id, class, type,
550 name->'name' as name,
551 country_code, errormessage, updated"""))\
552 .select_from(sa.text('import_polygon_error'))
553 if sql_params['days'] > 0:
554 sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
556 sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
557 if sql_params['cls'] is not None:
558 sql = sql.where(sa.text("class = :cls"))
560 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
562 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
564 return params.build_response(formatting.format_result(results, fmt, {}))
567 EndpointFunc = Callable[[NominatimAPIAsync, ASGIAdaptor], Any]
570 ('status', status_endpoint),
571 ('details', details_endpoint),
572 ('reverse', reverse_endpoint),
573 ('lookup', lookup_endpoint),
574 ('search', search_endpoint),
575 ('deletable', deletable_endpoint),
576 ('polygons', polygons_endpoint),