Skip to content

Commit 7d7b951

Browse files
cyclimseShillaker
andauthored
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>
1 parent 7fbe1a6 commit 7d7b951

File tree

7 files changed

+126
-19
lines changed

7 files changed

+126
-19
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
### Changed
2323

2424
- Update README with link to Serverless Functions Node
25+
26+
## [0.2.0] - 2023-04-23
27+
28+
### Added
29+
30+
- Added a simple server to test with multiple handlers

examples/multiple_handlers.py

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from typing import TYPE_CHECKING
2+
3+
if TYPE_CHECKING:
4+
# Doing a conditional import avoids the need to install the library
5+
# when deploying the function
6+
from scaleway_functions_python.framework.v1.hints import Context, Event, Response
7+
8+
9+
def hello(_event: "Event", _context: "Context") -> "Response":
10+
"""Say hello!"""
11+
return {"body": "hello"}
12+
13+
14+
def world(_event: "Event", _context: "Context") -> "Response":
15+
"""Say world!"""
16+
return {"body": "world"}
17+
18+
19+
if __name__ == "__main__":
20+
from scaleway_functions_python import local
21+
22+
server = local.LocalFunctionServer()
23+
server.add_handler(hello)
24+
server.add_handler(world)
25+
server.serve(port=8080)
26+
27+
# Functions can be queried with:
28+
# curl localhost:8080/hello
29+
# curl localhost:8080/world

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "scaleway-functions-python"
3-
version = "0.1.1"
3+
version = "0.2.0"
44
description = "Utilities for testing your Python handlers for Scaleway Serverless Functions."
55
authors = ["Scaleway Serverless Team <opensource@scaleway.com>"]
66

scaleway_functions_python/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from . import local as local
22
from .framework import v1 as v1
3+
from .local.serving import LocalFunctionServer as LocalFunctionServer
34
from .local.serving import serve_handler as serve_handler
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
from .serving import LocalFunctionServer as LocalFunctionServer
12
from .serving import serve_handler as serve_handler

scaleway_functions_python/local/serving.py

+53-14
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
from base64 import b64decode
33
from json import JSONDecodeError
4-
from typing import TYPE_CHECKING, Any, ClassVar, cast
4+
from typing import TYPE_CHECKING, Any, ClassVar, List, Optional, cast
55

66
from flask import Flask, json, jsonify, make_response, request
77
from flask.views import View
@@ -18,7 +18,7 @@
1818

