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
14 from nominatim.config import Configuration
15 import nominatim.api as napi
16 import nominatim.api.logging as loglib
17 from nominatim.api.v1.format import dispatch as formatting
20 'text': 'text/plain; charset=utf-8',
21 'xml': 'text/xml; charset=utf-8',
22 'debug': 'text/html; charset=utf-8'
26 class ASGIAdaptor(abc.ABC):
27 """ Adapter class for the different ASGI frameworks.
28 Wraps functionality over concrete requests and responses.
30 content_type: str = 'text/plain; charset=utf-8'
33 def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
34 """ Return an input parameter as a string. If the parameter was
35 not provided, return the 'default' value.
39 def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
40 """ Return a HTTP header parameter as a string. If the parameter was
41 not provided, return the 'default' value.
46 def error(self, msg: str, status: int = 400) -> Exception:
47 """ Construct an appropriate exception from the given error message.
48 The exception must result in a HTTP error with the given status.
53 def create_response(self, status: int, output: str) -> Any:
54 """ Create a response from the given parameters. The result will
55 be returned by the endpoint functions. The adaptor may also
56 return None when the response is created internally with some
59 The response must return the HTTP given status code 'status', set
60 the HTTP content-type headers to the string provided and the
61 body of the response to 'output'.
66 def config(self) -> Configuration:
67 """ Return the current configuration object.
71 def build_response(self, output: str, status: int = 200) -> Any:
72 """ Create a response from the given output. Wraps a JSONP function
73 around the response, if necessary.
75 if self.content_type == 'application/json' and status == 200:
76 jsonp = self.get('json_callback')
78 if any(not part.isidentifier() for part in jsonp.split('.')):
79 self.raise_error('Invalid json_callback value')
80 output = f"{jsonp}({output})"
81 self.content_type = 'application/javascript'
83 return self.create_response(status, output)
86 def raise_error(self, msg: str, status: int = 400) -> NoReturn:
87 """ Raise an exception resulting in the given HTTP status and
88 message. The message will be formatted according to the
89 output format chosen by the request.
91 if self.content_type == 'text/xml; charset=utf-8':
92 msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
95 <message>{msg}</message>
98 elif self.content_type == 'application/json':
99 msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
100 elif self.content_type == 'text/html; charset=utf-8':
101 loglib.log().section('Execution error')
102 loglib.log().var_dump('Status', status)
103 loglib.log().var_dump('Message', msg)
104 msg = loglib.get_and_disable()
106 raise self.error(msg, status)
109 def get_int(self, name: str, default: Optional[int] = None) -> int:
110 """ Return an input parameter as an int. Raises an exception if
111 the parameter is given but not in an integer format.
113 If 'default' is given, then it will be returned when the parameter
114 is missing completely. When 'default' is None, an error will be
115 raised on a missing parameter.
117 value = self.get(name)
120 if default is not None:
123 self.raise_error(f"Parameter '{name}' missing.")
128 self.raise_error(f"Parameter '{name}' must be a number.")
132 def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
133 """ Return an input parameter as bool. Only '0' is accepted as
134 an input for 'false' all other inputs will be interpreted as 'true'.
136 If 'default' is given, then it will be returned when the parameter
137 is missing completely. When 'default' is None, an error will be
138 raised on a missing parameter.
140 value = self.get(name)
143 if default is not None:
146 self.raise_error(f"Parameter '{name}' missing.")
151 def get_accepted_languages(self) -> str:
152 """ Return the accepted languages.
154 return self.get('accept-language')\
155 or self.get_header('http_accept_language')\
156 or self.config().DEFAULT_LANGUAGE
159 def setup_debugging(self) -> bool:
160 """ Set up collection of debug information if requested.
162 Return True when debugging was requested.
164 if self.get_bool('debug', False):
165 loglib.set_log_output('html')
166 self.content_type = 'text/html; charset=utf-8'
172 def parse_format(self, result_type: Type[Any], default: str) -> str:
173 """ Get and check the 'format' parameter and prepare the formatter.
174 `result_type` is the type of result to be returned by the function
175 and `default` the format value to assume when no parameter is present.
177 fmt = self.get('format', default=default)
178 assert fmt is not None
180 if not formatting.supports_format(result_type, fmt):
181 self.raise_error("Parameter 'format' must be one of: " +
182 ', '.join(formatting.list_formats(result_type)))
184 self.content_type = CONTENT_TYPE.get(fmt, 'application/json')
188 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
189 """ Server glue for /status endpoint. See API docs for details.
191 result = await api.status()
193 fmt = params.parse_format(napi.StatusResult, 'text')
195 if fmt == 'text' and result.status:
200 return params.build_response(formatting.format_result(result, fmt, {}),
204 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
205 """ Server glue for /details endpoint. See API docs for details.
207 fmt = params.parse_format(napi.DetailedResult, 'json')
208 place_id = params.get_int('place_id', 0)
211 place = napi.PlaceID(place_id)
213 osmtype = params.get('osmtype')
215 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
216 place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
218 debug = params.setup_debugging()
220 details = napi.LookupDetails(address_details=params.get_bool('addressdetails', False),
221 linked_places=params.get_bool('linkedplaces', False),
222 parented_places=params.get_bool('hierarchy', False),
223 keywords=params.get_bool('keywords', False))
225 if params.get_bool('polygon_geojson', False):
226 details.geometry_output = napi.GeometryFormat.GEOJSON
228 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
230 result = await api.lookup(place, details)
233 return params.build_response(loglib.get_and_disable())
236 params.raise_error('No place with that OSM ID found.', status=404)
238 output = formatting.format_result(result, fmt,
240 'group_hierarchy': params.get_bool('group_hierarchy', False),
241 'icon_base_url': params.config().MAPICON_URL})
243 return params.build_response(output)
246 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
249 ('status', status_endpoint),
250 ('details', details_endpoint)