├── tests ├── __init__.py ├── test_enums.py ├── test_openapi.py ├── test_api_model.py ├── test_api_settings.py ├── test_guid_type.py ├── test_session.py ├── conftest.py ├── test_inferring_router.py ├── test_camelcase.py ├── test_cbv_base.py ├── test_timing.py ├── test_cbv.py └── test_tasks.py ├── fastapi_utils ├── py.typed ├── inferring_router.py ├── openapi.py ├── __init__.py ├── camelcase.py ├── cbv_base.py ├── enums.py ├── api_model.py ├── guid_type.py ├── api_settings.py ├── tasks.py ├── session.py ├── timing.py └── cbv.py ├── docs ├── img │ ├── favicon.png │ └── icon-white.svg ├── src │ ├── class_resource_view1.py │ ├── guid2.py │ ├── camelcase2.py │ ├── enums1.py │ ├── enums2.py │ ├── camelcase1.py │ ├── api_settings.py │ ├── class_resource_view2.py │ ├── guid1.py │ ├── openapi1.py │ ├── class_resource_view3.py │ ├── openapi2.py │ ├── inferring_router1.py │ ├── api_model.py │ ├── repeated_tasks1.py │ ├── class_resource_view4.py │ ├── session1.py │ ├── timing1.py │ ├── class_based_views2.py │ └── class_based_views1.py ├── css │ └── custom.css ├── user-guide │ ├── basics │ │ ├── camelcase.md │ │ ├── enums.md │ │ ├── api-model.md │ │ ├── api-settings.md │ │ └── guid-type.md │ ├── class-resource.md │ ├── openapi.md │ ├── class-based-views.md │ ├── timing-middleware.md │ ├── repeated-tasks.md │ └── session.md ├── help-fastapi-utils.md ├── release-notes.md ├── index.md └── contributing.md ├── codecov.yml ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── question.md │ ├── feature_request.md │ └── bug_report.md ├── release.yml └── workflows │ ├── publish.yml │ ├── publish-docs.yml │ ├── pull-request.yml │ └── build.yml ├── scripts ├── lock.sh └── develop.sh ├── .vscode └── settings.json ├── .devcontainer └── devcontainer.json ├── .deepsource.toml ├── LICENSE ├── .gitignore ├── mkdocs.yml ├── CHANGELOG.md ├── README.md ├── CONTRIBUTING.md ├── pyproject.toml └── Makefile /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_utils/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapiutils/fastapi-utils/HEAD/docs/img/favicon.png -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | # basic 6 | target: auto 7 | threshold: 100% 8 | -------------------------------------------------------------------------------- /docs/src/class_resource_view1.py: -------------------------------------------------------------------------------- 1 | from fastapi_utils import Resource 2 | 3 | 4 | class MyApi(Resource): 5 | def get(self): 6 | return "done" 7 | -------------------------------------------------------------------------------- /docs/src/guid2.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | 3 | from fastapi_utils.guid_type import setup_guids_postgresql 4 | 5 | database_uri = "postgresql://user:password@db:5432/app" 6 | engine = sa.create_engine(database_uri) 7 | setup_guids_postgresql(engine) 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: pip 5 | directory: / 6 | schedule: 7 | interval: weekly 8 | - package-ecosystem: github-actions 9 | directory: / 10 | schedule: 11 | interval: weekly 12 | -------------------------------------------------------------------------------- /scripts/lock.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd .. && pwd)" 5 | cd "${PROJECT_ROOT}" 6 | 7 | set -x 8 | poetry lock 9 | poetry export --with dev -f requirements.txt >requirements_tmp.txt 10 | mv requirements_tmp.txt requirements.txt 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "strict", 3 | "python.testing.pytestArgs": [ 4 | "tests" 5 | ], 6 | "python.testing.unittestEnabled": false, 7 | "python.testing.pytestEnabled": true, 8 | "cSpell.words": [ 9 | "fastapi" 10 | ] 11 | } -------------------------------------------------------------------------------- /fastapi_utils/inferring_router.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import warnings 4 | 5 | from fastapi import APIRouter 6 | 7 | warnings.warn( 8 | "InferringRouter is deprecated, as its functionality is now provided in fastapi.APIRouter", DeprecationWarning 9 | ) 10 | 11 | InferringRouter = APIRouter 12 | -------------------------------------------------------------------------------- /docs/src/camelcase2.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import declarative_base, declared_attr 2 | 3 | from fastapi_utils.camelcase import camel2snake 4 | 5 | 6 | class CustomBase: 7 | @declared_attr 8 | def __tablename__(cls) -> str: 9 | return camel2snake(cls.__name__) 10 | 11 | 12 | Base = declarative_base(cls=CustomBase) 13 | -------------------------------------------------------------------------------- /docs/src/enums1.py: -------------------------------------------------------------------------------- 1 | from enum import auto 2 | 3 | from fastapi_utils.enums import StrEnum 4 | 5 | 6 | class MyEnum(StrEnum): 7 | choice_a = auto() 8 | choice_b = auto() 9 | 10 | 11 | assert MyEnum.choice_a.name == MyEnum.choice_a.value == "choice_a" 12 | assert MyEnum.choice_b.name == MyEnum.choice_b.value == "choice_b" 13 | -------------------------------------------------------------------------------- /docs/src/enums2.py: -------------------------------------------------------------------------------- 1 | from enum import auto 2 | 3 | from fastapi_utils.enums import CamelStrEnum 4 | 5 | 6 | class MyEnum(CamelStrEnum): 7 | choice_a = auto() 8 | choice_b = auto() 9 | 10 | 11 | assert MyEnum.choice_a.name == MyEnum.choice_a.value == "choiceOne" 12 | assert MyEnum.choice_b.name == MyEnum.choice_b.value == "choiceTwo" 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question 4 | title: "[QUESTION]" 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Description** 11 | 12 | How can I [...]? 13 | 14 | Is it possible to [...]? 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /docs/src/camelcase1.py: -------------------------------------------------------------------------------- 1 | from fastapi_utils.camelcase import camel2snake, snake2camel 2 | 3 | assert snake2camel("some_field_name", start_lower=False) == "SomeFieldName" 4 | assert snake2camel("some_field_name", start_lower=True) == "someFieldName" 5 | assert camel2snake("someFieldName") == "some_field_name" 6 | assert camel2snake("SomeFieldName") == "some_field_name" 7 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastapi-38", 3 | "image": "mcr.microsoft.com/devcontainers/python:1-3.8-bookworm", 4 | "features": { 5 | "ghcr.io/devcontainers/features/node:1": { 6 | "version": "latest" 7 | } 8 | }, 9 | "postCreateCommand": "pipx install poetry && poetry install", 10 | "forwardPorts": [ 11 | 2222 12 | ] 13 | } -------------------------------------------------------------------------------- /docs/src/api_settings.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from fastapi_utils.api_settings import get_api_settings 4 | 5 | 6 | def get_app() -> FastAPI: 7 | get_api_settings.cache_clear() 8 | settings = get_api_settings() 9 | app = FastAPI(**settings.fastapi_kwargs) 10 | # 11 | return app 12 | -------------------------------------------------------------------------------- /docs/src/class_resource_view2.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from docs.src.class_resource_view1 import MyApi 4 | from fastapi_utils import Api 5 | 6 | 7 | def create_app(): 8 | app = FastAPI() 9 | api = Api(app) 10 | 11 | myapi = MyApi() 12 | api.add_resource(myapi, "/uri") 13 | 14 | return app 15 | 16 | 17 | main = create_app() 18 | -------------------------------------------------------------------------------- /docs/css/custom.css: -------------------------------------------------------------------------------- 1 | a.external-link::after { 2 | /* \00A0 is a non-breaking space 3 | to make the mark be on the same line as the link 4 | */ 5 | content: "\00A0[↪]"; 6 | } 7 | 8 | a.internal-link::after { 9 | /* \00A0 is a non-breaking space 10 | to make the mark be on the same line as the link 11 | */ 12 | content: "\00A0↪"; 13 | } 14 | -------------------------------------------------------------------------------- /docs/img/icon-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/src/guid1.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | from sqlalchemy.orm import declarative_base 3 | 4 | from fastapi_utils.guid_type import GUID 5 | 6 | Base = declarative_base() 7 | 8 | 9 | class User(Base): 10 | __tablename__ = "user" 11 | id = sa.Column(GUID, primary_key=True) 12 | name = sa.Column(sa.String, nullable=False) 13 | related_id = sa.Column(GUID) # a nullable, related field 14 | -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | test_patterns = [ 4 | "tests/**", 5 | "test_*.py" 6 | ] 7 | 8 | [[analyzers]] 9 | name = "python" 10 | enabled = true 11 | 12 | [analyzers.meta] 13 | runtime_version = "3.x.x" 14 | max_line_length = 120 15 | type_checker = "mypy" 16 | 17 | [[transformers]] 18 | name = "black" 19 | enabled = true 20 | 21 | [[transformers]] 22 | name = "isort" 23 | enabled = true -------------------------------------------------------------------------------- /docs/src/openapi1.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | app = FastAPI() 4 | 5 | 6 | @app.get("/api/v1/resource/{resource_id}") 7 | def get_resource(resource_id: int) -> int: 8 | return resource_id 9 | 10 | 11 | path_spec = app.openapi()["paths"]["/api/v1/resource/{resource_id}"] 12 | operation_id = path_spec["get"]["operationId"] 13 | assert operation_id == "get_resource_api_v1_resource__resource_id__get" 14 | -------------------------------------------------------------------------------- /fastapi_utils/openapi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from fastapi import FastAPI 4 | from fastapi.routing import APIRoute 5 | 6 | 7 | def simplify_operation_ids(app: FastAPI) -> None: 8 | """ 9 | Simplify operation IDs so that generated clients have simpler api function names 10 | """ 11 | for route in app.routes: 12 | if isinstance(route, APIRoute): 13 | route.operation_id = route.name 14 | -------------------------------------------------------------------------------- /docs/src/class_resource_view3.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from pymongo import MongoClient 3 | 4 | from docs.src.class_resource_view1 import MyApi 5 | from fastapi_utils import Api 6 | 7 | 8 | def create_app(): 9 | app = FastAPI() 10 | api = Api(app) 11 | 12 | mongo_client = MongoClient("mongodb://localhost:27017") 13 | myapi = MyApi(mongo_client) 14 | api.add_resource(myapi, "/uri") 15 | 16 | return app 17 | 18 | 19 | main = create_app() 20 | -------------------------------------------------------------------------------- /docs/src/openapi2.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from fastapi_utils.openapi import simplify_operation_ids 4 | 5 | app = FastAPI() 6 | 7 | 8 | @app.get("/api/v1/resource/{resource_id}") 9 | def get_resource(resource_id: int) -> int: 10 | return resource_id 11 | 12 | 13 | simplify_operation_ids(app) 14 | 15 | path_spec = app.openapi()["paths"]["/api/v1/resource/{resource_id}"] 16 | operation_id = path_spec["get"]["operationId"] 17 | assert operation_id == "get_resource" 18 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | categories: 6 | - title: Breaking Changes 🛠 7 | labels: 8 | - Semver-Major 9 | - breaking-change 10 | - title: Exciting New Features 🎉 11 | labels: 12 | - Semver-Minor 13 | - enhancement 14 | - title: Other Changes 15 | labels: 16 | - "*" 17 | exclude: 18 | labels: 19 | - dependencies 20 | - title: 👒 Dependencies 21 | labels: 22 | - dependencies 23 | -------------------------------------------------------------------------------- /fastapi_utils/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | import warnings 3 | 4 | from .cbv_base import Api, Resource, set_responses, take_init_parameters 5 | 6 | try: 7 | __version__ = "0.8.0" 8 | except importlib.metadata.PackageNotFoundError as e: 9 | warnings.warn(f"Could not determine version of {__name__}", stacklevel=1) 10 | warnings.warn(str(e), stacklevel=1) 11 | __version__ = "0.8.0" 12 | 13 | 14 | __all__ = [ 15 | "Api", 16 | "Resource", 17 | "set_responses", 18 | "take_init_parameters", 19 | ] 20 | -------------------------------------------------------------------------------- /docs/src/inferring_router1.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | app = FastAPI() 4 | 5 | 6 | @app.get("/default") 7 | def get_resource(resource_id: int) -> str: 8 | # the response will be serialized as a JSON number, *not* a string 9 | return resource_id 10 | 11 | 12 | def get_response_schema(openapi_spec, endpoint_path): 13 | responses = openapi_spec["paths"][endpoint_path]["get"]["responses"] 14 | return responses["200"]["content"]["application/json"]["schema"] 15 | 16 | 17 | openapi_spec = app.openapi() 18 | assert get_response_schema(openapi_spec, "/default") == {} 19 | -------------------------------------------------------------------------------- /docs/src/api_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import NewType 3 | from uuid import UUID 4 | 5 | from fastapi import FastAPI 6 | 7 | from fastapi_utils.api_model import APIModel 8 | 9 | UserID = NewType("UserID", UUID) 10 | 11 | 12 | class User(APIModel): 13 | user_id: UserID 14 | email_address: str 15 | 16 | 17 | @dataclass 18 | class UserORM: 19 | """ 20 | You can pretend this class is a SQLAlchemy model 21 | """ 22 | 23 | user_id: UserID 24 | email_address: str 25 | 26 | 27 | app = FastAPI() 28 | 29 | 30 | @app.post("/users", response_model=User) 31 | async def create_user(user: User) -> UserORM: 32 | return UserORM(user.user_id, user.email_address) 33 | -------------------------------------------------------------------------------- /docs/src/repeated_tasks1.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from sqlalchemy.orm import Session 3 | 4 | from fastapi_utils.session import FastAPISessionMaker 5 | from fastapi_utils.tasks import repeat_every 6 | 7 | database_uri = f"sqlite:///./test.db?check_same_thread=False" 8 | sessionmaker = FastAPISessionMaker(database_uri) 9 | 10 | app = FastAPI() 11 | 12 | 13 | def remove_expired_tokens(db: Session) -> None: 14 | """Pretend this function deletes expired tokens from the database""" 15 | 16 | 17 | @app.on_event("startup") 18 | @repeat_every(seconds=60 * 60) # 1 hour 19 | def remove_expired_tokens_task() -> None: 20 | with sessionmaker.context_session() as db: 21 | remove_expired_tokens(db=db) 22 | -------------------------------------------------------------------------------- /tests/test_enums.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import auto 4 | 5 | from fastapi_utils.enums import CamelStrEnum, StrEnum 6 | 7 | 8 | class TestEnums: 9 | def test_str_enum(self) -> None: 10 | class MyStrEnum(StrEnum): 11 | choice_one = auto() 12 | choice_two = auto() 13 | 14 | values = [value for value in MyStrEnum] 15 | assert values == ["choice_one", "choice_two"] 16 | 17 | def test_camelcase_str_conversion(self) -> None: 18 | class MyCamelStrEnum(CamelStrEnum): 19 | choice_one = auto() 20 | choice_two = auto() 21 | 22 | values = [value for value in MyCamelStrEnum] 23 | assert values == ["choiceOne", "choiceTwo"] 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I want to be able to [...] but I can't because [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /tests/test_openapi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from fastapi import FastAPI 5 | 6 | from fastapi_utils.openapi import simplify_operation_ids 7 | 8 | 9 | @pytest.fixture 10 | def app() -> FastAPI: 11 | app = FastAPI() 12 | 13 | @app.get("/endpoint-path") 14 | def endpoint_name() -> str: # pragma: no cover 15 | return "" 16 | 17 | return app 18 | 19 | 20 | def test_base_spec(app: FastAPI) -> None: 21 | assert app.openapi()["paths"]["/endpoint-path"]["get"]["operationId"] == "endpoint_name_endpoint_path_get" 22 | 23 | 24 | def test_simplify_spec(app: FastAPI) -> None: 25 | simplify_operation_ids(app) 26 | assert app.openapi()["paths"]["/endpoint-path"]["get"]["operationId"] == "endpoint_name" 27 | -------------------------------------------------------------------------------- /tests/test_api_model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | import pydantic 6 | 7 | from fastapi_utils.api_model import APIModel 8 | 9 | PYDANTIC_VERSION = pydantic.VERSION 10 | 11 | 12 | def test_orm_mode() -> None: 13 | @dataclass 14 | class Data: 15 | x: int 16 | 17 | class Model(APIModel): 18 | x: int 19 | 20 | model_config = {"from_attributes": True} 21 | 22 | if PYDANTIC_VERSION[0] == "2": 23 | assert Model.model_validate(Data(x=1)).x == 1 24 | else: 25 | assert Model.from_orm(Data(x=1)).x == 1 26 | 27 | 28 | def test_aliases() -> None: 29 | class Model(APIModel): 30 | some_field: str 31 | 32 | assert Model(some_field="a").some_field == "a" 33 | assert Model(someField="a").some_field == "a" # type: ignore[call-arg] 34 | -------------------------------------------------------------------------------- /tests/test_api_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from _pytest.monkeypatch import MonkeyPatch 5 | from fastapi import FastAPI 6 | from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND 7 | from starlette.testclient import TestClient 8 | 9 | from fastapi_utils.api_settings import get_api_settings 10 | 11 | 12 | def get_app() -> FastAPI: 13 | get_api_settings.cache_clear() 14 | api_settings = get_api_settings() 15 | return FastAPI(**api_settings.fastapi_kwargs) 16 | 17 | 18 | @pytest.mark.parametrize("disable_docs,status_code", [("1", HTTP_404_NOT_FOUND), ("0", HTTP_200_OK)]) 19 | def test_enable_docs(monkeypatch: MonkeyPatch, disable_docs: str, status_code: int) -> None: 20 | monkeypatch.setenv("API_DISABLE_DOCS", disable_docs) 21 | app = get_app() 22 | response = TestClient(app).get("/docs") 23 | assert response.status_code == status_code 24 | -------------------------------------------------------------------------------- /docs/user-guide/basics/camelcase.md: -------------------------------------------------------------------------------- 1 | #### Source module: [`fastapi_utils.camelcase`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/camelcase.py){.internal-link target=_blank} 2 | 3 | --- 4 | 5 | The `fastapi_utils.camelcase` module contains functions for converting `camelCase` or `CamelCase` 6 | strings to `snake_case`, and vice versa: 7 | 8 | ```python hl_lines="" 9 | {!./src/camelcase1.py!} 10 | ``` 11 | 12 | These functions are used by [APIModel](api-model.md) to ensure `snake_case` can be used in your python code, 13 | and `camelCase` attributes in external `JSON`. 14 | 15 | But they can also come in handy in other places -- for example, you could use them to ensure tables 16 | declared using SQLAlchemy's declarative API are named using `snake_case`: 17 | 18 | ```python hl_lines="" 19 | {!./src/camelcase2.py!} 20 | ``` 21 | 22 | If you were to create a `class MyUser(Base):` using `Base` defined above, 23 | the resulting database table would be named `my_user`. 24 | -------------------------------------------------------------------------------- /fastapi_utils/camelcase.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | 6 | def snake2camel(snake: str, start_lower: bool = False) -> str: 7 | """ 8 | Converts a snake_case string to camelCase. 9 | 10 | The `start_lower` argument determines whether the first letter in the generated camelcase should 11 | be lowercase (if `start_lower` is True), or capitalized (if `start_lower` is False). 12 | """ 13 | camel = snake.title() 14 | camel = re.sub("([0-9A-Za-z])_(?=[0-9A-Z])", lambda m: m.group(1), camel) 15 | if start_lower: 16 | camel = re.sub("(^_*[A-Z])", lambda m: m.group(1).lower(), camel) 17 | return camel 18 | 19 | 20 | def camel2snake(camel: str) -> str: 21 | """ 22 | Converts a camelCase string to snake_case. 23 | """ 24 | snake = re.sub(r"([a-zA-Z])([0-9])", lambda m: f"{m.group(1)}_{m.group(2)}", camel) 25 | snake = re.sub(r"([a-z0-9])([A-Z])", lambda m: f"{m.group(1)}_{m.group(2)}", snake) 26 | return snake.lower() 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 David Montague, Yuval Levi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/src/class_resource_view4.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from fastapi_utils import Resource, set_responses 4 | 5 | 6 | # Setup 7 | class ResponseModel(BaseModel): 8 | answer: str 9 | 10 | 11 | class ResourceAlreadyExistsModel(BaseModel): 12 | is_found: bool 13 | 14 | 15 | class ResourceModel(BaseModel): 16 | ID: str 17 | name: str 18 | 19 | 20 | # Setup end 21 | 22 | 23 | class MyApi(Resource): 24 | def __init__(self, mongo_client): 25 | self.mongo = mongo_client 26 | 27 | @set_responses(ResponseModel) 28 | def get(self): 29 | return "Done" 30 | 31 | @set_responses(ResponseModel, 200) 32 | def put(self): 33 | return "Redone" 34 | 35 | @set_responses( 36 | ResponseModel, 37 | 201, 38 | { 39 | 409: { 40 | "description": "The path can't be found", 41 | "model": ResourceAlreadyExistsModel, 42 | } 43 | }, 44 | ) 45 | def post(self, res: ResourceModel): 46 | if self.mongo.is_resource_exist(res.name): 47 | return JSONResponse(409, content={"is_found": true}) 48 | return "Done again" 49 | -------------------------------------------------------------------------------- /tests/test_guid_type.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | from fastapi import FastAPI 6 | from starlette.testclient import TestClient 7 | 8 | from fastapi_utils.session import context_session 9 | from tests.conftest import User, session_maker 10 | 11 | 12 | def test_guid(test_app: FastAPI) -> None: 13 | name1 = "test_name_1" 14 | name2 = "test_name_2" 15 | user_id_1 = str(uuid.uuid4()) 16 | 17 | with context_session(session_maker.cached_engine) as session: 18 | user1 = User(id=user_id_1, name=name1) 19 | session.add(user1) 20 | session.commit() 21 | assert str(user1.id) == user_id_1 22 | assert user1.related_id is None 23 | 24 | with session_maker.context_session() as session: 25 | user2 = User(name=name2) 26 | assert user2.id is None 27 | session.add(user2) 28 | session.commit() 29 | user_id_2 = user2.id 30 | assert user_id_2 is not None 31 | assert user2.related_id is None 32 | 33 | test_client = TestClient(test_app) 34 | assert test_client.get(f"/{user_id_1}").json() == name1 35 | assert test_client.get(f"/{user_id_2}").json() == name2 36 | -------------------------------------------------------------------------------- /fastapi_utils/cbv_base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Tuple 2 | 3 | from fastapi import APIRouter, FastAPI 4 | 5 | from .cbv import INCLUDE_INIT_PARAMS_KEY, RETURN_TYPES_FUNC_KEY, _cbv 6 | 7 | 8 | class Resource: 9 | # raise NotImplementedError 10 | pass 11 | 12 | 13 | class Api: 14 | def __init__(self, app: FastAPI): 15 | self.app = app 16 | 17 | def add_resource(self, resource: Resource, *urls: str, **kwargs: Any) -> None: 18 | router = APIRouter() 19 | _cbv(router, type(resource), *urls, instance=resource) 20 | self.app.include_router(router, **kwargs) 21 | 22 | 23 | def take_init_parameters(cls: Any) -> Any: 24 | setattr(cls, INCLUDE_INIT_PARAMS_KEY, True) 25 | return cls 26 | 27 | 28 | def set_responses( 29 | response: Any, status_code: int = 200, responses: Optional[Dict[str, Any]] = None, **kwargs: Any 30 | ) -> Any: 31 | def decorator(func: Any) -> Any: 32 | def get_responses() -> Tuple[Any, int, Optional[Dict[str, Any]], Optional[Any]]: 33 | return response, status_code, responses, kwargs 34 | 35 | setattr(func, RETURN_TYPES_FUNC_KEY, get_responses) 36 | return func 37 | 38 | return decorator 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | If you can generate a relevant traceback, please include it. 14 | 15 | **To Reproduce** 16 | Steps to reproduce the behavior: 17 | 1. Create a file with '...' 18 | 2. Add a path operation function with '....' 19 | 3. Open the browser and call it with a payload of '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **Screenshots** 26 | If applicable, add screenshots to help explain your problem. 27 | 28 | **Environment:** 29 | - OS: [e.g. Linux / Windows / macOS] 30 | - FastAPI Utils, FastAPI, and Pydantic versions [e.g. `0.3.0`], get them with: 31 | 32 | ```Python 33 | import fastapi_utils 34 | import fastapi 35 | import pydantic.utils 36 | print(fastapi_utils.__version__) 37 | print(fastapi.__version__) 38 | print(pydantic.utils.version_info()) 39 | ``` 40 | 41 | - Python version, get it with: 42 | 43 | ```bash 44 | python --version 45 | ``` 46 | 47 | **Additional context** 48 | Add any other context about the problem here. 49 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | from collections.abc import Iterator 5 | from pathlib import Path 6 | 7 | import pytest 8 | from fastapi import FastAPI 9 | from sqlalchemy.exc import OperationalError 10 | from starlette.testclient import TestClient 11 | 12 | from tests.conftest import session_maker 13 | 14 | other_db_path = Path("./test2.db") 15 | other_db_uri = f"sqlite:///{other_db_path}?check_same_thread=False" 16 | 17 | 18 | @pytest.fixture() 19 | def use_uninitialized_db() -> Iterator[None]: 20 | if other_db_path.exists(): 21 | other_db_path.unlink() 22 | original_uri = session_maker.database_uri 23 | session_maker.database_uri = other_db_uri 24 | session_maker.reset_cache() 25 | yield 26 | session_maker.database_uri = original_uri 27 | session_maker.reset_cache() 28 | if other_db_path.exists(): 29 | other_db_path.unlink() 30 | 31 | 32 | def test_fail(test_app: FastAPI, use_uninitialized_db: None) -> None: 33 | test_client = TestClient(test_app) 34 | session_maker.reset_cache() 35 | session_maker.database_uri = other_db_uri 36 | random_id = uuid.uuid4() 37 | with pytest.raises(OperationalError) as exc_info: 38 | test_client.get(f"/{random_id}") 39 | assert "no such table: user" in str(exc_info.value) 40 | -------------------------------------------------------------------------------- /scripts/develop.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd .. && pwd)" 5 | cd "${PROJECT_ROOT}" 6 | 7 | check_for_python3() { 8 | command -v python3 >/dev/null 2>&1 || { 9 | cat <&2 10 | ***Required*** command not found: python3 11 | 12 | If pyenv is installed, you can install python3 via: 13 | 14 | pyenv install 3.8.1 # update version as desired 15 | 16 | See the following links for more information: 17 | * https://github.com/pyenv/pyenv 18 | * https://github.com/pyenv/pyenv-installer 19 | 20 | ERROR 21 | exit 1 22 | } 23 | } 24 | 25 | check_for_poetry() { 26 | command -v poetry >/dev/null 2>&1 || { 27 | cat <&2 28 | ***Required*** command not found: poetry 29 | 30 | This can be installed via: 31 | 32 | curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python3 33 | 34 | See the following links for more information: 35 | * https://poetry.eustace.io/docs/ 36 | * https://github.com/sdispater/poetry 37 | 38 | ERROR 39 | exit 1 40 | } 41 | } 42 | 43 | check_for_python3 44 | check_for_poetry 45 | 46 | set -x 47 | poetry install 48 | 49 | { set +x; } 2>/dev/null 50 | echo "" 51 | echo "Virtual environment interpreter details:" 52 | poetry env info 53 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push 5 | 6 | jobs: 7 | publish-tag: 8 | if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') 9 | runs-on: ubuntu-latest 10 | strategy: 11 | max-parallel: 1 12 | matrix: 13 | python-version: ['3.10'] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | # Avoid caching to be 100% confident things are working properly 22 | - name: Init python poetry action 23 | uses: abatilo/actions-poetry@v3.0.0 24 | with: 25 | poetry-version: 1.5.1 26 | 27 | - name: Install dependencies 28 | run: poetry install -E session 29 | 30 | - name: Check that formatting, linting, and tests pass for pydantic v1 31 | run: poetry run make ci-v1 32 | - name: Check that formatting, linting, and tests pass for pydantic v2 33 | run: poetry run make ci-v2 34 | 35 | - name: Build distribution 36 | run: poetry build 37 | 38 | - name: Publish distribution to PyPI 39 | run: poetry publish 40 | env: 41 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} 42 | 43 | -------------------------------------------------------------------------------- /fastapi_utils/enums.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | from typing import List 5 | 6 | from .camelcase import snake2camel 7 | 8 | 9 | class StrEnum(str, Enum): 10 | """ 11 | StrEnum subclasses that create variants using `auto()` will have values equal to their names 12 | Enums inheriting from this class that set values using `enum.auto()` will have variant values equal to their names 13 | """ 14 | 15 | @staticmethod 16 | def _generate_next_value_(name: str, start: int, count: int, last_values: List[str]) -> str: 17 | """ 18 | Uses the name as the automatic value, rather than an integer 19 | 20 | See https://docs.python.org/3/library/enum.html#using-automatic-values for reference 21 | """ 22 | return name 23 | 24 | 25 | class CamelStrEnum(str, Enum): 26 | """ 27 | CamelStrEnum subclasses that create variants using `auto()` will have values equal to their camelCase names 28 | """ 29 | 30 | @staticmethod 31 | def _generate_next_value_(name: str, start: int, count: int, last_values: List[str]) -> str: 32 | """ 33 | Uses the camelCase name as the automatic value, rather than an integer 34 | 35 | See https://docs.python.org/3/library/enum.html#using-automatic-values for reference 36 | """ 37 | return snake2camel(name, start_lower=True) 38 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: publish-docs 2 | 3 | permissions: 4 | id-token: write 5 | pages: write 6 | 7 | on: 8 | workflow_run: 9 | workflows: 10 | - publish 11 | types: 12 | - completed 13 | 14 | jobs: 15 | publish-docs: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | max-parallel: 1 19 | matrix: 20 | python-version: ['3.10'] 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | # Avoid caching to be 100% confident things are working properly 29 | - name: Init python poetry action 30 | uses: abatilo/actions-poetry@v3.0.0 31 | with: 32 | poetry-version: 1.5.1 33 | 34 | - name: Install dependencies 35 | run: poetry install -E session 36 | 37 | - name: Check docs are up to date 38 | run: poetry run make docs-build 39 | 40 | # Publish docs to github pages 41 | - name: Setup Pages 42 | uses: actions/configure-pages@v5 43 | - name: Upload artifact 44 | uses: actions/upload-pages-artifact@v3 45 | with: 46 | # Upload entire repository 47 | path: './site' 48 | - name: Deploy to GitHub Pages 49 | id: deployment 50 | uses: actions/deploy-pages@v4 51 | -------------------------------------------------------------------------------- /fastapi_utils/api_model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import partial 4 | 5 | import pydantic 6 | from pydantic import BaseModel 7 | 8 | from .camelcase import snake2camel 9 | 10 | PYDANTIC_VERSION = pydantic.VERSION 11 | 12 | if PYDANTIC_VERSION[0] == "2": 13 | from pydantic import ConfigDict 14 | else: 15 | from pydantic import BaseConfig 16 | 17 | 18 | class APIModel(BaseModel): 19 | """ 20 | Intended for use as a base class for externally-facing models. 21 | 22 | Any models that inherit from this class will: 23 | * accept fields using snake_case or camelCase keys 24 | * use camelCase keys in the generated OpenAPI spec 25 | * have orm_mode on by default 26 | * Because of this, FastAPI will automatically attempt to parse returned orm instances into the model 27 | """ 28 | 29 | if PYDANTIC_VERSION[0] == "2": 30 | model_config = ConfigDict( 31 | from_attributes=True, populate_by_name=True, alias_generator=partial(snake2camel, start_lower=True) 32 | ) 33 | else: 34 | 35 | class Config(BaseConfig): 36 | orm_mode = True 37 | allow_population_by_field_name = True 38 | alias_generator = partial(snake2camel, start_lower=True) 39 | 40 | 41 | class APIMessage(APIModel): 42 | """ 43 | A lightweight utility class intended for use with simple message-returning endpoints. 44 | """ 45 | 46 | detail: str 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | *.hdf5 3 | .idea/ 4 | 5 | env/ 6 | venv/ 7 | .venv/ 8 | env3*/ 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Mypy 19 | .mypy_cache 20 | .dmypy.json 21 | 22 | # CLion 23 | cmake*/ 24 | 25 | # Cython (may need to disable .c if using C extensions directly) 26 | *.c 27 | *.html 28 | 29 | # Distribution / packaging 30 | .Python 31 | build/ 32 | develop-eggs/ 33 | dist/ 34 | downloads/ 35 | eggs/ 36 | .eggs/ 37 | lib/ 38 | lib64/ 39 | parts/ 40 | pip-wheel-metadata/ 41 | sdist/ 42 | *.egg-info/ 43 | .installed.cfg 44 | *.egg 45 | MANIFEST 46 | var/projects 47 | var/.DS_STORE 48 | # PyInstaller 49 | # Usually these files are written by a python script from a template 50 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 51 | *.manifest 52 | *.spec 53 | 54 | # Installer logs 55 | pip-log.txt 56 | pip-delete-this-directory.txt 57 | 58 | # Unit test / coverage reports 59 | htmlcov/ 60 | .tox/ 61 | .coverage 62 | .coverage.* 63 | .cache 64 | nosetests.xml 65 | coverage.xml 66 | *.cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # Environments 74 | .env 75 | .venv 76 | env/ 77 | venv/ 78 | ENV/ 79 | env.bak/ 80 | venv.bak/ 81 | 82 | # File-based project format 83 | *.iws 84 | 85 | # Logs 86 | *.log 87 | 88 | # Pickles (usually for temp storage) 89 | *.pickle 90 | *.pkl 91 | 92 | site 93 | 94 | .tool-versions 95 | -------------------------------------------------------------------------------- /docs/src/session1.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import Iterator 3 | from uuid import UUID 4 | 5 | import sqlalchemy as sa 6 | from fastapi import Depends, FastAPI 7 | from pydantic import BaseSettings 8 | from sqlalchemy.orm import Session, declarative_base 9 | 10 | from fastapi_utils.guid_type import GUID, GUID_DEFAULT_SQLITE 11 | from fastapi_utils.session import FastAPISessionMaker 12 | 13 | Base = declarative_base() 14 | 15 | 16 | class User(Base): 17 | __tablename__ = "user" 18 | id = sa.Column(GUID, primary_key=True, default=GUID_DEFAULT_SQLITE) 19 | name = sa.Column(sa.String, nullable=False) 20 | 21 | 22 | class DBSettings(BaseSettings): 23 | """Parses variables from environment on instantiation""" 24 | 25 | database_uri: str # could break up into scheme, username, password, host, db 26 | 27 | 28 | def get_db() -> Iterator[Session]: 29 | """FastAPI dependency that provides a sqlalchemy session""" 30 | yield from _get_fastapi_sessionmaker().get_db() 31 | 32 | 33 | @lru_cache() 34 | def _get_fastapi_sessionmaker() -> FastAPISessionMaker: 35 | """This function could be replaced with a global variable if preferred""" 36 | database_uri = DBSettings().database_uri 37 | return FastAPISessionMaker(database_uri) 38 | 39 | 40 | app = FastAPI() 41 | 42 | 43 | @app.get("/{user_id}") 44 | def get_user_name(db: Session = Depends(get_db), *, user_id: UUID) -> str: 45 | user = db.get(User, user_id) 46 | username = user.name 47 | return username 48 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Iterator 3 | from uuid import UUID 4 | 5 | import pytest 6 | import sqlalchemy as sa 7 | from fastapi import Depends, FastAPI 8 | from sqlalchemy.ext.declarative import declarative_base 9 | from sqlalchemy.orm import Session 10 | 11 | from fastapi_utils.guid_type import GUID, GUID_DEFAULT_SQLITE 12 | from fastapi_utils.session import FastAPISessionMaker, get_engine 13 | 14 | Base = declarative_base() 15 | 16 | 17 | class User(Base): 18 | __tablename__ = "user" 19 | id = sa.Column(GUID, primary_key=True, default=GUID_DEFAULT_SQLITE) 20 | name = sa.Column(sa.String, nullable=False) 21 | related_id = sa.Column(GUID) 22 | 23 | 24 | test_db_path = Path("./test.db") 25 | database_uri = f"sqlite:///{test_db_path}?check_same_thread=False" 26 | session_maker = FastAPISessionMaker(database_uri=database_uri) 27 | 28 | 29 | def get_db() -> Iterator[Session]: 30 | yield from session_maker.get_db() 31 | 32 | 33 | app = FastAPI() 34 | 35 | 36 | @app.get("/{user_id}") 37 | def get_user_name(db: Session = Depends(get_db), *, user_id: UUID) -> str: 38 | user = db.query(User).get(user_id) 39 | if isinstance(user, User): 40 | username = user.name 41 | return username 42 | return "" 43 | 44 | 45 | @pytest.fixture(scope="module") 46 | def test_app() -> Iterator[FastAPI]: 47 | if test_db_path.exists(): 48 | test_db_path.unlink() 49 | 50 | engine = get_engine(database_uri) 51 | Base.metadata.create_all(bind=engine) 52 | 53 | yield app 54 | if test_db_path.exists(): 55 | test_db_path.unlink() 56 | -------------------------------------------------------------------------------- /tests/test_inferring_router.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Dict 4 | 5 | import pytest 6 | from fastapi import FastAPI 7 | 8 | with pytest.warns(DeprecationWarning): 9 | from fastapi_utils.inferring_router import InferringRouter 10 | 11 | OpenapiSchemaType = Dict[str, Any] 12 | 13 | 14 | def get_response_schema( 15 | openapi_spec: OpenapiSchemaType, endpoint_path: str, expected_status_code: int = 200 16 | ) -> OpenapiSchemaType: 17 | responses = openapi_spec["paths"][endpoint_path]["get"]["responses"] 18 | content = responses[str(expected_status_code)].get("content") 19 | return content["application/json"]["schema"] if content else content 20 | 21 | 22 | class TestInferringRouter: 23 | @pytest.fixture() 24 | def app(self) -> FastAPI: 25 | return FastAPI() 26 | 27 | @pytest.fixture() 28 | def inferring_router(self) -> InferringRouter: 29 | return InferringRouter() 30 | 31 | def test_inferring_route(self, app: FastAPI, inferring_router: InferringRouter) -> None: 32 | @inferring_router.get("/return_string") 33 | def endpoint_1() -> str: # pragma: no cover 34 | return "" 35 | 36 | @inferring_router.get("/return_integer", response_model=int) 37 | def endpoint_2() -> int: # pragma: no cover 38 | return 0 39 | 40 | app.include_router(inferring_router) 41 | openapi_spec = app.openapi() 42 | assert get_response_schema(openapi_spec, "/return_string")["type"] == "string" 43 | assert get_response_schema(openapi_spec, "/return_integer")["type"] == "integer" 44 | -------------------------------------------------------------------------------- /docs/src/timing1.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from fastapi import FastAPI 5 | from starlette.requests import Request 6 | from starlette.staticfiles import StaticFiles 7 | from starlette.testclient import TestClient 8 | 9 | from fastapi_utils.timing import add_timing_middleware, record_timing 10 | 11 | logging.basicConfig(level=logging.INFO) 12 | logger = logging.getLogger(__name__) 13 | 14 | app = FastAPI() 15 | add_timing_middleware(app, record=logger.info, prefix="app", exclude="untimed") 16 | static_files_app = StaticFiles(directory=".") 17 | app.mount(path="/static", app=static_files_app, name="static") 18 | 19 | 20 | @app.get("/timed") 21 | async def get_timed() -> None: 22 | await asyncio.sleep(0.05) 23 | 24 | 25 | @app.get("/untimed") 26 | async def get_untimed() -> None: 27 | await asyncio.sleep(0.1) 28 | 29 | 30 | @app.get("/timed-intermediate") 31 | async def get_with_intermediate_timing(request: Request) -> None: 32 | await asyncio.sleep(0.1) 33 | record_timing(request, note="halfway") 34 | await asyncio.sleep(0.1) 35 | 36 | 37 | TestClient(app).get("/timed") 38 | # INFO:__main__:TIMING: Wall: 53.0ms 39 | # | CPU: 1.2ms 40 | # | app.__main__.get_timed 41 | 42 | TestClient(app).get("/untimed") 43 | # 44 | 45 | TestClient(app).get("/timed-intermediate") 46 | # INFO:__main__:TIMING: Wall: 105.3ms 47 | # | CPU: 0.4ms 48 | # | app.__main__.get_with_intermediate_timing (halfway) 49 | # INFO:__main__:TIMING: Wall: 206.7ms 50 | # | CPU: 1.1ms 51 | # | app.__main__.get_with_intermediate_timing 52 | 53 | TestClient(app).get("/static/test") 54 | # INFO:__main__:TIMING: Wall: 1.6ms 55 | # | CPU: 1.6ms 56 | # | StaticFiles<'static'> 57 | -------------------------------------------------------------------------------- /docs/user-guide/class-resource.md: -------------------------------------------------------------------------------- 1 | Source module: [`fastapi_utils.cbv_base`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/cbv_base.py){.internal-link target=_blank} 2 | 3 | --- 4 | 5 | If you familiar with Flask-RESTful and you want to quickly create CRUD application, 6 | full of features and resources, and you also support OOP you might want to use this Resource based class 7 | 8 | --- 9 | 10 | Similar to Flask-RESTful all we have to do is create a class at inherit from `Resource` 11 | ```python 12 | {!./src/class_resource_view1.py!} 13 | ``` 14 | 15 | And then in `app.py` 16 | ```python hl_lines="1 4 9 12" 17 | {!./src/class_resource_view2.py!} 18 | ``` 19 | 20 | And that's it, You now got an app. 21 | 22 | --- 23 | 24 | Now how to handle things when it starting to get complicated: 25 | 26 | ##### Resource with dependencies 27 | Since initialization is taking place **before** adding the resource to the api, 28 | we can just insert our dependencies in the instance init: (`app.py`) 29 | ```python hl_lines="2 12 13 14" 30 | {!./src/class_resource_view3.py!} 31 | ``` 32 | 33 | #### Responses 34 | FastApi swagger is all beautiful with the responses and fit status codes, 35 | it is no sweat to declare those. 36 | 37 | Inside the resource class have `@set_responses` before the function 38 | ```python hl_lines="3 27 31 35 36 37 38 39 40 41 42 43 44" 39 | {!./src/class_resource_view4.py!} 40 | ``` 41 | 42 | Additional information about [responses can be found here](https://fastapi.tiangolo.com/advanced/additional-responses/) 43 | 44 | `@set_responses` also support kwargs of the original function which includes 45 | [different response classes](https://fastapi.tiangolo.com/advanced/custom-response/) and more! 46 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: tests-pull-request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | max-parallel: 3 13 | matrix: 14 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | 23 | - name: Init Python Poetry Action 24 | uses: abatilo/actions-poetry@v3.0.0 25 | with: 26 | poetry-version: 1.5.1 27 | - uses: actions/cache@v4 28 | id: cache-deps 29 | with: 30 | path: ~/.cache/pip 31 | key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('**/pyproject.toml') }} 32 | restore-keys: | 33 | ${{ runner.os }}-pip- 34 | 35 | - name: Install dependencies 36 | run: poetry install -E session 37 | - uses: actions/cache@v4 38 | with: 39 | path: .mypy_cache 40 | key: mypy-${{ matrix.python-version }} 41 | - uses: actions/cache@v4 42 | with: 43 | path: .pytest_cache 44 | key: pytest-${{ matrix.python-version }} 45 | - name: Check docs build 46 | # only run this for the python version used by netlify: 47 | if: matrix.python-version == '3.10' 48 | run: poetry run make docs-build 49 | - name: Check that formatting, linting, and tests pass for pydantic v1 50 | run: poetry run make ci-v1 51 | - name: Check that formatting, linting, and tests pass for pydantic v2 52 | run: poetry run make ci-v2 53 | 54 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: FastAPI Utilities 2 | site_description: FastAPI utilities 3 | site_url: https://fastapiutils.github.io/fastapi-utils// 4 | 5 | theme: 6 | name: "material" 7 | palette: 8 | primary: "green" 9 | accent: "orange" 10 | logo: "img/icon-white.svg" 11 | favicon: "img/favicon.png" 12 | 13 | repo_name: dmontagu/fastapi-utils 14 | repo_url: https://github.com/dmontagu/fastapi-utils 15 | 16 | nav: 17 | - FastAPI Utilities: "index.md" 18 | - User Guide: 19 | - Class Resource: "user-guide/class-resource.md" 20 | - Class Based Views: "user-guide/class-based-views.md" 21 | - Repeated Tasks: "user-guide/repeated-tasks.md" 22 | - Timing Middleware: "user-guide/timing-middleware.md" 23 | - SQLAlchemy Sessions: "user-guide/session.md" 24 | - OpenAPI Spec Simplification: "user-guide/openapi.md" 25 | - Other Utilities: 26 | - APIModel: "user-guide/basics/api-model.md" 27 | - APISettings: "user-guide/basics/api-settings.md" 28 | - String-Valued Enums: "user-guide/basics/enums.md" 29 | - CamelCase Conversion: "user-guide/basics/camelcase.md" 30 | - GUID Type: "user-guide/basics/guid-type.md" 31 | - Get Help: "help-fastapi-utils.md" 32 | - Development - Contributing: "contributing.md" 33 | - Release Notes: "release-notes.md" 34 | 35 | markdown_extensions: 36 | - toc: 37 | permalink: true 38 | - markdown.extensions.codehilite: 39 | guess_lang: false 40 | - markdown_include.include: 41 | base_path: docs 42 | - admonition 43 | - codehilite 44 | - extra 45 | - tables 46 | - smarty 47 | 48 | extra: 49 | social: 50 | - icon: "fontawesome/brands/github-alt" 51 | link: "https://github.com/dmontagu/fastapi-utils" 52 | 53 | extra_css: 54 | - "css/custom.css" 55 | -------------------------------------------------------------------------------- /docs/help-fastapi-utils.md: -------------------------------------------------------------------------------- 1 | Are you looking for help with `fastapi` or `fastapi_utils`? 2 | 3 | ## Help or Get Help with *FastAPI* 4 | 5 | Any improvements to FastAPI 6 | directly help this project! 7 | 8 | See more details about how to help FastAPI at 9 | https://fastapi.tiangolo.com/help-fastapi/ 10 | 11 | 12 | ## Star `fastapi-utils` on GitHub 13 | 14 | You can "star" `fastapi-utils` in [GitHub](https://github.com/dmontagu/fastapi-utils) (clicking the star button at the top right) 15 | 16 | Adding a star will help other users find this project more easily, and see that it has been useful for others. 17 | 18 | 19 | ## Connect with the author 20 | 21 | You can connect with the maintainer on 22 | [**GitHub** (`Yuval9313`)](https://github.com/yuval9313). 23 | 24 | * Follow me on [**GitHub**](https://github.com/yuval9313). 25 | * See other Open Source projects I have created that could help you. 26 | * Follow me to see when I create a new Open Source project. 27 | * Connect with me on [**Linkedin**](https://www.linkedin.com/in/levi-yuval-b50a73183/). 28 | * Talk to me. 29 | * Endorse me or recommend me :) 30 | 31 | 32 | ## Create issues 33 | 34 | You can create a new issue in the GitHub repository, for example to: 35 | 36 | * Report a bug/issue. 37 | * Suggest a new feature. 38 | * Ask a question. 39 | 40 | 41 | ## Create a Pull Request 42 | 43 | You can create a Pull Request, for example: 44 | 45 | * To fix a typo you found in the documentation. 46 | * To propose improvements to documentation. 47 | * To fix an existing issue/bug. 48 | * To add a new feature. 49 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | schedule: 8 | - cron: '0 0 * * *' # Nightly build 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | max-parallel: 3 15 | matrix: 16 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Init python poetry action 26 | uses: abatilo/actions-poetry@v3.0.0 27 | with: 28 | poetry-version: 1.5.1 29 | - uses: actions/cache@v4 30 | id: cache-deps 31 | with: 32 | path: ~/.cache/pip 33 | key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('**/pyproject.toml') }} 34 | restore-keys: | 35 | ${{ runner.os }}-pip- 36 | 37 | - name: Install dependencies 38 | run: poetry install -E session 39 | 40 | - uses: actions/cache@v4 41 | with: 42 | path: .mypy_cache 43 | key: mypy-${{ matrix.python-version }} 44 | - uses: actions/cache@v4 45 | with: 46 | path: .pytest_cache 47 | key: pytest-${{ matrix.python-version }} 48 | - name: Check docs build 49 | # only run this for the python version used by netlify: 50 | if: matrix.python-version == '3.10' 51 | run: poetry run make docs-build 52 | - name: Check that formatting, linting, and tests pass for pydantic v1 53 | run: poetry run make ci-v1 54 | - name: Check that formatting, linting, and tests pass for pydantic v2 55 | run: poetry run make ci-v2 56 | - name: Submit coverage report 57 | run: poetry run codecov --token=${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /docs/user-guide/openapi.md: -------------------------------------------------------------------------------- 1 | #### Source module: [`fastapi_utils.openapi`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/openapi.py){.internal-link target=_blank} 2 | 3 | --- 4 | 5 | One of the biggest benefits of working with FastAPI is the auto-generated OpenAPI spec, which enables 6 | integration with a variety of API development and documentation tooling, like Swagger UI and Redoc. 7 | 8 | A particularly powerful application of the OpenAPI spec is using it to generate an API client. 9 | 10 | The `openapi-generator` project makes it easy to generate API clients for a variety of languages based 11 | entirely on your OpenAPI spec. This is especially useful in situations where your server and client are 12 | implemented in different languages, or you have multiple clients to maintain (e.g., for native mobile apps). 13 | Using a generated client makes it easy to keep your client in sync with your server as you add or refactor endpoints. 14 | 15 | Typically, `openapi-generator` will use an endpoint's `operationId` to generate the name for the client function 16 | that hits the associated endpoint. 17 | 18 | When generating the OpenAPI spec, by default FastAPI includes the function name, endpoint path, and request method, 19 | in the generated `operationId`: 20 | 21 | ```python hl_lines="13" 22 | {!./src/openapi1.py!} 23 | ``` 24 | 25 | This is a good default behavior because it ensures that distinct endpoints on your server 26 | will have distinct `operationId`s. However, it also means that an auto-generated client will have 27 | extremely verbose function names like `getResourceApiV1ResourceResourceIdGet`. 28 | 29 | To simplify your operation IDs, you can use `fastapi_utils.openapi.simplify_operation_ids` to replace 30 | the generated operation IDs with ones generated using *only* the function name: 31 | 32 | ```python hl_lines="3 13 17" 33 | {!./src/openapi2.py!} 34 | ``` 35 | 36 | Note that this requires you to use different function names for each endpoint/method combination, or you 37 | will end up with conflicting `operationId`s. But this is usually pretty easy to ensure, and can 38 | significantly improve the naming used by your auto-generated API client(s). 39 | -------------------------------------------------------------------------------- /tests/test_camelcase.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from fastapi_utils.camelcase import camel2snake, snake2camel 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "value,result", 10 | [ 11 | ("snake_to_camel", "snakeToCamel"), 12 | ("snake_2_camel", "snake2Camel"), 13 | ("snake2camel", "snake2Camel"), 14 | ("_snake_to_camel", "_snakeToCamel"), 15 | ("snake_to_camel_", "snakeToCamel_"), 16 | ("__snake_to_camel__", "__snakeToCamel__"), 17 | ("snake_2", "snake2"), 18 | ("_snake_2", "_snake2"), 19 | ("snake_2_", "snake2_"), 20 | ], 21 | ) 22 | def test_snake2camel_start_lower(value: str, result: str) -> None: 23 | assert snake2camel(value, start_lower=True) == result 24 | 25 | 26 | @pytest.mark.parametrize( 27 | "value,result", 28 | [ 29 | ("snake_to_camel", "SnakeToCamel"), 30 | ("snake_2_camel", "Snake2Camel"), 31 | ("snake2camel", "Snake2Camel"), 32 | ("_snake_to_camel", "_SnakeToCamel"), 33 | ("snake_to_camel_", "SnakeToCamel_"), 34 | ("__snake_to_camel__", "__SnakeToCamel__"), 35 | ("snake_2", "Snake2"), 36 | ("_snake_2", "_Snake2"), 37 | ("snake_2_", "Snake2_"), 38 | ], 39 | ) 40 | def test_snake2camel(value: str, result: str) -> None: 41 | assert snake2camel(value) == result 42 | 43 | 44 | @pytest.mark.parametrize( 45 | "value,result", 46 | [ 47 | ("camel_to_snake", "camel_to_snake"), 48 | ("camelToSnake", "camel_to_snake"), 49 | ("camel2Snake", "camel_2_snake"), 50 | ("_camelToSnake", "_camel_to_snake"), 51 | ("camelToSnake_", "camel_to_snake_"), 52 | ("__camelToSnake__", "__camel_to_snake__"), 53 | ("CamelToSnake", "camel_to_snake"), 54 | ("Camel2Snake", "camel_2_snake"), 55 | ("_CamelToSnake", "_camel_to_snake"), 56 | ("CamelToSnake_", "camel_to_snake_"), 57 | ("__CamelToSnake__", "__camel_to_snake__"), 58 | ("Camel2", "camel_2"), 59 | ("Camel2_", "camel_2_"), 60 | ("_Camel2", "_camel_2"), 61 | ("camel2", "camel_2"), 62 | ("camel2_", "camel_2_"), 63 | ("_camel2", "_camel_2"), 64 | ], 65 | ) 66 | def test_camel2snake(value: str, result: str) -> None: 67 | assert camel2snake(value) == result 68 | -------------------------------------------------------------------------------- /docs/user-guide/basics/enums.md: -------------------------------------------------------------------------------- 1 | #### Source module: [`fastapi_utils.enums`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/enums.py){.internal-link target=_blank} 2 | 3 | --- 4 | 5 | Using enums as fields of a JSON payloads is a great way to force provided values into one 6 | of a limited number of self-documenting fields. 7 | 8 | However, integer-valued enums can make it more difficult to inspect payloads and debug endpoint calls, 9 | especially if the client and server are using different code bases. 10 | 11 | For most applications, the development benefits of using string-valued enums vastly outweigh the 12 | minimal performance/bandwidth tradeoffs. 13 | 14 | Creating a string-valued enum for use with pydantic/FastAPI that is properly encoded in the OpenAPI spec is 15 | as easy as inheriting from `str` in addition to `enum.Enum`: 16 | 17 | ```python 18 | from enum import Enum 19 | 20 | class MyEnum(str, Enum): 21 | value_a = "value_a" 22 | value_b = "value_b" 23 | ``` 24 | 25 | One nuisance with this approach is that if you rename one of the enum values (for example, using an IDE), 26 | you can end up with the name and value differing, which may lead to confusing errors. 27 | 28 | For example, if you refactored the above as follows (forgetting to change the associated values), you'll get 29 | pydantic parsing errors if you use the new *names* instead of the values in JSON bodies: 30 | 31 | ```python 32 | from enum import Enum 33 | 34 | class MyEnum(str, Enum): 35 | choice_a = "value_a" # pydantic would only parse "value_a" to MyEnum.choice_a 36 | choice_b = "value_b" 37 | ``` 38 | 39 | The standard library's `enum` package provides a way to automatically generate values: 40 | [`auto`](https://docs.python.org/3/library/enum.html#enum.auto). 41 | 42 | By default, `auto` will generate integer values, but this behavior can be overridden 43 | and the official python docs include a detailed section about 44 | [how to do this](https://docs.python.org/3/library/enum.html#using-automatic-values). 45 | 46 | Rather than repeating this definition in each new project, to reduce boilerplate 47 | you can just inherit from `fastapi_utils.enums.StrEnum` directly to get this behavior: 48 | 49 | ```python hl_lines="3 6" 50 | {!./src/enums1.py!} 51 | ``` 52 | 53 | You can also use `fastapi_utils.enums.CamelStrEnum` to get camelCase values: 54 | 55 | ```python hl_lines="3 6" 56 | {!./src/enums2.py!} 57 | ``` 58 | -------------------------------------------------------------------------------- /fastapi_utils/guid_type.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | from typing import TYPE_CHECKING, no_type_check 5 | 6 | import sqlalchemy as sa 7 | from sqlalchemy.dialects.postgresql.base import UUID 8 | from sqlalchemy.sql.sqltypes import CHAR 9 | from sqlalchemy.sql.type_api import TypeDecorator 10 | 11 | # Use the following as the value of server_default for primary keys of type GUID 12 | GUID_SERVER_DEFAULT_POSTGRESQL = sa.DefaultClause(sa.text("gen_random_uuid()")) 13 | GUID_DEFAULT_SQLITE = uuid.uuid4 14 | 15 | if TYPE_CHECKING: 16 | UUIDTypeDecorator = TypeDecorator[uuid.UUID] 17 | else: 18 | UUIDTypeDecorator = TypeDecorator 19 | 20 | 21 | class GUID(UUIDTypeDecorator): 22 | """ 23 | Platform-independent GUID type. 24 | 25 | Uses PostgreSQL's UUID type, otherwise uses CHAR(32), storing as stringified hex values. 26 | 27 | Taken from SQLAlchemy docs: https://docs.sqlalchemy.org/en/13/core/custom_types.html#backend-agnostic-guid-type 28 | """ 29 | 30 | impl = CHAR 31 | cache_ok = True 32 | 33 | @no_type_check 34 | def __init__(self, *args, **kwargs): 35 | super().__init__(*args, **kwargs) 36 | 37 | @no_type_check 38 | def load_dialect_impl(self, dialect): 39 | if dialect.name == "postgresql": # pragma: no cover 40 | return dialect.type_descriptor(UUID()) 41 | else: 42 | return dialect.type_descriptor(CHAR(32)) 43 | 44 | @no_type_check 45 | def process_bind_param(self, value, dialect): 46 | if value is None: 47 | return value 48 | elif dialect.name == "postgresql": # pragma: no cover 49 | return str(value) 50 | else: 51 | if not isinstance(value, uuid.UUID): 52 | return "%.32x" % uuid.UUID(value).int 53 | else: 54 | # hexstring 55 | return "%.32x" % value.int 56 | 57 | @no_type_check 58 | def process_result_value(self, value, dialect): 59 | if value is None: 60 | return value 61 | else: 62 | if not isinstance(value, uuid.UUID): # pragma: no branch 63 | value = uuid.UUID(value) 64 | return value 65 | 66 | 67 | def setup_guids_postgresql(engine: sa.engine.Engine) -> None: # pragma: no cover 68 | """ 69 | Set up UUID generation using the pgcrypto extension for postgres 70 | 71 | This query only needs to be executed once when the database is created 72 | """ 73 | engine.execute('create EXTENSION if not EXISTS "pgcrypto"') 74 | -------------------------------------------------------------------------------- /docs/user-guide/basics/api-model.md: -------------------------------------------------------------------------------- 1 | #### Source module: [`fastapi_utils.api_model`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/api_model.py){.internal-link target=_blank} 2 | 3 | --- 4 | 5 | One of the most common nuisances when developing a python web API is that python style typically involves 6 | `snake_case` attributes, whereas typical JSON style is to use `camelCase` field names. 7 | 8 | Fortunately, pydantic has built-in functionality to make it easy to have `snake_case` names for `BaseModel` attributes, 9 | and use `snake_case` attribute names when initializing model instances in your own code, 10 | but accept `camelCase` attributes from external requests. 11 | 12 | Another `BaseModel` config setting commonly used with FastAPI is `orm_mode`, which allows your models 13 | to be read directly from ORM objects (such as those used by SQLAlchemy). 14 | 15 | You can use `fastapi_utils.api_model.APIModel` to easily enable all of these frequently desirable settings. 16 | 17 | ## Create a model 18 | 19 | To make use of `APIModel`, just use it instead of `pydantic.BaseModel` as the base class of your pydantic models: 20 | 21 | 22 | ```python hl_lines="7 12" 23 | {!./src/api_model.py!} 24 | ``` 25 | 26 | !!! info 27 | You can use `typing.NewType` as above to (statically) ensure that you don't accidentally misuse an ID associated 28 | with one type of resource somewhere that an ID of another type of resource is expected. 29 | 30 | This is useful since it can be difficult, for example, to immediately recognize that you've passed a user ID where 31 | a product ID was supposed to go just by looking at its value. Using `typing.NewType` ensures mypy 32 | can check this for you. 33 | 34 | For a more detailed explanation and example, see 35 | [this GitHub issue comment](https://github.com/tiangolo/fastapi/issues/533#issuecomment-532597649) 36 | 37 | Now, you can make requests to endpoints expecting `User` as the body using either snake case: 38 | 39 | ```JSON 40 | { 41 | "user_id": "00000000-0000-0000-0000-000000000000", 42 | "email_address": "user@email.com" 43 | } 44 | ``` 45 | 46 | or camel case: 47 | 48 | ```JSON 49 | { 50 | "userId": "00000000-0000-0000-0000-000000000000", 51 | "emailAddress": "user@email.com" 52 | } 53 | ``` 54 | 55 | and both will work. 56 | 57 | In addition, if you set the `response_model` argument to the endpoint decorator and return an object that can't 58 | be converted to a dict, but has appropriately named fields, FastAPI will use pydantic's `orm_mode` to automatically 59 | serialize it. 60 | 61 | ```python hl_lines="30 32" 62 | {!./src/api_model.py!} 63 | ``` 64 | -------------------------------------------------------------------------------- /tests/test_cbv_base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Union 2 | 3 | from fastapi import FastAPI 4 | from fastapi.responses import PlainTextResponse 5 | from starlette.testclient import TestClient 6 | 7 | from fastapi_utils.cbv_base import Api, Resource, set_responses 8 | 9 | 10 | def test_cbv() -> None: 11 | class CBV(Resource): 12 | def __init__(self, z: int = 1): 13 | super().__init__() 14 | self.y = 1 15 | self.z = z 16 | 17 | @set_responses(int) 18 | def post(self, x: int) -> int: 19 | print(x) 20 | return x + self.y + self.z 21 | 22 | @set_responses(bool) 23 | def get(self) -> bool: 24 | return hasattr(self, "cy") 25 | 26 | app = FastAPI() 27 | api = Api(app) 28 | cbv = CBV(2) 29 | api.add_resource(cbv, "/", "/classvar") 30 | 31 | client = TestClient(app) 32 | response_1 = client.post("/", params={"x": 1}, json={}) 33 | assert response_1.status_code == 200 34 | assert response_1.content == b"4" 35 | 36 | response_2 = client.get("/classvar") 37 | assert response_2.status_code == 200 38 | assert response_2.content == b"false" 39 | 40 | 41 | def test_arg_in_path() -> None: 42 | class TestCBV(Resource): 43 | @set_responses(str) 44 | def get(self, item_id: str) -> str: 45 | return item_id 46 | 47 | app = FastAPI() 48 | api = Api(app) 49 | 50 | test_cbv_resource = TestCBV() 51 | api.add_resource(test_cbv_resource, "/{item_id}") 52 | 53 | assert TestClient(app).get("/test").json() == "test" 54 | 55 | 56 | def test_multiple_routes() -> None: 57 | class RootHandler(Resource): 58 | def get(self, item_path: Optional[str] = None) -> Union[List[Any], Dict[str, str]]: 59 | if item_path: 60 | return {"item_path": item_path} 61 | return [] 62 | 63 | app = FastAPI() 64 | api = Api(app) 65 | 66 | root_handler_resource = RootHandler() 67 | api.add_resource(root_handler_resource, "/items/?", "/items/{item_path:path}") 68 | 69 | client = TestClient(app) 70 | 71 | assert client.get("/items/1").json() == {"item_path": "1"} 72 | assert client.get("/items").json() == [] 73 | 74 | 75 | def test_different_response_model() -> None: 76 | class RootHandler(Resource): 77 | @set_responses({}, response_class=PlainTextResponse) 78 | def get(self) -> str: 79 | return "Done!" 80 | 81 | app = FastAPI() 82 | api = Api(app) 83 | 84 | api.add_resource(RootHandler(), "/check") 85 | 86 | client = TestClient(app) 87 | 88 | assert client.get("/check").text == "Done!" 89 | -------------------------------------------------------------------------------- /docs/user-guide/class-based-views.md: -------------------------------------------------------------------------------- 1 | #### Source module: [`fastapi_utils.cbv`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/cbv.py){.internal-link target=_blank} 2 | 3 | --- 4 | 5 | As you create more complex FastAPI applications, you may find yourself 6 | frequently repeating the same dependencies in multiple related endpoints. 7 | 8 | A common question people have as they become more comfortable with FastAPI 9 | is how they can reduce the number of times they have to copy/paste the same dependency 10 | into related routes. 11 | 12 | `fastapi_utils` provides a "class-based view" decorator (`@cbv`) to help reduce the amount of boilerplate 13 | necessary when developing related routes. 14 | 15 | ## A basic CRUD app 16 | 17 | Consider a basic create-read-update-delete (CRUD) app where users can create "Item" instances, 18 | but only the user that created an item is allowed to view or modify it: 19 | 20 | ```python hl_lines="61 62 74 75 85 86 100 101" 21 | {!./src/class_based_views1.py!} 22 | ``` 23 | 24 | If you look at the highlighted lines above, you can see `get_db` 25 | and `get_jwt_user` repeated in each endpoint. 26 | 27 | 28 | ## The `@cbv` decorator 29 | 30 | By using the `fastapi_utils.cbv.cbv` decorator, we can consolidate the 31 | endpoint signatures and reduce the number of repeated dependencies. 32 | 33 | To use the `@cbv` decorator, you need to: 34 | 35 | 1. Create an APIRouter to which you will add the endpoints 36 | 2. Create a class whose methods will be endpoints with shared depedencies, and decorate it with `@cbv(router)` 37 | 3. For each shared dependency, add a class attribute with a value of type `Depends` 38 | 4. Replace use of the original "unshared" dependencies with accesses like `self.dependency` 39 | 40 | Let's follow these steps to simplify the example above, while preserving all of the original logic: 41 | 42 | ```python hl_lines="11 58 61 63 64 65 69 70 71" 43 | {!./src/class_based_views2.py!} 44 | ``` 45 | 46 | The highlighted lines above show the results of performing each of the numbered steps. 47 | 48 | Note how the signature of each endpoint definition now includes only the parts specific 49 | to that endpoint. 50 | 51 | Hopefully this helps you to better reuse dependencies across endpoints! 52 | 53 | !!! info 54 | While it is not demonstrated above, you can also make use of custom instance-initialization logic 55 | by defining an `__init__` method on the CBV class. 56 | 57 | Arguments to the `__init__` function are injected by FastAPI in the same way they would be for normal 58 | functions. 59 | 60 | You should **not** make use of any arguments to `__init__` with the same name as any annotated instance attributes 61 | on the class. Those values will be set as attributes on the class instance prior to calling the `__init__` function 62 | you define, so you can still safely access them inside your custom `__init__` function if desired. 63 | -------------------------------------------------------------------------------- /docs/user-guide/timing-middleware.md: -------------------------------------------------------------------------------- 1 | #### Source module: [`fastapi_utils.timing`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/timing.py){.internal-link target=_blank} 2 | 3 | --- 4 | 5 | The `fastapi_utils.timing` module provides basic profiling functionality that could be 6 | used to find performance bottlenecks, monitor for regressions, etc. 7 | 8 | There are currently two public functions provided by this module: 9 | 10 | * `add_timing_middleware`, which can be used to add a middleware to a `FastAPI` app that will 11 | log very basic profiling information for each request (with low overhead). 12 | 13 | * `record_timing`, which can be called on a `starlette.requests.Request` instance for a `FastAPI` 14 | app with the timing middleware installed (via `add_timing_middleware`), and will emit performance 15 | information for the request at the point at which it is called. 16 | 17 | !!! tip 18 | If you are look for more fine-grained performance profiling data, consider 19 | `yappi`, 20 | a python profiling library that was recently updated with coroutine support to enable 21 | better coroutine-aware profiling. 22 | 23 | Note however that `yappi` adds considerable runtime overhead, and should typically be used during 24 | development rather than production. 25 | 26 | The middleware provided in this package is intended to be sufficiently performant for production use. 27 | 28 | 29 | ## Adding timing middleware 30 | 31 | The `add_timing_middleware` function takes the following arguments: 32 | 33 | * `app: FastAPI` : The app to which to add the timing middleware 34 | * `record: Optional[Callable[[str], None]] = None` : The callable to call on the generated timing messages. 35 | If not provided, defaults to `print`; a good choice is the `info` method of a `logging.Logger` instance 36 | * `prefix: str = ""` : A prefix to prepend to the generated route names. This can be useful for, e.g., 37 | distinguishing between mounted ASGI apps. 38 | * `exclude: Optional[str] = None` : If provided, any route whose generated name includes this value will not have its 39 | timing stats recorded. 40 | 41 | Here's an example demonstrating what the logged output looks like (note that the commented output has been 42 | split to multiple lines for ease of reading here, but each timing record is actually a single line): 43 | 44 | ```python hl_lines="15 37 42 45 53" 45 | {!./src/timing1.py!} 46 | ``` 47 | 48 | ## Recording intermediate timings 49 | 50 | In the above example, you can see the `get_with_intermediate_timing` function used in 51 | the `/timed-intermediate` endpoint to record an intermediate execution duration: 52 | 53 | ```python hl_lines="33 46 47 48" 54 | {!./src/timing1.py!} 55 | ``` 56 | 57 | Note that this requires the app that generated the `Request` instance to have had the timing middleware 58 | added using the `add_timing_middleware` function. 59 | 60 | This can be used to output multiple records at distinct times in order to introspect the relative 61 | contributions of different execution steps in a single endpoint. 62 | -------------------------------------------------------------------------------- /fastapi_utils/api_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import lru_cache 4 | from typing import Any 5 | 6 | import pydantic 7 | 8 | PYDANTIC_VERSION = pydantic.VERSION 9 | 10 | if PYDANTIC_VERSION[0] == "2": 11 | from pydantic_settings import BaseSettings, SettingsConfigDict 12 | else: 13 | from pydantic import BaseSettings # type: ignore[no-redef] 14 | 15 | 16 | class APISettings(BaseSettings): 17 | """ 18 | This class enables the configuration of your FastAPI instance through the use of environment variables. 19 | 20 | Any of the instance attributes can be overridden upon instantiation by either passing the desired value to the 21 | initializer, or by setting the corresponding environment variable. 22 | 23 | Attribute `xxx_yyy` corresponds to environment variable `API_XXX_YYY`. So, for example, to override 24 | `openapi_prefix`, you would set the environment variable `API_OPENAPI_PREFIX`. 25 | 26 | Note that assignments to variables are also validated, ensuring that even if you make runtime-modifications 27 | to the config, they should have the correct types. 28 | """ 29 | 30 | # fastapi.applications.FastAPI initializer kwargs 31 | debug: bool = False 32 | docs_url: str = "/docs" 33 | openapi_prefix: str = "" 34 | openapi_url: str = "/openapi.json" 35 | redoc_url: str = "/redoc" 36 | title: str = "FastAPI" 37 | version: str = "0.1.0" 38 | 39 | # Custom settings 40 | disable_docs: bool = False 41 | 42 | @property 43 | def fastapi_kwargs(self) -> dict[str, Any]: 44 | """ 45 | This returns a dictionary of the most commonly used keyword arguments when initializing a FastAPI instance 46 | 47 | If `self.disable_docs` is True, the various docs-related arguments are disabled, preventing your spec from being 48 | published. 49 | """ 50 | fastapi_kwargs: dict[str, Any] = { 51 | "debug": self.debug, 52 | "docs_url": self.docs_url, 53 | "openapi_prefix": self.openapi_prefix, 54 | "openapi_url": self.openapi_url, 55 | "redoc_url": self.redoc_url, 56 | "title": self.title, 57 | "version": self.version, 58 | } 59 | if self.disable_docs: 60 | fastapi_kwargs.update({"docs_url": None, "openapi_url": None, "redoc_url": None}) 61 | return fastapi_kwargs 62 | 63 | if PYDANTIC_VERSION[0] == "2": 64 | model_config = SettingsConfigDict(env_prefix="api_", validate_assignment=True) 65 | else: 66 | 67 | class Config: 68 | env_prefix = "api_" 69 | validate_assignment = True 70 | 71 | 72 | @lru_cache() 73 | def get_api_settings() -> APISettings: 74 | """ 75 | This function returns a cached instance of the APISettings object. 76 | 77 | Caching is used to prevent re-reading the environment every time the API settings are used in an endpoint. 78 | 79 | If you want to change an environment variable and reset the cache (e.g., during testing), this can be done 80 | using the `lru_cache` instance method `get_api_settings.cache_clear()`. 81 | """ 82 | return APISettings() 83 | -------------------------------------------------------------------------------- /tests/test_timing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING, Any 5 | 6 | import pytest 7 | from fastapi import FastAPI 8 | from starlette.requests import Request 9 | from starlette.staticfiles import StaticFiles 10 | from starlette.testclient import TestClient 11 | 12 | from fastapi_utils.timing import add_timing_middleware, record_timing 13 | 14 | if TYPE_CHECKING: 15 | from pytest.capture import CaptureFixture 16 | else: 17 | CaptureFixture = Any 18 | 19 | app = FastAPI() 20 | add_timing_middleware(app, exclude="untimed") 21 | static_files_app = StaticFiles(directory=".") 22 | app.mount(path="/static", app=static_files_app, name="static") 23 | 24 | 25 | @app.get("/timed") 26 | def get_timed() -> None: 27 | pass 28 | 29 | 30 | @app.get("/untimed") 31 | def get_untimed() -> None: 32 | pass 33 | 34 | 35 | client = TestClient(app) 36 | 37 | 38 | def test_timing(capsys: CaptureFixture[str]) -> None: 39 | client.get("/timed") 40 | out, err = capsys.readouterr() 41 | assert err == "" 42 | assert out.startswith("TIMING: Wall") 43 | assert "CPU:" in out 44 | assert out.endswith("test_timing.get_timed\n") 45 | 46 | 47 | def test_silent_timing(capsys: CaptureFixture[str]) -> None: 48 | client.get("/untimed") 49 | out, err = capsys.readouterr() 50 | assert err == "" 51 | assert out == "" 52 | 53 | 54 | def test_mount(capsys: CaptureFixture[str]) -> None: 55 | basename = Path(__file__).name 56 | client.get(f"/static/{basename}") 57 | out, err = capsys.readouterr() 58 | assert err == "" 59 | assert out.startswith("TIMING:") 60 | assert out.endswith("StaticFiles<'static'>\n") 61 | 62 | 63 | def test_missing(capsys: CaptureFixture[str]) -> None: 64 | client.get("/will-404") 65 | out, err = capsys.readouterr() 66 | assert err == "" 67 | assert out.startswith("TIMING:") 68 | assert out.endswith("\n") 69 | 70 | 71 | app2 = FastAPI() 72 | add_timing_middleware(app2, prefix="app2") 73 | 74 | 75 | @app2.get("/") 76 | def get_with_intermediate_timing(request: Request) -> None: 77 | record_timing(request, note="hello") 78 | 79 | 80 | client2 = TestClient(app2) 81 | 82 | 83 | def test_intermediate(capsys: CaptureFixture[str]) -> None: 84 | client2.get("/") 85 | out, err = capsys.readouterr() 86 | assert err == "" 87 | outs = out.strip().split("\n") 88 | assert len(outs) == 2 89 | assert outs[0].startswith("TIMING:") 90 | assert outs[0].endswith("test_timing.get_with_intermediate_timing (hello)") 91 | assert outs[1].startswith("TIMING:") 92 | assert outs[1].endswith("test_timing.get_with_intermediate_timing") 93 | 94 | 95 | app3 = FastAPI() 96 | 97 | 98 | @app3.get("/") 99 | def fail_to_record(request: Request) -> None: 100 | record_timing(request) 101 | 102 | 103 | client3 = TestClient(app3) 104 | 105 | 106 | def test_recording_fails_without_middleware() -> None: 107 | with pytest.raises(ValueError) as exc_info: 108 | client3.get("/") 109 | assert str(exc_info.value) == "No timer present on request" 110 | -------------------------------------------------------------------------------- /docs/src/class_based_views2.py: -------------------------------------------------------------------------------- 1 | from typing import NewType, Optional 2 | from uuid import UUID 3 | 4 | import sqlalchemy as sa 5 | from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException 6 | from sqlalchemy.orm import Session, declarative_base 7 | from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND 8 | 9 | from fastapi_utils.api_model import APIMessage, APIModel 10 | from fastapi_utils.cbv import cbv 11 | from fastapi_utils.guid_type import GUID 12 | 13 | # Begin Setup 14 | UserID = NewType("UserID", UUID) 15 | ItemID = NewType("ItemID", UUID) 16 | 17 | Base = declarative_base() 18 | 19 | 20 | class ItemORM(Base): 21 | __tablename__ = "item" 22 | 23 | item_id = sa.Column(GUID, primary_key=True) 24 | owner = sa.Column(GUID, nullable=False) 25 | name = sa.Column(sa.String, nullable=False) 26 | 27 | 28 | class ItemCreate(APIModel): 29 | name: str 30 | owner: UserID 31 | 32 | 33 | class ItemInDB(ItemCreate): 34 | item_id: ItemID 35 | 36 | 37 | def get_jwt_user(authorization: str = Header(...)) -> UserID: 38 | """Pretend this function gets a UserID from a JWT in the auth header""" 39 | 40 | 41 | def get_db() -> Session: 42 | """Pretend this function returns a SQLAlchemy ORM session""" 43 | 44 | 45 | def get_owned_item(session: Session, owner: UserID, item_id: ItemID) -> ItemORM: 46 | item: Optional[ItemORM] = session.get(ItemORM, item_id) 47 | if item is not None and item.owner != owner: 48 | raise HTTPException(status_code=HTTP_403_FORBIDDEN) 49 | if item is None: 50 | raise HTTPException(status_code=HTTP_404_NOT_FOUND) 51 | return item 52 | 53 | 54 | # End Setup 55 | app = FastAPI() 56 | router = APIRouter() # Step 1: Create a router 57 | 58 | 59 | @cbv(router) # Step 2: Create and decorate a class to hold the endpoints 60 | class ItemCBV: 61 | # Step 3: Add dependencies as class attributes 62 | session: Session = Depends(get_db) 63 | user_id: UserID = Depends(get_jwt_user) 64 | 65 | @router.post("/item") 66 | def create_item(self, item: ItemCreate) -> ItemInDB: 67 | # Step 4: Use `self.` to access shared dependencies 68 | item_orm = ItemORM(name=item.name, owner=self.user_id) 69 | self.session.add(item_orm) 70 | self.session.commit() 71 | return ItemInDB.from_orm(item_orm) 72 | 73 | @router.get("/item/{item_id}") 74 | def read_item(self, item_id: ItemID) -> ItemInDB: 75 | item_orm = get_owned_item(self.session, self.user_id, item_id) 76 | return ItemInDB.from_orm(item_orm) 77 | 78 | @router.put("/item/{item_id}") 79 | def update_item(self, item_id: ItemID, item: ItemCreate) -> ItemInDB: 80 | item_orm = get_owned_item(self.session, self.user_id, item_id) 81 | item_orm.name = item.name 82 | self.session.add(item_orm) 83 | self.session.commit() 84 | return ItemInDB.from_orm(item_orm) 85 | 86 | @router.delete("/item/{item_id}") 87 | def delete_item(self, item_id: ItemID) -> APIMessage: 88 | item = get_owned_item(self.session, self.user_id, item_id) 89 | self.session.delete(item) 90 | self.session.commit() 91 | return APIMessage(detail=f"Deleted item {item_id}") 92 | 93 | 94 | app.include_router(router) 95 | -------------------------------------------------------------------------------- /docs/release-notes.md: -------------------------------------------------------------------------------- 1 | ## Latest changes 2 | 3 | * Merge with [fastapi-utils](https://github.com/dmontagu/fastapi-utils) 4 | 5 | ## 0.6.0 6 | 7 | * Fix bug where `Request.url_for` is not working as intended [[yuval9313/FastApi-RESTful#90](https://github.com/yuval9313/FastApi-RESTful/issues/90)] 8 | * Update multiple dependencies using @dependebot 9 | * Fix `repeat_every` is only running once [#142](https://github.com/yuval9313/FastApi-RESTful/pull/142) 10 | 11 | ## 0.5.0 12 | 13 | * Bump sqlalchemy from 1.4.48 to 2.0.19 by @dependabot in #202 14 | * Pydantic v2 by @ollz272 in [#199](https://github.com/yuval9313/FastApi-RESTful/pull/199) 15 | * fix ci not run by @ollz272 in [#208](https://github.com/yuval9313/FastApi-RESTful/pull/208) 16 | 17 | ## 0.4.5 18 | 19 | * Change the lock of fastapi to enable more versions of it to be installed 20 | 21 | ## 0.4.4 22 | 23 | * Move to ruff for linting, etc. 24 | * Update various dependencies 25 | * Stop supporting Python 3.6 26 | * Deprecate InferringRouter (as its functionality is now built into `fastapi.APIRouter`) 27 | * Resolve various deprecationwarnings introduced by sqlalchemy 1.4. 28 | * Add support to Python 3.11 29 | * Change package description to avoid errors with pypi as [mentioned here](https://github.com/yuval9313/FastApi-RESTful/issues/175) 30 | 31 | ## 0.4.3 32 | 33 | * Fix bug where inferred router raises exception when no content is needed but type hint is provided (e.g. `None` as return type with status code 204) (As mentiond in [#134](https://github.com/yuval9313/FastApi-RESTful/pull/134)) 34 | * Improve tests and add more test cases 35 | * Bump dependencies versions 36 | 37 | ## 0.4.2 38 | 39 | * Remove version pinning to allow diversity in python environments 40 | 41 | ## 0.4.1 42 | 43 | * Add more pypi classifiers 44 | 45 | ## 0.4.0 46 | 47 | ** Breaking change ** 48 | * Remove support to python < 3.6.2 49 | 50 | Additionals: 51 | * Multiple version bumps 52 | * Add usage of **kwargs for to allow more options when including new router 53 | 54 | ## 0.3.1 55 | 56 | * [CVE-2021-29510](https://github.com/samuelcolvin/pydantic/security/advisories/GHSA-5jqp-qgf6-3pvh) fix of pydantic - update is required 57 | * Made sqlalchemy as extras installs 58 | 59 | ## 0.3.0 60 | 61 | * Add support for Python 3.9 :) 62 | * Fix case of duplicate routes when cbv used with prefix. (As mentioned in [#36](https://github.com/yuval9313/FastApi-RESTful/pull/36)) 63 | * Made repeatable task pre activate (`wait_first`) to be float instead of boolean (Mentioned here [#45](https://github.com/yuval9313/FastApi-RESTful/pull/45)) 64 | 65 | ## 0.2.4.1 66 | 67 | * Another docs fix 68 | * Rename package folder to small casing to ease imports 69 | 70 | ## 0.2.4 71 | 72 | * Mostly docs fixes 73 | 74 | ## 0.2.2 75 | 76 | * Add `Resorce` classes for more OOP like designing 77 | * Methods are now can be used as class names 78 | 79 | ## 0.2.1 80 | 81 | * Fix bug with multiple decorators on same method 82 | 83 | ## 0.2.0 84 | 85 | * Make some of the functions/classes in `fastapi_utils.timing` private to clarify the intended public API 86 | * Add documentation for `fastapi_utils.timing` module 87 | * Fix bug with ordering of routes in a CBV router 88 | 89 | ## 0.1.1 90 | 91 | * Add source docstrings for most functions. 92 | 93 | ## 0.1.0 94 | 95 | * Initial release. 96 | -------------------------------------------------------------------------------- /docs/src/class_based_views1.py: -------------------------------------------------------------------------------- 1 | from typing import NewType, Optional 2 | from uuid import UUID 3 | 4 | import sqlalchemy as sa 5 | from fastapi import Depends, FastAPI, Header, HTTPException 6 | from sqlalchemy.orm import Session, declarative_base 7 | from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND 8 | 9 | from fastapi_utils.api_model import APIMessage, APIModel 10 | from fastapi_utils.guid_type import GUID 11 | 12 | # Begin setup 13 | UserID = NewType("UserID", UUID) 14 | ItemID = NewType("ItemID", UUID) 15 | 16 | Base = declarative_base() 17 | 18 | 19 | class ItemORM(Base): 20 | __tablename__ = "item" 21 | 22 | item_id = sa.Column(GUID, primary_key=True) 23 | owner = sa.Column(GUID, nullable=False) 24 | name = sa.Column(sa.String, nullable=False) 25 | 26 | 27 | class ItemCreate(APIModel): 28 | name: str 29 | 30 | 31 | class ItemInDB(ItemCreate): 32 | item_id: ItemID 33 | owner: UserID 34 | 35 | 36 | def get_jwt_user(authorization: str = Header(...)) -> UserID: 37 | """Pretend this function gets a UserID from a JWT in the auth header""" 38 | 39 | 40 | def get_db() -> Session: 41 | """Pretend this function returns a SQLAlchemy ORM session""" 42 | 43 | 44 | def get_owned_item(session: Session, owner: UserID, item_id: ItemID) -> ItemORM: 45 | item: Optional[ItemORM] = session.get(ItemORM, item_id) 46 | if item is not None and item.owner != owner: 47 | raise HTTPException(status_code=HTTP_403_FORBIDDEN) 48 | if item is None: 49 | raise HTTPException(status_code=HTTP_404_NOT_FOUND) 50 | return item 51 | 52 | 53 | # End setup 54 | app = FastAPI() 55 | 56 | 57 | @app.post("/item", response_model=ItemInDB) 58 | def create_item( 59 | *, 60 | session: Session = Depends(get_db), 61 | user_id: UserID = Depends(get_jwt_user), 62 | item: ItemCreate, 63 | ) -> ItemInDB: 64 | item_orm = ItemORM(name=item.name, owner=user_id) 65 | session.add(item_orm) 66 | session.commit() 67 | return ItemInDB.from_orm(item_orm) 68 | 69 | 70 | @app.get("/item/{item_id}", response_model=ItemInDB) 71 | def read_item( 72 | *, 73 | session: Session = Depends(get_db), 74 | user_id: UserID = Depends(get_jwt_user), 75 | item_id: ItemID, 76 | ) -> ItemInDB: 77 | item_orm = get_owned_item(session, user_id, item_id) 78 | return ItemInDB.from_orm(item_orm) 79 | 80 | 81 | @app.put("/item/{item_id}", response_model=ItemInDB) 82 | def update_item( 83 | *, 84 | session: Session = Depends(get_db), 85 | user_id: UserID = Depends(get_jwt_user), 86 | item_id: ItemID, 87 | item: ItemCreate, 88 | ) -> ItemInDB: 89 | item_orm = get_owned_item(session, user_id, item_id) 90 | item_orm.name = item.name 91 | session.add(item_orm) 92 | session.commit() 93 | return ItemInDB.from_orm(item_orm) 94 | 95 | 96 | @app.delete("/item/{item_id}", response_model=APIMessage) 97 | def delete_item( 98 | *, 99 | session: Session = Depends(get_db), 100 | user_id: UserID = Depends(get_jwt_user), 101 | item_id: ItemID, 102 | ) -> APIMessage: 103 | item = get_owned_item(session, user_id, item_id) 104 | session.delete(item) 105 | session.commit() 106 | return APIMessage(detail=f"Deleted item {item_id}") 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Latest changes 2 | 3 | * Fix: tasks.repeat_every() and related tests [#305](https://github.com/dmontagu/fastapi-utils/issues/305) 4 | * Fix typo [#306](https://github.com/dmontagu/fastapi-utils/issues/306) 5 | * Merge with [fastapi-utils](https://github.com/dmontagu/fastapi-utils) 6 | 7 | ## 0.6.0 8 | 9 | * Fix bug where `Request.url_for` is not working as intended [[yuval9313/FastApi-RESTful#90](https://github.com/yuval9313/FastApi-RESTful/issues/90)] 10 | * Update multiple dependencies using @dependebot 11 | * Fix `repeat_every` is only running once [#142](https://github.com/yuval9313/FastApi-RESTful/pull/142) 12 | 13 | ## 0.5.0 14 | 15 | * Bump sqlalchemy from 1.4.48 to 2.0.19 by @dependabot in #202 16 | * Pydantic v2 by @ollz272 in [#199](https://github.com/yuval9313/FastApi-RESTful/pull/199) 17 | * fix ci not run by @ollz272 in [#208](https://github.com/yuval9313/FastApi-RESTful/pull/208) 18 | 19 | ## 0.4.5 20 | 21 | * Change the lock of fastapi to enable more versions of it to be installed 22 | 23 | ## 0.4.4 24 | 25 | * Move to ruff for linting, etc. 26 | * Update various dependencies 27 | * Stop supporting Python 3.6 28 | * Deprecate InferringRouter (as its functionality is now built into `fastapi.APIRouter`) 29 | * Resolve various deprecationwarnings introduced by sqlalchemy 1.4. 30 | * Add support to Python 3.11 31 | * Change package description to avoid errors with pypi as [mentioned here](https://github.com/yuval9313/FastApi-RESTful/issues/175) 32 | 33 | ## 0.4.3 34 | 35 | * Fix bug where inferred router raises exception when no content is needed but type hint is provided (e.g. `None` as return type with status code 204) (As mentiond in [#134](https://github.com/yuval9313/FastApi-RESTful/pull/134)) 36 | * Improve tests and add more test cases 37 | * Bump dependencies versions 38 | 39 | ## 0.4.2 40 | 41 | * Remove version pinning to allow diversity in python environments 42 | 43 | ## 0.4.1 44 | 45 | * Add more pypi classifiers 46 | 47 | ## 0.4.0 48 | 49 | ** Breaking change ** 50 | * Remove support to python < 3.6.2 51 | 52 | Additionals: 53 | * Multiple version bumps 54 | * Add usage of **kwargs for to allow more options when including new router 55 | 56 | ## 0.3.1 57 | 58 | * [CVE-2021-29510](https://github.com/samuelcolvin/pydantic/security/advisories/GHSA-5jqp-qgf6-3pvh) fix of pydantic - update is required 59 | * Made sqlalchemy as extras installs 60 | 61 | ## 0.3.0 62 | 63 | * Add support for Python 3.9 :) 64 | * Fix case of duplicate routes when cbv used with prefix. (As mentioned in [#36](https://github.com/yuval9313/FastApi-RESTful/pull/36)) 65 | * Made repeatable task pre activate (`wait_first`) to be float instead of boolean (Mentioned here [#45](https://github.com/yuval9313/FastApi-RESTful/pull/45)) 66 | 67 | ## 0.2.4.1 68 | 69 | * Another docs fix 70 | * Rename package folder to small casing to ease imports 71 | 72 | ## 0.2.4 73 | 74 | * Mostly docs fixes 75 | 76 | ## 0.2.2 77 | 78 | * Add `Resorce` classes for more OOP like designing 79 | * Methods are now can be used as class names 80 | 81 | ## 0.2.1 82 | 83 | * Fix bug with multiple decorators on same method 84 | 85 | ## 0.2.0 86 | 87 | * Make some of the functions/classes in `fastapi_utils.timing` private to clarify the intended public API 88 | * Add documentation for `fastapi_utils.timing` module 89 | * Fix bug with ordering of routes in a CBV router 90 | 91 | ## 0.1.1 92 | 93 | * Add source docstrings for most functions. 94 | 95 | ## 0.1.0 96 | 97 | * Initial release. 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Quicker FastApi developing tools 3 |

