Skip to content

Commit

Permalink
feat: add multiple handlers server (#32)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
cyclimse and Shillaker authored Apr 27, 2023
1 parent 7fbe1a6 commit 7d7b951
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 19 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions examples/multiple_handlers.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <opensource@scaleway.com>"]

Expand Down
1 change: 1 addition & 0 deletions scaleway_functions_python/__init__.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions scaleway_functions_python/local/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .serving import LocalFunctionServer as LocalFunctionServer
from .serving import serve_handler as serve_handler
67 changes: 53 additions & 14 deletions scaleway_functions_python/local/serving.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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("/<path:path>", 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}/<path:path>", 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(
Expand All @@ -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)
39 changes: 35 additions & 4 deletions tests/test_local/test_serving.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

0 comments on commit 7d7b951

Please sign in to comment.