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})
185 return build_response(params, output, num_results=1)
188 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
189 """ Server glue for /reverse endpoint. See API docs for details.
191 fmt = parse_format(params, ReverseResults, 'xml')
192 debug = setup_debugging(params)
193 coord = Point(params.get_float('lon'), params.get_float('lat'))
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)
199 result = await api.reverse(coord, **details)
202 return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
205 queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
206 zoom = params.get('zoom', None)
208 queryparts['zoom'] = zoom
209 query = urlencode(queryparts)
214 Locales.from_accept_languages(get_accepted_languages(params)).localize_results(
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)}
223 output = params.formatting().format_result(ReverseResults([result] if result else []),
226 return build_response(params, output, num_results=1 if result else 0)
229 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
230 """ Server glue for /lookup endpoint. See API docs for details.
232 fmt = parse_format(params, SearchResults, 'xml')
233 debug = setup_debugging(params)
234 details = parse_geometry_details(params, fmt)
237 for oid in (params.get('osm_ids') or '').split(','):
239 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
240 places.append(OsmID(oid[0].upper(), int(oid[1:])))
242 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
243 params.raise_error('Too many object IDs.')
246 results = await api.lookup(places, **details)
248 results = SearchResults()
251 return build_response(params, loglib.get_and_disable(), num_results=len(results))
253 Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
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)}
260 output = params.formatting().format_result(results, fmt, fmt_options)
262 return build_response(params, output, num_results=len(results))
265 async def _unstructured_search(query: str, api: NominatimAPIAsync,
266 details: Dict[str, Any]) -> SearchResults:
268 return SearchResults()
270 # Extract special format for coordinates from query.
271 query, x, y = helpers.extract_coords_from_query(query)
274 details['near'] = Point(x, y)
275 details['near_radius'] = 0.1
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)
281 return SearchResults()
283 return SearchResults(
284 [SearchResult(**{f.name: getattr(result, f.name)
285 for f in dataclasses.fields(SearchResult)
286 if hasattr(result, f.name)})])
288 query, cls, typ = helpers.extract_category_from_query(query)
290 assert typ is not None
291 return await api.search_category([(cls, typ)], near_query=query, **details)
293 return await api.search(query, **details)
296 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
297 """ Server glue for /search endpoint. See API docs for details.
299 fmt = parse_format(params, SearchResults, 'jsonv2')
300 debug = setup_debugging(params)
301 details = parse_geometry_details(params, fmt)
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)
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)
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
319 details['layers'] = get_layers(params)
321 # unstructured query parameters
322 query = params.get('q', None)
323 # structured query parameters
325 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
326 details[key] = params.get(key, None)
328 queryparts[key] = details[key]
331 if query is not None:
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)
339 query = ', '.join(queryparts.values())
341 results = await api.search_address(**details)
342 except UsageError as err:
343 params.raise_error(str(err))
345 Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
347 if details['dedupe'] and len(results) > 1:
348 results = helpers.deduplicate_results(results, max_results)
351 return build_response(params, loglib.get_and_disable(), num_results=len(results))
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
361 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
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)}
373 output = params.formatting().format_result(results, fmt, fmt_options)
375 return build_response(params, output, num_results=len(results))
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.
384 fmt = parse_format(params, RawDataList, 'json')
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
396 results.extend(r._asdict() for r in await conn.execute(sql, {'osm_type': osm_type}))
398 return build_response(params, params.formatting().format_result(results, fmt, {}))
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.
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')
412 reduced = params.get_bool('reduced', False)
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)"))
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"))
426 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
428 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
430 return build_response(params, params.formatting().format_result(results, fmt, {}))
433 async def get_routes(api: NominatimAPIAsync) -> Sequence[Tuple[str, EndpointFunc]]:
435 ('status', status_endpoint),
436 ('details', details_endpoint),
437 ('reverse', reverse_endpoint),
438 ('lookup', lookup_endpoint),
439 ('deletable', deletable_endpoint),
440 ('polygons', polygons_endpoint),
443 def has_search_name(conn: sa.engine.Connection) -> bool:
444 insp = sa.inspect(conn)
445 return insp.has_table('search_name')
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):