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 locales = Locales.from_accept_languages(get_accepted_languages(params))
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),
173 return build_response(params, loglib.get_and_disable())
176 params.raise_error('No place with that OSM ID found.', status=404)
178 output = params.formatting().format_result(
181 'group_hierarchy': params.get_bool('group_hierarchy', False),
182 'icon_base_url': params.config().MAPICON_URL})
184 return build_response(params, output, num_results=1)
187 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
188 """ Server glue for /reverse endpoint. See API docs for details.
190 fmt = parse_format(params, ReverseResults, 'xml')
191 debug = setup_debugging(params)
192 coord = Point(params.get_float('lon'), params.get_float('lat'))
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))
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)
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)}
218 output = params.formatting().format_result(ReverseResults([result] if result else []),
221 return build_response(params, output, num_results=1 if result else 0)
224 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
225 """ Server glue for /lookup endpoint. See API docs for details.
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))
233 for oid in (params.get('osm_ids') or '').split(','):
235 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
236 places.append(OsmID(oid[0].upper(), int(oid[1:])))
238 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
239 params.raise_error('Too many object IDs.')
242 results = await api.lookup(places, **details)
244 results = SearchResults()
247 return build_response(params, loglib.get_and_disable(), num_results=len(results))
249 fmt_options = {'extratags': params.get_bool('extratags', False),
250 'namedetails': params.get_bool('namedetails', False),
251 'addressdetails': params.get_bool('addressdetails', True)}
253 output = params.formatting().format_result(results, fmt, fmt_options)
255 return build_response(params, output, num_results=len(results))
258 async def _unstructured_search(query: str, api: NominatimAPIAsync,
259 details: Dict[str, Any]) -> SearchResults:
261 return SearchResults()
263 # Extract special format for coordinates from query.
264 query, x, y = helpers.extract_coords_from_query(query)
267 details['near'] = Point(x, y)
268 details['near_radius'] = 0.1
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)
274 return SearchResults()
276 return SearchResults(
277 [SearchResult(**{f.name: getattr(result, f.name)
278 for f in dataclasses.fields(SearchResult)
279 if hasattr(result, f.name)})])
281 query, cls, typ = helpers.extract_category_from_query(query)
283 assert typ is not None
284 return await api.search_category([(cls, typ)], near_query=query, **details)
286 return await api.search(query, **details)
289 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
290 """ Server glue for /search endpoint. See API docs for details.
292 fmt = parse_format(params, SearchResults, 'jsonv2')
293 debug = setup_debugging(params)
294 details = parse_geometry_details(params, fmt)
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)
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)
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
311 details['layers'] = get_layers(params)
313 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
315 # unstructured query parameters
316 query = params.get('q', None)
317 # structured query parameters
319 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
320 details[key] = params.get(key, None)
322 queryparts[key] = details[key]
325 if query is not None:
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)
333 query = ', '.join(queryparts.values())
335 results = await api.search_address(**details)
336 except UsageError as err:
337 params.raise_error(str(err))
339 if details['dedupe'] and len(results) > 1:
340 results = helpers.deduplicate_results(results, max_results)
343 return build_response(params, loglib.get_and_disable(), num_results=len(results))
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
353 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
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)}
364 output = params.formatting().format_result(results, fmt, fmt_options)
366 return build_response(params, output, num_results=len(results))
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.
375 fmt = parse_format(params, RawDataList, 'json')
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
384 results = RawDataList(r._asdict() for r in await conn.execute(sql))
386 return build_response(params, params.formatting().format_result(results, fmt, {}))
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.
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')
400 reduced = params.get_bool('reduced', False)
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)"))
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"))
414 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
416 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
418 return build_response(params, params.formatting().format_result(results, fmt, {}))
421 async def get_routes(api: NominatimAPIAsync) -> Sequence[Tuple[str, EndpointFunc]]:
423 ('status', status_endpoint),
424 ('details', details_endpoint),
425 ('reverse', reverse_endpoint),
426 ('lookup', lookup_endpoint),
427 ('deletable', deletable_endpoint),
428 ('polygons', polygons_endpoint),
431 def has_search_name(conn: sa.engine.Connection) -> bool:
432 insp = sa.inspect(conn)
433 return insp.has_table('search_name')
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):