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
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.asgi_adaptor import CONTENT_HTML, CONTENT_JSON, CONTENT_TYPE, ASGIAdaptor
29 def build_response(adaptor: ASGIAdaptor, output: str, status: int = 200,
30 num_results: int = 0) -> Any:
31 """ Create a response from the given output. Wraps a JSONP function
32 around the response, if necessary.
34 if adaptor.content_type == CONTENT_JSON and status == 200:
35 jsonp = adaptor.get('json_callback')
37 if any(not part.isidentifier() for part in jsonp.split('.')):
38 adaptor.raise_error('Invalid json_callback value')
39 output = f"{jsonp}({output})"
40 adaptor.content_type = 'application/javascript; charset=utf-8'
42 return adaptor.create_response(status, output, num_results)
45 def get_accepted_languages(adaptor: ASGIAdaptor) -> str:
46 """ Return the accepted languages.
48 return adaptor.get('accept-language')\
49 or adaptor.get_header('accept-language')\
50 or adaptor.config().DEFAULT_LANGUAGE
53 def setup_debugging(adaptor: ASGIAdaptor) -> bool:
54 """ Set up collection of debug information if requested.
56 Return True when debugging was requested.
58 if adaptor.get_bool('debug', False):
59 loglib.set_log_output('html')
60 adaptor.content_type = CONTENT_HTML
66 def get_layers(adaptor: ASGIAdaptor) -> Optional[DataLayer]:
67 """ Return a parsed version of the layer parameter.
69 param = adaptor.get('layer', None)
73 return cast(DataLayer,
74 reduce(DataLayer.__or__,
75 (getattr(DataLayer, s.upper()) for s in param.split(','))))
78 def parse_format(adaptor: ASGIAdaptor, result_type: Type[Any], default: str) -> str:
79 """ Get and check the 'format' parameter and prepare the formatter.
80 `result_type` is the type of result to be returned by the function
81 and `default` the format value to assume when no parameter is present.
83 fmt = adaptor.get('format', default=default)
84 assert fmt is not None
86 if not adaptor.formatting().supports_format(result_type, fmt):
87 adaptor.raise_error("Parameter 'format' must be one of: " +
88 ', '.join(adaptor.formatting().list_formats(result_type)))
90 adaptor.content_type = CONTENT_TYPE.get(fmt, CONTENT_JSON)
94 def parse_geometry_details(adaptor: ASGIAdaptor, fmt: str) -> Dict[str, Any]:
95 """ Create details structure from the supplied geometry parameters.
98 output = GeometryFormat.NONE
99 if adaptor.get_bool('polygon_geojson', False):
100 output |= GeometryFormat.GEOJSON
102 if fmt not in ('geojson', 'geocodejson'):
103 if adaptor.get_bool('polygon_text', False):
104 output |= GeometryFormat.TEXT
106 if adaptor.get_bool('polygon_kml', False):
107 output |= GeometryFormat.KML
109 if adaptor.get_bool('polygon_svg', False):
110 output |= GeometryFormat.SVG
113 if numgeoms > adaptor.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
114 adaptor.raise_error('Too many polygon output options selected.')
116 return {'address_details': True,
117 'geometry_simplification': adaptor.get_float('polygon_threshold', 0.0),
118 'geometry_output': output
122 async def status_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
123 """ Server glue for /status endpoint. See API docs for details.
125 result = await api.status()
127 fmt = parse_format(params, StatusResult, 'text')
129 if fmt == 'text' and result.status:
134 return build_response(params, params.formatting().format_result(result, fmt, {}),
138 async def details_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
139 """ Server glue for /details endpoint. See API docs for details.
141 fmt = parse_format(params, DetailedResult, 'json')
142 place_id = params.get_int('place_id', 0)
145 place = PlaceID(place_id)
147 osmtype = params.get('osmtype')
149 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
150 place = OsmID(osmtype, params.get_int('osmid'), params.get('class'))
152 debug = setup_debugging(params)
154 locales = Locales.from_accept_languages(get_accepted_languages(params))
156 result = await api.details(place,
157 address_details=params.get_bool('addressdetails', False),
158 linked_places=params.get_bool('linkedplaces', True),
159 parented_places=params.get_bool('hierarchy', False),
160 keywords=params.get_bool('keywords', False),
161 geometry_output = GeometryFormat.GEOJSON
162 if params.get_bool('polygon_geojson', False)
163 else GeometryFormat.NONE,
168 return build_response(params, loglib.get_and_disable())
171 params.raise_error('No place with that OSM ID found.', status=404)
173 output = params.formatting().format_result(result, fmt,
175 'group_hierarchy': params.get_bool('group_hierarchy', False),
176 'icon_base_url': params.config().MAPICON_URL})
178 return build_response(params, output, num_results=1)
181 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
182 """ Server glue for /reverse endpoint. See API docs for details.
184 fmt = parse_format(params, ReverseResults, 'xml')
185 debug = setup_debugging(params)
186 coord = Point(params.get_float('lon'), params.get_float('lat'))
188 details = parse_geometry_details(params, fmt)
189 details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
190 details['layers'] = get_layers(params)
191 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
193 result = await api.reverse(coord, **details)
196 return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
199 queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
200 zoom = params.get('zoom', None)
202 queryparts['zoom'] = zoom
203 query = urlencode(queryparts)
207 fmt_options = {'query': query,
208 'extratags': params.get_bool('extratags', False),
209 'namedetails': params.get_bool('namedetails', False),
210 'addressdetails': params.get_bool('addressdetails', True)}
212 output = params.formatting().format_result(ReverseResults([result] if result else []),
215 return build_response(params, output, num_results=1 if result else 0)
218 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
219 """ Server glue for /lookup endpoint. See API docs for details.
221 fmt = parse_format(params, SearchResults, 'xml')
222 debug = setup_debugging(params)
223 details = parse_geometry_details(params, fmt)
224 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
227 for oid in (params.get('osm_ids') or '').split(','):
229 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
230 places.append(OsmID(oid[0].upper(), int(oid[1:])))
232 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
233 params.raise_error('Too many object IDs.')
236 results = await api.lookup(places, **details)
238 results = SearchResults()
241 return build_response(params, loglib.get_and_disable(), num_results=len(results))
243 fmt_options = {'extratags': params.get_bool('extratags', False),
244 'namedetails': params.get_bool('namedetails', False),
245 'addressdetails': params.get_bool('addressdetails', True)}
247 output = params.formatting().format_result(results, fmt, fmt_options)
249 return build_response(params, output, num_results=len(results))
252 async def _unstructured_search(query: str, api: NominatimAPIAsync,
253 details: Dict[str, Any]) -> SearchResults:
255 return SearchResults()
257 # Extract special format for coordinates from query.
258 query, x, y = helpers.extract_coords_from_query(query)
261 details['near'] = Point(x, y)
262 details['near_radius'] = 0.1
264 # If no query is left, revert to reverse search.
265 if x is not None and not query:
266 result = await api.reverse(details['near'], **details)
268 return SearchResults()
270 return SearchResults(
271 [SearchResult(**{f.name: getattr(result, f.name)
272 for f in dataclasses.fields(SearchResult)
273 if hasattr(result, f.name)})])
275 query, cls, typ = helpers.extract_category_from_query(query)
277 assert typ is not None
278 return await api.search_category([(cls, typ)], near_query=query, **details)
280 return await api.search(query, **details)
283 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
284 """ Server glue for /search endpoint. See API docs for details.
286 fmt = parse_format(params, SearchResults, 'jsonv2')
287 debug = setup_debugging(params)
288 details = parse_geometry_details(params, fmt)
290 details['countries'] = params.get('countrycodes', None)
291 details['excluded'] = params.get('exclude_place_ids', None)
292 details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
293 details['bounded_viewbox'] = params.get_bool('bounded', False)
294 details['dedupe'] = params.get_bool('dedupe', True)
296 max_results = max(1, min(50, params.get_int('limit', 10)))
297 details['max_results'] = max_results + min(10, max_results) \
298 if details['dedupe'] else max_results
300 details['min_rank'], details['max_rank'] = \
301 helpers.feature_type_to_rank(params.get('featureType', ''))
302 if params.get('featureType', None) is not None:
303 details['layers'] = DataLayer.ADDRESS
305 details['layers'] = get_layers(params)
307 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
309 # unstructured query parameters
310 query = params.get('q', None)
311 # structured query parameters
313 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
314 details[key] = params.get(key, None)
316 queryparts[key] = details[key]
319 if query is not None:
321 params.raise_error("Structured query parameters"
322 "(amenity, street, city, county, state, postalcode, country)"
323 " cannot be used together with 'q' parameter.")
324 queryparts['q'] = query
325 results = await _unstructured_search(query, api, details)
327 query = ', '.join(queryparts.values())
329 results = await api.search_address(**details)
330 except UsageError as err:
331 params.raise_error(str(err))
333 if details['dedupe'] and len(results) > 1:
334 results = helpers.deduplicate_results(results, max_results)
337 return build_response(params, loglib.get_and_disable(), num_results=len(results))
340 helpers.extend_query_parts(queryparts, details,
341 params.get('featureType', ''),
342 params.get_bool('namedetails', False),
343 params.get_bool('extratags', False),
344 (str(r.place_id) for r in results if r.place_id))
345 queryparts['format'] = fmt
347 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
351 fmt_options = {'query': query, 'more_url': moreurl,
352 'exclude_place_ids': queryparts.get('exclude_place_ids'),
353 'viewbox': queryparts.get('viewbox'),
354 'extratags': params.get_bool('extratags', False),
355 'namedetails': params.get_bool('namedetails', False),
356 'addressdetails': params.get_bool('addressdetails', False)}
358 output = params.formatting().format_result(results, fmt, fmt_options)
360 return build_response(params, output, num_results=len(results))
363 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
364 """ Server glue for /deletable endpoint.
365 This is a special endpoint that shows polygons that have been
366 deleted or are broken in the OSM data but are kept in the
367 Nominatim database to minimize disruption.
369 fmt = parse_format(params, RawDataList, 'json')
371 async with api.begin() as conn:
372 sql = sa.text(""" SELECT p.place_id, country_code,
373 name->'name' as name, i.*
374 FROM placex p, import_polygon_delete i
375 WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
376 AND p.class = i.class AND p.type = i.type
378 results = RawDataList(r._asdict() for r in await conn.execute(sql))
380 return build_response(params, params.formatting().format_result(results, fmt, {}))
383 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
384 """ Server glue for /polygons endpoint.
385 This is a special endpoint that shows polygons that have changed
386 their size but are kept in the Nominatim database with their
387 old area to minimize disruption.
389 fmt = parse_format(params, RawDataList, 'json')
390 sql_params: Dict[str, Any] = {
391 'days': params.get_int('days', -1),
392 'cls': params.get('class')
394 reduced = params.get_bool('reduced', False)
396 async with api.begin() as conn:
397 sql = sa.select(sa.text("""osm_type, osm_id, class, type,
398 name->'name' as name,
399 country_code, errormessage, updated"""))\
400 .select_from(sa.text('import_polygon_error'))
401 if sql_params['days'] > 0:
402 sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
404 sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
405 if sql_params['cls'] is not None:
406 sql = sql.where(sa.text("class = :cls"))
408 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
410 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
412 return build_response(params, params.formatting().format_result(results, fmt, {}))
416 ('status', status_endpoint),
417 ('details', details_endpoint),
418 ('reverse', reverse_endpoint),
419 ('lookup', lookup_endpoint),
420 ('search', search_endpoint),
421 ('deletable', deletable_endpoint),
422 ('polygons', polygons_endpoint),