From 300921a93e29d6bdc2a7da8ceaebfcce0c555c84 Mon Sep 17 00:00:00 2001 From: Sarah Hoffmann Date: Sat, 25 Mar 2023 17:04:02 +0100 Subject: [PATCH] add server glue for reverse API call --- nominatim/api/v1/server_glue.py | 124 +++++++++++++++++++++++++-- nominatim/server/falcon/server.py | 28 ++++-- nominatim/server/sanic/server.py | 5 +- nominatim/server/starlette/server.py | 3 +- 4 files changed, 143 insertions(+), 17 deletions(-) diff --git a/nominatim/api/v1/server_glue.py b/nominatim/api/v1/server_glue.py index 550b1e3a..3c0c58b0 100644 --- a/nominatim/api/v1/server_glue.py +++ b/nominatim/api/v1/server_glue.py @@ -8,7 +8,7 @@ Generic part of the server implementation of the v1 API. Combine with the scaffolding provided for the various Python ASGI frameworks. """ -from typing import Optional, Any, Type, Callable, NoReturn +from typing import Optional, Any, Type, Callable, NoReturn, TypeVar import abc from nominatim.config import Configuration @@ -22,6 +22,7 @@ CONTENT_TYPE = { 'debug': 'text/html; charset=utf-8' } +ConvT = TypeVar('ConvT', int, float) class ASGIAdaptor(abc.ABC): """ Adapter class for the different ASGI frameworks. @@ -106,9 +107,10 @@ class ASGIAdaptor(abc.ABC): raise self.error(msg, status) - def get_int(self, name: str, default: Optional[int] = None) -> int: - """ Return an input parameter as an int. Raises an exception if - the parameter is given but not in an integer format. + def _get_typed(self, name: str, dest_type: Type[ConvT], type_name: str, + default: Optional[ConvT] = None) -> ConvT: + """ Return an input parameter as the type 'dest_type'. Raises an + exception if the parameter is given but not in the given format. If 'default' is given, then it will be returned when the parameter is missing completely. When 'default' is None, an error will be @@ -123,12 +125,35 @@ class ASGIAdaptor(abc.ABC): self.raise_error(f"Parameter '{name}' missing.") try: - intval = int(value) + intval = dest_type(value) except ValueError: - self.raise_error(f"Parameter '{name}' must be a number.") + self.raise_error(f"Parameter '{name}' must be a {type_name}.") return intval + + def get_int(self, name: str, default: Optional[int] = None) -> int: + """ Return an input parameter as an int. Raises an exception if + the parameter is given but not in an integer format. + + If 'default' is given, then it will be returned when the parameter + is missing completely. When 'default' is None, an error will be + raised on a missing parameter. + """ + return self._get_typed(name, int, 'number', default) + + + def get_float(self, name: str, default: Optional[float] = None) -> int: + """ Return an input parameter as a flaoting-point number. Raises an + exception if the parameter is given but not in an float format. + + If 'default' is given, then it will be returned when the parameter + is missing completely. When 'default' is None, an error will be + raised on a missing parameter. + """ + return self._get_typed(name, float, 'number', default) + + def get_bool(self, name: str, default: Optional[bool] = None) -> bool: """ Return an input parameter as bool. Only '0' is accepted as an input for 'false' all other inputs will be interpreted as 'true'. @@ -169,6 +194,17 @@ class ASGIAdaptor(abc.ABC): return False + def get_layers(self) -> napi.DataLayer: + """ Return a parsed version of the layer parameter. + """ + param = self.get('layer', None) + if param is None: + return None + + return reduce(napi.DataLayer.__or__, + (getattr(napi.DataLayer, s.upper()) for s in param.split(','))) + + def parse_format(self, result_type: Type[Any], default: str) -> str: """ Get and check the 'format' parameter and prepare the formatter. `result_type` is the type of result to be returned by the function @@ -243,9 +279,83 @@ async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> return params.build_response(output) +async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any: + """ Server glue for /reverse endpoint. See API docs for details. + """ + fmt = params.parse_format(napi.ReverseResults, 'xml') + debug = params.setup_debugging() + coord = napi.Point(params.get_float('lon'), params.get_float('lat')) + locales = napi.Locales.from_accept_languages(params.get_accepted_languages()) + + zoom = max(0, min(18, params.get_int('zoom', 18))) + + # Negation makes sure that NaN is handled. Don't change. + if not abs(coord[0]) <= 180 or not abs(coord[1]) <= 90: + params.raise_error('Invalid coordinates.') + + details = napi.LookupDetails(address_details=True, + geometry_simplification=params.get_float('polygon_threshold', 0.0)) + numgeoms = 0 + if params.get_bool('polygon_geojson', False): + details.geometry_output |= napi.GeometryFormat.GEOJSON + numgeoms += 1 + if fmt not in ('geojson', 'geocodejson'): + if params.get_bool('polygon_text', False): + details.geometry_output |= napi.GeometryFormat.TEXT + numgeoms += 1 + if params.get_bool('polygon_kml', False): + details.geometry_output |= napi.GeometryFormat.KML + numgeoms += 1 + if params.get_bool('polygon_svg', False): + details.geometry_output |= napi.GeometryFormat.SVG + numgeoms += 1 + + if numgeoms > params.config().get_int('POLYGON_OUTPUT_MAX_TYPES'): + params.raise_error(f'Too many polgyon output options selected.') + + result = await api.reverse(coord, REVERSE_MAX_RANKS[zoom], + params.get_layers() or + napi.DataLayer.ADDRESS | napi.DataLayer.POI, + details) + + if debug: + return params.build_response(loglib.get_and_disable()) + + fmt_options = {'locales': locales, + 'extratags': params.get_bool('extratags', False), + 'namedetails': params.get_bool('namedetails', False), + 'addressdetails': params.get_bool('addressdetails', True), + 'single_result': True} + if fmt == 'xml': + fmt_options['xml_roottag'] = 'reversegeocode' + fmt_options['xml_extra_info'] = {'querystring': 'TODO'} + + output = formatting.format_result(napi.ReverseResults([result] if result else []), + fmt, fmt_options) + + return params.build_response(output) + + EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any] +REVERSE_MAX_RANKS = [2, 2, 2, # 0-2 Continent/Sea + 4, 4, # 3-4 Country + 8, # 5 State + 10, 10, # 6-7 Region + 12, 12, # 8-9 County + 16, 17, # 10-11 City + 18, # 12 Town + 19, # 13 Village/Suburb + 22, # 14 Hamlet/Neighbourhood + 25, # 15 Localities + 26, # 16 Major Streets + 27, # 17 Minor Streets + 30 # 18 Building + ] + + ROUTES = [ ('status', status_endpoint), - ('details', details_endpoint) + ('details', details_endpoint), + ('reverse', reverse_endpoint) ] diff --git a/nominatim/server/falcon/server.py b/nominatim/server/falcon/server.py index 463407ea..18853307 100644 --- a/nominatim/server/falcon/server.py +++ b/nominatim/server/falcon/server.py @@ -7,7 +7,7 @@ """ Server implementation using the falcon webserver framework. """ -from typing import Optional, Mapping, cast +from typing import Optional, Mapping, cast, Any from pathlib import Path import falcon @@ -17,6 +17,22 @@ from nominatim.api import NominatimAPIAsync import nominatim.api.v1 as api_impl from nominatim.config import Configuration +class HTTPNominatimError(Exception): + """ A special exception class for errors raised during processing. + """ + def __init__(self, msg: str, status: int, content_type: str) -> None: + self.msg = msg + self.status = status + self.content_type = content_type + + +async def nominatim_error_handler(req: Request, resp: Response, + exception: HTTPNominatimError, + _: Any) -> None: + resp.status = exception.status + resp.text = exception.msg + resp.content_type = exception.content_type + class ParamWrapper(api_impl.ASGIAdaptor): """ Adaptor class for server glue to Falcon framework. @@ -37,13 +53,8 @@ class ParamWrapper(api_impl.ASGIAdaptor): return cast(Optional[str], self.request.get_header(name, default=default)) - def error(self, msg: str, status: int = 400) -> falcon.HTTPError: - if status == 400: - return falcon.HTTPBadRequest(description=msg) - if status == 404: - return falcon.HTTPNotFound(description=msg) - - return falcon.HTTPError(status, description=msg) + def error(self, msg: str, status: int = 400) -> HTTPNominatimError: + return HTTPNominatimError(msg, status, self.content_type) def create_response(self, status: int, output: str) -> None: @@ -78,6 +89,7 @@ def get_application(project_dir: Path, api = NominatimAPIAsync(project_dir, environ) app = App(cors_enable=api.config.get_bool('CORS_NOACCESSCONTROL')) + app.add_error_handler(HTTPNominatimError, nominatim_error_handler) legacy_urls = api.config.get_bool('SERVE_LEGACY_URLS') for name, func in api_impl.ROUTES: diff --git a/nominatim/server/sanic/server.py b/nominatim/server/sanic/server.py index a2379501..cf1ef4ce 100644 --- a/nominatim/server/sanic/server.py +++ b/nominatim/server/sanic/server.py @@ -35,7 +35,10 @@ class ParamWrapper(api_impl.ASGIAdaptor): def error(self, msg: str, status: int = 400) -> SanicException: - return SanicException(msg, status_code=status) + exception = SanicException(msg, status_code=status) + exception.headers = {'content-type': self.content_type} + + return exception def create_response(self, status: int, output: str) -> HTTPResponse: diff --git a/nominatim/server/starlette/server.py b/nominatim/server/starlette/server.py index c1c5a5fa..be7d95d1 100644 --- a/nominatim/server/starlette/server.py +++ b/nominatim/server/starlette/server.py @@ -39,7 +39,8 @@ class ParamWrapper(api_impl.ASGIAdaptor): def error(self, msg: str, status: int = 400) -> HTTPException: - return HTTPException(status, detail=msg) + return HTTPException(status, detail=msg, + headers={'content-type': self.content_type}) def create_response(self, status: int, output: str) -> Response: -- 2.39.5