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 linked_places=params.get_bool('linkedplaces', True),
162 parented_places=params.get_bool('hierarchy', False),
163 keywords=params.get_bool('keywords', False),
164 geometry_output=(GeometryFormat.GEOJSON
165 if params.get_bool('polygon_geojson', False)
166 else GeometryFormat.NONE),
170 return build_response(params, loglib.get_and_disable())
173 params.raise_error('No place with that OSM ID found.', status=404)
175 locales = Locales.from_accept_languages(get_accepted_languages(params))
176 locales.localize_results([result])
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)
198 result = await api.reverse(coord, **details)
201 return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
204 queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
205 zoom = params.get('zoom', None)
207 queryparts['zoom'] = zoom
208 query = urlencode(queryparts)
213 Locales.from_accept_languages(get_accepted_languages(params)).localize_results(
216 fmt_options = {'query': query,
217 'extratags': params.get_bool('extratags', False),
218 'namedetails': params.get_bool('namedetails', False),
219 'addressdetails': params.get_bool('addressdetails', True)}
221 output = params.formatting().format_result(ReverseResults([result] if result else []),
224 return build_response(params, output, num_results=1 if result else 0)
227 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
228 """ Server glue for /lookup endpoint. See API docs for details.
230 fmt = parse_format(params, SearchResults, 'xml')
231 debug = setup_debugging(params)
232 details = parse_geometry_details(params, fmt)
235 for oid in (params.get('osm_ids') or '').split(','):
237 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
238 places.append(OsmID(oid[0].upper(), int(oid[1:])))
240 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
241 params.raise_error('Too many object IDs.')
244 results = await api.lookup(places, **details)
246 results = SearchResults()
249 return build_response(params, loglib.get_and_disable(), num_results=len(results))
251 Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
253 fmt_options = {'extratags': params.get_bool('extratags', False),
254 'namedetails': params.get_bool('namedetails', False),
255 'addressdetails': params.get_bool('addressdetails', True)}
257 output = params.formatting().format_result(results, fmt, fmt_options)
259 return build_response(params, output, num_results=len(results))
262 async def _unstructured_search(query: str, api: NominatimAPIAsync,
263 details: Dict[str, Any]) -> SearchResults:
265 return SearchResults()
267 # Extract special format for coordinates from query.
268 query, x, y = helpers.extract_coords_from_query(query)
271 details['near'] = Point(x, y)
272 details['near_radius'] = 0.1
274 # If no query is left, revert to reverse search.
275 if x is not None and not query:
276 result = await api.reverse(details['near'], **details)
278 return SearchResults()
280 return SearchResults(
281 [SearchResult(**{f.name: getattr(result, f.name)
282 for f in dataclasses.fields(SearchResult)
283 if hasattr(result, f.name)})])
285 query, cls, typ = helpers.extract_category_from_query(query)
287 assert typ is not None
288 return await api.search_category([(cls, typ)], near_query=query, **details)
290 return await api.search(query, **details)
293 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
294 """ Server glue for /search endpoint. See API docs for details.
296 fmt = parse_format(params, SearchResults, 'jsonv2')
297 debug = setup_debugging(params)
298 details = parse_geometry_details(params, fmt)
300 details['countries'] = params.get('countrycodes', None)
301 details['excluded'] = params.get('exclude_place_ids', None)
302 details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
303 details['bounded_viewbox'] = params.get_bool('bounded', False)
304 details['dedupe'] = params.get_bool('dedupe', True)
306 max_results = max(1, min(50, params.get_int('limit', 10)))
307 details['max_results'] = (max_results + min(10, max_results)
308 if details['dedupe'] else max_results)
310 details['min_rank'], details['max_rank'] = \
311 helpers.feature_type_to_rank(params.get('featureType', ''))
312 if params.get('featureType', None) is not None:
313 details['layers'] = DataLayer.ADDRESS
315 details['layers'] = get_layers(params)
317 # unstructured query parameters
318 query = params.get('q', None)
319 # structured query parameters
321 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
322 details[key] = params.get(key, None)
324 queryparts[key] = details[key]
327 if query is not None:
329 params.raise_error("Structured query parameters"
330 "(amenity, street, city, county, state, postalcode, country)"
331 " cannot be used together with 'q' parameter.")
332 queryparts['q'] = query
333 results = await _unstructured_search(query, api, details)
335 query = ', '.join(queryparts.values())
337 results = await api.search_address(**details)
338 except UsageError as err:
339 params.raise_error(str(err))
341 Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
343 if details['dedupe'] and len(results) > 1:
344 results = helpers.deduplicate_results(results, max_results)
347 return build_response(params, loglib.get_and_disable(), num_results=len(results))
350 helpers.extend_query_parts(queryparts, details,
351 params.get('featureType', ''),
352 params.get_bool('namedetails', False),
353 params.get_bool('extratags', False),
354 (str(r.place_id) for r in results if r.place_id))
355 queryparts['format'] = fmt
357 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
361 fmt_options = {'query': query, 'more_url': moreurl,
362 'exclude_place_ids': queryparts.get('exclude_place_ids'),
363 'viewbox': queryparts.get('viewbox'),
364 'extratags': params.get_bool('extratags', False),
365 'namedetails': params.get_bool('namedetails', False),
366 'addressdetails': params.get_bool('addressdetails', False)}
368 output = params.formatting().format_result(results, fmt, fmt_options)
370 return build_response(params, output, num_results=len(results))
373 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
374 """ Server glue for /deletable endpoint.
375 This is a special endpoint that shows polygons that have been
376 deleted or are broken in the OSM data but are kept in the
377 Nominatim database to minimize disruption.
379 fmt = parse_format(params, RawDataList, 'json')
381 results = RawDataList()
382 async with api.begin() as conn:
383 for osm_type in ('N', 'W', 'R'):
384 sql = sa.text(""" SELECT p.place_id, country_code,
385 name->'name' as name, i.*
386 FROM placex p, import_polygon_delete i
387 WHERE i.osm_type = :osm_type
388 AND p.osm_id = i.osm_id AND p.osm_type = :osm_type
389 AND p.class = i.class AND p.type = i.type
391 results.extend(r._asdict() for r in await conn.execute(sql, {'osm_type': osm_type}))
393 return build_response(params, params.formatting().format_result(results, fmt, {}))
396 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
397 """ Server glue for /polygons endpoint.
398 This is a special endpoint that shows polygons that have changed
399 their size but are kept in the Nominatim database with their
400 old area to minimize disruption.
402 fmt = parse_format(params, RawDataList, 'json')
403 sql_params: Dict[str, Any] = {
404 'days': params.get_int('days', -1),
405 'cls': params.get('class')
407 reduced = params.get_bool('reduced', False)
409 async with api.begin() as conn:
410 sql = sa.select(sa.text("""osm_type, osm_id, class, type,
411 name->'name' as name,
412 country_code, errormessage, updated"""))\
413 .select_from(sa.text('import_polygon_error'))
414 if sql_params['days'] > 0:
415 sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
417 sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
418 if sql_params['cls'] is not None:
419 sql = sql.where(sa.text("class = :cls"))
421 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
423 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
425 return build_response(params, params.formatting().format_result(results, fmt, {}))
428 async def get_routes(api: NominatimAPIAsync) -> Sequence[Tuple[str, EndpointFunc]]:
430 ('status', status_endpoint),
431 ('details', details_endpoint),
432 ('reverse', reverse_endpoint),
433 ('lookup', lookup_endpoint),
434 ('deletable', deletable_endpoint),
435 ('polygons', polygons_endpoint),
438 def has_search_name(conn: sa.engine.Connection) -> bool:
439 insp = sa.inspect(conn)
440 return insp.has_table('search_name')
443 async with api.begin() as conn:
444 if await conn.connection.run_sync(has_search_name):
445 routes.append(('search', search_endpoint))
446 except (PGCORE_ERROR, sa.exc.OperationalError):