]> git.openstreetmap.org Git - nominatim.git/commitdiff
add server glue for reverse API call
authorSarah Hoffmann <lonvia@denofr.de>
Sat, 25 Mar 2023 16:04:02 +0000 (17:04 +0100)
committerSarah Hoffmann <lonvia@denofr.de>
Sat, 25 Mar 2023 16:04:02 +0000 (17:04 +0100)
nominatim/api/v1/server_glue.py
nominatim/server/falcon/server.py
nominatim/server/sanic/server.py
nominatim/server/starlette/server.py

index 550b1e3a68c717501d61e054d055d49efa0e0476..3c0c58b01e4ba31e98465545fc4266c30c32a586 100644 (file)
@@ -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)
 ]
index 463407ea3be2ad40db1ffb45d3763b91887c41a7..1885330748960de27e6d56f17155c7e4dddf1889 100644 (file)
@@ -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:
index a2379501d9a9ca6ed37679b02907eb1067fbc04c..cf1ef4ce2859ab352bec841fe591c8a2969d5af1 100644 (file)
@@ -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:
index c1c5a5fa8d8eabedb17b89e1d73726b0d4b288a0..be7d95d19462bbf2ac108fcc11af8d95edbae2d7 100644 (file)
@@ -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: