From 36f66a2c63815175d4c8a087ac44d36a23f2b72b Mon Sep 17 00:00:00 2001 From: Jonathan Sick Date: Wed, 17 Apr 2024 14:35:49 -0400 Subject: [PATCH 01/10] Consolidate models to basic external router module For this "external" router, we'll use it to demonstrate basic path operations, and keeping the models co-located with the path operation functions makes it a little easier to demonstrate. By getting rid of the models module, we also make space for a better architecture for separating internal and API models. --- src/fastapibootcamp/handlers/external.py | 26 +++++++++++++++++++++++- src/fastapibootcamp/models.py | 20 ------------------ 2 files changed, 25 insertions(+), 21 deletions(-) delete mode 100644 src/fastapibootcamp/models.py diff --git a/src/fastapibootcamp/handlers/external.py b/src/fastapibootcamp/handlers/external.py index 2dcd0ae..fc7e9ef 100644 --- a/src/fastapibootcamp/handlers/external.py +++ b/src/fastapibootcamp/handlers/external.py @@ -3,12 +3,13 @@ from typing import Annotated from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field from safir.dependencies.logger import logger_dependency +from safir.metadata import Metadata as SafirMetadata from safir.metadata import get_metadata from structlog.stdlib import BoundLogger from ..config import config -from ..models import Index __all__ = ["get_index", "external_router"] @@ -16,6 +17,29 @@ """FastAPI router for all external handlers.""" +# In the default template there's a "models" module that defines the Pydantic +# models. For this router, we're going to co-locate models and path operation +# function in the same module to make the demo easier to follow. For a real +# application, I recommend keeping models in their own module, but instead of +# a single root-level "models" module, keep the API models next to the +# handlers, and have internal models elsewhere in the "domain" and "storage" +# interface subpackages. + + +class Index(BaseModel): + """Metadata returned by the external root URL of the application. + + Notes + ----- + As written, this is not very useful. Add additional metadata that will be + helpful for a user exploring the application, or replace this model with + some other model that makes more sense to return from the application API + root. + """ + + metadata: SafirMetadata = Field(..., title="Package metadata") + + @external_router.get( "/", description=( diff --git a/src/fastapibootcamp/models.py b/src/fastapibootcamp/models.py deleted file mode 100644 index f97f47e..0000000 --- a/src/fastapibootcamp/models.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Models for fastapi-bootcamp.""" - -from pydantic import BaseModel, Field -from safir.metadata import Metadata as SafirMetadata - -__all__ = ["Index"] - - -class Index(BaseModel): - """Metadata returned by the external root URL of the application. - - Notes - ----- - As written, this is not very useful. Add additional metadata that will be - helpful for a user exploring the application, or replace this model with - some other model that makes more sense to return from the application API - root. - """ - - metadata: SafirMetadata = Field(..., title="Package metadata") From c57260fe287b316408db79795422f51c69a3899b Mon Sep 17 00:00:00 2001 From: Jonathan Sick Date: Wed, 17 Apr 2024 17:35:02 -0400 Subject: [PATCH 02/10] Fix paths for OpenAPI docs I think this was a bug in our template that'll be fixed in https://github.com/lsst/templates/pull/253 --- src/fastapibootcamp/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fastapibootcamp/main.py b/src/fastapibootcamp/main.py index ce29a46..83dc4b6 100644 --- a/src/fastapibootcamp/main.py +++ b/src/fastapibootcamp/main.py @@ -45,9 +45,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: title="fastapi-bootcamp", description=metadata("fastapi-bootcamp")["Summary"], version=version("fastapi-bootcamp"), - openapi_url=f"/{config.path_prefix}/openapi.json", - docs_url=f"/{config.path_prefix}/docs", - redoc_url=f"/{config.path_prefix}/redoc", + openapi_url=f"{config.path_prefix}/openapi.json", + docs_url=f"{config.path_prefix}/docs", + redoc_url=f"{config.path_prefix}/redoc", lifespan=lifespan, ) """The main FastAPI application for fastapi-bootcamp.""" From 5ffe072b17399b0f6b49f60de79b05c4ab1d9a45 Mon Sep 17 00:00:00 2001 From: Jonathan Sick Date: Wed, 17 Apr 2024 17:36:42 -0400 Subject: [PATCH 03/10] Add demos of basic endpoints Cover path parameters, query parameters, response models, and post requests with request models. Cover basic logging with Safir's logger_dependency. --- src/fastapibootcamp/handlers/external.py | 201 ++++++++++++++++++++++- 1 file changed, 195 insertions(+), 6 deletions(-) diff --git a/src/fastapibootcamp/handlers/external.py b/src/fastapibootcamp/handlers/external.py index fc7e9ef..3028dc4 100644 --- a/src/fastapibootcamp/handlers/external.py +++ b/src/fastapibootcamp/handlers/external.py @@ -1,8 +1,9 @@ """Handlers for the app's external root, ``/fastapi-bootcamp/``.""" +from enum import Enum from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, Field from safir.dependencies.logger import logger_dependency from safir.metadata import Metadata as SafirMetadata @@ -11,19 +12,20 @@ from ..config import config -__all__ = ["get_index", "external_router"] - external_router = APIRouter() """FastAPI router for all external handlers.""" -# In the default template there's a "models" module that defines the Pydantic +# In the default template, there's a "models" module that holds all Pydantic # models. For this router, we're going to co-locate models and path operation -# function in the same module to make the demo easier to follow. For a real +# functions in the same module to make the demo easier to follow. For a real # application, I recommend keeping models in their own module, but instead of # a single root-level "models" module, keep the API models next to the # handlers, and have internal models elsewhere in the "domain" and "storage" -# interface subpackages. +# interface subpackages. Keeping a separation between your API, your +# application's internal domain and storage, and models for interfacing with +# other services will make it easier to grow the codebase without +# breaking the API. class Index(BaseModel): @@ -74,3 +76,190 @@ async def get_index( application_name=config.name, ) return Index(metadata=metadata) + + +# ============================================================================= +# Lesson 1: A simple GET endpoint. + + +@external_router.get("/hello", summary="Get a friendly greeting.") +async def get_greeting() -> str: + return "Hello, SQuaRE Services Bootcamp!" + + +# ============================================================================= +# Lesson 2a: A GET endpoint with a JSON response and query parameters. +# +# In a web API, the response type will typically be a JSON object. With +# FastAPI, you'll model JSON with Pydantic models. Here, GreetingResponseModel +# is a Pydantic model with two JSON fields. The `language` field is an Enum, +# which is a good way to restrict the possible values of a field. We tell +# FastAPI what the model is with the response_model parameter and/or the return +# type annotation. +# +# To let the user pick the language, we support a query parameter. +# Try it out by visiting: +# /fastapi-bootcamp/greeting?language=es + + +class Language(str, Enum): + """Supported languages for the greeting endpoint.""" + + en = "en" + es = "es" + fr = "fr" + + +class GreetingResponseModel(BaseModel): + """Response model for the greeting endpoint.""" + + greeting: str = Field(..., title="The greeting message") + + language: Language = Field(..., title="Language of the greeting") + + +@external_router.get( + "/greeting", + summary="Get a greeting in several languages.", + response_model=GreetingResponseModel, +) +async def get_multilingual_greeting( + language: Annotated[Language, Query()] = Language.en, +) -> GreetingResponseModel: + """You can get the greeting in English, Spanish, or French.""" + greetings = { + Language.en: "Hello, SQuaRE Services Bootcamp!", + Language.es: "¡Hola, SQuaRE Services Bootcamp!", + Language.fr: "Bonjour, SQuaRE Services Bootcamp!", + } + + return GreetingResponseModel( + greeting=greetings[language], language=language + ) + + +# ============================================================================= +# Lesson 2b: A GET endpoint with path parameters. +# +# Path parameters are used to specify a resource in the URL. Let's pretend that +# languages are different resources, and let the user pick the language with a +# path parameter instead of a query parameter. +# +# With path parameters, we template the name of the parameter in the URL path +# and specify its type in the function signature. FastAPI will validate and +# convert the parameter to the correct type and pass it to the function. +# +# Try it out by visiting: +# /fastapi-bootcamp/greeting/en +# +# If you visit the wrong URL, FastAPI will return a 404 error. Try it out by +# visiting: +# /fastapi-bootcamp/greeting/de +# +# Note: Query parameters and path parameters have different use cases in +# RESTful APIs. Path parameters are used to specify a resource, while query +# parameters are used to filter. So although we've interchanged them here for +# demonstration, in real RESTful APIs they have distinct purposes. + + +@external_router.get( + "/greeting/{language}", + summary="Get a greeting in several languages.", + response_model=GreetingResponseModel, +) +async def get_multilingual_greeting_path( + language: Language, +) -> GreetingResponseModel: + """You can get the greeting in English, Spanish, or French.""" + greetings = { + Language.en: "Hello, SQuaRE Services Bootcamp!", + Language.es: "¡Hola, SQuaRE Services Bootcamp!", + Language.fr: "Bonjour, SQuaRE Services Bootcamp!", + } + + return GreetingResponseModel( + greeting=greetings[language], language=language + ) + + +# ============================================================================= +# Lesson 3: A POST endpoint with a JSON request body. +# +# POST requests are used to create a new resource. In RESTful APIs, the request +# body is a JSON object that represents the new resource. FastAPI will +# validate and convert the request body to the correct type and pass it to the +# function. + + +class GreetingRequestModel(BaseModel): + """Request model for the greeting POST endpoint.""" + + name: str = Field(..., title="Your name") + + language: Language = Field(Language.en, title="Language of the greeting") + + +@external_router.post( + "/greeting", + summary="Get a greeting in several languages.", + response_model=GreetingResponseModel, +) +async def post_greeting( + data: GreetingRequestModel, +) -> GreetingResponseModel: + """You can get the greeting in English, Spanish, or French.""" + greeting_templates = { + Language.en: "Hello, {name}!", + Language.es: "¡Hola, {name}!", + Language.fr: "Bonjour, {name}!", + } + + return GreetingResponseModel( + greeting=greeting_templates[data.language].format(name=data.name), + language=data.language, + ) + + +# ============================================================================= +# Lesson 4: Logging +# +# Logging is an important part of any application. With Safir, we use +# structlog to create structured logging. Structured log messages are +# JSON-formatted and let you add fields that are easily searchable and +# fitlerable. +# +# Safir provides a logger as a FastAPI dependency. Dependencies are also +# arguments to FastAPI path operation functions, set up with FastAPI's Depends +# function. +@external_router.post( + "/log-demo", summary="Log a message.", response_model=GreetingResponseModel +) +async def post_log_demo( + data: GreetingRequestModel, + logger: Annotated[BoundLogger, Depends(logger_dependency)], +) -> GreetingResponseModel: + """Log a message.""" + # With structlog, keyword argumemnts become fields in the log message. + # + # Why model_dump(mode="json")? This gives us a dict, but serializes the + # values the same way they would be in JSON. This formats the Enum values + # as strings. + logger.info("The log message", payload=data.model_dump(mode="json")) + + # You can "bind" fields to a logger to include them in all log messages. + # This is useful for establishing context. Safir binds some data for you + # like the request_id and Gafaelfawer user ID (if available). + logger = logger.bind(name=data.name, language=data.language) + logger.info("The log message with bound fields") + logger.info("Another log message with bound fields") + + greeting_templates = { + Language.en: "Hello, {name}!", + Language.es: "¡Hola, {name}!", + Language.fr: "Bonjour, {name}!", + } + + return GreetingResponseModel( + greeting=greeting_templates[data.language].format(name=data.name), + language=data.language, + ) From 43e2b12eceddd1451edbcaab9b55155d72850cf3 Mon Sep 17 00:00:00 2001 From: Jonathan Sick Date: Tue, 23 Apr 2024 17:49:06 -0400 Subject: [PATCH 04/10] Add tests for the basic endpoints --- tests/handlers/external_test.py | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/handlers/external_test.py b/tests/handlers/external_test.py index e00fe43..aeb446b 100644 --- a/tests/handlers/external_test.py +++ b/tests/handlers/external_test.py @@ -20,3 +20,44 @@ async def test_get_index(client: AsyncClient) -> None: assert isinstance(metadata["description"], str) assert isinstance(metadata["repository_url"], str) assert isinstance(metadata["documentation_url"], str) + + +@pytest.mark.asyncio +async def test_get_greeting(client: AsyncClient) -> None: + """Test ``GET /fastapi-bootcamp/hello``.""" + response = await client.get("/fastapi-bootcamp/hello") + assert response.status_code == 200 + assert response.text == '"Hello, SQuaRE Services Bootcamp!"' + + +@pytest.mark.asyncio +async def test_get_multilingual_greeting_es(client: AsyncClient) -> None: + """Test ``GET /fastapi-bootcamp/greeting?language=es``.""" + response = await client.get("/fastapi-bootcamp/greeting?language=es") + assert response.status_code == 200 + data = response.json() + assert data["greeting"] == "¡Hola, SQuaRE Services Bootcamp!" + assert data["language"] == "es" + + +@pytest.mark.asyncio +async def test_get_multilingual_greeting_path_fr(client: AsyncClient) -> None: + """Test ``GET /fastapi-bootcamp/greeting/fr``.""" + response = await client.get("/fastapi-bootcamp/greeting?language=fr") + assert response.status_code == 200 + data = response.json() + assert data["greeting"] == "Bonjour, SQuaRE Services Bootcamp!" + assert data["language"] == "fr" + + +@pytest.mark.asyncio +async def post_greeting(client: AsyncClient) -> None: + """Test ``POST /fastapi-bootcamp/greeting``.""" + response = await client.post( + "/fastapi-bootcamp/greeting", + json={"name": "SQuaRE", "language": "es"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["greeting"] == "¡Hola, SQuaRE!" + assert data["language"] == "es" From 9f284dad0db38af541417d600265dcfd35fb45df Mon Sep 17 00:00:00 2001 From: Jonathan Sick Date: Wed, 24 Apr 2024 14:40:59 -0400 Subject: [PATCH 05/10] Change env_prefix to BOOTCAMP We'll need to modify the template to deal with hyphenated app names. --- src/fastapibootcamp/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastapibootcamp/config.py b/src/fastapibootcamp/config.py index e64f2a4..201f67a 100644 --- a/src/fastapibootcamp/config.py +++ b/src/fastapibootcamp/config.py @@ -27,7 +27,7 @@ class Config(BaseSettings): ) model_config = SettingsConfigDict( - env_prefix="FASTAPI-BOOTCAMP_", case_sensitive=False + env_prefix="FASTAPI_BOOTCAMP_", case_sensitive=False ) From 64ad580b48038ee79cf853dc80625fcc6bc347c5 Mon Sep 17 00:00:00 2001 From: Jonathan Sick Date: Mon, 29 Apr 2024 15:38:11 -0400 Subject: [PATCH 06/10] Add nuance on returning text from an endpoint --- src/fastapibootcamp/handlers/external.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/fastapibootcamp/handlers/external.py b/src/fastapibootcamp/handlers/external.py index 3028dc4..fd66aba 100644 --- a/src/fastapibootcamp/handlers/external.py +++ b/src/fastapibootcamp/handlers/external.py @@ -80,6 +80,19 @@ async def get_index( # ============================================================================= # Lesson 1: A simple GET endpoint. +# +# This function handles a GET request to the /hello endpoint. Since the +# external_router is mounted at "/fastapi-bootcamp" (in main.py), the full URL +# The full path ends up being /fastapi-bootcamp/hello. The function returns +# simple string (*). You can try it out by visiting: +# http://localhost:8000/fastapi-bootcamp/hello +# +# (*) Well actually, FastAPI is built for JSON APIs and converts return values +# to JSON. So even though we're returning a string, FastAPI will convert it to +# a JSON string object. To return a true string, you can use a +# fastapi.responses.PlainTextResponse object. FastAPI has other specialized +# responses like HTMLResponse, StreamingResponse, and RedirectResponse. +# https://fastapi.tiangolo.com/advanced/custom-response/ @external_router.get("/hello", summary="Get a friendly greeting.") From a79d7d43397ed25631887f7ef4b73ee314a42cc4 Mon Sep 17 00:00:00 2001 From: Jonathan Sick Date: Mon, 29 Apr 2024 15:41:26 -0400 Subject: [PATCH 07/10] Add docs on how to run endpoints --- pyproject.toml | 3 +++ src/fastapibootcamp/handlers/external.py | 24 +++++++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 27678fe..cc32ffe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -164,6 +164,9 @@ select = ["ALL"] "src/fastapibootcamp/handlers/**" = [ "D103", # FastAPI handlers should not have docstrings ] +"src/fastapibootcamp/handlers/external.py" = [ + "ERA001", # Allow some commented code for documentation +] "tests/**" = [ "C901", # tests are allowed to be complex, sometimes that's convenient "D101", # tests don't need docstrings diff --git a/src/fastapibootcamp/handlers/external.py b/src/fastapibootcamp/handlers/external.py index fd66aba..75f9093 100644 --- a/src/fastapibootcamp/handlers/external.py +++ b/src/fastapibootcamp/handlers/external.py @@ -112,7 +112,7 @@ async def get_greeting() -> str: # # To let the user pick the language, we support a query parameter. # Try it out by visiting: -# /fastapi-bootcamp/greeting?language=es +# http://localhost:8000/fastapi-bootcamp/greeting?language=es class Language(str, Enum): @@ -163,11 +163,11 @@ async def get_multilingual_greeting( # convert the parameter to the correct type and pass it to the function. # # Try it out by visiting: -# /fastapi-bootcamp/greeting/en +# http://localhost:8000/fastapi-bootcamp/greeting/en # # If you visit the wrong URL, FastAPI will return a 404 error. Try it out by # visiting: -# /fastapi-bootcamp/greeting/de +# http://localhost:8000/fastapi-bootcamp/greeting/de # # Note: Query parameters and path parameters have different use cases in # RESTful APIs. Path parameters are used to specify a resource, while query @@ -202,6 +202,21 @@ async def get_multilingual_greeting_path( # body is a JSON object that represents the new resource. FastAPI will # validate and convert the request body to the correct type and pass it to the # function. +# +# To send a POST request you need an HTTP client. Curl is a command-line +# app that comes with most platforms: +# curl -X POST --json '{"name": "Rubin", "language": "en"}' \ +# http://localhost:8000/fastapi-bootcamp/greeting +# +# Older versions of curl may not have the --json flag. In that case, use -H +# to set the Content-Type header: +# curl -X POST -H "Content-Type: application/json" -d \ +# '{"name": "Rubin", "language": "en"}' \ +# http://localhost:8000/fastapi-bootcamp/greeting +# +# I like to use httpie, a more user-friendly REST API client +# (https://httpie.io/ and 'brew install httpie' or 'pip install httpie'): +# http post :8000/fastapi-bootcamp/greeting name=Rubin language=en class GreetingRequestModel(BaseModel): @@ -244,6 +259,9 @@ async def post_greeting( # Safir provides a logger as a FastAPI dependency. Dependencies are also # arguments to FastAPI path operation functions, set up with FastAPI's Depends # function. +# +# Try it out while looking at the console output for `tox run -e run`: +# http post :8000/fastapi-bootcamp/log-demo name=Rubin language=en @external_router.post( "/log-demo", summary="Log a message.", response_model=GreetingResponseModel ) From b12e9c1f906e5128629a5ae6747bc20a3251479b Mon Sep 17 00:00:00 2001 From: Jonathan Sick Date: Mon, 29 Apr 2024 15:56:35 -0400 Subject: [PATCH 08/10] Add intermediate example of JSON response Rather than mixing JSON and query parameters in the same example, let's do the JSON response first, and then add in the query parameter next. --- src/fastapibootcamp/handlers/external.py | 29 +++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/fastapibootcamp/handlers/external.py b/src/fastapibootcamp/handlers/external.py index 75f9093..0c75967 100644 --- a/src/fastapibootcamp/handlers/external.py +++ b/src/fastapibootcamp/handlers/external.py @@ -101,7 +101,7 @@ async def get_greeting() -> str: # ============================================================================= -# Lesson 2a: A GET endpoint with a JSON response and query parameters. +# Lesson 2: A GET endpoint with a JSON response # # In a web API, the response type will typically be a JSON object. With # FastAPI, you'll model JSON with Pydantic models. Here, GreetingResponseModel @@ -110,9 +110,8 @@ async def get_greeting() -> str: # FastAPI what the model is with the response_model parameter and/or the return # type annotation. # -# To let the user pick the language, we support a query parameter. # Try it out by visiting: -# http://localhost:8000/fastapi-bootcamp/greeting?language=es +# http://localhost:8000/fastapi-bootcamp/en-greeting class Language(str, Enum): @@ -131,6 +130,30 @@ class GreetingResponseModel(BaseModel): language: Language = Field(..., title="Language of the greeting") +@external_router.get( + "/en-greeting", + summary="Get a greeting in engish.", + response_model=GreetingResponseModel, +) +async def get_english_greeting( + language: Annotated[Language, Query()] = Language.en, +) -> GreetingResponseModel: + return GreetingResponseModel( + greeting="Hello, SQuaRE Services Bootcamp!", language=language + ) + + +# ============================================================================= +# Lesson 2a: A GET endpoint with a JSON response and query parameters. +# +# To let the user pick the language, we support a query parameter. This is an +# argument to the path function. The type annotation with fastapi.Query +# indicates its a query parameter. +# +# Try it out by visiting: +# http://localhost:8000/fastapi-bootcamp/greeting?language=es + + @external_router.get( "/greeting", summary="Get a greeting in several languages.", From d00ae18b5d841b7cae31a25ac7505ebd754ef138 Mon Sep 17 00:00:00 2001 From: Jonathan Sick Date: Mon, 29 Apr 2024 15:59:08 -0400 Subject: [PATCH 09/10] Update to Python 3.12.3 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 32f756d..0207a61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ # - Runs a non-root user. # - Sets up the entrypoint and port. -FROM python:3.12.2-slim-bookworm as base-image +FROM python:3.12.3-slim-bookworm as base-image # Update system packages COPY scripts/install-base-packages.sh . From 5e98fa428081a6a512f93b0f97ae6c97288b3134 Mon Sep 17 00:00:00 2001 From: Jonathan Sick Date: Mon, 29 Apr 2024 16:00:26 -0400 Subject: [PATCH 10/10] Add change log entry for basic demo --- changelog.d/20240429_155939_jsick_DM_43939.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/20240429_155939_jsick_DM_43939.md diff --git a/changelog.d/20240429_155939_jsick_DM_43939.md b/changelog.d/20240429_155939_jsick_DM_43939.md new file mode 100644 index 0000000..2bbe747 --- /dev/null +++ b/changelog.d/20240429_155939_jsick_DM_43939.md @@ -0,0 +1,3 @@ +### New features + +- Add examples of FastAPI path operation functions to the external router.