4 |

5 | 6 | 7 | Build CI 8 | 9 | 10 | Coverage 11 | 12 |
13 | 14 | Package version 15 | 16 | 17 | Python versions 18 | License 19 | 20 |

21 | 22 | --- 23 | **Documentation**: https://fastapiutils.github.io/fastapi-utils/ 24 | 25 | **Source Code**: https://github.com/dmontagu/fastapi-utils 26 | 27 | --- 28 | 29 | FastAPI is a modern, fast web framework for building APIs with Python 3.8+. 30 | 31 | But if you're here, you probably already knew that! 32 | 33 | --- 34 | 35 | ## Features 36 | 37 | This package includes a number of utilities to help reduce boilerplate and reuse common functionality across projects: 38 | 39 | * **Resource Class**: Create CRUD with ease the OOP way with `Resource` base class that lets you implement methods quick. 40 | * **Class Based Views**: Stop repeating the same dependencies over and over in the signature of related endpoints. 41 | * **Repeated Tasks**: Easily trigger periodic tasks on server startup 42 | * **Timing Middleware**: Log basic timing information for every request 43 | * **OpenAPI Spec Simplification**: Simplify your OpenAPI Operation IDs for cleaner output from OpenAPI Generator 44 | * **SQLAlchemy Sessions**: The `FastAPISessionMaker` class provides an easily-customized SQLAlchemy Session dependency 45 | 46 | --- 47 | 48 | It also adds a variety of more basic utilities that are useful across a wide variety of projects: 49 | 50 | * **APIModel**: A reusable `pydantic.BaseModel`-derived base class with useful defaults 51 | * **APISettings**: A subclass of `pydantic.BaseSettings` that makes it easy to configure FastAPI through environment variables 52 | * **String-Valued Enums**: The `StrEnum` and `CamelStrEnum` classes make string-valued enums easier to maintain 53 | * **CamelCase Conversions**: Convenience functions for converting strings from `snake_case` to `camelCase` or `PascalCase` and back 54 | * **GUID Type**: The provided GUID type makes it easy to use UUIDs as the primary keys for your database tables 55 | 56 | See the [docs](https://fastapiutils.github.io/fastapi-utils//) for more details and examples. 57 | 58 | ## Requirements 59 | 60 | This package is intended for use with any recent version of FastAPI (depending on `pydantic>=1.0`), and Python 3.8+. 61 | 62 | ## Installation 63 | 64 | ```bash 65 | pip install fastapi-utils # For basic slim package :) 66 | 67 | pip install fastapi-utils[session] # To add sqlalchemy session maker 68 | 69 | pip install fastapi-utils[all] # For all the packages 70 | ``` 71 | 72 | ## License 73 | 74 | This project is licensed under the terms of the MIT license. 75 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | Quicker FastApi developing tools 3 |

