]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/api/v1/server_glue.py
Merge pull request #3413 from osm-search/mtmail-patch-1
[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 structure 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                                locales=locales
313                               )
314
315     if debug:
316         return params.build_response(loglib.get_and_disable())
317
318     if result is None:
319         params.raise_error('No place with that OSM ID found.', status=404)
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     details['locales'] = napi.Locales.from_accept_languages(params.get_accepted_languages())
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     output = formatting.format_result(napi.ReverseResults([result] if result else []),
361                                       fmt, fmt_options)
362
363     return params.build_response(output, num_results=1 if result else 0)
364
365
366 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
367     """ Server glue for /lookup endpoint. See API docs for details.
368     """
369     fmt = params.parse_format(napi.SearchResults, 'xml')
370     debug = params.setup_debugging()
371     details = params.parse_geometry_details(fmt)
372     details['locales'] = napi.Locales.from_accept_languages(params.get_accepted_languages())
373
374     places = []
375     for oid in (params.get('osm_ids') or '').split(','):
376         oid = oid.strip()
377         if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
378             places.append(napi.OsmID(oid[0].upper(), int(oid[1:])))
379
380     if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
381         params.raise_error('Too many object IDs.')
382
383     if places:
384         results = await api.lookup(places, **details)
385     else:
386         results = napi.SearchResults()
387
388     if debug:
389         return params.build_response(loglib.get_and_disable(), num_results=len(results))
390
391     fmt_options = {'extratags': params.get_bool('extratags', False),
392                    'namedetails': params.get_bool('namedetails', False),
393                    'addressdetails': params.get_bool('addressdetails', True)}
394
395     output = formatting.format_result(results, fmt, fmt_options)
396
397     return params.build_response(output, num_results=len(results))
398
399
400 async def _unstructured_search(query: str, api: napi.NominatimAPIAsync,
401                               details: Dict[str, Any]) -> napi.SearchResults:
402     if not query:
403         return napi.SearchResults()
404
405     # Extract special format for coordinates from query.
406     query, x, y = helpers.extract_coords_from_query(query)
407     if x is not None:
408         assert y is not None
409         details['near'] = napi.Point(x, y)
410         details['near_radius'] = 0.1
411
412     # If no query is left, revert to reverse search.
413     if x is not None and not query:
414         result = await api.reverse(details['near'], **details)
415         if not result:
416             return napi.SearchResults()
417
418         return napi.SearchResults(
419                   [napi.SearchResult(**{f.name: getattr(result, f.name)
420                                         for f in dataclasses.fields(napi.SearchResult)
421                                         if hasattr(result, f.name)})])
422
423     query, cls, typ = helpers.extract_category_from_query(query)
424     if cls is not None:
425         assert typ is not None
426         return await api.search_category([(cls, typ)], near_query=query, **details)
427
428     return await api.search(query, **details)
429
430
431 async def search_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
432     """ Server glue for /search endpoint. See API docs for details.
433     """
434     fmt = params.parse_format(napi.SearchResults, 'jsonv2')
435     debug = params.setup_debugging()
436     details = params.parse_geometry_details(fmt)
437
438     details['countries']  = params.get('countrycodes', None)
439     details['excluded'] = params.get('exclude_place_ids', None)
440     details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
441     details['bounded_viewbox'] = params.get_bool('bounded', False)
442     details['dedupe'] = params.get_bool('dedupe', True)
443
444     max_results = max(1, min(50, params.get_int('limit', 10)))
445     details['max_results'] = max_results + min(10, max_results) \
446                              if details['dedupe'] else max_results
447
448     details['min_rank'], details['max_rank'] = \
449         helpers.feature_type_to_rank(params.get('featureType', ''))
450     if params.get('featureType', None) is not None:
451         details['layers'] = napi.DataLayer.ADDRESS
452     else:
453         details['layers'] = params.get_layers()
454
455     details['locales'] = napi.Locales.from_accept_languages(params.get_accepted_languages())
456
457     # unstructured query parameters
458     query = params.get('q', None)
459     # structured query parameters
460     queryparts = {}
461     for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
462         details[key] = params.get(key, None)
463         if details[key]:
464             queryparts[key] = details[key]
465
466     try:
467         if query is not None:
468             if queryparts:
469                 params.raise_error("Structured query parameters"
470                                    "(amenity, street, city, county, state, postalcode, country)"
471                                    " cannot be used together with 'q' parameter.")
472             queryparts['q'] = query
473             results = await _unstructured_search(query, api, details)
474         else:
475             query = ', '.join(queryparts.values())
476
477             results = await api.search_address(**details)
478     except UsageError as err:
479         params.raise_error(str(err))
480
481     if details['dedupe'] and len(results) > 1:
482         results = helpers.deduplicate_results(results, max_results)
483
484     if debug:
485         return params.build_response(loglib.get_and_disable(), num_results=len(results))
486
487     if fmt == 'xml':
488         helpers.extend_query_parts(queryparts, details,
489                                    params.get('featureType', ''),
490                                    params.get_bool('namedetails', False),
491                                    params.get_bool('extratags', False),
492                                    (str(r.place_id) for r in results if r.place_id))
493         queryparts['format'] = fmt
494
495         moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
496     else:
497         moreurl = ''
498
499     fmt_options = {'query': query, 'more_url': moreurl,
500                    'exclude_place_ids': queryparts.get('exclude_place_ids'),
501                    'viewbox': queryparts.get('viewbox'),
502                    'extratags': params.get_bool('extratags', False),
503                    'namedetails': params.get_bool('namedetails', False),
504                    'addressdetails': params.get_bool('addressdetails', False)}
505
506     output = formatting.format_result(results, fmt, fmt_options)
507
508     return params.build_response(output, num_results=len(results))
509
510
511 async def deletable_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
512     """ Server glue for /deletable endpoint.
513         This is a special endpoint that shows polygons that have been
514         deleted or are broken in the OSM data but are kept in the
515         Nominatim database to minimize disruption.
516     """
517     fmt = params.parse_format(RawDataList, 'json')
518
519     async with api.begin() as conn:
520         sql = sa.text(""" SELECT p.place_id, country_code,
521                                  name->'name' as name, i.*
522                           FROM placex p, import_polygon_delete i
523                           WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
524                                 AND p.class = i.class AND p.type = i.type
525                       """)
526         results = RawDataList(r._asdict() for r in await conn.execute(sql))
527
528     return params.build_response(formatting.format_result(results, fmt, {}))
529
530
531 async def polygons_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
532     """ Server glue for /polygons endpoint.
533         This is a special endpoint that shows polygons that have changed
534         their size but are kept in the Nominatim database with their
535         old area to minimize disruption.
536     """
537     fmt = params.parse_format(RawDataList, 'json')
538     sql_params: Dict[str, Any] = {
539         'days': params.get_int('days', -1),
540         'cls': params.get('class')
541     }
542     reduced = params.get_bool('reduced', False)
543
544     async with api.begin() as conn:
545         sql = sa.select(sa.text("""osm_type, osm_id, class, type,
546                                    name->'name' as name,
547                                    country_code, errormessage, updated"""))\
548                 .select_from(sa.text('import_polygon_error'))
549         if sql_params['days'] > 0:
550             sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
551         if reduced:
552             sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
553         if sql_params['cls'] is not None:
554             sql = sql.where(sa.text("class = :cls"))
555
556         sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
557
558         results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
559
560     return params.build_response(formatting.format_result(results, fmt, {}))
561
562
563 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
564
565 ROUTES = [
566     ('status', status_endpoint),
567     ('details', details_endpoint),
568     ('reverse', reverse_endpoint),
569     ('lookup', lookup_endpoint),
570     ('search', search_endpoint),
571     ('deletable', deletable_endpoint),
572     ('polygons', polygons_endpoint),
573 ]