factor out common server implementation code

Most of the server implementation of V1 API now resides in
api.v1.server_glue. The webframeworks only supply some glue code
which is independent to changes in the API code.
This commit is contained in:
Sarah Hoffmann
2023-01-24 21:04:32 +01:00
parent 8f4426fbc8
commit 654b652530
5 changed files with 264 additions and 134 deletions

View File

@@ -8,6 +8,12 @@
Implementation of API version v1 (aka the legacy version). Implementation of API version v1 (aka the legacy version).
""" """
#pylint: disable=useless-import-alias
from nominatim.api.v1.server_glue import (ASGIAdaptor as ASGIAdaptor,
EndpointFunc as EndpointFunc,
ROUTES as ROUTES)
import nominatim.api.v1.format as _format import nominatim.api.v1.format as _format
list_formats = _format.dispatch.list_formats list_formats = _format.dispatch.list_formats

View File

@@ -0,0 +1,153 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2023 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
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
import abc
import nominatim.api as napi
from nominatim.api.v1.format import dispatch as formatting
CONTENT_TYPE = {
'text': 'text/plain; charset=utf-8',
'xml': 'text/xml; charset=utf-8',
'jsonp': 'application/javascript'
}
class ASGIAdaptor(abc.ABC):
""" Adapter class for the different ASGI frameworks.
Wraps functionality over concrete requests and responses.
"""
@abc.abstractmethod
def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
""" Return an input parameter as a string. If the parameter was
not provided, return the 'default' value.
"""
@abc.abstractmethod
def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
""" Return a HTTP header parameter as a string. If the parameter was
not provided, return the 'default' value.
"""
@abc.abstractmethod
def error(self, msg: str) -> Exception:
""" Construct an appropriate exception from the given error message.
The exception must result in a HTTP 400 error.
"""
@abc.abstractmethod
def create_response(self, status: int, output: str, content_type: str) -> Any:
""" Create a response from the given parameters. The result will
be returned by the endpoint functions. The adaptor may also
return None when the response is created internally with some
different means.
The response must return the HTTP given status code 'status', set
the HTTP content-type headers to the string provided and the
body of the response to 'output'.
"""
def build_response(self, output: str, media_type: str, status: int = 200) -> Any:
""" Create a response from the given output. Wraps a JSONP function
around the response, if necessary.
"""
if media_type == 'json' and status == 200:
jsonp = self.get('json_callback')
if jsonp is not None:
if any(not part.isidentifier() for part in jsonp.split('.')):
raise self.error('Invalid json_callback value')
output = f"{jsonp}({output})"
media_type = 'jsonp'
return self.create_response(status, output,
CONTENT_TYPE.get(media_type, 'application/json'))
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.
"""
value = self.get(name)
if value is None:
if default is not None:
return default
raise self.error(f"Parameter '{name}' missing.")
try:
return int(value)
except ValueError as exc:
raise self.error(f"Parameter '{name}' must be a number.") from exc
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'.
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.
"""
value = self.get(name)
if value is None:
if default is not None:
return default
raise self.error(f"Parameter '{name}' missing.")
return value != '0'
def parse_format(params: ASGIAdaptor, result_type: Type[Any], default: str) -> str:
""" Get and check the 'format' parameter and prepare the formatter.
`fmtter` is a formatter and `default` the
format value to assume when no parameter is present.
"""
fmt = params.get('format', default=default)
assert fmt is not None
if not formatting.supports_format(result_type, fmt):
raise params.error("Parameter 'format' must be one of: " +
', '.join(formatting.list_formats(result_type)))
return fmt
async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
""" Server glue for /status endpoint. See API docs for details.
"""
result = await api.status()
fmt = parse_format(params, napi.StatusResult, 'text')
if fmt == 'text' and result.status:
status_code = 500
else:
status_code = 200
return params.build_response(formatting.format_result(result, fmt), fmt,
status=status_code)
EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
ROUTES = [
('status', status_endpoint)
]

View File

