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 from nominatim.errors import UsageError
19 from nominatim.config import Configuration
20 import nominatim.api as napi
21 import nominatim.api.logging as loglib
22 from nominatim.api.v1.format import dispatch as formatting
23 from nominatim.api.v1 import helpers
26 'text': 'text/plain; charset=utf-8',
27 'xml': 'text/xml; charset=utf-8',
28 'debug': 'text/html; charset=utf-8'
31 class ASGIAdaptor(abc.ABC):
32 """ Adapter class for the different ASGI frameworks.
33 Wraps functionality over concrete requests and responses.
35 content_type: str = 'text/plain; charset=utf-8'
38 def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
39 """ Return an input parameter as a string. If the parameter was
40 not provided, return the 'default' value.
44 def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
45 """ Return a HTTP header parameter as a string. If the parameter was
46 not provided, return the 'default' value.
51 def error(self, msg: str, status: int = 400) -> Exception:
52 """ Construct an appropriate exception from the given error message.
53 The exception must result in a HTTP error with the given status.
58 def create_response(self, status: int, output: str) -> Any:
59 """ Create a response from the given parameters. The result will
60 be returned by the endpoint functions. The adaptor may also
61 return None when the response is created internally with some
64 The response must return the HTTP given status code 'status', set
65 the HTTP content-type headers to the string provided and the
66 body of the response to 'output'.
71 def config(self) -> Configuration:
72 """ Return the current configuration object.
76 def build_response(self, output: str, status: int = 200) -> Any:
77 """ Create a response from the given output. Wraps a JSONP function
78 around the response, if necessary.
80 if self.content_type == 'application/json' and status == 200:
81 jsonp = self.get('json_callback')
83 if any(not part.isidentifier() for part in jsonp.split('.')):
84 self.raise_error('Invalid json_callback value')
85 output = f"{jsonp}({output})"
86 self.content_type = 'application/javascript'
88 return self.create_response(status, output)
91 def raise_error(self, msg: str, status: int = 400) -> NoReturn:
92 """ Raise an exception resulting in the given HTTP status and
93 message. The message will be formatted according to the
94 output format chosen by the request.
96 if self.content_type == 'text/xml; charset=utf-8':
97 msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
100 <message>{msg}</message>
103 elif self.content_type == 'application/json':
104 msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
105 elif self.content_type == 'text/html; charset=utf-8':
106 loglib.log().section('Execution error')
107 loglib.log().var_dump('Status', status)
108 loglib.log().var_dump('Message', msg)
109 msg = loglib.get_and_disable()
111 raise self.error(msg, status)
114 def get_int(self, name: str, default: Optional[int] = None) -> int:
115 """ Return an input parameter as an int. Raises an exception if
116 the parameter is given but not in an integer format.
118 If 'default' is given, then it will be returned when the parameter
119 is missing completely. When 'default' is None, an error will be
120 raised on a missing parameter.
122 value = self.get(name)
125 if default is not None:
128 self.raise_error(f"Parameter '{name}' missing.")
133 self.raise_error(f"Parameter '{name}' must be a number.")
138 def get_float(self, name: str, default: Optional[float] = None) -> float:
139 """ Return an input parameter as a flaoting-point number. Raises an
140 exception if the parameter is given but not in an float format.
142 If 'default' is given, then it will be returned when the parameter
143 is missing completely. When 'default' is None, an error will be
144 raised on a missing parameter.
146 value = self.get(name)
149 if default is not None:
152 self.raise_error(f"Parameter '{name}' missing.")
157 self.raise_error(f"Parameter '{name}' must be a number.")
159 if math.isnan(fval) or math.isinf(fval):
160 self.raise_error(f"Parameter '{name}' must be a number.")
165 def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
166 """ Return an input parameter as bool. Only '0' is accepted as
167 an input for 'false' all other inputs will be interpreted as 'true'.
169 If 'default' is given, then it will be returned when the parameter
170 is missing completely. When 'default' is None, an error will be
171 raised on a missing parameter.
173 value = self.get(name)
176 if default is not None:
179 self.raise_error(f"Parameter '{name}' missing.")
184 def get_accepted_languages(self) -> str:
185 """ Return the accepted languages.
187 return self.get('accept-language')\
188 or self.get_header('http_accept_language')\
189 or self.config().DEFAULT_LANGUAGE
192 def setup_debugging(self) -> bool:
193 """ Set up collection of debug information if requested.
195 Return True when debugging was requested.
197 if self.get_bool('debug', False):
198 loglib.set_log_output('html')
199 self.content_type = 'text/html; charset=utf-8'
205 def get_layers(self) -> Optional[napi.DataLayer]:
206 """ Return a parsed version of the layer parameter.
208 param = self.get('layer', None)
212 return cast(napi.DataLayer,
213 reduce(napi.DataLayer.__or__,
214 (getattr(napi.DataLayer, s.upper()) for s in param.split(','))))
217 def parse_format(self, result_type: Type[Any], default: str) -> str:
218 """ Get and check the 'format' parameter and prepare the formatter.
219 `result_type` is the type of result to be returned by the function
220 and `default` the format value to assume when no parameter is present.
222 fmt = self.get('format', default=default)
223 assert fmt is not None
225 if not formatting.supports_format(result_type, fmt):
226 self.raise_error("Parameter 'format' must be one of: " +
227 ', '.join(formatting.list_formats(result_type)))
229 self.content_type = CONTENT_TYPE.get(fmt, 'application/json')
233 def parse_geometry_details(self, fmt: str) -> Dict[str, Any]:
234 """ Create details strucutre from the supplied geometry parameters.
237 output = napi.GeometryFormat.NONE
238 if self.get_bool('polygon_geojson', False):
239 output |= napi.GeometryFormat.GEOJSON
241 if fmt not in ('geojson', 'geocodejson'):
242 if self.get_bool('polygon_text', False):
243 output |= napi.GeometryFormat.TEXT
245 if self.get_bool('polygon_kml', False):
246 output |= napi.GeometryFormat.KML
248 if self.get_bool('polygon_svg', False):
249 output |= napi.GeometryFormat.SVG
252 if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
253 self.raise_error('Too many polgyon output options selected.')
255 return {'address_details': True,
256 'geometry_simplification': self.get_float('polygon_threshold', 0.0),
257 'geometry_output': output
261 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
262 """ Server glue for /status endpoint. See API docs for details.
264 result = await api.status()
266 fmt = params.parse_format(napi.StatusResult, 'text')
268 if fmt == 'text' and result.status:
273 return params.build_response(formatting.format_result(result, fmt, {}),
277 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
278 """ Server glue for /details endpoint. See API docs for details.
280 fmt = params.parse_format(napi.DetailedResult, 'json')
281 place_id = params.get_int('place_id', 0)
284 place = napi.PlaceID(place_id)
286 osmtype = params.get('osmtype')
288 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
289 place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
291 debug = params.setup_debugging()
293 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
295 result = await api.details(place,
296 address_details=params.get_bool('addressdetails', False),
297 linked_places=params.get_bool('linkedplaces', False),
298 parented_places=params.get_bool('hierarchy', False),
299 keywords=params.get_bool('keywords', False),
300 geometry_output = napi.GeometryFormat.GEOJSON
301 if params.get_bool('polygon_geojson', False)
302 else napi.GeometryFormat.NONE
306 return params.build_response(loglib.get_and_disable())
309 params.raise_error('No place with that OSM ID found.', status=404)
311 result.localize(locales)
313 output = formatting.format_result(result, fmt,
315 'group_hierarchy': params.get_bool('group_hierarchy', False),
316 'icon_base_url': params.config().MAPICON_URL})
318 return params.build_response(output)
321 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
322 """ Server glue for /reverse endpoint. See API docs for details.
324 fmt = params.parse_format(napi.ReverseResults, 'xml')
325 debug = params.setup_debugging()
326 coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
328 details = params.parse_geometry_details(fmt)
329 details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
330 details['layers'] = params.get_layers()
332 result = await api.reverse(coord, **details)
335 return params.build_response(loglib.get_and_disable())
338 queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
339 zoom = params.get('zoom', None)
341 queryparts['zoom'] = zoom
342 query = urlencode(queryparts)
346 fmt_options = {'query': query,
347 'extratags': params.get_bool('extratags', False),
348 'namedetails': params.get_bool('namedetails', False),
349 'addressdetails': params.get_bool('addressdetails', True)}
352 result.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
354 output = formatting.format_result(napi.ReverseResults([result] if result else []),
357 return params.build_response(output)
360 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
361 """ Server glue for /lookup endpoint. See API docs for details.
363 fmt = params.parse_format(napi.SearchResults, 'xml')
364 debug = params.setup_debugging()
365 details = params.parse_geometry_details(fmt)
368 for oid in (params.get('osm_ids') or '').split(','):
370 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
371 places.append(napi.OsmID(oid[0], int(oid[1:])))
373 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
374 params.raise_error('Too many object IDs.')
377 results = await api.lookup(places, **details)
379 results = napi.SearchResults()
382 return params.build_response(loglib.get_and_disable())
384 fmt_options = {'extratags': params.get_bool('extratags', False),
385 'namedetails': params.get_bool('namedetails', False),
386 'addressdetails': params.get_bool('addressdetails', True)}
388 results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
390 output = formatting.format_result(results, fmt, fmt_options)
392 return params.build_response(output)
395 async def _unstructured_search(query: str, api: napi.NominatimAPIAsync,
396 details: Dict[str, Any]) -> napi.SearchResults:
398 return napi.SearchResults()
400 # Extract special format for coordinates from query.
401 query, x, y = helpers.extract_coords_from_query(query)
404 details['near'] = napi.Point(x, y)
405 details['near_radius'] = 0.1
407 # If no query is left, revert to reverse search.
408 if x is not None and not query:
409 result = await api.reverse(details['near'], **details)
411 return napi.SearchResults()
413 return napi.SearchResults(
414 [napi.SearchResult(**{f.name: getattr(result, f.name)
415 for f in dataclasses.fields(napi.SearchResult)
416 if hasattr(result, f.name)})])
418 query, cls, typ = helpers.extract_category_from_query(query)
420 assert typ is not None
421 return await api.search_category([(cls, typ)], near_query=query, **details)
423 return await api.search(query, **details)
426 async def search_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
427 """ Server glue for /search endpoint. See API docs for details.
429 fmt = params.parse_format(napi.SearchResults, 'jsonv2')
430 debug = params.setup_debugging()
431 details = params.parse_geometry_details(fmt)
433 details['countries'] = params.get('countrycodes', None)
434 details['excluded'] = params.get('exclude_place_ids', None)
435 details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
436 details['bounded_viewbox'] = params.get_bool('bounded', False)
437 details['dedupe'] = params.get_bool('dedupe', True)
439 max_results = max(1, min(50, params.get_int('limit', 10)))
440 details['max_results'] = max_results + min(10, max_results) \
441 if details['dedupe'] else max_results
443 details['min_rank'], details['max_rank'] = \
444 helpers.feature_type_to_rank(params.get('featureType', ''))
445 if params.get('featureType', None) is not None:
446 details['layers'] = napi.DataLayer.ADDRESS
448 query = params.get('q', None)
451 if query is not None:
452 queryparts['q'] = query
453 results = await _unstructured_search(query, api, details)
455 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
456 details[key] = params.get(key, None)
458 queryparts[key] = details[key]
459 query = ', '.join(queryparts.values())
461 results = await api.search_address(**details)
462 except UsageError as err:
463 params.raise_error(str(err))
465 results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
467 if details['dedupe'] and len(results) > 1:
468 results = helpers.deduplicate_results(results, max_results)
471 return params.build_response(loglib.get_and_disable())
474 helpers.extend_query_parts(queryparts, details,
475 params.get('featureType', ''),
476 params.get_bool('namedetails', False),
477 params.get_bool('extratags', False),
478 (str(r.place_id) for r in results if r.place_id))
479 queryparts['format'] = fmt
481 moreurl = urlencode(queryparts)
485 fmt_options = {'query': query, 'more_url': moreurl,
486 'exclude_place_ids': queryparts.get('exclude_place_ids'),
487 'viewbox': queryparts.get('viewbox'),
488 'extratags': params.get_bool('extratags', False),
489 'namedetails': params.get_bool('namedetails', False),
490 'addressdetails': params.get_bool('addressdetails', False)}
492 output = formatting.format_result(results, fmt, fmt_options)
494 return params.build_response(output)
497 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
500 ('status', status_endpoint),
501 ('details', details_endpoint),
502 ('reverse', reverse_endpoint),
503 ('lookup', lookup_endpoint),
504 ('search', search_endpoint)