]> git.openstreetmap.org Git - nominatim.git/blob - src/nominatim_api/v1/server_glue.py
move ASGIAdoptor out of v1 module
[nominatim.git] / src / nominatim_api / v1 / server_glue.py
1 # SPDX-License-Identifier: GPL-3.0-or-later
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2024 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, Dict, cast
12 from functools import reduce
13 import dataclasses
14 from urllib.parse import urlencode
15
16 import sqlalchemy as sa
17
18 from ..errors import UsageError
19 from .. import logging as loglib
20 from ..core import NominatimAPIAsync
21 from .format import dispatch as formatting
22 from .format import RawDataList
23 from ..types import DataLayer, GeometryFormat, PlaceRef, PlaceID, OsmID, Point
24 from ..status import StatusResult
25 from ..results import DetailedResult, ReverseResults, SearchResult, SearchResults
26 from ..localization import Locales
27 from . import helpers
28 from ..server.asgi_adaptor import CONTENT_HTML, CONTENT_JSON, CONTENT_TYPE, ASGIAdaptor
29
30 def build_response(adaptor: ASGIAdaptor, output: str, status: int = 200,
31                    num_results: int = 0) -> Any:
32     """ Create a response from the given output. Wraps a JSONP function
33         around the response, if necessary.
34     """
35     if adaptor.content_type == CONTENT_JSON and status == 200:
36         jsonp = adaptor.get('json_callback')
37         if jsonp is not None:
38             if any(not part.isidentifier() for part in jsonp.split('.')):
39                 adaptor.raise_error('Invalid json_callback value')
40             output = f"{jsonp}({output})"
41             adaptor.content_type = 'application/javascript; charset=utf-8'
42
43     return adaptor.create_response(status, output, num_results)
44
45
46 def get_accepted_languages(adaptor: ASGIAdaptor) -> str:
47     """ Return the accepted languages.
48     """
49     return adaptor.get('accept-language')\
50            or adaptor.get_header('accept-language')\
51            or adaptor.config().DEFAULT_LANGUAGE
52
53
54 def setup_debugging(adaptor: ASGIAdaptor) -> bool:
55     """ Set up collection of debug information if requested.
56
57         Return True when debugging was requested.
58     """
59     if adaptor.get_bool('debug', False):
60         loglib.set_log_output('html')
61         adaptor.content_type = CONTENT_HTML
62         return True
63
64     return False
65
66
67 def get_layers(adaptor: ASGIAdaptor) -> Optional[DataLayer]:
68     """ Return a parsed version of the layer parameter.
69     """
70     param = adaptor.get('layer', None)
71     if param is None:
72         return None
73
74     return cast(DataLayer,
75                 reduce(DataLayer.__or__,
76                        (getattr(DataLayer, s.upper()) for s in param.split(','))))
77
78
79 def parse_format(adaptor: ASGIAdaptor, result_type: Type[Any], default: str) -> str:
80     """ Get and check the 'format' parameter and prepare the formatter.
81         `result_type` is the type of result to be returned by the function
82         and `default` the format value to assume when no parameter is present.
83     """
84     fmt = adaptor.get('format', default=default)
85     assert fmt is not None
86
87     if not formatting.supports_format(result_type, fmt):
88         adaptor.raise_error("Parameter 'format' must be one of: " +
89                           ', '.join(formatting.list_formats(result_type)))
90
91     adaptor.content_type = CONTENT_TYPE.get(fmt, CONTENT_JSON)
92     return fmt
93
94
95 def parse_geometry_details(adaptor: ASGIAdaptor, fmt: str) -> Dict[str, Any]:
96     """ Create details structure from the supplied geometry parameters.
97     """
98     numgeoms = 0
99     output = GeometryFormat.NONE
100     if adaptor.get_bool('polygon_geojson', False):
101         output |= GeometryFormat.GEOJSON
102         numgeoms += 1
103     if fmt not in ('geojson', 'geocodejson'):
104         if adaptor.get_bool('polygon_text', False):
105             output |= GeometryFormat.TEXT
106             numgeoms += 1
107         if adaptor.get_bool('polygon_kml', False):
108             output |= GeometryFormat.KML
109             numgeoms += 1
110         if adaptor.get_bool('polygon_svg', False):
111             output |= GeometryFormat.SVG
112             numgeoms += 1
113
114     if numgeoms > adaptor.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
115         adaptor.raise_error('Too many polygon output options selected.')
116
117     return {'address_details': True,
118             'geometry_simplification': adaptor.get_float('polygon_threshold', 0.0),
119             'geometry_output': output
120            }
121
122
123 async def status_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
124     """ Server glue for /status endpoint. See API docs for details.
125     """
126     result = await api.status()
127
128     fmt = parse_format(params, StatusResult, 'text')
129
130     if fmt == 'text' and result.status:
131         status_code = 500
132     else:
133         status_code = 200
134
135     return build_response(params, formatting.format_result(result, fmt, {}),
136                                  status=status_code)
137
138
139 async def details_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
140     """ Server glue for /details endpoint. See API docs for details.
141     """
142     fmt = parse_format(params, DetailedResult, 'json')
143     place_id = params.get_int('place_id', 0)
144     place: PlaceRef
145     if place_id:
146         place = PlaceID(place_id)
147     else:
148         osmtype = params.get('osmtype')
149         if osmtype is None:
150             params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
151         place = OsmID(osmtype, params.get_int('osmid'), params.get('class'))
152
153     debug = setup_debugging(params)
154
155     locales = Locales.from_accept_languages(get_accepted_languages(params))
156
157     result = await api.details(place,
158                                address_details=params.get_bool('addressdetails', False),
159                                linked_places=params.get_bool('linkedplaces', True),
160                                parented_places=params.get_bool('hierarchy', False),
161                                keywords=params.get_bool('keywords', False),
162                                geometry_output = GeometryFormat.GEOJSON
163                                                  if params.get_bool('polygon_geojson', False)
164                                                  else GeometryFormat.NONE,
165                                locales=locales
166                               )
167
168     if debug:
169         return build_response(params, loglib.get_and_disable())
170
171     if result is None:
172         params.raise_error('No place with that OSM ID found.', status=404)
173
174     output = formatting.format_result(result, fmt,
175                  {'locales': locales,
176                   'group_hierarchy': params.get_bool('group_hierarchy', False),
177                   'icon_base_url': params.config().MAPICON_URL})
178
179     return build_response(params, output, num_results=1)
180
181
182 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
183     """ Server glue for /reverse endpoint. See API docs for details.
184     """
185     fmt = parse_format(params, ReverseResults, 'xml')
186     debug = setup_debugging(params)
187     coord = Point(params.get_float('lon'), params.get_float('lat'))
188
189     details = parse_geometry_details(params, fmt)
190     details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
191     details['layers'] = get_layers(params)
192     details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
193
194     result = await api.reverse(coord, **details)
195
196     if debug:
197         return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
198
199     if fmt == 'xml':
200         queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
201         zoom = params.get('zoom', None)
202         if zoom:
203             queryparts['zoom'] = zoom
204         query = urlencode(queryparts)
205     else:
206         query = ''
207
208     fmt_options = {'query': query,
209                    'extratags': params.get_bool('extratags', False),
210                    'namedetails': params.get_bool('namedetails', False),
211                    'addressdetails': params.get_bool('addressdetails', True)}
212
213     output = formatting.format_result(ReverseResults([result] if result else []),
214                                       fmt, fmt_options)
215
216     return build_response(params, output, num_results=1 if result else 0)
217
218
219 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
220     """ Server glue for /lookup endpoint. See API docs for details.
221     """
222     fmt = parse_format(params, SearchResults, 'xml')
223     debug = setup_debugging(params)
224     details = parse_geometry_details(params, fmt)
225     details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
226
227     places = []
228     for oid in (params.get('osm_ids') or '').split(','):
229         oid = oid.strip()
230         if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
231             places.append(OsmID(oid[0].upper(), int(oid[1:])))
232
233     if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
234         params.raise_error('Too many object IDs.')
235
236     if places:
237         results = await api.lookup(places, **details)
238     else:
239         results = SearchResults()
240
241     if debug:
242         return build_response(params, loglib.get_and_disable(), num_results=len(results))
243
244     fmt_options = {'extratags': params.get_bool('extratags', False),
245                    'namedetails': params.get_bool('namedetails', False),
246                    'addressdetails': params.get_bool('addressdetails', True)}
247
248     output = formatting.format_result(results, fmt, fmt_options)
249
250     return build_response(params, output, num_results=len(results))
251
252
253 async def _unstructured_search(query: str, api: NominatimAPIAsync,
254                               details: Dict[str, Any]) -> SearchResults:
255     if not query:
256         return SearchResults()
257
258     # Extract special format for coordinates from query.
259     query, x, y = helpers.extract_coords_from_query(query)
260     if x is not None:
261         assert y is not None
262         details['near'] = Point(x, y)
263         details['near_radius'] = 0.1
264
265     # If no query is left, revert to reverse search.
266     if x is not None and not query:
267         result = await api.reverse(details['near'], **details)
268         if not result:
269             return SearchResults()
270
271         return SearchResults(
272                   [SearchResult(**{f.name: getattr(result, f.name)
273                                    for f in dataclasses.fields(SearchResult)
274                                    if hasattr(result, f.name)})])
275
276     query, cls, typ = helpers.extract_category_from_query(query)
277     if cls is not None:
278         assert typ is not None
279         return await api.search_category([(cls, typ)], near_query=query, **details)
280
281     return await api.search(query, **details)
282
283
284 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
285     """ Server glue for /search endpoint. See API docs for details.
286     """
287     fmt = parse_format(params, SearchResults, 'jsonv2')
288     debug = setup_debugging(params)
289     details = parse_geometry_details(params, fmt)
290
291     details['countries']  = params.get('countrycodes', None)
292     details['excluded'] = params.get('exclude_place_ids', None)
293     details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
294     details['bounded_viewbox'] = params.get_bool('bounded', False)
295     details['dedupe'] = params.get_bool('dedupe', True)
296
297     max_results = max(1, min(50, params.get_int('limit', 10)))
298     details['max_results'] = max_results + min(10, max_results) \
299                              if details['dedupe'] else max_results
300
301     details['min_rank'], details['max_rank'] = \
302         helpers.feature_type_to_rank(params.get('featureType', ''))
303     if params.get('featureType', None) is not None:
304         details['layers'] = DataLayer.ADDRESS
305     else:
306         details['layers'] = get_layers(params)
307
308     details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
309
310     # unstructured query parameters
311     query = params.get('q', None)
312     # structured query parameters
313     queryparts = {}
314     for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
315         details[key] = params.get(key, None)
316         if details[key]:
317             queryparts[key] = details[key]
318
319     try:
320         if query is not None:
321             if queryparts:
322                 params.raise_error("Structured query parameters"
323                                    "(amenity, street, city, county, state, postalcode, country)"
324                                    " cannot be used together with 'q' parameter.")
325             queryparts['q'] = query
326             results = await _unstructured_search(query, api, details)
327         else:
328             query = ', '.join(queryparts.values())
329
330             results = await api.search_address(**details)
331     except UsageError as err:
332         params.raise_error(str(err))
333
334     if details['dedupe'] and len(results) > 1:
335         results = helpers.deduplicate_results(results, max_results)
336
337     if debug:
338         return build_response(params, loglib.get_and_disable(), num_results=len(results))
339
340     if fmt == 'xml':
341         helpers.extend_query_parts(queryparts, details,
342                                    params.get('featureType', ''),
343                                    params.get_bool('namedetails', False),
344                                    params.get_bool('extratags', False),
345                                    (str(r.place_id) for r in results if r.place_id))
346         queryparts['format'] = fmt
347
348         moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
349     else:
350         moreurl = ''
351
352     fmt_options = {'query': query, 'more_url': moreurl,
353                    'exclude_place_ids': queryparts.get('exclude_place_ids'),
354                    'viewbox': queryparts.get('viewbox'),
355                    'extratags': params.get_bool('extratags', False),
356                    'namedetails': params.get_bool('namedetails', False),
357                    'addressdetails': params.get_bool('addressdetails', False)}
358
359     output = formatting.format_result(results, fmt, fmt_options)
360
361     return build_response(params, output, num_results=len(results))
362
363
364 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
365     """ Server glue for /deletable endpoint.
366         This is a special endpoint that shows polygons that have been
367         deleted or are broken in the OSM data but are kept in the
368         Nominatim database to minimize disruption.
369     """
370     fmt = parse_format(params, RawDataList, 'json')
371
372     async with api.begin() as conn:
373         sql = sa.text(""" SELECT p.place_id, country_code,
374                                  name->'name' as name, i.*
375                           FROM placex p, import_polygon_delete i
376                           WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
377                                 AND p.class = i.class AND p.type = i.type
378                       """)
379         results = RawDataList(r._asdict() for r in await conn.execute(sql))
380
381     return build_response(params, formatting.format_result(results, fmt, {}))
382
383
384 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
385     """ Server glue for /polygons endpoint.
386         This is a special endpoint that shows polygons that have changed
387         their size but are kept in the Nominatim database with their
388         old area to minimize disruption.
389     """
390     fmt = parse_format(params, RawDataList, 'json')
391     sql_params: Dict[str, Any] = {
392         'days': params.get_int('days', -1),
393         'cls': params.get('class')
394     }
395     reduced = params.get_bool('reduced', False)
396
397     async with api.begin() as conn:
398         sql = sa.select(sa.text("""osm_type, osm_id, class, type,
399                                    name->'name' as name,
400                                    country_code, errormessage, updated"""))\
401                 .select_from(sa.text('import_polygon_error'))
402         if sql_params['days'] > 0:
403             sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
404         if reduced:
405             sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
406         if sql_params['cls'] is not None:
407             sql = sql.where(sa.text("class = :cls"))
408
409         sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
410
411         results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
412
413     return build_response(params, formatting.format_result(results, fmt, {}))
414
415
416 ROUTES = [
417     ('status', status_endpoint),
418     ('details', details_endpoint),
419     ('reverse', reverse_endpoint),
420     ('lookup', lookup_endpoint),
421     ('search', search_endpoint),
422     ('deletable', deletable_endpoint),
423     ('polygons', polygons_endpoint),
424 ]