@@ -7,70 +7,66 @@
""" """
Server implementation using the falcon webserver framework. Server implementation using the falcon webserver framework.
""" """
from typing import Type, Any, Optional, Mapping from typing import Optional, Mapping, cast
from pathlib import Path from pathlib import Path
import falcon import falcon
import falcon.asgi from falcon.asgi import App, Request, Response
from nominatim.api import NominatimAPIAsync, StatusResult from nominatim.api import NominatimAPIAsync
import nominatim.api.v1 as api_impl import nominatim.api.v1 as api_impl
CONTENT_TYPE = {
'text': falcon.MEDIA_TEXT,
'xml': falcon.MEDIA_XML
}
class NominatimV1: class ParamWrapper(api_impl.ASGIAdaptor):
""" Implementation of V1 version of the Nominatim API. """ Adaptor class for server glue to Falcon framework.
""" """
def __init__(self, project_dir: Path, environ: Optional[Mapping[str, str]]) -> None: def __init__(self, req: Request, resp: Response) -> None:
self.api = NominatimAPIAsync(project_dir, environ) self.request = req
self.response = resp
def parse_format(self, req: falcon.asgi.Request, rtype: Type[Any], default: str) -> None: def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
""" Get and check the 'format' parameter and prepare the formatter. return cast(Optional[str], self.request.get_param(name, default=default))
`rtype` describes the expected return type and `default` the
format value to assume when no parameter is present.
def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
return cast(Optional[str], self.request.get_header(name, default=default))
def error(self, msg: str) -> falcon.HTTPBadRequest:
return falcon.HTTPBadRequest(description=msg)
def create_response(self, status: int, output: str, content_type: str) -> None:
self.response.status = status
self.response.text = output
self.response.content_type = content_type
class EndpointWrapper:
""" Converter for server glue endpoint functions to Falcon request handlers.
"""
def __init__(self, func: api_impl.EndpointFunc, api: NominatimAPIAsync) -> None:
self.func = func
self.api = api
async def on_get(self, req: Request, resp: Response) -> None:
""" Implementation of the endpoint.
""" """
req.context.format = req.get_param('format', default=default) await self.func(self.api, ParamWrapper(req, resp))
if not api_impl.supports_format(rtype, req.context.format):
raise falcon.HTTPBadRequest(
description="Parameter 'format' must be one of: " +
', '.join(api_impl.list_formats(rtype)))
def format_response(self, req: falcon.asgi.Request, resp: falcon.asgi.Response,
result: Any) -> None:
""" Render response into a string according to the formatter
set in `parse_format()`.
"""
resp.text = api_impl.format_result(result, req.context.format)
resp.content_type = CONTENT_TYPE.get(req.context.format, falcon.MEDIA_JSON)
async def on_get_status(self, req: falcon.asgi.Request, resp: falcon.asgi.Response) -> None:
""" Implementation of status endpoint.
"""
self.parse_format(req, StatusResult, 'text')
result = await self.api.status()
self.format_response(req, resp, result)
if result.status and req.context.format == 'text':
resp.status = 500
def get_application(project_dir: Path, def get_application(project_dir: Path,
environ: Optional[Mapping[str, str]] = None) -> falcon.asgi.App: environ: Optional[Mapping[str, str]] = None) -> App:
""" Create a Nominatim falcon ASGI application. """ Create a Nominatim Falcon ASGI application.
""" """
app = falcon.asgi.App() api = NominatimAPIAsync(project_dir, environ)
api = NominatimV1(project_dir, environ) app = App()
for name, func in api_impl.ROUTES:
app.add_route('/status', api, suffix='status') app.add_route('/' + name, EndpointWrapper(func, api))
return app return app

View File

@@ -7,75 +7,58 @@
""" """
Server implementation using the sanic webserver framework. Server implementation using the sanic webserver framework.
""" """
from typing import Any, Optional, Mapping from typing import Any, Optional, Mapping, Callable, cast, Coroutine
from pathlib import Path from pathlib import Path
import sanic from sanic import Request, HTTPResponse, Sanic
from sanic.exceptions import SanicException
from sanic.response import text as TextResponse
from nominatim.api import NominatimAPIAsync, StatusResult from nominatim.api import NominatimAPIAsync
import nominatim.api.v1 as api_impl import nominatim.api.v1 as api_impl
api = sanic.Blueprint('NominatimAPI') class ParamWrapper(api_impl.ASGIAdaptor):
""" Adaptor class for server glue to Sanic framework.
CONTENT_TYPE = {
'text': 'text/plain; charset=utf-8',
'xml': 'text/xml; charset=utf-8'
}
def usage_error(msg: str) -> sanic.HTTPResponse:
""" Format the response for an error with the query parameters.
""" """
return sanic.response.text(msg, status=400)
def __init__(self, request: Request) -> None:
self.request = request
def api_response(request: sanic.Request, result: Any) -> sanic.HTTPResponse: def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
""" Render a response from the query results using the configured return cast(Optional[str], self.request.args.get(name, default))
formatter.
"""
body = api_impl.format_result(result, request.ctx.format)
return sanic.response.text(body,
content_type=CONTENT_TYPE.get(request.ctx.format,
'application/json'))
@api.on_request # type: ignore[misc] def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
async def extract_format(request: sanic.Request) -> Optional[sanic.HTTPResponse]: return cast(Optional[str], self.request.headers.get(name, default))
""" Get and check the 'format' parameter and prepare the formatter.
`ctx.result_type` describes the expected return type and
`ctx.default_format` the format value to assume when no parameter
is present.
"""
assert request.route is not None
request.ctx.format = request.args.get('format', request.route.ctx.default_format)
if not api_impl.supports_format(request.route.ctx.result_type, request.ctx.format):
return usage_error("Parameter 'format' must be one of: " +
', '.join(api_impl.list_formats(request.route.ctx.result_type)))
return None
@api.get('/status', ctx_result_type=StatusResult, ctx_default_format='text') def error(self, msg: str) -> SanicException:
async def status(request: sanic.Request) -> sanic.HTTPResponse: return SanicException(msg, status_code=400)
""" Implementation of status endpoint.
"""
result = await request.app.ctx.api.status()
response = api_response(request, result)
if request.ctx.format == 'text' and result.status:
response.status = 500
return response def create_response(self, status: int, output: str,
content_type: str) -> HTTPResponse:
return TextResponse(output, status=status, content_type=content_type)
def _wrap_endpoint(func: api_impl.EndpointFunc)\
-> Callable[[Request], Coroutine[Any, Any, HTTPResponse]]:
async def _callback(request: Request) -> HTTPResponse:
return cast(HTTPResponse, await func(request.app.ctx.api, ParamWrapper(request)))
return _callback
def get_application(project_dir: Path, def get_application(project_dir: Path,
environ: Optional[Mapping[str, str]] = None) -> sanic.Sanic: environ: Optional[Mapping[str, str]] = None) -> Sanic:
""" Create a Nominatim sanic ASGI application. """ Create a Nominatim sanic ASGI application.
""" """
app = sanic.Sanic("NominatimInstance") app = Sanic("NominatimInstance")
app.ctx.api = NominatimAPIAsync(project_dir, environ) app.ctx.api = NominatimAPIAsync(project_dir, environ)
app.blueprint(api) for name, func in api_impl.ROUTES:
app.add_route(_wrap_endpoint(func), f"/{name}", name=f"v1_{name}_simple")
return app return app

