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 dispatch as formatting
22 from .format import RawDataList
23 from ..types import DataLayer, GeometryFormat, PlaceRef, PlaceID, OsmID, Point
24 from ..status import StatusResult
25 from ..results import DetailedResult, ReverseResults, SearchResult, SearchResults
26 from ..localization import Locales
28 from ..server.asgi_adaptor import CONTENT_HTML, CONTENT_JSON, CONTENT_TYPE, ASGIAdaptor
30 def build_response(adaptor: ASGIAdaptor, output: str, status: int = 200,
31 num_results: int = 0) -> Any:
32 """ Create a response from the given output. Wraps a JSONP function
33 around the response, if necessary.
35 if adaptor.content_type == CONTENT_JSON and status == 200:
36 jsonp = adaptor.get('json_callback')
38 if any(not part.isidentifier() for part in jsonp.split('.')):
39 adaptor.raise_error('Invalid json_callback value')
40 output = f"{jsonp}({output})"
41 adaptor.content_type = 'application/javascript; charset=utf-8'
43 return adaptor.create_response(status, output, num_results)
46 def get_accepted_languages(adaptor: ASGIAdaptor) -> str:
47 """ Return the accepted languages.
49 return adaptor.get('accept-language')\
50 or adaptor.get_header('accept-language')\
51 or adaptor.config().DEFAULT_LANGUAGE
54 def setup_debugging(adaptor: ASGIAdaptor) -> bool:
55 """ Set up collection of debug information if requested.
57 Return True when debugging was requested.
59 if adaptor.get_bool('debug', False):
60 loglib.set_log_output('html')
61 adaptor.content_type = CONTENT_HTML
67 def get_layers(adaptor: ASGIAdaptor) -> Optional[DataLayer]:
68 """ Return a parsed version of the layer parameter.
70 param = adaptor.get('layer', None)
74 return cast(DataLayer,
75 reduce(DataLayer.__or__,
76 (getattr(DataLayer, s.upper()) for s in param.split(','))))
79 def parse_format(adaptor: ASGIAdaptor, result_type: Type[Any], default: str) -> str:
80 """ Get and check the 'format' parameter and prepare the formatter.
81 `result_type` is the type of result to be returned by the function
82 and `default` the format value to assume when no parameter is present.
84 fmt = adaptor.get('format', default=default)
85 assert fmt is not None
87 if not formatting.supports_format(result_type, fmt):
88 adaptor.raise_error("Parameter 'format' must be one of: " +
89 ', '.join(formatting.list_formats(result_type)))
91 adaptor.content_type = CONTENT_TYPE.get(fmt, CONTENT_JSON)
95 def parse_geometry_details(adaptor: ASGIAdaptor, fmt: str) -> Dict[str, Any]:
96 """ Create details structure from the supplied geometry parameters.
99 output = GeometryFormat.NONE
100 if adaptor.get_bool('polygon_geojson', False):
101 output |= GeometryFormat.GEOJSON
103 if fmt not in ('geojson', 'geocodejson'):
104 if adaptor.get_bool('polygon_text', False):
105 output |= GeometryFormat.TEXT
107 if adaptor.get_bool('polygon_kml', False):
108 output |= GeometryFormat.KML
110 if adaptor.get_bool('polygon_svg', False):
111 output |= GeometryFormat.SVG
114 if numgeoms > adaptor.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
115 adaptor.raise_error('Too many polygon output options selected.')
117 return {'address_details': True,
118 'geometry_simplification': adaptor.get_float('polygon_threshold', 0.0),
119 'geometry_output': output
123 async def status_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
124 """ Server glue for /status endpoint. See API docs for details.
126 result = await api.status()
128 fmt = parse_format(params, StatusResult, 'text')
130 if fmt == 'text' and result.status:
135 return build_response(params, formatting.format_result(result, fmt, {}),
139 async def details_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
140 """ Server glue for /details endpoint. See API docs for details.
142 fmt = parse_format(params, DetailedResult, 'json')
143 place_id = params.get_int('place_id', 0)
146 place = PlaceID(place_id)
148 osmtype = params.get('osmtype')
150 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
151 place = OsmID(osmtype, params.get_int('osmid'), params.get('class'))
153 debug = setup_debugging(params)
155 locales = Locales.from_accept_languages(get_accepted_languages(params))
157 result = await api.details(place,
158 address_details=params.get_bool('addressdetails', False),
159 linked_places=params.get_bool('linkedplaces', True),
160 parented_places=params.get_bool('hierarchy', False),
161 keywords=params.get_bool('keywords', False),
162 geometry_output = GeometryFormat.GEOJSON
163 if params.get_bool('polygon_geojson', False)
164 else GeometryFormat.NONE,
169 return build_response(params, loglib.get_and_disable())
172 params.raise_error('No place with that OSM ID found.', status=404)
174 output = formatting.format_result(result, fmt,
176 'group_hierarchy': params.get_bool('group_hierarchy', False),
177 'icon_base_url': params.config().MAPICON_URL})
179 return build_response(params, output, num_results=1)
182 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
183 """ Server glue for /reverse endpoint. See API docs for details.
185 fmt = parse_format(params, ReverseResults, 'xml')
186 debug = setup_debugging(params)
187 coord = Point(params.get_float('lon'), params.get_float('lat'))
189 details = parse_geometry_details(params, fmt)
190 details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
191 details['layers'] = get_layers(params)
192 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
194 result = await api.reverse(coord, **details)
197 return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
200 queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
201 zoom = params.get('zoom', None)
203 queryparts['zoom'] = zoom
204 query = urlencode(queryparts)
208 fmt_options = {'query': query,
209 'extratags': params.get_bool('extratags', False),
210 'namedetails': params.get_bool('namedetails', False),
211 'addressdetails': params.get_bool('addressdetails', True)}
213 output = formatting.format_result(ReverseResults([result] if result else []),
216 return build_response(params, output, num_results=1 if result else 0)
219 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
220 """ Server glue for /lookup endpoint. See API docs for details.
222 fmt = parse_format(params, SearchResults, 'xml')
223 debug = setup_debugging(params)
224 details = parse_geometry_details(params, fmt)
225 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
228 for oid in (params.get('osm_ids') or '').split(','):
230 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
231 places.append(OsmID(oid[0].upper(), int(oid[1:])))
233 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
234 params.raise_error('Too many object IDs.')
237 results = await api.lookup(places, **details)
239 results = SearchResults()
242 return build_response(params, loglib.get_and_disable(), num_results=len(results))
244 fmt_options = {'extratags': params.get_bool('extratags', False),
245 'namedetails': params.get_bool('namedetails', False),
246 'addressdetails': params.get_bool('addressdetails', True)}
248 output = formatting.format_result(results, fmt, fmt_options)
250 return build_response(params, output, num_results=len(results))
253 async def _unstructured_search(query: str, api: NominatimAPIAsync,
254 details: Dict[str, Any]) -> SearchResults:
256 return SearchResults()
258 # Extract special format for coordinates from query.
259 query, x, y = helpers.extract_coords_from_query(query)
262 details['near'] = Point(x, y)
263 details['near_radius'] = 0.1
265 # If no query is left, revert to reverse search.
266 if x is not None and not query:
267 result = await api.reverse(details['near'], **details)
269 return SearchResults()
271 return SearchResults(
272 [SearchResult(**{f.name: getattr(result, f.name)
273 for f in dataclasses.fields(SearchResult)
274 if hasattr(result, f.name)})])
276 query, cls, typ = helpers.extract_category_from_query(query)
278 assert typ is not None
279 return await api.search_category([(cls, typ)], near_query=query, **details)
281 return await api.search(query, **details)
284 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
285 """ Server glue for /search endpoint. See API docs for details.
287 fmt = parse_format(params, SearchResults, 'jsonv2')
288 debug = setup_debugging(params)
289 details = parse_geometry_details(params, fmt)
291 details['countries'] = params.get('countrycodes', None)
292 details['excluded'] = params.get('exclude_place_ids', None)
293 details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
294 details['bounded_viewbox'] = params.get_bool('bounded', False)
295 details['dedupe'] = params.get_bool('dedupe', True)
297 max_results = max(1, min(50, params.get_int('limit', 10)))
298 details['max_results'] = max_results + min(10, max_results) \
299 if details['dedupe'] else max_results
301 details['min_rank'], details['max_rank'] = \
302 helpers.feature_type_to_rank(params.get('featureType', ''))
303 if params.get('featureType', None) is not None:
304 details['layers'] = DataLayer.ADDRESS
306 details['layers'] = get_layers(params)
308 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
310 # unstructured query parameters
311 query = params.get('q', None)
312 # structured query parameters
314 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
315 details[key] = params.get(key, None)
317 queryparts[key] = details[key]
320 if query is not None:
322 params.raise_error("Structured query parameters"
323 "(amenity, street, city, county, state, postalcode, country)"
324 " cannot be used together with 'q' parameter.")
325 queryparts['q'] = query
326 results = await _unstructured_search(query, api, details)
328 query = ', '.join(queryparts.values())
330 results = await api.search_address(**details)
331 except UsageError as err:
332 params.raise_error(str(err))
334 if details['dedupe'] and len(results) > 1:
335 results = helpers.deduplicate_results(results, max_results)
338 return build_response(params, loglib.get_and_disable(), num_results=len(results))
341 helpers.extend_query_parts(queryparts, details,
342 params.get('featureType', ''),
343 params.get_bool('namedetails', False),
344 params.get_bool('extratags', False),
345 (str(r.place_id) for r in results if r.place_id))
346 queryparts['format'] = fmt
348 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
352 fmt_options = {'query': query, 'more_url': moreurl,
353 'exclude_place_ids': queryparts.get('exclude_place_ids'),
354 'viewbox': queryparts.get('viewbox'),
355 'extratags': params.get_bool('extratags', False),
356 'namedetails': params.get_bool('namedetails', False),
357 'addressdetails': params.get_bool('addressdetails', False)}
359 output = formatting.format_result(results, fmt, fmt_options)
361 return build_response(params, output, num_results=len(results))
364 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
365 """ Server glue for /deletable endpoint.
366 This is a special endpoint that shows polygons that have been
367 deleted or are broken in the OSM data but are kept in the
368 Nominatim database to minimize disruption.
370 fmt = parse_format(params, RawDataList, 'json')
372 async with api.begin() as conn:
373 sql = sa.text(""" SELECT p.place_id, country_code,
374 name->'name' as name, i.*
375 FROM placex p, import_polygon_delete i
376 WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
377 AND p.class = i.class AND p.type = i.type
379 results = RawDataList(r._asdict() for r in await conn.execute(sql))
381 return build_response(params, formatting.format_result(results, fmt, {}))
384 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
385 """ Server glue for /polygons endpoint.
386 This is a special endpoint that shows polygons that have changed
387 their size but are kept in the Nominatim database with their
388 old area to minimize disruption.
390 fmt = parse_format(params, RawDataList, 'json')
391 sql_params: Dict[str, Any] = {
392 'days': params.get_int('days', -1),
393 'cls': params.get('class')
395 reduced = params.get_bool('reduced', False)
397 async with api.begin() as conn:
398 sql = sa.select(sa.text("""osm_type, osm_id, class, type,
399 name->'name' as name,
400 country_code, errormessage, updated"""))\
401 .select_from(sa.text('import_polygon_error'))
402 if sql_params['days'] > 0:
403 sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
405 sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
406 if sql_params['cls'] is not None:
407 sql = sql.where(sa.text("class = :cls"))
409 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
411 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
413 return build_response(params, formatting.format_result(results, fmt, {}))
417 ('status', status_endpoint),
418 ('details', details_endpoint),
419 ('reverse', reverse_endpoint),
420 ('lookup', lookup_endpoint),
421 ('search', search_endpoint),
422 ('deletable', deletable_endpoint),
423 ('polygons', polygons_endpoint),