-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Extend Hype to support creating FastAPI apps for functions (#2)
* Add http extras Install http extra by default * Add Accept header parsing for content negotiation * Add Prefer header parsing for request preferences * Add HTTP Problem Details object and FastAPI exception handling * Add blocking HTTP request preference tests * Add create_fastapi_app function * Use hype.up consistently Rename export to wrap Rename from_function to validate * Add missing type annotation for json_schema method * Raise explicitly from None to silence warnings * Remove unused imports * Update uv.lock * Replace testing group with dev dependencies * Add missing python-multipart dependency * Add Python 3.13 to test matrix
- Loading branch information
Showing
17 changed files
with
2,099 additions
and
23 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,6 +20,7 @@ jobs: | |
- "3.10" | ||
- "3.11" | ||
- "3.12" | ||
- "3.13" | ||
|
||
steps: | ||
- uses: actions/checkout@v4 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,166 @@ | ||
import asyncio | ||
import warnings | ||
from contextlib import asynccontextmanager | ||
from typing import Annotated | ||
|
||
with warnings.catch_warnings(): | ||
warnings.simplefilter("ignore", DeprecationWarning) | ||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor | ||
|
||
|
||
from docstring_parser import parse as parse_docstring | ||
from fastapi import APIRouter, FastAPI, File, Header, HTTPException, UploadFile | ||
from fastapi.exceptions import RequestValidationError | ||
from fastapi.responses import JSONResponse | ||
from pydantic import BaseModel, create_model | ||
|
||
from hype.function import Function | ||
from hype.http.prefer import parse_prefer_headers | ||
from hype.http.problem import Problem, problem_exception_handler | ||
from hype.task import Tasks | ||
|
||
|
||
class FileUploadRequest(BaseModel): | ||
file: UploadFile | ||
|
||
|
||
class FileUploadResponse(BaseModel): | ||
ok: bool | ||
|
||
|
||
def create_file_upload_callback_router(source_operation_id: str) -> APIRouter: | ||
router = APIRouter() | ||
|
||
@router.put( | ||
"{$callback_url}/files/{$request.body.id}", | ||
response_model=FileUploadResponse, | ||
operation_id=f"{source_operation_id}_file_upload_callback", | ||
summary="File upload callback endpoint", | ||
) | ||
def upload_file( | ||
request: FileUploadRequest = File(...), # pylint: disable=unused-argument | ||
) -> FileUploadResponse: | ||
return FileUploadResponse(ok=True) | ||
|
||
return router | ||
|
||
|
||
def add_fastapi_endpoint( | ||
app: FastAPI, | ||
func: Function, | ||
) -> None: | ||
path = f"/{func.name}" | ||
|
||
# Create a new input model with a unique name | ||
|
||
name = func.name | ||
docstring = parse_docstring(func._wrapped.__doc__ or "") # pylint: disable=protected-access | ||
summary = docstring.short_description | ||
description = docstring.long_description | ||
operation_id = func.name | ||
|
||
input = create_model( | ||
f"{operation_id}_Input", | ||
__base__=func.input, | ||
) | ||
|
||
output = create_model( | ||
f"{operation_id}_Output", | ||
__base__=func.output, | ||
) | ||
|
||
@app.post( | ||
path, | ||
name=name, | ||
summary=summary, | ||
description=description, | ||
operation_id=operation_id, | ||
callbacks=create_file_upload_callback_router(operation_id).routes, | ||
responses={ | ||
"default": {"model": Problem, "description": "Default error response"} | ||
}, | ||
) | ||
async def endpoint( | ||
input: input, # type: ignore | ||
prefer: Annotated[list[str] | None, Header()] = None, | ||
) -> output: # type: ignore | ||
preferences = parse_prefer_headers(prefer) | ||
|
||
input_dict = input.model_dump(mode="python") | ||
if asyncio.iscoroutinefunction(func): | ||
task = asyncio.create_task(func(**input_dict)) | ||
else: | ||
coroutine = asyncio.to_thread(func, **input_dict) | ||
task = asyncio.create_task(coroutine) | ||
|
||
id = app.state.tasks.defer(task) | ||
done, _ = await asyncio.wait( | ||
[task], | ||
timeout=preferences.wait, | ||
return_when=asyncio.FIRST_COMPLETED, | ||
) | ||
if done: | ||
return done.pop().result() | ||
else: | ||
# If task was not completed within `wait` seconds, return the 202 response. | ||
return JSONResponse( | ||
status_code=202, content=None, headers={"Location": f"/tasks/{id}"} | ||
) | ||
|
||
|
||
def create_fastapi_app( | ||
functions: list[Function], | ||
title: str = "Hype API", | ||
summary: str | None = None, | ||
description: str = "", | ||
version: str = "0.1.0", | ||
) -> FastAPI: | ||
@asynccontextmanager | ||
async def lifespan(app: FastAPI): # noqa: ANN202 | ||
app.state.tasks = Tasks() | ||
|
||
for function in functions: | ||
add_fastapi_endpoint(app, function) | ||
|
||
yield | ||
|
||
await app.state.tasks.wait_until_empty() | ||
|
||
app = FastAPI( | ||
title=title, | ||
summary=summary, | ||
description=description, | ||
version=version, | ||
lifespan=lifespan, | ||
) | ||
|
||
FastAPIInstrumentor.instrument_app(app) | ||
|
||
app.add_exception_handler(ValueError, problem_exception_handler) | ||
app.add_exception_handler(HTTPException, problem_exception_handler) | ||
app.add_exception_handler(RequestValidationError, problem_exception_handler) | ||
|
||
@app.get("/tasks/{id}", include_in_schema=False) | ||
def get_task(id: str) -> JSONResponse: | ||
task = app.state.tasks.get(id) | ||
|
||
if task is None: | ||
raise HTTPException(status_code=404, detail="Task not found") from None | ||
|
||
return JSONResponse(status_code=200, content=task.to_dict()) | ||
|
||
@app.post("/tasks/{id}/cancel", include_in_schema=False) | ||
def cancel_task(id: str) -> JSONResponse: | ||
task = app.state.tasks.get(id) | ||
|
||
if task is None: | ||
raise HTTPException(status_code=404, detail="Task not found") from None | ||
|
||
task.cancel() | ||
return JSONResponse(status_code=200, content=task.to_dict()) | ||
|
||
@app.get("/openapi.json", include_in_schema=False) | ||
def get_openapi_schema() -> dict: | ||
return app.openapi() | ||
|
||
return app |
Oops, something went wrong.