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