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,
316 return params.build_response(loglib.get_and_disable())
319 params.raise_error('No place with that OSM ID found.', status=404)
321 output = formatting.format_result(result, fmt,
323 'group_hierarchy': params.get_bool('group_hierarchy', False),
324 'icon_base_url': params.config().MAPICON_URL})
326 return params.build_response(output, num_results=1)
329 async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
330 """ Server glue for /reverse endpoint. See API docs for details.
332 fmt = params.parse_format(napi.ReverseResults, 'xml')
333 debug = params.setup_debugging()
334 coord = napi.Point(params.get_float('lon'), params.get_float('lat'))
336 details = params.parse_geometry_details(fmt)
337 details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
338 details['layers'] = params.get_layers()
339 details['locales'] = napi.Locales.from_accept_languages(params.get_accepted_languages())
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)}
360 output = formatting.format_result(napi.ReverseResults([result] if result else []),
363 return params.build_response(output, num_results=1 if result else 0)
366 async def lookup_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
367 """ Server glue for /lookup endpoint. See API docs for details.
369 fmt = params.parse_format(napi.SearchResults, 'xml')
370 debug = params.setup_debugging()
371 details = params.parse_geometry_details(fmt)
372 details['locales'] = napi.Locales.from_accept_languages(params.get_accepted_languages())
375 for oid in (params.get('osm_ids') or '').split(','):
377 if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
378 places.append(napi.OsmID(oid[0].upper(), int(oid[1:])))
380 if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
381 params.raise_error('Too many object IDs.')
384 results = await api.lookup(places, **details)
386 results = napi.SearchResults()
389 return params.build_response(loglib.get_and_disable(), num_results=len(results))
391 fmt_options = {'extratags': params.get_bool('extratags', False),
392 'namedetails': params.get_bool('namedetails', False),
393 'addressdetails': params.get_bool('addressdetails', True)}
395 output = formatting.format_result(results, fmt, fmt_options)
397 return params.build_response(output, num_results=len(results))
400 async def _unstructured_search(query: str, api: napi.NominatimAPIAsync,
401 details: Dict[str, Any]) -> napi.SearchResults:
403 return napi.SearchResults()
405 # Extract special format for coordinates from query.
406 query, x, y = helpers.extract_coords_from_query(query)
409 details['near'] = napi.Point(x, y)
410 details['near_radius'] = 0.1
412 # If no query is left, revert to reverse search.
413 if x is not None and not query:
414 result = await api.reverse(details['near'], **details)
416 return napi.SearchResults()
418 return napi.SearchResults(
419 [napi.SearchResult(**{f.name: getattr(result, f.name)
420 for f in dataclasses.fields(napi.SearchResult)
421 if hasattr(result, f.name)})])
423 query, cls, typ = helpers.extract_category_from_query(query)
425 assert typ is not None
426 return await api.search_category([(cls, typ)], near_query=query, **details)
428 return await api.search(query, **details)
431 async def search_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
432 """ Server glue for /search endpoint. See API docs for details.
434 fmt = params.parse_format(napi.SearchResults, 'jsonv2')
435 debug = params.setup_debugging()
436 details = params.parse_geometry_details(fmt)
438 details['countries'] = params.get('countrycodes', None)
439 details['excluded'] = params.get('exclude_place_ids', None)
440 details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
441 details['bounded_viewbox'] = params.get_bool('bounded', False)
442 details['dedupe'] = params.get_bool('dedupe', True)
444 max_results = max(1, min(50, params.get_int('limit', 10)))
445 details['max_results'] = max_results + min(10, max_results) \
446 if details['dedupe'] else max_results
448 details['min_rank'], details['max_rank'] = \
449 helpers.feature_type_to_rank(params.get('featureType', ''))
450 if params.get('featureType', None) is not None:
451 details['layers'] = napi.DataLayer.ADDRESS
453 details['layers'] = params.get_layers()
455 details['locales'] = napi.Locales.from_accept_languages(params.get_accepted_languages())
457 # unstructured query parameters
458 query = params.get('q', None)
459 # structured query parameters
461 for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
462 details[key] = params.get(key, None)
464 queryparts[key] = details[key]
467 if query is not None:
469 params.raise_error("Structured query parameters"
470 "(amenity, street, city, county, state, postalcode, country)"
471 " cannot be used together with 'q' parameter.")
472 queryparts['q'] = query
473 results = await _unstructured_search(query, api, details)
475 query = ', '.join(queryparts.values())
477 results = await api.search_address(**details)
478 except UsageError as err:
479 params.raise_error(str(err))
481 if details['dedupe'] and len(results) > 1:
482 results = helpers.deduplicate_results(results, max_results)
485 return params.build_response(loglib.get_and_disable(), num_results=len(results))
488 helpers.extend_query_parts(queryparts, details,
489 params.get('featureType', ''),
490 params.get_bool('namedetails', False),
491 params.get_bool('extratags', False),
492 (str(r.place_id) for r in results if r.place_id))
493 queryparts['format'] = fmt
495 moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
499 fmt_options = {'query': query, 'more_url': moreurl,
500 'exclude_place_ids': queryparts.get('exclude_place_ids'),
501 'viewbox': queryparts.get('viewbox'),
502 'extratags': params.get_bool('extratags', False),
503 'namedetails': params.get_bool('namedetails', False),
504 'addressdetails': params.get_bool('addressdetails', False)}
506 output = formatting.format_result(results, fmt, fmt_options)
508 return params.build_response(output, num_results=len(results))
511 async def deletable_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
512 """ Server glue for /deletable endpoint.
513 This is a special endpoint that shows polygons that have been
514 deleted or are broken in the OSM data but are kept in the
515 Nominatim database to minimize disruption.
517 fmt = params.parse_format(RawDataList, 'json')
519 async with api.begin() as conn:
520 sql = sa.text(""" SELECT p.place_id, country_code,
521 name->'name' as name, i.*
522 FROM placex p, import_polygon_delete i
523 WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
524 AND p.class = i.class AND p.type = i.type
526 results = RawDataList(r._asdict() for r in await conn.execute(sql))
528 return params.build_response(formatting.format_result(results, fmt, {}))
531 async def polygons_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
532 """ Server glue for /polygons endpoint.
533 This is a special endpoint that shows polygons that have changed
534 thier size but are kept in the Nominatim database with their
535 old area to minimize disruption.
537 fmt = params.parse_format(RawDataList, 'json')
538 sql_params: Dict[str, Any] = {
539 'days': params.get_int('days', -1),
540 'cls': params.get('class')
542 reduced = params.get_bool('reduced', False)
544 async with api.begin() as conn:
545 sql = sa.select(sa.text("""osm_type, osm_id, class, type,
546 name->'name' as name,
547 country_code, errormessage, updated"""))\
548 .select_from(sa.text('import_polygon_error'))
549 if sql_params['days'] > 0:
550 sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
552 sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
553 if sql_params['cls'] is not None:
554 sql = sql.where(sa.text("class = :cls"))
556 sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
558 results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
560 return params.build_response(formatting.format_result(results, fmt, {}))
563 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
566 ('status', status_endpoint),
567 ('details', details_endpoint),
568 ('reverse', reverse_endpoint),
569 ('lookup', lookup_endpoint),
570 ('search', search_endpoint),
571 ('deletable', deletable_endpoint),
572 ('polygons', polygons_endpoint),