]> git.openstreetmap.org Git - nominatim.git/blob - src/nominatim_api/v1/server_glue.py
Update entrances schema
[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
185     return build_response(params, output, num_results=1)
186
187
188 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
189     """ Server glue for /reverse endpoint. See API docs for details.
190     """
191     fmt = parse_format(params, ReverseResults, 'xml')
192     debug = setup_debugging(params)
193     coord = Point(params.get_float('lon'), params.get_float('lat'))
194
195     details = parse_geometry_details(params, fmt)
196     details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
197     details['layers'] = get_layers(params)
198
199     result = await api.reverse(coord, **details)
200
201     if debug:
202         return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
203
204     if fmt == 'xml':
205         queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
206         zoom = params.get('zoom', None)
207         if zoom:
208             queryparts['zoom'] = zoom
209         query = urlencode(queryparts)
210     else:
211         query = ''
212
213     if result:
214         Locales.from_accept_languages(get_accepted_languages(params)).localize_results(
215             [result])
216
217     fmt_options = {'query': query,
218                    'extratags': params.get_bool('extratags', False),
219                    'namedetails': params.get_bool('namedetails', False),
220                    'entrances': params.get_bool('entrances', False),
221                    'addressdetails': params.get_bool('addressdetails', True)}
222
223     output = params.formatting().format_result(ReverseResults([result] if result else []),
224                                                fmt, fmt_options)
225
226     return build_response(params, output, num_results=1 if result else 0)
227
228
229 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
230     """ Server glue for /lookup endpoint. See API docs for details.
231     """
232     fmt = parse_format(params, SearchResults, 'xml')
233     debug = setup_debugging(params)
234     details = parse_geometry_details(params, fmt)
235
236     places = []
237     for oid in (params.get('osm_ids') or '').split(','):
238         oid = oid.strip()
239         if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
240             places.append(OsmID(oid[0].upper(), int(oid[1:])))
241
242     if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
243         params.raise_error('Too many object IDs.')
244
245     if places:
246         results = await api.lookup(places, **details)
247     else:
248         results = SearchResults()
249
250     if debug:
251         return build_response(params, loglib.get_and_disable(), num_results=len(results))
252
253     Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
254
255     fmt_options = {'extratags': params.get_bool('extratags', False),
256                    'namedetails': params.get_bool('namedetails', False),
257                    'entrances': params.get_bool('entrances', False),
258                    'addressdetails': params.get_bool('addressdetails', True)}
259
260     output = params.formatting().format_result(results, fmt, fmt_options)
261
262     return build_response(params, output, num_results=len(results))
263
264
265 async def _unstructured_search(query: str, api: NominatimAPIAsync,
266                                details: Dict[str, Any]) -> SearchResults:
267     if not query:
268         return SearchResults()
269
270     # Extract special format for coordinates from query.
271     query, x, y = helpers.extract_coords_from_query(query)
272     if x is not None:
273         assert y is not None
274         details['near'] = Point(x, y)
275         details['near_radius'] = 0.1
276
277     # If no query is left, revert to reverse search.
278     if x is not None and not query:
279         result = await api.reverse(details['near'], **details)
280         if not result:
281             return SearchResults()
282
283         return SearchResults(
284                   [SearchResult(**{f.name: getattr(result, f.name)
285                                    for f in dataclasses.fields(SearchResult)
286                                    if hasattr(result, f.name)})])
287
288     query, cls, typ = helpers.extract_category_from_query(query)
289     if cls is not None:
290         assert typ is not None
291         return await api.search_category([(cls, typ)], near_query=query, **details)
292
293     return await api.search(query, **details)
294
295
296 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
297     """ Server glue for /search endpoint. See API docs for details.
298     """
299     fmt = parse_format(params, SearchResults, 'jsonv2')
300     debug = setup_debugging(params)
301     details = parse_geometry_details(params, fmt)
302
303     details['countries'] = params.get('countrycodes', None)
304     details['entrances'] = params.get_bool('entrances', False)
305     details['excluded'] = params.get('exclude_place_ids', None)
306     details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
307     details['bounded_viewbox'] = params.get_bool('bounded', False)
308     details['dedupe'] = params.get_bool('dedupe', True)
309
310     max_results = max(1, min(50, params.get_int('limit', 10)))
311     details['max_results'] = (max_results + min(10, max_results)
312                               if details['dedupe'] else max_results)
313
314     details['min_rank'], details['max_rank'] = \
315         helpers.feature_type_to_rank(params.get('featureType', ''))
316     if params.get('featureType', None) is not None:
317         details['layers'] = DataLayer.ADDRESS
318     else:
319         details['layers'] = get_layers(params)
320
321     # unstructured query parameters
322     query = params.get('q', None)
323     # structured query parameters
324     queryparts = {}
325     for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
326         details[key] = params.get(key, None)
327         if details[key]:
328             queryparts[key] = details[key]
329
330     try:
331         if query is not None:
332             if queryparts:
333                 params.raise_error("Structured query parameters"
334                                    "(amenity, street, city, county, state, postalcode, country)"
335                                    " cannot be used together with 'q' parameter.")
336             queryparts['q'] = query
337             results = await _unstructured_search(query, api, details)
338         else:
339             query = ', '.join(queryparts.values())
340
341             results = await api.search_address(**details)
342     except UsageError as err:
343         params.raise_error(str(err))
344
345     Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
346
347     if details['dedupe'] and len(results) > 1:
348         results = helpers.deduplicate_results(results, max_results)
349
350     if debug:
351         return build_response(params, loglib.get_and_disable(), num_results=len(results))
352
353     if fmt == 'xml':
354         helpers.extend_query_parts(queryparts, details,
355                                    params.get('featureType', ''),
356                                    params.get_bool('namedetails', False),
357                                    params.get_bool('extratags', False),
358                                    (str(r.place_id) for r in results if r.place_id))
359         queryparts['format'] = fmt
360
361         moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
362     else:
363         moreurl = ''
364
365     fmt_options = {'query': query, 'more_url': moreurl,
366                    'exclude_place_ids': queryparts.get('exclude_place_ids'),
367                    'viewbox': queryparts.get('viewbox'),
368                    'extratags': params.get_bool('extratags', False),
369                    'namedetails': params.get_bool('namedetails', False),
370                    'entrances': params.get_bool('entrances', False),
371                    'addressdetails': params.get_bool('addressdetails', False)}
372
373     output = params.formatting().format_result(results, fmt, fmt_options)
374
375     return build_response(params, output, num_results=len(results))
376
377
378 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
379     """ Server glue for /deletable endpoint.
380         This is a special endpoint that shows polygons that have been
381         deleted or are broken in the OSM data but are kept in the
382         Nominatim database to minimize disruption.
383     """
384     fmt = parse_format(params, RawDataList, 'json')
385
386     results = RawDataList()
387     async with api.begin() as conn:
388         for osm_type in ('N', 'W', 'R'):
389             sql = sa.text(""" SELECT p.place_id, country_code,
390                                      name->'name' as name, i.*
391                               FROM placex p, import_polygon_delete i
392                               WHERE i.osm_type = :osm_type
393                                     AND p.osm_id = i.osm_id AND p.osm_type = :osm_type
394                                     AND p.class = i.class AND p.type = i.type
395                           """)
396             results.extend(r._asdict() for r in await conn.execute(sql, {'osm_type': osm_type}))
397
398     return build_response(params, params.formatting().format_result(results, fmt, {}))
399
400
401 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
402     """ Server glue for /polygons endpoint.
403         This is a special endpoint that shows polygons that have changed
404         their size but are kept in the Nominatim database with their
405         old area to minimize disruption.
406     """
407     fmt = parse_format(params, RawDataList, 'json')
408     sql_params: Dict[str, Any] = {
409         'days': params.get_int('days', -1),
410         'cls': params.get('class')
411     }
412     reduced = params.get_bool('reduced', False)
413
414     async with api.begin() as conn:
415         sql = sa.select(sa.text("""osm_type, osm_id, class, type,
416                                    name->'name' as name,
417                                    country_code, errormessage, updated"""))\
418                 .select_from(sa.text('import_polygon_error'))
419         if sql_params['days'] > 0:
420             sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
421         if reduced:
422             sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
423         if sql_params['cls'] is not None:
424             sql = sql.where(sa.text("class = :cls"))
425
426         sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
427
428         results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
429
430     return build_response(params, params.formatting().format_result(results, fmt, {}))
431
432
433 async def get_routes(api: NominatimAPIAsync) -> Sequence[Tuple[str, EndpointFunc]]:
434     routes = [
435         ('status', status_endpoint),
436         ('details', details_endpoint),
437         ('reverse', reverse_endpoint),
438         ('lookup', lookup_endpoint),
439         ('deletable', deletable_endpoint),
440         ('polygons', polygons_endpoint),
441     ]
442
443     def has_search_name(conn: sa.engine.Connection) -> bool:
444         insp = sa.inspect(conn)
445         return insp.has_table('search_name')
446
447     try:
448         async with api.begin() as conn:
449             if await conn.connection.run_sync(has_search_name):
450                 routes.append(('search', search_endpoint))
451     except (PGCORE_ERROR, sa.exc.OperationalError):
452         pass  # ignored
453
454     return routes