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 ..errors import UsageError
21 from ..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 get_int(self, name: str, default: Optional[int] = None) -> int:
90 """ Return an input parameter as an int. Raises an exception if
91 the parameter is given but not in an integer format.
93 If 'default' is given, then it will be returned when the parameter
94 is missing completely. When 'default' is None, an error will be
95 raised on a missing parameter.
97 value = self.get(name)
100 if default is not None:
103 self.raise_error(f"Parameter '{name}' missing.")
108 self.raise_error(f"Parameter '{name}' must be a number.")
113 def get_float(self, name: str, default: Optional[float] = None) -> float:
114 """ Return an input parameter as a flaoting-point number. Raises an
115 exception if the parameter is given but not in an float format.
117 If 'default' is given, then it will be returned when the parameter
118 is missing completely. When 'default' is None, an error will be
119 raised on a missing parameter.
121 value = self.get(name)
124 if default is not None:
127 self.raise_error(f"Parameter '{name}' missing.")
132 self.raise_error(f"Parameter '{name}' must be a number.")
134 if math.isnan(fval) or math.isinf(fval):
135 self.raise_error(f"Parameter '{name}' must be a number.")
140 def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
141 """ Return an input parameter as bool. Only '0' is accepted as
142 an input for 'false' all other inputs will be interpreted as 'true'.
144 If 'default' is given, then it will be returned when the parameter
145 is missing completely. When 'default' is None, an error will be
146 raised on a missing parameter.
148 value = self.get(name)
151 if default is not None:
154 self.raise_error(f"Parameter '{name}' missing.")
159 def raise_error(self, msg: str, status: int = 400) -> NoReturn:
160 """ Raise an exception resulting in the given HTTP status and
161 message. The message will be formatted according to the
162 output format chosen by the request.
164 if self.content_type == CONTENT_XML:
165 msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
167 <code>{status}</code>
168 <message>{msg}</message>
171 elif self.content_type == CONTENT_JSON:
172 msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
173 elif self.content_type == CONTENT_HTML:
174 loglib.log().section('Execution error')
175 loglib.log().var_dump('Status', status)
176 loglib.log().var_dump('Message', msg)
177 msg = loglib.get_and_disable()
179 raise self.error(msg, status)
182 def build_response(adaptor: ASGIAdaptor, output: str, status: int = 200,
183 num_results: int = 0) -> Any:
184 """ Create a response from the given output. Wraps a JSONP function
185 around the response, if necessary.
187 if adaptor.content_type == CONTENT_JSON and status == 200:
188 jsonp = adaptor.get('json_callback')
189 if jsonp is not None:
190 if any(not part.isidentifier() for part in jsonp.split('.')):
191 adaptor.raise_error('Invalid json_callback value')
192 output = f"{jsonp}({output})"
193 adaptor.content_type = 'application/javascript; charset=utf-8'
195 return adaptor.create_response(status, output, num_results)
198 def get_accepted_languages(adaptor: ASGIAdaptor) -> str:
199 """ Return the accepted languages.
201 return adaptor.get('accept-language')\
202 or adaptor.get_header('accept-language')\
203 or adaptor.config().DEFAULT_LANGUAGE
206 def setup_debugging(adaptor: ASGIAdaptor) -> bool:
207 """ Set up collection of debug information if requested.
209 Return True when debugging was requested.
211 if adaptor.get_bool('debug', False):
212 loglib.set_log_output('html')
213 adaptor.content_type = CONTENT_HTML
219 def get_layers(adaptor: ASGIAdaptor) -> Optional[DataLayer]:
220 """ Return a parsed version of the layer parameter.
222 param = adaptor.get('layer', None)
226 return cast(DataLayer,
227 reduce(DataLayer.__or__,
228 (getattr(DataLayer, s.upper()) for s in param.split(','))))
231 def parse_format(adaptor: ASGIAdaptor, result_type: Type[Any], default: str) -> str:
232 """ Get and check the 'format' parameter and prepare the formatter.
233 `result_type` is the type of result to be returned by the function
234 and `default` the format value to assume when no parameter is present.
236 fmt = adaptor.get('format', default=default)
237 assert fmt is not None
239 if not formatting.supports_format(result_type, fmt):
240 adaptor.raise_error("Parameter 'format' must be one of: " +
241 ', '.join(formatting.list_formats(result_type)))
243 adaptor.content_type = CONTENT_TYPE.get(fmt, CONTENT_JSON)
247 def parse_geometry_details(adaptor: ASGIAdaptor, fmt: str) -> Dict[str, Any]:
248 """ Create details structure from the supplied geometry parameters.
251 output = GeometryFormat.NONE
252 if adaptor.get_bool('polygon_geojson', False):
253 output |= GeometryFormat.GEOJSON
255 if fmt not in ('geojson', 'geocodejson'):
256 if adaptor.get_bool('polygon_text', False):
257 output |= GeometryFormat.TEXT
259 if adaptor.get_bool('polygon_kml', False):
260 output |= GeometryFormat.KML
262 if adaptor.get_bool('polygon_svg', False):
263 output |= GeometryFormat.SVG
266 if numgeoms > adaptor.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
267 adaptor.raise_error('Too many polygon output options selected.')
269 return {'address_details': True,
270 'geometry_simplification': adaptor.get_float('polygon_threshold', 0.0),
271 'geometry_output': output
275 async def status_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
276 """ Server glue for /status endpoint. See API docs for details.
278 result = await api.status()
280 fmt = parse_format(params, StatusResult, 'text')
282 if fmt == 'text' and result.status:
287 return build_response(params, formatting.format_result(result, fmt, {}),
291 async def details_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
292 """ Server glue for /details endpoint. See API docs for details.
294 fmt = parse_format(params, DetailedResult, 'json')
295 place_id = params.get_int('place_id', 0)
298 place = PlaceID(place_id)
300 osmtype = params.get('osmtype')
302 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
303 place = OsmID(osmtype, params.get_int('osmid'), params.get('class'))
305 debug = setup_debugging(params)
307 locales = Locales.from_accept_languages(get_accepted_languages(params))
309 result = await api.details(place,
310 address_details=params.get_bool('addressdetails', False),
311 linked_places=params.get_bool('linkedplaces', True),
312 parented_places=params.get_bool('hierarchy', False),
313 keywords=params.get_bool('keywords', False),
314 geometry_output = GeometryFormat.GEOJSON
315 if params.get_bool('polygon_geojson', False)
316 else GeometryFormat.NONE,
321 return build_response(params, loglib.get_and_disable())
324 params.raise_error('No place with that OSM ID found.', status=404)
326 output = formatting.format_result(result, fmt,
328 'group_hierarchy': params.get_bool('group_hierarchy', False),
329 'icon_base_url': params.config().MAPICON_URL})
331 return build_response(params, output, num_results=1)
334 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
335 """ Server glue for /reverse endpoint. See API docs for details.
337 fmt = parse_format(params, ReverseResults, 'xml')
338 debug = setup_debugging(params)
339 coord = Point(params.get_float('lon'), params.get_float('lat'))
341 details = parse_geometry_details(params, fmt)
342 details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
343 details['layers'] = get_layers(params)
344 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
346 result = await api.reverse(coord, **details)
349 return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
352 queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
353 zoom = params.get('zoom', None)
355 queryparts['zoom'] = zoom
356 query = urlencode(queryparts)
360 fmt_options = {'query': query,
361 'extratags': params.get_bool('extratags', False),
362 'namedetails': params.get_bool('namedetails', False),
363 'addressdetails': params.get_bool('addressdetails', True)}
365 output = formatting.format_result(ReverseResults([result] if result else []),
368 return build_response(params, output, num_results=1 if result else 0)
371 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
372 """ Server glue for /lookup endpoint. See API docs for details.
374 fmt = parse_format(params, SearchResults, 'xml')
375 debug = setup_debugging(params)
376 details = parse_geometry_details(params, fmt)
377 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
380 for oid in (params.get('osm_ids') or '').split(','):
382 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
383 places.append(OsmID(oid[0].upper(), int(oid[1:])))
385 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
386 params.raise_error('Too many object IDs.')
389 results = await api.lookup(places, **details)
391 results = SearchResults()
394 return build_response(params, loglib.get_and_disable(), num_results=len(results))
396 fmt_options = {'extratags': params.get_bool('extratags', False),
397 'namedetails': params.get_bool('namedetails', False),
398 'addressdetails': params.get_bool('addressdetails', True)}
400 output = formatting.format_result(results, fmt, fmt_options)
402 return build_response(params, output, num_results=len(results))
405 async def _unstructured_search(query: str, api: NominatimAPIAsync,
406 details: Dict[str, Any]) -> SearchResults:
408 return SearchResults()
410 # Extract special format for coordinates from query.
411 query, x, y = helpers.extract_coords_from_query(query)
414 details['near'] = Point(x, y)
415 details['near_radius'] = 0.1
417 # If no query is left, revert to reverse search.
418 if x is not None and not query:
419 result = await api.reverse(details['near'], **details)
421 return SearchResults()
423 return SearchResults(
424 [SearchResult(**{f.name: getattr(result, f.name)
425 for f in dataclasses.fields(SearchResult)
426 if hasattr(result, f.name)})])
428 query, cls, typ = helpers.extract_category_from_query(query)
430 assert typ is not None
431 return await api.search_category([(cls, typ)], near_query=query, **details)
433 return await api.search(query, **details)
436 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
437 """ Server glue for /search endpoint. See API docs for details.
439 fmt = parse_format(params, SearchResults, 'jsonv2')
440 debug = setup_debugging(params)
441 details = parse_geometry_details(params, fmt)
443 details['countries'] = params.get('countrycodes', None)
444 details['excluded'] = params.get('exclude_place_ids', None)
445 details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
446 details['bounded_viewbox'] = params.get_bool('bounded', False)
447 details['dedupe'] = params.get_bool('dedupe', True)
449 max_results = max(1, min(50, params.get_int('limit', 10)))
450 details['max_results'] = max_results + min(10, max_results) \
451 if details['dedupe'] else max_results
453 details['min_rank'], details['max_rank'] = \
454 helpers.feature_type_to_rank(params.get('featureType', ''))
455 if params.get('featureType', None) is not None:
456 details['layers'] = DataLayer.ADDRESS
458 details['layers'] = get_layers(params)
460 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
462 # unstructured query parameters
463 query = params.get('q', None)
464 # structured query parameters
466 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
467 details[key] = params.get(key, None)
469 queryparts[key] = details[key]
472 if query is not None:
474 params.raise_error("Structured query parameters"
475 "(amenity, street, city, county, state, postalcode, country)"
476 " cannot be used together with 'q' parameter.")
477 queryparts['q'] = query
478 results = await _unstructured_search(query, api, details)
480 query = ', '.join(queryparts.values())
482 results = await api.search_address(**details)
483 except UsageError as err:
484 params.raise_error(str(err))
486 if details['dedupe'] and len(results) > 1:
487 results = helpers.deduplicate_results(results, max_results)
490 return build_response(params, loglib.get_and_disable(), num_results=len(results))
493 helpers.extend_query_parts(queryparts, details,
494 params.get('featureType', ''),
495 params.get_bool('namedetails', False),
496 params.get_bool('extratags', False),
497 (str(r.place_id) for r in results if r.place_id))
498 queryparts['format'] = fmt
500 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
504 fmt_options = {'query': query, 'more_url': moreurl,
505 'exclude_place_ids': queryparts.get('exclude_place_ids'),
506 'viewbox': queryparts.get('viewbox'),
507 'extratags': params.get_bool('extratags', False),
508 'namedetails': params.get_bool('namedetails', False),
509 'addressdetails': params.get_bool('addressdetails', False)}
511 output = formatting.format_result(results, fmt, fmt_options)
513 return build_response(params, output, num_results=len(results))
516 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
517 """ Server glue for /deletable endpoint.
518 This is a special endpoint that shows polygons that have been
519 deleted or are broken in the OSM data but are kept in the
520 Nominatim database to minimize disruption.
522 fmt = parse_format(params, RawDataList, 'json')
524 async with api.begin() as conn:
525 sql = sa.text(""" SELECT p.place_id, country_code,
526 name->'name' as name, i.*
527 FROM placex p, import_polygon_delete i
528 WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
529 AND p.class = i.class AND p.type = i.type
531 results = RawDataList(r._asdict() for r in await conn.execute(sql))
533 return build_response(params, formatting.format_result(results, fmt, {}))
536 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
537 """ Server glue for /polygons endpoint.
538 This is a special endpoint that shows polygons that have changed
539 their size but are kept in the Nominatim database with their
540 old area to minimize disruption.
542 fmt = parse_format(params, RawDataList, 'json')
543 sql_params: Dict[str, Any] = {
544 'days': params.get_int('days', -1),
545 'cls': params.get('class')
547 reduced = params.get_bool('reduced', False)
549 async with api.begin() as conn:
550 sql = sa.select(sa.text("""osm_type, osm_id, class, type,
551 name->'name' as name,
552 country_code, errormessage, updated"""))\
553 .select_from(sa.text('import_polygon_error'))
554 if sql_params['days'] > 0:
555 sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
557 sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
558 if sql_params['cls'] is not None:
559 sql = sql.where(sa.text("class = :cls"))
561 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
563 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
565 return build_response(params, formatting.format_result(results, fmt, {}))
568 EndpointFunc = Callable[[NominatimAPIAsync, ASGIAdaptor], Any]
571 ('status', status_endpoint),
572 ('details', details_endpoint),
573 ('reverse', reverse_endpoint),
574 ('lookup', lookup_endpoint),
575 ('search', search_endpoint),
576 ('deletable', deletable_endpoint),
577 ('polygons', polygons_endpoint),