]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/api/v1/server_glue.py
Merge pull request #3091 from lonvia/fix-postcode-search
[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 dataclasses
15 import math
16 from urllib.parse import urlencode
17
18 from nominatim.errors import UsageError
19 from nominatim.config import Configuration
20 import nominatim.api as napi
21 import nominatim.api.logging as loglib
22 from nominatim.api.v1.format import dispatch as formatting
23 from nominatim.api.v1 import helpers
24
25 CONTENT_TYPE = {
26   'text': 'text/plain; charset=utf-8',
27   'xml': 'text/xml; charset=utf-8',
28   'debug': 'text/html; charset=utf-8'
29 }
30
31 class ASGIAdaptor(abc.ABC):
32     """ Adapter class for the different ASGI frameworks.
33         Wraps functionality over concrete requests and responses.
34     """
35     content_type: str = 'text/plain; charset=utf-8'
36
37     @abc.abstractmethod
38     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
39         """ Return an input parameter as a string. If the parameter was
40             not provided, return the 'default' value.
41         """
42
43     @abc.abstractmethod
44     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
45         """ Return a HTTP header parameter as a string. If the parameter was
46             not provided, return the 'default' value.
47         """
48
49
50     @abc.abstractmethod
51     def error(self, msg: str, status: int = 400) -> Exception:
52         """ Construct an appropriate exception from the given error message.
53             The exception must result in a HTTP error with the given status.
54         """
55
56
57     @abc.abstractmethod
58     def create_response(self, status: int, output: str) -> Any:
59         """ Create a response from the given parameters. The result will
60             be returned by the endpoint functions. The adaptor may also
61             return None when the response is created internally with some
62             different means.
63
64             The response must return the HTTP given status code 'status', set
65             the HTTP content-type headers to the string provided and the
66             body of the response to 'output'.
67         """
68
69
70     @abc.abstractmethod
71     def config(self) -> Configuration:
72         """ Return the current configuration object.
73         """
74
75
76     def build_response(self, output: str, status: int = 200) -> Any:
77         """ Create a response from the given output. Wraps a JSONP function
78             around the response, if necessary.
79         """
80         if self.content_type == 'application/json' and status == 200:
81             jsonp = self.get('json_callback')
82             if jsonp is not None:
83                 if any(not part.isidentifier() for part in jsonp.split('.')):
84                     self.raise_error('Invalid json_callback value')
85                 output = f"{jsonp}({output})"
86                 self.content_type = 'application/javascript'
87
88         return self.create_response(status, output)
89
90
91     def raise_error(self, msg: str, status: int = 400) -> NoReturn:
92         """ Raise an exception resulting in the given HTTP status and
93             message. The message will be formatted according to the
94             output format chosen by the request.
95         """
96         if self.content_type == 'text/xml; charset=utf-8':
97             msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
98                       <error>
99                         <code>{status}</code>
100                         <message>{msg}</message>
101                       </error>
102                    """
103         elif self.content_type == 'application/json':
104             msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
105         elif self.content_type == 'text/html; charset=utf-8':
106             loglib.log().section('Execution error')
107             loglib.log().var_dump('Status', status)
108             loglib.log().var_dump('Message', msg)
109             msg = loglib.get_and_disable()
110
111         raise self.error(msg, status)
112
113
114     def get_int(self, name: str, default: Optional[int] = None) -> int:
115         """ Return an input parameter as an int. Raises an exception if
116             the parameter is given but not in an integer format.
117
118             If 'default' is given, then it will be returned when the parameter
119             is missing completely. When 'default' is None, an error will be
120             raised on a missing parameter.
121         """
122         value = self.get(name)
123
124         if value is None:
125             if default is not None:
126                 return default
127
128             self.raise_error(f"Parameter '{name}' missing.")
129
130         try:
131             intval = int(value)
132         except ValueError:
133             self.raise_error(f"Parameter '{name}' must be a number.")
134
135         return intval
136
137
138     def get_float(self, name: str, default: Optional[float] = None) -> float:
139         """ Return an input parameter as a flaoting-point number. Raises an
140             exception if the parameter is given but not in an float format.
141
142             If 'default' is given, then it will be returned when the parameter
143             is missing completely. When 'default' is None, an error will be
144             raised on a missing parameter.
145         """
146         value = self.get(name)
147
148         if value is None:
149             if default is not None:
150                 return default
151
152             self.raise_error(f"Parameter '{name}' missing.")
153
154         try:
155             fval = float(value)
156         except ValueError:
157             self.raise_error(f"Parameter '{name}' must be a number.")
158
159         if math.isnan(fval) or math.isinf(fval):
160             self.raise_error(f"Parameter '{name}' must be a number.")
161
162         return fval
163
164
165     def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
166         """ Return an input parameter as bool. Only '0' is accepted as
167             an input for 'false' all other inputs will be interpreted as 'true'.
168
169             If 'default' is given, then it will be returned when the parameter
170             is missing completely. When 'default' is None, an error will be
171             raised on a missing parameter.
172         """
173         value = self.get(name)
174
175         if value is None:
176             if default is not None:
177                 return default
178
179             self.raise_error(f"Parameter '{name}' missing.")
180
181         return value != '0'
182
183
184     def get_accepted_languages(self) -> str:
185         """ Return the accepted languages.
186         """
187         return self.get('accept-language')\
188                or self.get_header('accept-language')\
189                or self.config().DEFAULT_LANGUAGE
190
191
192     def setup_debugging(self) -> bool:
193         """ Set up collection of debug information if requested.
194
195             Return True when debugging was requested.
196         """
197         if self.get_bool('debug', False):
198             loglib.set_log_output('html')
199             self.content_type = 'text/html; charset=utf-8'
200             return True
201
202         return False
203
204
205     def get_layers(self) -> Optional[napi.DataLayer]:
206         """ Return a parsed version of the layer parameter.
207         """
208         param = self.get('layer', None)
209         if param is None:
210             return None
211
212         return cast(napi.DataLayer,
213                     reduce(napi.DataLayer.__or__,
214                            (getattr(napi.DataLayer, s.upper()) for s in param.split(','))))
215
216
217     def parse_format(self, result_type: Type[Any], default: str) -> str:
218         """ Get and check the 'format' parameter and prepare the formatter.
219             `result_type` is the type of result to be returned by the function
220             and `default` the format value to assume when no parameter is present.
221         """
222         fmt = self.get('format', default=default)
223         assert fmt is not None
224
225         if not formatting.supports_format(result_type, fmt):
226             self.raise_error("Parameter 'format' must be one of: " +
227                               ', '.join(formatting.list_formats(result_type)))
228
229         self.content_type = CONTENT_TYPE.get(fmt, 'application/json')
230         return fmt
231
232
233     def parse_geometry_details(self, fmt: str) -> Dict[str, Any]:
234         """ Create details strucutre from the supplied geometry parameters.
235         """
236         numgeoms = 0
237         output = napi.GeometryFormat.NONE
238         if self.get_bool('polygon_geojson', False):
239             output |= napi.GeometryFormat.GEOJSON
240             numgeoms += 1
241         if fmt not in ('geojson', 'geocodejson'):
242             if self.get_bool('polygon_text', False):
243                 output |= napi.GeometryFormat.TEXT
244                 numgeoms += 1
245             if self.get_bool('polygon_kml', False):
246                 output |= napi.GeometryFormat.KML
247                 numgeoms += 1
248             if self.get_bool('polygon_svg', False):
249                 output |= napi.GeometryFormat.SVG
250                 numgeoms += 1
251
252         if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
253             self.raise_error('Too many polgyon output options selected.')
254
255         return {'address_details': True,
256                 'geometry_simplification': self.get_float('polygon_threshold', 0.0),
257                 'geometry_output': output
258                }
259
260
261 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
262     """ Server glue for /status endpoint. See API docs for details.
263     """
264     result = await api.status()
265
266     fmt = params.parse_format(napi.StatusResult, 'text')
267
268     if fmt == 'text' and result.status:
269         status_code = 500
270     else:
271         status_code = 200
272
273     return params.build_response(formatting.format_result(result, fmt, {}),
274                                  status=status_code)
275
276
277 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
278     """ Server glue for /details endpoint. See API docs for details.
279     """
280     fmt = params.parse_format(napi.DetailedResult, 'json')
281     place_id = params.get_int('place_id', 0)
282     place: napi.PlaceRef
283     if place_id:
284         place = napi.PlaceID(place_id)
285     else:
286         osmtype = params.get('osmtype')
287         if osmtype is None:
288             params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
289         place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
290
291     debug = params.setup_debugging()
292
293     locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
294
295     result = await api.details(place,
296                                address_details=params.get_bool('addressdetails', False),
297                                linked_places=params.get_bool('linkedplaces', False),
298                                parented_places=params.get_bool('hierarchy', False),
299                                keywords=params.get_bool('keywords', False),
300                                geometry_output = napi.GeometryFormat.GEOJSON
301                                                  if params.get_bool('polygon_geojson', False)
302                                                  else napi.GeometryFormat.NONE
303                               )
304
305     if debug:
306         return params.build_response(loglib.get_and_disable())
307
308     if result is None:
309         params.raise_error('No place with that OSM ID found.', status=404)
310
311     result.localize(locales)
312
313     output = formatting.format_result(result, fmt,
314                  {'locales': locales,
315                   'group_hierarchy': params.get_bool('group_hierarchy', False),
316                   'icon_base_url': params.config().MAPICON_URL})
317
318     return params.build_response(output)
319
320
321 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
322     """ Server glue for /reverse endpoint. See API docs for details.
323     """
324     fmt = params.parse_format(napi.ReverseResults, 'xml')
325     debug = params.setup_debugging()
326     coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
327
328     details = params.parse_geometry_details(fmt)
329     details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
330     details['layers'] = params.get_layers()
331
332     result = await api.reverse(coord, **details)
333
334     if debug:
335         return params.build_response(loglib.get_and_disable())
336
337     if fmt == 'xml':
338         queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
339         zoom = params.get('zoom', None)
340         if zoom:
341             queryparts['zoom'] = zoom
342         query = urlencode(queryparts)
343     else:
344         query = ''
345
346     fmt_options = {'query': query,
347                    'extratags': params.get_bool('extratags', False),
348                    'namedetails': params.get_bool('namedetails', False),
349                    'addressdetails': params.get_bool('addressdetails', True)}
350
351     if result:
352         result.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
353
354     output = formatting.format_result(napi.ReverseResults([result] if result else []),
355                                       fmt, fmt_options)
356
357     return params.build_response(output)
358
359
360 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
361     """ Server glue for /lookup endpoint. See API docs for details.
362     """
363     fmt = params.parse_format(napi.SearchResults, 'xml')
364     debug = params.setup_debugging()
365     details = params.parse_geometry_details(fmt)
366
367     places = []
368     for oid in (params.get('osm_ids') or '').split(','):
369         oid = oid.strip()
370         if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
371             places.append(napi.OsmID(oid[0], int(oid[1:])))
372
373     if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
374         params.raise_error('Too many object IDs.')
375
376     if places:
377         results = await api.lookup(places, **details)
378     else:
379         results = napi.SearchResults()
380
381     if debug:
382         return params.build_response(loglib.get_and_disable())
383
384     fmt_options = {'extratags': params.get_bool('extratags', False),
385                    'namedetails': params.get_bool('namedetails', False),
386                    'addressdetails': params.get_bool('addressdetails', True)}
387
388     results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
389
390     output = formatting.format_result(results, fmt, fmt_options)
391
392     return params.build_response(output)
393
394
395 async def _unstructured_search(query: str, api: napi.NominatimAPIAsync,
396                               details: Dict[str, Any]) -> napi.SearchResults:
397     if not query:
398         return napi.SearchResults()
399
400     # Extract special format for coordinates from query.
401     query, x, y = helpers.extract_coords_from_query(query)
402     if x is not None:
403         assert y is not None
404         details['near'] = napi.Point(x, y)
405         details['near_radius'] = 0.1
406
407     # If no query is left, revert to reverse search.
408     if x is not None and not query:
409         result = await api.reverse(details['near'], **details)
410         if not result:
411             return napi.SearchResults()
412
413         return napi.SearchResults(
414                   [napi.SearchResult(**{f.name: getattr(result, f.name)
415                                         for f in dataclasses.fields(napi.SearchResult)
416                                         if hasattr(result, f.name)})])
417
418     query, cls, typ = helpers.extract_category_from_query(query)
419     if cls is not None:
420         assert typ is not None
421         return await api.search_category([(cls, typ)], near_query=query, **details)
422
423     return await api.search(query, **details)
424
425
426 async def search_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
427     """ Server glue for /search endpoint. See API docs for details.
428     """
429     fmt = params.parse_format(napi.SearchResults, 'jsonv2')
430     debug = params.setup_debugging()
431     details = params.parse_geometry_details(fmt)
432
433     details['countries']  = params.get('countrycodes', None)
434     details['excluded'] = params.get('exclude_place_ids', None)
435     details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
436     details['bounded_viewbox'] = params.get_bool('bounded', False)
437     details['dedupe'] = params.get_bool('dedupe', True)
438
439     max_results = max(1, min(50, params.get_int('limit', 10)))
440     details['max_results'] = max_results + min(10, max_results) \
441                              if details['dedupe'] else max_results
442
443     details['min_rank'], details['max_rank'] = \
444         helpers.feature_type_to_rank(params.get('featureType', ''))
445     if params.get('featureType', None) is not None:
446         details['layers'] = napi.DataLayer.ADDRESS
447
448     query = params.get('q', None)
449     queryparts = {}
450     try:
451         if query is not None:
452             queryparts['q'] = query
453             results = await _unstructured_search(query, api, details)
454         else:
455             for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
456                 details[key] = params.get(key, None)
457                 if details[key]:
458                     queryparts[key] = details[key]
459             query = ', '.join(queryparts.values())
460
461             results = await api.search_address(**details)
462     except UsageError as err:
463         params.raise_error(str(err))
464
465     results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
466
467     if details['dedupe'] and len(results) > 1:
468         results = helpers.deduplicate_results(results, max_results)
469
470     if debug:
471         return params.build_response(loglib.get_and_disable())
472
473     if fmt == 'xml':
474         helpers.extend_query_parts(queryparts, details,
475                                    params.get('featureType', ''),
476                                    params.get_bool('namedetails', False),
477                                    params.get_bool('extratags', False),
478                                    (str(r.place_id) for r in results if r.place_id))
479         queryparts['format'] = fmt
480
481         moreurl = urlencode(queryparts)
482     else:
483         moreurl = ''
484
485     fmt_options = {'query': query, 'more_url': moreurl,
486                    'exclude_place_ids': queryparts.get('exclude_place_ids'),
487                    'viewbox': queryparts.get('viewbox'),
488                    'extratags': params.get_bool('extratags', False),
489                    'namedetails': params.get_bool('namedetails', False),
490                    'addressdetails': params.get_bool('addressdetails', False)}
491
492     output = formatting.format_result(results, fmt, fmt_options)
493
494     return params.build_response(output)
495
496
497 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
498
499 ROUTES = [
500     ('status', status_endpoint),
501     ('details', details_endpoint),
502     ('reverse', reverse_endpoint),
503     ('lookup', lookup_endpoint),
504     ('search', search_endpoint)
505 ]