├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── docs.yml │ ├── lint.yml │ ├── publish.yml │ ├── test.yml │ └── type-check.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── docs ├── build.novella ├── content │ ├── api │ │ ├── exceptions.md │ │ ├── helpers.md │ │ ├── interfaces.md │ │ ├── pagination-integration.md │ │ └── routing.md │ ├── contributing.md │ ├── guide │ │ ├── pagination-integration.md │ │ ├── routing.md │ │ ├── schemas.md │ │ └── utilities.md │ └── index.md ├── mkdocs.yml └── requirements.txt ├── fastapi_responseschema ├── __init__.py ├── _compat.py ├── exceptions.py ├── helpers.py ├── integrations │ ├── __init__.py │ └── pagination.py ├── interfaces.py ├── py.typed └── routing.py ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── common.py ├── sandbox.py ├── test_exceptions.py ├── test_fastapi_responseschema.py ├── test_helpers.py ├── test_integration.py ├── test_interfaces.py ├── test_pagination_integration.py ├── test_response_model_compatibility.py └── test_routing.py /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Contributions are very welcome! 4 | 5 | ### How to contribute 6 | Just open an issue or submit a pull request on [GitHub](https://github.com/acwazz/fastapi-responseschema). 7 | 8 | While submitting a pull request describe what changes have been made. 9 | 10 | ## Guidelines 11 | - Try to adhere as much as possible to the Python style and language conventions. 12 | - Add unit tests for classes and methods. 13 | - When writing features exposed in API, always add documentation following the Google Style Python docstrings. 14 | 15 | 16 | ## Enviroment 17 | This package is developed using Python version `3.8`. 18 | 19 | This package uses [poetry](https://python-poetry.org/) to handle dependencies, you can install them with: 20 | ```sh 21 | poetry install -E pagination 22 | poetry run pre-commit install 23 | ``` 24 | 25 | 26 | ## Formatting 27 | [Black](https://black.readthedocs.io/en/stable/) is used to provide code autoformatting e linting. 28 | Before committing your changes run `black`: 29 | ```sh 30 | black . 31 | ``` 32 | 33 | ## Type checking 34 | [mypy](https://mypy.readthedocs.io/en/stable/index.html) is used to statically type check the source code. 35 | Before committing your changes run `mypy`: 36 | ```sh 37 | mypy . 38 | ``` 39 | 40 | ## Testing 41 | Tests are written using [pytest](https://docs.pytest.org/en/7.1.x/). 42 | To run the test suite just type in your terminal: 43 | ```sh 44 | pytest . 45 | ``` 46 | This will generate the coverage in html format in a root level directory `htmlcov`. 47 | 48 | 49 | ## Documentation 50 | Documentation is built using [pydoc-markdown](https://niklasrosenstein.github.io/pydoc-markdown/). 51 | To run the documentation dev server: 52 | ```sh 53 | novella -d docs --serve 54 | ``` 55 | To build the docs: 56 | ```sh 57 | novella -d docs 58 | ``` 59 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] - Something is not working" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Environment** 11 | - `fastapi` version is: x.x.x 12 | - `fastapi-responseschema` version is: x.x.x 13 | - `python` version is: x.x.x 14 | 15 | **Describe the bug** 16 | A clear and concise description of what the bug is. 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Snippets** 22 | If applicable, add snippets of code to help explain your problem. 23 | 24 | **Additional context** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | target-branch: "master" 8 | allow: 9 | - dependency-type: production 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 3 | 4 | # Type of change 5 | Please delete options that are not relevant. 6 | 7 | - [ ] Bug fix (non-breaking change which fixes an issue) 8 | - [ ] New feature (non-breaking change which adds functionality) 9 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 10 | - [ ] This change requires a documentation update -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs publish 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | inputs: 7 | logLevel: 8 | description: 'Log level' 9 | required: true 10 | default: 'info' 11 | type: choice 12 | options: 13 | - info 14 | - warning 15 | - debug 16 | tags: 17 | description: 'Testing' 18 | required: false 19 | type: boolean 20 | jobs: 21 | docs: 22 | runs-on: ubuntu-latest 23 | steps: 24 | 25 | - uses: actions/checkout@v2 26 | 27 | - name: Set up Python 3.8 28 | uses: actions/setup-python@v2 29 | with: { python-version: "3.8" } 30 | 31 | - name: Install dependencies 32 | run: | 33 | pip install -U poetry 34 | poetry install 35 | - name: Build documentation 36 | run: | 37 | poetry run novella -d docs 38 | - name: Publish docs 39 | uses: JamesIves/github-pages-deploy-action@4.1.4 40 | with: 41 | branch: gh-pages 42 | folder: docs/_site 43 | ssh-key: ${{ secrets.DEPLOY_KEY }} 44 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - "opened" 7 | - "synchronize" 8 | push: 9 | branches: 10 | - "master" 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: psf/black@stable 18 | with: 19 | options: "--check --diff --color --verbose" 20 | src: "." -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Set up Python 11 | uses: actions/setup-python@v4 12 | with: 13 | python-version: '3.8' 14 | - name: Install dependencies 15 | run: | 16 | pip install -U poetry 17 | poetry install -E pagination 18 | - name: Publish 19 | run: poetry publish --build -u "__token__" -p "${{ secrets.PYPI_TOKEN }}" -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | pull_request: 4 | types: 5 | - "opened" 6 | - "synchronize" 7 | push: 8 | branches: 9 | - "master" 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | pip install -U poetry 26 | poetry install -E pagination 27 | - name: Testing 28 | run: | 29 | poetry run pytest . 30 | -------------------------------------------------------------------------------- /.github/workflows/type-check.yml: -------------------------------------------------------------------------------- 1 | name: Type Check 2 | on: 3 | pull_request: 4 | types: 5 | - "opened" 6 | - "synchronize" 7 | push: 8 | branches: 9 | - "master" 10 | 11 | jobs: 12 | type-checking: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: jpetrucciani/mypy-check@master 17 | with: 18 | path: '.' 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | __pycache__ 4 | */__pycache__/* 5 | *.py[cod] 6 | *$py.class 7 | *.pyc 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 89 | __pypackages__/ 90 | 91 | # Celery stuff 92 | celerybeat-schedule 93 | celerybeat.pid 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | env.bak/ 105 | venv.bak/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | .dmypy.json 120 | dmypy.json 121 | 122 | # Pyre type checker 123 | .pyre/ 124 | 125 | # pytype static type analyzer 126 | .pytype/ 127 | 128 | # Cython debug symbols 129 | cython_debug/ 130 | 131 | .vscode/ 132 | tmp/ 133 | .ruff_cache/ 134 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.2.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | exclude: | 7 | (?x)^( 8 | \./idea| 9 | \./vscode| 10 | .coverage 11 | )$ 12 | - id: trailing-whitespace 13 | - repo: https://github.com/charliermarsh/ruff-pre-commit 14 | rev: v0.1.11 15 | hooks: 16 | - id: ruff 17 | args: [ "--line-length=120", "--target-version=py311" ] 18 | - repo: https://github.com/pre-commit/mirrors-mypy 19 | rev: v1.8.0 20 | hooks: 21 | - id: mypy 22 | args: ["--check-untyped-defs", "--ignore-missing-imports"] 23 | exclude: ^tests/ 24 | - repo: https://github.com/psf/black 25 | rev: "23.3.0" 26 | hooks: 27 | - id: black 28 | language_version: python3.8 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Emanuele Addis 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 all 13 | 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 THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ☄️ FastAPI Response Schema 2 | [![PyPI](https://img.shields.io/pypi/v/fastapi-responseschema)](https://pypi.org/project/fastapi-responseschema/) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-responseschema)](https://pypi.org/project/fastapi-responseschema/) [![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/acwazz/fastapi-responseschema)](https://github.com/acwazz/fastapi-responseschema/releases) [![Commits](https://img.shields.io/github/last-commit/acwazz/fastapi-responseschema)](https://github.com/acwazz/fastapi-responseschema/commit/master) [![Tests](https://github.com/acwazz/fastapi-responseschema/actions/workflows/test.yml/badge.svg)](https://github.com/acwazz/fastapi-responseschema/actions/workflows/test.yml)[![Lint](https://github.com/acwazz/fastapi-responseschema/actions/workflows/lint.yml/badge.svg)](https://github.com/acwazz/fastapi-responseschema/actions/workflows/lint.yml) 3 | 4 | 5 | ## Overview 6 | This package extends the [FastAPI](https://fastapi.tiangolo.com/) response model schema allowing you to have a common response wrapper via a `fastapi.routing.APIRoute`. 7 | 8 | This library supports Python versions **>=3.8** and FastAPI versions **>=0.89.1**. 9 | 10 | 11 | ## Getting started 12 | 13 | ### Install the package 14 | ```sh 15 | pip install fastapi-responseschema 16 | ``` 17 | 18 | If you are planning to use the pagination integration, you can install the package including [fastapi-pagination](https://github.com/uriyyo/fastapi-pagination) 19 | ```sh 20 | pip install fastapi-responseschema[pagination] 21 | ``` 22 | 23 | ### Usage 24 | 25 | ```py 26 | from typing import Generic, TypeVar, Any, Optional, List 27 | from pydantic import BaseModel 28 | from fastapi import FastAPI 29 | from fastapi_responseschema import AbstractResponseSchema, SchemaAPIRoute, wrap_app_responses 30 | 31 | 32 | # Build your "Response Schema" 33 | class ResponseMetadata(BaseModel): 34 | error: bool 35 | message: Optional[str] 36 | 37 | 38 | T = TypeVar("T") 39 | 40 | 41 | class ResponseSchema(AbstractResponseSchema[T], Generic[T]): 42 | data: T 43 | meta: ResponseMetadata 44 | 45 | @classmethod 46 | def from_exception(cls, reason, status_code, message: str = "Error", **others): 47 | return cls( 48 | data=reason, 49 | meta=ResponseMetadata(error=status_code >= 400, message=message) 50 | ) 51 | 52 | @classmethod 53 | def from_api_route( 54 | cls, content: Any, status_code: int, description: Optional[str] = None, **others 55 | ): 56 | return cls( 57 | data=content, 58 | meta=ResponseMetadata(error=status_code >= 400, message=description) 59 | ) 60 | 61 | 62 | # Create an APIRoute 63 | class Route(SchemaAPIRoute): 64 | response_schema = ResponseSchema 65 | 66 | # Integrate in FastAPI app 67 | app = FastAPI() 68 | wrap_app_responses(app, Route) 69 | 70 | class Item(BaseModel): 71 | id: int 72 | name: str 73 | 74 | 75 | @app.get("/items", response_model=List[Item], description="This is a route") 76 | def get_operation(): 77 | return [Item(id=1, name="ciao"), Item(id=2, name="hola"), Item(id=3, name="hello")] 78 | ``` 79 | 80 | Te result of `GET /items`: 81 | ```http 82 | HTTP/1.1 200 OK 83 | content-length: 131 84 | content-type: application/json 85 | 86 | { 87 | "data": [ 88 | { 89 | "id": 1, 90 | "name": "ciao" 91 | }, 92 | { 93 | "id": 2, 94 | "name": "hola" 95 | }, 96 | { 97 | "id": 3, 98 | "name": "hello" 99 | } 100 | ], 101 | "meta": { 102 | "error": false, 103 | "message": "This is a route" 104 | } 105 | } 106 | ``` 107 | 108 | 109 | ## Docs 110 | You can find detailed info for this package in the [Documentation](https://acwazz.github.io/fastapi-responseschema/). 111 | 112 | 113 | 114 | ## Contributing 115 | 116 | Contributions are very welcome! 117 | 118 | ### How to contribute 119 | Just open an issue or submit a pull request on [GitHub](https://github.com/Luis-David95/fastapi_py). 120 | 121 | While submitting a pull request describe what changes have been made. 122 | 123 | ## Contributors Wall 124 | [![Contributors Wall](https://contrib.rocks/image?repo=acwazz/fastapi-responseschema)](https://github.com/acwazz/fastapi-responseschema/graphs/contributors) 125 | -------------------------------------------------------------------------------- /docs/build.novella: -------------------------------------------------------------------------------- 1 | 2 | template "mkdocs" { 3 | content_directory = "content" 4 | } 5 | 6 | action "mkdocs-update-config" { 7 | site_name = "FastAPI Response Schema" 8 | update '$.theme.features' add: [ 9 | 'navigation.tracking', 10 | 'navigation.instant', 11 | 'navigation.tabs', 12 | 'navigation.expand', 13 | 'navigation.top', 14 | 'toc.integrate', 15 | 'search.suggest', 16 | 'search.highlight', 17 | 'search.share' 18 | ] 19 | } 20 | 21 | action "preprocess-markdown" { 22 | use "pydoc" 23 | } 24 | -------------------------------------------------------------------------------- /docs/content/api/exceptions.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - footer 4 | --- 5 | # Exceptions (`fastapi_responseschema.exceptions`) 6 | 7 | @pydoc fastapi_responseschema.exceptions.BaseGenericHTTPException 8 | 9 | @pydoc fastapi_responseschema.exceptions.GenericHTTPException 10 | 11 | @pydoc fastapi_responseschema.exceptions.BadRequest 12 | @pydoc fastapi_responseschema.exceptions.Unauthorized 13 | @pydoc fastapi_responseschema.exceptions.Forbidden 14 | @pydoc fastapi_responseschema.exceptions.NotFound 15 | @pydoc fastapi_responseschema.exceptions.MethodNotAllowed 16 | @pydoc fastapi_responseschema.exceptions.Conflict 17 | @pydoc fastapi_responseschema.exceptions.Gone 18 | @pydoc fastapi_responseschema.exceptions.UnprocessableEntity 19 | @pydoc fastapi_responseschema.exceptions.InternalServerError -------------------------------------------------------------------------------- /docs/content/api/helpers.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - footer 4 | --- 5 | # Helpers (`fastapi_responseschema.helpers`) 6 | 7 | @pydoc fastapi_responseschema.helpers.wrap_error_responses 8 | @pydoc fastapi_responseschema.helpers.wrap_app_responses -------------------------------------------------------------------------------- /docs/content/api/interfaces.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - footer 4 | --- 5 | # Interfaces (`fastapi_responseschema.interfaces`) 6 | 7 | @pydoc fastapi_responseschema.interfaces.AbstractResponseSchema 8 | 9 | @pydoc fastapi_responseschema.interfaces.ResponseWithMetadata 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/content/api/pagination-integration.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - footer 4 | --- 5 | # Pagination Integration (`fastapi_responseschema.integrations.pagination`) 6 | 7 | @pydoc fastapi_responseschema.integrations.pagination.AbstractPagedResponseSchema 8 | @pydoc fastapi_responseschema.integrations.pagination.PagedSchemaAPIRoute 9 | @pydoc fastapi_responseschema.integrations.pagination.PaginationMetadata 10 | @pydoc fastapi_responseschema.integrations.pagination.PaginationParams 11 | -------------------------------------------------------------------------------- /docs/content/api/routing.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - footer 4 | --- 5 | # Exceptions (`fastapi_responseschema.routing`) 6 | 7 | @pydoc fastapi_responseschema.routing.SchemaAPIRoute 8 | 9 | @pydoc fastapi_responseschema.routing.respond -------------------------------------------------------------------------------- /docs/content/contributing.md: -------------------------------------------------------------------------------- 1 | @cat ../../README.md :with slice_lines = "117:125" 2 | 3 | ## Guidelines 4 | - Try to adhere as much as possible to the Python style and language conventions. 5 | - Add unit tests for classes and methods. 6 | - When writing features exposed in API, always add documentation following the Google Style Python docstrings. 7 | 8 | 9 | ## Enviroment 10 | This package is developed using Python version `3.8`. 11 | 12 | This package uses [poetry](https://python-poetry.org/) to handle dependencies, you can install them with: 13 | ```sh 14 | poetry install -E pagination 15 | ``` 16 | 17 | 18 | ## Formatting 19 | [Black](https://black.readthedocs.io/en/stable/) is used to provide code autoformatting e linting. 20 | Before committing your changes run `black`: 21 | ```sh 22 | black . 23 | ``` 24 | 25 | ## Type checking 26 | [mypy](https://mypy.readthedocs.io/en/stable/index.html) is used to statically type check the source code. 27 | Before committing your changes run `mypy`: 28 | ```sh 29 | mypy fastapi_responseschema 30 | ``` 31 | 32 | ## Testing 33 | Tests are written using [pytest](https://docs.pytest.org/en/7.1.x/). 34 | To run the test suite just type in your terminal: 35 | ```sh 36 | pytest 37 | ``` 38 | This will generate the coverage in html format in a root level directory `htmlcov`. 39 | 40 | 41 | ## Documentation 42 | Documentation is built using [pydoc-markdown](https://niklasrosenstein.github.io/pydoc-markdown/). 43 | To run the documentation dev server: 44 | ```sh 45 | novella -d docs --serve 46 | ``` 47 | To build the docs: 48 | ```sh 49 | novella -d docs 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/content/guide/pagination-integration.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - footer 4 | --- 5 | 6 | FastAPI Response Schema integrates with [FastAPI Pagination](https://github.com/uriyyo/fastapi-pagination) to handle pagination logic without reinventig the wheel. 7 | 8 | However, you can implement your own pagination utilities and integrate them with FastaAPI Response Schema. 9 | 10 | 11 | ## PagedResponseSchema 12 | A `PagedResponseSchema` is a generic that inherits from `fastapi_pagination.base.AbstractPage` and `fastapi_responseschema.interfaces.AbstractResponseSchema`. 13 | 14 | You can use this type of classes to handle pagination with a global response schema logic. 15 | 16 | ```py 17 | from typing import Sequence, TypeVar, Any, Generic, Union 18 | from fastapi_responseschema.integrations.pagination import AbstractPagedResponseSchema, PaginationMetadata, PagedSchemaAPIRoute, PaginationParams 19 | 20 | 21 | class ResponseMetadata(BaseModel): 22 | error: bool 23 | message: Optional[str] 24 | pagination: Optional[PaginationMetadata] 25 | 26 | T = TypeVar("T") 27 | 28 | class PagedResponseSchema(AbstractPagedResponseSchema[T], Generic[T]): 29 | data: Union[Sequence[T], T] # In case of error response we will pass a scalar type, a string or a dict 30 | meta: ResponseMetadata 31 | 32 | @classmethod 33 | def create( 34 | cls, 35 | items: Sequence[T], 36 | total: int, 37 | params: PaginationParams, 38 | ): # This constructor gets called first and creates the FastAPI Pagination response model. 39 | # For fields that are not present in this method signature just set some defaults, 40 | # you will override them in the `from_api_route` constructor 41 | return cls( 42 | data=items, 43 | meta=ResponseMetadata( 44 | error=False, 45 | pagination=PaginationMetadata.from_abstract_page_create(total=total, params=params) 46 | ) 47 | ) 48 | 49 | @classmethod 50 | def from_exception(cls, reason: T, status_code: int, **others): 51 | return cls( 52 | data=reason, 53 | meta=ResponseMetadata(error=status_code >= 400, message=message) 54 | ) 55 | 56 | @classmethod 57 | def from_api_route(cls, content: Sequence[T], description: Optional[str] = None, **others): 58 | # `content` parameter is the output from the `create` constructor. 59 | return cls(error=status_code >= 400, data=content.data, meta=content.meta) 60 | ``` 61 | 62 | ## PagedSchemaAPIRoute 63 | 64 | This is a SchemaAPIRoute that supports a `PagedResponseSchema` for paginated responses. 65 | 66 | ```py 67 | from fastapi_responseschema.integrations.pagination import PagedSchemaAPIRoute 68 | 69 | ... 70 | 71 | class PagedRoute(PagedSchemaAPIRoute): 72 | response_schema = ResponseSchema 73 | paged_response_schema = PagedResponseSchema 74 | 75 | ``` 76 | This `PagedSchemaAPIRoute` can be integrated in fastapi as a `SchemaAPIRoute`. 77 | 78 | 79 | ## Usage 80 | The `AbstractPagedResponseSchema` class inherits from the `fastapi_pagination.bases.AbstractPage` and has to be used to configure the pagination correctly. 81 | 82 | 83 | ```py 84 | from pydantic import BaseModel 85 | from fastapi import FastAPI 86 | from fastapi_pagination import paginate, add_pagination 87 | from fastapi_responseschema import wrap_app_responses 88 | from .myroutes import PagedRoute # the SchemaAPIRoute you defined 89 | from .myschemas import PagedResponseSchema # the ResponseSchema you defined 90 | 91 | app = FastAPI() 92 | wrap_app_responses(app, route_class=PagedRoute) 93 | 94 | class Bird(BaseModel): 95 | id: int 96 | 97 | 98 | @app.get("/birds", response_model=PagedResponseSchema[Bird]) 99 | def list_birds_w_pagination(): 100 | return paginate([Bird(id=n) for n in range(1, 3000)]) 101 | 102 | add_pagination(app) 103 | ``` 104 | 105 | 106 | ## `PaginationParams` and `PaginationMetadata` 107 | 108 | Just take a look at the [API documentation](/api/pagination-integration/#class-paginationmetadata) to learn more about. 109 | -------------------------------------------------------------------------------- /docs/content/guide/routing.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - footer 4 | --- 5 | Once you have created your response schemas you can define the `SchemaAPIRoute` that will be used for your API set. 6 | 7 | A `SchemaAPIRoute` just inherits from `fastapi.routing.APIRoute`. 8 | 9 | ```py 10 | from fastapi_responseschema import SchemaAPIRoute 11 | from .myschemas import StandardResponseSchema # The response schema you defined 12 | 13 | class StandardAPIRoute(SchemaAPIRoute): 14 | response_schema = StandardResponseSchema # This attribute is required 15 | ``` 16 | 17 | If you want to handle different schemas for success and error responses you can set the error response schema. 18 | 19 | ```py 20 | from fastapi_responseschema import SchemaAPIRoute 21 | from .myschemas import OKResponseSchema, KOResponseSchema # The response schemas you defined 22 | 23 | class StandardAPIRoute(SchemaAPIRoute): 24 | response_schema = OKResponseSchema # This attribute is required 25 | error_response_schema = KOResponseSchema # If not set defaults to `SchemaAPIRoute.response_schema` 26 | ``` 27 | 28 | ### Integrating in your API 29 | 30 | You can set the defined `SchemaAPIRoute` in you FastAPI application. 31 | 32 | ```py 33 | from fastapi import FastAPI 34 | from .myroutes import StandardAPIRoute # the SchemaAPIRoute you defined 35 | 36 | app = FastAPI() 37 | 38 | app.router.route_class = StandardAPIRoute 39 | 40 | @app.get("/") 41 | def just_a_route(): 42 | return {"message": "It Works!"} 43 | ``` 44 | 45 | You can even integrate the schema api route in `APIRouter`. 46 | 47 | ```py 48 | from pydantic import BaseModel 49 | from fastapi import APIRouter 50 | from .myroutes import StandardAPIRoute # the SchemaAPIRoute you defined 51 | 52 | router = APIRouter(route_class=StandardAPIRoute) 53 | 54 | class ParrotMessage(BaseModel): 55 | message: str 56 | 57 | 58 | @router.post("/parrot") 59 | def repeat(body: ParrotMessage): 60 | return {"parrot_says": body.message} 61 | ``` 62 | 63 | ### Handling errors 64 | You can wrap all error responses with the `wrap_error_responses` helper. 65 | 66 | ```py 67 | from fastapi import FastAPI 68 | from fastapi_responseschema import wrap_error_responses 69 | from .myroutes import StandardAPIRoute 70 | from .myschemas import KOResponseSchema 71 | 72 | app = FastAPI() 73 | 74 | app.router.route_class = StandardAPIRoute 75 | wrap_error_responses(app, error_response_schema=KOResponseSchema) 76 | 77 | @app.get("/") 78 | def just_a_route(): 79 | return {"message": "It Works!"} 80 | ``` 81 | 82 | 83 | ### Handling errors and override application APIRoute 84 | The same functionality as: 85 | ```py 86 | ... 87 | 88 | app.router.route_class = StandardAPIRoute 89 | wrap_error_responses(app, error_response_schema=KOResponseSchema) 90 | 91 | ... 92 | ``` 93 | 94 | Can be achieved with `wrap_app_responses`: 95 | ```py 96 | from fastapi import FastAPI 97 | from fastapi_responseschema import wrap_app_responses 98 | from .myroutes import StandardAPIRoute # the SchemaAPIRoute you defined 99 | 100 | app = FastAPI() 101 | 102 | wrap_app_responses(app, route_class=StandardAPIRoute) 103 | 104 | @app.get("/") 105 | def just_a_route(): 106 | return {"message": "It Works!"} 107 | ``` 108 | 109 | > You still need to configure the route class for every `fastapi.APIRouter`. 110 | 111 | ### About `response_model_exclude`, `response_model_include` and others `response_model_*` parametrs 112 | When using response fields modifiers on-the-fly. you must consider that the final output of `response_model` will be wrapped by the configured ResponseSchema. 113 | 114 | For this snippet: 115 | ```py 116 | from typing import TypeVar, Generic 117 | from pydantic import BaseModel 118 | from fastapi import APIRouter 119 | from fastapi_responseschema import AbstractResponseSchema, SchemaAPIRoute 120 | 121 | T = TypeVar("T") 122 | 123 | class ResponseSchema(AbstractResponseSchema[T], Generic[T]): 124 | data: T 125 | error: bool 126 | message: Optional[str] 127 | 128 | ... # constructors etc. 129 | 130 | class Item(BaseModel): 131 | id: int 132 | name: str 133 | additional_desc: Optional[str] 134 | 135 | class MainAPIRoute(SchemaAPIRoute): 136 | response_schema = ResponseSchema 137 | 138 | router = APIRouter(route_class=MainAPIRoute) 139 | 140 | @router.get("/item", response_model=Item) 141 | def show_item(): 142 | return {"id": 11, "name": "Just a Teapot!"} 143 | ``` 144 | 145 | The resulting response payload of `GET /items` will be: 146 | ```json 147 | { 148 | "data": { 149 | "id": 11, 150 | "name": "Just a Teapot!", 151 | "additional_desc": null 152 | }, 153 | "error": false, 154 | "message": null 155 | } 156 | ``` 157 | 158 | When applying the `response_model_exclude` and `additional_model_include` for the `response_model` remeber to consider the nested output. 159 | 160 | For Example: 161 | ```py 162 | ... 163 | 164 | @router.get("/item", response_model=Item, response_model_exclude={"data": {"name"}}) # Exclusion of nested fields 165 | def show_item(): 166 | return {"id": 11, "name": "Just a Teapot!"} 167 | ``` 168 | Returns: 169 | 170 | ```json 171 | { 172 | "data": { 173 | "id": 11, 174 | "additional_desc": null 175 | }, 176 | "error": false, 177 | "message": null 178 | } 179 | ``` 180 | 181 | When you use `response_model_exclude_none` and similar parameters the configuration will be applyed to all the response schema. 182 | 183 | For example: 184 | ```py 185 | ... 186 | 187 | @router.get("/item", response_model=Item, response_model_exclude_none=True) # Exclusion of nested fields 188 | def show_item(): 189 | return {"id": 11, "name": "Just a Teapot!"} 190 | ``` 191 | Returns: 192 | 193 | ```json 194 | { 195 | "data": { 196 | "id": 11, 197 | "name": "Just a Teapot!" 198 | }, 199 | "error": false 200 | } 201 | ``` 202 | 203 | > To modify the response content you should prefer the definition of dedicated models. -------------------------------------------------------------------------------- /docs/content/guide/schemas.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - footer 4 | --- 5 | Response schemas heavily rely on the concept of [pydantic GenericModel](https://pydantic-docs.helpmanual.io/usage/models/#generic-models). 6 | 7 | In this way `response_model` can be wrapped by the `ResponseSchema` in the fastapi operation. 8 | 9 | **`ResponseSchema` will wrap your response ONLY if you configured a `response_model` in your route operation.** 10 | 11 | In order to create a response schema you need to make the class a [Generic](https://docs.python.org/3.8/library/typing.html#generics) type. 12 | ```py 13 | from typing import TypeVar, Generic 14 | from fastapi_responseschema import AbstractResponseSchema 15 | 16 | T = TypeVar("T") 17 | 18 | 19 | class ResponseSchema(AbstractResponseSchema[T], Generic[T]): 20 | ... 21 | ``` 22 | 23 | ## Constructors 24 | When creating a response schema, constructors must be defined in subclass to ensure that the final response model gets correctly created and additional metadata can be passed to the final response. 25 | 26 | ### `AbstractResponseSchema.from_exception` 27 | This constructor wraps the final response from an [exception handler](https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers). 28 | You can view the full parameter list [here](/api/interfaces/#from_exception). 29 | 30 | ### `AbstractResponseSchema.from_api_route` 31 | This constructor wraps the final response when initializing an [APIRoute](https://fastapi.tiangolo.com/advanced/custom-request-and-route/?h=apiroute). 32 | You can view the full parameter list [here](/api/interfaces/#from_api_route). 33 | 34 | 35 | ```py 36 | from typing import TypeVar, Generic 37 | from fastapi_responseschema import AbstractResponseSchema 38 | 39 | T = TypeVar("T") 40 | 41 | 42 | class ResponseSchema(AbstractResponseSchema[T], Generic[T]): 43 | data: T 44 | error: bool 45 | message: str 46 | 47 | @classmethod 48 | def from_exception(cls, reason: T, status_code: int, message: str = "Error", **others): # from an exception handler 49 | return cls( 50 | data=reason, 51 | error=status_code >= 400, 52 | message=message 53 | ) 54 | 55 | @classmethod 56 | def from_api_route( 57 | cls, content: T, status_code: int, description: Optional[str] = None, **others 58 | ): # from an api route 59 | return cls( 60 | data=content, 61 | error=status_code >= 400, 62 | description=description 63 | ) 64 | ``` 65 | 66 | > Multiple response schemas can be built and composed in `SchemaAPIRoute` subclasses. -------------------------------------------------------------------------------- /docs/content/guide/utilities.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - footer 4 | --- 5 | ### Additional metadata in the resulting response schema 6 | If you need to add fields to the response schema that are not supported by [`AbstractResponseSchema.from_api_route`](/api/interfaces/#from_api_route), you can use the `respond` function. 7 | 8 | ```py 9 | # schemas.py file 10 | from typing import TypeVar, Generic 11 | from fastapi_responseschema import AbstractResponseSchema 12 | 13 | T = TypeVar("T") 14 | 15 | 16 | class ResponseSchema(AbstractResponseSchema[T], Generic[T]): 17 | data: T 18 | error: bool 19 | code: str # From a `result_code` field, not natively supported by constructors 20 | 21 | @classmethod 22 | def from_exception(cls, reason: T, status_code: int, result_code: str = "Error", **others): 23 | return cls( 24 | data=reason, 25 | error=status_code >= 400, 26 | code=result_code 27 | ) 28 | 29 | @classmethod 30 | def from_api_route( 31 | cls, content: T, status_code: int, result_code: Optional[str] = None, **others 32 | ): 33 | return cls( 34 | data=content, 35 | error=status_code >= 400, 36 | code=result_code 37 | ) 38 | 39 | ... 40 | 41 | # api.py file 42 | from fastapi import APIRouter 43 | from fastapi_responseschema import respond 44 | from .schemas import StandardAPIRoute # the SchemaAPIRoute you defined 45 | 46 | router = APIRouter(route_class=StandardAPIRoute) 47 | 48 | class ParrotMessage(BaseModel): 49 | message: str 50 | 51 | 52 | @router.post("/parrot") 53 | def repeat(body: ParrotMessage): 54 | return respond({"parrot_says": body.message}, result_code="OK_PARROT_HEALTHY") 55 | ``` 56 | 57 | In a similar way, for fields that are not supported in [`AbstractResponseSchema.from_exception`](/api/interfaces/#from_exception) you can raise an exception with metadata: 58 | ```py 59 | from fastapi_responseschema.exceptions import GenericHTTPException 60 | ... 61 | 62 | @router.get("/faulty") 63 | def repeat(): 64 | raise GenericHTTPException(status_code=405, detail="This is a faulty service", result_code="KO_NOT_SUPPORTED") 65 | ``` 66 | 67 | ### Exceptions 68 | When developing a backend service usually we keep raising the same few excpetions with the same status code. 69 | You can use the `exceptions` module to reduce a little bit the boilerplate code. 70 | 71 | ```py 72 | from fastapi_responseschema.exceptions import MethodNotAllowed, Gone, NotFound 73 | 74 | ... 75 | 76 | @router.get("/faulty") 77 | def repeat(): 78 | raise MethodNotAllowed(detail="This is a faulty service", result_code="KO_NOT_SUPPORTED") 79 | 80 | @router.get("/ghost") 81 | def ghost(): 82 | raise Gone(detail="This resource is gone, forever.", result_code="KO_CREEPY_GONE") 83 | 84 | @router.get("/nope") 85 | def ghost(): 86 | raise NotFound(detail="Nope man, can't help you", result_code="KO_NOT_FOUND") 87 | ``` -------------------------------------------------------------------------------- /docs/content/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - footer 4 | --- 5 | 6 | @cat ../../README.md :with slice_lines = "2:107" -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: FastAPI Response Schema 2 | repo_url: https://github.com/acwazz/fastapi-responseschema 3 | repo_name: fastapi-responseschema 4 | 5 | theme: 6 | name: material 7 | language: en 8 | icon: 9 | logo: fontawesome/solid/meteor 10 | repo: fontawesome/brands/github 11 | font: 12 | text: Fira Sans 13 | code: Fira Code 14 | palette: 15 | # Toggle light mode 16 | - scheme: default 17 | primary: orange 18 | accent: teal 19 | toggle: 20 | icon: fontawesome/solid/moon 21 | name: Dark mode 22 | - scheme: slate 23 | primary: teal 24 | accent: orange 25 | toggle: 26 | icon: fontawesome/regular/lightbulb 27 | name: Light mode 28 | 29 | plugins: 30 | - search: 31 | lang: en 32 | - tags 33 | 34 | nav: 35 | - Guide: 36 | - Prelude: 'index.md' 37 | - Schemas: 'guide/schemas.md' 38 | - Routing: 'guide/routing.md' 39 | - Utilities: 'guide/utilities.md' 40 | - Pagination Integration: 'guide/pagination-integration.md' 41 | - API: 42 | - Interfaces: 'api/interfaces.md' 43 | - Exceptions: 'api/exceptions.md' 44 | - Routing: 'api/routing.md' 45 | - Helpers: 'api/helpers.md' 46 | - Pagination Integration: 'api/pagination-integration.md' 47 | - Contibuting: 'contributing.md' -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs 2 | mkdocs-material 3 | pydoc-markdown==4.7.0 4 | novella==0.2.5 -------------------------------------------------------------------------------- /fastapi_responseschema/__init__.py: -------------------------------------------------------------------------------- 1 | from .interfaces import AbstractResponseSchema 2 | from .routing import respond, SchemaAPIRoute 3 | from .helpers import wrap_app_responses, wrap_error_responses 4 | 5 | 6 | __version__ = "2.1.0" 7 | 8 | 9 | __all__ = ["AbstractResponseSchema", "respond", "SchemaAPIRoute", "wrap_app_responses", "wrap_error_responses"] 10 | -------------------------------------------------------------------------------- /fastapi_responseschema/_compat.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Set, Union 2 | from importlib.metadata import version 3 | from pydantic import BaseModel # noqa: E402 4 | 5 | PYDANTIC_MAJOR = int(version("pydantic").split(".")[0]) 6 | 7 | try: 8 | from fastapi.encoders import DictIntStrAny, SetIntStr # type: ignore 9 | except ImportError: 10 | SetIntStr = Set[Union[int, str]] 11 | DictIntStrAny = Dict[Union[int, str], Any] 12 | 13 | 14 | if PYDANTIC_MAJOR < 2: 15 | from pydantic.generics import GenericModel as PydanticGenericModel # noqa: F401 16 | from pydantic.utils import lenient_issubclass, lenient_isinstance # noqa: F401 17 | 18 | def model_to_dict(model: BaseModel) -> dict: 19 | return model.dict() 20 | 21 | else: 22 | from pydantic import BaseModel as PydanticGenericModel # noqa: F401 23 | from pydantic.v1.utils import lenient_issubclass, lenient_isinstance # noqa: F401 24 | 25 | def model_to_dict(model: BaseModel) -> dict: 26 | return model.model_dump() 27 | -------------------------------------------------------------------------------- /fastapi_responseschema/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Optional, Dict, Any 3 | from starlette import status 4 | from fastapi.exceptions import HTTPException as FastAPIHTTPException 5 | 6 | 7 | class BaseGenericHTTPException(FastAPIHTTPException): 8 | """BaseClass for HTTPExceptions with additional data""" 9 | 10 | status_code: Optional[int] = None # type: ignore 11 | 12 | def __init__(self, detail: Any = None, headers: Optional[Dict[str, Any]] = None, **extra_params: Any) -> None: 13 | """Instances can be initialized with a set of extra params. 14 | 15 | Args: 16 | detail (Any, optional): The error response content. Defaults to None. 17 | headers (Optional[Dict[str, Any]], optional): A set of headers to be returned in the response. Defaults to None. 18 | """ 19 | self.extra_params = extra_params 20 | super().__init__(status_code=self.status_code, detail=detail, headers=headers) # type: ignore 21 | 22 | def __init_subclass__(cls) -> None: 23 | if not hasattr(cls, "status_code") or cls.status_code is None: 24 | raise AttributeError("`status_code` must be defined in subclass.") 25 | return super().__init_subclass__() 26 | 27 | 28 | class GenericHTTPException(BaseGenericHTTPException): 29 | """HTTP exception with extra data""" 30 | 31 | status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR 32 | 33 | def __init__( 34 | self, status_code: int, detail: Any = None, headers: Optional[Dict[str, Any]] = None, **extra_params: Any 35 | ) -> None: 36 | """Used to raise custom exceptions with extra params. 37 | 38 | Args: 39 | status_code (int): Exception status code. 40 | detail (Any, optional): Error response content. Defaults to None. 41 | headers (Optional[Dict[str, Any]], optional): Error response data. Defaults to None. 42 | """ 43 | self.status_code = status_code 44 | super().__init__(detail=detail, headers=headers, extra_params=extra_params) 45 | 46 | 47 | class InternalServerError(BaseGenericHTTPException): 48 | """Raises with HTTP status 500 49 | 50 | Args: 51 | detail (Any, optional): The error response content. Defaults to None. 52 | headers (Optional[Dict[str, Any]], optional): A set of headers to be returned in the response. Defaults to None. 53 | """ 54 | 55 | status_code = status.HTTP_500_INTERNAL_SERVER_ERROR 56 | 57 | 58 | class BadRequest(BaseGenericHTTPException): 59 | """Raises with HTTP status 400 60 | 61 | Args: 62 | detail (Any, optional): The error response content. Defaults to None. 63 | headers (Optional[Dict[str, Any]], optional): A set of headers to be returned in the response. Defaults to None. 64 | """ 65 | 66 | status_code = status.HTTP_400_BAD_REQUEST 67 | 68 | 69 | class Unauthorized(BaseGenericHTTPException): 70 | """Raises with HTTP status 401 71 | 72 | Args: 73 | detail (Any, optional): The error response content. Defaults to None. 74 | headers (Optional[Dict[str, Any]], optional): A set of headers to be returned in the response. Defaults to None. 75 | """ 76 | 77 | status_code = status.HTTP_401_UNAUTHORIZED 78 | 79 | 80 | class Forbidden(BaseGenericHTTPException): 81 | """Raises with HTTP status 403 82 | 83 | Args: 84 | detail (Any, optional): The error response content. Defaults to None. 85 | headers (Optional[Dict[str, Any]], optional): A set of headers to be returned in the response. Defaults to None. 86 | """ 87 | 88 | status_code = status.HTTP_403_FORBIDDEN 89 | 90 | 91 | class NotFound(BaseGenericHTTPException): 92 | """Raises with HTTP status 404 93 | 94 | Args: 95 | detail (Any, optional): The error response content. Defaults to None. 96 | headers (Optional[Dict[str, Any]], optional): A set of headers to be returned in the response. Defaults to None. 97 | """ 98 | 99 | status_code = status.HTTP_404_NOT_FOUND 100 | 101 | 102 | class MethodNotAllowed(BaseGenericHTTPException): 103 | """Raises with HTTP status 405 104 | 105 | Args: 106 | detail (Any, optional): The error response content. Defaults to None. 107 | headers (Optional[Dict[str, Any]], optional): A set of headers to be returned in the response. Defaults to None. 108 | """ 109 | 110 | status_code = status.HTTP_405_METHOD_NOT_ALLOWED 111 | 112 | 113 | class Conflict(BaseGenericHTTPException): 114 | """Raises with HTTP status 409 115 | 116 | Args: 117 | detail (Any, optional): The error response content. Defaults to None. 118 | headers (Optional[Dict[str, Any]], optional): A set of headers to be returned in the response. Defaults to None. 119 | """ 120 | 121 | status_code = status.HTTP_409_CONFLICT 122 | 123 | 124 | class Gone(BaseGenericHTTPException): 125 | """Raises with HTTP status 410 126 | 127 | Args: 128 | detail (Any, optional): The error response content. Defaults to None. 129 | headers (Optional[Dict[str, Any]], optional): A set of headers to be returned in the response. Defaults to None. 130 | """ 131 | 132 | status_code = status.HTTP_410_GONE 133 | 134 | 135 | class UnprocessableEntity(BaseGenericHTTPException): 136 | """Raises with HTTP status 422 137 | 138 | Args: 139 | detail (Any, optional): The error response content. Defaults to None. 140 | headers (Optional[Dict[str, Any]], optional): A set of headers to be returned in the response. Defaults to None. 141 | """ 142 | 143 | status_code = status.HTTP_422_UNPROCESSABLE_ENTITY 144 | -------------------------------------------------------------------------------- /fastapi_responseschema/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Type 3 | from fastapi import FastAPI 4 | from fastapi.responses import JSONResponse 5 | from fastapi.exceptions import RequestValidationError 6 | from starlette.exceptions import HTTPException as StarletteHTTPException 7 | 8 | from .routing import SchemaAPIRoute 9 | from .exceptions import BaseGenericHTTPException 10 | from .interfaces import AbstractResponseSchema 11 | from ._compat import model_to_dict 12 | 13 | 14 | def wrap_error_responses(app: FastAPI, error_response_schema: Type[AbstractResponseSchema]) -> FastAPI: 15 | """Wraps all exception handlers with the provided response schema. 16 | 17 | Args: 18 | app (FastAPI): A FastAPI application instance. 19 | error_response_schema (Type[AbstractResponseSchema]): Response schema wrapper model. 20 | 21 | Returns: 22 | FastAPI: The application instance 23 | """ 24 | 25 | async def exception_handler(request, exc): 26 | status_code = getattr(exc, "status_code") if not isinstance(exc, RequestValidationError) else 422 27 | # due to: https://github.com/python/mypy/issues/12392 FIXME: when gets fixed 28 | model = error_response_schema[Any] # type: ignore 29 | return JSONResponse( 30 | content=model_to_dict(model.from_exception_handler(request=request, exception=exc)), 31 | status_code=status_code, 32 | headers=getattr(exc, "headers", dict()), 33 | ) 34 | 35 | app.add_exception_handler(RequestValidationError, exception_handler) 36 | app.add_exception_handler(StarletteHTTPException, exception_handler) 37 | app.add_exception_handler(BaseGenericHTTPException, exception_handler) 38 | return app 39 | 40 | 41 | def wrap_app_responses(app: FastAPI, route_class: Type[SchemaAPIRoute]) -> FastAPI: 42 | """Wraps all app defaults responses 43 | 44 | Args: 45 | app (FastAPI): A FastAPI application instance. 46 | route_class (Type[SchemaAPIRoute]): The SchemaAPIRoute with your response schemas. 47 | 48 | Returns: 49 | FastAPI: The application instance. 50 | """ 51 | app.router.route_class = route_class 52 | err_schema = getattr(route_class, "error_response_schema") 53 | if err_schema is None: 54 | err_schema = route_class.response_schema 55 | app = wrap_error_responses(app, error_response_schema=err_schema) 56 | return app 57 | -------------------------------------------------------------------------------- /fastapi_responseschema/integrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xp3p3x0/fastapi_py/8694e21857e32068cfaf0f2e5ae95e0b213ebd41/fastapi_responseschema/integrations/__init__.py -------------------------------------------------------------------------------- /fastapi_responseschema/integrations/pagination.py: -------------------------------------------------------------------------------- 1 | from math import ceil 2 | from typing import Generic, TypeVar, Type, Any, Optional, ClassVar, Protocol 3 | from fastapi import Query 4 | from fastapi_pagination.bases import AbstractPage, AbstractParams, RawParams 5 | from fastapi_pagination.links.bases import Links, create_links 6 | from pydantic import BaseModel 7 | from pydantic.types import conint 8 | from fastapi_responseschema.routing import SchemaAPIRoute 9 | from fastapi_responseschema.interfaces import AbstractResponseSchema 10 | 11 | 12 | T = TypeVar("T") 13 | TPagedResponseSchema = TypeVar("TPagedResponseSchema", bound="AbstractPagedResponseSchema") 14 | 15 | 16 | class SupportedParams(Protocol): # pragma: no cover 17 | page: int 18 | page_size: int 19 | 20 | def to_raw_params(self) -> RawParams: 21 | pass 22 | 23 | 24 | class PaginationMetadata(BaseModel): 25 | """Pagination metadata model for pagination info. 26 | 27 | Args: 28 | total (int): Total number of items. 29 | page_size (int): Number of items per page. 30 | page (int): Page number. 31 | links (dict): Object containing pagination links. 32 | """ 33 | 34 | total: conint(ge=0) # type: ignore 35 | page_size: conint(ge=0) # type: ignore 36 | page: conint(ge=1) # type: ignore 37 | links: Links 38 | 39 | @classmethod 40 | def from_abstract_page_create(cls, total: int, params: SupportedParams) -> "PaginationMetadata": 41 | """Create pagination metadata from an abstract page. 42 | 43 | Args: 44 | total (int): Total number of items. 45 | params (SupportedParams): A FastaAPI Pagination Params instance. 46 | 47 | Returns: 48 | PaginationMetadata: PaginationMetadata instance 49 | """ 50 | return cls( 51 | total=total, 52 | page_size=params.page_size, 53 | page=params.page, 54 | links=create_links( 55 | first={"page": 1}, 56 | last={"page": ceil(total / params.page_size) if total > 0 else 1}, 57 | next={"page": params.page + 1} if params.page * params.page_size < total else None, 58 | prev={"page": params.page - 1} if 1 <= params.page - 1 else None, 59 | ), 60 | ) 61 | 62 | 63 | class PaginationParams(BaseModel, AbstractParams): # pragma: no cover 64 | """Pagination Querystring parameters 65 | 66 | Args: 67 | page (int): The page number. 68 | page_size (int): Number of items per page. 69 | """ 70 | 71 | page: int = Query(1, ge=1, description="Page number") 72 | page_size: int = Query(50, ge=1, le=100, description="Page size") 73 | 74 | def to_raw_params(self) -> RawParams: 75 | return RawParams( 76 | limit=self.page_size, 77 | offset=self.page_size * (self.page - 1), 78 | ) 79 | 80 | 81 | class AbstractPagedResponseSchema(AbstractPage[T], AbstractResponseSchema[T], Generic[T]): 82 | """Abstract generic model for building response schema interfaces with pagination logic.""" 83 | 84 | __params_type__: ClassVar[Type[AbstractParams]] = PaginationParams 85 | 86 | class Config: 87 | arbitrary_types_allowed = True 88 | 89 | 90 | class PagedSchemaAPIRoute(SchemaAPIRoute): 91 | """A SchemaAPIRoute class with pagination support. 92 | Must be subclassed setting at least SchemaAPIRoute.response_model. 93 | 94 | Usage: 95 | 96 | from typing import Generic, TypeVar 97 | from fastapi_responseschema.integrations.pagination import AbstractPagedResponseSchema 98 | 99 | T = TypeVar("T") 100 | class MyResponseSchema(AbstractPagedResponseSchema[T], Generic[T]): 101 | ... 102 | 103 | class MyAPIRoute(SchemaPagedAPIRoute): 104 | response_schema = MyResponseSchema 105 | paged_response_schema = MyResponseSchema 106 | 107 | from fastapi import APIRouter 108 | 109 | router = APIRouter(route_class=MyAPIRoute) 110 | """ 111 | 112 | paged_response_schema: Type[AbstractPagedResponseSchema[Any]] 113 | response_schema: Optional[Type[AbstractResponseSchema[Any]]] = None # type: ignore 114 | error_response_schema: Optional[Type[AbstractResponseSchema[Any]]] = None 115 | 116 | def __init_subclass__(cls) -> None: 117 | if not hasattr(cls, "paged_response_schema") or getattr(cls, "paged_response_schema") is None: 118 | raise AttributeError("`paged_response_schema` must be defined in subclass.") 119 | if not hasattr(cls, "response_schema") or getattr(cls, "response_schema") is None: 120 | raise AttributeError("`response_schema` must be defined in subclass.") 121 | return super().__init_subclass__() 122 | 123 | def get_wrapper_model(self, is_error: bool, response_model: Type[Any]) -> Type[AbstractResponseSchema[Any]]: 124 | if issubclass(response_model, AbstractPagedResponseSchema): 125 | if not self.error_response_schema: 126 | return self.paged_response_schema 127 | return self.error_response_schema if is_error else self.paged_response_schema 128 | return super().get_wrapper_model(is_error, response_model) 129 | -------------------------------------------------------------------------------- /fastapi_responseschema/interfaces.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Optional, Any, Type, List, Union, Set, TypeVar, Generic, NamedTuple, ClassVar, Tuple 3 | from dataclasses import dataclass 4 | from abc import ABC, abstractmethod 5 | from fastapi import Request, Response 6 | from pydantic import BaseModel 7 | from fastapi.exceptions import RequestValidationError, HTTPException as FastAPIHTTPException 8 | from starlette.exceptions import HTTPException as StarletteHTTPException 9 | from .exceptions import BaseGenericHTTPException 10 | from ._compat import DictIntStrAny, SetIntStr, PydanticGenericModel 11 | 12 | T = TypeVar("T") 13 | TResponseSchema = TypeVar("TResponseSchema", bound="AbstractResponseSchema") 14 | 15 | 16 | @dataclass 17 | class HTTPExceptionAdapter: 18 | reason: Any 19 | status_code: int 20 | headers: Optional[dict] 21 | extra_params: Any 22 | 23 | @classmethod 24 | def from_starlette_exc(cls, exc: StarletteHTTPException) -> "HTTPExceptionAdapter": 25 | return cls( 26 | status_code=exc.status_code, reason=getattr(exc, "detail", str(exc)), headers=None, extra_params=dict() 27 | ) 28 | 29 | @classmethod 30 | def from_fastapi_exc(cls, exc: FastAPIHTTPException) -> "HTTPExceptionAdapter": 31 | return cls( 32 | status_code=exc.status_code, 33 | reason=getattr(exc, "detail", str(exc)), 34 | headers=exc.headers, 35 | extra_params=dict(), 36 | ) 37 | 38 | @classmethod 39 | def from_request_validation_err(cls, exc: RequestValidationError) -> "HTTPExceptionAdapter": 40 | return cls(status_code=422, reason=exc.errors(), headers=None, extra_params=dict()) 41 | 42 | @classmethod 43 | def from_generic_http_exc(cls, exc: BaseGenericHTTPException) -> "HTTPExceptionAdapter": 44 | return cls( 45 | status_code=exc.status_code if exc.status_code is not None else 500, 46 | reason=exc.detail, 47 | headers=exc.headers, 48 | extra_params=exc.extra_params, 49 | ) 50 | 51 | 52 | class AbstractResponseSchema(PydanticGenericModel, Generic[T], ABC): 53 | """Abstract generic model for building response schema interfaces.""" 54 | 55 | __inner_type__: ClassVar[Union[Type[Any], Tuple[Type[Any], ...]]] 56 | 57 | @classmethod 58 | @abstractmethod 59 | def from_api_route( 60 | cls: Type[TResponseSchema], 61 | content: T, 62 | path: str, 63 | status_code: int, 64 | response_model: Optional[Type[BaseModel]] = None, 65 | tags: Optional[List[str]] = None, 66 | summary: Optional[str] = None, 67 | description: Optional[str] = None, 68 | response_description: str = "Successful Response", 69 | deprecated: Optional[bool] = None, 70 | name: Optional[str] = None, 71 | methods: Optional[Union[Set[str], List[str]]] = None, 72 | operation_id: Optional[str] = None, 73 | response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, 74 | response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, 75 | response_model_by_alias: bool = True, 76 | response_model_exclude_unset: bool = False, 77 | response_model_exclude_defaults: bool = False, 78 | response_model_exclude_none: bool = False, 79 | include_in_schema: bool = True, 80 | response_class: Optional[Type[Response]] = None, 81 | **extra_params: Any, 82 | ) -> TResponseSchema: # pragma: no cover 83 | """Builds an instance of response model from an API Route constructor. 84 | This method must be overridden by subclasses. 85 | 86 | Args: 87 | content (Any): The response content. 88 | path (str): Response path info. 89 | status_code (int): response status code. 90 | response_model (Optional[Type[BaseModel]], optional): The route response model. Defaults to None. 91 | tags (Optional[List[str]], optional): OpenAPI Tags configured in the API Route. Defaults to None. 92 | summary (Optional[str], optional): OpenAPI Summary. Defaults to None. 93 | description (Optional[str], optional): OpenAPI description. Defaults to None. 94 | response_description (str, optional): A string describing the response. Defaults to "Successful Response". 95 | deprecated (Optional[bool], optional): OpenAPI deprecation flag. Defaults to None. 96 | name (Optional[str], optional): Operation name. Defaults to None. 97 | methods (Optional[Union[Set[str], List[str]]], optional): supoported methods. Defaults to None. 98 | operation_id (Optional[str], optional): OpenAPI operation ID. Defaults to None. 99 | response_model_include (Optional[Union[SetIntStr, DictIntStrAny]], optional): `response_model` \ 100 | Included fields. Defaults to None. 101 | response_model_exclude (Optional[Union[SetIntStr, DictIntStrAny]], optional): `response_model` \ 102 | Excluded fields. Defaults to None. 103 | response_model_by_alias (bool, optional): Enable or disable field aliases in `response_model`. \ 104 | Defaults to True. 105 | response_model_exclude_unset (bool, optional): excludes unset values in `response_model`. Defaults to False. 106 | response_model_exclude_defaults (bool, optional): excludes default values in `response_model`. Defaults to False. 107 | response_model_exclude_none (bool, optional): excludes None values in `response_model`. Defaults to False. 108 | include_in_schema (bool, optional): wether or not include this operation in the OpenAPI Schema. Defaults to True. 109 | response_class (Optional[Type[Response]], optional): FastaAPI/Starlette Response Class. Defaults to None. 110 | 111 | Returns: 112 | TResponseSchema: A ResponseSchema instance 113 | """ 114 | pass 115 | 116 | @classmethod 117 | @abstractmethod 118 | def from_exception( 119 | cls: Type[TResponseSchema], 120 | request: Request, 121 | reason: T, 122 | status_code: int, 123 | headers: Optional[dict] = None, 124 | **extra_params: Any, 125 | ) -> TResponseSchema: # pragma: no cover 126 | """Builds a ResponseSchema instance from an exception. 127 | This method must be overridden by subclasses. 128 | 129 | Args: 130 | request (Request): A FastaAPI/Starlette Request. 131 | reason (str): The `Exception` description or response data. 132 | status_code (int): the response status code. 133 | headers (dict): the response_headers 134 | 135 | Returns: 136 | TResponseSchema: A ResponseSchema instance 137 | """ 138 | pass 139 | 140 | @classmethod 141 | def from_exception_handler( 142 | cls: Type[TResponseSchema], 143 | request: Request, 144 | exception: Union[ 145 | RequestValidationError, StarletteHTTPException, FastAPIHTTPException, BaseGenericHTTPException 146 | ], 147 | ) -> TResponseSchema: 148 | """Used in exception handlers to build a ResponseSchema instance. 149 | This method should not be overridden by subclasses. 150 | 151 | Args: 152 | request (Request): A FastaAPI/Starlette Request. 153 | exception (Union[RequestValidationError, StarletteHTTPException, FastAPIHTTPException, BaseGenericHTTPException]): The instantiated raised exception. 154 | 155 | Returns: 156 | TResponseSchema: A ResponseSchema instance 157 | """ 158 | if isinstance(exception, BaseGenericHTTPException): 159 | adapted = HTTPExceptionAdapter.from_generic_http_exc(exception) 160 | elif isinstance(exception, FastAPIHTTPException): 161 | adapted = HTTPExceptionAdapter.from_fastapi_exc(exception) 162 | elif isinstance(exception, StarletteHTTPException): 163 | adapted = HTTPExceptionAdapter.from_starlette_exc(exception) 164 | else: 165 | adapted = HTTPExceptionAdapter.from_request_validation_err(exception) 166 | return cls.from_exception( 167 | request=request, 168 | reason=adapted.reason, 169 | status_code=adapted.status_code, 170 | headers=adapted.headers, 171 | **adapted.extra_params, 172 | ) 173 | 174 | def __class_getitem__( 175 | cls: Type[TResponseSchema], params: Union[Type[Any], Tuple[Type[Any], ...]] 176 | ) -> Type[TResponseSchema]: 177 | cls.__inner_type__ = params 178 | return super().__class_getitem__(params) 179 | 180 | class Config: 181 | arbitrary_types_allowed = True 182 | 183 | 184 | class ResponseWithMetadata(NamedTuple): 185 | """This Interface wraps the response content with the additional metadata 186 | 187 | Args: 188 | metadata (dict): A dictionary containing the metadata fields. 189 | response_content (Optional[Any]): The content of the response. Default to None. 190 | 191 | """ 192 | 193 | metadata: dict 194 | response_content: Optional[Any] = None 195 | -------------------------------------------------------------------------------- /fastapi_responseschema/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xp3p3x0/fastapi_py/8694e21857e32068cfaf0f2e5ae95e0b213ebd41/fastapi_responseschema/py.typed -------------------------------------------------------------------------------- /fastapi_responseschema/routing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import asyncio 3 | from typing import Callable, Optional, Any, Type, List, Sequence, Dict, Union, Set 4 | from functools import wraps 5 | from starlette.routing import BaseRoute 6 | from fastapi import params, Response 7 | from fastapi.routing import APIRoute 8 | from fastapi.responses import JSONResponse 9 | from fastapi.datastructures import DefaultPlaceholder, Default 10 | from .interfaces import AbstractResponseSchema, ResponseWithMetadata 11 | from ._compat import DictIntStrAny, SetIntStr, lenient_issubclass, lenient_isinstance 12 | 13 | 14 | class SchemaAPIRoute(APIRoute): 15 | """An APIRoute class to wrap response_model(s) with a ResponseSchema 16 | Must be subclassed setting at least SchemaAPIRoute.response_model. 17 | 18 | Usage: 19 | 20 | from typing import Generic, TypeVar 21 | from fastapi_responseschema.interfaces import AbstractResponseSchema 22 | 23 | T = TypeVar("T") 24 | class MyResponseSchema(AbstractResponseSchema[T], Generic[T]): 25 | ... 26 | 27 | class MyAPIRoute(SchemaAPIRoute): 28 | response_schema = MyResponseSchema 29 | 30 | from fastapi import APIRouter 31 | 32 | router = APIRouter(route_class=MyAPIRoute) 33 | """ 34 | 35 | response_schema: Type[AbstractResponseSchema[Any]] 36 | error_response_schema: Optional[Type[AbstractResponseSchema[Any]]] = None 37 | 38 | def __init_subclass__(cls) -> None: 39 | if not hasattr(cls, "response_schema"): 40 | raise AttributeError("`response_schema` must be defined in subclass.") 41 | return super().__init_subclass__() 42 | 43 | def is_error_state(self, status_code: Optional[int] = None) -> bool: 44 | """Handles the error_state for the operation evaluating the status_code. 45 | This method gets called internally and can be overridden to modify the error state of the operation. 46 | 47 | Args: 48 | status_code (Optional[int], optional): Operation status code. Defaults to None. 49 | 50 | Returns: 51 | bool: wether or not the operation returns an error 52 | """ 53 | return (status_code >= 400) if status_code else False 54 | 55 | def get_wrapper_model(self, is_error: bool, response_model: Type[Any]) -> Type[AbstractResponseSchema[Any]]: 56 | """Implements the ResponseSchema selection logic. 57 | This method gets called internally and can be overridden to gain control over the ResponseSchema selection logic. 58 | 59 | Args: 60 | is_error (int): wheteher or not the operation returns an error. 61 | response_model (Type[Any]): response_model set for APIRoute. 62 | 63 | Returns: 64 | Type[AbstractResponseSchema[Any]]: The ResponseSchema to wrap the response_model. 65 | """ 66 | if not self.error_response_schema: 67 | return self.response_schema 68 | return self.error_response_schema if is_error else self.response_schema 69 | 70 | def override_response_model( 71 | self, wrapper_model: Type[AbstractResponseSchema[Any]], response_model: Type[Any] 72 | ) -> Type[AbstractResponseSchema[Any]]: 73 | """Wraps the given response_model with the ResponseSchema. 74 | This method gets called internally and can be overridden to gain control over the response_model wrapping logic. 75 | 76 | Args: 77 | wrapper_model (Type[AbstractResponseSchema[Any]]): ResponseSchema Model 78 | response_model (Type[Any]): response_model set for APIRoute 79 | response_model_include (Optional[Union[SetIntStr, DictIntStrAny]], optional): Pydantic BaseModel include. Defaults to None. 80 | response_model_exclude (Optional[Union[SetIntStr, DictIntStrAny]], optional): Pydantic BaseModel exclude. Defaults to None. 81 | 82 | Returns: 83 | Type[AbstractResponseSchema[Any]]: The response_model wrapped in response_schema 84 | """ 85 | # due to: https://github.com/python/mypy/issues/12392 FIXME: when gets fixed 86 | return wrapper_model[response_model] # type: ignore 87 | 88 | def _wrap_endpoint_output( 89 | self, 90 | endpoint_output: Any, 91 | wrapper_model: Type[AbstractResponseSchema], 92 | response_model: Type[Any], 93 | **params: Any, 94 | ) -> Any: 95 | if lenient_isinstance(endpoint_output, ResponseWithMetadata): # Handling the `respond` function 96 | params.update(endpoint_output.metadata) 97 | content = endpoint_output.response_content 98 | else: 99 | content = endpoint_output 100 | params["status_code"] = params.get("status_code") or 200 101 | # due to: https://github.com/python/mypy/issues/12392 FIXME: when gets fixed 102 | wrapped_model = wrapper_model[response_model] # type: ignore 103 | return wrapped_model.from_api_route( 104 | content=content, 105 | response_model=response_model, 106 | **params, 107 | ) 108 | 109 | def _create_endpoint_handler_decorator( 110 | self, wrapper_model: Type[AbstractResponseSchema], response_model: Type[Any], **params: Any 111 | ) -> Callable: 112 | def decorator(func: Callable) -> Callable: 113 | if asyncio.iscoroutinefunction(func): # Not blocking asncyio loop 114 | 115 | @wraps(func) 116 | async def wrapper(*args: Any, **kwargs: Any) -> Any: 117 | endpoint_output = await func(*args, **kwargs) 118 | return self._wrap_endpoint_output( 119 | endpoint_output=endpoint_output, 120 | wrapper_model=wrapper_model, 121 | response_model=response_model, 122 | **params, 123 | ) 124 | 125 | else: 126 | 127 | @wraps(func) 128 | def wrapper(*args: Any, **kwargs: Any) -> Any: 129 | endpoint_output = func(*args, **kwargs) 130 | return self._wrap_endpoint_output( 131 | endpoint_output=endpoint_output, 132 | response_model=response_model, 133 | wrapper_model=wrapper_model, 134 | **params, 135 | ) 136 | 137 | return wrapper 138 | 139 | return decorator 140 | 141 | def __init__( 142 | self, 143 | path: str, 144 | endpoint: Callable, 145 | *, 146 | response_model: Optional[Type[Any]] = None, 147 | status_code: int = 200, 148 | tags: Optional[List[Any]] = None, 149 | dependencies: Optional[Sequence[params.Depends]] = None, 150 | summary: Optional[str] = None, 151 | description: Optional[str] = None, 152 | response_description: str = "Successful Response", 153 | responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, 154 | deprecated: Optional[bool] = None, 155 | name: Optional[str] = None, 156 | methods: Optional[Union[Set[str], List[str]]] = None, 157 | operation_id: Optional[str] = None, 158 | response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, 159 | response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, 160 | response_model_by_alias: bool = True, 161 | response_model_exclude_unset: bool = False, 162 | response_model_exclude_defaults: bool = False, 163 | response_model_exclude_none: bool = False, 164 | include_in_schema: bool = True, 165 | response_class: Union[Type[Response], DefaultPlaceholder] = Default(JSONResponse), 166 | dependency_overrides_provider: Optional[Any] = None, 167 | callbacks: Optional[List["BaseRoute"]] = None, 168 | **kwargs: Any, 169 | ) -> None: 170 | if response_model and not lenient_issubclass( 171 | response_model, AbstractResponseSchema 172 | ): # If a `response_model` is set, then wrap the `response_model` with a response schema 173 | WrapperModel = self.get_wrapper_model( 174 | is_error=self.is_error_state(status_code=status_code), response_model=response_model 175 | ) 176 | endpoint_wrapper = self._create_endpoint_handler_decorator( 177 | path=path, 178 | wrapper_model=WrapperModel, 179 | response_model=response_model, 180 | status_code=status_code, 181 | tags=tags, 182 | summary=summary, 183 | description=description, 184 | response_description=response_description, 185 | deprecated=deprecated, 186 | name=name, 187 | methods=methods, 188 | operation_id=operation_id, 189 | response_model_include=response_model_include, 190 | response_model_exclude=response_model_exclude, 191 | response_model_by_alias=response_model_by_alias, 192 | response_model_exclude_unset=response_model_exclude_unset, 193 | response_model_exclude_defaults=response_model_exclude_defaults, 194 | response_model_exclude_none=response_model_exclude_none, 195 | include_in_schema=include_in_schema, 196 | response_class=response_class, 197 | ) 198 | endpoint = endpoint_wrapper(endpoint) 199 | response_model = self.override_response_model(wrapper_model=WrapperModel, response_model=response_model) 200 | super().__init__( 201 | path, 202 | endpoint, 203 | response_model=response_model, 204 | status_code=status_code, 205 | tags=tags, 206 | dependencies=dependencies, 207 | summary=summary, 208 | description=description, 209 | response_description=response_description, 210 | responses=responses, 211 | deprecated=deprecated, 212 | name=name, 213 | methods=methods, 214 | operation_id=operation_id, 215 | response_model_include=response_model_include, 216 | response_model_exclude=response_model_exclude, 217 | response_model_by_alias=response_model_by_alias, 218 | response_model_exclude_unset=response_model_exclude_unset, 219 | response_model_exclude_defaults=response_model_exclude_defaults, 220 | response_model_exclude_none=response_model_exclude_none, 221 | include_in_schema=include_in_schema, 222 | response_class=response_class, 223 | dependency_overrides_provider=dependency_overrides_provider, 224 | callbacks=callbacks, 225 | **kwargs, 226 | ) 227 | 228 | 229 | def respond(response_content: Optional[Any] = None, **metadata: Any) -> ResponseWithMetadata: 230 | """Returns the response content with optional metadata 231 | 232 | Args: 233 | response_content (Optional[Any], optional): Response Content. Defaults to None. 234 | **metadata: Arbitrary metadata 235 | 236 | Returns: 237 | ResponseWithMetadata: An intermediate data structure to add metadatato a ResponseSchema serialization 238 | """ 239 | _metadata = metadata or dict() 240 | return ResponseWithMetadata(metadata=_metadata, response_content=response_content) 241 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastapi-responseschema" 3 | version = "2.1.0" 4 | description = "Generic and common response schemas for FastAPI" 5 | authors = ["Emanuele Addis ", "Florin Cotovanu "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/acwazz/fastapi-responseschema" 9 | homepage = "https://acwazz.github.io/fastapi-responseschema/" 10 | 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.8" 14 | fastapi = ">=0.89.1" 15 | fastapi-pagination = {version = "^0", optional = true} 16 | 17 | [tool.poetry.extras] 18 | pagination = ["fastapi-pagination"] 19 | 20 | [tool.poetry.group.dev.dependencies] 21 | mypy = "^1.8.0" 22 | pydoc-markdown = "^4.6.4" 23 | novella = "^0.2.3" 24 | pytest = "^7.2.0" 25 | pytest-cov = "^4.0.0" 26 | pytest-asyncio = "^0.20.2" 27 | requests = "^2.28.1" 28 | uvicorn = ">=0.19,<0.21" 29 | ipython = "^8.6.0" 30 | mkdocs = "^1.4.2" 31 | mkdocs-material = ">=8.5.10,<10.0.0" 32 | black = "^22.10.0" 33 | httpx = "^0.23.3" 34 | pre-commit = "^3.3.3" 35 | tox = "^4.11.4" 36 | ruff = "^0.1.11" 37 | 38 | [build-system] 39 | requires = ["poetry-core>=1.0.0"] 40 | build-backend = "poetry.core.masonry.api" 41 | 42 | [tool.pytest.ini_options] 43 | addopts = "--cov --cov-report html:htmlcov" 44 | asyncio_mode = "auto" 45 | 46 | [tool.coverage.run] 47 | omit = [".*", "*/site-packages/*, */__init__.py"] 48 | source = ['fastapi_responseschema'] 49 | 50 | [tool.coverage.report] 51 | fail_under = 90 52 | 53 | [[tool.pydoc-markdown.loaders]] 54 | type = "python" 55 | search_path = [ "fastapi_responseschema" ] 56 | 57 | [tool.pydoc-markdown.renderer] 58 | type = "mkdocs" 59 | 60 | [tool.mypy] 61 | exclude = "tests" 62 | check_untyped_defs = true 63 | ignore_missing_imports = true 64 | 65 | [tool.ruff] 66 | line-length = 120 67 | ignore = [] 68 | respect-gitignore = true 69 | target-version = "py38" 70 | src = ["fastapi_responseschema", "tests"] 71 | 72 | [tool.black] 73 | line-length = 120 74 | target-version = ['py38'] 75 | include = '\.pyi?$' 76 | # 'extend-exclude' excludes files or directories in addition to the defaults 77 | extend-exclude = ''' 78 | /( 79 | \.git 80 | | \.hg 81 | | \.mypy_cache 82 | | \.tox 83 | | \.venv 84 | | _build 85 | | buck-out 86 | | build 87 | | dist 88 | | migrations 89 | | stubs 90 | | \.pyre 91 | | \.github 92 | | \.pyre_configuration 93 | | \.gitignore 94 | | \.coverage 95 | )/ 96 | ''' 97 | 98 | [tool.tox] 99 | legacy_tox_ini = """ 100 | [tox] 101 | min_version = 4.0 102 | env_list = 103 | py312 104 | py311 105 | py310 106 | py39 107 | type 108 | fastapi-89 109 | fastapi-96 110 | fastapi-100 111 | 112 | [testenv] 113 | deps = 114 | pytest 115 | pytest-cov 116 | pytest-asyncio 117 | requests 118 | httpx 119 | fastapi-pagination 120 | fastapi-89: fastapi>=0.89,<0.96 121 | fastapi-96: fastapi>=0.96,<0.100 122 | fastapi-100: fastapi>=0.100,<0.110 123 | commands = pytest tests 124 | 125 | [testenv:type] 126 | deps = mypy 127 | commands = mypy . 128 | """ 129 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xp3p3x0/fastapi_py/8694e21857e32068cfaf0f2e5ae95e0b213ebd41/tests/__init__.py -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, Generic 2 | from pydantic import BaseModel 3 | from fastapi_responseschema.interfaces import AbstractResponseSchema 4 | 5 | 6 | T = TypeVar("T") 7 | 8 | 9 | class AResponseModel(BaseModel): 10 | id: int 11 | name: str 12 | 13 | 14 | class SimpleResponseSchema(AbstractResponseSchema[T], Generic[T]): 15 | data: T 16 | error: bool 17 | 18 | @classmethod 19 | def from_exception(cls, reason: T, status_code: int, **others): 20 | return cls(data=reason, error=status_code >= 400) 21 | 22 | @classmethod 23 | def from_api_route(cls, content: T, status_code: int, **others): 24 | return cls(data=content, error=status_code >= 400) 25 | 26 | 27 | class SimpleErrorResponseSchema(AbstractResponseSchema[T], Generic[T]): 28 | reason: T 29 | error: bool = True 30 | 31 | @classmethod 32 | def from_exception(cls, reason: T, status_code: int, **others): 33 | return cls(reason=reason, error=status_code >= 400) 34 | 35 | @classmethod 36 | def from_api_route(cls, content: T, status_code: int, **others): 37 | return cls(reason=content, error=status_code >= 400) 38 | -------------------------------------------------------------------------------- /tests/sandbox.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar, Optional, Union, Sequence 2 | from pydantic import BaseModel 3 | from fastapi import FastAPI 4 | from fastapi_responseschema import AbstractResponseSchema, wrap_app_responses 5 | from fastapi_responseschema.integrations.pagination import ( 6 | AbstractPagedResponseSchema, 7 | PaginationMetadata, 8 | PagedSchemaAPIRoute, 9 | PaginationParams, 10 | ) 11 | from fastapi_pagination import paginate, add_pagination 12 | 13 | T = TypeVar("T") 14 | 15 | 16 | # Build your "Response Schema" 17 | class ResponseMetadata(BaseModel): 18 | error: bool 19 | message: Optional[str] 20 | pagination: Optional[PaginationMetadata] 21 | 22 | 23 | class ResponseSchema(AbstractResponseSchema[T], Generic[T]): 24 | data: T 25 | meta: ResponseMetadata 26 | 27 | @classmethod 28 | def from_exception(cls, reason: T, status_code: int, message: str = "Error", **others): 29 | return cls(data=reason, meta=ResponseMetadata(error=status_code >= 400, message=message)) 30 | 31 | @classmethod 32 | def from_api_route(cls, content: T, status_code: int, description: Optional[str] = None, **others): 33 | return cls(data=content, meta=ResponseMetadata(error=status_code >= 400, message=description)) 34 | 35 | 36 | class PagedResponseSchema(AbstractPagedResponseSchema[T], Generic[T]): 37 | data: Union[Sequence[T], T] 38 | meta: ResponseMetadata 39 | 40 | @classmethod 41 | def create( 42 | cls, 43 | items: Sequence[T], 44 | total: int, 45 | params: PaginationParams, 46 | ) -> "PagedResponseSchema": 47 | return cls( 48 | data=items, 49 | meta=ResponseMetadata( 50 | error=False, pagination=PaginationMetadata.from_abstract_page_create(total=total, params=params) 51 | ), 52 | ) 53 | 54 | @classmethod 55 | def from_exception(cls, reason: T, status_code: int, message: str = "Error", **others): 56 | return cls(data=reason, meta=ResponseMetadata(error=status_code >= 400, message=message)) 57 | 58 | @classmethod 59 | def from_api_route(cls, content: Sequence[T], status_code: int, description: Optional[str] = None, **others): 60 | return cls(error=status_code >= 400, data=content.data, meta=content.meta) 61 | 62 | 63 | # Create an APIRoute 64 | class Route(PagedSchemaAPIRoute): 65 | response_schema = ResponseSchema 66 | paged_response_schema = PagedResponseSchema 67 | 68 | 69 | # Setup you FastAPI app 70 | app = FastAPI(debug=True) 71 | wrap_app_responses(app, Route) 72 | 73 | 74 | class Item(BaseModel): 75 | id: int 76 | name: str 77 | 78 | 79 | @app.get("/", response_model=PagedResponseSchema[Item], description="This is a route") 80 | def get_operation(): 81 | page = paginate([Item(id=0, name="ciao"), Item(id=1, name="hola"), Item(id=1, name="hello")]) 82 | return page 83 | 84 | 85 | add_pagination(app) 86 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi_responseschema import exceptions as exc 3 | 4 | 5 | class TestBaseGenericHTTPException: 6 | def test_initialize(self): 7 | inst = exc.BaseGenericHTTPException(detail="hello", headers={"Test": True}, extra=True) 8 | assert isinstance(inst, exc.BaseGenericHTTPException) # Sanity check here 9 | assert inst.extra_params.get("extra") 10 | assert inst.headers.get("Test") 11 | assert inst.detail == "hello" 12 | 13 | def test_wrong_subsclassing(self): 14 | with pytest.raises(AttributeError): 15 | 16 | class Exc(exc.BaseGenericHTTPException): 17 | pass 18 | 19 | 20 | def test_generic_http_ecxeption_initialize(): 21 | inst = exc.GenericHTTPException(status_code=501) 22 | assert inst.status_code == 501 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "status_code,exc_class", 27 | [ 28 | (400, exc.BadRequest), 29 | (401, exc.Unauthorized), 30 | (403, exc.Forbidden), 31 | (404, exc.NotFound), 32 | (405, exc.MethodNotAllowed), 33 | (409, exc.Conflict), 34 | (410, exc.Gone), 35 | (422, exc.UnprocessableEntity), 36 | (500, exc.InternalServerError), 37 | ], 38 | ) 39 | def test_web_exceptions_status_codes(status_code, exc_class): 40 | e = exc_class() 41 | assert e.status_code == status_code 42 | -------------------------------------------------------------------------------- /tests/test_fastapi_responseschema.py: -------------------------------------------------------------------------------- 1 | from fastapi_responseschema import __version__ 2 | 3 | 4 | def test_version(): 5 | assert __version__ == "2.1.0" 6 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI 3 | from fastapi.exceptions import StarletteHTTPException 4 | from fastapi.testclient import TestClient 5 | from fastapi_responseschema import SchemaAPIRoute 6 | from fastapi_responseschema.exceptions import GenericHTTPException 7 | from fastapi_responseschema.helpers import wrap_app_responses 8 | 9 | from .common import SimpleErrorResponseSchema, SimpleResponseSchema, AResponseModel 10 | 11 | 12 | class Route(SchemaAPIRoute): 13 | response_schema = SimpleResponseSchema 14 | error_response_schema = SimpleErrorResponseSchema 15 | 16 | 17 | app = FastAPI() 18 | wrap_app_responses(app, Route) 19 | 20 | client = TestClient(app) 21 | 22 | 23 | @app.get("/", response_model=AResponseModel) 24 | def wrapped(): 25 | return {"id": 1, "name": "hello"} 26 | 27 | 28 | @app.get("/exceptions/{code}") 29 | def raise_exception(code: int): 30 | raise GenericHTTPException(status_code=code) 31 | 32 | 33 | @app.get("/validation-error/{param}") 34 | def raise_validation_error(param: int): 35 | return param 36 | 37 | 38 | @app.get("/starlette-exception") 39 | def raise_starlette_exception(): 40 | raise StarletteHTTPException(400, "Error") 41 | 42 | 43 | @pytest.mark.parametrize("status_code", [400, 401, 403, 404, 405, 409, 410, 422, 500]) 44 | def test_web_exceptions(status_code): 45 | response = client.get(f"/exceptions/{status_code}/") 46 | assert response.status_code == status_code 47 | 48 | 49 | def test_starlette_exception(): 50 | response = client.get("/starlette-exception") 51 | assert response.status_code == 400 52 | assert response.json().get("reason") == "Error" 53 | assert response.json().get("error") 54 | 55 | 56 | def test_request_validation_error(): 57 | response = client.get("/validation-error/invalid") 58 | assert response.status_code == 422 59 | assert response.json().get("error") 60 | 61 | 62 | def test_response_model_wrapping(): 63 | raw = client.get("/") 64 | r = raw.json() 65 | assert r.get("data").get("id") == 1 66 | assert r.get("data").get("name") == "hello" 67 | assert not r.get("error") 68 | 69 | 70 | class Route2(SchemaAPIRoute): 71 | response_schema = SimpleResponseSchema 72 | 73 | 74 | app2 = FastAPI() 75 | wrap_app_responses(app2, Route2) 76 | 77 | client2 = TestClient(app2) 78 | 79 | 80 | @app2.get("/starlette-exception") 81 | def raise_starlette_exception_2(): 82 | raise StarletteHTTPException(400, "Error") 83 | 84 | 85 | def test_fallback_error_schema(): 86 | response = client2.get("/starlette-exception") 87 | assert response.status_code == 400 88 | assert response.json().get("data") == "Error" 89 | assert response.json().get("error") 90 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, APIRouter 2 | from fastapi.testclient import TestClient 3 | from fastapi_responseschema import SchemaAPIRoute, wrap_app_responses 4 | from .common import SimpleResponseSchema 5 | 6 | 7 | class Route(SchemaAPIRoute): 8 | response_schema = SimpleResponseSchema 9 | 10 | 11 | app_wrapped_no_router = FastAPI() 12 | 13 | wrap_app_responses(app_wrapped_no_router, Route) 14 | 15 | 16 | @app_wrapped_no_router.get("/wrapped", response_model=bool) 17 | def wrapped_call_first(): 18 | return True 19 | 20 | 21 | router_unwrapped = APIRouter() 22 | 23 | 24 | @router_unwrapped.get("/unwrapped", response_model=dict) 25 | def unwrapped_call_first(): 26 | return {"unwrapped": True} 27 | 28 | 29 | app_wrapped_no_router.include_router(router_unwrapped) 30 | 31 | client_app_wrapped_no_router = TestClient(app_wrapped_no_router) 32 | 33 | 34 | def test_app_wrapped_call(): 35 | r = client_app_wrapped_no_router.get("/wrapped") 36 | resp = r.json() 37 | assert resp.get("data") 38 | assert not resp.get("error") 39 | 40 | 41 | def test_router_unwrapped_call(): 42 | r = client_app_wrapped_no_router.get("/unwrapped") 43 | resp = r.json() 44 | assert resp.get("unwrapped") 45 | 46 | 47 | app_not_wrapped = FastAPI() 48 | 49 | 50 | @app_not_wrapped.get("/unwrapped", response_model=dict) 51 | def un_wrapped_call_second(): 52 | return {"unwrapped": True} 53 | 54 | 55 | router_wrapped = APIRouter(route_class=Route) 56 | 57 | 58 | @router_wrapped.get("/wrapped", response_model=dict) 59 | def wrapped_call_second(): 60 | return {"wrapped": True} 61 | 62 | 63 | app_not_wrapped.include_router(router_wrapped) 64 | 65 | client_app_not_wrapped = TestClient(app_not_wrapped) 66 | 67 | 68 | def test_not_recursive_wrapping_on_router(): 69 | r = client_app_not_wrapped.get("/wrapped") 70 | resp = r.json() 71 | assert resp.get("data").get("wrapped") 72 | assert not resp.get("error") 73 | 74 | 75 | def test_app_unwrapped_call(): 76 | r = client_app_wrapped_no_router.get("/unwrapped") 77 | resp = r.json() 78 | assert resp.get("unwrapped") 79 | -------------------------------------------------------------------------------- /tests/test_interfaces.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from fastapi import Request 3 | from fastapi_responseschema.exceptions import NotFound 4 | from .common import SimpleResponseSchema 5 | 6 | 7 | def test_from_exception(): 8 | exc = NotFound(detail="oh no!") 9 | resp = SimpleResponseSchema[Any].from_exception(reason=exc.detail, status_code=exc.status_code) 10 | assert resp.error 11 | assert resp.data == exc.detail 12 | 13 | 14 | def test_from_exception_handler(): 15 | req = Request( 16 | { 17 | "type": "http", 18 | "body": b"", 19 | "more_body": False, 20 | } 21 | ) 22 | exc = NotFound(detail="oh no!") 23 | resp = SimpleResponseSchema[Any].from_exception_handler(request=req, exception=exc) 24 | assert resp.error 25 | assert resp.data == exc.detail 26 | 27 | 28 | def test_from_api_route(): 29 | resp = SimpleResponseSchema[dict].from_api_route(content={"hello": "world"}, status_code=201) 30 | assert not resp.error 31 | assert resp.data.get("hello") == "world" 32 | -------------------------------------------------------------------------------- /tests/test_pagination_integration.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, Generic, Any, Sequence, Union 2 | import pytest 3 | from fastapi import FastAPI, APIRouter 4 | from fastapi.testclient import TestClient 5 | from fastapi_responseschema.integrations.pagination import ( 6 | AbstractPagedResponseSchema, 7 | PagedSchemaAPIRoute, 8 | PaginationMetadata, 9 | PaginationParams, 10 | ) 11 | from fastapi_pagination import paginate, add_pagination 12 | from .common import SimpleResponseSchema 13 | 14 | 15 | def test_paged_response_schema_inner_types(): 16 | V = AbstractPagedResponseSchema[int] 17 | assert V.__inner_type__ == int 18 | 19 | 20 | T = TypeVar("T") 21 | 22 | 23 | class SimplePagedResponseSchema(AbstractPagedResponseSchema[T], Generic[T]): 24 | data: Union[Sequence[T], T] 25 | error: bool 26 | pagination: PaginationMetadata 27 | 28 | @classmethod 29 | def create(cls, items: Sequence[T], params: PaginationParams, total: int): # 30 | return cls( 31 | data=items, error=False, pagination=PaginationMetadata.from_abstract_page_create(total=total, params=params) 32 | ) 33 | 34 | @classmethod 35 | def from_exception(cls, reason, status_code, **others): 36 | return cls(data=reason, error=status_code >= 400) 37 | 38 | @classmethod 39 | def from_api_route(cls, content: Any, status_code: int, **others): 40 | return cls(error=status_code >= 400, data=content.data, pagination=content.pagination) 41 | 42 | 43 | def test_get_wrapper_model_pagination_success(): 44 | class Route(PagedSchemaAPIRoute): 45 | response_schema = SimpleResponseSchema 46 | paged_response_schema = SimplePagedResponseSchema 47 | 48 | r = Route("/", lambda: [True, False, True], response_model=SimplePagedResponseSchema[bool]) 49 | assert ( 50 | r.get_wrapper_model(is_error=False, response_model=SimplePagedResponseSchema[bool]) == SimplePagedResponseSchema 51 | ) 52 | 53 | 54 | def test_get_wrapper_model_pagination_error_fallaback(): 55 | class Route(PagedSchemaAPIRoute): 56 | response_schema = SimpleResponseSchema 57 | paged_response_schema = SimplePagedResponseSchema 58 | 59 | r = Route("/", lambda: [True, False, True], response_model=SimplePagedResponseSchema[bool]) 60 | assert ( 61 | r.get_wrapper_model(is_error=True, response_model=SimplePagedResponseSchema[bool]) == SimplePagedResponseSchema 62 | ) 63 | 64 | 65 | def test_get_wrapper_model_pagination_error(): 66 | class Route(PagedSchemaAPIRoute): 67 | response_schema = SimpleResponseSchema 68 | paged_response_schema = SimplePagedResponseSchema 69 | error_response_schema = SimpleResponseSchema 70 | 71 | r = Route("/", lambda: [True, False, True], response_model=SimplePagedResponseSchema[bool]) 72 | assert r.get_wrapper_model(is_error=True, response_model=SimplePagedResponseSchema[bool]) == SimpleResponseSchema 73 | 74 | 75 | def test_get_wrapper_model_no_pagination(): 76 | class Route(PagedSchemaAPIRoute): 77 | response_schema = SimpleResponseSchema 78 | paged_response_schema = SimplePagedResponseSchema 79 | 80 | r = Route("/", lambda: True, response_model=bool) 81 | assert r.get_wrapper_model(is_error=False, response_model=bool) == SimpleResponseSchema 82 | 83 | 84 | def test_wrong_subsclassing_paged_schema(): 85 | with pytest.raises(AttributeError): 86 | 87 | class Route(PagedSchemaAPIRoute): 88 | response_schema = SimpleResponseSchema 89 | 90 | 91 | def test_subsclassing_response_schema_is_none(): 92 | with pytest.raises(AttributeError): 93 | 94 | class Route(PagedSchemaAPIRoute): 95 | response_schema = None 96 | paged_response_schema = SimplePagedResponseSchema 97 | 98 | 99 | def test_subsclassing_response_schema_is_unset(): 100 | with pytest.raises(AttributeError): 101 | 102 | class Route(PagedSchemaAPIRoute): 103 | paged_response_schema = SimplePagedResponseSchema 104 | 105 | 106 | class Route(PagedSchemaAPIRoute): 107 | response_schema = SimpleResponseSchema 108 | paged_response_schema = SimplePagedResponseSchema 109 | 110 | 111 | app = FastAPI() 112 | app.router.route_class = Route 113 | 114 | 115 | @app.get("/with-model", response_model=SimplePagedResponseSchema[bool]) 116 | def with_response_model(): 117 | return paginate([True, False, True, False]) 118 | 119 | 120 | router = APIRouter(route_class=Route) 121 | 122 | 123 | @router.get("/with-router", response_model=SimplePagedResponseSchema[bool]) 124 | def with_router(): 125 | return paginate([True, False, True, False]) 126 | 127 | 128 | app.include_router(router) 129 | 130 | add_pagination(app) 131 | 132 | client = TestClient(app) 133 | 134 | 135 | def test_response_model_paginated_wrapping_in_app(): 136 | raw = client.get("/with-model") 137 | r = raw.json() 138 | assert not r.get("data")[1] 139 | assert r.get("pagination").get("total") == 4 140 | assert not r.get("error") 141 | 142 | 143 | def test_response_model_paginated_wrapping_in_router(): 144 | raw = client.get("/with-router") 145 | r = raw.json() 146 | assert not r.get("data")[1] 147 | assert r.get("pagination").get("total") == 4 148 | assert not r.get("error") 149 | -------------------------------------------------------------------------------- /tests/test_response_model_compatibility.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any 3 | from fastapi import FastAPI 4 | from fastapi.testclient import TestClient 5 | from fastapi_responseschema import SchemaAPIRoute, wrap_app_responses 6 | from pydantic import BaseModel 7 | from .common import SimpleResponseSchema 8 | 9 | 10 | class RModel(BaseModel): 11 | id: str 12 | name: str 13 | 14 | 15 | class Route(SchemaAPIRoute): 16 | response_schema = SimpleResponseSchema 17 | 18 | 19 | app = FastAPI() 20 | 21 | 22 | wrap_app_responses(app, Route) 23 | 24 | 25 | @app.get("/model-exclude", response_model=RModel, response_model_exclude={"data": {"name"}}) 26 | def filter_via_response_model_exclude() -> dict[str, str]: 27 | return {"id": "1", "name": "excluded"} 28 | 29 | 30 | @app.get("/model-filters", response_model=RModel) 31 | def filter_via_response_model_schema() -> dict[str, Any]: 32 | return {"id": "1", "name": "ok", "hidden": True} 33 | 34 | 35 | client = TestClient(app) 36 | 37 | 38 | def test_response_model_exclude_works() -> None: 39 | r = client.get("/model-exclude") 40 | assert r.json() == {"error": False, "data": {"id": "1"}} 41 | 42 | 43 | def test_inner_response_model_schema_not_overridden() -> None: 44 | r = client.get("/model-filters") 45 | assert r.json() == {"error": False, "data": {"id": "1", "name": "ok"}} 46 | -------------------------------------------------------------------------------- /tests/test_routing.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pytest 3 | from fastapi import FastAPI 4 | from fastapi.testclient import TestClient 5 | from fastapi.routing import APIRoute 6 | from fastapi_responseschema.routing import respond, SchemaAPIRoute 7 | from fastapi_responseschema.interfaces import ResponseWithMetadata 8 | from .common import SimpleResponseSchema, SimpleErrorResponseSchema, AResponseModel 9 | 10 | 11 | def test_respond_returns_type(): 12 | out = respond() 13 | assert isinstance(out, ResponseWithMetadata) 14 | 15 | 16 | def test_respond_returns_content(): 17 | out = respond({"a": 1}) 18 | assert out.response_content.get("a") == 1 19 | 20 | 21 | def test_respond_returns_metadata(): 22 | out = respond(None, first=1, second=2) 23 | assert out.metadata.get("first") == 1 24 | assert out.metadata.get("second") == 2 25 | 26 | 27 | class TestSchemaAPIRouteOverridables: 28 | def test_wrong_subsclassing(self): 29 | with pytest.raises(AttributeError): 30 | 31 | class Route(SchemaAPIRoute): 32 | pass 33 | 34 | def test_is_api_route(self): 35 | class Route(SchemaAPIRoute): 36 | response_schema = SimpleResponseSchema 37 | 38 | r = Route("/", lambda: True) 39 | assert isinstance(r, APIRoute) 40 | assert issubclass(Route, APIRoute) 41 | 42 | def test_is_error_state(self): 43 | class Route(SchemaAPIRoute): 44 | response_schema = SimpleResponseSchema 45 | 46 | r = Route("/", lambda: False) 47 | assert r.is_error_state(status_code=405) 48 | assert not r.is_error_state(status_code=301) 49 | 50 | def test_get_wrapper_model(self): 51 | class Route(SchemaAPIRoute): 52 | response_schema = SimpleResponseSchema 53 | 54 | r = Route("/", lambda: False) 55 | assert r.get_wrapper_model(is_error=False, response_model=dict) == SimpleResponseSchema 56 | assert r.get_wrapper_model(is_error=True, response_model=dict) == SimpleResponseSchema 57 | 58 | def test_get_wrapper_model_with_error(self): 59 | class Route(SchemaAPIRoute): 60 | response_schema = SimpleResponseSchema 61 | error_response_schema = SimpleErrorResponseSchema 62 | 63 | r = Route("/", lambda: False) 64 | assert r.get_wrapper_model(is_error=False, response_model=dict) == SimpleResponseSchema 65 | assert r.get_wrapper_model(is_error=True, response_model=dict) == SimpleErrorResponseSchema 66 | 67 | def test_override_response_model(self): 68 | class Route(SchemaAPIRoute): 69 | response_schema = SimpleResponseSchema 70 | 71 | r = Route("/", lambda: False) 72 | assert ( 73 | r.override_response_model(wrapper_model=r.response_schema, response_model=AResponseModel) 74 | == SimpleResponseSchema[AResponseModel] 75 | ) 76 | 77 | 78 | class SimpleRoute(SchemaAPIRoute): 79 | response_schema = SimpleResponseSchema 80 | error_response_schema = SimpleErrorResponseSchema 81 | 82 | 83 | app = FastAPI() 84 | app.router.route_class = SimpleRoute 85 | 86 | 87 | @app.get("/") 88 | def simple_route(): 89 | return {"op": True} 90 | 91 | 92 | @app.get("/with-model", response_model=AResponseModel) 93 | def with_response_model(): 94 | return {"id": 1, "name": "hello"} 95 | 96 | 97 | @app.get("/as-error", response_model=AResponseModel, status_code=404) 98 | def as_error(): 99 | return {"id": 0, "name": ""} 100 | 101 | 102 | @app.get("/respond", response_model=AResponseModel) 103 | def responder(): 104 | return respond({"id": 1, "name": "hello"}) 105 | 106 | 107 | @app.get("/async", response_model=AResponseModel) 108 | async def async_route(): 109 | await asyncio.sleep(0.001) 110 | return {"id": 1, "name": "hello"} 111 | 112 | 113 | client = TestClient(app) 114 | 115 | 116 | def test_legacy_behaviour(): 117 | raw = client.get("/") 118 | r = raw.json() 119 | assert r.get("op") 120 | 121 | 122 | def test_response_model_wrapping(): 123 | raw = client.get("/with-model") 124 | r = raw.json() 125 | assert r.get("data").get("id") == 1 126 | assert r.get("data").get("name") == "hello" 127 | assert not r.get("error") 128 | 129 | 130 | def test_response_as_error(): 131 | raw = client.get("/as-error") 132 | r = raw.json() 133 | assert r.get("reason").get("id") == 0 134 | assert r.get("reason").get("name") == "" 135 | assert r.get("error") 136 | 137 | 138 | def test_respond_in_operation(): 139 | raw = client.get("/respond") 140 | r = raw.json() 141 | assert r.get("data").get("id") == 1 142 | assert r.get("data").get("name") == "hello" 143 | assert not r.get("error") 144 | 145 | 146 | def test_async_behavior(): 147 | raw = client.get("/async") 148 | r = raw.json() 149 | assert r.get("data").get("id") == 1 150 | assert r.get("data").get("name") == "hello" 151 | assert not r.get("error") 152 | --------------------------------------------------------------------------------