From 7d7b951c53e304198f0ac29579f5dfe84432e70d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Thu, 27 Apr 2023 09:57:10 +0200 Subject: [PATCH] feat: add multiple handlers server (#32) * feat: add multiple handlers server * fix: missing relative_url to root * fix: update CHANGELOG.md Co-authored-by: Simon Shillaker <554768+Shillaker@users.noreply.github.com> --------- Co-authored-by: Simon Shillaker <554768+Shillaker@users.noreply.github.com> --- CHANGELOG.md | 6 ++ examples/multiple_handlers.py | 29 +++++++++ pyproject.toml | 2 +- scaleway_functions_python/__init__.py | 1 + scaleway_functions_python/local/__init__.py | 1 + scaleway_functions_python/local/serving.py | 67 ++++++++++++++++----- tests/test_local/test_serving.py | 39 ++++++++++-- 7 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 examples/multiple_handlers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a7685bd..0e0121d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,3 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Update README with link to Serverless Functions Node + +## [0.2.0] - 2023-04-23 + +### Added + +- Added a simple server to test with multiple handlers diff --git a/examples/multiple_handlers.py b/examples/multiple_handlers.py new file mode 100644 index 0000000..0f1069c --- /dev/null +++ b/examples/multiple_handlers.py @@ -0,0 +1,29 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # Doing a conditional import avoids the need to install the library + # when deploying the function + from scaleway_functions_python.framework.v1.hints import Context, Event, Response + + +def hello(_event: "Event", _context: "Context") -> "Response": + """Say hello!""" + return {"body": "hello"} + + +def world(_event: "Event", _context: "Context") -> "Response": + """Say world!""" + return {"body": "world"} + + +if __name__ == "__main__": + from scaleway_functions_python import local + + server = local.LocalFunctionServer() + server.add_handler(hello) + server.add_handler(world) + server.serve(port=8080) + + # Functions can be queried with: + # curl localhost:8080/hello + # curl localhost:8080/world diff --git a/pyproject.toml b/pyproject.toml index f37d5a4..ea81f3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "scaleway-functions-python" -version = "0.1.1" +version = "0.2.0" description = "Utilities for testing your Python handlers for Scaleway Serverless Functions." authors = ["Scaleway Serverless Team "] diff --git a/scaleway_functions_python/__init__.py b/scaleway_functions_python/__init__.py index 601ebd8..3535d22 100644 --- a/scaleway_functions_python/__init__.py +++ b/scaleway_functions_python/__init__.py @@ -1,3 +1,4 @@ from . import local as local from .framework import v1 as v1 +from .local.serving import LocalFunctionServer as LocalFunctionServer from .local.serving import serve_handler as serve_handler diff --git a/scaleway_functions_python/local/__init__.py b/scaleway_functions_python/local/__init__.py index 54d1307..6deb5a5 100644 --- a/scaleway_functions_python/local/__init__.py +++ b/scaleway_functions_python/local/__init__.py @@ -1 +1,2 @@ +from .serving import LocalFunctionServer as LocalFunctionServer from .serving import serve_handler as serve_handler diff --git a/scaleway_functions_python/local/serving.py b/scaleway_functions_python/local/serving.py index 6ce8534..1c6840f 100644 --- a/scaleway_functions_python/local/serving.py +++ b/scaleway_functions_python/local/serving.py @@ -1,7 +1,7 @@ import logging from base64 import b64decode from json import JSONDecodeError -from typing import TYPE_CHECKING, Any, ClassVar, cast +from typing import TYPE_CHECKING, Any, ClassVar, List, Optional, cast from flask import Flask, json, jsonify, make_response, request from flask.views import View @@ -18,7 +18,7 @@ # TODO?: Switch to https://docs.python.org/3/library/http.html#http-methods # for Python 3.11+ -HTTP_METHODS = [ +ALL_HTTP_METHODS = [ "GET", "HEAD", "POST", @@ -144,17 +144,57 @@ def resp_record_to_flask_response( return resp -def _create_flask_app(handler: "hints.Handler") -> Flask: - app = Flask(f"serverless_local_{handler.__name__}") +class LocalFunctionServer: + """LocalFunctionServer serves Scaleway FaaS handlers on a local http server.""" - # Create the view from the handler - view = HandlerWrapper(handler).as_view(handler.__name__, handler) + def __init__(self) -> None: + self.app = Flask("serverless_local") - # By default, methods contains ["GET", "HEAD", "OPTIONS"] - app.add_url_rule("/", methods=HTTP_METHODS, view_func=view) - app.add_url_rule("/", methods=HTTP_METHODS, defaults={"path": ""}, view_func=view) + def add_handler( + self, + handler: "hints.Handler", + relative_url: Optional[str] = None, + http_methods: Optional[List[str]] = None, + ) -> "LocalFunctionServer": + """Add a handler to be served by the server. - return app + :param handler: serverless python handler + :param relative_url: path to the handler, defaults to / + handler's name + :param http_methods: HTTP methods for the handler, defaults to all methods + """ + relative_url = relative_url if relative_url else "/" + handler.__name__ + if not relative_url.startswith("/"): + relative_url = "/" + relative_url + + http_methods = http_methods if http_methods else ALL_HTTP_METHODS + http_methods = [method.upper() for method in http_methods] + + view = HandlerWrapper(handler).as_view(handler.__name__, handler) + + # By default, methods contains ["GET", "HEAD", "OPTIONS"] + self.app.add_url_rule( + f"{relative_url}/", methods=http_methods, view_func=view + ) + self.app.add_url_rule( + relative_url, + methods=http_methods, + defaults={"path": ""}, + view_func=view, + ) + + return self + + def serve( + self, *args: Any, port: int = 8080, debug: bool = True, **kwargs: Any + ) -> None: + """Serve the added FaaS handlers. + + :param port: port that the server should listen on, defaults to 8080 + :param debug: run Flask in debug mode, enables hot-reloading and stack trace. + """ + kwargs["port"] = port + kwargs["debug"] = debug + self.app.run(*args, **kwargs) def serve_handler( @@ -175,7 +215,6 @@ def serve_handler( ... return {"body": event["httpMethod"]} >>> serve_handler_locally(handle, port=8080) """ - app: Flask = _create_flask_app(handler) - kwargs["port"] = port - kwargs["debug"] = debug - app.run(*args, **kwargs) + server = LocalFunctionServer() + server.add_handler(handler=handler, relative_url="/") + server.serve(*args, port=port, debug=debug, **kwargs) diff --git a/tests/test_local/test_serving.py b/tests/test_local/test_serving.py index 65474a2..0c681ff 100644 --- a/tests/test_local/test_serving.py +++ b/tests/test_local/test_serving.py @@ -4,16 +4,17 @@ import pytest from flask.testing import FlaskClient -from scaleway_functions_python.local.serving import _create_flask_app +from scaleway_functions_python.local.serving import LocalFunctionServer from .. import handlers as h @pytest.fixture(scope="function") def client(request) -> FlaskClient: - app = _create_flask_app(request.param) - app.config.update({"TESTING": True}) - return app.test_client() + server = LocalFunctionServer() + server.add_handler(handler=request.param, relative_url="/") + server.app.config.update({"TESTING": True}) + return server.app.test_client() @pytest.mark.parametrize( @@ -89,3 +90,33 @@ def test_serve_handler_inject_infra_headers(client): assert headers["X-Forwarded-Proto"] == "http" uuid.UUID(headers["X-Request-Id"]) + + +def test_local_function_server_multiple_routes(): + # Setup a server with two handlers + server = LocalFunctionServer() + server.add_handler( + handler=h.handler_that_returns_string, + relative_url="/message", + http_methods=["GET"], # type: ignore + ) + server.add_handler( + handler=h.handler_returns_exception, + relative_url="kaboom", + http_methods=["POST", "PUT"], # type: ignore + ) # type: ignore + server.app.config.update({"TESTING": True}) + client = server.app.test_client() + + resp = client.get("/message") + assert resp.text == h.HELLO_WORLD + + resp = client.post("/message") + assert resp.status_code == 405 # Method not allowed + + resp = client.get("/kaboom") + assert resp.status_code == 405 + + with pytest.raises(Exception) as e: + client.put("/kaboom") + assert str(e) == h.EXCEPTION_MESSAGE