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 import content_types as ct
28 from ..server.asgi_adaptor import ASGIAdaptor
31 def build_response(adaptor: ASGIAdaptor, output: str, status: int = 200,
32 num_results: int = 0) -> Any:
33 """ Create a response from the given output. Wraps a JSONP function
34 around the response, if necessary.
36 if adaptor.content_type == ct.CONTENT_JSON and status == 200:
37 jsonp = adaptor.get('json_callback')
39 if any(not part.isidentifier() for part in jsonp.split('.')):
40 adaptor.raise_error('Invalid json_callback value')
41 output = f"{jsonp}({output})"
42 adaptor.content_type = 'application/javascript; charset=utf-8'
44 return adaptor.create_response(status, output, num_results)
47 def get_accepted_languages(adaptor: ASGIAdaptor) -> str:
48 """ Return the accepted languages.
50 return adaptor.get('accept-language')\
51 or adaptor.get_header('accept-language')\
52 or adaptor.config().DEFAULT_LANGUAGE
55 def setup_debugging(adaptor: ASGIAdaptor) -> bool:
56 """ Set up collection of debug information if requested.
58 Return True when debugging was requested.
60 if adaptor.get_bool('debug', False):
61 loglib.set_log_output('html')
62 adaptor.content_type = ct.CONTENT_HTML
68 def get_layers(adaptor: ASGIAdaptor) -> Optional[DataLayer]:
69 """ Return a parsed version of the layer parameter.
71 param = adaptor.get('layer', None)
75 return cast(DataLayer,
76 reduce(DataLayer.__or__,
77 (getattr(DataLayer, s.upper()) for s in param.split(','))))
80 def parse_format(adaptor: ASGIAdaptor, result_type: Type[Any], default: str) -> str:
81 """ Get and check the 'format' parameter and prepare the formatter.
82 `result_type` is the type of result to be returned by the function
83 and `default` the format value to assume when no parameter is present.
85 fmt = adaptor.get('format', default=default)
86 assert fmt is not None
88 formatting = adaptor.formatting()
90 if not formatting.supports_format(result_type, fmt):
91 adaptor.raise_error("Parameter 'format' must be one of: " +
92 ', '.join(formatting.list_formats(result_type)))
94 adaptor.content_type = formatting.get_content_type(fmt)
98 def parse_geometry_details(adaptor: ASGIAdaptor, fmt: str) -> Dict[str, Any]:
99 """ Create details structure from the supplied geometry parameters.
102 output = GeometryFormat.NONE
103 if adaptor.get_bool('polygon_geojson', False):
104 output |= GeometryFormat.GEOJSON
106 if fmt not in ('geojson', 'geocodejson'):
107 if adaptor.get_bool('polygon_text', False):
108 output |= GeometryFormat.TEXT
110 if adaptor.get_bool('polygon_kml', False):
111 output |= GeometryFormat.KML
113 if adaptor.get_bool('polygon_svg', False):
114 output |= GeometryFormat.SVG
117 if numgeoms > adaptor.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
118 adaptor.raise_error('Too many polygon output options selected.')
120 return {'address_details': True,
121 'geometry_simplification': adaptor.get_float('polygon_threshold', 0.0),
122 'geometry_output': output
126 async def status_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
127 """ Server glue for /status endpoint. See API docs for details.
129 result = await api.status()
131 fmt = parse_format(params, StatusResult, 'text')
133 if fmt == 'text' and result.status:
138 return build_response(params, params.formatting().format_result(result, fmt, {}),
142 async def details_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
143 """ Server glue for /details endpoint. See API docs for details.
145 fmt = parse_format(params, DetailedResult, 'json')
146 place_id = params.get_int('place_id', 0)
149 place = PlaceID(place_id)
151 osmtype = params.get('osmtype')
153 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
154 place = OsmID(osmtype, params.get_int('osmid'), params.get('class'))
156 debug = setup_debugging(params)
158 locales = Locales.from_accept_languages(get_accepted_languages(params))
160 result = await api.details(place,
161 address_details=params.get_bool('addressdetails', 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),
172 return build_response(params, loglib.get_and_disable())
175 params.raise_error('No place with that OSM ID found.', status=404)
177 output = params.formatting().format_result(
180 'group_hierarchy': params.get_bool('group_hierarchy', False),
181 'icon_base_url': params.config().MAPICON_URL})
183 return build_response(params, output, num_results=1)
186 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
187 """ Server glue for /reverse endpoint. See API docs for details.
189 fmt = parse_format(params, ReverseResults, 'xml')
190 debug = setup_debugging(params)
191 coord = Point(params.get_float('lon'), params.get_float('lat'))
193 details = parse_geometry_details(params, fmt)
194 details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
195 details['layers'] = get_layers(params)
196 details['locales'] = Locales.from_accept_languages(get_accepted_languages(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)
212 fmt_options = {'query': query,
213 'extratags': params.get_bool('extratags', False),
214 'namedetails': params.get_bool('namedetails', False),
215 'addressdetails': params.get_bool('addressdetails', True)}
217 output = params.formatting().format_result(ReverseResults([result] if result else []),
220 return build_response(params, output, num_results=1 if result else 0)
223 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
224 """ Server glue for /lookup endpoint. See API docs for details.
226 fmt = parse_format(params, SearchResults, 'xml')
227 debug = setup_debugging(params)
228 details = parse_geometry_details(params, fmt)
229 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
232 for oid in (params.get('osm_ids') or '').split(','):
234 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
235 places.append(OsmID(oid[0].upper(), int(oid[1:])))
237 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
238 params.raise_error('Too many object IDs.')
241 results = await api.lookup(places, **details)
243 results = SearchResults()
246 return build_response(params, loglib.get_and_disable(), num_results=len(results))
248 fmt_options = {'extratags': params.get_bool('extratags', False),
249 'namedetails': params.get_bool('namedetails', False),
250 'addressdetails': params.get_bool('addressdetails', True)}
252 output = params.formatting().format_result(results, fmt, fmt_options)
254 return build_response(params, output, num_results=len(results))
257 async def _unstructured_search(query: str, api: NominatimAPIAsync,
258 details: Dict[str, Any]) -> SearchResults:
260 return SearchResults()
262 # Extract special format for coordinates from query.
263 query, x, y = helpers.extract_coords_from_query(query)
266 details['near'] = Point(x, y)
267 details['near_radius'] = 0.1
269 # If no query is left, revert to reverse search.
270 if x is not None and not query:
271 result = await api.reverse(details['near'], **details)
273 return SearchResults()
275 return SearchResults(
276 [SearchResult(**{f.name: getattr(result, f.name)
277 for f in dataclasses.fields(SearchResult)
278 if hasattr(result, f.name)})])
280 query, cls, typ = helpers.extract_category_from_query(query)
282 assert typ is not None
283 return await api.search_category([(cls, typ)], near_query=query, **details)
285 return await api.search(query, **details)
288 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
289 """ Server glue for /search endpoint. See API docs for details.
291 fmt = parse_format(params, SearchResults, 'jsonv2')
292 debug = setup_debugging(params)
293 details = parse_geometry_details(params, fmt)
295 details['countries'] = params.get('countrycodes', None)
296 details['excluded'] = params.get('exclude_place_ids', None)
297 details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
298 details['bounded_viewbox'] = params.get_bool('bounded', False)
299 details['dedupe'] = params.get_bool('dedupe', True)
301 max_results = max(1, min(50, params.get_int('limit', 10)))
302 details['max_results'] = (max_results + min(10, max_results)
303 if details['dedupe'] else max_results)
305 details['min_rank'], details['max_rank'] = \
306 helpers.feature_type_to_rank(params.get('featureType', ''))
307 if params.get('featureType', None) is not None:
308 details['layers'] = DataLayer.ADDRESS
310 details['layers'] = get_layers(params)
312 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
314 # unstructured query parameters
315 query = params.get('q', None)
316 # structured query parameters
318 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
319 details[key] = params.get(key, None)
321 queryparts[key] = details[key]
324 if query is not None:
326 params.raise_error("Structured query parameters"
327 "(amenity, street, city, county, state, postalcode, country)"
328 " cannot be used together with 'q' parameter.")
329 queryparts['q'] = query
330 results = await _unstructured_search(query, api, details)
332 query = ', '.join(queryparts.values())
334 results = await api.search_address(**details)
335 except UsageError as err:
336 params.raise_error(str(err))
338 if details['dedupe'] and len(results) > 1:
339 results = helpers.deduplicate_results(results, max_results)
342 return build_response(params, loglib.get_and_disable(), num_results=len(results))
345 helpers.extend_query_parts(queryparts, details,
346 params.get('featureType', ''),
347 params.get_bool('namedetails', False),
348 params.get_bool('extratags', False),
349 (str(r.place_id) for r in results if r.place_id))
350 queryparts['format'] = fmt
352 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
356 fmt_options = {'query': query, 'more_url': moreurl,
357 'exclude_place_ids': queryparts.get('exclude_place_ids'),
358 'viewbox': queryparts.get('viewbox'),
359 'extratags': params.get_bool('extratags', False),
360 'namedetails': params.get_bool('namedetails', False),
361 'addressdetails': params.get_bool('addressdetails', False)}
363 output = params.formatting().format_result(results, fmt, fmt_options)
365 return build_response(params, output, num_results=len(results))
368 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
369 """ Server glue for /deletable endpoint.
370 This is a special endpoint that shows polygons that have been
371 deleted or are broken in the OSM data but are kept in the
372 Nominatim database to minimize disruption.
374 fmt = parse_format(params, RawDataList, 'json')
376 async with api.begin() as conn:
377 sql = sa.text(""" SELECT p.place_id, country_code,
378 name->'name' as name, i.*
379 FROM placex p, import_polygon_delete i
380 WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
381 AND p.class = i.class AND p.type = i.type
383 results = RawDataList(r._asdict() for r in await conn.execute(sql))
385 return build_response(params, params.formatting().format_result(results, fmt, {}))
388 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
389 """ Server glue for /polygons endpoint.
390 This is a special endpoint that shows polygons that have changed
391 their size but are kept in the Nominatim database with their
392 old area to minimize disruption.
394 fmt = parse_format(params, RawDataList, 'json')
395 sql_params: Dict[str, Any] = {
396 'days': params.get_int('days', -1),
397 'cls': params.get('class')
399 reduced = params.get_bool('reduced', False)
401 async with api.begin() as conn:
402 sql = sa.select(sa.text("""osm_type, osm_id, class, type,
403 name->'name' as name,
404 country_code, errormessage, updated"""))\
405 .select_from(sa.text('import_polygon_error'))
406 if sql_params['days'] > 0:
407 sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
409 sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
410 if sql_params['cls'] is not None:
411 sql = sql.where(sa.text("class = :cls"))
413 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
415 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
417 return build_response(params, params.formatting().format_result(results, fmt, {}))
421 ('status', status_endpoint),
422 ('details', details_endpoint),
423 ('reverse', reverse_endpoint),
424 ('lookup', lookup_endpoint),
425 ('search', search_endpoint),
426 ('deletable', deletable_endpoint),
427 ('polygons', polygons_endpoint),