From ee2204ea397d1752da4e47bf2ac183c363dfda60 Mon Sep 17 00:00:00 2001 From: Jonathan Sick Date: Mon, 6 May 2024 16:47:34 -0400 Subject: [PATCH] Add a lesson on dependencies --- changelog.d/20240506_125840_jsick_DM_44230.md | 1 + pyproject.toml | 3 + .../dependencies/demostatefuldependency.py | 68 +++++++++++++++ src/fastapibootcamp/handlers/external.py | 86 ++++++++++++++++++- src/fastapibootcamp/main.py | 7 ++ 5 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 src/fastapibootcamp/dependencies/demostatefuldependency.py diff --git a/changelog.d/20240506_125840_jsick_DM_44230.md b/changelog.d/20240506_125840_jsick_DM_44230.md index 35e3877..2a0a78e 100644 --- a/changelog.d/20240506_125840_jsick_DM_44230.md +++ b/changelog.d/20240506_125840_jsick_DM_44230.md @@ -1,3 +1,4 @@ ### New features - Demonstrate `SlackException` in the `POST /fastapi-bootcamp/error-demo` endpoint. +- Demonstrate custom FastAPI dependencies in the `GET /fastapi-bootcamp/dependency-demo` endpoint. diff --git a/pyproject.toml b/pyproject.toml index 58e1d49..25ab94a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -168,6 +168,9 @@ select = ["ALL"] "src/fastapibootcamp/handlers/external.py" = [ "ERA001", # Allow some commented code for documentation ] +"src/fastapibootcamp/dependencies/demostatefuldependency.py" = [ + "S311", # Allow use of random in this module +] "tests/**" = [ "C901", # tests are allowed to be complex, sometimes that's convenient "D101", # tests don't need docstrings diff --git a/src/fastapibootcamp/dependencies/demostatefuldependency.py b/src/fastapibootcamp/dependencies/demostatefuldependency.py new file mode 100644 index 0000000..6a3a50a --- /dev/null +++ b/src/fastapibootcamp/dependencies/demostatefuldependency.py @@ -0,0 +1,68 @@ +"""A class-based FastAPI dependency for demonstration purposes.""" + +from __future__ import annotations + +import random + +from ..exceptions import DemoInternalError + +__all__ = ["example_stateful_dependency", "ExampleStatefulDependency"] + + +class ExampleStatefulDependency: + """A stateful FastAPI dependency for demonstration purposes.""" + + def __init__(self) -> None: + # For this demo we're just using a semi-random string as the state. In + # real applications, this could be a database connection, a client + # to a remote service, etc. This "state" is reused over the life of + # this application instance. It's not shared between instances, though, + self._state: str | None = None + + async def init(self) -> None: + """Initialize the dependency.""" + # This initialization is called in main.py in the lifespan context + self._state = f"{random.choice(ADJECTIVES)} {random.choice(ANIMALS)}" + + async def __call__(self) -> str: + """Provide the dependency. + + This gets called by the fastapi Depends() function when your + path operation function is called. + """ + if self._state is None: + raise DemoInternalError( + "ExamplePersistentDependency not initialized" + ) + + return self._state + + async def aclose(self) -> None: + """Close the dependency.""" + self.state = None + + +# This is the instance of the dependency that's referenced in path operation +# functions with the fastapi.Depends() function. Note that it needs to be +# initialized before it can be used. This is done in the lifespan context +# manager in main.py. Another option is to initialize it on the first use. +example_stateful_dependency = ExampleStatefulDependency() + + +ADJECTIVES = [ + "speedy", + "ponderous", + "furious", + "careful", + "mammoth", + "crafty", +] + +ANIMALS = [ + "cat", + "dog", + "sloth", + "snail", + "rabbit", + "turtle", +] diff --git a/src/fastapibootcamp/handlers/external.py b/src/fastapibootcamp/handlers/external.py index 856cd51..1352364 100644 --- a/src/fastapibootcamp/handlers/external.py +++ b/src/fastapibootcamp/handlers/external.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Annotated -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from safir.dependencies.logger import logger_dependency @@ -13,6 +13,7 @@ from structlog.stdlib import BoundLogger from ..config import config +from ..dependencies.demostatefuldependency import example_stateful_dependency from ..exceptions import DemoInternalError # The APIRouter is what the individual endpoints are attached to. In main.py, @@ -378,3 +379,86 @@ async def post_error_demo(data: ErrorRequestModel) -> JSONResponse: ) raise RuntimeError("A generic error occurred.") + + +# ============================================================================= +# Lesson 6: Custom dependencies +# +# FastAPI dependencies are a way to add reusable code to your path operation +# that's aware of the current request. Safir provides several dependencies, +# see https://safir.lsst.io/api.html#module-safir.dependencies.arq etc. +# +# - arq_dependency provides a client to the Arq distributed job queue +# - db_session_dependency provides a SQLAlchemy session +# - auth_delegated_token_dependency provides a delegated token +# - auth_dependency provides info about the current user +# - auth_logger_dependency provides a logger with user info bound +# - http_client_dependency provides an HTTPX async client +# - logger_dependency provides a structlog logger (see Lesson 4) +# +# Besides these, you can create your own dependencies. In the astroplan +# application we'll explore the request context dependency pattern. +# +# There are two types of dependencies you'll develop: functional dependencies +# and class-based dependencies. These class-based dependencies can hold +# persistent state that's reused across multiple requests. +# +# Try it out: +# http get :8000/fastapi-bootcamp/dependency-demo X-Custom-Header:foo + +# See src/fastapibootcamp/dependencies/demopersistentdependency.py for the +# dependency that holds a persistent value. Below is a functional dependency: + + +async def custom_header_dependency(request: Request) -> str | None: + """Provide the value of the X-Custom-Header from the request. + + This shows how you can access the request object, query parameters, and + even other depdenencies in a dependency function's arguments. + """ + return request.headers["X-Custom-Header"] + + +class DependencyDemoResponseModel(BaseModel): + """Response model for the dependency demo endpoint.""" + + header: str | None = Field( + ..., title="The X-Custom-Header.", examples=["foo"] + ) + + persistent_value: str = Field( + ..., + title="A persistent value provided by the dependency.", + examples=["crafty sloth"], + ) + + +@external_router.get( + "/dependency-demo", + summary="Demonstrate custom dependencies.", + response_model=DependencyDemoResponseModel, +) +async def get_dependency_demo( + # This is the functional dependency defined above + custom_header: Annotated[str | None, Depends(custom_header_dependency)], + # This is the class-based dependency defined in + # src/fastapibootcamp/dependencies/demopersistentdependency.py + persistent_value: Annotated[str, Depends(example_stateful_dependency)], + # This is a dependency from Safir + logger: Annotated[BoundLogger, Depends(logger_dependency)], +) -> DependencyDemoResponseModel: + logger.info( + "Dependency demo", + custom_header=custom_header, + persistent_value=persistent_value, + ) + + return DependencyDemoResponseModel( + header=custom_header, persistent_value=persistent_value + ) + + +# ============================================================================= +# This covers the basics of writing endpoints in FastAPI. Next, we'll explore +# how to structure a larger application with a service/storage/domain +# architecture. We'll see you there at src/fastapibootcamp/handlers/astroplan. diff --git a/src/fastapibootcamp/main.py b/src/fastapibootcamp/main.py index 549de37..4608918 100644 --- a/src/fastapibootcamp/main.py +++ b/src/fastapibootcamp/main.py @@ -23,6 +23,7 @@ # Notice how the the config instance is imported early so it's both # instantiated on app start-up and available to set up the app. from .config import config +from .dependencies.demostatefuldependency import example_stateful_dependency from .handlers.astroplan import astroplan_router from .handlers.external import external_router from .handlers.internal import internal_router @@ -40,6 +41,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Set up and tear down the application.""" # Any code here will be run when the application starts up. logger = get_logger(__name__) + + # Set up the example persistent dependency. + await example_stateful_dependency.init() + iers_cache_manager = IersCacheManager(logger) iers_cache_manager.config_iers_cache() if config.clear_iers_on_startup: @@ -51,6 +56,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # Any code here will be run when the application shuts down. await http_client_dependency.aclose() + await example_stateful_dependency.aclose() + logger.info("fastapi-bootcamp application shut down complete.")