1919
# TODO?: Switch to https://docs.python.org/3/library/http.html#http-methods
2020
# for Python 3.11+
21-
HTTP_METHODS = [
21+
ALL_HTTP_METHODS = [
2222
"GET",
2323
"HEAD",
2424
"POST",
@@ -144,17 +144,57 @@ def resp_record_to_flask_response(
144144
return resp
145145

146146

147-
def _create_flask_app(handler: "hints.Handler") -> Flask:
148-
app = Flask(f"serverless_local_{handler.__name__}")
147+
class LocalFunctionServer:
148+
"""LocalFunctionServer serves Scaleway FaaS handlers on a local http server."""
149149

150-
# Create the view from the handler
151-
view = HandlerWrapper(handler).as_view(handler.__name__, handler)
150+
def __init__(self) -> None:
151+
self.app = Flask("serverless_local")
152152

153-
# By default, methods contains ["GET", "HEAD", "OPTIONS"]
154-
app.add_url_rule("/<path:path>", methods=HTTP_METHODS, view_func=view)
155-
app.add_url_rule("/", methods=HTTP_METHODS, defaults={"path": ""}, view_func=view)
153+
def add_handler(
154+
self,
155+
handler: "hints.Handler",
156+
relative_url: Optional[str] = None,
157+
http_methods: Optional[List[str]] = None,
158+
) -> "LocalFunctionServer":
159+
"""Add a handler to be served by the server.
156160
157-
return app
161+
:param handler: serverless python handler
162+
:param relative_url: path to the handler, defaults to / + handler's name
163+
:param http_methods: HTTP methods for the handler, defaults to all methods
164+
"""
165+
relative_url = relative_url if relative_url else "/" + handler.__name__
166+
if not relative_url.startswith("/"):
167+
relative_url = "/" + relative_url
168+
169+
http_methods = http_methods if http_methods else ALL_HTTP_METHODS
170+
http_methods = [method.upper() for method in http_methods]
171+
172+
view = HandlerWrapper(handler).as_view(handler.__name__, handler)
173+
174+
# By default, methods contains ["GET", "HEAD", "OPTIONS"]
175+
self.app.add_url_rule(
176+
f"{relative_url}/<path:path>", methods=http_methods, view_func=view
177+
)
178+
self.app.add_url_rule(
179+
relative_url,
180+
methods=http_methods,
181+
defaults={"path": ""},
182+
view_func=view,
183+
)
184+
185+
return self
186+
187+
def serve(
188+
self, *args: Any, port: int = 8080, debug: bool = True, **kwargs: Any
189+
) -> None:
190+
"""Serve the added FaaS handlers.
191+
192+
:param port: port that the server should listen on, defaults to 8080
193+
:param debug: run Flask in debug mode, enables hot-reloading and stack trace.
194+
"""
195+
kwargs["port"] = port
196+
kwargs["debug"] = debug
197+
self.app.run(*args, **kwargs)
158198

159199

160200
def serve_handler(
@@ -175,7 +215,6 @@ def serve_handler(
175215
... return {"body": event["httpMethod"]}
176216
>>> serve_handler_locally(handle, port=8080)
177217
"""
178-
app: Flask = _create_flask_app(handler)
179-
kwargs["port"] = port
180-
kwargs["debug"] = debug
181-
app.run(*args, **kwargs)
218+
server = LocalFunctionServer()
219+
server.add_handler(handler=handler, relative_url="/")
220+
server.serve(*args, port=port, debug=debug, **kwargs)

tests/test_local/test_serving.py

+35-4
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@
44
import pytest
55
from flask.testing import FlaskClient
66

7-
from scaleway_functions_python.local.serving import _create_flask_app
7+
from scaleway_functions_python.local.serving import LocalFunctionServer
88

99
from .. import handlers as h
1010

1111

1212
@pytest.fixture(scope="function")
1313
def client(request) -> FlaskClient:
14-
app = _create_flask_app(request.param)
15-
app.config.update({"TESTING": True})
16-
return app.test_client()
14+
server = LocalFunctionServer()
15+
server.add_handler(handler=request.param, relative_url="/")
16+
server.app.config.update({"TESTING": True})
17+
return server.app.test_client()
1718

1819

1920
@pytest.mark.parametrize(
@@ -89,3 +90,33 @@ def test_serve_handler_inject_infra_headers(client):
8990
assert headers["X-Forwarded-Proto"] == "http"
9091

9192
uuid.UUID(headers["X-Request-Id"])
93+
94+
95+
def test_local_function_server_multiple_routes():
96+
# Setup a server with two handlers
97+
server = LocalFunctionServer()
98+
server.add_handler(
99+
handler=h.handler_that_returns_string,
100+
relative_url="/message",
101+
http_methods=["GET"], # type: ignore
102+
)
103+
server.add_handler(
104+
handler=h.handler_returns_exception,
105+
relative_url="kaboom",
106+
http_methods=["POST", "PUT"], # type: ignore
107+
) # type: ignore
108+
server.app.config.update({"TESTING": True})
109+
client = server.app.test_client()
110+
111+
resp = client.get("/message")
112+
assert resp.text == h.HELLO_WORLD
113+
114+
resp = client.post("/message")
115+
assert resp.status_code == 405 # Method not allowed
116+
117+
resp = client.get("/kaboom")
118+
assert resp.status_code == 405
119+
120+
with pytest.raises(Exception) as e:
121+
client.put("/kaboom")
122+
assert str(e) == h.EXCEPTION_MESSAGE

0 commit comments

Comments
 (0)