Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add multiple handlers server #32

Merged
merged 3 commits into from
Apr 27, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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