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