4 |

5 | 6 | 7 | Build CI 8 | 9 | 10 | Coverage 11 | 12 |
13 | 14 | Package version 15 | 16 | 17 | Python versions 18 | License 19 | 20 |

21 | 22 | --- 23 | **Documentation**: https://fastapiutils.github.io/fastapi-utils/ 24 | 25 | **Source Code**: https://github.com/dmontagu/fastapi-utils 26 | 27 | --- 28 | 29 | FastAPI is a modern, fast web framework for building APIs with Python 3.8+. 30 | 31 | But if you're here, you probably already knew that! 32 | 33 | --- 34 | 35 | ## Features 36 | 37 | This package includes a number of utilities to help reduce boilerplate and reuse common functionality across projects: 38 | 39 | * **Resource Class**: Create CRUD with ease the OOP way with `Resource` base class that lets you implement methods quick. 40 | * **Class Based Views**: Stop repeating the same dependencies over and over in the signature of related endpoints. 41 | * **Repeated Tasks**: Easily trigger periodic tasks on server startup 42 | * **Timing Middleware**: Log basic timing information for every request 43 | * **OpenAPI Spec Simplification**: Simplify your OpenAPI Operation IDs for cleaner output from OpenAPI Generator 44 | * **SQLAlchemy Sessions**: The `FastAPISessionMaker` class provides an easily-customized SQLAlchemy Session dependency 45 | 46 | --- 47 | 48 | It also adds a variety of more basic utilities that are useful across a wide variety of projects: 49 | 50 | * **APIModel**: A reusable `pydantic.BaseModel`-derived base class with useful defaults 51 | * **APISettings**: A subclass of `pydantic.BaseSettings` that makes it easy to configure FastAPI through environment variables 52 | * **String-Valued Enums**: The `StrEnum` and `CamelStrEnum` classes make string-valued enums easier to maintain 53 | * **CamelCase Conversions**: Convenience functions for converting strings from `snake_case` to `camelCase` or `PascalCase` and back 54 | * **GUID Type**: The provided GUID type makes it easy to use UUIDs as the primary keys for your database tables 55 | 56 | See the [docs](https://https://fastapiutils.github.io/fastapi-utils//) for more details and examples. 57 | 58 | ## Requirements 59 | 60 | This package is intended for use with any recent version of FastAPI (depending on `pydantic>=1.0`), and Python 3.8+. 61 | 62 | ## Installation 63 | 64 | ```bash 65 | pip install fastapi-utils # For basic slim package :) 66 | 67 | pip install fastapi-utils[session] # To add sqlalchemy session maker 68 | 69 | pip install fastapi-utils[all] # For all the packages 70 | ``` 71 | 72 | ## License 73 | 74 | This project is licensed under the terms of the MIT license. 75 | -------------------------------------------------------------------------------- /docs/user-guide/repeated-tasks.md: -------------------------------------------------------------------------------- 1 | #### Source module: [`fastapi_utils.tasks`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/tasks.py){.internal-link target=_blank} 2 | 3 | --- 4 | 5 | Startup and shutdown events are a great way to trigger actions related to the server lifecycle. 6 | 7 | However, sometimes you want a task to trigger not just when the server starts, but also 8 | on a periodic basis. For example, you might want to regularly reset an internal cache, or delete 9 | expired tokens from a database. 10 | 11 | You can accomplish this by triggering a loop inside a start-up event, but there are a few 12 | challenges to overcome: 13 | 14 | 1. You finish the startup event before the periodic loop ends (so the server can start!) 15 | 2. If the repeated tasks performs blocking IO, it shouldn't block the event loop 16 | 3. Exceptions raised by the periodic task shouldn't just be silently swallowed 17 | 18 | The `fastapi_utils.tasks.repeat_every` decorator handles all of these issues and adds some other conveniences as well. 19 | 20 | ## The `@repeat_every` decorator 21 | 22 | When a function decorated with the `@repeat_every(...)` decorator is called, a loop is started, 23 | and the function is called periodically with a delay determined by the `seconds` argument provided to the decorator. 24 | 25 | If you *also* apply the `@app.event("startup")` decorator, FastAPI will call the function during server startup, 26 | and the function will then be called repeatedly while the server is still running. 27 | 28 | Here's a hypothetical example that could be used to periodically clean up expired tokens: 29 | 30 | ```python hl_lines="5 18" 31 | {!./src/repeated_tasks1.py!} 32 | ``` 33 | 34 | (You may want to reference the [sessions docs](session.md){.internal-link target=_blank} for more 35 | information about `FastAPISessionMaker`.) 36 | 37 | By passing `seconds=60 * 60`, we ensure that the decorated function is called once every hour. 38 | 39 | Some other notes: 40 | 41 | * The wrapped function should not take any required arguments. 42 | * `repeat_every` function works right with both `async def` and `def` functions. 43 | * `repeat_every` is safe to use with `def` functions that perform blocking IO -- they are executed in a threadpool 44 | (just like `def` endpoints). 45 | 46 | 47 | ## Keyword arguments 48 | 49 | Here is a more detailed description of the various keyword arguments for `repeat_every`: 50 | 51 | * `seconds: float` : The number of seconds to wait between successive calls 52 | * `wait_first: bool = False` : If `False` (the default), the wrapped function is called immediately when the decorated 53 | function is first called. If `True`, the decorated function will wait one period before making the first call to the wrapped function 54 | * `logger: Optional[logging.Logger] = None` : If you pass a logger, any exceptions raised in the repeating execution loop will be logged (with a traceback) 55 | to the provided logger. 56 | * `raise_exceptions: bool = False` 57 | * If `False` (the default), exceptions are caught in the repeating execution loop, but are not raised. 58 | If you leave this argument `False`, you'll probably want to provide a `logger` to ensure your repeated events 59 | don't just fail silently. 60 | * If `True`, an exception will be raised. 61 | In order to handle this exception, you'll need to register an exception handler that is able to catch it 62 | For example, you could use `asyncio.get_running_loop().set_exception_handler(...)`, as documented 63 | [here](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.set_exception_handler). 64 | * Note that if an exception is raised, the repeated execution will stop. 65 | * `max_repetitions: Optional[int] = None` : If `None` (the default), the decorated function will keep repeating forever. 66 | Otherwise, it will stop repeated execution after the specified number of calls 67 | -------------------------------------------------------------------------------- /docs/user-guide/basics/api-settings.md: -------------------------------------------------------------------------------- 1 | #### Source module: [`fastapi_utils.api_settings`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/api_settings.py){.internal-link target=_blank} 2 | 3 | --- 4 | 5 | The `BaseSettings` class provided as part of pydantic makes it very easy to load variables 6 | from the environment for use as part of application configuration. 7 | 8 | This package provides a class called `APISettings` which makes it easy to set the most 9 | common configuration settings used with FastAPI through environment variables. 10 | 11 | It also provides an `lru_cache`-decorated function for accessing a cached settings 12 | instance to ensure maximum performance if you want to access the settings in endpoint 13 | functions. 14 | 15 | Even if you care about different settings in your own application, you can follow 16 | the patterns in `fastapi_utils.api_settings` to efficiently access environment-determined 17 | application configuration settings. 18 | 19 | ### Settings provided by `APISettings`: 20 | 21 | When initialized, `APISettings` reads the following environment variables into the specified attributes: 22 | 23 | Environment Variable | Attribute Name | Type | Default Value 24 | -------------------- | ---------------- | ------ | ------------- 25 | `API_DEBUG` | `debug` | `bool` | `False` 26 | `API_DOCS_URL` | `docs_url` | `str` | `"/docs` 27 | `API_OPENAPI_PREFIX` | `openapi_prefix` | `str` | `""` 28 | `API_OPENAPI_URL` | `openapi_url` | `str` | `"/openapi.json"` 29 | `API_REDOC_URL` | `redoc_url` | `str` | `"/redoc"` 30 | `API_TITLE` | `title` | `str` | `"FastAPI"` 31 | `API_VERSION` | `version` | `str` | `"0.1.0"` 32 | `API_DISABLE_DOCS` | `disable_docs` | `bool` | `False` 33 | 34 | `APISettings` also has a derived property `fastapi_kwargs` consisting of a dict with all of the attributes above except 35 | `disable_docs`. 36 | 37 | (Note that each of the keys of `fastapi_kwargs` are keyword arguments for `fastapi.FastAPI.__init__`.) 38 | 39 | If `disable_docs` is `True`, the values of `docs_url`, `redoc_url`, and `openapi_url` are all set to `None` 40 | in the `fastapi_kwargs` property value. 41 | 42 | 43 | ### Using `APISettings` to configure a `FastAPI` instance 44 | 45 | It is generally a good idea to initialize your `FastAPI` instance inside a function. 46 | This ensures that you never have access to a partially-configured instance of your app, 47 | and you can easily change settings and generate a new instance (for example during tests). 48 | 49 | Here's a simple example of what this might look like: 50 | 51 | ```python hl_lines="3" 52 | {!./src/api_settings.py!} 53 | ``` 54 | 55 | The `get_api_settings` just returns an instance of `APISettings`, but it is decorated with `lru_cache` 56 | to ensure that the expensive operation of reading and parsing environment variables is only 57 | done once, even if you were to frequently access the settings in endpoint code. 58 | 59 | However, we can make sure that settings are reloaded whenever the app is created by adding 60 | `get_api_settings.cache_clear()` to the app creation function, which resets the `lru_cache`: 61 | 62 | ```python hl_lines="7" 63 | {!./src/api_settings.py!} 64 | ``` 65 | 66 | You can then reload (and cache) the environment settings by calling `get_api_settings()`, 67 | and can get environment-determined keyword arguments for `FastAPI` from `api_settings.fastapi_kwargs`: 68 | 69 | ```python hl_lines="8 9" 70 | {!./src/api_settings.py!} 71 | ``` 72 | 73 | If none of the relevant environment variables are set, the resulting instance would have 74 | been initialized with the default keyword arguments of `FastAPI`. 75 | 76 | But, for example, if the `API_DISABLE_DOCS` environment variable had the value `"true"`, 77 | then the result of `get_app()` would be a `FastAPI` instance where `docs_url`, `redoc_url`, 78 | and `openapi_url` are all `None` (and the API docs are secure). 79 | 80 | This might be useful if you want to enable docs during development, but hide your OpenAPI schema 81 | and disable the docs endpoints in production. 82 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | First, you might want to see the basic ways to [help and get help](help-fastapi-utils.md){.internal-link target=_blank}. 2 | 3 | ## Developing 4 | 5 | Once you've cloned the repository, here are some guidelines to set up your environment: 6 | 7 | ### Set up the development evironment 8 | 9 | After cloning the repository, you can use `poetry` to create a virtual environment: 10 | 11 | ```console 12 | $ make develop 13 | ``` 14 | 15 | Behind the scenes, this checks that you have python3 and poetry installed, 16 | then creates a virtual environment and installs the dependencies. At the end, it will print out 17 | the path to the executable in case you want to add it to your IDE. 18 | 19 | 20 | ### Activate the environment 21 | 22 | Once the virtual environment is created, you can activate it with: 23 | 24 | ```console 25 | $ poetry shell 26 | ``` 27 | 28 | To check if this worked, try running: 29 | 30 | ```console 31 | $ which python 32 | 33 | some/directory/fastapi-utils-SOMETHING-py3.X/bin/python 34 | ``` 35 | 36 | If the output of this command shows the `python` binary in a path containing `fastapi-utils` somewhere in the name 37 | (as above), then it worked! 🎉 38 | 39 | !!! tip 40 | Every time you install a new package with `pip` under that environment, activate the environment again. 41 | 42 | This makes sure that if you use a terminal program installed by that package (like `mypy`), 43 | you use the one from your local environment and not any other that could be installed globally. 44 | 45 | ### Static Code Checks 46 | 47 | This project makes use of `black`, `autoflake8`, and `isort` for formatting, 48 | `flake8` for linting, and `mypy` for static type checking. 49 | 50 | 51 | To auto-format your code, just run: 52 | 53 | ```console 54 | $ make format 55 | ``` 56 | 57 | It will also auto-sort all your imports, and attempt to remove any unused imports. 58 | 59 | You can run flake8 with: 60 | 61 | ```console 62 | $ make lint 63 | ``` 64 | 65 | and you can run mypy with: 66 | 67 | ```console 68 | $ make mypy 69 | ``` 70 | 71 | There are a number of other useful makefile recipes; you can see basic documentation of these by calling plain `make`: 72 | 73 | ```console 74 | $ make 75 | ``` 76 | 77 | 78 | ## Docs 79 | 80 | The documentation uses MkDocs. 81 | 82 | All the documentation is in Markdown format in the directory `./docs`. 83 | 84 | Many of the sections in the User Guide have blocks of code. 85 | 86 | In fact, those blocks of code are not written inside the Markdown, they are Python files in the `./docs/src/` directory. 87 | 88 | And those Python files are included/injected in the documentation when generating the site. 89 | 90 | ### Docs for tests 91 | 92 | Most of the tests actually run against the example source files in the documentation. 93 | 94 | This helps making sure that: 95 | 96 | * The documentation is up to date. 97 | * The documentation examples can be run as is. 98 | * Most of the features are covered by the documentation, ensured by test coverage. 99 | 100 | During local development, there is a script that builds the site and checks for any changes, live-reloading: 101 | 102 | ```console 103 | $ bash scripts/docs-live.sh 104 | ``` 105 | 106 | It will serve the documentation on `http://0.0.0.0:8008`. 107 | 108 | That way, you can edit the documentation/source files and see the changes live. 109 | 110 | ## Tests 111 | 112 | You can run all tests via: 113 | 114 | ```console 115 | $ make test 116 | ``` 117 | 118 | You can also generate a coverage report with: 119 | 120 | ```console 121 | make testcov 122 | ``` 123 | 124 | On MacOS, if the tests all pass, the coverage report will be opened directly in a browser; on other operating systems 125 | a link will be printed to the local HTML containing the coverage report. 126 | 127 | ### Tests in your editor 128 | 129 | If you want to use the integrated tests in your editor add `./docs/src` to your `PYTHONPATH` variable. 130 | 131 | For example, in VS Code you can create a file `.env` with: 132 | 133 | ```env 134 | PYTHONPATH=./docs/src 135 | ``` 136 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | First, you might want to see the basic ways to [help and get help](help-fastapi-utils.md){.internal-link target=_blank}. 2 | 3 | ## Developing 4 | 5 | Once you've cloned the repository, here are some guidelines to set up your environment: 6 | 7 | ### Set up the development evironment 8 | 9 | After cloning the repository, you can use `poetry` to create a virtual environment: 10 | 11 | ```console 12 | $ make develop 13 | ``` 14 | 15 | Behind the scenes, this checks that you have python3 and poetry installed, 16 | then creates a virtual environment and installs the dependencies. At the end, it will print out 17 | the path to the executable in case you want to add it to your IDE. 18 | 19 | 20 | ### Activate the environment 21 | 22 | Once the virtual environment is created, you can activate it with: 23 | 24 | ```console 25 | $ poetry shell 26 | ``` 27 | 28 | To check if this worked, try running: 29 | 30 | ```console 31 | $ which python 32 | 33 | some/directory/fastapi-utils-SOMETHING-py3.X/bin/python 34 | ``` 35 | 36 | If the output of this command shows the `python` binary in a path containing `fastapi-utils` somewhere in the name 37 | (as above), then it worked! 🎉 38 | 39 | !!! tip 40 | Every time you install a new package with `pip` under that environment, activate the environment again. 41 | 42 | This makes sure that if you use a terminal program installed by that package (like `mypy`), 43 | you use the one from your local environment and not any other that could be installed globally. 44 | 45 | ### Static Code Checks 46 | 47 | This project makes use of `black`, `autoflake8`, and `isort` for formatting, 48 | `flake8` for linting, and `mypy` for static type checking. 49 | 50 | 51 | To auto-format your code, just run: 52 | 53 | ```console 54 | $ make format 55 | ``` 56 | 57 | It will also auto-sort all your imports, and attempt to remove any unused imports. 58 | 59 | You can run flake8 with: 60 | 61 | ```console 62 | $ make lint 63 | ``` 64 | 65 | and you can run mypy with: 66 | 67 | ```console 68 | $ make mypy 69 | ``` 70 | 71 | There are a number of other useful makefile recipes; you can see basic documentation of these by calling plain `make`: 72 | 73 | ```console 74 | $ make 75 | ``` 76 | 77 | 78 | ## Docs 79 | 80 | The documentation uses MkDocs. 81 | 82 | All the documentation is in Markdown format in the directory `./docs`. 83 | 84 | Many of the sections in the User Guide have blocks of code. 85 | 86 | In fact, those blocks of code are not written inside the Markdown, they are Python files in the `./docs/src/` directory. 87 | 88 | And those Python files are included/injected in the documentation when generating the site. 89 | 90 | ### Docs for tests 91 | 92 | Most of the tests actually run against the example source files in the documentation. 93 | 94 | This helps making sure that: 95 | 96 | * The documentation is up to date. 97 | * The documentation examples can be run as is. 98 | * Most of the features are covered by the documentation, ensured by test coverage. 99 | 100 | During local development, there is a script that builds the site and checks for any changes, live-reloading: 101 | 102 | ```console 103 | $ bash scripts/docs-live.sh 104 | ``` 105 | 106 | It will serve the documentation on `http://0.0.0.0:8008`. 107 | 108 | That way, you can edit the documentation/source files and see the changes live. 109 | 110 | ## Tests 111 | 112 | You can run all tests via: 113 | 114 | ```console 115 | $ make test 116 | ``` 117 | 118 | You can also generate a coverage report with: 119 | 120 | ```console 121 | make testcov 122 | ``` 123 | 124 | On MacOS, if the tests all pass, the coverage report will be opened directly in a browser; on other operating systems 125 | a link will be printed to the local HTML containing the coverage report. 126 | 127 | ### Tests in your editor 128 | 129 | If you want to use the integrated tests in your editor add `./docs/src` to your `PYTHONPATH` variable. 130 | 131 | For example, in VS Code you can create a file `.env` with: 132 | 133 | ```env 134 | PYTHONPATH=./docs/src 135 | ``` 136 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastapi-utils" 3 | version = "0.8.0" 4 | description = "Reusable utilities for FastAPI" 5 | license = "MIT" 6 | authors = ["Yuval Levi ", "David Montague "] 7 | readme = "README.md" 8 | homepage = "https://fastapiutils.github.io/fastapi-utils/" 9 | repository = "https://github.com/dmontagu/fastapi-utils" 10 | documentation = "https://fastapiutils.github.io/fastapi-utils/" 11 | keywords = ["fastapi", "OOP", "RESTful"] 12 | classifiers = [ 13 | "Intended Audience :: Information Technology", 14 | "Intended Audience :: System Administrators", 15 | "Intended Audience :: Developers", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python", 19 | "Topic :: Internet", 20 | "Topic :: Software Development :: Libraries :: Python Modules", 21 | "Topic :: Software Development :: Libraries", 22 | "Topic :: Software Development", 23 | "Typing :: Typed", 24 | "Development Status :: 4 - Beta", 25 | "Environment :: Web Environment", 26 | "Framework :: AsyncIO", 27 | "Framework :: FastAPI", 28 | "Intended Audience :: Developers", 29 | "License :: OSI Approved :: MIT License", 30 | "Programming Language :: Python :: 3 :: Only", 31 | "Programming Language :: Python :: 3.8", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 37 | "Topic :: Internet :: WWW/HTTP", 38 | "Topic :: Utilities" 39 | ] 40 | 41 | [tool.poetry.dependencies] 42 | python = "^3.8" 43 | 44 | fastapi = ">=0.89,<1.0" 45 | pydantic = ">1.0, <3.0" 46 | sqlalchemy = { version = ">=1.4,<3.0", optional = true } 47 | psutil = ">=5,<7" 48 | pydantic-settings = { version= "^2.0.1", optional = true } 49 | typing-inspect = { version = "^0.9.0", optional = true} 50 | 51 | [tool.poetry.group.dev.dependencies] 52 | # Starlette features 53 | aiofiles = "*" # Serving static files 54 | httpx = "*" # TestClient 55 | 56 | # Testing 57 | pytest = "^7.0" 58 | pytest-cov = "*" 59 | pytest-asyncio = [ 60 | {version = "^0.21.1", python = ">=3.8,<3.10"}, 61 | {version = "*", python = ">=3.10"} 62 | ] 63 | coverage = "*" 64 | codecov = "^2.1.13" 65 | pytest-timeout = "^2.3.1" 66 | 67 | # Documentation 68 | mkdocs = "*" 69 | mkdocs-material = "*" 70 | markdown-include = "*" 71 | autoflake = "^1.4" 72 | 73 | # Static 74 | ruff = ">0.3" 75 | black = { version = "*", allow-prereleases = true } 76 | mypy = "*" 77 | sqlalchemy-stubs = "*" 78 | types-setuptools = "*" # for pkg_resources import 79 | 80 | [tool.poetry.extras] 81 | all = ["sqlalchemy", "pydantic-settings", "typing-inspect"] 82 | session = ["sqlalchemy"] 83 | 84 | [tool.black] 85 | line-length = 120 86 | target_version = ['py39'] 87 | include = '\.pyi?$' 88 | exclude = ''' 89 | ( 90 | /( 91 | \.git 92 | | \.mypy_cache 93 | | \.pytest_cache 94 | | htmlcov 95 | | build 96 | )/ 97 | ) 98 | ''' 99 | 100 | [tool.ruff] 101 | line-length = 120 102 | 103 | [tool.ruff.lint] 104 | extend-select = ['RUF100', 'C90', 'I'] 105 | isort = { known-first-party = ['FastApi-RESTful', 'tests'] } 106 | 107 | 108 | [tool.coverage.run] 109 | source = ["fastapi_utils"] 110 | branch = true 111 | 112 | [tool.coverage.report] 113 | precision = 2 114 | exclude_lines = [ 115 | "pragma: no cover", 116 | "raise NotImplementedError", 117 | "raise NotImplemented", 118 | "@overload", 119 | "if TYPE_CHECKING:", 120 | 'if __name__ == "__main__":' 121 | ] 122 | 123 | [tool.pytest.ini_options] 124 | testpaths = "tests" 125 | filterwarnings = "ignore:.*The explicit passing of coroutine objects to asyncio.wait()*:DeprecationWarning" 126 | 127 | 128 | [tool.mypy] 129 | plugins = "pydantic.mypy,sqlmypy" 130 | 131 | follow_imports = "silent" 132 | strict_optional = true 133 | warn_redundant_casts = true 134 | warn_unused_ignores = true 135 | disallow_any_generics = true 136 | check_untyped_defs = true 137 | ignore_missing_imports = true 138 | disallow_untyped_defs = true 139 | 140 | [tool.poetry-version-plugin] 141 | source = "init" 142 | 143 | [build-system] 144 | requires = ["poetry_core>=1.0.0"] 145 | build-backend = "poetry.core.masonry.api" 146 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | .DEFAULT_GOAL := help 3 | 4 | pkg_src = fastapi_utils 5 | tests_src = tests 6 | docs_src = docs/src 7 | all_src = $(pkg_src) $(tests_src) 8 | 9 | mypy_base = mypy --show-error-codes 10 | mypy = $(mypy_base) $(all_src) 11 | test = pytest --cov=$(pkg_src) 12 | 13 | .PHONY: all ## Run the most common rules used during development 14 | all: static test 15 | 16 | .PHONY: static ## Perform all static checks (format, mypy) 17 | static: format lint mypy 18 | 19 | .PHONY: test ## Run tests 20 | test: 21 | $(test) 22 | 23 | .PHONY: format ## Auto-format the source code (ruff, black) 24 | format: 25 | black $(all_src) 26 | black -l 82 $(docs_src) 27 | ruff check --fix $(all_src) 28 | 29 | .PHONY: lint 30 | lint: 31 | ruff check $(all_src) 32 | black --check --diff $(all_src) 33 | black -l 82 $(docs_src) --check --diff 34 | 35 | .PHONY: mypy ## Run mypy over the application source and tests 36 | mypy: 37 | $(mypy) 38 | 39 | .PHONY: testcov ## Run tests, generate a coverage report, and open in browser 40 | testcov: 41 | $(test) 42 | @echo "building coverage html" 43 | @coverage html 44 | @echo "A coverage report was generated at htmlcov/index.html" 45 | @if [ "$$(uname -s)" = "Darwin" ]; then \ 46 | open htmlcov/index.html; \ 47 | fi 48 | 49 | .PHONY: ci-v1 ## Run all CI validation steps without making any changes to code in pydantic v1 50 | ci-v1: install-v1 lint test 51 | 52 | 53 | .PHONY: ci-v2 ## Run all CI validation steps without making any changes to code in pydantic v2 54 | ci-v2: install-v2 lint mypy test 55 | 56 | 57 | install-v1: 58 | poetry run python -m pip uninstall "pydantic-settings" -y 59 | poetry run python -m pip uninstall "typing-inspect" -y 60 | poetry run python -m pip install "pydantic>=1.10,<2.0.0" 61 | 62 | install-v2: 63 | poetry run python -m pip install "pydantic>=2.0.0,<3.0.0" 64 | poetry run python -m pip install "pydantic-settings>=2.0.0,<3.0.0" 65 | poetry run python -m pip install "typing-inspect>=0.9.0,<1.0.0" 66 | 67 | 68 | .PHONY: clean ## Remove temporary and cache files/directories 69 | clean: 70 | rm -f `find . -type f -name '*.py[co]' ` 71 | rm -f `find . -type f -name '*~' ` 72 | rm -f `find . -type f -name '.*~' ` 73 | rm -f `find . -type f -name .coverage` 74 | rm -f `find . -type f -name ".coverage.*"` 75 | rm -rf `find . -name __pycache__` 76 | rm -rf `find . -type d -name '*.egg-info' ` 77 | rm -rf `find . -type d -name 'pip-wheel-metadata' ` 78 | rm -rf `find . -type d -name .pytest_cache` 79 | rm -rf `find . -type d -name .cache` 80 | rm -rf `find . -type d -name .mypy_cache` 81 | rm -rf `find . -type d -name .ruff_cache` 82 | rm -rf `find . -type d -name htmlcov` 83 | rm -rf `find . -type d -name "*.egg-info"` 84 | rm -rf `find . -type d -name build` 85 | rm -rf `find . -type d -name dist` 86 | 87 | .PHONY: lock ## Update the lockfile 88 | lock: 89 | ./scripts/lock.sh 90 | 91 | .PHONY: develop ## Set up the development environment, or reinstall from the lockfile 92 | develop: 93 | ./scripts/develop.sh 94 | 95 | .PHONY: version ## Bump the version in both pyproject.toml and __init__.py (usage: `make version version=minor`) 96 | version: poetryversion 97 | $(eval NEW_VERS := $(shell cat pyproject.toml | grep "^version = \"*\"" | cut -d'"' -f2)) 98 | @sed -i "s/__version__ = .*/__version__ = \"$(NEW_VERS)\"/g" $(pkg_src)/__init__.py 99 | 100 | .PHONY: docs-build ## Generate the docs and update README.md 101 | docs-build: 102 | cp ./README.md ./docs/index.md 103 | cp ./CONTRIBUTING.md ./docs/contributing.md 104 | cp ./CHANGELOG.md ./docs/release-notes.md 105 | pip install mkdocs mkdocs-material markdown-include 106 | python -m mkdocs build 107 | 108 | .PHONY: docs-format ## Format the python code that is part of the docs 109 | docs-format: 110 | ruff check $(docs_src) 111 | autoflake -r --remove-all-unused-imports --ignore-init-module-imports $(docs_src) -i 112 | black -l 82 $(docs_src) 113 | 114 | .PHONY: docs-live ## Serve the docs with live reload as you make changes 115 | docs-live: 116 | mkdocs serve --dev-addr 0.0.0.0:8008 117 | 118 | .PHONY: poetryversion 119 | poetryversion: 120 | poetry version $(version) 121 | 122 | .PHONY: help ## Display this message 123 | help: 124 | @grep -E \ 125 | '^.PHONY: .*?## .*$$' $(MAKEFILE_LIST) | \ 126 | sort | \ 127 | awk 'BEGIN {FS = ".PHONY: |## "}; {printf "\033[36m%-16s\033[0m %s\n", $$2, $$3}' 128 | -------------------------------------------------------------------------------- /docs/user-guide/basics/guid-type.md: -------------------------------------------------------------------------------- 1 | #### Source module: [`fastapi_utils.guid_type`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/guid_type.py){.internal-link target=_blank} 2 | 3 | --- 4 | 5 | The two most common types used for primary keys in database tables are integers and UUIDs 6 | (sometimes referred to as GUIDs). 7 | 8 | There are a number of tradeoffs to make when deciding whether to use integers vs. UUIDs, 9 | including: 10 | 11 | * UUIDs don't reveal anything about the number of records in a table 12 | * UUIDs are practically impossible for an adversary to guess (though you shouldn't rely solely on that for security!) 13 | * UUIDs are harder to communicate/remember 14 | * UUIDs may result in worse performance for certain access patterns due to the random ordering 15 | 16 | You'll have to decide based on your application which is right for you, but if you want to 17 | use UUIDs/GUIDs for your primary keys, there are some difficulties to navigate. 18 | 19 | ## Challenges using UUID-valued primary keys with sqlalchemy 20 | 21 | Python has support for UUIDs in the standard library, and most relational databases 22 | have good support for them as well. 23 | 24 | However, if you want a database-agnostic or database-driver-agnostic type, you may run into 25 | challenges. 26 | 27 | In particular, the postgres-compatible UUID type provided by sqlalchemy (`sqlalchemy.dialects.postgresql.UUID`) 28 | will not work with other databases, and it also doesn't come with a way to set a server-default, meaning that 29 | you'll always need to take responsibility for generating an ID in your application code. 30 | 31 | Even worse, if you try to use the postgres-compatible UUID type simultaneously with both `sqlalchemy` and the 32 | `encode/databases` package, you may run into issues where queries using one require you to set `UUID(as_uuid=True)`, 33 | when declaring the column, and the other requires you to declare the table using `UUID(as_uuid=False)`. 34 | 35 | Fortunately, sqlalchemy provides a 36 | [backend-agnostic implementation of GUID type](https://docs.sqlalchemy.org/en/13/core/custom_types.html#backend-agnostic-guid-type) 37 | that uses the postgres-specific UUID type when possible, and more carefully parses the result to ensure 38 | `uuid.UUID` isn't called on something that is already a `uuid.UUID` (which raises an error). 39 | 40 | For convenience, this package includes this `GUID` type, along with conveniences for setting up server defaults 41 | for primary keys of this type. 42 | 43 | ## Using GUID 44 | 45 | You can create a sqlalchemy table with a GUID as a primary key using the declarative API like this: 46 | 47 | ```python hl_lines="" 48 | {!./src/guid1.py!} 49 | ``` 50 | 51 | ## Server Default 52 | If you want to add a server default, it will no longer be backend-agnostic, but 53 | you can use `fastapi_utils.guid_type.GUID_SERVER_DEFAULT_POSTGRESQL`: 54 | 55 | ```python 56 | import sqlalchemy as sa 57 | from sqlalchemy.orm import declarative_base 58 | 59 | from fastapi_utils.guid_type import GUID, GUID_SERVER_DEFAULT_POSTGRESQL 60 | 61 | Base = declarative_base() 62 | 63 | 64 | class User(Base): 65 | __tablename__ = "user" 66 | id = sa.Column( 67 | GUID, 68 | primary_key=True, 69 | server_default=GUID_SERVER_DEFAULT_POSTGRESQL 70 | ) 71 | name = sa.Column(sa.String, nullable=False) 72 | related_id = sa.Column(GUID) 73 | ``` 74 | (Behind the scenes, this is essentially just setting the server-side default to `"gen_random_uuid()"`.) 75 | 76 | Note this will only work if you have installed the `pgcrypto` extension 77 | in your postgres instance. If the user you connect with has the right privileges, this can be done 78 | by calling the `fastapi_utils.guid_type.setup_guids_postgresql` function: 79 | 80 | ```python 81 | {!./src/guid2.py!} 82 | ``` 83 | 84 | ## Non-Server Default 85 | 86 | If you are comfortable having no server default for your primary key column, you can still 87 | make use of an application-side default (so that `sqlalchemy` will generate a default value when you 88 | create new records): 89 | 90 | ```python 91 | import sqlalchemy as sa 92 | from sqlalchemy.orm import declarative_base 93 | 94 | from fastapi_utils.guid_type import GUID, GUID_DEFAULT_SQLITE 95 | 96 | Base = declarative_base() 97 | 98 | 99 | class User(Base): 100 | __tablename__ = "user" 101 | id = sa.Column(GUID, primary_key=True, default=GUID_DEFAULT_SQLITE) 102 | name = sa.Column(sa.String, nullable=False) 103 | related_id = sa.Column(GUID) 104 | ``` 105 | 106 | `GUID_DEFAULT_SQLITE` is just an alias for the standard library `uuid.uuid4`, 107 | which could be used in its place. 108 | -------------------------------------------------------------------------------- /docs/user-guide/session.md: -------------------------------------------------------------------------------- 1 | #### Source module: [`fastapi_utils.sessions`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/session.py){.internal-link target=_blank} 2 | 3 | !!! Note 4 | #### To use please install with: `pip install fastapi-restful[session]` or `pip install fastapi-restful[all]` 5 | --- 6 | 7 | 8 | One of the most commonly used ways to power database functionality with FastAPI is SQLAlchemy's ORM. 9 | 10 | FastAPI has [great documentation](https://fastapi.tiangolo.com/tutorial/sql-databases/) about how to integrate 11 | ORM into your application. 12 | 13 | However, the recommended approach for using SQLAlchemy's ORM with FastAPI has evolved over time to reflect both insights 14 | from the community and the addition of new features to FastAPI. 15 | 16 | The `fastapi_utils.session` module contains an implementation making use of the most up-to-date best practices for 17 | managing SQLAlchemy sessions with FastAPI. 18 | 19 | --- 20 | 21 | ## `FastAPISessionMaker` 22 | The `fastapi_utils.session.FastAPISessionMaker` class conveniently wraps session-making functionality for use with 23 | FastAPI. This section contains an example showing how to use this class. 24 | 25 | Let's begin with some infrastructure. The first thing we'll do is make sure we have an ORM 26 | table to query: 27 | 28 | ```python hl_lines="8 9 11 14 17 18 19 20" 29 | {!./src/session1.py!} 30 | ``` 31 | 32 | Next, we set up infrastructure for loading the database uri from the environment: 33 | 34 | ```python hl_lines="23 24 25 26" 35 | {!./src/session1.py!} 36 | ``` 37 | 38 | We use the `pydantic.BaseSettings` to load variables from the environment. There is documentation for this class in the 39 | pydantic docs, 40 | but the basic idea is that if a model inherits from this class, any fields not specified during initialization 41 | are read from the environment if possible. 42 | 43 | !!! info 44 | Since `database_uri` is not an optional field, a `ValidationError` will be raised if the `DATABASE_URI` environment 45 | variable is not set. 46 | 47 | !!! info 48 | For finer grained control, you could remove the `database_uri` field, and replace it with 49 | separate fields for `scheme`, `username`, `password`, `host`, and `db`. You could then give the model a `@property` 50 | called `database_uri` that builds the uri from these components. 51 | 52 | Now that we have a way to load the database uri, we can create the FastAPI dependency we'll use 53 | to obtain the sqlalchemy session: 54 | 55 | ```python hl_lines="29 30 31 34 35 36 37 38" 56 | {!./src/session1.py!} 57 | ``` 58 | 59 | !!! info 60 | The `get_db` dependency makes use of a context-manager dependency, rather than a middleware-based approach. 61 | This means that any endpoints that don't make use of a sqlalchemy session will not be exposed to any 62 | session-related overhead. 63 | 64 | This is in contrast with middleware-based approaches, where the handling of every request would result in 65 | a session being created and closed, even if the endpoint would not make use of it. 66 | 67 | !!! warning 68 | The `get_db` dependency **will not finalize your ORM session until *after* a response is returned to the user**. 69 | 70 | This has minor response-latency benefits, but also means that if you have any uncommitted 71 | database writes that will raise errors, you may return a success response to the user (status code 200), 72 | but still raise an error afterward during request clean-up. 73 | 74 | To deal with this, for any request where you expect a database write to potentially fail, you should **manually 75 | perform a commit inside your endpoint logic and appropriately handle any resulting errors.** 76 | 77 | ----- 78 | 79 | Note that while middleware-based approaches can automatically ensure database errors are visible to users, the 80 | result would be a generic 500 internal server error, which you should strive to avoid sending to clients under 81 | normal circumstances. 82 | 83 | You can still log any database errors raised during cleanup by appropriately modifying the `get_db` function 84 | with a `try: except:` block. 85 | 86 | The `get_db` function can be used as a FastAPI dependency that will inject a sqlalchemy ORM session where used: 87 | 88 | ```python hl_lines="45 46" 89 | {!./src/session1.py!} 90 | ``` 91 | 92 | !!! info 93 | We make use of `@lru_cache` on `_get_fastapi_sessionmaker` to ensure the same `FastAPISessionMaker` instance is 94 | reused across requests. This reduces the per-request overhead while still ensuring the instance is created 95 | lazily, making it possible to have the `database_uri` reflect modifications to the environment performed *after* 96 | importing the relevant source file. 97 | 98 | This can be especially useful during testing if you want to override environment variables programmatically using 99 | your testing framework. 100 | -------------------------------------------------------------------------------- /tests/test_cbv.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, ClassVar, Optional 4 | 5 | import pytest 6 | from fastapi import APIRouter, Depends, Request 7 | from starlette.testclient import TestClient 8 | 9 | from fastapi_utils.cbv import cbv 10 | 11 | 12 | class TestCBV: 13 | @pytest.fixture(autouse=True) 14 | def router(self) -> APIRouter: 15 | return APIRouter() 16 | 17 | def test_response_models(self, router: APIRouter) -> None: 18 | expected_response = "home" 19 | 20 | @cbv(router) 21 | class CBV: 22 | def __init__(self) -> None: 23 | self.one = 1 24 | self.two = 2 25 | 26 | @router.get("/", response_model=str) 27 | def string_response(self) -> str: 28 | return expected_response 29 | 30 | @router.get("/sum", response_model=int) 31 | def int_response(self) -> int: 32 | return self.one + self.two 33 | 34 | client = TestClient(router) 35 | response_1 = client.get("/") 36 | assert response_1.status_code == 200 37 | assert response_1.json() == expected_response 38 | 39 | response_2 = client.get("/sum") 40 | assert response_2.status_code == 200 41 | assert response_2.content == b"3" 42 | 43 | def test_dependencies(self, router: APIRouter) -> None: 44 | def dependency_one() -> int: 45 | return 1 46 | 47 | def dependency_two() -> int: 48 | return 2 49 | 50 | @cbv(router) 51 | class CBV: 52 | one: int = Depends(dependency_one) 53 | 54 | def __init__(self, two: int = Depends(dependency_two)): 55 | self.two = two 56 | 57 | @router.get("/", response_model=int) 58 | def int_dependencies(self) -> int: 59 | return self.one + self.two 60 | 61 | client = TestClient(router) 62 | response = client.get("/") 63 | assert response.status_code == 200 64 | assert response.content == b"3" 65 | 66 | def test_class_var(self, router: APIRouter) -> None: 67 | @cbv(router) 68 | class CBV: 69 | class_var: ClassVar[int] 70 | 71 | @router.get("/", response_model=bool) 72 | def g(self) -> bool: 73 | return hasattr(self, "class_var") 74 | 75 | client = TestClient(router) 76 | response = client.get("/") 77 | assert response.status_code == 200 78 | assert response.content == b"false" 79 | 80 | def test_routes_path_order_preserved(self, router: APIRouter) -> None: 81 | @cbv(router) 82 | class CBV: 83 | @router.get("/test") 84 | def get_test(self) -> int: 85 | return 1 86 | 87 | @router.get("/{any_path}") 88 | def get_any_path(self) -> int: # Alphabetically before `get_test` 89 | return 2 90 | 91 | client = TestClient(router) 92 | assert client.get("/test").json() == 1 93 | assert client.get("/any_other_path").json() == 2 94 | 95 | def test_multiple_paths(self, router: APIRouter) -> None: 96 | @cbv(router) 97 | class CBV: 98 | @router.get("/items") 99 | @router.get("/items/{custom_path:path}") 100 | @router.get("/database/{custom_path:path}") 101 | def root(self, custom_path: Optional[str] = None) -> Any: 102 | return {"custom_path": custom_path} if custom_path else [] 103 | 104 | client = TestClient(router) 105 | assert client.get("/items").json() == [] 106 | assert client.get("/items/1").json() == {"custom_path": "1"} 107 | assert client.get("/database/abc").json() == {"custom_path": "abc"} 108 | 109 | def test_query_parameters(self, router: APIRouter) -> None: 110 | @cbv(router) 111 | class CBV: 112 | @router.get("/route") 113 | def root(self, param: Optional[int] = None) -> int: 114 | return param if param else 0 115 | 116 | client = TestClient(router) 117 | assert client.get("/route").json() == 0 118 | assert client.get("/route?param=3").json() == 3 119 | 120 | def test_prefix(self) -> None: 121 | router = APIRouter(prefix="/api") 122 | 123 | @cbv(router) 124 | class CBV: 125 | @router.get("/item") 126 | def root(self) -> str: 127 | return "hello" 128 | 129 | client = TestClient(router) 130 | response = client.get("/api/item") 131 | assert response.status_code == 200 132 | assert response.json() == "hello" 133 | 134 | def test_url_for(self, router: APIRouter) -> None: 135 | @cbv(router) 136 | class Foo: 137 | @router.get("/foo") 138 | def example(self, request: Request) -> str: 139 | return str(request.url_for("Bar.example")) 140 | 141 | @cbv(router) 142 | class Bar: 143 | @router.get("/bar") 144 | def example(self, request: Request) -> str: 145 | return str(request.url_for("Foo.example")) 146 | 147 | client = TestClient(router) 148 | response = client.get("/foo") 149 | assert response.json() == "http://testserver/bar" 150 | -------------------------------------------------------------------------------- /fastapi_utils/tasks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | import warnings 6 | from functools import wraps 7 | from traceback import format_exception 8 | from typing import Any, Callable, Coroutine, Union 9 | 10 | from starlette.concurrency import run_in_threadpool 11 | 12 | NoArgsNoReturnFuncT = Callable[[], None] 13 | NoArgsNoReturnAsyncFuncT = Callable[[], Coroutine[Any, Any, None]] 14 | ExcArgNoReturnFuncT = Callable[[Exception], None] 15 | ExcArgNoReturnAsyncFuncT = Callable[[Exception], Coroutine[Any, Any, None]] 16 | NoArgsNoReturnAnyFuncT = Union[NoArgsNoReturnFuncT, NoArgsNoReturnAsyncFuncT] 17 | ExcArgNoReturnAnyFuncT = Union[ExcArgNoReturnFuncT, ExcArgNoReturnAsyncFuncT] 18 | NoArgsNoReturnDecorator = Callable[[NoArgsNoReturnAnyFuncT], NoArgsNoReturnAsyncFuncT] 19 | 20 | 21 | async def _handle_func(func: NoArgsNoReturnAnyFuncT) -> None: 22 | if asyncio.iscoroutinefunction(func): 23 | await func() 24 | else: 25 | await run_in_threadpool(func) 26 | 27 | 28 | async def _handle_exc(exc: Exception, on_exception: ExcArgNoReturnAnyFuncT | None) -> None: 29 | if on_exception: 30 | if asyncio.iscoroutinefunction(on_exception): 31 | await on_exception(exc) 32 | else: 33 | await run_in_threadpool(on_exception, exc) 34 | 35 | 36 | def repeat_every( 37 | *, 38 | seconds: float, 39 | wait_first: float | None = None, 40 | logger: logging.Logger | None = None, 41 | raise_exceptions: bool = False, 42 | max_repetitions: int | None = None, 43 | on_complete: NoArgsNoReturnAnyFuncT | None = None, 44 | on_exception: ExcArgNoReturnAnyFuncT | None = None, 45 | ) -> NoArgsNoReturnDecorator: 46 | """ 47 | This function returns a decorator that modifies a function so it is periodically re-executed after its first call. 48 | 49 | The function it decorates should accept no arguments and return nothing. If necessary, this can be accomplished 50 | by using `functools.partial` or otherwise wrapping the target function prior to decoration. 51 | 52 | Parameters 53 | ---------- 54 | seconds: float 55 | The number of seconds to wait between repeated calls 56 | wait_first: float (default None) 57 | If not None, the function will wait for the given duration before the first call 58 | logger: Optional[logging.Logger] (default None) 59 | Warning: This parameter is deprecated and will be removed in the 1.0 release. 60 | The logger to use to log any exceptions raised by calls to the decorated function. 61 | If not provided, exceptions will not be logged by this function (though they may be handled by the event loop). 62 | raise_exceptions: bool (default False) 63 | Warning: This parameter is deprecated and will be removed in the 1.0 release. 64 | If True, errors raised by the decorated function will be raised to the event loop's exception handler. 65 | Note that if an error is raised, the repeated execution will stop. 66 | Otherwise, exceptions are just logged and the execution continues to repeat. 67 | See https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.set_exception_handler for more info. 68 | max_repetitions: Optional[int] (default None) 69 | The maximum number of times to call the repeated function. If `None`, the function is repeated forever. 70 | on_complete: Optional[Callable[[], None]] (default None) 71 | A function to call after the final repetition of the decorated function. 72 | on_exception: Optional[Callable[[Exception], None]] (default None) 73 | A function to call when an exception is raised by the decorated function. 74 | """ 75 | 76 | def decorator(func: NoArgsNoReturnAnyFuncT) -> NoArgsNoReturnAsyncFuncT: 77 | """ 78 | Converts the decorated function into a repeated, periodically-called version of itself. 79 | """ 80 | 81 | @wraps(func) 82 | async def wrapped() -> None: 83 | async def loop() -> None: 84 | if wait_first is not None: 85 | await asyncio.sleep(wait_first) 86 | 87 | repetitions = 0 88 | while max_repetitions is None or repetitions < max_repetitions: 89 | try: 90 | await _handle_func(func) 91 | 92 | except Exception as exc: 93 | if logger is not None: 94 | warnings.warn( 95 | "'logger' is to be deprecated in favor of 'on_exception' in the 1.0 release.", 96 | DeprecationWarning, 97 | ) 98 | formatted_exception = "".join(format_exception(type(exc), exc, exc.__traceback__)) 99 | logger.error(formatted_exception) 100 | if raise_exceptions: 101 | warnings.warn( 102 | "'raise_exceptions' is to be deprecated in favor of 'on_exception' in the 1.0 release.", 103 | DeprecationWarning, 104 | ) 105 | raise exc 106 | await _handle_exc(exc, on_exception) 107 | 108 | repetitions += 1 109 | await asyncio.sleep(seconds) 110 | 111 | if on_complete: 112 | await _handle_func(on_complete) 113 | 114 | asyncio.ensure_future(loop()) 115 | 116 | return wrapped 117 | 118 | return decorator 119 | -------------------------------------------------------------------------------- /fastapi_utils/session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Iterator 4 | from contextlib import contextmanager 5 | 6 | import sqlalchemy as sa 7 | from sqlalchemy.orm import Session 8 | 9 | 10 | class FastAPISessionMaker: 11 | """ 12 | A convenience class for managing a (cached) sqlalchemy ORM engine and sessionmaker. 13 | 14 | Intended for use creating ORM sessions injected into endpoint functions by FastAPI. 15 | """ 16 | 17 | def __init__(self, database_uri: str): 18 | """ 19 | `database_uri` should be any sqlalchemy-compatible database URI. 20 | 21 | In particular, `sqlalchemy.create_engine(database_uri)` should work to create an engine. 22 | 23 | Typically, this would look like: 24 | 25 | "://:@:/" 26 | 27 | A concrete example looks like "postgresql://db_user:password@db:5432/app" 28 | """ 29 | self.database_uri = database_uri 30 | 31 | self._cached_engine: sa.engine.Engine | None = None 32 | self._cached_sessionmaker: sa.orm.sessionmaker | None = None 33 | 34 | @property 35 | def cached_engine(self) -> sa.engine.Engine: 36 | """ 37 | Returns a lazily-cached sqlalchemy engine for the instance's database_uri. 38 | """ 39 | engine = self._cached_engine 40 | if engine is None: 41 | engine = self.get_new_engine() 42 | self._cached_engine = engine 43 | return engine 44 | 45 | @property 46 | def cached_sessionmaker(self) -> sa.orm.sessionmaker: 47 | """ 48 | Returns a lazily-cached sqlalchemy sessionmaker using the instance's (lazily-cached) engine. 49 | """ 50 | sessionmaker = self._cached_sessionmaker 51 | if sessionmaker is None: 52 | sessionmaker = self.get_new_sessionmaker(self.cached_engine) 53 | self._cached_sessionmaker = sessionmaker 54 | return sessionmaker 55 | 56 | def get_new_engine(self) -> sa.engine.Engine: 57 | """ 58 | Returns a new sqlalchemy engine using the instance's database_uri. 59 | """ 60 | return get_engine(self.database_uri) 61 | 62 | def get_new_sessionmaker(self, engine: sa.engine.Engine | None) -> sa.orm.sessionmaker: 63 | """ 64 | Returns a new sessionmaker for the provided sqlalchemy engine. If no engine is provided, the 65 | instance's (lazily-cached) engine is used. 66 | """ 67 | engine = engine or self.cached_engine 68 | return get_sessionmaker_for_engine(engine) 69 | 70 | def get_db(self) -> Iterator[Session]: 71 | """ 72 | A generator function that yields a sqlalchemy orm session and cleans up the session once resumed after yielding. 73 | 74 | Can be used directly as a context-manager FastAPI dependency, or yielded from inside a separate dependency. 75 | """ 76 | yield from _get_db(self.cached_sessionmaker) 77 | 78 | @contextmanager 79 | def context_session(self) -> Iterator[Session]: 80 | """ 81 | A context-manager wrapped version of the `get_db` method. 82 | 83 | This makes it possible to get a context-managed orm session for the relevant database_uri without 84 | needing to rely on FastAPI's dependency injection. 85 | 86 | Usage looks like: 87 | 88 | session_maker = FastAPISessionMaker(database_uri) 89 | with session_maker.context_session() as session: 90 | session.query(...) 91 | ... 92 | """ 93 | yield from self.get_db() 94 | 95 | def reset_cache(self) -> None: 96 | """ 97 | Resets the engine and sessionmaker caches. 98 | 99 | After calling this method, the next time you try to use the cached engine or sessionmaker, 100 | new ones will be created. 101 | """ 102 | self._cached_engine = None 103 | self._cached_sessionmaker = None 104 | 105 | 106 | def get_engine(uri: str) -> sa.engine.Engine: 107 | """ 108 | Returns a sqlalchemy engine with pool_pre_ping enabled. 109 | 110 | This function may be updated over time to reflect recommended engine configuration for use with FastAPI. 111 | """ 112 | return sa.create_engine(uri, pool_pre_ping=True) 113 | 114 | 115 | def get_sessionmaker_for_engine(engine: sa.engine.Engine) -> sa.orm.sessionmaker: 116 | """ 117 | Returns a sqlalchemy sessionmaker for the provided engine with recommended configuration settings. 118 | 119 | This function may be updated over time to reflect recommended sessionmaker configuration for use with FastAPI. 120 | """ 121 | return sa.orm.sessionmaker(autocommit=False, autoflush=False, bind=engine) 122 | 123 | 124 | @contextmanager 125 | def context_session(engine: sa.engine.Engine) -> Iterator[Session]: 126 | """ 127 | This contextmanager yields a managed session for the provided engine. 128 | 129 | Usage is similar to `FastAPISessionMaker.context_session`, except that you have to provide the engine to use. 130 | 131 | A new sessionmaker is created for each call, so the FastAPISessionMaker.context_session 132 | method may be preferable in performance-sensitive contexts. 133 | """ 134 | sessionmaker = get_sessionmaker_for_engine(engine) 135 | yield from _get_db(sessionmaker) 136 | 137 | 138 | def _get_db(sessionmaker: sa.orm.sessionmaker) -> Iterator[Session]: 139 | """ 140 | A generator function that yields an ORM session using the provided sessionmaker, and cleans it up when resumed. 141 | """ 142 | session = sessionmaker() 143 | try: 144 | yield session 145 | session.commit() 146 | except Exception as exc: 147 | session.rollback() 148 | raise exc 149 | finally: 150 | session.close() 151 | -------------------------------------------------------------------------------- /fastapi_utils/timing.py: -------------------------------------------------------------------------------- 1 | """ 2 | Based on https://github.com/steinnes/timing-asgi.git 3 | 4 | The middleware from this module is intended for use during both development and production, 5 | but only reports timing data at the granularity of individual endpoint calls. 6 | 7 | For more detailed performance investigations (during development only, due to added overhead), 8 | consider using the coroutine-aware profiling library `yappi`. 9 | """ 10 | 11 | from __future__ import annotations 12 | 13 | import os 14 | import time 15 | from collections.abc import Callable 16 | from typing import Any 17 | 18 | import psutil 19 | from fastapi import FastAPI 20 | from starlette.middleware.base import RequestResponseEndpoint 21 | from starlette.requests import Request 22 | from starlette.responses import Response 23 | from starlette.routing import Match, Mount 24 | from starlette.types import Scope 25 | 26 | TIMER_ATTRIBUTE = "__fastapi_utils_timer__" 27 | 28 | 29 | def add_timing_middleware( 30 | app: FastAPI, record: Callable[[str], None] | None = None, prefix: str = "", exclude: str | None = None 31 | ) -> None: 32 | """ 33 | Adds a middleware to the provided `app` that records timing metrics using the provided `record` callable. 34 | 35 | Typically `record` would be something like `logger.info` for a `logging.Logger` instance. 36 | 37 | The provided `prefix` is used when generating route names. 38 | 39 | If `exclude` is provided, timings for any routes containing `exclude` 40 | as an exact substring of the generated metric name will not be logged. 41 | This provides an easy way to disable logging for routes 42 | 43 | The `exclude` will probably be replaced by a regex match at some point in the future. (PR welcome!) 44 | """ 45 | metric_namer = _MetricNamer(prefix=prefix, app=app) 46 | 47 | @app.middleware("http") 48 | async def timing_middleware(request: Request, call_next: RequestResponseEndpoint) -> Response: 49 | metric_name = metric_namer(request.scope) 50 | with _TimingStats(metric_name, record=record, exclude=exclude) as timer: 51 | setattr(request.state, TIMER_ATTRIBUTE, timer) 52 | response = await call_next(request) 53 | return response 54 | 55 | 56 | def record_timing(request: Request, note: str | None = None) -> None: 57 | """ 58 | Call this function at any point that you want to display elapsed time during the handling of a single request 59 | 60 | This can help profile which piece of a request is causing a performance bottleneck. 61 | 62 | Note that for this function to succeed, the request should have been generated by a FastAPI app 63 | that has had timing middleware added using the `fastapi_utils.timing.add_timing_middleware` function. 64 | """ 65 | timer = getattr(request.state, TIMER_ATTRIBUTE, None) 66 | if timer is not None: 67 | if not isinstance(timer, _TimingStats): 68 | raise ValueError("Timer should be of an instance of TimingStats") 69 | timer.emit(note) 70 | else: 71 | raise ValueError("No timer present on request") 72 | 73 | 74 | class _TimingStats: 75 | """ 76 | This class tracks and records endpoint timing data. 77 | 78 | Should be used as a context manager; on exit, timing stats will be emitted. 79 | 80 | name: 81 | The name to include with the recorded timing data 82 | record: 83 | The callable to call on generated messages. Defaults to `print`, but typically 84 | something like `logger.info` for a `logging.Logger` instance would be preferable. 85 | exclude: 86 | An optional string; if it is not None and occurs inside `name`, no stats will be emitted 87 | """ 88 | 89 | def __init__( 90 | self, name: str | None = None, record: Callable[[str], None] | None = None, exclude: str | None = None 91 | ) -> None: 92 | self.name = name 93 | self.record = record or print 94 | 95 | self.process: psutil.Process = psutil.Process(os.getpid()) 96 | self.start_time: float = 0 97 | self.start_cpu_time: float = 0 98 | self.end_cpu_time: float = 0 99 | self.end_time: float = 0 100 | self.silent: bool = False 101 | 102 | if self.name is not None and exclude is not None and (exclude in self.name): 103 | self.silent = True 104 | 105 | def start(self) -> None: 106 | self.start_time = time.time() 107 | self.start_cpu_time = self._get_cpu_time() 108 | 109 | def take_split(self) -> None: 110 | self.end_time = time.time() 111 | self.end_cpu_time = self._get_cpu_time() 112 | 113 | @property 114 | def time(self) -> float: 115 | return self.end_time - self.start_time 116 | 117 | @property 118 | def cpu_time(self) -> float: 119 | return self.end_cpu_time - self.start_cpu_time 120 | 121 | def __enter__(self) -> _TimingStats: 122 | self.start() 123 | return self 124 | 125 | def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: 126 | self.emit() 127 | 128 | def emit(self, note: str | None = None) -> None: 129 | """ 130 | Emit timing information, optionally including a specified note 131 | """ 132 | if not self.silent: 133 | self.take_split() 134 | cpu_ms = 1000 * self.cpu_time 135 | wall_ms = 1000 * self.time 136 | message = f"TIMING: Wall: {wall_ms:6.1f}ms | CPU: {cpu_ms:6.1f}ms | {self.name}" 137 | if note is not None: 138 | message += f" ({note})" 139 | self.record(message) 140 | 141 | def _get_cpu_time(self) -> float: 142 | """ 143 | Generates the cpu time to report. Adds the user and system time, following the implementation from timing-asgi 144 | """ 145 | resources = self.process.cpu_times() 146 | # add up user time and system time 147 | return resources[0] + resources[1] 148 | 149 | 150 | class _MetricNamer: 151 | """ 152 | This class generates the route "name" used when logging timing records. 153 | 154 | If the route has `endpoint` and `name` attributes, the endpoint's module and route's name will be used 155 | (along with an optional prefix that can be used, e.g., to distinguish between multiple mounted ASGI apps). 156 | 157 | By default, in FastAPI the route name is the `__name__` of the route's function (or type if it is a callable class 158 | instance). 159 | 160 | For example, with prefix == "custom", a function defined in the module `app.crud` with name `read_item` 161 | would get name `custom.app.crud.read_item`. If the empty string were used as the prefix, the result would be 162 | just "app.crud.read_item". 163 | 164 | For starlette.routing.Mount instances, the name of the type of `route.app` is used in a slightly different format. 165 | 166 | For other routes missing either an endpoint or name, the raw route path is included in the generated name. 167 | """ 168 | 169 | def __init__(self, prefix: str, app: FastAPI): 170 | if prefix: 171 | prefix += "." 172 | self.prefix = prefix 173 | self.app = app 174 | 175 | def __call__(self, scope: Scope) -> str: 176 | """ 177 | Generates the actual name to use when logging timing metrics for a specified ASGI Scope 178 | """ 179 | route = None 180 | for r in self.app.router.routes: 181 | if r.matches(scope)[0] == Match.FULL: 182 | route = r 183 | break 184 | if hasattr(route, "endpoint") and hasattr(route, "name"): 185 | name = f"{self.prefix}{route.endpoint.__module__}.{route.name}" # type: ignore 186 | elif isinstance(route, Mount): 187 | name = f"{type(route.app).__name__}<{route.name!r}>" 188 | else: 189 | name = str(f"") 190 | return name 191 | -------------------------------------------------------------------------------- /fastapi_utils/cbv.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import ( 3 | Any, 4 | Callable, 5 | List, 6 | Tuple, 7 | Type, 8 | TypeVar, 9 | Union, 10 | cast, 11 | get_type_hints, 12 | ) 13 | 14 | import pydantic 15 | from fastapi import APIRouter, Depends 16 | from fastapi.routing import APIRoute 17 | from starlette.routing import Route, WebSocketRoute 18 | 19 | PYDANTIC_VERSION = pydantic.VERSION 20 | if PYDANTIC_VERSION[0] == "2": 21 | from typing_inspect import is_classvar 22 | else: 23 | from pydantic.typing import is_classvar # type: ignore[no-redef] 24 | 25 | T = TypeVar("T") 26 | 27 | CBV_CLASS_KEY = "__cbv_class__" 28 | INCLUDE_INIT_PARAMS_KEY = "__include_init_params__" 29 | RETURN_TYPES_FUNC_KEY = "__return_types_func__" 30 | 31 | 32 | def cbv(router: APIRouter, *urls: str) -> Callable[[Type[T]], Type[T]]: 33 | """ 34 | This function returns a decorator that converts the decorated into a class-based view for the provided router. 35 | 36 | Any methods of the decorated class that are decorated as endpoints using the router provided to this function 37 | will become endpoints in the router. The first positional argument to the methods (typically `self`) 38 | will be populated with an instance created using FastAPI's dependency-injection. 39 | 40 | For more detail, review the documentation at 41 | https://fastapi-restful.netlify.app/user-guide/class-based-views//#the-cbv-decorator 42 | """ 43 | 44 | def decorator(cls: Type[T]) -> Type[T]: 45 | # Define cls as cbv class exclusively when using the decorator 46 | return _cbv(router, cls, *urls) 47 | 48 | return decorator 49 | 50 | 51 | def _cbv(router: APIRouter, cls: Type[T], *urls: str, instance: Any = None) -> Type[T]: 52 | """ 53 | Replaces any methods of the provided class `cls` that are endpoints of routes in `router` with updated 54 | function calls that will properly inject an instance of `cls`. 55 | """ 56 | _init_cbv(cls, instance) 57 | _register_endpoints(router, cls, *urls) 58 | return cls 59 | 60 | 61 | def _init_cbv(cls: Type[Any], instance: Any = None) -> None: 62 | """ 63 | Idempotently modifies the provided `cls`, performing the following modifications: 64 | * The `__init__` function is updated to set any class-annotated dependencies as instance attributes 65 | * The `__signature__` attribute is updated to indicate to FastAPI what arguments should be passed to the initializer 66 | """ 67 | if getattr(cls, CBV_CLASS_KEY, False): # pragma: no cover 68 | return # Already initialized 69 | old_init: Callable[..., Any] = cls.__init__ 70 | old_signature = inspect.signature(old_init) 71 | old_parameters = list(old_signature.parameters.values())[1:] # drop `self` parameter 72 | new_parameters = [ 73 | x for x in old_parameters if x.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) 74 | ] 75 | 76 | dependency_names: List[str] = [] 77 | for name, hint in get_type_hints(cls).items(): 78 | if is_classvar(hint): 79 | continue 80 | parameter_kwargs = {"default": getattr(cls, name, Ellipsis)} 81 | dependency_names.append(name) 82 | new_parameters.append( 83 | inspect.Parameter(name=name, kind=inspect.Parameter.KEYWORD_ONLY, annotation=hint, **parameter_kwargs) 84 | ) 85 | new_signature = inspect.Signature(()) 86 | if not instance or hasattr(cls, INCLUDE_INIT_PARAMS_KEY): 87 | new_signature = old_signature.replace(parameters=new_parameters) 88 | 89 | def new_init(self: Any, *args: Any, **kwargs: Any) -> None: 90 | for dep_name in dependency_names: 91 | dep_value = kwargs.pop(dep_name) 92 | setattr(self, dep_name, dep_value) 93 | if instance and not hasattr(cls, INCLUDE_INIT_PARAMS_KEY): 94 | self.__class__ = instance.__class__ 95 | self.__dict__ = instance.__dict__ 96 | else: 97 | old_init(self, *args, **kwargs) 98 | 99 | setattr(cls, "__signature__", new_signature) 100 | setattr(cls, "__init__", new_init) 101 | setattr(cls, CBV_CLASS_KEY, True) 102 | 103 | 104 | def _register_endpoints(router: APIRouter, cls: Type[Any], *urls: str) -> None: 105 | cbv_router = APIRouter() 106 | function_members = inspect.getmembers(cls, inspect.isfunction) 107 | for url in urls: 108 | _allocate_routes_by_method_name(router, url, function_members) 109 | router_roles = [] 110 | for route in router.routes: 111 | if not isinstance(route, APIRoute): 112 | raise ValueError("The provided routes should be of type APIRoute") 113 | 114 | route_methods: Any = route.methods 115 | cast(Tuple[Any], route_methods) 116 | router_roles.append((route.path, tuple(route_methods))) 117 | 118 | if len(set(router_roles)) != len(router_roles): 119 | raise Exception("An identical route role has been implemented more then once") 120 | 121 | functions_set = {func for _, func in function_members} 122 | cbv_routes = [ 123 | route 124 | for route in router.routes 125 | if isinstance(route, (Route, WebSocketRoute)) and route.endpoint in functions_set 126 | ] 127 | prefix_length = len(router.prefix) # Until 'black' would fix an issue which causes PEP8: E203 128 | for route in cbv_routes: 129 | router.routes.remove(route) 130 | route.path = route.path[prefix_length:] 131 | _update_cbv_route_endpoint_signature(cls, route) 132 | route.name = cls.__name__ + "." + route.name 133 | cbv_router.routes.append(route) 134 | router.include_router(cbv_router) 135 | 136 | 137 | def _allocate_routes_by_method_name(router: APIRouter, url: str, function_members: List[Tuple[str, Any]]) -> None: 138 | existing_routes_endpoints: List[Tuple[Any, str]] = [ 139 | (route.endpoint, route.path) for route in router.routes if isinstance(route, APIRoute) 140 | ] 141 | for name, func in function_members: 142 | if hasattr(router, name) and not name.startswith("__") and not name.endswith("__"): 143 | if (func, url) not in existing_routes_endpoints: 144 | response_model = None 145 | responses = None 146 | kwargs = {} 147 | status_code = 200 148 | return_types_func = getattr(func, RETURN_TYPES_FUNC_KEY, None) 149 | if return_types_func: 150 | response_model, status_code, responses, kwargs = return_types_func() 151 | 152 | api_resource = router.api_route( 153 | url, 154 | methods=[name.capitalize()], 155 | response_model=response_model, 156 | status_code=status_code, 157 | responses=responses, 158 | **kwargs, 159 | ) 160 | api_resource(func) 161 | 162 | 163 | def _update_cbv_route_endpoint_signature(cls: Type[Any], route: Union[Route, WebSocketRoute]) -> None: 164 | """ 165 | Fixes the endpoint signature for a cbv route to ensure FastAPI performs dependency injection properly. 166 | """ 167 | old_endpoint = route.endpoint 168 | old_signature = inspect.signature(old_endpoint) 169 | old_parameters: List[inspect.Parameter] = list(old_signature.parameters.values()) 170 | old_first_parameter = old_parameters[0] 171 | new_first_parameter = old_first_parameter.replace(default=Depends(cls)) 172 | new_parameters = [new_first_parameter] + [ 173 | parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY) for parameter in old_parameters[1:] 174 | ] 175 | 176 | new_signature = old_signature.replace(parameters=new_parameters) 177 | setattr(route.endpoint, "__signature__", new_signature) 178 | -------------------------------------------------------------------------------- /tests/test_tasks.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import NoReturn 3 | from unittest.mock import AsyncMock, call, patch 4 | 5 | import pytest 6 | 7 | from fastapi_utils.tasks import NoArgsNoReturnAsyncFuncT, repeat_every 8 | 9 | 10 | # Fixtures: 11 | @pytest.fixture(scope="module") 12 | def seconds() -> float: 13 | return 0.01 14 | 15 | 16 | @pytest.fixture(scope="module") 17 | def max_repetitions() -> int: 18 | return 3 19 | 20 | 21 | @pytest.fixture(scope="module") 22 | def wait_first(seconds: float) -> float: 23 | return seconds 24 | 25 | 26 | # Tests: 27 | class TestRepeatEveryBase: 28 | def setup_method(self) -> None: 29 | self.counter = 0 30 | self.completed = asyncio.Event() 31 | 32 | def increase_counter(self) -> None: 33 | self.counter += 1 34 | 35 | async def increase_counter_async(self) -> None: 36 | self.increase_counter() 37 | 38 | def loop_completed(self) -> None: 39 | self.completed.set() 40 | 41 | async def loop_completed_async(self) -> None: 42 | self.loop_completed() 43 | 44 | def kill_loop(self, exc: Exception) -> None: 45 | self.completed.set() 46 | raise exc 47 | 48 | async def kill_loop_async(self, exc: Exception) -> None: 49 | self.kill_loop(exc) 50 | 51 | def continue_loop(self, exc: Exception) -> None: 52 | return 53 | 54 | async def continue_loop_async(self, exc: Exception) -> None: 55 | self.continue_loop(exc) 56 | 57 | def raise_exc(self) -> NoReturn: 58 | self.increase_counter() 59 | raise ValueError("error") 60 | 61 | async def raise_exc_async(self) -> NoReturn: 62 | self.raise_exc() 63 | 64 | @pytest.fixture 65 | def increase_counter_task(self, is_async: bool, seconds: float, max_repetitions: int) -> NoArgsNoReturnAsyncFuncT: 66 | decorator = repeat_every(seconds=seconds, max_repetitions=max_repetitions, on_complete=self.loop_completed) 67 | if is_async: 68 | return decorator(self.increase_counter_async) 69 | else: 70 | return decorator(self.increase_counter) 71 | 72 | @pytest.fixture 73 | def wait_first_increase_counter_task( 74 | self, is_async: bool, seconds: float, max_repetitions: int, wait_first: float 75 | ) -> NoArgsNoReturnAsyncFuncT: 76 | decorator = repeat_every( 77 | seconds=seconds, max_repetitions=max_repetitions, wait_first=wait_first, on_complete=self.loop_completed 78 | ) 79 | if is_async: 80 | return decorator(self.increase_counter_async) 81 | else: 82 | return decorator(self.increase_counter) 83 | 84 | @pytest.fixture 85 | def stop_on_exception_task(self, is_async: bool, seconds: float, max_repetitions: int) -> NoArgsNoReturnAsyncFuncT: 86 | if is_async: 87 | decorator = repeat_every( 88 | seconds=seconds, 89 | max_repetitions=max_repetitions, 90 | on_complete=self.loop_completed_async, 91 | on_exception=self.kill_loop_async, 92 | ) 93 | return decorator(self.raise_exc_async) 94 | else: 95 | decorator = repeat_every( 96 | seconds=seconds, 97 | max_repetitions=max_repetitions, 98 | on_complete=self.loop_completed, 99 | on_exception=self.kill_loop, 100 | ) 101 | return decorator(self.raise_exc) 102 | 103 | @pytest.fixture 104 | def suppressed_exception_task( 105 | self, is_async: bool, seconds: float, max_repetitions: int 106 | ) -> NoArgsNoReturnAsyncFuncT: 107 | if is_async: 108 | decorator = repeat_every( 109 | seconds=seconds, 110 | max_repetitions=max_repetitions, 111 | on_complete=self.loop_completed_async, 112 | on_exception=self.continue_loop_async, 113 | ) 114 | return decorator(self.raise_exc_async) 115 | else: 116 | decorator = repeat_every( 117 | seconds=seconds, 118 | max_repetitions=max_repetitions, 119 | on_complete=self.loop_completed, 120 | on_exception=self.continue_loop, 121 | ) 122 | return decorator(self.raise_exc) 123 | 124 | 125 | class TestRepeatEveryWithSynchronousFunction(TestRepeatEveryBase): 126 | @pytest.fixture 127 | def is_async(self) -> bool: 128 | return False 129 | 130 | @pytest.mark.asyncio 131 | @pytest.mark.timeout(1) 132 | @patch("asyncio.sleep") 133 | async def test_max_repetitions( 134 | self, 135 | asyncio_sleep_mock: AsyncMock, 136 | seconds: float, 137 | max_repetitions: int, 138 | increase_counter_task: NoArgsNoReturnAsyncFuncT, 139 | ) -> None: 140 | await increase_counter_task() 141 | await self.completed.wait() 142 | 143 | assert self.counter == max_repetitions 144 | asyncio_sleep_mock.assert_has_calls(max_repetitions * [call(seconds)], any_order=True) 145 | 146 | @pytest.mark.asyncio 147 | @pytest.mark.timeout(1) 148 | @patch("asyncio.sleep") 149 | async def test_max_repetitions_and_wait_first( 150 | self, 151 | asyncio_sleep_mock: AsyncMock, 152 | seconds: float, 153 | max_repetitions: int, 154 | wait_first_increase_counter_task: NoArgsNoReturnAsyncFuncT, 155 | ) -> None: 156 | await wait_first_increase_counter_task() 157 | await self.completed.wait() 158 | 159 | assert self.counter == max_repetitions 160 | asyncio_sleep_mock.assert_has_calls((max_repetitions + 1) * [call(seconds)], any_order=True) 161 | 162 | @pytest.mark.asyncio 163 | @pytest.mark.timeout(1) 164 | async def test_stop_loop_on_exc( 165 | self, 166 | stop_on_exception_task: NoArgsNoReturnAsyncFuncT, 167 | ) -> None: 168 | await stop_on_exception_task() 169 | await self.completed.wait() 170 | 171 | assert self.counter == 1 172 | 173 | @pytest.mark.asyncio 174 | @pytest.mark.timeout(1) 175 | @patch("asyncio.sleep") 176 | async def test_continue_loop_on_exc( 177 | self, 178 | asyncio_sleep_mock: AsyncMock, 179 | seconds: float, 180 | max_repetitions: int, 181 | suppressed_exception_task: NoArgsNoReturnAsyncFuncT, 182 | ) -> None: 183 | await suppressed_exception_task() 184 | await self.completed.wait() 185 | 186 | assert self.counter == max_repetitions 187 | asyncio_sleep_mock.assert_has_calls(max_repetitions * [call(seconds)], any_order=True) 188 | 189 | 190 | class TestRepeatEveryWithAsynchronousFunction(TestRepeatEveryBase): 191 | @pytest.fixture 192 | def is_async(self) -> bool: 193 | return True 194 | 195 | @pytest.mark.asyncio 196 | @pytest.mark.timeout(1) 197 | @patch("asyncio.sleep") 198 | async def test_max_repetitions( 199 | self, 200 | asyncio_sleep_mock: AsyncMock, 201 | seconds: float, 202 | max_repetitions: int, 203 | increase_counter_task: NoArgsNoReturnAsyncFuncT, 204 | ) -> None: 205 | await increase_counter_task() 206 | await self.completed.wait() 207 | 208 | assert self.counter == max_repetitions 209 | asyncio_sleep_mock.assert_has_calls(max_repetitions * [call(seconds)], any_order=True) 210 | 211 | @pytest.mark.asyncio 212 | @pytest.mark.timeout(1) 213 | @patch("asyncio.sleep") 214 | async def test_max_repetitions_and_wait_first( 215 | self, 216 | asyncio_sleep_mock: AsyncMock, 217 | seconds: float, 218 | max_repetitions: int, 219 | wait_first_increase_counter_task: NoArgsNoReturnAsyncFuncT, 220 | ) -> None: 221 | await wait_first_increase_counter_task() 222 | await self.completed.wait() 223 | 224 | assert self.counter == max_repetitions 225 | asyncio_sleep_mock.assert_has_calls((max_repetitions + 1) * [call(seconds)], any_order=True) 226 | 227 | @pytest.mark.asyncio 228 | @pytest.mark.timeout(1) 229 | async def test_stop_loop_on_exc( 230 | self, 231 | stop_on_exception_task: NoArgsNoReturnAsyncFuncT, 232 | ) -> None: 233 | await stop_on_exception_task() 234 | await self.completed.wait() 235 | 236 | assert self.counter == 1 237 | 238 | @pytest.mark.asyncio 239 | @pytest.mark.timeout(1) 240 | @patch("asyncio.sleep") 241 | async def test_continue_loop_on_exc( 242 | self, 243 | asyncio_sleep_mock: AsyncMock, 244 | seconds: float, 245 | max_repetitions: int, 246 | suppressed_exception_task: NoArgsNoReturnAsyncFuncT, 247 | ) -> None: 248 | await suppressed_exception_task() 249 | await self.completed.wait() 250 | 251 | assert self.counter == max_repetitions 252 | asyncio_sleep_mock.assert_has_calls(max_repetitions * [call(seconds)], any_order=True) 253 | --------------------------------------------------------------------------------