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