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 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
230 """ Server glue for /status endpoint. See API docs for details.
232 result = await api.status()
234 fmt = params.parse_format(napi.StatusResult, 'text')
236 if fmt == 'text' and result.status:
241 return params.build_response(formatting.format_result(result, fmt, {}),
245 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
246 """ Server glue for /details endpoint. See API docs for details.
248 fmt = params.parse_format(napi.DetailedResult, 'json')
249 place_id = params.get_int('place_id', 0)
252 place = napi.PlaceID(place_id)
254 osmtype = params.get('osmtype')
256 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
257 place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
259 debug = params.setup_debugging()
261 details = napi.LookupDetails(address_details=params.get_bool('addressdetails', False),
262 linked_places=params.get_bool('linkedplaces', False),
263 parented_places=params.get_bool('hierarchy', False),
264 keywords=params.get_bool('keywords', False))
266 if params.get_bool('polygon_geojson', False):
267 details.geometry_output = napi.GeometryFormat.GEOJSON
269 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
271 result = await api.lookup(place, details)
274 return params.build_response(loglib.get_and_disable())
277 params.raise_error('No place with that OSM ID found.', status=404)
279 output = formatting.format_result(result, fmt,
281 'group_hierarchy': params.get_bool('group_hierarchy', False),
282 'icon_base_url': params.config().MAPICON_URL})
284 return params.build_response(output)
287 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
288 """ Server glue for /reverse endpoint. See API docs for details.
290 fmt = params.parse_format(napi.ReverseResults, 'xml')
291 debug = params.setup_debugging()
292 coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
293 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
295 zoom = max(0, min(18, params.get_int('zoom', 18)))
297 details = napi.LookupDetails(address_details=True,
298 geometry_simplification=params.get_float('polygon_threshold', 0.0))
300 if params.get_bool('polygon_geojson', False):
301 details.geometry_output |= napi.GeometryFormat.GEOJSON
303 if fmt not in ('geojson', 'geocodejson'):
304 if params.get_bool('polygon_text', False):
305 details.geometry_output |= napi.GeometryFormat.TEXT
307 if params.get_bool('polygon_kml', False):
308 details.geometry_output |= napi.GeometryFormat.KML
310 if params.get_bool('polygon_svg', False):
311 details.geometry_output |= napi.GeometryFormat.SVG
314 if numgeoms > params.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
315 params.raise_error('Too many polgyon output options selected.')
317 result = await api.reverse(coord, REVERSE_MAX_RANKS[zoom],
318 params.get_layers() or
319 napi.DataLayer.ADDRESS | napi.DataLayer.POI,
323 return params.build_response(loglib.get_and_disable())
325 fmt_options = {'locales': locales,
326 'extratags': params.get_bool('extratags', False),
327 'namedetails': params.get_bool('namedetails', False),
328 'addressdetails': params.get_bool('addressdetails', True)}
330 fmt_options['xml_roottag'] = 'reversegeocode'
331 fmt_options['xml_extra_info'] = {'querystring': 'TODO'}
333 output = formatting.format_result(napi.ReverseResults([result] if result else []),
336 return params.build_response(output)
339 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
341 REVERSE_MAX_RANKS = [2, 2, 2, # 0-2 Continent/Sea
348 19, # 13 Village/Suburb
349 22, # 14 Hamlet/Neighbourhood
351 26, # 16 Major Streets
352 27, # 17 Minor Streets
358 ('status', status_endpoint),
359 ('details', details_endpoint),
360 ('reverse', reverse_endpoint)