1 # SPDX-License-Identifier: GPL-2.0-only
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2023 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.errors import UsageError
21 from nominatim.config import Configuration
22 import nominatim.api as napi
23 import nominatim.api.logging as loglib
24 from nominatim.api.v1.format import dispatch as formatting
25 from nominatim.api.v1.format import RawDataList
26 from nominatim.api.v1 import helpers
29 'text': 'text/plain; charset=utf-8',
30 'xml': 'text/xml; charset=utf-8',
31 'debug': 'text/html; charset=utf-8'
34 class ASGIAdaptor(abc.ABC):
35 """ Adapter class for the different ASGI frameworks.
36 Wraps functionality over concrete requests and responses.
38 content_type: str = 'text/plain; charset=utf-8'
41 def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
42 """ Return an input parameter as a string. If the parameter was
43 not provided, return the 'default' value.
47 def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
48 """ Return a HTTP header parameter as a string. If the parameter was
49 not provided, return the 'default' value.
54 def error(self, msg: str, status: int = 400) -> Exception:
55 """ Construct an appropriate exception from the given error message.
56 The exception must result in a HTTP error with the given status.
61 def create_response(self, status: int, output: str) -> Any:
62 """ Create a response from the given parameters. The result will
63 be returned by the endpoint functions. The adaptor may also
64 return None when the response is created internally with some
67 The response must return the HTTP given status code 'status', set
68 the HTTP content-type headers to the string provided and the
69 body of the response to 'output'.
74 def config(self) -> Configuration:
75 """ Return the current configuration object.
79 def build_response(self, output: str, status: int = 200) -> Any:
80 """ Create a response from the given output. Wraps a JSONP function
81 around the response, if necessary.
83 if self.content_type == 'application/json' and status == 200:
84 jsonp = self.get('json_callback')
86 if any(not part.isidentifier() for part in jsonp.split('.')):
87 self.raise_error('Invalid json_callback value')
88 output = f"{jsonp}({output})"
89 self.content_type = 'application/javascript'
91 return self.create_response(status, output)
94 def raise_error(self, msg: str, status: int = 400) -> NoReturn:
95 """ Raise an exception resulting in the given HTTP status and
96 message. The message will be formatted according to the
97 output format chosen by the request.
99 if self.content_type == 'text/xml; charset=utf-8':
100 msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
102 <code>{status}</code>
103 <message>{msg}</message>
106 elif self.content_type == 'application/json':
107 msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
108 elif self.content_type == 'text/html; charset=utf-8':
109 loglib.log().section('Execution error')
110 loglib.log().var_dump('Status', status)
111 loglib.log().var_dump('Message', msg)
112 msg = loglib.get_and_disable()
114 raise self.error(msg, status)
117 def get_int(self, name: str, default: Optional[int] = None) -> int:
118 """ Return an input parameter as an int. Raises an exception if
119 the parameter is given but not in an integer format.
121 If 'default' is given, then it will be returned when the parameter
122 is missing completely. When 'default' is None, an error will be
123 raised on a missing parameter.
125 value = self.get(name)
128 if default is not None:
131 self.raise_error(f"Parameter '{name}' missing.")
136 self.raise_error(f"Parameter '{name}' must be a number.")
141 def get_float(self, name: str, default: Optional[float] = None) -> float:
142 """ Return an input parameter as a flaoting-point number. Raises an
143 exception if the parameter is given but not in an float format.
145 If 'default' is given, then it will be returned when the parameter
146 is missing completely. When 'default' is None, an error will be
147 raised on a missing parameter.
149 value = self.get(name)
152 if default is not None:
155 self.raise_error(f"Parameter '{name}' missing.")
160 self.raise_error(f"Parameter '{name}' must be a number.")
162 if math.isnan(fval) or math.isinf(fval):
163 self.raise_error(f"Parameter '{name}' must be a number.")
168 def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
169 """ Return an input parameter as bool. Only '0' is accepted as
170 an input for 'false' all other inputs will be interpreted as 'true'.
172 If 'default' is given, then it will be returned when the parameter
173 is missing completely. When 'default' is None, an error will be
174 raised on a missing parameter.
176 value = self.get(name)
179 if default is not None:
182 self.raise_error(f"Parameter '{name}' missing.")
187 def get_accepted_languages(self) -> str:
188 """ Return the accepted languages.
190 return self.get('accept-language')\
191 or self.get_header('accept-language')\
192 or self.config().DEFAULT_LANGUAGE
195 def setup_debugging(self) -> bool:
196 """ Set up collection of debug information if requested.
198 Return True when debugging was requested.
200 if self.get_bool('debug', False):
201 loglib.set_log_output('html')
202 self.content_type = 'text/html; charset=utf-8'
208 def get_layers(self) -> Optional[napi.DataLayer]:
209 """ Return a parsed version of the layer parameter.
211 param = self.get('layer', None)
215 return cast(napi.DataLayer,
216 reduce(napi.DataLayer.__or__,
217 (getattr(napi.DataLayer, s.upper()) for s in param.split(','))))
220 def parse_format(self, result_type: Type[Any], default: str) -> str:
221 """ Get and check the 'format' parameter and prepare the formatter.
222 `result_type` is the type of result to be returned by the function
223 and `default` the format value to assume when no parameter is present.
225 fmt = self.get('format', default=default)
226 assert fmt is not None
228 if not formatting.supports_format(result_type, fmt):
229 self.raise_error("Parameter 'format' must be one of: " +
230 ', '.join(formatting.list_formats(result_type)))
232 self.content_type = CONTENT_TYPE.get(fmt, 'application/json')
236 def parse_geometry_details(self, fmt: str) -> Dict[str, Any]:
237 """ Create details strucutre from the supplied geometry parameters.
240 output = napi.GeometryFormat.NONE
241 if self.get_bool('polygon_geojson', False):
242 output |= napi.GeometryFormat.GEOJSON
244 if fmt not in ('geojson', 'geocodejson'):
245 if self.get_bool('polygon_text', False):
246 output |= napi.GeometryFormat.TEXT
248 if self.get_bool('polygon_kml', False):
249 output |= napi.GeometryFormat.KML
251 if self.get_bool('polygon_svg', False):
252 output |= napi.GeometryFormat.SVG
255 if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
256 self.raise_error('Too many polygon output options selected.')
258 return {'address_details': True,
259 'geometry_simplification': self.get_float('polygon_threshold', 0.0),
260 'geometry_output': output
264 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
265 """ Server glue for /status endpoint. See API docs for details.
267 result = await api.status()
269 fmt = params.parse_format(napi.StatusResult, 'text')
271 if fmt == 'text' and result.status:
276 return params.build_response(formatting.format_result(result, fmt, {}),
280 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
281 """ Server glue for /details endpoint. See API docs for details.
283 fmt = params.parse_format(napi.DetailedResult, 'json')
284 place_id = params.get_int('place_id', 0)
287 place = napi.PlaceID(place_id)
289 osmtype = params.get('osmtype')
291 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
292 place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
294 debug = params.setup_debugging()
296 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
298 result = await api.details(place,
299 address_details=params.get_bool('addressdetails', False),
300 linked_places=params.get_bool('linkedplaces', False),
301 parented_places=params.get_bool('hierarchy', False),
302 keywords=params.get_bool('keywords', False),
303 geometry_output = napi.GeometryFormat.GEOJSON
304 if params.get_bool('polygon_geojson', False)
305 else napi.GeometryFormat.NONE
309 return params.build_response(loglib.get_and_disable())
312 params.raise_error('No place with that OSM ID found.', status=404)
314 result.localize(locales)
316 output = formatting.format_result(result, fmt,
318 'group_hierarchy': params.get_bool('group_hierarchy', False),
319 'icon_base_url': params.config().MAPICON_URL})
321 return params.build_response(output)
324 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
325 """ Server glue for /reverse endpoint. See API docs for details.
327 fmt = params.parse_format(napi.ReverseResults, 'xml')
328 debug = params.setup_debugging()
329 coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
331 details = params.parse_geometry_details(fmt)
332 details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
333 details['layers'] = params.get_layers()
335 result = await api.reverse(coord, **details)
338 return params.build_response(loglib.get_and_disable())
341 queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
342 zoom = params.get('zoom', None)
344 queryparts['zoom'] = zoom
345 query = urlencode(queryparts)
349 fmt_options = {'query': query,
350 'extratags': params.get_bool('extratags', False),
351 'namedetails': params.get_bool('namedetails', False),
352 'addressdetails': params.get_bool('addressdetails', True)}
355 result.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
357 output = formatting.format_result(napi.ReverseResults([result] if result else []),
360 return params.build_response(output)
363 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
364 """ Server glue for /lookup endpoint. See API docs for details.
366 fmt = params.parse_format(napi.SearchResults, 'xml')
367 debug = params.setup_debugging()
368 details = params.parse_geometry_details(fmt)
371 for oid in (params.get('osm_ids') or '').split(','):
373 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
374 places.append(napi.OsmID(oid[0], int(oid[1:])))
376 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
377 params.raise_error('Too many object IDs.')
380 results = await api.lookup(places, **details)
382 results = napi.SearchResults()
385 return params.build_response(loglib.get_and_disable())
387 fmt_options = {'extratags': params.get_bool('extratags', False),
388 'namedetails': params.get_bool('namedetails', False),
389 'addressdetails': params.get_bool('addressdetails', True)}
391 results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
393 output = formatting.format_result(results, fmt, fmt_options)
395 return params.build_response(output)
398 async def _unstructured_search(query: str, api: napi.NominatimAPIAsync,
399 details: Dict[str, Any]) -> napi.SearchResults:
401 return napi.SearchResults()
403 # Extract special format for coordinates from query.
404 query, x, y = helpers.extract_coords_from_query(query)
407 details['near'] = napi.Point(x, y)
408 details['near_radius'] = 0.1
410 # If no query is left, revert to reverse search.
411 if x is not None and not query:
412 result = await api.reverse(details['near'], **details)
414 return napi.SearchResults()
416 return napi.SearchResults(
417 [napi.SearchResult(**{f.name: getattr(result, f.name)
418 for f in dataclasses.fields(napi.SearchResult)
419 if hasattr(result, f.name)})])
421 query, cls, typ = helpers.extract_category_from_query(query)
423 assert typ is not None
424 return await api.search_category([(cls, typ)], near_query=query, **details)
426 return await api.search(query, **details)
429 async def search_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
430 """ Server glue for /search endpoint. See API docs for details.
432 fmt = params.parse_format(napi.SearchResults, 'jsonv2')
433 debug = params.setup_debugging()
434 details = params.parse_geometry_details(fmt)
436 details['countries'] = params.get('countrycodes', None)
437 details['excluded'] = params.get('exclude_place_ids', None)
438 details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
439 details['bounded_viewbox'] = params.get_bool('bounded', False)
440 details['dedupe'] = params.get_bool('dedupe', True)
442 max_results = max(1, min(50, params.get_int('limit', 10)))
443 details['max_results'] = max_results + min(10, max_results) \
444 if details['dedupe'] else max_results
446 details['min_rank'], details['max_rank'] = \
447 helpers.feature_type_to_rank(params.get('featureType', ''))
448 if params.get('featureType', None) is not None:
449 details['layers'] = napi.DataLayer.ADDRESS
451 query = params.get('q', None)
454 if query is not None:
455 queryparts['q'] = query
456 results = await _unstructured_search(query, api, details)
458 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
459 details[key] = params.get(key, None)
461 queryparts[key] = details[key]
462 query = ', '.join(queryparts.values())
464 results = await api.search_address(**details)
465 except UsageError as err:
466 params.raise_error(str(err))
468 results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
470 if details['dedupe'] and len(results) > 1:
471 results = helpers.deduplicate_results(results, max_results)
474 return params.build_response(loglib.get_and_disable())
477 helpers.extend_query_parts(queryparts, details,
478 params.get('featureType', ''),
479 params.get_bool('namedetails', False),
480 params.get_bool('extratags', False),
481 (str(r.place_id) for r in results if r.place_id))
482 queryparts['format'] = fmt
484 moreurl = urlencode(queryparts)
488 fmt_options = {'query': query, 'more_url': moreurl,
489 'exclude_place_ids': queryparts.get('exclude_place_ids'),
490 'viewbox': queryparts.get('viewbox'),
491 'extratags': params.get_bool('extratags', False),
492 'namedetails': params.get_bool('namedetails', False),
493 'addressdetails': params.get_bool('addressdetails', False)}
495 output = formatting.format_result(results, fmt, fmt_options)
497 return params.build_response(output)
500 async def deletable_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
501 """ Server glue for /deletable endpoint.
502 This is a special endpoint that shows polygons that have been
503 deleted or are broken in the OSM data but are kept in the
504 Nominatim database to minimize disruption.
506 fmt = params.parse_format(RawDataList, 'json')
508 async with api.begin() as conn:
509 sql = sa.text(""" SELECT p.place_id, country_code,
510 name->'name' as name, i.*
511 FROM placex p, import_polygon_delete i
512 WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
513 AND p.class = i.class AND p.type = i.type
515 results = RawDataList(r._asdict() for r in await conn.execute(sql))
517 return params.build_response(formatting.format_result(results, fmt, {}))
520 async def polygons_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
521 """ Server glue for /polygons endpoint.
522 This is a special endpoint that shows polygons that have changed
523 thier size but are kept in the Nominatim database with their
524 old area to minimize disruption.
526 fmt = params.parse_format(RawDataList, 'json')
527 sql_params: Dict[str, Any] = {
528 'days': params.get_int('days', -1),
529 'cls': params.get('class')
531 reduced = params.get_bool('reduced', False)
533 async with api.begin() as conn:
534 sql = sa.select(sa.text("""osm_type, osm_id, class, type,
535 name->'name' as name,
536 country_code, errormessage, updated"""))\
537 .select_from(sa.text('import_polygon_error'))
538 if sql_params['days'] > 0:
539 sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
541 sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
542 if sql_params['cls'] is not None:
543 sql = sql.where(sa.text("class = :cls"))
545 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
547 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
549 return params.build_response(formatting.format_result(results, fmt, {}))
552 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
555 ('status', status_endpoint),
556 ('details', details_endpoint),
557 ('reverse', reverse_endpoint),
558 ('lookup', lookup_endpoint),
559 ('search', search_endpoint),
560 ('deletable', deletable_endpoint),
561 ('polygons', polygons_endpoint),