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