]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/api/v1/server_glue.py
40081c039cfdc9478205c4639327631521d6de76
[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     def parse_geometry_details(self, fmt: str) -> napi.LookupDetails:
230         details = napi.LookupDetails(address_details=True,
231                                      geometry_simplification=self.get_float('polygon_threshold', 0.0))
232         numgeoms = 0
233         if self.get_bool('polygon_geojson', False):
234             details.geometry_output |= napi.GeometryFormat.GEOJSON
235             numgeoms += 1
236         if fmt not in ('geojson', 'geocodejson'):
237             if self.get_bool('polygon_text', False):
238                 details.geometry_output |= napi.GeometryFormat.TEXT
239                 numgeoms += 1
240             if self.get_bool('polygon_kml', False):
241                 details.geometry_output |= napi.GeometryFormat.KML
242                 numgeoms += 1
243             if self.get_bool('polygon_svg', False):
244                 details.geometry_output |= napi.GeometryFormat.SVG
245                 numgeoms += 1
246
247         if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
248             self.raise_error('Too many polgyon output options selected.')
249
250         return details
251
252
253 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
254     """ Server glue for /status endpoint. See API docs for details.
255     """
256     result = await api.status()
257
258     fmt = params.parse_format(napi.StatusResult, 'text')
259
260     if fmt == 'text' and result.status:
261         status_code = 500
262     else:
263         status_code = 200
264
265     return params.build_response(formatting.format_result(result, fmt, {}),
266                                  status=status_code)
267
268
269 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
270     """ Server glue for /details endpoint. See API docs for details.
271     """
272     fmt = params.parse_format(napi.DetailedResult, 'json')
273     place_id = params.get_int('place_id', 0)
274     place: napi.PlaceRef
275     if place_id:
276         place = napi.PlaceID(place_id)
277     else:
278         osmtype = params.get('osmtype')
279         if osmtype is None:
280             params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
281         place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
282
283     debug = params.setup_debugging()
284
285     details = napi.LookupDetails(address_details=params.get_bool('addressdetails', False),
286                                  linked_places=params.get_bool('linkedplaces', False),
287                                  parented_places=params.get_bool('hierarchy', False),
288                                  keywords=params.get_bool('keywords', False))
289
290     if params.get_bool('polygon_geojson', False):
291         details.geometry_output = napi.GeometryFormat.GEOJSON
292
293     locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
294
295     result = await api.details(place, details)
296
297     if debug:
298         return params.build_response(loglib.get_and_disable())
299
300     if result is None:
301         params.raise_error('No place with that OSM ID found.', status=404)
302
303     output = formatting.format_result(result, fmt,
304                  {'locales': locales,
305                   'group_hierarchy': params.get_bool('group_hierarchy', False),
306                   'icon_base_url': params.config().MAPICON_URL})
307
308     return params.build_response(output)
309
310
311 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
312     """ Server glue for /reverse endpoint. See API docs for details.
313     """
314     fmt = params.parse_format(napi.ReverseResults, 'xml')
315     debug = params.setup_debugging()
316     coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
317     locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
318     details = params.parse_geometry_details(fmt)
319
320     zoom = max(0, min(18, params.get_int('zoom', 18)))
321
322
323     result = await api.reverse(coord, REVERSE_MAX_RANKS[zoom],
324                                params.get_layers() or
325                                  napi.DataLayer.ADDRESS | napi.DataLayer.POI,
326                                details)
327
328     if debug:
329         return params.build_response(loglib.get_and_disable())
330
331     fmt_options = {'locales': locales,
332                    'extratags': params.get_bool('extratags', False),
333                    'namedetails': params.get_bool('namedetails', False),
334                    'addressdetails': params.get_bool('addressdetails', True)}
335
336     output = formatting.format_result(napi.ReverseResults([result] if result else []),
337                                       fmt, fmt_options)
338
339     return params.build_response(output)
340
341
342 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
343     """ Server glue for /lookup endpoint. See API docs for details.
344     """
345     fmt = params.parse_format(napi.SearchResults, 'xml')
346     debug = params.setup_debugging()
347     locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
348     details = params.parse_geometry_details(fmt)
349
350     places = []
351     for oid in params.get('osm_ids', '').split(','):
352         oid = oid.strip()
353         if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
354             places.append(napi.OsmID(oid[0], int(oid[1:])))
355
356     if places:
357         results = await api.lookup(places, details)
358     else:
359         results = napi.SearchResults()
360
361     if debug:
362         return params.build_response(loglib.get_and_disable())
363
364     fmt_options = {'locales': locales,
365                    'extratags': params.get_bool('extratags', False),
366                    'namedetails': params.get_bool('namedetails', False),
367                    'addressdetails': params.get_bool('addressdetails', True)}
368
369     output = formatting.format_result(results, fmt, fmt_options)
370
371     return params.build_response(output)
372
373 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
374
375 REVERSE_MAX_RANKS = [2, 2, 2,   # 0-2   Continent/Sea
376                      4, 4,      # 3-4   Country
377                      8,         # 5     State
378                      10, 10,    # 6-7   Region
379                      12, 12,    # 8-9   County
380                      16, 17,    # 10-11 City
381                      18,        # 12    Town
382                      19,        # 13    Village/Suburb
383                      22,        # 14    Hamlet/Neighbourhood
384                      25,        # 15    Localities
385                      26,        # 16    Major Streets
386                      27,        # 17    Minor Streets
387                      30         # 18    Building
388                     ]
389
390
391 ROUTES = [
392     ('status', status_endpoint),
393     ('details', details_endpoint),
394     ('reverse', reverse_endpoint),
395     ('lookup', lookup_endpoint)
396 ]