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