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