diff --git a/.gitignore b/.gitignore index ccab2a249..f679971e2 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,5 @@ tests/**/extensions.csproj __blobstorage__/* __queuestorage__/* __azurite* + +.github/agents \ No newline at end of file diff --git a/RUNTIME_BASE_DESIGN.md b/RUNTIME_BASE_DESIGN.md new file mode 100644 index 000000000..671dbe584 --- /dev/null +++ b/RUNTIME_BASE_DESIGN.md @@ -0,0 +1,506 @@ +# Runtime Base Extension Design Proposal + +## Executive Summary + +This document proposes a base extension pattern for Azure Functions Python Worker that enables seamless addition of new runtime frameworks (FastAPI, Flask, Django, etc.) without requiring proxy worker code changes. The solution uses metaclass-based automatic registration, inspired by the proven `azurefunctions-extensions-base` architecture used for HTTP streaming. + +--- + +## 1. Problem Statement + +### Current Challenges + +The Azure Functions Python Worker currently supports multiple programming models (V1 with function.json, V2 with decorators), but adding new runtime frameworks presents several challenges: + +**1.1 Hardcoded Runtime Detection** +- The proxy worker (`dispatcher.py`) contains hardcoded logic to detect and load runtimes +- Adding FastAPI required explicit try-import statements and detection logic +- Each new runtime (Flask, Django, etc.) would require modifying `dispatcher.py` + +**1.2 Maintenance Burden** +- Every new runtime necessitates proxy worker changes +- Creates tight coupling between proxy worker and runtime implementations +- Difficult to test runtimes in isolation + +**1.3 Extensibility Limitations** +- Third-party runtime packages cannot be added without worker changes +- Community contributions are difficult to integrate +- No clear contract for what a runtime must implement + +### Requirements + +A solution must: +- Allow adding new runtimes without proxy worker changes (after initial base integration) +- Provide clear abstractions and contracts for runtime implementations +- Maintain backward compatibility with existing V1/V2 runtimes +- Support automatic runtime discovery and registration +- Ensure type safety and enforce required method implementations +- Enable third-party runtime packages +- Minimize performance overhead + +--- + +## 2. Proposed Solution Overview + +### 2.1 Solution Approach + +Implement a **runtime base package** using metaclass-based automatic registration, following the proven pattern from `azurefunctions-extensions-base` used for HTTP streaming extensions. + +### 2.2 Key Concepts + +**Base Package (`runtimes/base/`)** +- Provides abstract base classes defining the runtime contract +- Uses metaclasses to automatically register runtime implementations at import time +- Acts as the single point of integration with the proxy worker + +**Runtime Implementations** (FastAPI, Flask, etc.) +- Extend the base package's abstract classes +- Auto-register via metaclass when imported +- Implement required event handler methods + +**Proxy Worker Integration** +- Imports only the base package +- Queries base for registered runtime +- Dynamically loads the appropriate runtime module + +### 2.3 High-Level Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 1. Worker Startup │ +│ - Proxy worker imports runtimes.base │ +│ - Detects which runtime to use (FastAPI, Flask, V2, V1) │ +│ - Imports that runtime package │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 2. Automatic Registration (Metaclass Magic) │ +│ - Runtime class definition executes │ +│ - RuntimeTrackerMeta.__new__ fires automatically │ +│ - Runtime module name stored in metaclass │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 3. Runtime Discovery │ +│ - Proxy worker queries: RuntimeTrackerMeta.get_module() │ +│ - Base returns: "azure_functions_fastapi.runtime" │ +│ - Worker dynamically imports the runtime module │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 4. Event Handling │ +│ - Worker calls runtime.worker_init_request() │ +│ - Runtime executes FastAPI-specific logic │ +│ - Returns standard protobuf responses │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Design Overview + +### 3.1 Architecture Components + +#### 3.1.1 Runtime Base Package (`runtimes/base/`) + +**File Structure:** +``` +runtimes/base/ +├── __init__.py # Package exports +└── runtime.py # Core abstractions +``` + +**Core Classes:** + +**RuntimeTrackerMeta (Metaclass)** +```python +class RuntimeTrackerMeta(type): + _module = None # Stores registered module name + _runtime_name = None # Stores runtime identifier + + def __new__(cls, name, bases, dct, **kwargs): + # Auto-registers runtime on class definition + new_module = dct.get("__module__") + if new_module != base_runtime_module: + cls._module = new_module # Store module! + cls._runtime_name = dct.get("runtime_name") + return new_class +``` + +**Key Features:** +- Automatic registration at import time (no explicit calls needed) +- Single runtime enforcement (prevents multiple runtimes) +- Zero-overhead registration (happens once at class definition) + +**RuntimeBase (Abstract Base Class)** +```python +class RuntimeBase(metaclass=RuntimeTrackerMeta): + runtime_name = None # Must be set by subclass + + @abstractmethod + async def worker_init_request(self, request): ... + + @abstractmethod + async def functions_metadata_request(self, request): ... + + @abstractmethod + async def function_load_request(self, request): ... + + @abstractmethod + async def invocation_request(self, request): ... + + @abstractmethod + async def function_environment_reload_request(self, request): ... +``` + +**Key Features:** +- Enforces contract via abstract methods +- Python's type system ensures implementations are complete +- Clear documentation of required methods + +**RuntimeFeatureChecker (Utility)** +```python +class RuntimeFeatureChecker: + @staticmethod + def runtime_loaded(): ... + + @staticmethod + def get_runtime_name(): ... +``` + +#### 3.1.2 Runtime Implementation (Example: FastAPI) + +**File Structure:** +``` +runtimes/fastapi/azure_functions_fastapi/ +├── __init__.py # Imports Runtime class +├── runtime.py # Runtime class extending base +├── handle_event.py # Event handler implementations +├── handler.py # Request/response handling +├── indexer.py # FastAPI app indexing +└── ... # Other modules +``` + +**Runtime Class:** +```python +from runtimes.base import RuntimeBase + +class Runtime(RuntimeBase): + runtime_name = "fastapi" # Identifies this runtime + + async def worker_init_request(self, request): + return await worker_init_request(request) + + # ... other methods delegate to existing handlers +``` + +**Registration Flow:** +``` +import azure_functions_fastapi + ↓ +Runtime class is defined + ↓ +RuntimeTrackerMeta.__new__ executes + ↓ +Module "azure_functions_fastapi.runtime" stored + ↓ +Runtime is now registered! ✓ +``` + +#### 3.1.3 Proxy Worker Integration + +**Updated `dispatcher.py`:** +```python +def reload_library_worker(directory: str): + import runtimes.base as runtime_base + + # Detect which runtime to use + if is_fastapi_app(directory): + import azure_functions_fastapi # Auto-registers! + elif is_v2_app(directory): + import azure_functions_runtime + else: + import azure_functions_runtime_v1 + + # Check if runtime registered + if runtime_base.RuntimeFeatureChecker.runtime_loaded(): + module_name = runtime_base.RuntimeTrackerMeta.get_module() + runtime_module = importlib.import_module(module_name) + _library_worker = runtime_module +``` + +### 3.2 Registration Mechanism + +**How Metaclass Registration Works:** + +1. **Class Definition Phase** (Import Time) + ```python + class Runtime(RuntimeBase): # Metaclass is RuntimeTrackerMeta + runtime_name = "fastapi" + ``` + +2. **Metaclass `__new__` Fires** + - Python calls `RuntimeTrackerMeta.__new__()` automatically + - Extracts `__module__` from class definition + - Stores in class variable `_module` + +3. **Discovery Phase** (Runtime) + ```python + RuntimeTrackerMeta.get_module() # Returns stored module name + ``` + +**Why This Works:** +- No explicit registration calls needed +- Happens automatically at import time +- Zero runtime overhead (registration is one-time) +- Thread-safe (class definition is atomic) + +### 3.3 Event Handler Contract + +All runtimes must implement these async methods: + +| Method | Purpose | Input | Output | +|--------|---------|-------|--------| +| `worker_init_request()` | Initialize runtime, discover functions | WorkerInitRequest | WorkerInitResponse | +| `functions_metadata_request()` | Return discovered function metadata | FunctionMetadataRequest | FunctionMetadataResponse | +| `function_load_request()` | Verify/load specific function | FunctionLoadRequest | FunctionLoadResponse | +| `invocation_request()` | Execute function invocation | InvocationRequest | InvocationResponse | +| `function_environment_reload_request()` | Reload environment (Linux Consumption) | FunctionEnvironmentReloadRequest | FunctionEnvironmentReloadResponse | + +### 3.4 Sequence Diagrams + +**Runtime Loading Sequence:** +``` +Proxy Worker Base Package Runtime Package + | | | + |--import runtimes.base--->| | + |<----[base loaded]-------| | + | | | + |--import azure_functions_fastapi---->| + | |<--metaclass registers--| + | | (auto-registration) | + | | | + |--get_module()------>| | + |<--"...fastapi.runtime"-| | + | | | + |--importlib.import_module("...runtime")----->| + |<--runtime module-----------------------------| +``` + +**Function Invocation Sequence:** +``` +Proxy Worker Runtime Module FastAPI App + | | | + |--invocation_request()-->| | + | |--parse request---- | + | |--extract path params- | + | |--execute_fastapi_route()-->| + | | |--route handler--> + | | |<--result---------| + | |<--response-------------| + |<--InvocationResponse-| | +``` + +--- + +## 4. Benefits + +### 4.1 Extensibility +✅ **Add New Runtimes Without Worker Changes** +- Flask, Django, Bottle, etc. can be added by creating new packages +- No modifications to `dispatcher.py` after initial base integration +- Third-party runtimes possible + +✅ **Clear Contract** +- `RuntimeBase` defines exact interface +- Abstract methods enforce implementation +- Type hints provide IDE support + +### 4.2 Maintainability +✅ **Separation of Concerns** +- Runtime logic isolated in runtime packages +- Proxy worker only handles orchestration +- Each runtime can be tested independently + +✅ **Reduced Coupling** +- Proxy worker depends only on base package +- Runtimes are interchangeable +- Changes to one runtime don't affect others + +✅ **Code Reuse** +- Common patterns abstracted in base +- Utilities can be shared across runtimes +- Consistent error handling + +### 4.3 Developer Experience +✅ **Simple Runtime Creation** +```python +# Just extend RuntimeBase and implement methods! +from runtimes.base import RuntimeBase + +class Runtime(RuntimeBase): + runtime_name = "flask" + async def worker_init_request(self, request): ... +``` + +✅ **Auto-Discovery** +- No registration boilerplate +- Import = registration (metaclass magic) +- Intuitive for developers + +✅ **Type Safety** +- Abstract base ensures all methods implemented +- Python type system catches missing methods +- Better IDE autocomplete and error detection + +### 4.4 Performance +✅ **Zero Runtime Overhead** +- Registration happens once at import time +- No performance penalty during function execution +- Metaclass overhead is negligible (one-time) + +✅ **Lazy Loading** +- Only the detected runtime is imported +- Other runtimes stay unloaded +- Minimal memory footprint + +### 4.5 Backward Compatibility +✅ **Gradual Migration** +- V1 and V2 runtimes work unchanged +- Can extend them with base later +- Fallback logic for non-base runtimes + +✅ **No Breaking Changes** +- Existing function apps continue working +- Optional adoption of base pattern +- Transparent to end users + +--- + +## 5. Potential Issues and Mitigations + +### 5.1 Metaclass Complexity + +**Issue:** Metaclasses can be difficult to understand and debug. + +**Mitigation:** +- Comprehensive documentation with examples +- Clear logging of registration events +- Well-tested base package +- Inspired by proven pattern (`azurefunctions-extensions-base`) + +### 5.2 Single Runtime Limitation + +**Issue:** Only one runtime can be registered at a time (enforced by metaclass). + +**Mitigation:** +- This is intentional and desired behavior +- Function apps should use one runtime consistently +- Clear error message if multiple runtimes imported +- Matches behavior of HTTP streaming extensions + +### 5.3 Import-Time Side Effects + +**Issue:** Registration happens at import time (not explicit). + +**Mitigation:** +- This is standard Python practice for plugins +- Similar to how decorators and metaclasses work +- Well-documented in code and guides +- Predictable behavior (always happens on import) + +### 5.4 Detection Logic Still Needed + +**Issue:** Proxy worker still needs logic to decide which runtime to import. + +**Mitigation:** +- Detection is simple file pattern matching +- Can be improved with manifest files (future work) +- Detection happens before import (not runtime-specific) +- Much simpler than full runtime integration + +### 5.5 Third-Party Runtime Security + +**Issue:** Third-party runtimes could execute arbitrary code. + +**Mitigation:** +- Same risk as any third-party Python package +- Runtimes must be explicitly installed +- Package signing and verification (future work) +- Trust model same as V2 programming model + +--- + +## 6. Implementation Details + +### 6.1 File Structure + +``` +azure-functions-python-extensions/ +├── azurefunctions-extensions-bindings-base/ +│ ├── azurefunctions/extensions/bindings/base/ # Runtime base package ⭐ NEW +│ │ ├── __init__.py + └── runtime.py +``` + +### 6.2 Key Code Changes + +**1. Proxy Worker Update:** +- Import `azurefunctions.extensions.bindings.base` when indexing +- Query base for registered runtime +- Dynamic import using `importlib.import_module()` +- Fallback to traditional detection for backward compatibility + +### 6.3 Migration Path + +**Phase 1: FastAPI (Current)** +- ✅ Create base package +- ✅ Update FastAPI runtime to extend base +- ✅ Update proxy worker to use base +- ✅ Test with FastAPI apps + +**Phase 2: New Runtimes (Future)** +- Flask runtime using base +- Django runtime using base +- Community-contributed runtimes + +--- + + +## 7. Conclusion + +The runtime base extension pattern provides a clean, maintainable, and extensible solution for adding new runtime frameworks to Azure Functions Python Worker. By leveraging metaclass-based automatic registration (proven by `azurefunctions-extensions-base`), we achieve: + +- ✅ Zero proxy worker changes for new runtimes (after initial base integration) +- ✅ Clear contract via abstract base classes +- ✅ Automatic discovery and registration +- ✅ Type safety and IDE support +- ✅ Backward compatibility with existing runtimes +- ✅ Foundation for community-contributed runtimes + +The implementation is straightforward, well-tested, and ready for production use with FastAPI. It provides a clear path for adding Flask, Django, and other frameworks in the future. + +--- + +## 8. References + +- **HTTP Streaming Pattern:** `azurefunctions-extensions-base` and `azurefunctions-extensions-http-fastapi` +- **Python Metaclasses:** [PEP 3115](https://www.python.org/dev/peps/pep-3115/) +- **Abstract Base Classes:** [PEP 3119](https://www.python.org/dev/peps/pep-3119/) +- **Azure Functions Python Worker:** Current architecture and design + +--- + +## Appendix A: Code Samples + +### Complete RuntimeBase Implementation + +See `runtimes/base/runtime.py` for full implementation. + +### Complete FastAPI Runtime + +See `runtimes/fastapi/azure_functions_fastapi/runtime.py` for full implementation. + +### Proxy Worker Integration + +See `workers/proxy_worker/dispatcher.py` for integration code. diff --git a/runtimes/fastapi/README.md b/runtimes/fastapi/README.md new file mode 100644 index 000000000..2d76d7c28 --- /dev/null +++ b/runtimes/fastapi/README.md @@ -0,0 +1,291 @@ +# Azure Functions FastAPI Runtime + +The Azure Functions FastAPI runtime is a proposed native hosting path for +existing FastAPI applications. Customers add a runtime dependency to their +project, and Azure Functions discovers and runs the application without +requiring it to adopt the Azure Functions programming model. + +> [!IMPORTANT] +> This package is an alpha prototype. This document distinguishes the intended +> customer experience from behavior implemented in the current prototype. + +## Motivation + +More than 4,000 Azure Functions applications already bring FastAPI as a +dependency, but Azure Functions does not currently provide a simple, native +hosting experience for FastAPI web applications. + +Customers can use `func.AsgiFunctionApp` or `func.WsgiFunctionApp` today. Those +adapters require changes to the application entry point, move the application +into the Azure Functions programming model, and do not support streaming. +Custom handlers provide another option, but they require customers to own the +web-server setup and deployment contract, and their documentation and tooling +are limited. + +The goal of this runtime is to remove that integration work. A conventional +FastAPI application should run on Azure Functions without source changes. The +customer adds one dependency, and the Functions platform handles runtime +selection, route discovery, function metadata, invocation, and HTTP transport. + +## Customer Experience + +### Application code + +The application remains a standard FastAPI application. It does not import +`azure.functions`, create a `FunctionApp`, or wrap the FastAPI app in an +Azure Functions adapter. + +```python +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/hello") +async def hello(): + return {"message": "Hello from FastAPI on Azure Functions"} + + +@app.get("/users/{user_id}") +async def get_user(user_id: int): + return {"user_id": user_id} +``` + +Place the app in either `function_app.py` or `app.py`. The module must expose +exactly one module-level `FastAPI` instance; the variable itself does not have +to be named `app`. + +### Project dependency + +The customer adds the FastAPI runtime package to `requirements.txt`: + +```text +azure-functions-fastapi-runtime +``` + +`azure-functions-fastapi-runtime` is the proposed distribution name used in +this document. The prototype's `pyproject.toml` still uses a temporary package +name, so it is not yet ready for the described public installation flow. + +No FastAPI-specific setting should be required in the target experience. The +runtime package registers itself through the `azurefunctions.runtimes` Python +entry-point group, allowing the proxy worker to select it automatically. + +### Application discovery + +The runtime checks for `function_app.py` first, then `app.py`. If both files +exist, `function_app.py` takes precedence. To use another filename, set: + +```text +PYTHON_SCRIPT_FILE_NAME=app.py +``` + +### Modular applications + +Use FastAPI's `APIRouter` and `include_router()` APIs to organize routes across +multiple files: + +```text +function_app.py +app/ +|-- schemas.py +`-- routers/ + |-- items.py + |-- root.py + `-- users.py +``` + +Each router module defines and exports an `APIRouter`: + +```python +# app/routers/items.py +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/") +async def list_items(): + return [] +``` + +The entry point creates the FastAPI app and explicitly registers its routers: + +```python +# function_app.py +from fastapi import FastAPI + +from app.routers import items, root, users + +app = FastAPI() +app.include_router(root.router) +app.include_router(items.router, prefix="/items") +app.include_router(users.router, prefix="/users") +``` + +FastAPI stores included and nested routers in the application's route registry. +The runtime indexes that registry directly, so it does not scan or import every +file under `app/routers`. Router registration must run while `function_app.py` +is imported; routers added later from startup or lifespan hooks are not +available when the Functions host requests metadata. A router module that is +not passed to `include_router()` is intentionally not indexed. + +### API documentation + +The runtime indexes FastAPI's configured documentation routes alongside the +application's API routes. With the default FastAPI configuration, these are: + +- `/openapi.json` for the OpenAPI schema +- `/docs` for Swagger UI +- `/docs/oauth2-redirect` for the Swagger UI OAuth redirect +- `/redoc` for ReDoc + +Custom `openapi_url`, `docs_url`, `swagger_ui_oauth2_redirect_url`, and +`redoc_url` values are respected, including disabling a route with `None`. +Generated documentation URLs include the route prefix used by Azure Functions. + +## Implementation + +### Architecture overview + +```mermaid +flowchart TD + Host[Azure Functions host] + Proxy[Python proxy worker] + Runtime[FastAPI runtime] + Loader[App loader] + Indexer[Route indexer] + Converter[Functions metadata converter] + Handler[Request and response adapter] + App[Customer FastAPI app] + Http[Local HTTP v2 transport] + + Host <-->|gRPC worker protocol| Proxy + Proxy -->|runtime entry point| Runtime + Runtime --> Loader + Loader --> App + Loader --> Indexer + Indexer --> Converter + Converter -->|HTTP trigger metadata| Host + Host <-->|HTTP request and response| Http + Http <-->|invocation ID coordination| Runtime + Runtime --> Handler + Handler -->|direct endpoint call| App +``` + +The FastAPI package implements the runtime contract from +`azurefunctions-extensions-base`. The proxy worker discovers installed +runtimes from the `azurefunctions.runtimes` entry-point group and loads the +single registered runtime. The FastAPI package exports `Runtime`, which maps +worker protocol events to the implementation in `handle_event.py`. + +### Startup and route discovery + +During `WorkerInitRequest`, the runtime: + +1. Reads the application directory supplied by the Functions host. +2. Selects the application filename from `PYTHON_SCRIPT_FILE_NAME`, or discovers + `function_app.py` and then `app.py`. +3. Imports the module and finds its module-level `FastAPI` instance. Startup + fails if none or more than one is present. +4. Iterates over the app's registered `APIRoute` entries and configured FastAPI + documentation routes. +5. Creates one Functions metadata entry for every route, with an HTTP trigger + and HTTP output binding. +6. Caches the app, route handlers, and generated metadata for invocation. + +The endpoint callable name becomes the Azure Function name. For example, +`async def get_user(...)` is indexed as `get_user`, regardless of its route. +Changing the callable name therefore changes the generated function identity. + +### Invocation + +The Functions host uses the generated metadata to route a request to a function +ID. The runtime looks up the corresponding FastAPI endpoint and passes it to a +custom request/response adapter. + +The current adapter does not execute the FastAPI application as a complete ASGI +application. It inspects the endpoint signature, supplies simple path and query +arguments, optionally supplies a request-like object, calls the endpoint +directly, and converts the result into an Azure Functions HTTP response. This +design proves route discovery and invocation, but it bypasses FastAPI processing +that normally occurs around the endpoint. + +### HTTP v2 transport + +HTTP v2 is enabled by default in the prototype. At worker initialization, the +runtime starts a local Uvicorn server with a Starlette catch-all route and +reports its URI to the Functions host. The host sends the HTTP request to this +local endpoint while sending the corresponding invocation over gRPC. An +in-process coordinator pairs the two paths using the invocation ID. + +This is streaming transport between the host and worker. It does not currently +preserve a FastAPI `StreamingResponse` as an end-to-end streaming response: the +custom response adapter reads response bodies into the runtime's response +format. Server-sent events and other streaming-response scenarios must not yet +be considered supported. + +## Current Support + +The implementation and existing tests establish the following behavior: + +| Area | Prototype status | +| --- | --- | +| Discover a module-level FastAPI app | Implemented | +| Index `APIRoute` routes | Implemented and unit tested | +| OpenAPI, Swagger UI, OAuth redirect, and ReDoc routes | Implemented and unit tested | +| Generate HTTP trigger and output metadata | Implemented and unit tested | +| GET, POST, PUT, DELETE, PATCH, HEAD, and OPTIONS metadata | Implemented | +| Async and sync endpoint calls | Implemented, without end-to-end coverage | +| Simple path and query parameters | Implemented, without end-to-end coverage | +| Basic JSON, text, and Starlette response formatting | Implemented, without end-to-end coverage | +| Full Pydantic request and response validation | Not implemented | +| FastAPI dependency injection | Not implemented | +| Middleware and lifespan events | Not implemented | +| FastAPI exception-handler semantics | Not implemented | +| Background tasks | Not implemented | +| WebSockets | Not supported by the current HTTP-trigger design | +| `StreamingResponse` and server-sent events | Not implemented end to end | + +The sample application declares Pydantic models and several endpoint types, but +the current tests only validate route indexing and metadata conversion. They do +not constitute end-to-end coverage of request handling or FastAPI validation. + +## Configuration and Constraints + +- Python 3.10 or later is required. +- FastAPI 0.100.0 or later is required. +- Exactly one runtime package may be registered in the proxy worker process. +- The application module must contain exactly one module-level `FastAPI` + instance. +- `PYTHON_SCRIPT_FILE_NAME` overrides automatic application-file discovery. +- A route's endpoint callable name is used as its generated Function name, so + endpoint names must be unique. +- The package is classified as alpha and is not currently included in the + repository's official build or release pipelines. + +## Package Layout + +| Component | Responsibility | +| --- | --- | +| `runtime.py` | Implements the runtime-base event contract | +| `handle_event.py` | Handles worker initialization, metadata, load, invocation, and reload events | +| `loader.py` | Imports the customer module and builds Functions metadata | +| `indexer.py` | Discovers FastAPI routes and endpoint callables | +| `converter.py` | Converts routes into HTTP trigger and output bindings | +| `handler.py` | Adapts requests, calls endpoints, and formats responses | +| `http_v2.py` | Runs and coordinates the local HTTP v2 transport | + +## Development + +Install the package and development dependencies from this directory: + +```bash +python -m pip install -e ".[dev]" +python -m pytest tests -v +``` + +The package is a prototype rather than a supported Azure Functions feature. +Production readiness requires running FastAPI through the appropriate ASGI +lifecycle, adding end-to-end host coverage, settling the distribution identity, +and integrating the package into the official build and release process. diff --git a/runtimes/fastapi/azure_functions_fastapi/__init__.py b/runtimes/fastapi/azure_functions_fastapi/__init__.py new file mode 100644 index 000000000..3343a73d8 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/__init__.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +FastAPI Runtime for Azure Functions + +Imports the Runtime class which auto-registers with the base package. +""" +from concurrent.futures import ThreadPoolExecutor +from typing import Optional + +from .runtime import Runtime +from .handle_event import ( + worker_init_request, + functions_metadata_request, + function_environment_reload_request, + invocation_request, + function_load_request +) +from .utils.executor import invocation_id_cv +from .version import VERSION + + +# Threadpool executor stubs - FastAPI runtime is async-only +def start_threadpool_executor() -> None: + """ + No-op for FastAPI runtime (async-only). + + The FastAPI runtime doesn't use a threadpool executor since all + operations are async. This function is provided for API compatibility + with the proxy worker. + """ + pass + + +def stop_threadpool_executor() -> None: + """ + No-op for FastAPI runtime (async-only). + + This function is provided for API compatibility with the proxy worker. + """ + pass + + +def get_threadpool_executor() -> Optional[ThreadPoolExecutor]: + """ + Return None for FastAPI runtime (async-only). + + The FastAPI runtime doesn't use a threadpool executor since all + operations are async. + + Returns: + None + """ + return None + + +# Version namespace for _library_worker.version.VERSION access pattern +class version: + """Version namespace to support _library_worker.version.VERSION access.""" + VERSION = VERSION + + +__all__ = ( + 'Runtime', + 'worker_init_request', + 'functions_metadata_request', + 'function_environment_reload_request', + 'invocation_request', + 'function_load_request', + 'start_threadpool_executor', + 'stop_threadpool_executor', + 'get_threadpool_executor', + 'invocation_id_cv', + 'VERSION', + 'version' +) diff --git a/runtimes/fastapi/azure_functions_fastapi/bindings/__init__.py b/runtimes/fastapi/azure_functions_fastapi/bindings/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/bindings/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/runtimes/fastapi/azure_functions_fastapi/converter.py b/runtimes/fastapi/azure_functions_fastapi/converter.py new file mode 100644 index 000000000..76efba997 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/converter.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +FastAPI to Azure Functions Converter +Converts FastAPI route metadata to Azure Functions function metadata +""" +import typing +import uuid +from typing import Dict, List + +from .indexer import FastAPIFunctionMetadata + + +class AzureFunctionInfo(typing.NamedTuple): + """Azure Function metadata compatible with Python worker""" + name: str + function_id: str + directory: str + script_file: str + entry_point: str + bindings: List[Dict] + is_async: bool + route_path: str + http_methods: List[str] + route_handler: typing.Callable + + +class FastAPIConverter: + """Converts FastAPI routes to Azure Functions metadata""" + + def __init__(self): + self.functions: Dict[str, AzureFunctionInfo] = {} + + def convert_to_azure_functions( + self, + fastapi_functions: List[FastAPIFunctionMetadata] + ) -> List[AzureFunctionInfo]: + """ + Convert FastAPI function metadata to Azure Functions metadata + + Each FastAPI route becomes an HTTP-triggered Azure Function + """ + azure_functions = [] + + for fastapi_func in fastapi_functions: + # Create HTTP trigger binding + http_trigger = { + "name": "req", + "type": "httpTrigger", + "direction": "in", + "authLevel": "anonymous", + "methods": [m.lower() for m in fastapi_func.http_methods], + "route": fastapi_func.route_path.lstrip('/') + } + + # Create HTTP output binding + http_output = { + "name": "$return", + "type": "http", + "direction": "out" + } + + # Create Azure Function info + azure_func = AzureFunctionInfo( + name=fastapi_func.name, + function_id=fastapi_func.function_id, + directory=fastapi_func.directory, + script_file=fastapi_func.function_script_file, + entry_point=fastapi_func.name, + bindings=[http_trigger, http_output], + is_async=fastapi_func.is_async, + route_path=fastapi_func.route_path, + http_methods=fastapi_func.http_methods, + route_handler=fastapi_func.route_handler + ) + + azure_functions.append(azure_func) + self.functions[azure_func.function_id] = azure_func + + return azure_functions + + def get_function(self, function_id: str) -> typing.Optional[AzureFunctionInfo]: + """Get function info by ID""" + return self.functions.get(function_id) diff --git a/runtimes/fastapi/azure_functions_fastapi/handle_event.py b/runtimes/fastapi/azure_functions_fastapi/handle_event.py new file mode 100644 index 000000000..7e4a5bccd --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/handle_event.py @@ -0,0 +1,332 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +FastAPI Runtime Event Handler +Main entry point for handling Azure Functions worker events for FastAPI apps +""" +import asyncio +import json +import logging +import os +import sys +from typing import Dict, List, MutableMapping, Optional + +from fastapi import FastAPI + +from .converter import AzureFunctionInfo, FastAPIConverter +from .handler import execute_fastapi_route +from .http_v2 import ( + HttpServerInitError, + HttpV2Registry, + http_coordinator, + initialize_http_server, +) +from .loader import load_function_metadata +from .utils.constants import ( + HTTP_URI, + PYTHON_SCRIPT_FILE_NAME, + PYTHON_SCRIPT_FILE_NAME_DEFAULT, + PYTHON_SCRIPT_FILE_NAME_FALLBACK, + REQUIRES_ROUTE_PARAMETERS, +) +from .utils.tracing import serialize_exception +from .utils.helpers import get_worker_metadata +from .logging import logger +from .version import VERSION + + +# Module-level state +_converter: Optional[FastAPIConverter] = None +_fastapi_app: Optional[FastAPI] = None +_metadata_result: Optional[List] = None +_function_path: Optional[str] = None +_host: str = "127.0.0.1" +protos = None + + +def _get_function_app_script_file(function_app_directory: str) -> str: + configured_script_file = os.environ.get(PYTHON_SCRIPT_FILE_NAME) + if configured_script_file: + return configured_script_file + + for script_file in ( + PYTHON_SCRIPT_FILE_NAME_DEFAULT, + PYTHON_SCRIPT_FILE_NAME_FALLBACK, + ): + if os.path.isfile(os.path.join(function_app_directory, script_file)): + return script_file + + return PYTHON_SCRIPT_FILE_NAME_DEFAULT + + +async def worker_init_request(request): + """ + Handle WorkerInitRequest - Initialize the FastAPI runtime + + This is called when the worker starts up + """ + logger.info(f"FastAPI Runtime: received WorkerInitRequest, Version {VERSION}") + + global protos, _host + init_request = request.request.worker_init_request + host_capabilities = init_request.capabilities + _host = request.properties.get("host", "127.0.0.1") + protos = request.properties.get("protos") + + # Declare capabilities + capabilities = { + "RawHttpBodyBytes": "true", + "TypedDataCollection": "true", + "RpcHttpBodyOnly": "true", + "WorkerStatus": "true", + "RpcHttpTriggerMetadataRemoved": "true", + } + + # Index in init by default. Fail if an exception occurs. + try: + function_app_directory = init_request.function_app_directory + script_file_name = _get_function_app_script_file( + function_app_directory) + function_path = os.path.join(function_app_directory, script_file_name) + + # Index the FastAPI app + global _fastapi_app, _converter, _metadata_result + _fastapi_app, _metadata_result, _converter = load_function_metadata( + function_path, function_app_directory, protos) + + # Initialize HTTP streaming server if enabled (enabled by default for FastAPI) + try: + if HttpV2Registry.http_v2_enabled(): + logger.info("HTTP streaming enabled for FastAPI runtime") + capabilities[HTTP_URI] = await initialize_http_server(_host, _fastapi_app) + capabilities[REQUIRES_ROUTE_PARAMETERS] = "true" + except HttpServerInitError as ex: + logger.error(f"Failed to initialize HTTP streaming server: {ex}") + return protos.WorkerInitResponse( + capabilities=capabilities, + worker_metadata=get_worker_metadata(protos), + result=protos.StatusResult( + status=protos.StatusResult.Failure, + exception=serialize_exception(ex, protos)) + ) + except Exception as ex: + logger.error(f"Failed to index FastAPI app during init: {ex}", exc_info=True) + return protos.WorkerInitResponse( + capabilities=capabilities, + worker_metadata=get_worker_metadata(protos), + result=protos.StatusResult( + status=protos.StatusResult.Failure, + exception=serialize_exception( + ex, protos)) + ) + + logger.info("Successfully completed WorkerInitRequest") + return protos.WorkerInitResponse( + capabilities=capabilities, + worker_metadata=get_worker_metadata(protos), + result=protos.StatusResult(status=protos.StatusResult.Success) + ) + +async def functions_metadata_request(request): + """ + Handle FunctionMetadataRequest - Return metadata for all discovered FastAPI routes + + This tells the host about all the functions (routes) available in the FastAPI app + """ + metadata_request = request.request.functions_metadata_request + function_app_directory = metadata_request.function_app_directory + script_file_name = _get_function_app_script_file(function_app_directory) + function_path = os.path.join(function_app_directory, script_file_name) + + global _fastapi_app, _converter, _metadata_result + + # If we haven't indexed yet, do it now + if not _metadata_result: + _fastapi_app, _metadata_result, _converter = load_function_metadata( + function_path, function_app_directory, protos) + + if not _metadata_result: + logger.error("No FastAPI functions were discovered") + return protos.FunctionMetadataResponse( + use_default_metadata_indexing=False, + function_metadata_results=[], + result=protos.StatusResult( + status=protos.StatusResult.Failure) + ) + + logger.info(f"Returning metadata for {len(_metadata_result)} FastAPI functions") + for func_metadata in _metadata_result: + logger.info(f" - Function: {func_metadata.name}, Route: {func_metadata.properties.get('FastAPIRoute', 'N/A')}") + logger.info(f" Raw bindings: {func_metadata.raw_bindings}") + + return protos.FunctionMetadataResponse( + use_default_metadata_indexing=False, + function_metadata_results=_metadata_result, + result=protos.StatusResult( + status=protos.StatusResult.Success)) + + +async def function_load_request(request): + """ + Handle FunctionLoadRequest - Load a specific function + + For FastAPI, functions are already "loaded" during indexing, so this is mostly a no-op + """ + logger.info("FastAPI Runtime: received FunctionLoadRequest") + + func_request = request.request.function_load_request + function_id = func_request.function_id + + # Verify the function exists + if _converter: + func_info = _converter.get_function(function_id) + if func_info: + logger.info(f"Function {function_id} loaded: {func_info.route_path}") + return protos.FunctionLoadResponse( + function_id=function_id, + result=protos.StatusResult(status=protos.StatusResult.Success) + ) + + logger.error(f"Function {function_id} not found") + return protos.FunctionLoadResponse( + function_id=function_id, + result=protos.StatusResult( + status=protos.StatusResult.Failure) + ) + + +async def invocation_request(request): + """ + Handle InvocationRequest - Execute a FastAPI route + + This is called when a function is invoked (e.g., HTTP request comes in) + """ + logger.info("FastAPI Runtime: received InvocationRequest") + + invoc_request = request.request.invocation_request + function_id = invoc_request.function_id + invocation_id = invoc_request.invocation_id + + logger.info(f"[Invocation] Function ID: {function_id}, Invocation ID: {invocation_id}") + + # Check if HTTP streaming is enabled + http_v2_enabled = HttpV2Registry.http_v2_enabled() + logger.info(f"[Invocation] HTTP streaming enabled: {http_v2_enabled}") + + try: + # Get the function info + if not _converter: + raise RuntimeError("FastAPI converter not initialized") + + func_info = _converter.get_function(function_id) + if not func_info: + raise RuntimeError(f"Function {function_id} not found") + + logger.info(f"[Invocation] Found function: {func_info.name}, route: {func_info.route_path}") + + # Extract HTTP request + azure_request = None + + if http_v2_enabled: + # Get the HTTP request from the streaming coordinator + logger.info(f"Using HTTP streaming for invocation {invocation_id}") + azure_request = await http_coordinator.get_http_request_async(invocation_id) + else: + # Extract HTTP request from input data (traditional RPC) + for input_data in invoc_request.input_data: + if input_data.data.http: + azure_request = input_data.data.http + break + + if not azure_request: + raise RuntimeError("No HTTP request data found") + + # Execute the FastAPI route + response = await execute_fastapi_route( + app=_fastapi_app, + azure_request=azure_request, + route_handler=func_info.route_handler, + route_path=func_info.route_path, + is_async=func_info.is_async + ) + + if http_v2_enabled: + # For HTTP streaming, convert response to Starlette Response and send via coordinator + from starlette.responses import Response as StarletteResponse + + starlette_response = StarletteResponse( + content=response.get('body', ''), + status_code=response.get('status_code', 200), + headers=response.get('headers', {}) + ) + + http_coordinator.set_http_response(invocation_id, starlette_response) + + # Return empty response - the actual response goes via HTTP + return protos.InvocationResponse( + invocation_id=invocation_id, + result=protos.StatusResult(status=protos.StatusResult.Success) + ) + else: + # Traditional RPC response + http_response = protos.RpcHttp( + status_code=str(response.get('status_code', 200)), + headers=response.get('headers', {}), + body=protos.TypedData(string=response.get('body', '')) + ) + + return protos.InvocationResponse( + invocation_id=invocation_id, + return_value=protos.TypedData(http=http_response), + result=protos.StatusResult(status=protos.StatusResult.Success) + ) + + except Exception as e: + logger.error(f"Error executing function {function_id}: {e}", exc_info=True) + + if http_v2_enabled: + # Send exception via HTTP coordinator + http_coordinator.set_http_response(invocation_id, e) + + return protos.InvocationResponse( + invocation_id=invocation_id, + result=protos.StatusResult( + status=protos.StatusResult.Failure, + exception=serialize_exception(e, protos) + ) + ) + + +async def function_environment_reload_request(request): + """ + Handle FunctionEnvironmentReloadRequest - Reload the environment + + This might be called when the function app needs to reload (e.g., code changes) + """ + logger.info("FastAPI Runtime: received FunctionEnvironmentReloadRequest") + + # Re-index the FastAPI app + try: + reload_request = request.request.function_environment_reload_request + function_app_directory = reload_request.function_app_directory + script_file_name = _get_function_app_script_file( + function_app_directory) + function_path = os.path.join(function_app_directory, script_file_name) + + global _fastapi_app, _converter, _metadata_result + _fastapi_app, _metadata_result, _converter = load_function_metadata( + function_path, function_app_directory, protos) + + return protos.FunctionEnvironmentReloadResponse( + capabilities={}, + worker_metadata=get_worker_metadata(protos), + result=protos.StatusResult( + status=protos.StatusResult.Success)) + except Exception as e: + logger.error(f"Error reloading environment: {e}", exc_info=True) + return protos.FunctionEnvironmentReloadResponse( + result=protos.StatusResult( + status=protos.StatusResult.Failure, + exception=serialize_exception(e, protos) + ) + ) diff --git a/runtimes/fastapi/azure_functions_fastapi/handler.py b/runtimes/fastapi/azure_functions_fastapi/handler.py new file mode 100644 index 000000000..ba4280982 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/handler.py @@ -0,0 +1,347 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +FastAPI Request/Response Handler +Handles execution of FastAPI routes and conversion between Azure Functions and ASGI +""" +import asyncio +import json +import re +import typing +from typing import Any, Dict, List, Optional +from io import BytesIO +from urllib.parse import urlsplit + +from fastapi import FastAPI +from starlette.requests import Request +from starlette.datastructures import Headers, QueryParams + + +class ASGIRequest: + """ASGI-compatible request object built from Azure Functions HTTP request""" + + def __init__(self, azure_request): + self.azure_request = azure_request + self.method = azure_request.method + self.url = azure_request.url + self.headers = dict(azure_request.headers) if azure_request.headers else {} + self.query_params = dict(azure_request.params) if azure_request.params else {} + self.body = azure_request.get_body() if hasattr(azure_request, 'get_body') else b'' + self.route_params = azure_request.route_params if hasattr(azure_request, 'route_params') else {} + + +class FastAPIHandler: + """Handles execution of FastAPI routes in Azure Functions context""" + + def __init__(self, app: FastAPI): + self.app = app + + async def handle_request( + self, + azure_request, + route_handler: typing.Callable, + route_path: str, + is_async: bool + ) -> Dict[str, Any]: + """ + Execute a FastAPI route handler and return Azure Functions-compatible response + + Args: + azure_request: Azure Functions HTTP request + route_handler: The FastAPI route handler function + route_path: The route path pattern (e.g., "/items/{item_id}") + is_async: Whether the handler is async + + Returns: + Dict with status_code, headers, and body for Azure Functions response + """ + try: + # Get the request URL path (convert to string if it's a URL object) + request_url = str(azure_request.url) if hasattr(azure_request.url, '__str__') else azure_request.url + + # Extract path parameters by matching the route pattern + path_params = self._extract_path_params(route_path, request_url) + + # Build ASGI scope for the request + scope = self._build_scope(azure_request, route_path, path_params) + + # Create a Starlette Request object that FastAPI can work with + starlette_request = self._create_starlette_request(azure_request, scope, path_params) + + # Build the arguments to pass to the route handler + # This includes path params, query params, and the Request object if needed + kwargs = await self._build_handler_kwargs( + route_handler, + starlette_request, + path_params + ) + + # Execute the handler + if is_async: + result = await route_handler(**kwargs) + else: + result = route_handler(**kwargs) + + # Convert result to Azure Functions response format + return self._format_response(result) + + except Exception as e: + # Return error response + return { + 'status_code': 500, + 'headers': {'Content-Type': 'application/json'}, + 'body': json.dumps({'error': str(e)}) + } + + def _extract_path_params(self, route_path: str, request_url: str) -> Dict[str, str]: + """ + Extract path parameters from the request URL by matching against the route pattern. + + For example: + route_path = "/items/{item_id}" + request_url = "http://localhost:7071/api/items/123" + returns: {"item_id": "123"} + """ + # Remove /api prefix if present in the request URL + url_path = request_url.split('?')[0] # Remove query string + if '://' in url_path: + # Extract just the path from full URL + url_path = '/' + url_path.split('/', 3)[-1] if url_path.count('/') >= 3 else '/' + + # Remove /api prefix if it exists + if url_path.startswith('/api/'): + url_path = url_path[4:] # Remove '/api' + elif url_path.startswith('/api'): + url_path = url_path[4:] # Remove '/api' + + # If path is empty after stripping prefix, it represents root path + if not url_path: + url_path = '/' + + # Debug logging + from .logging import logger + logger.info(f"[FastAPI Handler] Request URL: {request_url}") + logger.info(f"[FastAPI Handler] Extracted path: {url_path}") + logger.info(f"[FastAPI Handler] Route pattern: {route_path}") + + # Ensure route_path has leading slash + if not route_path.startswith('/'): + route_path = '/' + route_path + + # Convert FastAPI route pattern to regex + # Replace {param} with named capture groups + pattern = re.sub(r'\{([^}]+)\}', r'(?P<\1>[^/]+)', route_path) + pattern = '^' + pattern + '$' + + logger.info(f"[FastAPI Handler] Regex pattern: {pattern}") + + # Match the URL path against the pattern + match = re.match(pattern, url_path) + if match: + logger.info(f"[FastAPI Handler] Path matched! Params: {match.groupdict()}") + return match.groupdict() + + logger.warning(f"[FastAPI Handler] No match! url_path='{url_path}' pattern='{pattern}'") + return {} + + def _build_scope(self, azure_request, route_path: str, path_params: Dict[str, str]) -> Dict[str, Any]: + """Build ASGI scope from Azure Functions request""" + # Get the URL path (convert to string if it's a URL object) + url_str = str(azure_request.url) if hasattr(azure_request.url, '__str__') else azure_request.url + url_path = urlsplit(url_str).path + + root_path = '' + if route_path == '/' and url_path.endswith('/'): + root_path = url_path[:-1].rstrip('/') + elif url_path.endswith(route_path): + root_path = url_path[:-len(route_path)].rstrip('/') + + # Build query string from params + query_string = b'' + if hasattr(azure_request, 'params') and azure_request.params: + query_parts = [f"{k}={v}" for k, v in azure_request.params.items()] + query_string = '&'.join(query_parts).encode('utf-8') + + return { + 'type': 'http', + 'method': azure_request.method.upper(), + 'path': url_path, + 'query_string': query_string, + 'headers': list((k.lower().encode(), v.encode()) for k, v in azure_request.headers.items()) if azure_request.headers else [], + 'server': ('localhost', 80), + 'scheme': 'http', + 'root_path': root_path, + 'path_params': path_params, + } + + def _create_starlette_request(self, azure_request, scope: Dict[str, Any], path_params: Dict[str, str]) -> Request: + """Create a Starlette Request object from Azure Functions request""" + # This creates a minimal Request-like object for FastAPI + class MockRequest: + def __init__(self, azure_req, scope_dict, path_params_dict): + self.method = azure_req.method.upper() if hasattr(azure_req.method, 'upper') else str(azure_req.method).upper() + self.url = str(azure_req.url) if hasattr(azure_req.url, '__str__') else azure_req.url + self.headers = Headers(azure_req.headers if azure_req.headers else {}) + # Handle query_params from Starlette Request or Azure Functions params + if hasattr(azure_req, 'query_params'): + self.query_params = azure_req.query_params + elif hasattr(azure_req, 'params'): + self.query_params = QueryParams(azure_req.params if azure_req.params else {}) + else: + self.query_params = QueryParams({}) + self.path_params = path_params_dict + self.scope = scope_dict + + # Store reference to original request for lazy body loading + self._azure_req = azure_req + self._body_cache = None + + async def body(self): + """Lazily load and cache the request body""" + if self._body_cache is not None: + return self._body_cache + + # Handle different request types + if hasattr(self._azure_req, 'get_body'): + # Azure Functions RPC request + self._body_cache = self._azure_req.get_body() + elif hasattr(self._azure_req, 'body'): + # Starlette Request - body() is async + if callable(self._azure_req.body): + self._body_cache = await self._azure_req.body() + else: + self._body_cache = self._azure_req.body + else: + self._body_cache = b'' + + return self._body_cache + + async def json(self): + """Parse body as JSON""" + body_data = await self.body() + return json.loads(body_data) if body_data else {} + + return MockRequest(azure_request, scope, path_params) + + async def _build_handler_kwargs( + self, + route_handler: typing.Callable, + request: Any, + path_params: Dict[str, str] + ) -> Dict[str, Any]: + """ + Build kwargs for the route handler by inspecting its signature. + This handles path parameters, query parameters, and Request dependencies. + """ + import inspect + + kwargs = {} + sig = inspect.signature(route_handler) + + for param_name, param in sig.parameters.items(): + # Check if this is a path parameter + if param_name in path_params: + # Convert to the correct type if annotation is provided + value = path_params[param_name] + if param.annotation != inspect.Parameter.empty: + try: + # Try to convert to the annotated type (e.g., int, str) + if param.annotation == int: + value = int(value) + elif param.annotation == float: + value = float(value) + elif param.annotation == bool: + value = value.lower() in ('true', '1', 'yes') + except (ValueError, AttributeError): + pass # Keep as string if conversion fails + kwargs[param_name] = value + + # Check if this is a query parameter + elif param_name in request.query_params: + value = request.query_params[param_name] + if param.annotation != inspect.Parameter.empty: + try: + if param.annotation == int: + value = int(value) + elif param.annotation == float: + value = float(value) + elif param.annotation == bool: + value = value.lower() in ('true', '1', 'yes') + except (ValueError, AttributeError): + pass + kwargs[param_name] = value + + # Check if parameter expects the Request object + elif param.annotation == Request or (hasattr(param.annotation, '__name__') and param.annotation.__name__ == 'Request'): + kwargs[param_name] = request + + # Use default value if available and no value provided + elif param.default != inspect.Parameter.empty: + # Don't add to kwargs, let Python use the default + pass + + return kwargs + + def _format_response(self, result: Any) -> Dict[str, Any]: + """ + Format FastAPI response to Azure Functions response format + + Handles various FastAPI return types: + - Dict/List: JSON response + - String: Text response + - FastAPI Response objects: Extract status, headers, body + """ + # If result is already a dict with status_code, assume it's formatted + if isinstance(result, dict) and 'status_code' in result: + return result + + # Handle FastAPI Response objects + if hasattr(result, 'status_code'): + body = result.body if hasattr(result, 'body') else '' + if isinstance(body, bytes): + body = body.decode('utf-8') + + return { + 'status_code': result.status_code, + 'headers': dict(result.headers) if hasattr(result, 'headers') else {}, + 'body': body + } + + # Handle dict/list - return as JSON + if isinstance(result, (dict, list)): + return { + 'status_code': 200, + 'headers': {'Content-Type': 'application/json'}, + 'body': json.dumps(result) + } + + # Handle string + if isinstance(result, str): + return { + 'status_code': 200, + 'headers': {'Content-Type': 'text/plain'}, + 'body': result + } + + # Default: convert to string + return { + 'status_code': 200, + 'headers': {'Content-Type': 'text/plain'}, + 'body': str(result) + } + + +async def execute_fastapi_route( + app: FastAPI, + azure_request, + route_handler: typing.Callable, + route_path: str, + is_async: bool +) -> Dict[str, Any]: + """ + Execute a FastAPI route in response to an Azure Functions invocation + + This is the main entry point called by the proxy worker during function invocation + """ + handler = FastAPIHandler(app) + return await handler.handle_request(azure_request, route_handler, route_path, is_async) diff --git a/runtimes/fastapi/azure_functions_fastapi/http_v2.py b/runtimes/fastapi/azure_functions_fastapi/http_v2.py new file mode 100644 index 000000000..5250b8b4e --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/http_v2.py @@ -0,0 +1,298 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +HTTP v2 Streaming Support for FastAPI Runtime + +This module provides HTTP streaming capabilities, allowing the Azure Functions +host to communicate with the worker via HTTP rather than gRPC for HTTP-triggered +functions. This enables streaming responses and better performance for FastAPI apps. +""" +import abc +import asyncio +import socket +from typing import Any, Dict + +from .logging import logger +from .utils.constants import X_MS_INVOCATION_ID + + +# Http V2 Exceptions +class HttpServerInitError(Exception): + """Exception raised when there is an error during HTTP server initialization.""" + + +class MissingHeaderError(ValueError): + """Exception raised when a required header is missing in the HTTP request.""" + + +class BaseContextReference(abc.ABC): + """ + Base class for context references. + Stores HTTP request/response pairs for each invocation. + """ + def __init__(self, event_class, http_request=None, http_response=None, + function=None, fi_context=None, args=None, + http_trigger_param_name=None): + self._http_request = http_request + self._http_response = http_response + self._function = function + self._fi_context = fi_context + self._args = args + self._http_trigger_param_name = http_trigger_param_name + self._http_request_available_event = event_class() + self._http_response_available_event = event_class() + + @property + def http_request(self): + return self._http_request + + @http_request.setter + def http_request(self, value): + self._http_request = value + self._http_request_available_event.set() + + @property + def http_response(self): + return self._http_response + + @http_response.setter + def http_response(self, value): + self._http_response = value + self._http_response_available_event.set() + + @property + def function(self): + return self._function + + @function.setter + def function(self, value): + self._function = value + + @property + def fi_context(self): + return self._fi_context + + @fi_context.setter + def fi_context(self, value): + self._fi_context = value + + @property + def http_trigger_param_name(self): + return self._http_trigger_param_name + + @http_trigger_param_name.setter + def http_trigger_param_name(self, value): + self._http_trigger_param_name = value + + @property + def args(self): + return self._args + + @args.setter + def args(self, value): + self._args = value + + @property + def http_request_available_event(self): + return self._http_request_available_event + + @property + def http_response_available_event(self): + return self._http_response_available_event + + +class AsyncContextReference(BaseContextReference): + """ + Asynchronous context reference class. + """ + def __init__(self, http_request=None, http_response=None, function=None, + fi_context=None, args=None): + super().__init__(event_class=asyncio.Event, http_request=http_request, + http_response=http_response, + function=function, fi_context=fi_context, args=args) + self.is_async = True + + +class SingletonMeta(type): + """ + Metaclass for implementing the singleton pattern. + """ + _instances: Dict[Any, Any] = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] + + +class HttpCoordinator(metaclass=SingletonMeta): + """ + HTTP coordinator class for managing HTTP v2 requests and responses. + + This coordinates between the HTTP server receiving requests and the + invocation handler processing them. + """ + def __init__(self): + self._context_references: Dict[str, BaseContextReference] = {} + + def set_http_request(self, invoc_id, http_request): + if invoc_id not in self._context_references: + self._context_references[invoc_id] = AsyncContextReference() + context_ref = self._context_references.get(invoc_id) + context_ref.http_request = http_request + + def set_http_response(self, invoc_id, http_response): + if invoc_id not in self._context_references: + raise KeyError("No context reference found for invocation %s" % invoc_id) + context_ref = self._context_references.get(invoc_id) + context_ref.http_response = http_response + + async def get_http_request_async(self, invoc_id): + if invoc_id not in self._context_references: + self._context_references[invoc_id] = AsyncContextReference() + + await self._context_references.get(invoc_id).http_request_available_event.wait() + return self._pop_http_request(invoc_id) + + async def await_http_response_async(self, invoc_id): + if invoc_id not in self._context_references: + raise KeyError("No context reference found for invocation %s" % invoc_id) + + await self._context_references.get(invoc_id).http_response_available_event.wait() + return self._pop_http_response(invoc_id) + + def _pop_http_request(self, invoc_id): + context_ref = self._context_references.get(invoc_id) + request = context_ref.http_request + if request is not None: + context_ref.http_request = None + return request + + raise ValueError("No http request found for invocation %s" % invoc_id) + + def _pop_http_response(self, invoc_id): + context_ref = self._context_references.pop(invoc_id, None) + if context_ref is None: + raise KeyError( + "No context reference found for invocation %s" % invoc_id) + + response = context_ref.http_response + if response is not None: + return response + + raise ValueError("No http response found for invocation %s" % invoc_id) + + +def get_unused_tcp_port(): + """Find an unused TCP port for the HTTP server""" + tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp_socket.bind(("", 0)) + port = tcp_socket.getsockname()[1] + tcp_socket.close() + return port + + +async def initialize_http_server(host_addr: str, fastapi_app) -> str: + """ + Initialize HTTP v2 server for handling HTTP streaming requests. + + This creates a simple HTTP server using Starlette (FastAPI's underlying framework) + that receives HTTP requests from the Azure Functions host and coordinates with + the invocation handler to process them. + + Args: + host_addr: The host address to bind to (e.g., "127.0.0.1") + fastapi_app: The user's FastAPI application instance + + Returns: + The URL of the HTTP server (e.g., "http://127.0.0.1:8080") + """ + try: + from starlette.applications import Starlette + from starlette.responses import Response, JSONResponse + from starlette.routing import Route + import uvicorn + + unused_port = get_unused_tcp_port() + + async def catch_all(request): + """ + Catch-all route that receives HTTP requests from the Azure Functions host. + + The request includes the invocation ID in the X-MS-INVOCATION-ID header. + We store the request and wait for the invocation handler to process it, + then return the response. + """ + invoc_id = request.headers.get(X_MS_INVOCATION_ID) + if invoc_id is None: + raise MissingHeaderError("Header %s not found" % X_MS_INVOCATION_ID) + + logger.info('HTTP streaming: Received HTTP request for invocation %s', invoc_id) + http_coordinator.set_http_request(invoc_id, request) + + # Wait for the invocation handler to process and set the response + http_resp = await http_coordinator.await_http_response_async(invoc_id) + + logger.info('HTTP streaming: Sending HTTP response for invocation %s', invoc_id) + + # If http_resp is an exception, raise it + if isinstance(http_resp, Exception): + raise http_resp + + return http_resp + + # Create a Starlette app with a catch-all route + streaming_app = Starlette( + routes=[ + Route("/{path:path}", catch_all, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]), + ] + ) + + # Configure Uvicorn server + config = uvicorn.Config( + app=streaming_app, + host=host_addr, + port=unused_port, + log_level="info", + access_log=False, + ) + server = uvicorn.Server(config) + + # Run server in background + loop = asyncio.get_event_loop() + loop.create_task(server.serve()) + + web_server_address = f"http://{host_addr}:{unused_port}" + logger.info('HTTP streaming server starting on %s', web_server_address) + + return web_server_address + + except Exception as e: + raise HttpServerInitError("Error initializing HTTP server: %s" % e) from e + + +class HttpV2Registry: + """ + HTTP v2 registry class for managing HTTP v2 streaming state. + + For FastAPI runtime, we always enable HTTP streaming by default. + """ + _http_v2_enabled = True # Always enabled for FastAPI runtime + _http_v2_enabled_checked = True + + @classmethod + def http_v2_enabled(cls, **kwargs): + """Check if HTTP v2 streaming is enabled (always True for FastAPI)""" + logger.debug("HTTP streaming enabled: %s", cls._http_v2_enabled) + return cls._http_v2_enabled + + @classmethod + def set_http_v2_enabled(cls, enabled: bool): + """Allow programmatic enabling/disabling of HTTP streaming""" + cls._http_v2_enabled = enabled + logger.info("HTTP streaming set to: %s", enabled) + + +# Global singleton instance +http_coordinator = HttpCoordinator() + diff --git a/runtimes/fastapi/azure_functions_fastapi/indexer.py b/runtimes/fastapi/azure_functions_fastapi/indexer.py new file mode 100644 index 000000000..d80584e06 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/indexer.py @@ -0,0 +1,172 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +FastAPI Indexer - Discovers FastAPI routes and converts them to Azure Functions metadata +""" +import importlib +import inspect +import os.path +import pathlib +import sys +import typing +from typing import Dict, List, Optional + +import fastapi.routing as fastapi_routing +from fastapi import FastAPI +from fastapi.routing import APIRoute +from starlette.routing import Route + +from .utils.constants import PYTHON_SCRIPT_FILE_NAME_DEFAULT + + +def _iter_effective_routes(routes): + route_iterator = getattr( + fastapi_routing, "_iter_routes_with_context", None) + if route_iterator is None: + for route in routes: + yield route, None + return + + yield from route_iterator(routes) + + +class FastAPIFunctionMetadata(typing.NamedTuple): + """Metadata for a function generated from a FastAPI route""" + name: str + function_id: str + route_path: str + http_methods: List[str] + function_script_file: str + directory: str + route_handler: typing.Callable + is_async: bool + + +class FastAPIIndexer: + """Indexes a FastAPI application and generates function metadata""" + + def __init__( + self, + fastapi_app: FastAPI, + function_script_file: str = PYTHON_SCRIPT_FILE_NAME_DEFAULT, + ): + self.app = fastapi_app + self.function_script_file = function_script_file + self.functions: List[FastAPIFunctionMetadata] = [] + + def index_routes(self) -> List[FastAPIFunctionMetadata]: + """ + Scan all routes in the FastAPI app and create function metadata + for each route that will be converted to an Azure Function + """ + functions = [] + documentation_paths = { + path + for path in ( + self.app.openapi_url, + self.app.docs_url, + self.app.redoc_url, + self.app.swagger_ui_oauth2_redirect_url, + ) + if path is not None + } + + for route, route_context in _iter_effective_routes(self.app.routes): + is_documentation_route = ( + not isinstance(route, APIRoute) + and isinstance(route, Route) + and route.path in documentation_paths + ) + if isinstance(route, APIRoute) or is_documentation_route: + # Generate a unique function name from the route + function_name = ( + f"fastapi_{route.name}" + if is_documentation_route + else self._generate_function_name(route) + ) + + # Get HTTP methods for this route + http_methods = list(route.methods) + route_path = ( + route_context.path if route_context else route.path) + route_handler = ( + route_context.endpoint if route_context else route.endpoint) + + # Create metadata for this route + metadata = FastAPIFunctionMetadata( + name=function_name, + function_id=function_name, # Using name as ID for now + route_path=route_path, + http_methods=http_methods, + function_script_file=self.function_script_file, + directory=os.getcwd(), + route_handler=route_handler, + is_async=inspect.iscoroutinefunction(route_handler) + ) + + functions.append(metadata) + + self.functions = functions + return functions + + def _generate_function_name(self, route: APIRoute) -> str: + """ + Get the function name from the route's endpoint function. + + Uses the actual function name defined by the developer, e.g.: + @app.get("/users/{id}") + async def get_user_by_id(id: int): # <- Uses "get_user_by_id" + """ + # Use the actual function name from the endpoint + if route.endpoint and hasattr(route.endpoint, '__name__'): + return route.endpoint.__name__ + + # Fallback: generate from path if endpoint name not available + path = route.path.strip('/') + path = path.replace('/', '_').replace('{', '').replace('}', '') + path = path.replace('-', '_') + + method = list(route.methods)[0].lower() if route.methods else 'http' + + if path: + return f"{method}_{path}" + else: + return f"{method}_root" + + +def index_fastapi_app(function_path: str) -> List[FastAPIFunctionMetadata]: + """ + Index a FastAPI application from the given module path + + Args: + function_path: Path to the Python module containing the FastAPI app + + Returns: + List of FastAPIFunctionMetadata for each route in the app + """ + module_name = pathlib.Path(function_path).stem + imported_module = importlib.import_module(module_name) + + # Find the FastAPI app instance + app: Optional[FastAPI] = None + for attr_name in dir(imported_module): + attr = getattr(imported_module, attr_name, None) + if isinstance(attr, FastAPI): + if not app: + app = attr + else: + raise ValueError( + "More than one FastAPI app instance found. " + "Please ensure only one FastAPI() instance is defined at the module level." + ) + + if not app: + raise ValueError( + f"Could not find FastAPI app instance in {function_path}. " + "Please ensure you have created a FastAPI() instance." + ) + + # Index all routes + indexer = FastAPIIndexer( + app, function_script_file=pathlib.Path(function_path).name) + return indexer.index_routes() diff --git a/runtimes/fastapi/azure_functions_fastapi/loader.py b/runtimes/fastapi/azure_functions_fastapi/loader.py new file mode 100644 index 000000000..a248e4795 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/loader.py @@ -0,0 +1,268 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +FastAPI Loader - Indexes FastAPI applications and generates Azure Functions metadata +""" +import importlib +import os.path +import pathlib +import sys +from typing import Dict, List, Tuple + +from fastapi import FastAPI + +from .converter import FastAPIConverter +from .indexer import index_fastapi_app +from .logging import logger +from .utils.constants import ( + METADATA_PROPERTIES_WORKER_INDEXED, + PYTHON_LANGUAGE_RUNTIME, + PYTHON_SCRIPT_FILE_NAME, + PYTHON_SCRIPT_FILE_NAME_DEFAULT, +) +from .utils.app_setting_manager import get_app_setting +from .utils.wrappers import attach_message_to_exception + + +def build_binding_protos(protos, func_info) -> Dict: + """ + Build protobuf binding metadata for a FastAPI route + + For FastAPI, all functions are HTTP triggered, so we create: + - An HTTP trigger binding (input) + - An HTTP output binding (output) + """ + binding_protos = {} + + for binding in func_info.bindings: + # Map string direction to protobuf enum value + if binding['direction'] == 'in': + direction = 0 # BindingInfo.Direction.in + elif binding['direction'] == 'out': + direction = 1 # BindingInfo.Direction.out + elif binding['direction'] == 'inout': + direction = 2 # BindingInfo.Direction.inout + else: + direction = 0 # Default to 'in' + + binding_protos[binding['name']] = protos.BindingInfo( + type=binding['type'], + direction=direction + ) + + return binding_protos + + +def build_raw_bindings(func_info) -> List[str]: + """ + Build raw bindings as a list of JSON strings for FastAPI function + + Each binding becomes a separate JSON string in the list, matching + the format expected by the Azure Functions host. + + Returns: + List of JSON strings, one per binding + """ + import json + + raw_bindings = [] + for binding in func_info.bindings: + raw_binding = { + "name": binding['name'], + "type": binding['type'], + "direction": binding['direction'].upper() # Direction must be uppercase: IN, OUT, INOUT + } + + # Add HTTP-specific properties for trigger + if binding['type'] == 'httpTrigger': + raw_binding["authLevel"] = "ANONYMOUS" # Uppercase to match v2 runtime + raw_binding["methods"] = [m.lower() for m in func_info.http_methods] + # For Azure Functions, omit 'route' key entirely for root path + # Setting route to empty string doesn't work - the host won't match it + route = func_info.route_path.lstrip('/') + if route: # Only set route if it's not empty (not root path) + raw_binding["route"] = route + + # Each binding becomes a separate JSON string + raw_bindings.append(json.dumps(raw_binding)) + + return raw_bindings + + +def process_indexed_function(protos, fastapi_app: FastAPI, + azure_functions, function_dir: str) -> Tuple[List, Dict]: + """ + Process indexed FastAPI functions and generate RpcFunctionMetadata + + This converts FastAPI routes into Azure Functions metadata that matches + the structure expected by the host. + + Args: + protos: Protobuf definitions module + fastapi_app: The FastAPI application instance + azure_functions: List of AzureFunctionInfo from converter + function_dir: The function app directory path + + Returns: + Tuple of (metadata_results, bindings_logs) + - metadata_results: List of RpcFunctionMetadata protobuf objects + - bindings_logs: Dict mapping functions to their binding logs + """ + fx_metadata_results = [] + fx_bindings_logs = {} + + for func_info in azure_functions: + # Build binding protobuf metadata + binding_protos = build_binding_protos(protos, func_info) + + # Build raw bindings JSON + raw_bindings = build_raw_bindings(func_info) + + # Create RpcFunctionMetadata matching v2 runtime structure + function_metadata = protos.RpcFunctionMetadata( + name=func_info.name, + function_id=func_info.function_id, + managed_dependency_enabled=False, # Not applicable for FastAPI + directory=function_dir, + script_file=func_info.script_file, + entry_point=func_info.entry_point, + is_proxy=False, # Not supported in V4 + language=PYTHON_LANGUAGE_RUNTIME, + bindings=binding_protos, + raw_bindings=raw_bindings, + retry_options=None, # FastAPI doesn't use retry policies at function level + properties={ + METADATA_PROPERTIES_WORKER_INDEXED: "True", + "FastAPIRoute": func_info.route_path, + "HttpMethods": ",".join(func_info.http_methods) + } + ) + + fx_metadata_results.append(function_metadata) + + # Create binding logs for debugging + bindings_log = {} + for binding in func_info.bindings: + bindings_log[binding['name']] = { + "type": binding['type'], + "direction": binding['direction'] + } + fx_bindings_logs[func_info.name] = bindings_log + + return fx_metadata_results, fx_bindings_logs + + +@attach_message_to_exception( + expt_type=(ImportError, ModuleNotFoundError), + message="Cannot find module. Please check the requirements.txt file for the " + "missing module. Current sys.path: " + " ".join(sys.path), + debug_logs="Error when indexing FastAPI app. Sys Path:" + " ".join(sys.path)) +def index_function_app_fastapi(function_path: str) -> Tuple[FastAPI, List]: + """ + Index a FastAPI application and return the app instance and discovered routes + + Args: + function_path: Path to the Python module containing the FastAPI app + + Returns: + Tuple of (fastapi_app, fastapi_functions) + - fastapi_app: The FastAPI application instance + - fastapi_functions: List of FastAPIFunctionMetadata for each route + + Raises: + ValueError: If no FastAPI app is found or multiple apps are defined + ImportError/ModuleNotFoundError: If the module cannot be imported + """ + module_name = pathlib.Path(function_path).stem + imported_module = importlib.import_module(module_name) + + # Find the FastAPI app instance + app: FastAPI = None + for attr_name in dir(imported_module): + attr = getattr(imported_module, attr_name, None) + if isinstance(attr, FastAPI): + if not app: + app = attr + else: + raise ValueError( + "More than one FastAPI app instance found. " + "Please ensure only one FastAPI() instance is defined at the module level." + ) + + if not app: + script_file_name = get_app_setting( + setting=PYTHON_SCRIPT_FILE_NAME, + default_value=PYTHON_SCRIPT_FILE_NAME_DEFAULT) + raise ValueError( + f"Could not find FastAPI app instance in {script_file_name}. " + "Please ensure you have created a FastAPI() instance." + ) + + # Index all routes in the FastAPI app + fastapi_functions = index_fastapi_app(function_path) + + logger.info(f"Successfully indexed FastAPI app with {len(fastapi_functions)} routes") + + return app, fastapi_functions + + +def load_function_metadata(function_path: str, function_dir: str, protos) -> Tuple[FastAPI, List]: + """ + Load and index a FastAPI application, converting routes to Azure Functions metadata + + This is the main entry point for indexing a FastAPI app. It: + 1. Discovers the FastAPI app instance + 2. Indexes all routes + 3. Converts routes to Azure Functions + 4. Generates RpcFunctionMetadata for the host + + Args: + function_path: Path to the Python module containing the FastAPI app + function_dir: Directory containing the function app + protos: Protobuf definitions module + + Returns: + Tuple of (fastapi_app, metadata_results) + - fastapi_app: The FastAPI application instance + - metadata_results: List of RpcFunctionMetadata protobuf objects + """ + function_app_directory = os.path.dirname(os.path.abspath(function_path)) + if function_app_directory not in sys.path: + sys.path.insert(0, function_app_directory) + + logger.info(f"Indexing FastAPI app from {function_path}") + + # Index the FastAPI app and get the app instance + fastapi_app, fastapi_functions = index_function_app_fastapi(function_path) + + logger.info(f"Discovered {len(fastapi_functions)} FastAPI routes") + + # Convert FastAPI routes to Azure Functions + converter = FastAPIConverter() + azure_functions = converter.convert_to_azure_functions(fastapi_functions) + + # Generate RpcFunctionMetadata for each function + metadata_results, bindings_logs = process_indexed_function( + protos, fastapi_app, azure_functions, function_dir) + + # Log function details + indexed_function_logs: List[str] = [] + for func_info in azure_functions: + bindings_info = ", ".join([ + f"{b['name']}({b['type']})" for b in func_info.bindings + ]) + function_log = ( + f"Function Name: {func_info.name}, " + f"Route: {func_info.route_path}, " + f"Methods: {func_info.http_methods}, " + f"Bindings: [{bindings_info}]" + ) + indexed_function_logs.append(function_log) + + logger.info( + f"Successfully indexed FastAPI app: " + f"function_count={len(metadata_results)}, " + f"functions={'; '.join(indexed_function_logs)}" + ) + + return fastapi_app, metadata_results, converter diff --git a/runtimes/fastapi/azure_functions_fastapi/logging.py b/runtimes/fastapi/azure_functions_fastapi/logging.py new file mode 100644 index 000000000..49be533f6 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/logging.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging.handlers +import traceback + +# Logging Prefixes +SDK_LOG_PREFIX = "azure.functions" + +logger: logging.Logger = logging.getLogger(SDK_LOG_PREFIX) + + +def format_exception(exception: Exception) -> str: + msg = str(exception) + "\n" + msg += ''.join(traceback.format_exception(exception)) + return msg diff --git a/runtimes/fastapi/azure_functions_fastapi/runtime.py b/runtimes/fastapi/azure_functions_fastapi/runtime.py new file mode 100644 index 000000000..630d5bd48 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/runtime.py @@ -0,0 +1,108 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +FastAPI Runtime - Extends the runtime base package + +This runtime implementation provides native FastAPI support for Azure Functions. +It auto-registers with the runtime base when imported. +""" +import contextvars +from concurrent.futures import ThreadPoolExecutor +from typing import Optional + +from azurefunctions.extensions.base import RuntimeBase +from .handle_event import ( + worker_init_request, + functions_metadata_request, + function_environment_reload_request, + invocation_request, + function_load_request +) +from .utils.executor import invocation_id_cv as _invocation_id_cv +from .version import VERSION as _VERSION + +VERSION = _VERSION + + +class Runtime(RuntimeBase): + """ + FastAPI Runtime implementation. + + This class auto-registers with RuntimeTrackerMeta when defined, + allowing the proxy worker to discover it dynamically. + + The FastAPI runtime is async-only and does not use thread pools. + """ + runtime_name = "fastapi" + + @property + def VERSION(self) -> str: + """Get the runtime version string.""" + return _VERSION + + async def worker_init_request(self, request): + return await worker_init_request(request) + + async def functions_metadata_request(self, request): + return await functions_metadata_request(request) + + async def function_load_request(self, request): + return await function_load_request(request) + + async def invocation_request(self, request): + return await invocation_request(request) + + async def function_environment_reload_request(self, request): + return await function_environment_reload_request(request) + + def start_threadpool_executor(self) -> None: + """ + No-op for FastAPI runtime (async-only). + + The FastAPI runtime doesn't use a threadpool executor since all + operations are async. + """ + pass + + def stop_threadpool_executor(self) -> None: + """ + No-op for FastAPI runtime (async-only). + + The FastAPI runtime doesn't use a threadpool executor since all + operations are async. + """ + pass + + def get_threadpool_executor(self) -> Optional[ThreadPoolExecutor]: + """ + Return None for FastAPI runtime (async-only). + + The FastAPI runtime doesn't use a threadpool executor since all + operations are async. + + Returns: + None + """ + return None + + @property + def invocation_id_cv(self) -> contextvars.ContextVar: + """ + Get the invocation ID context variable. + + Returns: + ContextVar for tracking invocation IDs + """ + return _invocation_id_cv + + +# Export for backward compatibility +__all__ = ( + 'Runtime', + 'worker_init_request', + 'functions_metadata_request', + 'function_environment_reload_request', + 'invocation_request', + 'function_load_request', + 'VERSION' +) diff --git a/runtimes/fastapi/azure_functions_fastapi/utils/__init__.py b/runtimes/fastapi/azure_functions_fastapi/utils/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/utils/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/runtimes/fastapi/azure_functions_fastapi/utils/app_setting_manager.py b/runtimes/fastapi/azure_functions_fastapi/utils/app_setting_manager.py new file mode 100644 index 000000000..2463b5935 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/utils/app_setting_manager.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""App setting manager for FastAPI runtime""" +import os +from typing import Optional + + +def get_app_setting(setting: str, default_value: Optional[str] = None) -> str: + """ + Get an application setting from environment variables + + Args: + setting: The name of the setting to retrieve + default_value: Default value if setting is not found + + Returns: + The setting value or default_value + """ + return os.environ.get(setting, default_value) diff --git a/runtimes/fastapi/azure_functions_fastapi/utils/constants.py b/runtimes/fastapi/azure_functions_fastapi/utils/constants.py new file mode 100644 index 000000000..0db1fa5ac --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/utils/constants.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import sys + +# Constants for Azure Functions Python Worker +CUSTOMER_PACKAGES_PATH = "/home/site/wwwroot/.python_packages/lib/site" \ + "-packages" +HTTP = "http" +HTTP_TRIGGER = "httpTrigger" +METADATA_PROPERTIES_WORKER_INDEXED = "worker_indexed" +MODULE_NOT_FOUND_TS_URL = "https://aka.ms/functions-modulenotfound" +PYTHON_LANGUAGE_RUNTIME = "python" +RETRY_POLICY = "retry_policy" +SERVICE_BUS_CLIENT_NAME = "serviceBusClient" +TRUE = "true" +TRACEPARENT = "traceparent" +TRACESTATE = "tracestate" +X_MS_INVOCATION_ID = "x-ms-invocation-id" + + +# Capabilities +FUNCTION_DATA_CACHE = "FunctionDataCache" +HTTP_URI = "HttpUri" +RAW_HTTP_BODY_BYTES = "RawHttpBodyBytes" +REQUIRES_ROUTE_PARAMETERS = "RequiresRouteParameters" +RPC_HTTP_BODY_ONLY = "RpcHttpBodyOnly" +RPC_HTTP_TRIGGER_METADATA_REMOVED = "RpcHttpTriggerMetadataRemoved" +SHARED_MEMORY_DATA_TRANSFER = "SharedMemoryDataTransfer" +TYPED_DATA_COLLECTION = "TypedDataCollection" +# When this capability is enabled, logs are not piped back to the +# host from the worker. Logs will directly go to where the user has +# configured them to go. This is to ensure that the logs are not +# duplicated. +WORKER_OPEN_TELEMETRY_ENABLED = "WorkerOpenTelemetryEnabled" +WORKER_STATUS = "WorkerStatus" + + +# Platform Environment Variables +AZURE_WEBJOBS_SCRIPT_ROOT = "AzureWebJobsScriptRoot" +CONTAINER_NAME = "CONTAINER_NAME" + + +# Python Specific Feature Flags and App Settings +# Appsetting to specify AppInsights connection string +APPLICATIONINSIGHTS_CONNECTION_STRING = "APPLICATIONINSIGHTS_CONNECTION_STRING" +# Appsetting to turn on ApplicationInsights support/features +# A value of "true" enables the setting +PYTHON_APPLICATIONINSIGHTS_ENABLE_TELEMETRY = \ + "PYTHON_APPLICATIONINSIGHTS_ENABLE_TELEMETRY" +# Appsetting to specify root logger name of logger to collect telemetry for +# Used by Azure monitor distro (Application Insights) +PYTHON_APPLICATIONINSIGHTS_LOGGER_NAME = "PYTHON_APPLICATIONINSIGHTS_LOGGER_NAME" +PYTHON_APPLICATIONINSIGHTS_LOGGER_NAME_DEFAULT = "" +PYTHON_ENABLE_DEBUG_LOGGING = "PYTHON_ENABLE_DEBUG_LOGGING" +# Appsetting to turn on OpenTelemetry support/features +# A value of "true" enables the setting +PYTHON_ENABLE_OPENTELEMETRY = "PYTHON_ENABLE_OPENTELEMETRY" +# Allows for non-default script file name +PYTHON_SCRIPT_FILE_NAME = "PYTHON_SCRIPT_FILE_NAME" +PYTHON_SCRIPT_FILE_NAME_DEFAULT = "function_app.py" +PYTHON_SCRIPT_FILE_NAME_FALLBACK = "app.py" +PYTHON_THREADPOOL_THREAD_COUNT = "PYTHON_THREADPOOL_THREAD_COUNT" +PYTHON_THREADPOOL_THREAD_COUNT_DEFAULT = 1 +PYTHON_THREADPOOL_THREAD_COUNT_MAX = sys.maxsize +PYTHON_THREADPOOL_THREAD_COUNT_MIN = 1 diff --git a/runtimes/fastapi/azure_functions_fastapi/utils/executor.py b/runtimes/fastapi/azure_functions_fastapi/utils/executor.py new file mode 100644 index 000000000..16be039c3 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/utils/executor.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +Execution utilities for FastAPI runtime. + +Provides invocation ID tracking via ContextVar for async execution. +""" +import contextvars + +# ContextVar for tracking invocation IDs across async contexts +# This is used by the proxy worker for logging and telemetry correlation +invocation_id_cv = contextvars.ContextVar('invocation_id', default=None) diff --git a/runtimes/fastapi/azure_functions_fastapi/utils/helpers.py b/runtimes/fastapi/azure_functions_fastapi/utils/helpers.py new file mode 100644 index 000000000..b9c95f4a9 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/utils/helpers.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import platform +import sys + +from .constants import PYTHON_LANGUAGE_RUNTIME +from ..version import VERSION + +def get_worker_metadata(protos): + return protos.WorkerMetadata( + runtime_name=PYTHON_LANGUAGE_RUNTIME, + runtime_version=str(sys.version_info.major) + "." + str(sys.version_info.minor), + worker_version=VERSION, + worker_bitness=platform.machine(), + custom_properties={}) \ No newline at end of file diff --git a/runtimes/fastapi/azure_functions_fastapi/utils/tracing.py b/runtimes/fastapi/azure_functions_fastapi/utils/tracing.py new file mode 100644 index 000000000..0561a7bfa --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/utils/tracing.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Tracing utilities for FastAPI runtime""" +import traceback + + +def serialize_exception(exc: Exception, protos): + """ + Serialize an exception to protobuf format + + Args: + exc: The exception to serialize + protos: The protobuf module + + Returns: + RpcException protobuf object + """ + tb = ''.join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + + return protos.RpcException( + message=str(exc), + stack_trace=tb + ) diff --git a/runtimes/fastapi/azure_functions_fastapi/utils/wrappers.py b/runtimes/fastapi/azure_functions_fastapi/utils/wrappers.py new file mode 100644 index 000000000..06010d2f3 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/utils/wrappers.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Wrapper utilities for FastAPI runtime""" +import functools +from typing import Type, Union + + +def attach_message_to_exception(expt_type: Union[Type[Exception], tuple], + message: str, + debug_logs: str = ""): + """ + Decorator to attach additional context to exceptions + + Args: + expt_type: Exception type or tuple of exception types to catch + message: Message to append to the exception + debug_logs: Additional debug information + + Returns: + Decorated function + """ + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except expt_type as e: + # Append additional context to the exception message + enhanced_message = f"{str(e)}\n{message}" + if debug_logs: + enhanced_message += f"\n{debug_logs}" + + # Re-raise with enhanced message + raise type(e)(enhanced_message) from e + + return wrapper + return decorator diff --git a/runtimes/fastapi/azure_functions_fastapi/version.py b/runtimes/fastapi/azure_functions_fastapi/version.py new file mode 100644 index 000000000..0a5f8c951 --- /dev/null +++ b/runtimes/fastapi/azure_functions_fastapi/version.py @@ -0,0 +1,4 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +VERSION = "0.7.0" diff --git a/runtimes/fastapi/pyproject.toml b/runtimes/fastapi/pyproject.toml new file mode 100644 index 000000000..3b7ef7a9a --- /dev/null +++ b/runtimes/fastapi/pyproject.toml @@ -0,0 +1,72 @@ +[project] +name = "victorias-fastapi-test" +dynamic = ["version"] +requires-python = ">=3.10" +description = "FastAPI Runtime for Azure Functions Python Worker" +authors = [ + { name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com" } +] +keywords = ["azure", "functions", "azurefunctions", + "python", "serverless", "fastapi"] +license = { name = "MIT", file = "LICENSE" } +readme = { file = "README.md", content-type = "text/markdown" } +classifiers = [ + "Development Status :: 3 - Alpha", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: MacOS :: MacOS X", + "Environment :: Web Environment", + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers" +] +dependencies = [ + "azure-functions", + "azurefunctions-extensions-base>=1.3.0b1", + "fastapi>=0.100.0", + "uvicorn>=0.20.0", # ASGI server for HTTP streaming + "starlette>=0.27.0", # FastAPI's underlying framework +] + +[project.urls] +Documentation = "https://github.com/Azure/azure-functions-python-worker/blob/dev/runtimes/fastapi/README.md" +Repository = "https://github.com/Azure/azure-functions-python-worker" + +[project.optional-dependencies] +dev = [ + "flake8==6.*", + "mypy", + "pytest", + "pytest-asyncio", + "httpx", # For FastAPI testing + "requests==2.*", + "coverage", + "pytest-sugar", + "pytest-cov", + "pytest-xdist", + "pytest-randomly", + "pytest-instafail", + "pytest-rerunfailures", +] + +[build-system] +requires = ["setuptools>=61.0", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["azure_functions_fastapi"] + +[tool.setuptools.dynamic] +version = {attr = "azure_functions_fastapi.version.VERSION"} + +[tool.setuptools.package-data] +azure_functions_fastapi = ["py.typed"] + +[project.entry-points."azurefunctions.runtimes"] +fastapi = "azure_functions_fastapi:Runtime" diff --git a/runtimes/fastapi/pytest.ini b/runtimes/fastapi/pytest.ini new file mode 100644 index 000000000..42e5fadde --- /dev/null +++ b/runtimes/fastapi/pytest.ini @@ -0,0 +1,30 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +asyncio_mode = auto + +# Show test output +addopts = + -v + --tb=short + --strict-markers + +markers = + unit: Unit tests + integration: Integration tests + slow: Slow-running tests + +# Coverage settings (if using pytest-cov) +[coverage:run] +source = azure_functions_fastapi + +[coverage:report] +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: + if TYPE_CHECKING: diff --git a/runtimes/fastapi/requirements.txt b/runtimes/fastapi/requirements.txt new file mode 100644 index 000000000..3fdb69c81 --- /dev/null +++ b/runtimes/fastapi/requirements.txt @@ -0,0 +1,2 @@ +# Required dependencies listed in pyproject.toml +. diff --git a/runtimes/fastapi/tests/__init__.py b/runtimes/fastapi/tests/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /dev/null +++ b/runtimes/fastapi/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/runtimes/fastapi/tests/example_app.py b/runtimes/fastapi/tests/example_app.py new file mode 100644 index 000000000..58aa1a043 --- /dev/null +++ b/runtimes/fastapi/tests/example_app.py @@ -0,0 +1,110 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +Example FastAPI application for testing the FastAPI runtime +""" +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +from typing import List, Optional + +# Create FastAPI app +app = FastAPI(title="Example FastAPI on Azure Functions") + + +# Models +class Item(BaseModel): + id: Optional[int] = None + name: str + description: Optional[str] = None + price: float + + +class User(BaseModel): + username: str + email: str + + +# In-memory storage +items_db: List[Item] = [] +users_db: List[User] = [] + + +# Routes +@app.get("/") +async def root(): + """Root endpoint""" + return { + "message": "Welcome to FastAPI on Azure Functions!", + "version": "1.0.0" + } + + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy"} + + +@app.get("/items") +async def list_items(): + """List all items""" + return {"items": items_db, "count": len(items_db)} + + +@app.get("/items/{item_id}") +async def get_item(item_id: int): + """Get a specific item by ID""" + for item in items_db: + if item.id == item_id: + return item + raise HTTPException(status_code=404, detail="Item not found") + + +@app.post("/items") +async def create_item(item: Item): + """Create a new item""" + if item.id is None: + item.id = len(items_db) + 1 + items_db.append(item) + return {"status": "created", "item": item} + + +@app.put("/items/{item_id}") +async def update_item(item_id: int, item: Item): + """Update an existing item""" + for idx, existing_item in enumerate(items_db): + if existing_item.id == item_id: + item.id = item_id + items_db[idx] = item + return {"status": "updated", "item": item} + raise HTTPException(status_code=404, detail="Item not found") + + +@app.delete("/items/{item_id}") +async def delete_item(item_id: int): + """Delete an item""" + for idx, item in enumerate(items_db): + if item.id == item_id: + items_db.pop(idx) + return {"status": "deleted", "item_id": item_id} + raise HTTPException(status_code=404, detail="Item not found") + + +@app.get("/users") +async def list_users(): + """List all users""" + return {"users": users_db, "count": len(users_db)} + + +@app.post("/users") +async def create_user(user: User): + """Create a new user""" + users_db.append(user) + return {"status": "created", "user": user} + + +# This demonstrates a synchronous route (less common in FastAPI but supported) +@app.get("/sync-example") +def sync_route(): + """Example of a synchronous route""" + return {"type": "sync", "message": "This is a synchronous route"} diff --git a/runtimes/fastapi/tests/fixtures/modular_app/app/routers/items.py b/runtimes/fastapi/tests/fixtures/modular_app/app/routers/items.py new file mode 100644 index 000000000..cfb02c7ee --- /dev/null +++ b/runtimes/fastapi/tests/fixtures/modular_app/app/routers/items.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.schemas import Item + + +router = APIRouter() + + +@router.get("/") +async def list_items(): + return [] + + +@router.post("/") +async def create_item(item: Item): + return item diff --git a/runtimes/fastapi/tests/fixtures/modular_app/app/routers/root.py b/runtimes/fastapi/tests/fixtures/modular_app/app/routers/root.py new file mode 100644 index 000000000..29480ae6f --- /dev/null +++ b/runtimes/fastapi/tests/fixtures/modular_app/app/routers/root.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + + +router = APIRouter() + + +@router.get("/") +async def get_root(): + return {"message": "FastAPI on Azure Functions"} diff --git a/runtimes/fastapi/tests/fixtures/modular_app/app/routers/unregistered.py b/runtimes/fastapi/tests/fixtures/modular_app/app/routers/unregistered.py new file mode 100644 index 000000000..42ad99504 --- /dev/null +++ b/runtimes/fastapi/tests/fixtures/modular_app/app/routers/unregistered.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + + +router = APIRouter() + + +@router.get("/unregistered") +async def unregistered_route(): + return {"registered": False} diff --git a/runtimes/fastapi/tests/fixtures/modular_app/app/routers/users.py b/runtimes/fastapi/tests/fixtures/modular_app/app/routers/users.py new file mode 100644 index 000000000..e036d3cc6 --- /dev/null +++ b/runtimes/fastapi/tests/fixtures/modular_app/app/routers/users.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter + +from app.schemas import User + + +profile_router = APIRouter(prefix="/{user_id}/profile") + + +@profile_router.get("/") +async def get_user_profile(user_id: int): + return {"user_id": user_id} + + +router = APIRouter() + + +@router.post("/") +async def create_user(user: User): + return user + + +router.include_router(profile_router) diff --git a/runtimes/fastapi/tests/fixtures/modular_app/app/schemas.py b/runtimes/fastapi/tests/fixtures/modular_app/app/schemas.py new file mode 100644 index 000000000..c9747df0a --- /dev/null +++ b/runtimes/fastapi/tests/fixtures/modular_app/app/schemas.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class Item(BaseModel): + name: str + + +class User(BaseModel): + name: str diff --git a/runtimes/fastapi/tests/fixtures/modular_app/function_app.py b/runtimes/fastapi/tests/fixtures/modular_app/function_app.py new file mode 100644 index 000000000..e34d60cd4 --- /dev/null +++ b/runtimes/fastapi/tests/fixtures/modular_app/function_app.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI + +from app.routers import items, root, users + + +app = FastAPI(openapi_url=None) +app.include_router(root.router) +app.include_router(items.router, prefix="/items") +app.include_router(users.router, prefix="/users") diff --git a/runtimes/fastapi/tests/test_app_discovery.py b/runtimes/fastapi/tests/test_app_discovery.py new file mode 100644 index 000000000..a4b23c6a3 --- /dev/null +++ b/runtimes/fastapi/tests/test_app_discovery.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import pytest +from fastapi import FastAPI + +from azure_functions_fastapi.handle_event import ( + _get_function_app_script_file, +) +from azure_functions_fastapi.indexer import FastAPIIndexer +from azure_functions_fastapi.utils.constants import PYTHON_SCRIPT_FILE_NAME + + +@pytest.mark.parametrize( + ("existing_files", "expected"), + [ + ([], "function_app.py"), + (["app.py"], "app.py"), + (["function_app.py"], "function_app.py"), + (["app.py", "function_app.py"], "function_app.py"), + ], +) +def test_get_function_app_script_file( + tmp_path, monkeypatch, existing_files, expected +): + monkeypatch.delenv(PYTHON_SCRIPT_FILE_NAME, raising=False) + for file_name in existing_files: + (tmp_path / file_name).touch() + + assert _get_function_app_script_file(str(tmp_path)) == expected + + +def test_configured_script_file_takes_precedence(tmp_path, monkeypatch): + (tmp_path / "function_app.py").touch() + (tmp_path / "app.py").touch() + monkeypatch.setenv(PYTHON_SCRIPT_FILE_NAME, "main.py") + + assert _get_function_app_script_file(str(tmp_path)) == "main.py" + + +def test_indexer_uses_selected_script_file(): + app = FastAPI() + + @app.get("/") + def root(): + return {"message": "root"} + + functions = FastAPIIndexer( + app, function_script_file="app.py").index_routes() + + assert functions[0].function_script_file == "app.py" diff --git a/runtimes/fastapi/tests/test_converter.py b/runtimes/fastapi/tests/test_converter.py new file mode 100644 index 000000000..08866f709 --- /dev/null +++ b/runtimes/fastapi/tests/test_converter.py @@ -0,0 +1,99 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +Test FastAPI Converter +""" +import pytest + +from azure_functions_fastapi.converter import FastAPIConverter +from azure_functions_fastapi.indexer import FastAPIIndexer +from fastapi import FastAPI + + +def test_converter_creates_azure_functions(): + """Test that converter creates proper Azure Functions metadata""" + app = FastAPI(openapi_url=None) + + @app.get("/api/hello") + def hello(): + return {"message": "hello"} + + # Index and convert + indexer = FastAPIIndexer(app) + fastapi_functions = indexer.index_routes() + + converter = FastAPIConverter() + azure_functions = converter.convert_to_azure_functions(fastapi_functions) + + assert len(azure_functions) == 1 + func = azure_functions[0] + + # Check basic properties + assert func.name == "hello" + assert func.route_path == "/api/hello" + assert func.http_methods == ["GET"] + + # Check bindings + assert len(func.bindings) == 2 + + # HTTP trigger binding + trigger = func.bindings[0] + assert trigger['type'] == 'httpTrigger' + assert trigger['direction'] == 'in' + assert trigger['name'] == 'req' + assert 'get' in trigger['methods'] + assert trigger['route'] == 'api/hello' + + # HTTP output binding + output = func.bindings[1] + assert output['type'] == 'http' + assert output['direction'] == 'out' + assert output['name'] == '$return' + + +def test_converter_handles_multiple_methods(): + """Test converter handles routes with multiple HTTP methods""" + app = FastAPI(openapi_url=None) + + @app.api_route("/items", methods=["GET", "POST"]) + def items(): + return {"items": []} + + indexer = FastAPIIndexer(app) + fastapi_functions = indexer.index_routes() + + converter = FastAPIConverter() + azure_functions = converter.convert_to_azure_functions(fastapi_functions) + + func = azure_functions[0] + trigger = func.bindings[0] + + # Should have both methods + assert set(trigger['methods']) == {'get', 'post'} + + +def test_converter_get_function(): + """Test that converter can retrieve functions by ID""" + app = FastAPI(openapi_url=None) + + @app.get("/test") + def test(): + return {} + + indexer = FastAPIIndexer(app) + fastapi_functions = indexer.index_routes() + + converter = FastAPIConverter() + azure_functions = converter.convert_to_azure_functions(fastapi_functions) + + # Should be able to retrieve by function_id + func = converter.get_function(azure_functions[0].function_id) + assert func is not None + assert func.name == "test" + + # Non-existent ID should return None + assert converter.get_function("nonexistent") is None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/runtimes/fastapi/tests/test_documentation_routes.py b/runtimes/fastapi/tests/test_documentation_routes.py new file mode 100644 index 000000000..4083d22e6 --- /dev/null +++ b/runtimes/fastapi/tests/test_documentation_routes.py @@ -0,0 +1,135 @@ +import json + +import pytest +from fastapi import FastAPI + +from azure_functions_fastapi.converter import FastAPIConverter +from azure_functions_fastapi.handler import execute_fastapi_route +from azure_functions_fastapi.indexer import FastAPIIndexer + + +class MockAzureRequest: + method = "GET" + headers = {} + params = {} + route_params = {} + + def __init__(self, url): + self.url = url + + def get_body(self): + return b"" + + +def test_indexes_default_documentation_routes(): + functions = FastAPIIndexer(FastAPI()).index_routes() + + routes = { + (function.name, function.route_path): set(function.http_methods) + for function in functions + } + + assert routes == { + ("fastapi_openapi", "/openapi.json"): {"GET", "HEAD"}, + ("fastapi_swagger_ui_html", "/docs"): {"GET", "HEAD"}, + ( + "fastapi_swagger_ui_redirect", + "/docs/oauth2-redirect", + ): {"GET", "HEAD"}, + ("fastapi_redoc_html", "/redoc"): {"GET", "HEAD"}, + } + + +def test_respects_custom_and_disabled_documentation_routes(): + app = FastAPI( + openapi_url="/schema.json", + docs_url="/swagger", + redoc_url=None, + swagger_ui_oauth2_redirect_url="/swagger/oauth2-redirect", + ) + + functions = FastAPIIndexer(app).index_routes() + + assert {function.route_path for function in functions} == { + "/schema.json", + "/swagger", + "/swagger/oauth2-redirect", + } + + +def test_documentation_function_ids_do_not_collide_with_user_endpoints(): + app = FastAPI() + + @app.get("/custom-openapi") + async def openapi(): + return {"custom": True} + + converter = FastAPIConverter() + functions = converter.convert_to_azure_functions( + FastAPIIndexer(app).index_routes()) + + assert converter.get_function("fastapi_openapi").route_path == \ + "/openapi.json" + assert converter.get_function("openapi").route_path == "/custom-openapi" + assert len(functions) == 5 + + +@pytest.mark.asyncio +async def test_documentation_handlers_use_functions_route_prefix(): + app = FastAPI(title="Documentation Test") + functions = { + function.route_path: function + for function in FastAPIIndexer(app).index_routes() + } + + openapi_function = functions["/openapi.json"] + openapi_response = await execute_fastapi_route( + app, + MockAzureRequest("http://localhost:7071/api/openapi.json"), + openapi_function.route_handler, + openapi_function.route_path, + openapi_function.is_async, + ) + assert openapi_response["status_code"] == 200 + assert openapi_response["headers"]["content-type"] == "application/json" + assert json.loads(openapi_response["body"])["servers"] == [ + {"url": "/api"} + ] + + docs_function = functions["/docs"] + docs_response = await execute_fastapi_route( + app, + MockAzureRequest("http://localhost:7071/api/docs"), + docs_function.route_handler, + docs_function.route_path, + docs_function.is_async, + ) + assert docs_response["status_code"] == 200 + assert "url: '/api/openapi.json'" in docs_response["body"] + assert ( + "window.location.origin + '/api/docs/oauth2-redirect'" + in docs_response["body"] + ) + + redirect_function = functions["/docs/oauth2-redirect"] + redirect_response = await execute_fastapi_route( + app, + MockAzureRequest( + "http://localhost:7071/api/docs/oauth2-redirect"), + redirect_function.route_handler, + redirect_function.route_path, + redirect_function.is_async, + ) + assert redirect_response["status_code"] == 200 + assert "Swagger UI: OAuth2 Redirect" in redirect_response["body"] + + redoc_function = functions["/redoc"] + redoc_response = await execute_fastapi_route( + app, + MockAzureRequest("http://localhost:7071/api/redoc"), + redoc_function.route_handler, + redoc_function.route_path, + redoc_function.is_async, + ) + assert redoc_response["status_code"] == 200 + assert 'spec-url="/api/openapi.json"' in redoc_response["body"] diff --git a/runtimes/fastapi/tests/test_example_app.py b/runtimes/fastapi/tests/test_example_app.py new file mode 100644 index 000000000..13a84981e --- /dev/null +++ b/runtimes/fastapi/tests/test_example_app.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +Test the example FastAPI app indexing +""" +import sys +import os +import pytest + +# Add parent directory to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from azure_functions_fastapi.indexer import index_fastapi_app, FastAPIIndexer +from azure_functions_fastapi.converter import FastAPIConverter + + +def test_example_app_indexing(): + """Test indexing the example FastAPI app""" + # We need to be in the tests directory for imports to work + original_dir = os.getcwd() + try: + test_dir = os.path.dirname(os.path.abspath(__file__)) + os.chdir(test_dir) + + # Add tests dir to path + if test_dir not in sys.path: + sys.path.insert(0, test_dir) + + # Index the example app + functions = index_fastapi_app("example_app.py") + + # Should have discovered multiple routes + assert len(functions) > 0 + + # Check for expected routes + function_names = [f.name for f in functions] + + expected_routes = [ + "root", # GET / + "health_check", # GET /health + "list_items", # GET /items + "create_item", # POST /items + "list_users", # GET /users + "create_user", # POST /users + ] + + for expected in expected_routes: + assert expected in function_names, f"Expected route {expected} not found" + + print(f"\nDiscovered {len(functions)} routes:") + for func in functions: + print(f" - {func.name}: {func.http_methods} {func.route_path}") + + finally: + os.chdir(original_dir) + + +def test_example_app_conversion(): + """Test converting example app to Azure Functions""" + original_dir = os.getcwd() + try: + test_dir = os.path.dirname(os.path.abspath(__file__)) + os.chdir(test_dir) + + if test_dir not in sys.path: + sys.path.insert(0, test_dir) + + # Index and convert + fastapi_functions = index_fastapi_app("example_app.py") + + converter = FastAPIConverter() + azure_functions = converter.convert_to_azure_functions(fastapi_functions) + + assert len(azure_functions) == len(fastapi_functions) + + # Verify each function has proper bindings + for func in azure_functions: + assert len(func.bindings) == 2 + assert func.bindings[0]['type'] == 'httpTrigger' + assert func.bindings[1]['type'] == 'http' + + # Verify route is properly set + assert func.route_path + assert func.http_methods + + print(f"\nConverted {len(azure_functions)} Azure Functions:") + for func in azure_functions: + print(f" - {func.name}") + print(f" Route: {func.route_path}") + print(f" Methods: {func.http_methods}") + print(f" Async: {func.is_async}") + + finally: + os.chdir(original_dir) + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) diff --git a/runtimes/fastapi/tests/test_http_v2.py b/runtimes/fastapi/tests/test_http_v2.py new file mode 100644 index 000000000..704e55013 --- /dev/null +++ b/runtimes/fastapi/tests/test_http_v2.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import pytest + +from azure_functions_fastapi.http_v2 import http_coordinator + + +@pytest.fixture(autouse=True) +def clear_http_contexts(): + http_coordinator._context_references.clear() + yield + http_coordinator._context_references.clear() + + +@pytest.mark.asyncio +async def test_response_consumption_removes_invocation_context(): + invocation_id = "test-invocation" + request = object() + response = object() + + http_coordinator.set_http_request(invocation_id, request) + assert await http_coordinator.get_http_request_async(invocation_id) \ + is request + + http_coordinator.set_http_response(invocation_id, response) + + assert await http_coordinator.await_http_response_async(invocation_id) \ + is response + assert invocation_id not in http_coordinator._context_references diff --git a/runtimes/fastapi/tests/test_indexer.py b/runtimes/fastapi/tests/test_indexer.py new file mode 100644 index 000000000..62ae5a75f --- /dev/null +++ b/runtimes/fastapi/tests/test_indexer.py @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +""" +Test FastAPI Indexer +""" +import pytest +from fastapi import FastAPI + +from azure_functions_fastapi.indexer import FastAPIIndexer, index_fastapi_app + + +def test_indexer_discovers_routes(): + """Test that the indexer can discover FastAPI routes""" + app = FastAPI(openapi_url=None) + + @app.get("/hello") + def hello(): + return {"message": "hello"} + + @app.post("/users") + def create_user(): + return {"status": "created"} + + @app.get("/items/{item_id}") + def get_item(item_id: int): + return {"item_id": item_id} + + # Index the app + indexer = FastAPIIndexer(app) + functions = indexer.index_routes() + + # Should have discovered 3 routes + assert len(functions) == 3 + + # Check function names are generated correctly + function_names = [f.name for f in functions] + assert "hello" in function_names + assert "create_user" in function_names + assert "get_item" in function_names + + # Check route paths are preserved + for func in functions: + if func.name == "hello": + assert func.route_path == "/hello" + assert "GET" in func.http_methods + elif func.name == "create_user": + assert func.route_path == "/users" + assert "POST" in func.http_methods + + +def test_indexer_handles_async_routes(): + """Test that the indexer correctly identifies async routes""" + app = FastAPI(openapi_url=None) + + @app.get("/sync") + def sync_route(): + return {"type": "sync"} + + @app.get("/async") + async def async_route(): + return {"type": "async"} + + indexer = FastAPIIndexer(app) + functions = indexer.index_routes() + + functions_by_name = {func.name: func for func in functions} + assert not functions_by_name["sync_route"].is_async + assert functions_by_name["async_route"].is_async + + +def test_indexer_handles_root_path(): + """Test that the indexer handles root path correctly""" + app = FastAPI(openapi_url=None) + + @app.get("/") + def root(): + return {"message": "root"} + + indexer = FastAPIIndexer(app) + functions = indexer.index_routes() + + assert len(functions) == 1 + assert functions[0].name == "root" + assert functions[0].route_path == "/" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/runtimes/fastapi/tests/test_loader.py b/runtimes/fastapi/tests/test_loader.py new file mode 100644 index 000000000..5ea2f909f --- /dev/null +++ b/runtimes/fastapi/tests/test_loader.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import sys + +from azure_functions_fastapi import loader + + +def test_load_function_metadata_imports_from_function_directory( + tmp_path, monkeypatch +): + function_app_directory = tmp_path / "app" + function_app_directory.mkdir() + function_path = function_app_directory / "customer_app.py" + function_path.write_text( + "from fastapi import FastAPI\n" + "app = FastAPI(openapi_url=None)\n" + "@app.get('/hello')\n" + "def hello():\n" + " return {'message': 'hello'}\n", + encoding="utf-8", + ) + unrelated_directory = tmp_path / "cwd" + unrelated_directory.mkdir() + monkeypatch.chdir(unrelated_directory) + monkeypatch.setattr( + loader, + "process_indexed_function", + lambda protos, app, functions, function_dir: (["metadata"], {}), + ) + + original_sys_path = sys.path.copy() + try: + app, metadata, converter = loader.load_function_metadata( + str(function_path), str(function_app_directory), protos=None) + finally: + sys.path[:] = original_sys_path + sys.modules.pop("customer_app", None) + + assert app.routes[-1].path == "/hello" + assert metadata == ["metadata"] + assert converter.get_function("hello").route_path == "/hello" diff --git a/runtimes/fastapi/tests/test_modular_app.py b/runtimes/fastapi/tests/test_modular_app.py new file mode 100644 index 000000000..a226a84c1 --- /dev/null +++ b/runtimes/fastapi/tests/test_modular_app.py @@ -0,0 +1,54 @@ +import sys +from pathlib import Path + +from azure_functions_fastapi.loader import index_function_app_fastapi + + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "modular_app" + + +def _is_fixture_module(module_name): + return module_name == "function_app" or module_name == "app" or \ + module_name.startswith("app.") + + +def test_indexes_routes_registered_from_router_modules(monkeypatch): + monkeypatch.chdir(FIXTURE_DIR) + monkeypatch.syspath_prepend(str(FIXTURE_DIR)) + + previous_modules = { + module_name: sys.modules.pop(module_name) + for module_name in list(sys.modules) + if _is_fixture_module(module_name) + } + + try: + _, functions = index_function_app_fastapi( + str(FIXTURE_DIR / "function_app.py")) + + indexed_routes = { + (function.route_path, frozenset(function.http_methods)) + for function in functions + } + + assert indexed_routes == { + ("/", frozenset({"GET"})), + ("/items/", frozenset({"GET"})), + ("/items/", frozenset({"POST"})), + ("/users/", frozenset({"POST"})), + ("/users/{user_id}/profile/", frozenset({"GET"})), + } + assert all( + function.function_script_file == "function_app.py" + for function in functions + ) + assert "app.routers.unregistered" not in sys.modules + assert all( + function.route_path != "/unregistered" + for function in functions + ) + finally: + for module_name in list(sys.modules): + if _is_fixture_module(module_name): + sys.modules.pop(module_name) + sys.modules.update(previous_modules) diff --git a/runtimes/fastapi/tests/test_runtime.py b/runtimes/fastapi/tests/test_runtime.py new file mode 100644 index 000000000..c4ac8aa17 --- /dev/null +++ b/runtimes/fastapi/tests/test_runtime.py @@ -0,0 +1,121 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI + +from azure_functions_fastapi import handle_event +from azure_functions_fastapi.runtime import VERSION +from azure_functions_fastapi.version import VERSION as PACKAGE_VERSION + + +class ProtoMessage: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +class StatusResult(ProtoMessage): + Success = "success" + Failure = "failure" + + +class Protos: + FunctionEnvironmentReloadResponse = ProtoMessage + FunctionMetadataResponse = ProtoMessage + StatusResult = StatusResult + + +def test_runtime_exports_package_version(): + assert VERSION == PACKAGE_VERSION + + +@pytest.mark.asyncio +async def test_metadata_request_loads_and_caches_metadata( + tmp_path, monkeypatch +): + function_app_directory = tmp_path / "app" + function_app_directory.mkdir() + (function_app_directory / "function_app.py").touch() + unrelated_directory = tmp_path / "cwd" + unrelated_directory.mkdir() + monkeypatch.chdir(unrelated_directory) + + app = FastAPI() + metadata = [SimpleNamespace( + name="root", + properties={"FastAPIRoute": "/"}, + raw_bindings=[], + )] + converter = object() + loader_args = None + + def load_metadata(function_path, function_dir, protos): + nonlocal loader_args + loader_args = (function_path, function_dir, protos) + return app, metadata, converter + + monkeypatch.setattr(handle_event, "protos", Protos) + monkeypatch.setattr(handle_event, "_fastapi_app", None) + monkeypatch.setattr(handle_event, "_metadata_result", None) + monkeypatch.setattr(handle_event, "_converter", None) + monkeypatch.setattr( + handle_event, "load_function_metadata", load_metadata) + + request = SimpleNamespace(request=SimpleNamespace( + functions_metadata_request=SimpleNamespace( + function_app_directory=str(function_app_directory)))) + + response = await handle_event.functions_metadata_request(request) + + assert loader_args == ( + str(function_app_directory / "function_app.py"), + str(function_app_directory), + Protos, + ) + assert handle_event._fastapi_app is app + assert handle_event._metadata_result is metadata + assert handle_event._converter is converter + assert response.function_metadata_results is metadata + assert response.result.status == StatusResult.Success + + +@pytest.mark.asyncio +async def test_environment_reload_uses_request_directory(tmp_path, monkeypatch): + function_app_directory = tmp_path / "app" + function_app_directory.mkdir() + (function_app_directory / "app.py").touch() + unrelated_directory = tmp_path / "cwd" + unrelated_directory.mkdir() + monkeypatch.chdir(unrelated_directory) + + app = FastAPI() + metadata = [object()] + converter = object() + loader_args = None + + def load_metadata(function_path, function_dir, protos): + nonlocal loader_args + loader_args = (function_path, function_dir, protos) + return app, metadata, converter + + request = SimpleNamespace(request=SimpleNamespace( + function_environment_reload_request=SimpleNamespace( + function_app_directory=str(function_app_directory)))) + monkeypatch.setattr(handle_event, "protos", Protos) + monkeypatch.setattr( + handle_event, "load_function_metadata", load_metadata) + monkeypatch.setattr( + handle_event, "get_worker_metadata", lambda protos: object()) + + response = await handle_event.function_environment_reload_request(request) + + assert loader_args == ( + str(function_app_directory / "app.py"), + str(function_app_directory), + Protos, + ) + assert handle_event._fastapi_app is app + assert handle_event._metadata_result is metadata + assert handle_event._converter is converter + assert response.result.status == StatusResult.Success