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