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