1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2024 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Generic part of the server implementation of the v1 API.
9 Combine with the scaffolding provided for the various Python ASGI frameworks.
11 from typing import Optional, Any, Type, Dict, cast, Sequence, Tuple
12 from functools import reduce
14 from urllib.parse import urlencode
16 import sqlalchemy as sa
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
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
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.
37 if adaptor.content_type == ct.CONTENT_JSON and status == 200:
38 jsonp = adaptor.get('json_callback')
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'
45 return adaptor.create_response(status, output, num_results)
48 def get_accepted_languages(adaptor: ASGIAdaptor) -> str:
49 """ Return the accepted languages.
51 return adaptor.get('accept-language')\
52 or adaptor.get_header('accept-language')\
53 or adaptor.config().DEFAULT_LANGUAGE
56 def setup_debugging(adaptor: ASGIAdaptor) -> bool:
57 """ Set up collection of debug information if requested.
59 Return True when debugging was requested.
61 if adaptor.get_bool('debug', False):
62 loglib.set_log_output('html')
63 adaptor.content_type = ct.CONTENT_HTML
69 def get_layers(adaptor: ASGIAdaptor) -> Optional[DataLayer]:
70 """ Return a parsed version of the layer parameter.
72 param = adaptor.get('layer', None)
76 return cast(DataLayer,
77 reduce(DataLayer.__or__,
78 (getattr(DataLayer, s.upper()) for s in param.split(','))))
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.
86 fmt = adaptor.get('format', default=default)
87 assert fmt is not None
89 formatting = adaptor.formatting()
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)))
95 adaptor.content_type = formatting.get_content_type(fmt)
99 def parse_geometry_details(adaptor: ASGIAdaptor, fmt: str) -> Dict[str, Any]:
100 """ Create details structure from the supplied geometry parameters.
103 output = GeometryFormat.NONE
104 if adaptor.get_bool('polygon_geojson', False):
105 output |= GeometryFormat.GEOJSON
107 if fmt not in ('geojson', 'geocodejson'):
108 if adaptor.get_bool('polygon_text', False):
109 output |= GeometryFormat.TEXT
111 if adaptor.get_bool('polygon_kml', False):
112 output |= GeometryFormat.KML
114 if adaptor.get_bool('polygon_svg', False):
115 output |= GeometryFormat.SVG
118 if numgeoms > adaptor.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
119 adaptor.raise_error('Too many polygon output options selected.')
121 return {'address_details': True,
122 'geometry_simplification': adaptor.get_float('polygon_threshold', 0.0),
123 'geometry_output': output
127 async def status_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
128 """ Server glue for /status endpoint. See API docs for details.
130 result = await api.status()
132 fmt = parse_format(params, StatusResult, 'text')
134 if fmt == 'text' and result.status:
139 return build_response(params, params.formatting().format_result(result, fmt, {}),
143 async def details_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
144 """ Server glue for /details endpoint. See API docs for details.
146 fmt = parse_format(params, DetailedResult, 'json')
147 place_id = params.get_int('place_id', 0)
150 place = PlaceID(place_id)
152 osmtype = params.get('osmtype')
154 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
155 place = OsmID(osmtype, params.get_int('osmid'), params.get('class'))
157 debug = setup_debugging(params)
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),
171 return build_response(params, loglib.get_and_disable())
174 params.raise_error('No place with that OSM ID found.', status=404)
176 locales = Locales.from_accept_languages(get_accepted_languages(params))
177 locales.localize_results([result])
179 output = params.formatting().format_result(
182 'group_hierarchy': params.get_bool('group_hierarchy', False),
183 'icon_base_url': params.config().MAPICON_URL,
184 'entrances': params.get_bool('entrances', False),
187 return build_response(params, output, num_results=1)
190 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
191 """ Server glue for /reverse endpoint. See API docs for details.
193 fmt = parse_format(params, ReverseResults, 'xml')
194 debug = setup_debugging(params)
195 coord = Point(params.get_float('lon'), params.get_float('lat'))
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)
201 result = await api.reverse(coord, **details)
204 return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
207 queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
208 zoom = params.get('zoom', None)
210 queryparts['zoom'] = zoom
211 query = urlencode(queryparts)
216 Locales.from_accept_languages(get_accepted_languages(params)).localize_results(
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)}
225 output = params.formatting().format_result(ReverseResults([result] if result else []),
228 return build_response(params, output, num_results=1 if result else 0)
231 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
232 """ Server glue for /lookup endpoint. See API docs for details.
234 fmt = parse_format(params, SearchResults, 'xml')
235 debug = setup_debugging(params)
236 details = parse_geometry_details(params, fmt)
239 for oid in (params.get('osm_ids') or '').split(','):
241 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
242 places.append(OsmID(oid[0].upper(), int(oid[1:])))
244 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
245 params.raise_error('Too many object IDs.')
248 results = await api.lookup(places, **details)
250 results = SearchResults()
253 return build_response(params, loglib.get_and_disable(), num_results=len(results))
255 Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
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)}
262 output = params.formatting().format_result(results, fmt, fmt_options)
264 return build_response(params, output, num_results=len(results))
267 async def _unstructured_search(query: str, api: NominatimAPIAsync,
268 details: Dict[str, Any]) -> SearchResults:
270 return SearchResults()
272 # Extract special format for coordinates from query.
273 query, x, y = helpers.extract_coords_from_query(query)
276 details['near'] = Point(x, y)
277 details['near_radius'] = 0.1
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)
283 return SearchResults()
285 return SearchResults(
286 [SearchResult(**{f.name: getattr(result, f.name)
287 for f in dataclasses.fields(SearchResult)
288 if hasattr(result, f.name)})])
290 query, cls, typ = helpers.extract_category_from_query(query)
292 assert typ is not None
293 return await api.search_category([(cls, typ)], near_query=query, **details)
295 return await api.search(query, **details)
298 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
299 """ Server glue for /search endpoint. See API docs for details.
301 fmt = parse_format(params, SearchResults, 'jsonv2')
302 debug = setup_debugging(params)
303 details = parse_geometry_details(params, fmt)
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)
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)
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
321 details['layers'] = get_layers(params)
323 # unstructured query parameters
324 query = params.get('q', None)
325 # structured query parameters
327 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
328 details[key] = params.get(key, None)
330 queryparts[key] = details[key]
333 if query is not None:
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)
341 query = ', '.join(queryparts.values())
343 results = await api.search_address(**details)
344 except UsageError as err:
345 params.raise_error(str(err))
347 Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
349 if details['dedupe'] and len(results) > 1:
350 results = helpers.deduplicate_results(results, max_results)
353 return build_response(params, loglib.get_and_disable(), num_results=len(results))
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
363 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
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)}
375 output = params.formatting().format_result(results, fmt, fmt_options)
377 return build_response(params, output, num_results=len(results))
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.
386 fmt = parse_format(params, RawDataList, 'json')
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
398 results.extend(r._asdict() for r in await conn.execute(sql, {'osm_type': osm_type}))
400 return build_response(params, params.formatting().format_result(results, fmt, {}))
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.
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')
414 reduced = params.get_bool('reduced', False)
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)"))
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"))
428 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
430 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
432 return build_response(params, params.formatting().format_result(results, fmt, {}))
435 async def get_routes(api: NominatimAPIAsync) -> Sequence[Tuple[str, EndpointFunc]]:
437 ('status', status_endpoint),
438 ('details', details_endpoint),
439 ('reverse', reverse_endpoint),
440 ('lookup', lookup_endpoint),
441 ('deletable', deletable_endpoint),
442 ('polygons', polygons_endpoint),
445 def has_search_name(conn: sa.engine.Connection) -> bool:
446 insp = sa.inspect(conn)
447 return insp.has_table('search_name')
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):