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 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
20 from nominatim.api.v1 import helpers
23 'text': 'text/plain; charset=utf-8',
24 'xml': 'text/xml; charset=utf-8',
25 'debug': 'text/html; charset=utf-8'
28 class ASGIAdaptor(abc.ABC):
29 """ Adapter class for the different ASGI frameworks.
30 Wraps functionality over concrete requests and responses.
32 content_type: str = 'text/plain; charset=utf-8'
35 def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
36 """ Return an input parameter as a string. If the parameter was
37 not provided, return the 'default' value.
41 def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
42 """ Return a HTTP header parameter as a string. If the parameter was
43 not provided, return the 'default' value.
48 def error(self, msg: str, status: int = 400) -> Exception:
49 """ Construct an appropriate exception from the given error message.
50 The exception must result in a HTTP error with the given status.
55 def create_response(self, status: int, output: str) -> Any:
56 """ Create a response from the given parameters. The result will
57 be returned by the endpoint functions. The adaptor may also
58 return None when the response is created internally with some
61 The response must return the HTTP given status code 'status', set
62 the HTTP content-type headers to the string provided and the
63 body of the response to 'output'.
68 def config(self) -> Configuration:
69 """ Return the current configuration object.
73 def build_response(self, output: str, status: int = 200) -> Any:
74 """ Create a response from the given output. Wraps a JSONP function
75 around the response, if necessary.
77 if self.content_type == 'application/json' and status == 200:
78 jsonp = self.get('json_callback')
80 if any(not part.isidentifier() for part in jsonp.split('.')):
81 self.raise_error('Invalid json_callback value')
82 output = f"{jsonp}({output})"
83 self.content_type = 'application/javascript'
85 return self.create_response(status, output)
88 def raise_error(self, msg: str, status: int = 400) -> NoReturn:
89 """ Raise an exception resulting in the given HTTP status and
90 message. The message will be formatted according to the
91 output format chosen by the request.
93 if self.content_type == 'text/xml; charset=utf-8':
94 msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
97 <message>{msg}</message>
100 elif self.content_type == 'application/json':
101 msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
102 elif self.content_type == 'text/html; charset=utf-8':
103 loglib.log().section('Execution error')
104 loglib.log().var_dump('Status', status)
105 loglib.log().var_dump('Message', msg)
106 msg = loglib.get_and_disable()
108 raise self.error(msg, status)
111 def get_int(self, name: str, default: Optional[int] = None) -> int:
112 """ Return an input parameter as an int. Raises an exception if
113 the parameter is given but not in an integer format.
115 If 'default' is given, then it will be returned when the parameter
116 is missing completely. When 'default' is None, an error will be
117 raised on a missing parameter.
119 value = self.get(name)
122 if default is not None:
125 self.raise_error(f"Parameter '{name}' missing.")
130 self.raise_error(f"Parameter '{name}' must be a number.")
135 def get_float(self, name: str, default: Optional[float] = None) -> float:
136 """ Return an input parameter as a flaoting-point number. Raises an
137 exception if the parameter is given but not in an float format.
139 If 'default' is given, then it will be returned when the parameter
140 is missing completely. When 'default' is None, an error will be
141 raised on a missing parameter.
143 value = self.get(name)
146 if default is not None:
149 self.raise_error(f"Parameter '{name}' missing.")
154 self.raise_error(f"Parameter '{name}' must be a number.")
156 if math.isnan(fval) or math.isinf(fval):
157 self.raise_error(f"Parameter '{name}' must be a number.")
162 def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
163 """ Return an input parameter as bool. Only '0' is accepted as
164 an input for 'false' all other inputs will be interpreted as 'true'.
166 If 'default' is given, then it will be returned when the parameter
167 is missing completely. When 'default' is None, an error will be
168 raised on a missing parameter.
170 value = self.get(name)
173 if default is not None:
176 self.raise_error(f"Parameter '{name}' missing.")
181 def get_accepted_languages(self) -> str:
182 """ Return the accepted languages.
184 return self.get('accept-language')\
185 or self.get_header('http_accept_language')\
186 or self.config().DEFAULT_LANGUAGE
189 def setup_debugging(self) -> bool:
190 """ Set up collection of debug information if requested.
192 Return True when debugging was requested.
194 if self.get_bool('debug', False):
195 loglib.set_log_output('html')
196 self.content_type = 'text/html; charset=utf-8'
202 def get_layers(self) -> Optional[napi.DataLayer]:
203 """ Return a parsed version of the layer parameter.
205 param = self.get('layer', None)
209 return cast(napi.DataLayer,
210 reduce(napi.DataLayer.__or__,
211 (getattr(napi.DataLayer, s.upper()) for s in param.split(','))))
214 def parse_format(self, result_type: Type[Any], default: str) -> str:
215 """ Get and check the 'format' parameter and prepare the formatter.
216 `result_type` is the type of result to be returned by the function
217 and `default` the format value to assume when no parameter is present.
219 fmt = self.get('format', default=default)
220 assert fmt is not None
222 if not formatting.supports_format(result_type, fmt):
223 self.raise_error("Parameter 'format' must be one of: " +
224 ', '.join(formatting.list_formats(result_type)))
226 self.content_type = CONTENT_TYPE.get(fmt, 'application/json')
230 def parse_geometry_details(self, fmt: str) -> Dict[str, Any]:
231 """ Create details strucutre from the supplied geometry parameters.
234 output = napi.GeometryFormat.NONE
235 if self.get_bool('polygon_geojson', False):
236 output |= napi.GeometryFormat.GEOJSON
238 if fmt not in ('geojson', 'geocodejson'):
239 if self.get_bool('polygon_text', False):
240 output |= napi.GeometryFormat.TEXT
242 if self.get_bool('polygon_kml', False):
243 output |= napi.GeometryFormat.KML
245 if self.get_bool('polygon_svg', False):
246 output |= napi.GeometryFormat.SVG
249 if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
250 self.raise_error('Too many polgyon output options selected.')
252 return {'address_details': True,
253 'geometry_simplification': self.get_float('polygon_threshold', 0.0),
254 'geometry_output': output
258 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
259 """ Server glue for /status endpoint. See API docs for details.
261 result = await api.status()
263 fmt = params.parse_format(napi.StatusResult, 'text')
265 if fmt == 'text' and result.status:
270 return params.build_response(formatting.format_result(result, fmt, {}),
274 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
275 """ Server glue for /details endpoint. See API docs for details.
277 fmt = params.parse_format(napi.DetailedResult, 'json')
278 place_id = params.get_int('place_id', 0)
281 place = napi.PlaceID(place_id)
283 osmtype = params.get('osmtype')
285 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
286 place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
288 debug = params.setup_debugging()
290 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
292 result = await api.details(place,
293 address_details=params.get_bool('addressdetails', False),
294 linked_places=params.get_bool('linkedplaces', False),
295 parented_places=params.get_bool('hierarchy', False),
296 keywords=params.get_bool('keywords', False),
297 geometry_output = napi.GeometryFormat.GEOJSON
298 if params.get_bool('polygon_geojson', False)
299 else napi.GeometryFormat.NONE
303 return params.build_response(loglib.get_and_disable())
306 params.raise_error('No place with that OSM ID found.', status=404)
308 result.localize(locales)
310 output = formatting.format_result(result, fmt,
312 'group_hierarchy': params.get_bool('group_hierarchy', False),
313 'icon_base_url': params.config().MAPICON_URL})
315 return params.build_response(output)
318 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
319 """ Server glue for /reverse endpoint. See API docs for details.
321 fmt = params.parse_format(napi.ReverseResults, 'xml')
322 debug = params.setup_debugging()
323 coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
324 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
326 details = params.parse_geometry_details(fmt)
327 details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
328 details['layers'] = params.get_layers()
330 result = await api.reverse(coord, **details)
333 return params.build_response(loglib.get_and_disable())
335 fmt_options = {'extratags': params.get_bool('extratags', False),
336 'namedetails': params.get_bool('namedetails', False),
337 'addressdetails': params.get_bool('addressdetails', True)}
340 result.localize(locales)
342 output = formatting.format_result(napi.ReverseResults([result] if result else []),
345 return params.build_response(output)
348 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
349 """ Server glue for /lookup endpoint. See API docs for details.
351 fmt = params.parse_format(napi.SearchResults, 'xml')
352 debug = params.setup_debugging()
353 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
354 details = params.parse_geometry_details(fmt)
357 for oid in (params.get('osm_ids') or '').split(','):
359 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
360 places.append(napi.OsmID(oid[0], int(oid[1:])))
363 results = await api.lookup(places, **details)
365 results = napi.SearchResults()
368 return params.build_response(loglib.get_and_disable())
370 fmt_options = {'extratags': params.get_bool('extratags', False),
371 'namedetails': params.get_bool('namedetails', False),
372 'addressdetails': params.get_bool('addressdetails', True)}
374 for result in results:
375 result.localize(locales)
377 output = formatting.format_result(results, fmt, fmt_options)
379 return params.build_response(output)
381 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
384 ('status', status_endpoint),
385 ('details', details_endpoint),
386 ('reverse', reverse_endpoint),
387 ('lookup', lookup_endpoint)