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
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 == ct.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 = ct.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 formatting = adaptor.formatting()
89 if not formatting.supports_format(result_type, fmt):
90 adaptor.raise_error("Parameter 'format' must be one of: " +
91 ', '.join(formatting.list_formats(result_type)))
93 adaptor.content_type = formatting.get_content_type(fmt)
97 def parse_geometry_details(adaptor: ASGIAdaptor, fmt: str) -> Dict[str, Any]:
98 """ Create details structure from the supplied geometry parameters.
101 output = GeometryFormat.NONE
102 if adaptor.get_bool('polygon_geojson', False):
103 output |= GeometryFormat.GEOJSON
105 if fmt not in ('geojson', 'geocodejson'):
106 if adaptor.get_bool('polygon_text', False):
107 output |= GeometryFormat.TEXT
109 if adaptor.get_bool('polygon_kml', False):
110 output |= GeometryFormat.KML
112 if adaptor.get_bool('polygon_svg', False):
113 output |= GeometryFormat.SVG
116 if numgeoms > adaptor.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
117 adaptor.raise_error('Too many polygon output options selected.')
119 return {'address_details': True,
120 'geometry_simplification': adaptor.get_float('polygon_threshold', 0.0),
121 'geometry_output': output
125 async def status_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
126 """ Server glue for /status endpoint. See API docs for details.
128 result = await api.status()
130 fmt = parse_format(params, StatusResult, 'text')
132 if fmt == 'text' and result.status:
137 return build_response(params, params.formatting().format_result(result, fmt, {}),
141 async def details_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
142 """ Server glue for /details endpoint. See API docs for details.
144 fmt = parse_format(params, DetailedResult, 'json')
145 place_id = params.get_int('place_id', 0)
148 place = PlaceID(place_id)
150 osmtype = params.get('osmtype')
152 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
153 place = OsmID(osmtype, params.get_int('osmid'), params.get('class'))
155 debug = setup_debugging(params)
157 locales = Locales.from_accept_languages(get_accepted_languages(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,
171 return build_response(params, loglib.get_and_disable())
174 params.raise_error('No place with that OSM ID found.', status=404)
176 output = params.formatting().format_result(result, fmt,
178 'group_hierarchy': params.get_bool('group_hierarchy', False),
179 'icon_base_url': params.config().MAPICON_URL})
181 return build_response(params, output, num_results=1)
184 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
185 """ Server glue for /reverse endpoint. See API docs for details.
187 fmt = parse_format(params, ReverseResults, 'xml')
188 debug = setup_debugging(params)
189 coord = Point(params.get_float('lon'), params.get_float('lat'))
191 details = parse_geometry_details(params, fmt)
192 details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
193 details['layers'] = get_layers(params)
194 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
196 result = await api.reverse(coord, **details)
199 return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
202 queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
203 zoom = params.get('zoom', None)
205 queryparts['zoom'] = zoom
206 query = urlencode(queryparts)
210 fmt_options = {'query': query,
211 'extratags': params.get_bool('extratags', False),
212 'namedetails': params.get_bool('namedetails', False),
213 'addressdetails': params.get_bool('addressdetails', True)}
215 output = params.formatting().format_result(ReverseResults([result] if result else []),
218 return build_response(params, output, num_results=1 if result else 0)
221 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
222 """ Server glue for /lookup endpoint. See API docs for details.
224 fmt = parse_format(params, SearchResults, 'xml')
225 debug = setup_debugging(params)
226 details = parse_geometry_details(params, fmt)
227 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
230 for oid in (params.get('osm_ids') or '').split(','):
232 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
233 places.append(OsmID(oid[0].upper(), int(oid[1:])))
235 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
236 params.raise_error('Too many object IDs.')
239 results = await api.lookup(places, **details)
241 results = SearchResults()
244 return build_response(params, loglib.get_and_disable(), num_results=len(results))
246 fmt_options = {'extratags': params.get_bool('extratags', False),
247 'namedetails': params.get_bool('namedetails', False),
248 'addressdetails': params.get_bool('addressdetails', True)}
250 output = params.formatting().format_result(results, fmt, fmt_options)
252 return build_response(params, output, num_results=len(results))
255 async def _unstructured_search(query: str, api: NominatimAPIAsync,
256 details: Dict[str, Any]) -> SearchResults:
258 return SearchResults()
260 # Extract special format for coordinates from query.
261 query, x, y = helpers.extract_coords_from_query(query)
264 details['near'] = Point(x, y)
265 details['near_radius'] = 0.1
267 # If no query is left, revert to reverse search.
268 if x is not None and not query:
269 result = await api.reverse(details['near'], **details)
271 return SearchResults()
273 return SearchResults(
274 [SearchResult(**{f.name: getattr(result, f.name)
275 for f in dataclasses.fields(SearchResult)
276 if hasattr(result, f.name)})])
278 query, cls, typ = helpers.extract_category_from_query(query)
280 assert typ is not None
281 return await api.search_category([(cls, typ)], near_query=query, **details)
283 return await api.search(query, **details)
286 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
287 """ Server glue for /search endpoint. See API docs for details.
289 fmt = parse_format(params, SearchResults, 'jsonv2')
290 debug = setup_debugging(params)
291 details = parse_geometry_details(params, fmt)
293 details['countries'] = params.get('countrycodes', None)
294 details['excluded'] = params.get('exclude_place_ids', None)
295 details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
296 details['bounded_viewbox'] = params.get_bool('bounded', False)
297 details['dedupe'] = params.get_bool('dedupe', True)
299 max_results = max(1, min(50, params.get_int('limit', 10)))
300 details['max_results'] = max_results + min(10, max_results) \
301 if details['dedupe'] else max_results
303 details['min_rank'], details['max_rank'] = \
304 helpers.feature_type_to_rank(params.get('featureType', ''))
305 if params.get('featureType', None) is not None:
306 details['layers'] = DataLayer.ADDRESS
308 details['layers'] = get_layers(params)
310 details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
312 # unstructured query parameters
313 query = params.get('q', None)
314 # structured query parameters
316 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
317 details[key] = params.get(key, None)
319 queryparts[key] = details[key]
322 if query is not None:
324 params.raise_error("Structured query parameters"
325 "(amenity, street, city, county, state, postalcode, country)"
326 " cannot be used together with 'q' parameter.")
327 queryparts['q'] = query
328 results = await _unstructured_search(query, api, details)
330 query = ', '.join(queryparts.values())
332 results = await api.search_address(**details)
333 except UsageError as err:
334 params.raise_error(str(err))
336 if details['dedupe'] and len(results) > 1:
337 results = helpers.deduplicate_results(results, max_results)
340 return build_response(params, loglib.get_and_disable(), num_results=len(results))
343 helpers.extend_query_parts(queryparts, details,
344 params.get('featureType', ''),
345 params.get_bool('namedetails', False),
346 params.get_bool('extratags', False),
347 (str(r.place_id) for r in results if r.place_id))
348 queryparts['format'] = fmt
350 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
354 fmt_options = {'query': query, 'more_url': moreurl,
355 'exclude_place_ids': queryparts.get('exclude_place_ids'),
356 'viewbox': queryparts.get('viewbox'),
357 'extratags': params.get_bool('extratags', False),
358 'namedetails': params.get_bool('namedetails', False),
359 'addressdetails': params.get_bool('addressdetails', False)}
361 output = params.formatting().format_result(results, fmt, fmt_options)
363 return build_response(params, output, num_results=len(results))
366 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
367 """ Server glue for /deletable endpoint.
368 This is a special endpoint that shows polygons that have been
369 deleted or are broken in the OSM data but are kept in the
370 Nominatim database to minimize disruption.
372 fmt = parse_format(params, RawDataList, 'json')
374 async with api.begin() as conn:
375 sql = sa.text(""" SELECT p.place_id, country_code,
376 name->'name' as name, i.*
377 FROM placex p, import_polygon_delete i
378 WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
379 AND p.class = i.class AND p.type = i.type
381 results = RawDataList(r._asdict() for r in await conn.execute(sql))
383 return build_response(params, params.formatting().format_result(results, fmt, {}))
386 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
387 """ Server glue for /polygons endpoint.
388 This is a special endpoint that shows polygons that have changed
389 their size but are kept in the Nominatim database with their
390 old area to minimize disruption.
392 fmt = parse_format(params, RawDataList, 'json')
393 sql_params: Dict[str, Any] = {
394 'days': params.get_int('days', -1),
395 'cls': params.get('class')
397 reduced = params.get_bool('reduced', False)
399 async with api.begin() as conn:
400 sql = sa.select(sa.text("""osm_type, osm_id, class, type,
401 name->'name' as name,
402 country_code, errormessage, updated"""))\
403 .select_from(sa.text('import_polygon_error'))
404 if sql_params['days'] > 0:
405 sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
407 sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
408 if sql_params['cls'] is not None:
409 sql = sql.where(sa.text("class = :cls"))
411 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
413 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
415 return build_response(params, params.formatting().format_result(results, fmt, {}))
419 ('status', status_endpoint),
420 ('details', details_endpoint),
421 ('reverse', reverse_endpoint),
422 ('lookup', lookup_endpoint),
423 ('search', search_endpoint),
424 ('deletable', deletable_endpoint),
425 ('polygons', polygons_endpoint),