]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/api/v1/server_glue.py
Merge remote-tracking branch 'upstream/master'
[nominatim.git] / nominatim / api / v1 / server_glue.py
1 # SPDX-License-Identifier: GPL-2.0-only
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2023 by the Nominatim developer community.
6 # For a full list of authors see the git log.
7 """
8 Generic part of the server implementation of the v1 API.
9 Combine with the scaffolding provided for the various Python ASGI frameworks.
10 """
11 from typing import Optional, Any, Type, Callable, NoReturn, cast
12 from functools import reduce
13 import abc
14 import math
15
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
21 CONTENT_TYPE = {
22   'text': 'text/plain; charset=utf-8',
23   'xml': 'text/xml; charset=utf-8',
24   'debug': 'text/html; charset=utf-8'
25 }
26
27 class ASGIAdaptor(abc.ABC):
28     """ Adapter class for the different ASGI frameworks.
29         Wraps functionality over concrete requests and responses.
30     """
31     content_type: str = 'text/plain; charset=utf-8'
32
33     @abc.abstractmethod
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.
37         """
38
39     @abc.abstractmethod
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.
43         """
44
45
46     @abc.abstractmethod
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.
50         """
51
52
53     @abc.abstractmethod
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
58             different means.
59
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'.
63         """
64
65
66     @abc.abstractmethod
67     def config(self) -> Configuration:
68         """ Return the current configuration object.
69         """
70
71
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.
75         """
76         if self.content_type == 'application/json' and status == 200:
77             jsonp = self.get('json_callback')
78             if jsonp is not None:
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'
83
84         return self.create_response(status, output)
85
86
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.
91         """
92         if self.content_type == 'text/xml; charset=utf-8':
93             msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
94                       <error>
95                         <code>{status}</code>
96                         <message>{msg}</message>
97                       </error>
98                    """
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()
106
107         raise self.error(msg, status)
108
109
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.
113
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.
117         """
118         value = self.get(name)
119
120         if value is None:
121             if default is not None:
122                 return default
123
124             self.raise_error(f"Parameter '{name}' missing.")
125
126         try:
127             intval = int(value)
128         except ValueError:
129             self.raise_error(f"Parameter '{name}' must be a number.")
130
131         return intval
132
133
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.
137
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.
141         """
142         value = self.get(name)
143
144         if value is None:
145             if default is not None:
146                 return default
147
148             self.raise_error(f"Parameter '{name}' missing.")
149
150         try:
151             fval = float(value)
152         except ValueError:
153             self.raise_error(f"Parameter '{name}' must be a number.")
154
155         if math.isnan(fval) or math.isinf(fval):
156             self.raise_error(f"Parameter '{name}' must be a number.")
157
158         return fval
159
160
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'.
164
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.
168         """
169         value = self.get(name)
170
171         if value is None:
172             if default is not None:
173                 return default
174
175             self.raise_error(f"Parameter '{name}' missing.")
176
177         return value != '0'
178
179
180     def get_accepted_languages(self) -> str:
181         """ Return the accepted languages.
182         """
183         return self.get('accept-language')\
184                or self.get_header('http_accept_language')\
185                or self.config().DEFAULT_LANGUAGE
186
187
188     def setup_debugging(self) -> bool:
189         """ Set up collection of debug information if requested.
190
191             Return True when debugging was requested.
192         """
193         if self.get_bool('debug', False):
194             loglib.set_log_output('html')
195             self.content_type = 'text/html; charset=utf-8'
196             return True
197
198         return False
199
200
201     def get_layers(self) -> Optional[napi.DataLayer]:
202         """ Return a parsed version of the layer parameter.
203         """
204         param = self.get('layer', None)
205         if param is None:
206             return None
207
208         return cast(napi.DataLayer,
209                     reduce(napi.DataLayer.__or__,
210                            (getattr(napi.DataLayer, s.upper()) for s in param.split(','))))
211
212
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.
217         """
218         fmt = self.get('format', default=default)
219         assert fmt is not None
220
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)))
224
225         self.content_type = CONTENT_TYPE.get(fmt, 'application/json')
226         return fmt
227
228
229 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
230     """ Server glue for /status endpoint. See API docs for details.
231     """
232     result = await api.status()
233
234     fmt = params.parse_format(napi.StatusResult, 'text')
235
236     if fmt == 'text' and result.status:
237         status_code = 500
238     else:
239         status_code = 200
240
241     return params.build_response(formatting.format_result(result, fmt, {}),
242                                  status=status_code)
243
244
245 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
246     """ Server glue for /details endpoint. See API docs for details.
247     """
248     fmt = params.parse_format(napi.DetailedResult, 'json')
249     place_id = params.get_int('place_id', 0)
250     place: napi.PlaceRef
251     if place_id:
252         place = napi.PlaceID(place_id)
253     else:
254         osmtype = params.get('osmtype')
255         if osmtype is None:
256             params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
257         place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
258
259     debug = params.setup_debugging()
260
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))
265
266     if params.get_bool('polygon_geojson', False):
267         details.geometry_output = napi.GeometryFormat.GEOJSON
268
269     locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
270
271     result = await api.lookup(place, details)
272
273     if debug:
274         return params.build_response(loglib.get_and_disable())
275
276     if result is None:
277         params.raise_error('No place with that OSM ID found.', status=404)
278
279     output = formatting.format_result(result, fmt,
280                  {'locales': locales,
281                   'group_hierarchy': params.get_bool('group_hierarchy', False),
282                   'icon_base_url': params.config().MAPICON_URL})
283
284     return params.build_response(output)
285
286
287 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
288     """ Server glue for /reverse endpoint. See API docs for details.
289     """
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())
294
295     zoom = max(0, min(18, params.get_int('zoom', 18)))
296
297     details = napi.LookupDetails(address_details=True,
298                                  geometry_simplification=params.get_float('polygon_threshold', 0.0))
299     numgeoms = 0
300     if params.get_bool('polygon_geojson', False):
301         details.geometry_output |= napi.GeometryFormat.GEOJSON
302         numgeoms += 1
303     if fmt not in ('geojson', 'geocodejson'):
304         if params.get_bool('polygon_text', False):
305             details.geometry_output |= napi.GeometryFormat.TEXT
306             numgeoms += 1
307         if params.get_bool('polygon_kml', False):
308             details.geometry_output |= napi.GeometryFormat.KML
309             numgeoms += 1
310         if params.get_bool('polygon_svg', False):
311             details.geometry_output |= napi.GeometryFormat.SVG
312             numgeoms += 1
313
314     if numgeoms > params.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
315         params.raise_error('Too many polgyon output options selected.')
316
317     result = await api.reverse(coord, REVERSE_MAX_RANKS[zoom],
318                                params.get_layers() or
319                                  napi.DataLayer.ADDRESS | napi.DataLayer.POI,
320                                details)
321
322     if debug:
323         return params.build_response(loglib.get_and_disable())
324
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)}
329     if fmt == 'xml':
330         fmt_options['xml_roottag'] = 'reversegeocode'
331         fmt_options['xml_extra_info'] = {'querystring': 'TODO'}
332
333     output = formatting.format_result(napi.ReverseResults([result] if result else []),
334                                       fmt, fmt_options)
335
336     return params.build_response(output)
337
338
339 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
340
341 REVERSE_MAX_RANKS = [2, 2, 2,   # 0-2   Continent/Sea
342                      4, 4,      # 3-4   Country
343                      8,         # 5     State
344                      10, 10,    # 6-7   Region
345                      12, 12,    # 8-9   County
346                      16, 17,    # 10-11 City
347                      18,        # 12    Town
348                      19,        # 13    Village/Suburb
349                      22,        # 14    Hamlet/Neighbourhood
350                      25,        # 15    Localities
351                      26,        # 16    Major Streets
352                      27,        # 17    Minor Streets
353                      30         # 18    Building
354                     ]
355
356
357 ROUTES = [
358     ('status', status_endpoint),
359     ('details', details_endpoint),
360     ('reverse', reverse_endpoint)
361 ]