View File

@@ -7,7 +7,7 @@
""" """
Server implementation using the starlette webserver framework. Server implementation using the starlette webserver framework.
""" """
from typing import Any, Type, Optional, Mapping from typing import Any, Optional, Mapping, Callable, cast, Coroutine
from pathlib import Path from pathlib import Path
from starlette.applications import Starlette from starlette.applications import Starlette
@@ -16,58 +16,50 @@ from starlette.exceptions import HTTPException
from starlette.responses import Response from starlette.responses import Response
from starlette.requests import Request from starlette.requests import Request
from nominatim.api import NominatimAPIAsync, StatusResult from nominatim.api import NominatimAPIAsync
import nominatim.api.v1 as api_impl import nominatim.api.v1 as api_impl
CONTENT_TYPE = { class ParamWrapper(api_impl.ASGIAdaptor):
'text': 'text/plain; charset=utf-8', """ Adaptor class for server glue to Starlette framework.
'xml': 'text/xml; charset=utf-8'
}
def parse_format(request: Request, rtype: Type[Any], default: str) -> None:
""" Get and check the 'format' parameter and prepare the formatter.
`rtype` describes the expected return type and `default` the
format value to assume when no parameter is present.
""" """
fmt = request.query_params.get('format', default=default)
if not api_impl.supports_format(rtype, fmt): def __init__(self, request: Request) -> None:
raise HTTPException(400, detail="Parameter 'format' must be one of: " + self.request = request
', '.join(api_impl.list_formats(rtype)))
request.state.format = fmt
def format_response(request: Request, result: Any) -> Response: def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
""" Render response into a string according. return self.request.query_params.get(name, default=default)
"""
fmt = request.state.format
return Response(api_impl.format_result(result, fmt),
media_type=CONTENT_TYPE.get(fmt, 'application/json'))
async def on_status(request: Request) -> Response: def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
""" Implementation of status endpoint. return self.request.headers.get(name, default)
"""
parse_format(request, StatusResult, 'text')
result = await request.app.state.API.status()
response = format_response(request, result)
if request.state.format == 'text' and result.status:
response.status_code = 500
return response
V1_ROUTES = [ def error(self, msg: str) -> HTTPException:
Route('/status', endpoint=on_status) return HTTPException(400, detail=msg)
]
def create_response(self, status: int, output: str, content_type: str) -> Response:
return Response(output, status_code=status, media_type=content_type)
def _wrap_endpoint(func: api_impl.EndpointFunc)\
-> Callable[[Request], Coroutine[Any, Any, Response]]:
async def _callback(request: Request) -> Response:
return cast(Response, await func(request.app.state.API, ParamWrapper(request)))
return _callback
def get_application(project_dir: Path, def get_application(project_dir: Path,
environ: Optional[Mapping[str, str]] = None) -> Starlette: environ: Optional[Mapping[str, str]] = None) -> Starlette:
""" Create a Nominatim falcon ASGI application. """ Create a Nominatim falcon ASGI application.
""" """
app = Starlette(debug=True, routes=V1_ROUTES) routes = []
for name, func in api_impl.ROUTES:
routes.append(Route(f"/{name}", endpoint=_wrap_endpoint(func)))
app = Starlette(debug=True, routes=routes)
app.state.API = NominatimAPIAsync(project_dir, environ) app.state.API = NominatimAPIAsync(project_dir, environ)