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 details = napi.LookupDetails(address_details=True,
231 geometry_simplification=self.get_float('polygon_threshold', 0.0))
233 if self.get_bool('polygon_geojson', False):
234 details.geometry_output |= napi.GeometryFormat.GEOJSON
236 if fmt not in ('geojson', 'geocodejson'):
237 if self.get_bool('polygon_text', False):
238 details.geometry_output |= napi.GeometryFormat.TEXT
240 if self.get_bool('polygon_kml', False):
241 details.geometry_output |= napi.GeometryFormat.KML
243 if self.get_bool('polygon_svg', False):
244 details.geometry_output |= napi.GeometryFormat.SVG
247 if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
248 self.raise_error('Too many polgyon output options selected.')
253 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
254 """ Server glue for /status endpoint. See API docs for details.
256 result = await api.status()
258 fmt = params.parse_format(napi.StatusResult, 'text')
260 if fmt == 'text' and result.status:
265 return params.build_response(formatting.format_result(result, fmt, {}),
269 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
270 """ Server glue for /details endpoint. See API docs for details.
272 fmt = params.parse_format(napi.DetailedResult, 'json')
273 place_id = params.get_int('place_id', 0)
276 place = napi.PlaceID(place_id)
278 osmtype = params.get('osmtype')
280 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
281 place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
283 debug = params.setup_debugging()
285 details = napi.LookupDetails(address_details=params.get_bool('addressdetails', False),
286 linked_places=params.get_bool('linkedplaces', False),
287 parented_places=params.get_bool('hierarchy', False),
288 keywords=params.get_bool('keywords', False))
290 if params.get_bool('polygon_geojson', False):
291 details.geometry_output = napi.GeometryFormat.GEOJSON
293 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
295 result = await api.details(place, details)
298 return params.build_response(loglib.get_and_disable())
301 params.raise_error('No place with that OSM ID found.', status=404)
303 output = formatting.format_result(result, fmt,
305 'group_hierarchy': params.get_bool('group_hierarchy', False),
306 'icon_base_url': params.config().MAPICON_URL})
308 return params.build_response(output)
311 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
312 """ Server glue for /reverse endpoint. See API docs for details.
314 fmt = params.parse_format(napi.ReverseResults, 'xml')
315 debug = params.setup_debugging()
316 coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
317 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
318 details = params.parse_geometry_details(fmt)
320 zoom = max(0, min(18, params.get_int('zoom', 18)))
323 result = await api.reverse(coord, REVERSE_MAX_RANKS[zoom],
324 params.get_layers() or
325 napi.DataLayer.ADDRESS | napi.DataLayer.POI,
329 return params.build_response(loglib.get_and_disable())
331 fmt_options = {'locales': locales,
332 'extratags': params.get_bool('extratags', False),
333 'namedetails': params.get_bool('namedetails', False),
334 'addressdetails': params.get_bool('addressdetails', True)}
336 output = formatting.format_result(napi.ReverseResults([result] if result else []),
339 return params.build_response(output)
342 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
343 """ Server glue for /lookup endpoint. See API docs for details.
345 fmt = params.parse_format(napi.SearchResults, 'xml')
346 debug = params.setup_debugging()
347 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
348 details = params.parse_geometry_details(fmt)
351 for oid in params.get('osm_ids', '').split(','):
353 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
354 places.append(napi.OsmID(oid[0], int(oid[1:])))
357 results = await api.lookup(places, details)
359 results = napi.SearchResults()
362 return params.build_response(loglib.get_and_disable())
364 fmt_options = {'locales': locales,
365 'extratags': params.get_bool('extratags', False),
366 'namedetails': params.get_bool('namedetails', False),
367 'addressdetails': params.get_bool('addressdetails', True)}
369 output = formatting.format_result(results, fmt, fmt_options)
371 return params.build_response(output)
373 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
375 REVERSE_MAX_RANKS = [2, 2, 2, # 0-2 Continent/Sea
382 19, # 13 Village/Suburb
383 22, # 14 Hamlet/Neighbourhood
385 26, # 16 Major Streets
386 27, # 17 Minor Streets
392 ('status', status_endpoint),
393 ('details', details_endpoint),
394 ('reverse', reverse_endpoint),
395 ('lookup', lookup_endpoint)