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

@@ -7,70 +7,66 @@
"""
Server implementation using the falcon webserver framework.
"""
from typing import Type, Any, Optional, Mapping
from typing import Optional, Mapping, cast
from pathlib import Path
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
CONTENT_TYPE = {
'text': falcon.MEDIA_TEXT,
'xml': falcon.MEDIA_XML
}
class NominatimV1:
""" Implementation of V1 version of the Nominatim API.
class ParamWrapper(api_impl.ASGIAdaptor):
""" Adaptor class for server glue to Falcon framework.
"""
def __init__(self, project_dir: Path, environ: Optional[Mapping[str, str]]) -> None:
self.api = NominatimAPIAsync(project_dir, environ)
def __init__(self, req: Request, resp: Response) -> None:
self.request = req
self.response = resp
def parse_format(self, req: falcon.asgi.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.
def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
return cast(Optional[str], self.request.get_param(name, default=default))
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)
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
await self.func(self.api, ParamWrapper(req, resp))
def get_application(project_dir: Path,
environ: Optional[Mapping[str, str]] = None) -> falcon.asgi.App:
""" Create a Nominatim falcon ASGI application.
environ: Optional[Mapping[str, str]] = None) -> App:
""" Create a Nominatim Falcon ASGI application.
"""
app = falcon.asgi.App()
api = NominatimAPIAsync(project_dir, environ)
api = NominatimV1(project_dir, environ)
app.add_route('/status', api, suffix='status')
app = App()
for name, func in api_impl.ROUTES:
app.add_route('/' + name, EndpointWrapper(func, api))
return app

View File

@@ -7,75 +7,58 @@
"""
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
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
api = sanic.Blueprint('NominatimAPI')
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.
class ParamWrapper(api_impl.ASGIAdaptor):
""" Adaptor class for server glue to Sanic framework.
"""
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:
""" Render a response from the query results using the configured
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'))
def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
return cast(Optional[str], self.request.args.get(name, default))
@api.on_request # type: ignore[misc]
async def extract_format(request: sanic.Request) -> Optional[sanic.HTTPResponse]:
""" 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
def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
return cast(Optional[str], self.request.headers.get(name, default))
@api.get('/status', ctx_result_type=StatusResult, ctx_default_format='text')
async def status(request: sanic.Request) -> sanic.HTTPResponse:
""" Implementation of status endpoint.
"""
result = await request.app.ctx.api.status()
response = api_response(request, result)
def error(self, msg: str) -> SanicException:
return SanicException(msg, status_code=400)
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,
environ: Optional[Mapping[str, str]] = None) -> sanic.Sanic:
environ: Optional[Mapping[str, str]] = None) -> Sanic:
""" Create a Nominatim sanic ASGI application.
"""
app = sanic.Sanic("NominatimInstance")
app = Sanic("NominatimInstance")
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

View File

@@ -7,7 +7,7 @@
"""
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 starlette.applications import Starlette
@@ -16,58 +16,50 @@ from starlette.exceptions import HTTPException
from starlette.responses import Response
from starlette.requests import Request
from nominatim.api import NominatimAPIAsync, StatusResult
from nominatim.api import NominatimAPIAsync
import nominatim.api.v1 as api_impl
CONTENT_TYPE = {
'text': 'text/plain; charset=utf-8',
'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.
class ParamWrapper(api_impl.ASGIAdaptor):
""" Adaptor class for server glue to Starlette framework.
"""
fmt = request.query_params.get('format', default=default)
if not api_impl.supports_format(rtype, fmt):
raise HTTPException(400, detail="Parameter 'format' must be one of: " +
', '.join(api_impl.list_formats(rtype)))
request.state.format = fmt
def __init__(self, request: Request) -> None:
self.request = request
def format_response(request: Request, result: Any) -> Response:
""" Render response into a string according.
"""
fmt = request.state.format
return Response(api_impl.format_result(result, fmt),
media_type=CONTENT_TYPE.get(fmt, 'application/json'))
def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
return self.request.query_params.get(name, default=default)
async def on_status(request: Request) -> Response:
""" Implementation of status endpoint.
"""
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
def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
return self.request.headers.get(name, default)
V1_ROUTES = [
Route('/status', endpoint=on_status)
]
def error(self, msg: str) -> HTTPException:
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,
environ: Optional[Mapping[str, str]] = None) -> Starlette:
""" 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)