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
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) -> Dict[str, Any]:
230 """ Create details strucutre from the supplied geometry parameters.
233 output = napi.GeometryFormat.NONE
234 if self.get_bool('polygon_geojson', False):
235 output |= napi.GeometryFormat.GEOJSON
237 if fmt not in ('geojson', 'geocodejson'):
238 if self.get_bool('polygon_text', False):
239 output |= napi.GeometryFormat.TEXT
241 if self.get_bool('polygon_kml', False):
242 output |= napi.GeometryFormat.KML
244 if self.get_bool('polygon_svg', False):
245 output |= napi.GeometryFormat.SVG
248 if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
249 self.raise_error('Too many polgyon output options selected.')
251 return {'address_details': True,
252 'geometry_simplification': self.get_float('polygon_threshold', 0.0),
253 'geometry_output': output
257 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
258 """ Server glue for /status endpoint. See API docs for details.
260 result = await api.status()
262 fmt = params.parse_format(napi.StatusResult, 'text')
264 if fmt == 'text' and result.status:
269 return params.build_response(formatting.format_result(result, fmt, {}),
273 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
274 """ Server glue for /details endpoint. See API docs for details.
276 fmt = params.parse_format(napi.DetailedResult, 'json')
277 place_id = params.get_int('place_id', 0)
280 place = napi.PlaceID(place_id)
282 osmtype = params.get('osmtype')
284 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
285 place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
287 debug = params.setup_debugging()
289 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
291 result = await api.details(place,
292 address_details=params.get_bool('addressdetails', False),
293 linked_places=params.get_bool('linkedplaces', False),
294 parented_places=params.get_bool('hierarchy', False),
295 keywords=params.get_bool('keywords', False),
296 geometry_output = napi.GeometryFormat.GEOJSON
297 if params.get_bool('polygon_geojson', False)
298 else napi.GeometryFormat.NONE
302 return params.build_response(loglib.get_and_disable())
305 params.raise_error('No place with that OSM ID found.', status=404)
307 output = formatting.format_result(result, fmt,
309 'group_hierarchy': params.get_bool('group_hierarchy', False),
310 'icon_base_url': params.config().MAPICON_URL})
312 return params.build_response(output)
315 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
316 """ Server glue for /reverse endpoint. See API docs for details.
318 fmt = params.parse_format(napi.ReverseResults, 'xml')
319 debug = params.setup_debugging()
320 coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
321 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
322 zoom = max(0, min(18, params.get_int('zoom', 18)))
324 details = params.parse_geometry_details(fmt)
325 details['max_rank'] = REVERSE_MAX_RANKS[zoom]
326 details['layers'] = params.get_layers()
328 result = await api.reverse(coord, **details)
331 return params.build_response(loglib.get_and_disable())
333 fmt_options = {'locales': locales,
334 'extratags': params.get_bool('extratags', False),
335 'namedetails': params.get_bool('namedetails', False),
336 'addressdetails': params.get_bool('addressdetails', True)}
338 output = formatting.format_result(napi.ReverseResults([result] if result else []),
341 return params.build_response(output)
344 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
345 """ Server glue for /lookup endpoint. See API docs for details.
347 fmt = params.parse_format(napi.SearchResults, 'xml')
348 debug = params.setup_debugging()
349 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
350 details = params.parse_geometry_details(fmt)
353 for oid in (params.get('osm_ids') or '').split(','):
355 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
356 places.append(napi.OsmID(oid[0], int(oid[1:])))
359 results = await api.lookup(places, **details)
361 results = napi.SearchResults()
364 return params.build_response(loglib.get_and_disable())
366 fmt_options = {'locales': locales,
367 'extratags': params.get_bool('extratags', False),
368 'namedetails': params.get_bool('namedetails', False),
369 'addressdetails': params.get_bool('addressdetails', True)}
371 output = formatting.format_result(results, fmt, fmt_options)
373 return params.build_response(output)
375 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
377 REVERSE_MAX_RANKS = [2, 2, 2, # 0-2 Continent/Sea
384 19, # 13 Village/Suburb
385 22, # 14 Hamlet/Neighbourhood
387 26, # 16 Major Streets
388 27, # 17 Minor Streets
394 ('status', status_endpoint),
395 ('details', details_endpoint),
396 ('reverse', reverse_endpoint),
397 ('lookup', lookup_endpoint)