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, cast
12 from functools import reduce
16 from nominatim.config import Configuration
17 import nominatim.api as napi
18 import nominatim.api.logging as loglib
19 from nominatim.api.v1.format import dispatch as formatting
22 'text': 'text/plain; charset=utf-8',
23 'xml': 'text/xml; charset=utf-8',
24 'debug': 'text/html; charset=utf-8'
27 class ASGIAdaptor(abc.ABC):
28 """ Adapter class for the different ASGI frameworks.
29 Wraps functionality over concrete requests and responses.
31 content_type: str = 'text/plain; charset=utf-8'
34 def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
35 """ Return an input parameter as a string. If the parameter was
36 not provided, return the 'default' value.
40 def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
41 """ Return a HTTP header parameter as a string. If the parameter was
42 not provided, return the 'default' value.
47 def error(self, msg: str, status: int = 400) -> Exception:
48 """ Construct an appropriate exception from the given error message.
49 The exception must result in a HTTP error with the given status.
54 def create_response(self, status: int, output: str) -> Any:
55 """ Create a response from the given parameters. The result will
56 be returned by the endpoint functions. The adaptor may also
57 return None when the response is created internally with some
60 The response must return the HTTP given status code 'status', set
61 the HTTP content-type headers to the string provided and the
62 body of the response to 'output'.
67 def config(self) -> Configuration:
68 """ Return the current configuration object.
72 def build_response(self, output: str, status: int = 200) -> Any:
73 """ Create a response from the given output. Wraps a JSONP function
74 around the response, if necessary.
76 if self.content_type == 'application/json' and status == 200:
77 jsonp = self.get('json_callback')
79 if any(not part.isidentifier() for part in jsonp.split('.')):
80 self.raise_error('Invalid json_callback value')
81 output = f"{jsonp}({output})"
82 self.content_type = 'application/javascript'
84 return self.create_response(status, output)
87 def raise_error(self, msg: str, status: int = 400) -> NoReturn:
88 """ Raise an exception resulting in the given HTTP status and
89 message. The message will be formatted according to the
90 output format chosen by the request.
92 if self.content_type == 'text/xml; charset=utf-8':
93 msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
96 <message>{msg}</message>
99 elif self.content_type == 'application/json':
100 msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
101 elif self.content_type == 'text/html; charset=utf-8':
102 loglib.log().section('Execution error')
103 loglib.log().var_dump('Status', status)
104 loglib.log().var_dump('Message', msg)
105 msg = loglib.get_and_disable()
107 raise self.error(msg, status)
110 def get_int(self, name: str, default: Optional[int] = None) -> int:
111 """ Return an input parameter as an int. Raises an exception if
112 the parameter is given but not in an integer format.
114 If 'default' is given, then it will be returned when the parameter
115 is missing completely. When 'default' is None, an error will be
116 raised on a missing parameter.
118 value = self.get(name)
121 if default is not None:
124 self.raise_error(f"Parameter '{name}' missing.")
129 self.raise_error(f"Parameter '{name}' must be a number.")
134 def get_float(self, name: str, default: Optional[float] = None) -> float:
135 """ Return an input parameter as a flaoting-point number. Raises an
136 exception if the parameter is given but not in an float format.
138 If 'default' is given, then it will be returned when the parameter
139 is missing completely. When 'default' is None, an error will be
140 raised on a missing parameter.
142 value = self.get(name)
145 if default is not None:
148 self.raise_error(f"Parameter '{name}' missing.")
153 self.raise_error(f"Parameter '{name}' must be a number.")
155 if math.isnan(fval) or math.isinf(fval):
156 self.raise_error(f"Parameter '{name}' must be a number.")
161 def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
162 """ Return an input parameter as bool. Only '0' is accepted as
163 an input for 'false' all other inputs will be interpreted as 'true'.
165 If 'default' is given, then it will be returned when the parameter
166 is missing completely. When 'default' is None, an error will be
167 raised on a missing parameter.
169 value = self.get(name)
172 if default is not None:
175 self.raise_error(f"Parameter '{name}' missing.")
180 def get_accepted_languages(self) -> str:
181 """ Return the accepted languages.
183 return self.get('accept-language')\
184 or self.get_header('http_accept_language')\
185 or self.config().DEFAULT_LANGUAGE
188 def setup_debugging(self) -> bool:
189 """ Set up collection of debug information if requested.
191 Return True when debugging was requested.
193 if self.get_bool('debug', False):
194 loglib.set_log_output('html')
195 self.content_type = 'text/html; charset=utf-8'
201 def get_layers(self) -> Optional[napi.DataLayer]:
202 """ Return a parsed version of the layer parameter.
204 param = self.get('layer', None)
208 return cast(napi.DataLayer,
209 reduce(napi.DataLayer.__or__,
210 (getattr(napi.DataLayer, s.upper()) for s in param.split(','))))
213 def parse_format(self, result_type: Type[Any], default: str) -> str:
214 """ Get and check the 'format' parameter and prepare the formatter.
215 `result_type` is the type of result to be returned by the function
216 and `default` the format value to assume when no parameter is present.
218 fmt = self.get('format', default=default)
219 assert fmt is not None
221 if not formatting.supports_format(result_type, fmt):
222 self.raise_error("Parameter 'format' must be one of: " +
223 ', '.join(formatting.list_formats(result_type)))
225 self.content_type = CONTENT_TYPE.get(fmt, 'application/json')
229 def parse_geometry_details(self, fmt: str) -> napi.LookupDetails:
230 """ Create details strucutre from the supplied geometry parameters.
232 details = napi.LookupDetails(address_details=True,
233 geometry_simplification=
234 self.get_float('polygon_threshold', 0.0))
236 if self.get_bool('polygon_geojson', False):
237 details.geometry_output |= napi.GeometryFormat.GEOJSON
239 if fmt not in ('geojson', 'geocodejson'):
240 if self.get_bool('polygon_text', False):
241 details.geometry_output |= napi.GeometryFormat.TEXT
243 if self.get_bool('polygon_kml', False):
244 details.geometry_output |= napi.GeometryFormat.KML
246 if self.get_bool('polygon_svg', False):
247 details.geometry_output |= napi.GeometryFormat.SVG
250 if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
251 self.raise_error('Too many polgyon output options selected.')
256 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
257 """ Server glue for /status endpoint. See API docs for details.
259 result = await api.status()
261 fmt = params.parse_format(napi.StatusResult, 'text')
263 if fmt == 'text' and result.status:
268 return params.build_response(formatting.format_result(result, fmt, {}),
272 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
273 """ Server glue for /details endpoint. See API docs for details.
275 fmt = params.parse_format(napi.DetailedResult, 'json')
276 place_id = params.get_int('place_id', 0)
279 place = napi.PlaceID(place_id)
281 osmtype = params.get('osmtype')
283 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
284 place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
286 debug = params.setup_debugging()
288 details = napi.LookupDetails(address_details=params.get_bool('addressdetails', False),
289 linked_places=params.get_bool('linkedplaces', False),
290 parented_places=params.get_bool('hierarchy', False),
291 keywords=params.get_bool('keywords', False))
293 if params.get_bool('polygon_geojson', False):
294 details.geometry_output = napi.GeometryFormat.GEOJSON
296 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
298 result = await api.details(place, details)
301 return params.build_response(loglib.get_and_disable())
304 params.raise_error('No place with that OSM ID found.', status=404)
306 output = formatting.format_result(result, fmt,
308 'group_hierarchy': params.get_bool('group_hierarchy', False),
309 'icon_base_url': params.config().MAPICON_URL})
311 return params.build_response(output)
314 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
315 """ Server glue for /reverse endpoint. See API docs for details.
317 fmt = params.parse_format(napi.ReverseResults, 'xml')
318 debug = params.setup_debugging()
319 coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
320 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
321 details = params.parse_geometry_details(fmt)
323 zoom = max(0, min(18, params.get_int('zoom', 18)))
326 result = await api.reverse(coord, REVERSE_MAX_RANKS[zoom],
327 params.get_layers() or
328 napi.DataLayer.ADDRESS | napi.DataLayer.POI,
332 return params.build_response(loglib.get_and_disable())
334 fmt_options = {'locales': locales,
335 'extratags': params.get_bool('extratags', False),
336 'namedetails': params.get_bool('namedetails', False),
337 'addressdetails': params.get_bool('addressdetails', True)}
339 output = formatting.format_result(napi.ReverseResults([result] if result else []),
342 return params.build_response(output)
345 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
346 """ Server glue for /lookup endpoint. See API docs for details.
348 fmt = params.parse_format(napi.SearchResults, 'xml')
349 debug = params.setup_debugging()
350 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
351 details = params.parse_geometry_details(fmt)
354 for oid in (params.get('osm_ids') or '').split(','):
356 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
357 places.append(napi.OsmID(oid[0], int(oid[1:])))
360 results = await api.lookup(places, details)
362 results = napi.SearchResults()
365 return params.build_response(loglib.get_and_disable())
367 fmt_options = {'locales': locales,
368 'extratags': params.get_bool('extratags', False),
369 'namedetails': params.get_bool('namedetails', False),
370 'addressdetails': params.get_bool('addressdetails', True)}
372 output = formatting.format_result(results, fmt, fmt_options)
374 return params.build_response(output)
376 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
378 REVERSE_MAX_RANKS = [2, 2, 2, # 0-2 Continent/Sea
385 19, # 13 Village/Suburb
386 22, # 14 Hamlet/Neighbourhood
388 26, # 16 Major Streets
389 27, # 17 Minor Streets
395 ('status', status_endpoint),
396 ('details', details_endpoint),
397 ('reverse', reverse_endpoint),
398 ('lookup', lookup_endpoint)