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