1 # SPDX-License-Identifier: GPL-2.0-only
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2023 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, Callable, NoReturn, Dict, cast
12 from functools import reduce
16 from urllib.parse import urlencode
18 import sqlalchemy as sa
20 from nominatim.errors import UsageError
21 from nominatim.config import Configuration
22 import nominatim.api as napi
23 import nominatim.api.logging as loglib
24 from nominatim.api.v1.format import dispatch as formatting
25 from nominatim.api.v1.format import RawDataList
26 from nominatim.api.v1 import helpers
28 CONTENT_TEXT = 'text/plain; charset=utf-8'
29 CONTENT_XML = 'text/xml; charset=utf-8'
30 CONTENT_HTML = 'text/html; charset=utf-8'
31 CONTENT_JSON = 'application/json; charset=utf-8'
33 CONTENT_TYPE = {'text': CONTENT_TEXT, 'xml': CONTENT_XML, 'debug': CONTENT_HTML}
35 class ASGIAdaptor(abc.ABC):
36 """ Adapter class for the different ASGI frameworks.
37 Wraps functionality over concrete requests and responses.
39 content_type: str = CONTENT_TEXT
42 def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
43 """ Return an input parameter as a string. If the parameter was
44 not provided, return the 'default' value.
48 def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
49 """ Return a HTTP header parameter as a string. If the parameter was
50 not provided, return the 'default' value.
55 def error(self, msg: str, status: int = 400) -> Exception:
56 """ Construct an appropriate exception from the given error message.
57 The exception must result in a HTTP error with the given status.
62 def create_response(self, status: int, output: str, num_results: int) -> Any:
63 """ Create a response from the given parameters. The result will
64 be returned by the endpoint functions. The adaptor may also
65 return None when the response is created internally with some
68 The response must return the HTTP given status code 'status', set
69 the HTTP content-type headers to the string provided and the
70 body of the response to 'output'.
74 def base_uri(self) -> str:
75 """ Return the URI of the original request.
80 def config(self) -> Configuration:
81 """ Return the current configuration object.
85 def build_response(self, output: str, status: int = 200, num_results: int = 0) -> Any:
86 """ Create a response from the given output. Wraps a JSONP function
87 around the response, if necessary.
89 if self.content_type == CONTENT_JSON and status == 200:
90 jsonp = self.get('json_callback')
92 if any(not part.isidentifier() for part in jsonp.split('.')):
93 self.raise_error('Invalid json_callback value')
94 output = f"{jsonp}({output})"
95 self.content_type = 'application/javascript; charset=utf-8'
97 return self.create_response(status, output, num_results)
100 def raise_error(self, msg: str, status: int = 400) -> NoReturn:
101 """ Raise an exception resulting in the given HTTP status and
102 message. The message will be formatted according to the
103 output format chosen by the request.
105 if self.content_type == CONTENT_XML:
106 msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
108 <code>{status}</code>
109 <message>{msg}</message>
112 elif self.content_type == CONTENT_JSON:
113 msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
114 elif self.content_type == CONTENT_HTML:
115 loglib.log().section('Execution error')
116 loglib.log().var_dump('Status', status)
117 loglib.log().var_dump('Message', msg)
118 msg = loglib.get_and_disable()
120 raise self.error(msg, status)
123 def get_int(self, name: str, default: Optional[int] = None) -> int:
124 """ Return an input parameter as an int. Raises an exception if
125 the parameter is given but not in an integer format.
127 If 'default' is given, then it will be returned when the parameter
128 is missing completely. When 'default' is None, an error will be
129 raised on a missing parameter.
131 value = self.get(name)
134 if default is not None:
137 self.raise_error(f"Parameter '{name}' missing.")
142 self.raise_error(f"Parameter '{name}' must be a number.")
147 def get_float(self, name: str, default: Optional[float] = None) -> float:
148 """ Return an input parameter as a flaoting-point number. Raises an
149 exception if the parameter is given but not in an float format.
151 If 'default' is given, then it will be returned when the parameter
152 is missing completely. When 'default' is None, an error will be
153 raised on a missing parameter.
155 value = self.get(name)
158 if default is not None:
161 self.raise_error(f"Parameter '{name}' missing.")
166 self.raise_error(f"Parameter '{name}' must be a number.")
168 if math.isnan(fval) or math.isinf(fval):
169 self.raise_error(f"Parameter '{name}' must be a number.")
174 def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
175 """ Return an input parameter as bool. Only '0' is accepted as
176 an input for 'false' all other inputs will be interpreted as 'true'.
178 If 'default' is given, then it will be returned when the parameter
179 is missing completely. When 'default' is None, an error will be
180 raised on a missing parameter.
182 value = self.get(name)
185 if default is not None:
188 self.raise_error(f"Parameter '{name}' missing.")
193 def get_accepted_languages(self) -> str:
194 """ Return the accepted languages.
196 return self.get('accept-language')\
197 or self.get_header('accept-language')\
198 or self.config().DEFAULT_LANGUAGE
201 def setup_debugging(self) -> bool:
202 """ Set up collection of debug information if requested.
204 Return True when debugging was requested.
206 if self.get_bool('debug', False):
207 loglib.set_log_output('html')
208 self.content_type = CONTENT_HTML
214 def get_layers(self) -> Optional[napi.DataLayer]:
215 """ Return a parsed version of the layer parameter.
217 param = self.get('layer', None)
221 return cast(napi.DataLayer,
222 reduce(napi.DataLayer.__or__,
223 (getattr(napi.DataLayer, s.upper()) for s in param.split(','))))
226 def parse_format(self, result_type: Type[Any], default: str) -> str:
227 """ Get and check the 'format' parameter and prepare the formatter.
228 `result_type` is the type of result to be returned by the function
229 and `default` the format value to assume when no parameter is present.
231 fmt = self.get('format', default=default)
232 assert fmt is not None
234 if not formatting.supports_format(result_type, fmt):
235 self.raise_error("Parameter 'format' must be one of: " +
236 ', '.join(formatting.list_formats(result_type)))
238 self.content_type = CONTENT_TYPE.get(fmt, CONTENT_JSON)
242 def parse_geometry_details(self, fmt: str) -> Dict[str, Any]:
243 """ Create details strucutre from the supplied geometry parameters.
246 output = napi.GeometryFormat.NONE
247 if self.get_bool('polygon_geojson', False):
248 output |= napi.GeometryFormat.GEOJSON
250 if fmt not in ('geojson', 'geocodejson'):
251 if self.get_bool('polygon_text', False):
252 output |= napi.GeometryFormat.TEXT
254 if self.get_bool('polygon_kml', False):
255 output |= napi.GeometryFormat.KML
257 if self.get_bool('polygon_svg', False):
258 output |= napi.GeometryFormat.SVG
261 if numgeoms > self.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
262 self.raise_error('Too many polygon output options selected.')
264 return {'address_details': True,
265 'geometry_simplification': self.get_float('polygon_threshold', 0.0),
266 'geometry_output': output
270 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
271 """ Server glue for /status endpoint. See API docs for details.
273 result = await api.status()
275 fmt = params.parse_format(napi.StatusResult, 'text')
277 if fmt == 'text' and result.status:
282 return params.build_response(formatting.format_result(result, fmt, {}),
286 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
287 """ Server glue for /details endpoint. See API docs for details.
289 fmt = params.parse_format(napi.DetailedResult, 'json')
290 place_id = params.get_int('place_id', 0)
293 place = napi.PlaceID(place_id)
295 osmtype = params.get('osmtype')
297 params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
298 place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
300 debug = params.setup_debugging()
302 locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
304 result = await api.details(place,
305 address_details=params.get_bool('addressdetails', False),
306 linked_places=params.get_bool('linkedplaces', True),
307 parented_places=params.get_bool('hierarchy', False),
308 keywords=params.get_bool('keywords', False),
309 geometry_output = napi.GeometryFormat.GEOJSON
310 if params.get_bool('polygon_geojson', False)
311 else napi.GeometryFormat.NONE
315 return params.build_response(loglib.get_and_disable())
318 params.raise_error('No place with that OSM ID found.', status=404)
320 result.localize(locales)
322 output = formatting.format_result(result, fmt,
324 'group_hierarchy': params.get_bool('group_hierarchy', False),
325 'icon_base_url': params.config().MAPICON_URL})
327 return params.build_response(output, num_results=1)
330 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
331 """ Server glue for /reverse endpoint. See API docs for details.
333 fmt = params.parse_format(napi.ReverseResults, 'xml')
334 debug = params.setup_debugging()
335 coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
337 details = params.parse_geometry_details(fmt)
338 details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
339 details['layers'] = params.get_layers()
341 result = await api.reverse(coord, **details)
344 return params.build_response(loglib.get_and_disable(), num_results=1 if result else 0)
347 queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
348 zoom = params.get('zoom', None)
350 queryparts['zoom'] = zoom
351 query = urlencode(queryparts)
355 fmt_options = {'query': query,
356 'extratags': params.get_bool('extratags', False),
357 'namedetails': params.get_bool('namedetails', False),
358 'addressdetails': params.get_bool('addressdetails', True)}
361 result.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
363 output = formatting.format_result(napi.ReverseResults([result] if result else []),
366 return params.build_response(output, num_results=1 if result else 0)
369 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
370 """ Server glue for /lookup endpoint. See API docs for details.
372 fmt = params.parse_format(napi.SearchResults, 'xml')
373 debug = params.setup_debugging()
374 details = params.parse_geometry_details(fmt)
377 for oid in (params.get('osm_ids') or '').split(','):
379 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
380 places.append(napi.OsmID(oid[0].upper(), int(oid[1:])))
382 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
383 params.raise_error('Too many object IDs.')
386 results = await api.lookup(places, **details)
388 results = napi.SearchResults()
391 return params.build_response(loglib.get_and_disable(), num_results=len(results))
393 fmt_options = {'extratags': params.get_bool('extratags', False),
394 'namedetails': params.get_bool('namedetails', False),
395 'addressdetails': params.get_bool('addressdetails', True)}
397 results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
399 output = formatting.format_result(results, fmt, fmt_options)
401 return params.build_response(output, num_results=len(results))
404 async def _unstructured_search(query: str, api: napi.NominatimAPIAsync,
405 details: Dict[str, Any]) -> napi.SearchResults:
407 return napi.SearchResults()
409 # Extract special format for coordinates from query.
410 query, x, y = helpers.extract_coords_from_query(query)
413 details['near'] = napi.Point(x, y)
414 details['near_radius'] = 0.1
416 # If no query is left, revert to reverse search.
417 if x is not None and not query:
418 result = await api.reverse(details['near'], **details)
420 return napi.SearchResults()
422 return napi.SearchResults(
423 [napi.SearchResult(**{f.name: getattr(result, f.name)
424 for f in dataclasses.fields(napi.SearchResult)
425 if hasattr(result, f.name)})])
427 query, cls, typ = helpers.extract_category_from_query(query)
429 assert typ is not None
430 return await api.search_category([(cls, typ)], near_query=query, **details)
432 return await api.search(query, **details)
435 async def search_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
436 """ Server glue for /search endpoint. See API docs for details.
438 fmt = params.parse_format(napi.SearchResults, 'jsonv2')
439 debug = params.setup_debugging()
440 details = params.parse_geometry_details(fmt)
442 details['countries'] = params.get('countrycodes', None)
443 details['excluded'] = params.get('exclude_place_ids', None)
444 details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
445 details['bounded_viewbox'] = params.get_bool('bounded', False)
446 details['dedupe'] = params.get_bool('dedupe', True)
448 max_results = max(1, min(50, params.get_int('limit', 10)))
449 details['max_results'] = max_results + min(10, max_results) \
450 if details['dedupe'] else max_results
452 details['min_rank'], details['max_rank'] = \
453 helpers.feature_type_to_rank(params.get('featureType', ''))
454 if params.get('featureType', None) is not None:
455 details['layers'] = napi.DataLayer.ADDRESS
457 details['layers'] = params.get_layers()
459 # unstructured query parameters
460 query = params.get('q', None)
461 # structured query parameters
463 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
464 details[key] = params.get(key, None)
466 queryparts[key] = details[key]
469 if query is not None:
471 params.raise_error("Structured query parameters"
472 "(amenity, street, city, county, state, postalcode, country)"
473 " cannot be used together with 'q' parameter.")
474 queryparts['q'] = query
475 results = await _unstructured_search(query, api, details)
477 query = ', '.join(queryparts.values())
479 results = await api.search_address(**details)
480 except UsageError as err:
481 params.raise_error(str(err))
483 results.localize(napi.Locales.from_accept_languages(params.get_accepted_languages()))
485 if details['dedupe'] and len(results) > 1:
486 results = helpers.deduplicate_results(results, max_results)
489 return params.build_response(loglib.get_and_disable(), num_results=len(results))
492 helpers.extend_query_parts(queryparts, details,
493 params.get('featureType', ''),
494 params.get_bool('namedetails', False),
495 params.get_bool('extratags', False),
496 (str(r.place_id) for r in results if r.place_id))
497 queryparts['format'] = fmt
499 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
503 fmt_options = {'query': query, 'more_url': moreurl,
504 'exclude_place_ids': queryparts.get('exclude_place_ids'),
505 'viewbox': queryparts.get('viewbox'),
506 'extratags': params.get_bool('extratags', False),
507 'namedetails': params.get_bool('namedetails', False),
508 'addressdetails': params.get_bool('addressdetails', False)}
510 output = formatting.format_result(results, fmt, fmt_options)
512 return params.build_response(output, num_results=len(results))
515 async def deletable_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
516 """ Server glue for /deletable endpoint.
517 This is a special endpoint that shows polygons that have been
518 deleted or are broken in the OSM data but are kept in the
519 Nominatim database to minimize disruption.
521 fmt = params.parse_format(RawDataList, 'json')
523 async with api.begin() as conn:
524 sql = sa.text(""" SELECT p.place_id, country_code,
525 name->'name' as name, i.*
526 FROM placex p, import_polygon_delete i
527 WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
528 AND p.class = i.class AND p.type = i.type
530 results = RawDataList(r._asdict() for r in await conn.execute(sql))
532 return params.build_response(formatting.format_result(results, fmt, {}))
535 async def polygons_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
536 """ Server glue for /polygons endpoint.
537 This is a special endpoint that shows polygons that have changed
538 thier size but are kept in the Nominatim database with their
539 old area to minimize disruption.
541 fmt = params.parse_format(RawDataList, 'json')
542 sql_params: Dict[str, Any] = {
543 'days': params.get_int('days', -1),
544 'cls': params.get('class')
546 reduced = params.get_bool('reduced', False)
548 async with api.begin() as conn:
549 sql = sa.select(sa.text("""osm_type, osm_id, class, type,
550 name->'name' as name,
551 country_code, errormessage, updated"""))\
552 .select_from(sa.text('import_polygon_error'))
553 if sql_params['days'] > 0:
554 sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
556 sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
557 if sql_params['cls'] is not None:
558 sql = sql.where(sa.text("class = :cls"))
560 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
562 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
564 return params.build_response(formatting.format_result(results, fmt, {}))
567 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
570 ('status', status_endpoint),
571 ('details', details_endpoint),
572 ('reverse', reverse_endpoint),
573 ('lookup', lookup_endpoint),
574 ('search', search_endpoint),
575 ('deletable', deletable_endpoint),
576 ('polygons', polygons_endpoint),