]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/api/v1/server_glue.py
send charset again in content-type when returning json
[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], 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
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     results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
482
483     if details['dedupe'] and len(results) > 1:
484         results = helpers.deduplicate_results(results, max_results)
485
486     if debug:
487         return params.build_response(loglib.get_and_disable(), num_results=len(results))
488
489     if fmt == 'xml':
490         helpers.extend_query_parts(queryparts, details,
491                                    params.get('featureType', ''),
492                                    params.get_bool('namedetails', False),
493                                    params.get_bool('extratags', False),
494                                    (str(r.place_id) for r in results if r.place_id))
495         queryparts['format'] = fmt
496
497         moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
498     else:
499         moreurl = ''
500
501     fmt_options = {'query': query, 'more_url': moreurl,
502                    'exclude_place_ids': queryparts.get('exclude_place_ids'),
503                    'viewbox': queryparts.get('viewbox'),
504                    'extratags': params.get_bool('extratags', False),
505                    'namedetails': params.get_bool('namedetails', False),
506                    'addressdetails': params.get_bool('addressdetails', False)}
507
508     output = formatting.format_result(results, fmt, fmt_options)
509
510     return params.build_response(output, num_results=len(results))
511
512
513 async def deletable_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
514     """ Server glue for /deletable endpoint.
515         This is a special endpoint that shows polygons that have been
516         deleted or are broken in the OSM data but are kept in the
517         Nominatim database to minimize disruption.
518     """
519     fmt = params.parse_format(RawDataList, 'json')
520
521     async with api.begin() as conn:
522         sql = sa.text(""" SELECT p.place_id, country_code,
523                                  name->'name' as name, i.*
524                           FROM placex p, import_polygon_delete i
525                           WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
526                                 AND p.class = i.class AND p.type = i.type
527                       """)
528         results = RawDataList(r._asdict() for r in await conn.execute(sql))
529
530     return params.build_response(formatting.format_result(results, fmt, {}))
531
532
533 async def polygons_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
534     """ Server glue for /polygons endpoint.
535         This is a special endpoint that shows polygons that have changed
536         thier size but are kept in the Nominatim database with their
537         old area to minimize disruption.
538     """
539     fmt = params.parse_format(RawDataList, 'json')
540     sql_params: Dict[str, Any] = {
541         'days': params.get_int('days', -1),
542         'cls': params.get('class')
543     }
544     reduced = params.get_bool('reduced', False)
545
546     async with api.begin() as conn:
547         sql = sa.select(sa.text("""osm_type, osm_id, class, type,
548                                    name->'name' as name,
549                                    country_code, errormessage, updated"""))\
550                 .select_from(sa.text('import_polygon_error'))
551         if sql_params['days'] > 0:
552             sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
553         if reduced:
554             sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
555         if sql_params['cls'] is not None:
556             sql = sql.where(sa.text("class = :cls"))
557
558         sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
559
560         results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
561
562     return params.build_response(formatting.format_result(results, fmt, {}))
563
564
565 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
566
567 ROUTES = [
568     ('status', status_endpoint),
569     ('details', details_endpoint),
570     ('reverse', reverse_endpoint),
571     ('lookup', lookup_endpoint),
572     ('search', search_endpoint),
573     ('deletable', deletable_endpoint),
574     ('polygons', polygons_endpoint),
575 ]