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