From 26f29bf133e968b11a055955379bd3ebe4b44b2a Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 19 Jun 2025 18:52:31 -0700 Subject: [PATCH] feat: support asgi apps. --- pyproject.toml | 1 + src/functions_framework/aio/__init__.py | 37 +++++++- tests/test_aio.py | 88 ++++++++++++++++++- tests/test_functions/asgi_apps/bare_asgi.py | 16 ++++ tests/test_functions/asgi_apps/fastapi_app.py | 13 +++ .../test_functions/asgi_apps/starlette_app.py | 14 +++ tox.ini | 3 +- 7 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 tests/test_functions/asgi_apps/bare_asgi.py create mode 100644 tests/test_functions/asgi_apps/fastapi_app.py create mode 100644 tests/test_functions/asgi_apps/starlette_app.py diff --git a/pyproject.toml b/pyproject.toml index 2b6e0639..96caa49c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ functions_framework = ["py.typed"] dev = [ "black>=23.3.0", "build>=1.1.1", + "fastapi>=0.100.0", "isort>=5.11.5", "pretend>=1.0.9", "pytest>=7.4.4", diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 4245f2d1..eb6e0a9d 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -42,7 +42,7 @@ from starlette.middleware import Middleware from starlette.requests import Request from starlette.responses import JSONResponse, Response - from starlette.routing import Route + from starlette.routing import Mount, Route except ImportError: raise FunctionsFrameworkException( "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): _configure_app_execution_id_logging() spec.loader.exec_module(source_module) + + # Check if the target function is an ASGI app + if hasattr(source_module, target): + target_obj = getattr(source_module, target) + if _is_asgi_app(target_obj): + app = Starlette( + routes=[ + Mount("/", app=target_obj), + ], + middleware=[ + Middleware(ExceptionHandlerMiddleware), + Middleware(execution_id.AsgiMiddleware), + ], + ) + return app + function = _function_registry.get_user_function(source, source_module, target) signature_type = _function_registry.get_func_signature_type(target, signature_type) @@ -326,4 +342,23 @@ async def __call__(self, scope, receive, send): await self.app(scope, receive, send) +def _is_asgi_app(target) -> bool: + """Check if an target looks like an ASGI application.""" + if not callable(target): + return False + + # Check for common ASGI framework attributes + # FastAPI, Starlette, Quart all have these + if hasattr(target, "routes") or hasattr(target, "router"): + return True + + # Check if it's a coroutine function with 3 params (scope, receive, send) + if inspect.iscoroutinefunction(target): + sig = inspect.signature(target) + params = list(sig.parameters.keys()) + return len(params) == 3 + + return False + + app = LazyASGIApp() diff --git a/tests/test_aio.py b/tests/test_aio.py index 4f34c279..906907da 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -17,18 +17,18 @@ import sys import tempfile -from unittest.mock import Mock, call - -if sys.version_info >= (3, 8): - from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock, call import pytest +from starlette.testclient import TestClient + from functions_framework import exceptions from functions_framework.aio import ( LazyASGIApp, _cloudevent_func_wrapper, _http_func_wrapper, + _is_asgi_app, create_asgi_app, ) @@ -192,3 +192,83 @@ def sync_cloud_event(event): assert called_with_event is not None assert called_with_event["type"] == "test.event" assert called_with_event["source"] == "test-source" + + +def test_detects_starlette_app(): + from starlette.applications import Starlette + + app = Starlette() + assert _is_asgi_app(app) is True + + +def test_detects_fastapi_app(): + from fastapi import FastAPI + + app = FastAPI() + assert _is_asgi_app(app) is True + + +def test_detects_bare_asgi_callable(): + async def asgi_app(scope, receive, send): + pass + + assert _is_asgi_app(asgi_app) is True + + +def test_rejects_non_asgi_functions(): + def regular_function(request): + return "response" + + async def async_function(request): + return "response" + + async def wrong_params(a, b): + pass + + assert _is_asgi_app(regular_function) is False + assert _is_asgi_app(async_function) is False + assert _is_asgi_app(wrong_params) is False + assert _is_asgi_app("not a function") is False + + +def test_fastapi_app(): + source = str(TEST_FUNCTIONS_DIR / "asgi_apps" / "fastapi_app.py") + app = create_asgi_app(target="app", source=source) + client = TestClient(app) + + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "Hello World"} + + response = client.get("/items/42") + assert response.status_code == 200 + assert response.json() == {"item_id": 42} + + +def test_bare_asgi_app(): + source = str(TEST_FUNCTIONS_DIR / "asgi_apps" / "bare_asgi.py") + app = create_asgi_app(target="app", source=source) + client = TestClient(app) + + response = client.get("/") + assert response.status_code == 200 + assert response.text == "Hello from ASGI" + + +def test_starlette_app(): + source = str(TEST_FUNCTIONS_DIR / "asgi_apps" / "starlette_app.py") + app = create_asgi_app(target="app", source=source) + client = TestClient(app) + + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "Hello from Starlette"} + + +def test_error_handling_in_asgi_app(): + source = str(TEST_FUNCTIONS_DIR / "asgi_apps" / "fastapi_app.py") + app = create_asgi_app(target="app", source=source) + client = TestClient(app) + + response = client.get("/nonexistent") + assert response.status_code == 404 diff --git a/tests/test_functions/asgi_apps/bare_asgi.py b/tests/test_functions/asgi_apps/bare_asgi.py new file mode 100644 index 00000000..b41ee38a --- /dev/null +++ b/tests/test_functions/asgi_apps/bare_asgi.py @@ -0,0 +1,16 @@ +async def app(scope, receive, send): + assert scope["type"] == "http" + + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [[b"content-type", b"text/plain"]], + } + ) + await send( + { + "type": "http.response.body", + "body": b"Hello from ASGI", + } + ) diff --git a/tests/test_functions/asgi_apps/fastapi_app.py b/tests/test_functions/asgi_apps/fastapi_app.py new file mode 100644 index 00000000..7717ab26 --- /dev/null +++ b/tests/test_functions/asgi_apps/fastapi_app.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +async def root(): + return {"message": "Hello World"} + + +@app.get("/items/{item_id}") +async def read_item(item_id: int): + return {"item_id": item_id} diff --git a/tests/test_functions/asgi_apps/starlette_app.py b/tests/test_functions/asgi_apps/starlette_app.py new file mode 100644 index 00000000..5b241cf1 --- /dev/null +++ b/tests/test_functions/asgi_apps/starlette_app.py @@ -0,0 +1,14 @@ +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route + + +async def homepage(request): + return JSONResponse({"message": "Hello from Starlette"}) + + +app = Starlette( + routes=[ + Route("/", homepage), + ] +) diff --git a/tox.ini b/tox.ini index fd3e38a6..44141fe4 100644 --- a/tox.ini +++ b/tox.ini @@ -29,6 +29,7 @@ deps = pytest-cov pytest-integration pretend + py,py38,py39,py310,py311,py312: fastapi extras = async setenv = @@ -48,8 +49,6 @@ deps = isort mypy build -extras = - async commands = black --check src tests conftest.py --exclude tests/test_functions/background_load_error/main.py isort -c src tests conftest.py