Skip to content

Commit 26f29bf

Browse files
committed
feat: support asgi apps.
1 parent 1123eea commit 26f29bf

File tree

7 files changed

+165
-7
lines changed

7 files changed

+165
-7
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ functions_framework = ["py.typed"]
6666
dev = [
6767
"black>=23.3.0",
6868
"build>=1.1.1",
69+
"fastapi>=0.100.0",
6970
"isort>=5.11.5",
7071
"pretend>=1.0.9",
7172
"pytest>=7.4.4",

src/functions_framework/aio/__init__.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
from starlette.middleware import Middleware
4343
from starlette.requests import Request
4444
from starlette.responses import JSONResponse, Response
45-
from starlette.routing import Route
45+
from starlette.routing import Mount, Route
4646
except ImportError:
4747
raise FunctionsFrameworkException(
4848
"Starlette is not installed. Install the framework with the 'async' extra: "
@@ -247,6 +247,22 @@ def create_asgi_app(target=None, source=None, signature_type=None):
247247
_configure_app_execution_id_logging()
248248

249249
spec.loader.exec_module(source_module)
250+
251+
# Check if the target function is an ASGI app
252+
if hasattr(source_module, target):
253+
target_obj = getattr(source_module, target)
254+
if _is_asgi_app(target_obj):
255+
app = Starlette(
256+
routes=[
257+
Mount("/", app=target_obj),
258+
],
259+
middleware=[
260+
Middleware(ExceptionHandlerMiddleware),
261+
Middleware(execution_id.AsgiMiddleware),
262+
],
263+
)
264+
return app
265+
250266
function = _function_registry.get_user_function(source, source_module, target)
251267
signature_type = _function_registry.get_func_signature_type(target, signature_type)
252268

@@ -326,4 +342,23 @@ async def __call__(self, scope, receive, send):
326342
await self.app(scope, receive, send)
327343

328344

345+
def _is_asgi_app(target) -> bool:
346+
"""Check if an target looks like an ASGI application."""
347+
if not callable(target):
348+
return False
349+
350+
# Check for common ASGI framework attributes
351+
# FastAPI, Starlette, Quart all have these
352+
if hasattr(target, "routes") or hasattr(target, "router"):
353+
return True
354+
355+
# Check if it's a coroutine function with 3 params (scope, receive, send)
356+
if inspect.iscoroutinefunction(target):
357+
sig = inspect.signature(target)
358+
params = list(sig.parameters.keys())
359+
return len(params) == 3
360+
361+
return False
362+
363+
329364
app = LazyASGIApp()

tests/test_aio.py

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,18 @@
1717
import sys
1818
import tempfile
1919

20-
from unittest.mock import Mock, call
21-
22-
if sys.version_info >= (3, 8):
23-
from unittest.mock import AsyncMock
20+
from unittest.mock import AsyncMock, Mock, call
2421

2522
import pytest
2623

24+
from starlette.testclient import TestClient
25+
2726
from functions_framework import exceptions
2827
from functions_framework.aio import (
2928
LazyASGIApp,
3029
_cloudevent_func_wrapper,
3130
_http_func_wrapper,
31+
_is_asgi_app,
3232
create_asgi_app,
3333
)
3434

@@ -192,3 +192,83 @@ def sync_cloud_event(event):
192192
assert called_with_event is not None
193193
assert called_with_event["type"] == "test.event"
194194
assert called_with_event["source"] == "test-source"
195+
196+
197+
def test_detects_starlette_app():
198+
from starlette.applications import Starlette
199+
200+
app = Starlette()
201+
assert _is_asgi_app(app) is True
202+
203+
204+
def test_detects_fastapi_app():
205+
from fastapi import FastAPI
206+
207+
app = FastAPI()
208+
assert _is_asgi_app(app) is True
209+
210+
211+
def test_detects_bare_asgi_callable():
212+
async def asgi_app(scope, receive, send):
213+
pass
214+
215+
assert _is_asgi_app(asgi_app) is True
216+
217+
218+
def test_rejects_non_asgi_functions():
219+
def regular_function(request):
220+
return "response"
221+
222+
async def async_function(request):
223+
return "response"
224+
225+
async def wrong_params(a, b):
226+
pass
227+
228+
assert _is_asgi_app(regular_function) is False
229+
assert _is_asgi_app(async_function) is False
230+
assert _is_asgi_app(wrong_params) is False
231+
assert _is_asgi_app("not a function") is False
232+
233+
234+
def test_fastapi_app():
235+
source = str(TEST_FUNCTIONS_DIR / "asgi_apps" / "fastapi_app.py")
236+
app = create_asgi_app(target="app", source=source)
237+
client = TestClient(app)
238+
239+
response = client.get("/")
240+
assert response.status_code == 200
241+
assert response.json() == {"message": "Hello World"}
242+
243+
response = client.get("/items/42")
244+
assert response.status_code == 200
245+
assert response.json() == {"item_id": 42}
246+
247+
248+
def test_bare_asgi_app():
249+
source = str(TEST_FUNCTIONS_DIR / "asgi_apps" / "bare_asgi.py")
250+
app = create_asgi_app(target="app", source=source)
251+
client = TestClient(app)
252+
253+
response = client.get("/")
254+
assert response.status_code == 200
255+
assert response.text == "Hello from ASGI"
256+
257+
258+
def test_starlette_app():
259+
source = str(TEST_FUNCTIONS_DIR / "asgi_apps" / "starlette_app.py")
260+
app = create_asgi_app(target="app", source=source)
261+
client = TestClient(app)
262+
263+
response = client.get("/")
264+
assert response.status_code == 200
265+
assert response.json() == {"message": "Hello from Starlette"}
266+
267+
268+
def test_error_handling_in_asgi_app():
269+
source = str(TEST_FUNCTIONS_DIR / "asgi_apps" / "fastapi_app.py")
270+
app = create_asgi_app(target="app", source=source)
271+
client = TestClient(app)
272+
273+
response = client.get("/nonexistent")
274+
assert response.status_code == 404
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
async def app(scope, receive, send):
2+
assert scope["type"] == "http"
3+
4+
await send(
5+
{
6+
"type": "http.response.start",
7+
"status": 200,
8+
"headers": [[b"content-type", b"text/plain"]],
9+
}
10+
)
11+
await send(
12+
{
13+
"type": "http.response.body",
14+
"body": b"Hello from ASGI",
15+
}
16+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from fastapi import FastAPI
2+
3+
app = FastAPI()
4+
5+
6+
@app.get("/")
7+
async def root():
8+
return {"message": "Hello World"}
9+
10+
11+
@app.get("/items/{item_id}")
12+
async def read_item(item_id: int):
13+
return {"item_id": item_id}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from starlette.applications import Starlette
2+
from starlette.responses import JSONResponse
3+
from starlette.routing import Route
4+
5+
6+
async def homepage(request):
7+
return JSONResponse({"message": "Hello from Starlette"})
8+
9+
10+
app = Starlette(
11+
routes=[
12+
Route("/", homepage),
13+
]
14+
)

tox.ini

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ deps =
2929
pytest-cov
3030
pytest-integration
3131
pretend
32+
py,py38,py39,py310,py311,py312: fastapi
3233
extras =
3334
async
3435
setenv =
@@ -48,8 +49,6 @@ deps =
4849
isort
4950
mypy
5051
build
51-
extras =
52-
async
5352
commands =
5453
black --check src tests conftest.py --exclude tests/test_functions/background_load_error/main.py
5554
isort -c src tests conftest.py

0 commit comments

Comments
 (0)