├── tests ├── __init__.py └── test_apifairy.py ├── docs ├── _static │ └── apispec-example.png ├── index.rst ├── Makefile ├── make.bat ├── conf.py ├── apifairy_class.rst ├── intro.rst ├── decorators.rst └── guide.rst ├── MANIFEST.in ├── src └── apifairy │ ├── exceptions.py │ ├── __init__.py │ ├── fields.py │ ├── templates │ └── apifairy │ │ ├── rapidoc.html │ │ ├── redoc.html │ │ ├── elements.html │ │ └── swagger_ui.html │ ├── decorators.py │ └── core.py ├── .readthedocs.yaml ├── examples ├── README.md ├── app.py └── app_with_class_views.py ├── tox.ini ├── README.md ├── LICENSE ├── bin ├── release └── mkchangelog.py ├── pyproject.toml ├── .github └── workflows │ └── tests.yml ├── .gitignore └── CHANGES.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_static/apispec-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miguelgrinberg/APIFairy/HEAD/docs/_static/apispec-example.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE tox.ini 2 | recursive-include docs * 3 | recursive-exclude docs/_build * 4 | recursive-include tests * 5 | exclude **/*.pyc 6 | -------------------------------------------------------------------------------- /src/apifairy/exceptions.py: -------------------------------------------------------------------------------- 1 | class ValidationError(Exception): 2 | def __init__(self, status_code, messages): 3 | self.status_code = status_code 4 | self.messages = messages 5 | -------------------------------------------------------------------------------- /src/apifairy/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import APIFairy # noqa: F401 2 | from .decorators import authenticate, arguments, body, response, \ 3 | other_responses, webhook # noqa: F401 4 | from .fields import FileField # noqa: F401 5 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - method: pip 14 | path: . 15 | extra_requirements: 16 | - docs 17 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | For a non-trivial example that uses Flask, Marshmallow and APIFairy together, 4 | see the [microblog-api](https://github.com/miguelgrinberg/microblog-api) project. 5 | 6 | This directory contains simpler examples that can be used as a starting point 7 | when building your own project. 8 | -------------------------------------------------------------------------------- /src/apifairy/fields.py: -------------------------------------------------------------------------------- 1 | from marshmallow import ValidationError 2 | from marshmallow.fields import Field 3 | from werkzeug.datastructures import FileStorage 4 | 5 | 6 | class FileField(Field): 7 | def _deserialize(self, value, attr, data, **kwargs): 8 | if not isinstance(value, FileStorage): 9 | raise ValidationError('Not a file.') 10 | return value 11 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. APIFairy documentation master file, created by 2 | sphinx-quickstart on Sun Sep 27 17:34:58 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | APIFairy 7 | ======== 8 | 9 | Welcome to the documentation for APIFairy, the minimalistic API framework for 10 | Flask. 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | 15 | intro 16 | guide 17 | decorators 18 | apifairy_class 19 | -------------------------------------------------------------------------------- /src/apifairy/templates/apifairy/rapidoc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} {{ version }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/apifairy/templates/apifairy/redoc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} {{ version }} 5 | 6 | 7 | 8 | 9 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/apifairy/templates/apifairy/elements.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ title }} {{ version }} 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=flake8,py39,py310,py311,py312,py313,py314,pypy3,docs 3 | skip_missing_interpreters=True 4 | 5 | [gh-actions] 6 | python = 7 | 3.9: py39 8 | 3.10: py310 9 | 3.11: py311 10 | 3.12: py312 11 | 3.13: py313 12 | 3.14: py314 13 | pypy-3: pypy3 14 | 15 | [testenv] 16 | commands= 17 | pip install -e . 18 | pytest -p no:logging --cov=apifairy --cov-branch --cov-report=term-missing --cov-report=xml 19 | deps= 20 | asgiref 21 | pytest 22 | pytest-cov 23 | openapi-spec-validator 24 | 25 | [testenv:flake8] 26 | deps= 27 | flake8 28 | commands= 29 | flake8 --exclude=".*" src/apifairy tests 30 | 31 | [testenv:docs] 32 | changedir=docs 33 | deps= 34 | sphinx 35 | allowlist_externals= 36 | make 37 | commands= 38 | make html 39 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # APIFairy 2 | 3 | [![Build status](https://github.com/miguelgrinberg/apifairy/workflows/build/badge.svg)](https://github.com/miguelgrinberg/apifairy/actions) [![codecov](https://codecov.io/gh/miguelgrinberg/apifairy/branch/main/graph/badge.svg)](https://codecov.io/gh/miguelgrinberg/APIFairy) 4 | 5 | APIFairy is a minimalistic API framework built on top of Flask, and with the 6 | support of Marshmallow schemas. Using a familiar decorator syntax you can 7 | generate a live documentation site directly from your source code. 8 | 9 | Check out [Microblog-API](https://github.com/miguelgrinberg/microblog-api) to 10 | see APIFairy in action in a non-trivial project. 11 | 12 | ![APIFairy example](docs/_static/apispec-example.png) 13 | 14 | Resources 15 | --------- 16 | 17 | - [Documentation](http://apifairy.readthedocs.io/en/latest/) 18 | - [PyPI](https://pypi.python.org/pypi/APIFairy) 19 | - [Change Log](https://github.com/miguelgrinberg/APIFairy/blob/main/CHANGES.md) 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Miguel Grinberg 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. 22 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | VERSION="$1" 4 | VERSION_FILE=apifairy/__init__.py 5 | 6 | if [[ "$VERSION" == "" ]]; then 7 | echo "Usage: $0 " 8 | exit 1 9 | fi 10 | 11 | # update change log 12 | head -n 2 CHANGES.md > _CHANGES.md 13 | echo "**Release $VERSION** - $(date +%F)" >> _CHANGES.md 14 | echo "" >> _CHANGES.md 15 | pip install gitpython 16 | python bin/mkchangelog.py >> _CHANGES.md 17 | echo "" >> _CHANGES.md 18 | len=$(wc -l < CHANGES.md) 19 | tail -n $(expr $len - 2) CHANGES.md >> _CHANGES.md 20 | vim _CHANGES.md 21 | set +e 22 | grep -q ABORT _CHANGES.md 23 | if [[ "$?" == "0" ]]; then 24 | rm _CHANGES.md 25 | echo "Aborted." 26 | exit 1 27 | fi 28 | set -e 29 | mv _CHANGES.md CHANGES.md 30 | 31 | sed -i "" "s/^__version__ = '.*'$/__version__ = '$VERSION'/" $VERSION_FILE 32 | rm -rf dist 33 | pip install --upgrade pip wheel twine 34 | python setup.py sdist bdist_wheel --universal 35 | 36 | git add $VERSION_FILE CHANGES.md 37 | git commit -m "Release $VERSION" 38 | git tag -f v$VERSION 39 | git push --tags origin master 40 | 41 | read -p "Press any key to submit to PyPI or Ctrl-C to abort..." -n1 -s 42 | twine upload dist/* 43 | 44 | NEW_VERSION="${VERSION%.*}.$((${VERSION##*.}+1))dev" 45 | sed -i "" "s/^__version__ = '.*'$/__version__ = '$NEW_VERSION'/" $VERSION_FILE 46 | git add $VERSION_FILE 47 | git commit -m "Version $NEW_VERSION" 48 | git push origin master 49 | echo "Development is now open on version $NEW_VERSION!" 50 | -------------------------------------------------------------------------------- /src/apifairy/templates/apifairy/swagger_ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ title }} {{ version }} 6 | 7 | 28 | 29 | 30 | 31 |
32 | 33 | 34 | 35 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "apifairy" 3 | version = "1.5.2.dev0" 4 | authors = [ 5 | { name = "Miguel Grinberg", email = "miguel.grinberg@gmail.com" }, 6 | ] 7 | description = "A minimalistic API framework built on top of Flask, Marshmallow and friends." 8 | classifiers = [ 9 | "Intended Audience :: Developers", 10 | "Programming Language :: Python :: 3", 11 | "License :: OSI Approved :: MIT License", 12 | "Operating System :: OS Independent", 13 | ] 14 | requires-python = ">=3.9" 15 | dependencies = [ 16 | "flask >= 1.1.0", 17 | "flask-marshmallow", 18 | "webargs >= 8.3.0", 19 | "flask-httpauth >= 4", 20 | "apispec >= 4", 21 | ] 22 | 23 | [project.readme] 24 | file = "README.md" 25 | content-type = "text/markdown" 26 | 27 | [project.urls] 28 | Homepage = "https://github.com/miguelgrinberg/apifairy" 29 | "Bug Tracker" = "https://github.com/miguelgrinberg/apifairy/issues" 30 | 31 | [project.optional-dependencies] 32 | docs = [ 33 | "sphinx", 34 | ] 35 | dev = [ 36 | "tox", 37 | ] 38 | 39 | [tool.setuptools] 40 | zip-safe = false 41 | include-package-data = false 42 | 43 | [tool.setuptools.package-dir] 44 | "" = "src" 45 | 46 | [tool.setuptools.packages.find] 47 | where = [ 48 | "src", 49 | ] 50 | namespaces = false 51 | 52 | [tool.setuptools.package-data] 53 | apifairy = [ 54 | "templates/apifairy/*.html", 55 | ] 56 | 57 | [build-system] 58 | requires = [ 59 | "setuptools>=61.2", 60 | ] 61 | build-backend = "setuptools.build_meta" 62 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | lint: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-python@v3 16 | - run: python -m pip install --upgrade pip wheel 17 | - run: pip install tox tox-gh-actions 18 | - run: tox -eflake8 19 | - run: tox -edocs 20 | tests: 21 | name: tests 22 | strategy: 23 | matrix: 24 | os: [ubuntu-latest, macos-latest, windows-latest] 25 | python: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 'pypy-3.11'] 26 | fail-fast: false 27 | runs-on: ${{ matrix.os }} 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: actions/setup-python@v3 31 | with: 32 | python-version: ${{ matrix.python }} 33 | - run: python -m pip install --upgrade pip wheel 34 | - run: pip install tox tox-gh-actions 35 | - run: tox 36 | coverage: 37 | name: coverage 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v3 41 | - uses: actions/setup-python@v3 42 | - run: python -m pip install --upgrade pip wheel 43 | - run: pip install tox tox-gh-actions 44 | - run: tox 45 | - uses: codecov/codecov-action@v3 46 | with: 47 | files: ./coverage.xml 48 | fail_ci_if_error: true 49 | token: ${{ secrets.CODECOV_TOKEN }} 50 | -------------------------------------------------------------------------------- /bin/mkchangelog.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | import sys 4 | import git 5 | 6 | URL = 'https://github.com/miguelgrinberg/apifairy' 7 | merges = {} 8 | 9 | 10 | def format_message(commit): 11 | if commit.message.startswith('Version '): 12 | return '' 13 | if '#nolog' in commit.message: 14 | return '' 15 | if commit.message.startswith('Merge pull request'): 16 | pr = commit.message.split('#')[1].split(' ')[0] 17 | message = ' '.join([line for line in [line.strip() for line in commit.message.split('\n')[1:]] if line]) 18 | merges[message] = pr 19 | return '' 20 | if commit.message.startswith('Release '): 21 | return '\n**{message}** - {date}\n'.format( 22 | message=commit.message.strip(), 23 | date=datetime.datetime.fromtimestamp(commit.committed_date).strftime('%Y-%m-%d')) 24 | message = ' '.join([line for line in [line.strip() for line in commit.message.split('\n')] if line]) 25 | if message in merges: 26 | message += ' #' + merges[message] 27 | message = re.sub('\\(.*(#[0-9]+)\\)', '\\1', message) 28 | message = re.sub('Fixes (#[0-9]+)', '\\1', message) 29 | message = re.sub('fixes (#[0-9]+)', '\\1', message) 30 | message = re.sub('#([0-9]+)', '[#\\1]({url}/issues/\\1)'.format(url=URL), message) 31 | message += ' ([commit]({url}/commit/{sha}))'.format(url=URL, sha=str(commit)) 32 | if commit.author.name != 'Miguel Grinberg': 33 | message += ' (thanks **{name}**!)'.format(name=commit.author.name) 34 | return '- ' + message 35 | 36 | 37 | def main(all=False): 38 | repo = git.Repo() 39 | 40 | for commit in repo.iter_commits(): 41 | if not all and commit.message.startswith('Release '): 42 | break 43 | message = format_message(commit) 44 | if message: 45 | print(message) 46 | 47 | 48 | if __name__ == '__main__': 49 | main(all=len(sys.argv) > 1 and sys.argv[1] == 'all') 50 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'APIFairy' 21 | copyright = '2020, Miguel Grinberg' 22 | author = 'Miguel Grinberg' 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | 'sphinx.ext.autosectionlabel', 32 | ] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ['_templates'] 36 | 37 | # List of patterns, relative to source directory, that match files and 38 | # directories to ignore when looking for source files. 39 | # This pattern also affects html_static_path and html_extra_path. 40 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 41 | 42 | # The master toctree document. 43 | master_doc = 'index' 44 | 45 | # -- Options for HTML output ------------------------------------------------- 46 | 47 | # The theme to use for HTML and HTML Help pages. See the documentation for 48 | # a list of builtin themes. 49 | # 50 | html_theme = 'alabaster' 51 | 52 | # Add any paths that contain custom static files (such as style sheets) here, 53 | # relative to this directory. They are copied after the builtin static files, 54 | # so a file named "default.css" will overwrite the builtin "default.css". 55 | html_static_path = ['_static'] 56 | 57 | html_theme_options = { 58 | 'description': ('A minimalistic API framework built on top of Flask, ' 59 | 'Marshmallow and friends.'), 60 | 'fixed_sidebar': True, 61 | 'github_user': 'miguelgrinberg', 62 | 'github_repo': 'APIFairy', 63 | 'github_button': True, 64 | 'github_type': 'star', 65 | 'github_banner': True, 66 | } 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | mise.toml 132 | requirements*.txt 133 | -------------------------------------------------------------------------------- /examples/app.py: -------------------------------------------------------------------------------- 1 | """Welcome to the APIFairy Simple Example project! 2 | 3 | ## Overview 4 | 5 | This is a short and simple example that demonstrates many of the features of 6 | APIFairy. 7 | """ 8 | from typing import Annotated 9 | from uuid import uuid4 10 | from flask import Flask, abort 11 | from flask_marshmallow import Marshmallow 12 | from apifairy import APIFairy, body, response, other_responses 13 | 14 | app = Flask(__name__) 15 | app.config['APIFAIRY_TITLE'] = 'APIFairy Simple Example' 16 | app.config['APIFAIRY_VERSION'] = '1.0' 17 | ma = Marshmallow(app) 18 | apifairy = APIFairy(app) 19 | users = [] 20 | 21 | 22 | class UserSchema(ma.Schema): 23 | class Meta: 24 | description = 'This schema represents a user' 25 | 26 | id = ma.String(dump_only=True, metadata={"description": "The user's id"}) 27 | username = ma.String(required=True, metadata={"description": "The user's username"}) 28 | first_name = ma.String(metadata={"description": "The user's first name"}) 29 | last_name = ma.String(metadata={"description": "The user's last name"}) 30 | age = ma.Integer(metadata={"description": "The user's age"}) 31 | password = ma.String(load_only=True, metadata={"description": "The user's password"}) 32 | 33 | 34 | @app.get('/users') 35 | @response(UserSchema(many=True), description="The users") 36 | def get_users(): 37 | """Return all the users.""" 38 | return users 39 | 40 | 41 | @app.post('/users') 42 | @body(UserSchema) 43 | @response(UserSchema, description="The new user") 44 | @other_responses({400: 'Duplicate username or validation error'}) 45 | def new_user(user): 46 | """Create a new user.""" 47 | if any([u['username'] == user['username'] for u in users]): 48 | abort(400) 49 | new_id = uuid4().hex 50 | user['id'] = new_id 51 | users.append(user) 52 | return user 53 | 54 | 55 | @app.get('/users/') 56 | @response(UserSchema, description="The requested user") 57 | @other_responses({404: 'User not found'}) 58 | def get_user(id: Annotated[str, 'The id of the user']): 59 | """Return a user.""" 60 | user = [u for u in users if u['id'] == id] 61 | if not user: 62 | abort(404) 63 | return user[0] 64 | 65 | 66 | @app.errorhandler(400) 67 | def bad_request(e): 68 | return {'code': 400, 'error': 'bad request'} 69 | 70 | 71 | @app.errorhandler(404) 72 | def not_found(e): 73 | return {'code': 404, 'error': 'not found'} 74 | 75 | 76 | @apifairy.error_handler 77 | def validation_error(status_code, messages): 78 | return {'code': status_code, 'error': 'validation error', 79 | 'messages': messages['json']} 80 | -------------------------------------------------------------------------------- /examples/app_with_class_views.py: -------------------------------------------------------------------------------- 1 | """Welcome to the APIFairy Simple Example project! 2 | 3 | ## Overview 4 | 5 | This is a short and simple example that demonstrates many of the features of 6 | APIFairy. The difference between this version of the example and `app.py` is 7 | that in this example class-based views are used. 8 | """ 9 | from typing import Annotated 10 | from uuid import uuid4 11 | from flask import Flask, abort 12 | from flask.views import MethodView 13 | from flask_marshmallow import Marshmallow 14 | from apifairy import APIFairy, body, response, other_responses 15 | 16 | app = Flask(__name__) 17 | app.config['APIFAIRY_TITLE'] = 'APIFairy Simple Example' 18 | app.config['APIFAIRY_VERSION'] = '1.0' 19 | ma = Marshmallow(app) 20 | apifairy = APIFairy(app) 21 | users = [] 22 | 23 | 24 | class UserSchema(ma.Schema): 25 | class Meta: 26 | description = 'This schema represents a user' 27 | 28 | id = ma.String(dump_only=True, metadata={"description": "The user's id"}) 29 | username = ma.String(required=True, metadata={"description": "The user's username"}) 30 | first_name = ma.String(metadata={"description": "The user's first name"}) 31 | last_name = ma.String(metadata={"description": "The user's last name"}) 32 | age = ma.Integer(metadata={"description": "The user's age"}) 33 | password = ma.String(load_only=True, metadata={"description": "The user's password"}) 34 | 35 | 36 | class GetUsersEndpoint(MethodView): 37 | decorators= [ 38 | response(UserSchema(many=True), description="The users"), 39 | ] 40 | 41 | def get(self): 42 | """Return all the users.""" 43 | return users 44 | 45 | 46 | class NewUserEndpoint(MethodView): 47 | decorators = [ 48 | other_responses({400: 'Duplicate username or validation error'}), 49 | response(UserSchema, description="The new user"), 50 | body(UserSchema), 51 | ] 52 | 53 | # important note: endpoints like this one that take arguments from APIFairy 54 | # are currently broken, due to a bug in Flask 55 | # see https://github.com/pallets/flask/issues/5199 56 | def post(self, user): 57 | """Create a new user.""" 58 | if any([u['username'] == user['username'] for u in users]): 59 | abort(400) 60 | new_id = uuid4().hex 61 | user['id'] = new_id 62 | users.append(user) 63 | return user 64 | 65 | 66 | class UserEndpoint(MethodView): 67 | decorators = [ 68 | response(UserSchema, description="The requested user"), 69 | other_responses({404: 'User not found'}), 70 | ] 71 | 72 | def get(self, id: Annotated[str, 'The id of the user']): 73 | """Return a user.""" 74 | user = [u for u in users if u['id'] == id] 75 | if not user: 76 | abort(404) 77 | return user[0] 78 | 79 | 80 | app.add_url_rule("/users", view_func=GetUsersEndpoint.as_view("get_users")) 81 | app.add_url_rule("/users", view_func=NewUserEndpoint.as_view("new_user")) 82 | app.add_url_rule("/user/", view_func=UserEndpoint.as_view("get_user")) 83 | 84 | 85 | @app.errorhandler(400) 86 | def bad_request(e): 87 | return {'code': 400, 'error': 'bad request'} 88 | 89 | 90 | @app.errorhandler(404) 91 | def not_found(e): 92 | return {'code': 404, 'error': 'not found'} 93 | 94 | 95 | @apifairy.error_handler 96 | def validation_error(status_code, messages): 97 | return {'code': status_code, 'error': 'validation error', 98 | 'messages': messages['json']} 99 | -------------------------------------------------------------------------------- /docs/apifairy_class.rst: -------------------------------------------------------------------------------- 1 | .. APIFairy documentation master file, created by 2 | sphinx-quickstart on Sun Sep 27 17:34:58 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | The APIFairy Class 7 | ================== 8 | 9 | The main function of the ``APIFairy`` instance is to gather all the information 10 | registered by the decorators and generate an `OpenAPI 3.x 11 | `_ compliant schema with it. This schema is 12 | then used to render the documentation site using one of the available 13 | open-source documentation projects that are compatible with this specification. 14 | 15 | In addition to ducmentation, ``APIFairy`` allows the application to 16 | install a custom error handler to be used when a schema validation error occurs 17 | in routes decorated with the ``@body`` or ``@arguments`` decorators. It also 18 | registers routes to serve the OpenAPI definition in JSON format and a 19 | documentation site based on one of the supported third-party documentation 20 | projects. 21 | 22 | APIFairy.apispec 23 | ---------------- 24 | 25 | The ``apispec`` property returns the complete OpenAPI definition for the 26 | project as a Python dictionary. The information used to build this data is 27 | obtained from several places: 28 | 29 | - The project's name and version are obtained from the ``APIFAIRY_TITLE`` and 30 | ``APIFAIRY_VERSION`` configuration items respectively. 31 | - The top-level documentation for the project, which appears above the API 32 | definitions, is obtained from the main module's docstring. Markdown can be 33 | used to organize this content in sections and use rich-text formatting. 34 | - The paths are obtained from all the Flask routes that have been decorated 35 | with at least one of the five decorators from this project. Routes that have 36 | not been decorated with these decorators are not included in the 37 | documentation. 38 | - The schemas and security schemes are collected from decorator usages. 39 | - Each path is documented using the information provided in the decorators, 40 | plus the route definition for Flask and the docstring of the view function. 41 | The first line of the docstring is used as a summary and the remaining lines 42 | as a description. 43 | - If a route belongs to a blueprint, the corresponding path is tagged with the 44 | blueprint name. Paths are grouped by their tag, which ensures that routes 45 | from each blueprint are rendered together in their own section. The 46 | ``APIFAIRY_TAGS`` configuration item can be used to provide a custom ordering 47 | for tags. 48 | - Each security scheme is documented by inspecting the Flask-HTTPAuth object, 49 | plus the contents of the ``__doc__`` property if it exists. 50 | 51 | APIFairy.process_apispec 52 | ------------------------ 53 | 54 | The ``process_apispec`` decorator can be used to register a custom function 55 | that receives the generated OpenAPI definition as its single argument. The 56 | function can make changes and adjustments to it and return the modified 57 | definition, which will then be rendered:: 58 | 59 | @apifairy.process_apispec 60 | def my_apispec_processor(spec): 61 | # modify spec as needed here 62 | return spec 63 | 64 | APIFairy.error_handler 65 | ---------------------- 66 | 67 | The ``error_handler`` method can be used to register a custom error handler 68 | function that will be invoked whenever a validation error is raised by the 69 | webargs project. This method can be used as a decorator as follows:: 70 | 71 | @apifairy.error_handler 72 | def my_error_handler(status_code, messages): 73 | return {'code': status_code, 'messages': messages}, status_code 74 | 75 | The ``status_code`` argument is the suggested HTTP status code, which is 76 | typically 400 for a "bad request" response. The ``messages`` argument is a 77 | dictionary with all the validation error messages that were found, organized as 78 | a dictionary with the following structure:: 79 | 80 | "location1": { 81 | "field1": ["message1", "message2", ...], 82 | "field2": [ ... ], 83 | ... 84 | }, 85 | "location2": { ... }, 86 | ... 87 | 88 | The location keys can be ``'json'`` for the request body or ``'query'`` for the 89 | query string. 90 | 91 | The return value of the error handling function is interpreted as a standard 92 | Flask response, and returned to the client as such. 93 | -------------------------------------------------------------------------------- /src/apifairy/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from flask import current_app, Response 4 | from webargs.flaskparser import FlaskParser as BaseFlaskParser 5 | 6 | from apifairy.exceptions import ValidationError 7 | 8 | 9 | class FlaskParser(BaseFlaskParser): 10 | USE_ARGS_POSITIONAL = False 11 | DEFAULT_VALIDATION_STATUS = 400 12 | 13 | def load_form(self, req, schema): 14 | return {**self.load_files(req, schema), 15 | **super().load_form(req, schema)} 16 | 17 | def handle_error(self, error, req, schema, *, error_status_code, 18 | error_headers): 19 | raise ValidationError( 20 | error_status_code or self.DEFAULT_VALIDATION_STATUS, 21 | error.messages) 22 | 23 | 24 | parser = FlaskParser() 25 | use_args = parser.use_args 26 | _webhooks = {} 27 | 28 | 29 | def _ensure_sync(f): 30 | if hasattr(f, '_sync_ensured'): 31 | return f 32 | 33 | @wraps(f) 34 | def wrapper(*args, **kwargs): 35 | if hasattr(current_app, 'ensure_sync'): 36 | return current_app.ensure_sync(f)(*args, **kwargs) 37 | else: # pragma: no cover 38 | return f(*args, **kwargs) 39 | 40 | wrapper._sync_ensured = True 41 | return wrapper 42 | 43 | 44 | def _annotate(f, **kwargs): 45 | if not hasattr(f, '_spec'): 46 | f._spec = {} 47 | for key, value in kwargs.items(): 48 | f._spec[key] = value 49 | 50 | 51 | def authenticate(auth, **kwargs): 52 | def decorator(f): 53 | roles = kwargs.get('role') 54 | if not isinstance(roles, list): # pragma: no cover 55 | roles = [roles] if roles is not None else [] 56 | f = _ensure_sync(f) 57 | _annotate(f, auth=auth, roles=roles) 58 | return auth.login_required(**kwargs)(f) 59 | return decorator 60 | 61 | 62 | def arguments(schema, location='query', **kwargs): 63 | if isinstance(schema, type): # pragma: no cover 64 | schema = schema() 65 | 66 | def decorator(f): 67 | f = _ensure_sync(f) 68 | if not hasattr(f, '_spec') or f._spec.get('args') is None: 69 | _annotate(f, args=[]) 70 | f._spec['args'].append((schema, location)) 71 | arg_name = f'{location}_{schema.__class__.__name__}_args' 72 | 73 | @wraps(f) 74 | def _f(*args, **kwargs): 75 | location_args = kwargs.pop(arg_name, {}) 76 | return f(*args, location_args, **kwargs) 77 | 78 | return use_args(schema, location=location, arg_name=arg_name, 79 | **kwargs)(_f) 80 | return decorator 81 | 82 | 83 | def body(schema, location='json', media_type=None, **kwargs): 84 | if isinstance(schema, type): # pragma: no cover 85 | schema = schema() 86 | 87 | def decorator(f): 88 | f = _ensure_sync(f) 89 | _annotate(f, body=(schema, location, media_type)) 90 | arg_name = f'{location}_{schema.__class__.__name__}_args' 91 | 92 | @wraps(f) 93 | def _f(*args, **kwargs): 94 | location_args = kwargs.pop(arg_name, {}) 95 | return f(*args, location_args, **kwargs) 96 | 97 | return use_args(schema, location=location, arg_name=arg_name, 98 | **kwargs)(_f) 99 | return decorator 100 | 101 | 102 | def response(schema, status_code=200, description=None, headers=None): 103 | if isinstance(schema, type): # pragma: no cover 104 | schema = schema() 105 | 106 | def decorator(f): 107 | f = _ensure_sync(f) 108 | _annotate(f, response=schema, status_code=status_code, 109 | description=description, response_headers=headers) 110 | 111 | @wraps(f) 112 | def _response(*args, **kwargs): 113 | rv = f(*args, **kwargs) 114 | if isinstance(rv, Response): # pragma: no cover 115 | raise RuntimeError( 116 | 'The @response decorator cannot handle Response objects.') 117 | if isinstance(rv, tuple): 118 | json = schema.jsonify(rv[0]) 119 | if len(rv) == 2: 120 | if not isinstance(rv[1], int): 121 | rv = (json, status_code, rv[1]) 122 | else: 123 | rv = (json, rv[1]) 124 | elif len(rv) >= 3: 125 | rv = (json, rv[1], rv[2]) 126 | else: 127 | rv = (json, status_code) 128 | return rv 129 | else: 130 | return schema.jsonify(rv), status_code 131 | return _response 132 | return decorator 133 | 134 | 135 | def other_responses(responses): 136 | def decorator(f): 137 | f = _ensure_sync(f) 138 | _annotate(f, other_responses=responses) 139 | return f 140 | return decorator 141 | 142 | 143 | def webhook(method='GET', blueprint=None, endpoint=None): 144 | def decorator(f): 145 | class WebhookRule: 146 | def __init__(self, view_func, endpoint, methods): 147 | self.view_func = view_func 148 | self.endpoint = endpoint 149 | self.methods = methods 150 | 151 | nonlocal endpoint 152 | endpoint = endpoint or f.__name__ 153 | if blueprint is not None: 154 | endpoint = blueprint.name + '.' + endpoint 155 | if endpoint not in _webhooks: 156 | _webhooks[endpoint] = WebhookRule(f, endpoint, methods=[method]) 157 | else: 158 | raise ValueError(f'Webhook {endpoint} has been defined twice') 159 | return f 160 | 161 | if callable(method) and blueprint is None and endpoint is None: 162 | # invoked as a decorator without arguments 163 | f = method 164 | method = 'GET' 165 | return decorator(f) 166 | else: 167 | # invoked as a decorator with arguments 168 | return decorator 169 | -------------------------------------------------------------------------------- /docs/intro.rst: -------------------------------------------------------------------------------- 1 | .. APIFairy documentation master file, created by 2 | sphinx-quickstart on Sun Sep 27 17:34:58 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Getting Started 7 | =============== 8 | 9 | APIFairy is a minimalistic API framework for Flask with the following goals: 10 | 11 | - Give you a way to specify what the input arguments for each endpoint are, 12 | and automatically validate them for you. 13 | - Give you a way to specify what the response format for each endpoint is, and 14 | automatically serialize these responses for you. 15 | - Automatically generate API documentation for your project. 16 | - Introduce the least amount of rules. You should be able to code your 17 | endpoints in the style that you like. 18 | 19 | Below you can see an example API endpoint augmented with 20 | APIFairy decorators:: 21 | 22 | from apifairy import authenticate, body, response, other_responses 23 | 24 | # ... 25 | 26 | @posts_blueprint.route('/posts/', methods=['PUT']) 27 | @authenticate(token_auth) 28 | @body(update_post_schema) 29 | @response(post_schema) 30 | @other_responses({404: 'Post not found'}) 31 | def put(updated_post, id): 32 | """Edit a post.""" 33 | post = Post.query.get_or_404(id) 34 | for attr, value in updated_post.items(): 35 | setattr(post, attr, value) 36 | db.session.commit() 37 | return post 38 | 39 | APIFairy's decorators are simple wrappers for existing solutions. In the 40 | example above, ``token_auth`` is an intialized authentication object from the 41 | Flask-HTTPAuth extension, and ``post_schema`` and ``update_post_schema`` are 42 | Flask-Marshmallow schema objects. Using the decorator wrappers allow APIFairy 43 | to automatically generate documentation using the OpenAPI 3.x standard. Below 44 | is a screenshot of the documentation for the above endpoint: 45 | 46 | .. image:: _static/apispec-example.png 47 | :width: 100% 48 | :alt: Automatic documentation example 49 | 50 | Installation 51 | ------------ 52 | 53 | APIFairy is installed with ``pip``:: 54 | 55 | pip install apifairy 56 | 57 | Once installed, this package is initialized as a standard Flask extension:: 58 | 59 | from flask import Flask 60 | from apifairy import APIFairy 61 | 62 | app = Flask(__name__) 63 | apifairy = APIFairy(app) 64 | 65 | The two-phase initialization style is also supported:: 66 | 67 | from flask import Flask 68 | from apifairy import APIFairy 69 | 70 | apifairy = APIFairy() 71 | 72 | def create_app(): 73 | app = Flask(__name__) 74 | apifairy.init_app(app) 75 | return app 76 | 77 | Once APIFairy is initialized, automatically generated documentation can be 78 | accessed at the */docs* URL. The raw OpenAPI documentation data in JSON format 79 | can be accessed at the */apispec.json* URL. Both URLs can be changed in the 80 | configuration if desired. 81 | 82 | Configuration 83 | ------------- 84 | 85 | APIFairy imports its configuration from the Flask configuration object. 86 | The available options are shown in the table below. 87 | 88 | =============================== ====== =============== ======================================================================================================= 89 | Name Type Default Description 90 | =============================== ====== =============== ======================================================================================================= 91 | ``APIFAIRY_TITLE`` String No title The API's title. 92 | ``APIFAIRY_VERSION`` String No version The API's version. 93 | ``APIFAIRY_APISPEC_PATH`` String */apispec.json* The URL path where the JSON OpenAPI specification for this project is served. 94 | ``APIFAIRY_APISPEC_VERSION`` String ``None`` The version of the OpenAPI specification to generate for this project. 95 | ``APIFAIRY_APISPEC_DECORATORS`` List [] A list of decorators to apply to the JSON OpenAPI endpoint. 96 | ``APIFAIRY_UI`` String redoc The documentation format to use. Supported formats are "redoc", "swagger_ui", "rapidoc" and "elements". 97 | ``APIFAIRY_UI_PATH`` String */docs* The URL path where the documentation is served. 98 | ``APIFAIRY_UI_DECORATORS`` List [] A list of decorators to apply to the documentation endpoint. 99 | ``APIFAIRY_TAGS`` List ``None`` A list of tags to include in the documentation, in the desired order. 100 | =============================== ====== =============== ======================================================================================================= 101 | 102 | Using a Custom Documentation Endpoint 103 | ------------------------------------- 104 | 105 | APIFairy provides templates for a few popular open source OpenAPI documentation renderers: 106 | 107 | - ``swagger_ui``: `Swagger UI `_ 108 | - ``redoc``: `ReDoc `_ 109 | - ``rapidoc``: `RapiDoc `_ 110 | - ``elements``: `Elements `_ 111 | 112 | If neither of these work for your project, or if you would like to configure 113 | any of these differently, you can set the ``APIFAIRY_UI_PATH`` to ``None`` in 114 | the configuration to disable the default documentation endpoint, and then 115 | implement your own. 116 | 117 | The stock documentation options offered by this package are implemented as 118 | Jinja2 templates, which you can `view on GitHub `_. 119 | To implement a custom documentation, just create an endpoint in your Flask 120 | application and render your own template, using the 121 | ``{{ url_for('apifairy.json') }}`` expression where your documentation 122 | renderer needs the API specification URL. 123 | 124 | .. note:: 125 | When using a custom documentation endpoint, the ``APIFAIRY_UI_PATH`` and 126 | ``APIFAIRY_UI_DECORATORS`` configuration options are ignored. 127 | 128 | While less useful, the JSON OpenAPI specification endpoint can also be 129 | customized by setting the ``APIFAIRY_APISPEC_PATH`` configuration option to 130 | ``None``. If a custom version of this endpoint is used, then the documentation 131 | endpoint must also be provided by the application. 132 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # APIFairy Change Log 2 | 3 | **Release 1.5.1** - 2025-11-14 4 | 5 | - Pin redoc CDN package to v2 [#94](https://github.com/miguelgrinberg/apifairy/issues/94) ([commit](https://github.com/miguelgrinberg/apifairy/commit/7acc2b4927ecf07bc270b7497eaa3334d2c92e3b)) (thanks **Trevor Mack**!) 6 | 7 | **Release 1.5.0** - 2025-10-28 8 | 9 | - Add `APIFAIRY_APISPEC_VERSION` option to the configuration [#92](https://github.com/miguelgrinberg/apifairy/issues/92) ([commit](https://github.com/miguelgrinberg/apifairy/commit/3fdd7632b24669901a0ffe67c5366a2992b6548e)) (thanks **Daniel Black**!) 10 | - Add support for Marshmallow 4, Python 3.13, 3.14 and pypy-3.11. Remove Python 3.8. [#93](https://github.com/miguelgrinberg/apifairy/issues/93) ([commit](https://github.com/miguelgrinberg/apifairy/commit/a2b791d59963c3cb61f1dcc1625bc5cdb4151bdd)) 11 | 12 | **Release 1.4.0** - 2024-01-15 13 | 14 | - Remove use of deprecated `flask.__version__` ([commit](https://github.com/miguelgrinberg/apifairy/commit/a21ecba2fc6dbcdbb7e25c44933116bcaea8aaa4)) 15 | - Handle breaking changes in `webargs.use_args` decorator ([commit](https://github.com/miguelgrinberg/apifairy/commit/943d30303bbdcaabda028ada8e1b2fee0132e7fa)) 16 | - Option to set the media type for the body explicitly [#78](https://github.com/miguelgrinberg/apifairy/issues/78) ([commit](https://github.com/miguelgrinberg/apifairy/commit/b6886ebb4dd276d1d6c68de1122f362e0dec1f84)) 17 | - Update to latest versions of JS and CSS 3rd-party resources [#73](https://github.com/miguelgrinberg/apifairy/issues/73) ([commit](https://github.com/miguelgrinberg/apifairy/commit/f91945a89dde4362be81b4ad9feec1486ac13170)) (thanks **Frank Yu**!) 18 | - Examples added to the repository ([commit #1](https://github.com/miguelgrinberg/apifairy/commit/b864bd2d4bbaf39f238dcddb691bca2a0cf4a34b) [commit #2](https://github.com/miguelgrinberg/apifairy/commit/ed2c9b99e8ed8b7cd61a1b95f7f295bd2a902590) [commit #3](https://github.com/miguelgrinberg/apifairy/commit/5612f2648c7d118013d0e77565f960e5a5eec07d)) 19 | - Add Python 3.12 to builds ([commit](https://github.com/miguelgrinberg/apifairy/commit/2f3b99c19b1ddaf197b6eb7cf74d645375a42c0f)) 20 | - Migrate Python package metadata to pyproject.toml ([commit](https://github.com/miguelgrinberg/apifairy/commit/38d765b6a492a3c40cbf4fdff6e235be84c67111)) 21 | 22 | **Release 1.3.0** - 2022-11-13 23 | 24 | - Support for documenting webhooks, per OpenAPI 3.1.0 spec ([commit](https://github.com/miguelgrinberg/apifairy/commit/f5b3843a7097c0d2a297e6074c2c1837521a4077)) 25 | - Add Python 3.11 to test builds ([commit](https://github.com/miguelgrinberg/apifairy/commit/0d11acb143a6661f0a0d0b1e857a7626ba066f1d)) 26 | - Stop testing Python 3.6 ([commit](https://github.com/miguelgrinberg/apifairy/commit/e17f702566792bdb045faebb21f1f682bca79b28)) 27 | 28 | **Release 1.2.0** - 2022-10-06 29 | 30 | - Documentation of request and response headers [#63](https://github.com/miguelgrinberg/apifairy/issues/63) ([commit](https://github.com/miguelgrinberg/apifairy/commit/c2a9ec2cc5608f5c26c30428d964b964d00c8b8f)) 31 | 32 | **Release 1.1.0** - 2022-09-22 33 | 34 | - Optional schema for error responses listed in the `@other_responses` decorator [#60](https://github.com/miguelgrinberg/apifairy/issues/60) ([commit](https://github.com/miguelgrinberg/apifairy/commit/e7164b2fada8666e1748fbd06cd78fed7b8d8867)) 35 | - Optional decorators to apply to the apispec and documentation endpoints [#58](https://github.com/miguelgrinberg/apifairy/issues/58) ([commit](https://github.com/miguelgrinberg/apifairy/commit/f9b037d7654691ac39850c311cf5759a0a42a1ab)) 36 | - Fixing some typos in documentation [#53](https://github.com/miguelgrinberg/apifairy/issues/53) ([commit](https://github.com/miguelgrinberg/apifairy/commit/972eb76d9494aceb0ca9d159a3d2ebf59f7e0603)) (thanks **GustavMauler**!) 37 | - Add link to Microblog API example in readme ([commit](https://github.com/miguelgrinberg/apifairy/commit/6bcdf2ff74008b37aab0f723343469713a6998fb)) 38 | - Updated readme with a screenshot ([commit](https://github.com/miguelgrinberg/apifairy/commit/71d9e96a3abd34b6e528ab43679ac2b781c66dbe)) 39 | 40 | **Release 1.0.0** - 2022-08-02 41 | 42 | - Document path parameters with string annotations ([commit](https://github.com/miguelgrinberg/apifairy/commit/4cade08b60ba4336fcfaf01e63b3ad4b72a8fccc)) 43 | - Support for `typing.Annotated` in path parameter documentation ([commit](https://github.com/miguelgrinberg/apifairy/commit/aa090a0a1d06c298f81efaa3d0b10a844097caae)) 44 | - Correct handling of custom blueprint ordering ([commit](https://github.com/miguelgrinberg/apifairy/commit/1ac7938c5c1288da953231818e567fe740b65ba6)) 45 | - Documentation on how to add manually written documentation ([commit](https://github.com/miguelgrinberg/apifairy/commit/5bfda7e62891b84dfbd63ecaef83bc4191c99272)) 46 | 47 | **Release 0.9.2** - 2022-07-20 48 | 49 | - Form and file upload support [#35](https://github.com/miguelgrinberg/apifairy/issues/35) ([commit](https://github.com/miguelgrinberg/apifairy/commit/59dfb3c252119beb982adef2346c76592ef14528)) 50 | - Additional unit testing coverage ([commit](https://github.com/miguelgrinberg/apifairy/commit/407cf6ba724b6f4c5b90bae8685fee0697f16146)) 51 | - Add Python 3.10 and PyPy 3.8 to builds ([commit](https://github.com/miguelgrinberg/apifairy/commit/66ad682d602f2551d0f075678b63b3f338ec6a28)) 52 | 53 | **Release 0.9.1** - 2022-01-11 54 | 55 | - Mark request body as required when `@body` decorator is used [#37](https://github.com/miguelgrinberg/apifairy/issues/37) ([commit](https://github.com/miguelgrinberg/apifairy/commit/5558b240cf0697fd6da875fdb7b98b76eb6d2d30)) 56 | - Set page title in rapidoc and elements templates ([commit](https://github.com/miguelgrinberg/apifairy/commit/95352b1c430183166a77459983190894c6596122)) 57 | 58 | **Release 0.9.0** - 2021-12-14 59 | 60 | - Better ordering for authentication schemes ([commit](https://github.com/miguelgrinberg/apifairy/commit/a6067f8eeb1fe429935e75c0ca71389caed4754f)) 61 | - Added rapidoc template ([commit](https://github.com/miguelgrinberg/apifairy/commit/ff9a161bc9edfe7e88f1b6f658ea12f2ae91a0e2)) 62 | - Added Elements template ([commit](https://github.com/miguelgrinberg/apifairy/commit/d2ff0543cbf4ed8f293c48b1839445b3deacbf3d)) 63 | - Documented how to create a custom documentation endpoint ([commit](https://github.com/miguelgrinberg/apifairy/commit/47d13793fa06a9f23eca5435478f42b103c980b3)) 64 | 65 | **Release 0.8.2** - 2021-08-30 66 | 67 | - One more change needed to include HTML files in package ([commit](https://github.com/miguelgrinberg/apifairy/commit/7ed49227de57afbd51dbea5bd2b1e24ff12f733f)) 68 | 69 | **Release 0.8.1** - 2021-08-30 70 | 71 | - Add the documentation templates back into the package [#2](https://github.com/miguelgrinberg/apifairy/issues/2) ([commit](https://github.com/miguelgrinberg/apifairy/commit/7e0115cd5706652d7208bfafb8b47e8fe84b5de7)) 72 | 73 | **Release 0.8.0** - 2021-08-07 74 | 75 | - Add `servers` section ([commit](https://github.com/miguelgrinberg/apifairy/commit/6d5d614ff0dc9ef7666191f4ca7c9e9139518d99)) 76 | - Add `operationId` for each endpoint ([commit](https://github.com/miguelgrinberg/apifairy/commit/198855f810b4f97b7f3e61c0cf602e31ab2e0fa8)) 77 | - Add default description for responses ([commit](https://github.com/miguelgrinberg/apifairy/commit/73ec17f13933c5d4a55a81d5131706a531f88dfb)) 78 | - Remove indentation spaces from docstrings [#30](https://github.com/miguelgrinberg/apifairy/issues/30) ([commit](https://github.com/miguelgrinberg/apifairy/commit/30ef9983bf0c5bb31451cdcc2d5d91447d3cf80e)) 79 | - Support Flask 2 async views ([commit](https://github.com/miguelgrinberg/apifairy/commit/bae399aa76d13ebf167a5933f50ddbb5f3923039)) 80 | - Support nested blueprints ([commit](https://github.com/miguelgrinberg/apifairy/commit/c5883a626631744c8ec28782bf852c738169dd8f)) (thanks **Grey Li**!) 81 | - Improved project structure ([commit](https://github.com/miguelgrinberg/apifairy/commit/1fbd5a59d3c8aa4e2ea38331c750e41f3164bd3f)) 82 | 83 | **Release 0.7.0** - 2021-05-24 84 | 85 | - Correctly handle routes with multiple path arguments [#11](https://github.com/miguelgrinberg/apifairy/issues/11) ([commit](https://github.com/miguelgrinberg/apifairy/commit/898b2f1f6bb7de5b5125162fe17879e4d1734dee)) (thanks **Grey Li**!) 86 | - Use default status code when route returns a one-element tutple ([commit](https://github.com/miguelgrinberg/apifairy/commit/c895739ce51ea8165de8cd20e322dea7fd2c4645)) 87 | - Update schema name resolver to remove unnecessary List suffix [#21](https://github.com/miguelgrinberg/apifairy/issues/21) ([commit](https://github.com/miguelgrinberg/apifairy/commit/fee7425c32ce0629d65cf1729337d3fe940864a6)) (thanks **Grey Li**!) 88 | - Fix path arguments order ([commit](https://github.com/miguelgrinberg/apifairy/commit/6793feb36c893212966eeaf4c9bea2b753e3d142)) (thanks **Grey Li**!) 89 | - Fix path arguments regex [#16](https://github.com/miguelgrinberg/apifairy/issues/16) ([commit](https://github.com/miguelgrinberg/apifairy/commit/7c81c154698dfab0a3c49613ea9885c2ea81be51)) (thanks **Grey Li**!) 90 | - Fix detection of view docstring [#8](https://github.com/miguelgrinberg/apifairy/issues/8) ([commit](https://github.com/miguelgrinberg/apifairy/commit/4dd8568f037b27a54bb1b57a4ea27580f97cf786)) (thanks **Grey Li**!) 91 | - Add missing backtick for inline code [#17](https://github.com/miguelgrinberg/apifairy/issues/17) ([commit](https://github.com/miguelgrinberg/apifairy/commit/e25f5487d1be1b9fef828ce8376e35f51d2231dc)) (thanks **Grey Li**!) 92 | - Document the process_apispec decorator ([commit](https://github.com/miguelgrinberg/apifairy/commit/fd22e11302da82e4aed58e5793efa997d113dc74)) 93 | - Fix typo in Getting Started section [#13](https://github.com/miguelgrinberg/apifairy/issues/13) ([commit](https://github.com/miguelgrinberg/apifairy/commit/11bab4baf9f609c174ff8c7810a2f83f697257e5)) (thanks **Grey Li**!) 94 | - Fix typo in exception message [#20](https://github.com/miguelgrinberg/apifairy/issues/20) ([commit](https://github.com/miguelgrinberg/apifairy/commit/217a7fc976b860daa07199c297c7086b63e341be)) (thanks **Grey Li**!) 95 | - Add openapi-spec-validator into tests_require [#9](https://github.com/miguelgrinberg/apifairy/issues/9) ([commit](https://github.com/miguelgrinberg/apifairy/commit/faf551cd2bb224c33f5f6cfc94b2cb34a5249bf6)) (thanks **Grey Li**!) 96 | - Added missing import statements in documentation examples [#7](https://github.com/miguelgrinberg/apifairy/issues/7) ([commit](https://github.com/miguelgrinberg/apifairy/commit/316e0a5af3689947aa7d080c3c3aad87454235bd)) (thanks **Grey Li**!) 97 | - Move builds to GitHub actions ([commit](https://github.com/miguelgrinberg/apifairy/commit/b8cec62a7d719b6dd51b69dbf8f983b61459be94)) 98 | 99 | **Release 0.6.2** - 2020-10-10 100 | 101 | - Documentation updates ([commit](https://github.com/miguelgrinberg/apifairy/commit/ae72b2abc850ecf58c47603fac39fc92fd5c76ec)) 102 | 103 | **Release 0.6.1** - 2020-10-05 104 | 105 | - Fixed release script to include HTML templates 106 | - Rename blueprint to `apifairy` 107 | 108 | **Release 0.6.0** - 2020-10-03 109 | 110 | - More unit test coverage 111 | - Configuration through Flask's `config` object 112 | - Error handling 113 | 114 | **Release 0.5.0** - 2020-09-28 115 | 116 | - First public release! 117 | -------------------------------------------------------------------------------- /docs/decorators.rst: -------------------------------------------------------------------------------- 1 | .. APIFairy documentation master file, created by 2 | sphinx-quickstart on Sun Sep 27 17:34:58 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Decorator Reference 7 | =================== 8 | 9 | The core functionality of APIFairy is accessed through its five decorators, 10 | which are used to define what the inputs and outputs of each endpoint are. 11 | 12 | @arguments 13 | ---------- 14 | 15 | The ``arguments`` decorator specifies input arguments, given in the query 16 | string of the request URL. The only argument this decorator requires is the 17 | schema definition for the input data, which can be given as a schema class or 18 | instance:: 19 | 20 | from apifairy import arguments 21 | 22 | class PaginationSchema(ma.Schema): 23 | page = ma.Int(missing=1) 24 | limit = ma.Int(missing=10) 25 | 26 | @app.route('/api/user//followers') 27 | @arguments(PaginationSchema) 28 | def get_followers(pagination, id): 29 | page = pagination['page'] 30 | limit = pagination['limit'] 31 | # ... 32 | 33 | The decorator will deserialize and validate the input data and will only 34 | invoke the view function when the arguments are valid. In the case of a 35 | validation error, the error handler is invoked to generate an error response 36 | to the client. 37 | 38 | The deserialized input data is passed to the view function as a positional 39 | argument. Note that Flask passes path arguments as keyword arguments, so the 40 | argument from this decorator must be defined first, as seen in the example 41 | above. When multiple input decorators are used, the positional arguments are 42 | given in the same order as the decorators. 43 | 44 | Using multiple inputs 45 | ~~~~~~~~~~~~~~~~~~~~~ 46 | 47 | The ``arguments`` decorator can be given multiple times, but in that case the 48 | schemas must have their ``unknown`` attribute set to ``EXCLUDE``, so that the 49 | arguments from the different schemas are assigned properly:: 50 | 51 | class PaginationSchema(ma.Schema): 52 | page = ma.Int(missing=1) 53 | limit = ma.Int(missing=10) 54 | 55 | class FilterSchema(ma.Schema): 56 | f = ma.Str() 57 | 58 | @app.route('/api/user//followers') 59 | @arguments(PaginationSchema(unknown=ma.EXCLUDE)) 60 | @arguments(FilterSchema(unknown=ma.EXCLUDE)) 61 | def get_followers(pagination, filter, id): 62 | page = pagination['page'] 63 | limit = pagination['limit'] 64 | f = filter.get('filter') 65 | # ... 66 | 67 | Note that in this example the ``filter`` argument does not have ``missing`` or 68 | ``required`` attributes, so it will be considered optional. If the query string 69 | does not include it, then ``filter`` will be empty. 70 | 71 | Lists 72 | ~~~~~ 73 | 74 | A list can be defined in the usual way using Marshmallow's ``List`` field:: 75 | 76 | class Filter(ma.Schema): 77 | f = ma.List(ma.Str()) 78 | 79 | @app.route('/test') 80 | @arguments(Filter()) 81 | def test(filter): 82 | f = filter.get('f', []) 83 | # ... 84 | 85 | The client then must repeat the argument as many times as needed in the query 86 | string. For example, the URL *http://localhost:5000/test?f=foo&f=bar* would 87 | set the ``filter`` argument to ``{'f': ['foo', 'bar']}``. 88 | 89 | Advanced Usage 90 | ~~~~~~~~~~~~~~ 91 | 92 | The ``arguments`` decorator is a thin wrapper around the ``use_args`` 93 | decorator from the `webargs `_ project with 94 | the ``location`` argument set to ``query``. Any additional options are passed 95 | directly into ``use_args``, which among other things allow the use of other 96 | locations for input arguments besides the query string. 97 | 98 | @body 99 | ----- 100 | 101 | The ``body`` decorator defines the structure of the body of the request. The 102 | only required argument to this decorator is the schema definition for the 103 | request body, which can be given as a schema class or instance:: 104 | 105 | from apifairy import body 106 | 107 | class UserSchema(ma.Schema): 108 | id = ma.Int() 109 | username = ma.Str(required=True) 110 | email = ma.Str(required=True) 111 | about_me = ma.Str(missing='') 112 | 113 | @app.route('/users', methods=['POST']) 114 | @body(UserSchema) 115 | def create_user(user): 116 | # ... 117 | 118 | The decorator will deserialize and validate the input data and will only 119 | invoke the view function when the data passes validation. In the case of a 120 | validation error, the error handler is invoked to generate an error response 121 | to the client. 122 | 123 | The deserialized input data is passed to the view function as a positional 124 | argument. Note that Flask passes path arguments as keyword arguments, so the 125 | argument from this decorator must be defined first. When multiple input 126 | decorators are used, the positional arguments are given in the same order as 127 | the decorators. 128 | 129 | Forms 130 | ~~~~~ 131 | 132 | This decorator can also be used to configure an endpoint to accept form data, 133 | by adding the optional ``location`` argument set to ``form``:: 134 | 135 | from apifairy import body 136 | 137 | class UserSchema(ma.Schema): 138 | id = ma.Int() 139 | username = ma.Str(required=True) 140 | email = ma.Str(required=True) 141 | about_me = ma.Str(missing='') 142 | 143 | @app.route('/users', methods=['POST']) 144 | @body(UserSchema, location='form') 145 | def create_user(user): 146 | # ... 147 | 148 | File uploads can be declared with the ``FileField`` field type, which returns 149 | a standard ``FileStorage`` object from Flask:: 150 | 151 | from apifairy import body 152 | from apifairy.fields import FileField 153 | 154 | class UserSchema(ma.Schema): 155 | id = ma.Int() 156 | username = ma.Str(required=True) 157 | avatar = FileField() 158 | 159 | @app.route('/users', methods=['POST']) 160 | @body(UserSchema, location='form') 161 | def create_user(user): 162 | # ... 163 | 164 | The ``FileField`` field type can also be combined with Marshmallow's ``List`` 165 | to accept a list of files. But for this to work, the ``media_type`` argument 166 | needs to be added to the ``@body`` decorator to ensure that the request is 167 | parsed as a multipart form:: 168 | 169 | from apifairy import body 170 | from apifairy.fields import FileField 171 | 172 | class UserSchema(ma.Schema): 173 | id = ma.Int() 174 | username = ma.Str(required=True) 175 | files = ma.List(FileField()) 176 | 177 | @app.route('/users', methods=['POST']) 178 | @body(UserSchema, location='form', media_type='multipart/form-data') 179 | def create_user(user): 180 | # ... 181 | 182 | Advanced Usage 183 | ~~~~~~~~~~~~~~ 184 | 185 | The ``body`` decorator is a thin wrapper around the ``use_args`` decorator 186 | from the `webargs `_ project with 187 | the ``location`` argument set to ``json`` or ``form``. Any additional options 188 | are passed directly into ``use_args``. 189 | 190 | @response 191 | --------- 192 | 193 | The ``response`` decorator specifies the structure of the endpoint response. 194 | The only required argument to this decorator is the schema that defines the 195 | response, which can be given as a schema class or instance:: 196 | 197 | from apifairy import response 198 | 199 | @app.route('/users/') 200 | @response(UserSchema) 201 | def get_user(id): 202 | return User.query.get_or_404(id) 203 | 204 | The decorator performs the serialization of the returned object or dictionary 205 | to JSON through the schema's ``jsonify()`` method. 206 | 207 | This decorator accepts two optional arguments. The ``status_code`` argument is 208 | used to specify the HTTP status code for the response, when it is not the 209 | default of 200. The ``description`` argument is used to provide a text 210 | description of this response to be added to the documentation:: 211 | 212 | @app.route('/users', methods=['POST']) 213 | @body(UserSchema) 214 | @response(UserSchema, status_code=201, description='A user was created.') 215 | def create_user(user): 216 | # ... 217 | 218 | @other_responses 219 | ---------------- 220 | 221 | The ``other_responses`` decorator is used to specify additional responses the 222 | endpoint can return, usually as a result of an error condition. The only 223 | argument to this decorator is a dictionary with the keys set to numeric HTTP 224 | status codes. In its simplest form, the values of the dictionary are strings 225 | that describe each response:: 226 | 227 | from apifairy import response, other_responses 228 | 229 | @app.route('/users/') 230 | @response(UserSchema) 231 | @other_responses({400: 'Invalid request.', 404: 'User not found.'}) 232 | def get_user(id): 233 | # ... 234 | 235 | If desired a schema can be provided for each response instead:: 236 | 237 | from apifairy import response, other_responses 238 | 239 | @app.route('/users/') 240 | @response(UserSchema) 241 | @other_responses({400: BadRequestSchema, 404: UserNotFoundSchema}) 242 | def get_user(id): 243 | # ... 244 | 245 | Finally, a schema and a description can both be given as a tuple:: 246 | 247 | from apifairy import response, other_responses 248 | 249 | @app.route('/users/') 250 | @response(UserSchema) 251 | @other_responses({400: (BadRequestSchema, 'Invalid request.'), 252 | 404: (UserNotFoundSchema, 'User not found.')}) 253 | def get_user(id): 254 | # ... 255 | 256 | This decorator does not perform any validation or formatting of error 257 | responses, it just adds the information provided to the documentation. 258 | 259 | @authenticate 260 | ------------- 261 | 262 | The ``authenticate`` decorator is used to specify the authentication and 263 | authorization requirements of the endpoint. The only required argument for 264 | this decorator is an authentication object from the `Flask-HTTPAuth 265 | `_ extension:: 266 | 267 | from flask_httpauth import HTTPBasicAuth 268 | from apifairy import authenticate 269 | 270 | auth = HTTPBasicAuth() 271 | 272 | @app.route('/users/') 273 | @authenticate(auth) 274 | @response(UserSchema) 275 | def get_user(id): 276 | return User.query.get_or_404(id) 277 | 278 | The decorator invokes the ``login_required`` method of the authentication 279 | object, and also adds an Authentication section to the documentation. 280 | 281 | If the roles feature of Flask-HTTPAuth is used, the documentation will include 282 | the required role(s) for each endpoint. Any keyword arguments given to the 283 | ``authenticate`` decorator, including the ``role`` argument, are passed 284 | through to Flask-HTTPAuth. 285 | 286 | @webhook 287 | -------- 288 | 289 | The ``webhook`` decorator is used to document a webhook, which is an endpoint 290 | that must be implemented by the API client for the server to invoke as a 291 | callback or notification. OpenAPI added support for webhooks in its 3.1.0 292 | version. 293 | 294 | Webhooks are defined with a dummy function that is never invoked. After the 295 | ``webhook`` decorator is applied, the ``arguments``, ``body``, ``response`` 296 | and ``other_responses`` decorators can be used to document the inputs and 297 | outputs. 298 | 299 | Example:: 300 | 301 | from apifairy import webhook, body 302 | 303 | @webhook 304 | @body(ResultsSchema) 305 | def results(): 306 | pass 307 | 308 | The ``webhook`` decorator accepts three optional arguments. The ``method`` 309 | argument is used to specify the HTTP method that the server will use to invoke 310 | the webhook. If this argument is not specified, ``GET`` is used. 311 | 312 | The ``blueprint`` argument is used to optionally specify a blueprint with which 313 | this webhook should be grouped. This adds the a tag with the blueprint's name, 314 | which will make most documentation renderers add the webhook definition in the 315 | same section as the endpoints in the blueprint. 316 | 317 | The ``endpoint`` argument can be used to explicitly provide the endpoint name 318 | under which the webhook should be documented. If this argument is not given, 319 | the endpoint name is the name of the webhook function. 320 | 321 | The next example shows webhook definition using a ``POST`` HTTP method, added 322 | to a ``users`` blueprint:: 323 | 324 | from apifairy import webhook, body 325 | 326 | @webhook(method='POST', blueprint=users) 327 | @body(ResultsSchema) 328 | def results(): 329 | pass 330 | -------------------------------------------------------------------------------- /docs/guide.rst: -------------------------------------------------------------------------------- 1 | Documenting your API with APIFairy 2 | ================================== 3 | 4 | APIFairy can discover and document your API through its 5 | :ref:`decorators `, but in most cases you'll want to 6 | complement automatically generated documentation with manually written notes. 7 | The following sections describe all the places where APIFairy looks for text to 8 | attach to your project's documentation. 9 | 10 | Project Title and Version 11 | ------------------------- 12 | 13 | The title and version of your project are defined in the Flask configuration 14 | object:: 15 | 16 | app = Flask(__name__) 17 | app.config['APIFAIRY_TITLE'] = 'My API Project' 18 | app.config['APIFAIRY_VERSION'] = '1.0' 19 | 20 | Project Overview 21 | ---------------- 22 | 23 | Most API documentation sites include one or more sections that provide general 24 | project information for developers, such as how to authenticate, how 25 | pagination works, or what is the structure of error responses. APIFairy looks 26 | for project description text to attach to the documentation in module-level 27 | docstrings in all the packages and modules referenced in the Flask 28 | application's import name, starting from the right side. 29 | 30 | While different OpenAPI documentation renderers may have different expectations 31 | for the formatting of this text, it is fairly common for documentation to be 32 | written in Markdown format, with support for long, multi-line text. 33 | 34 | To help clarify how this works, consider a project with the following 35 | structure: 36 | 37 | - my_api_project/ 38 | - api/ 39 | - __init__.py 40 | - app.py 41 | - routes.py 42 | - project.py 43 | 44 | The contents of *project.py* are:: 45 | 46 | from api.app import create_app 47 | 48 | app = create_app() 49 | 50 | The contents of *api/app.py* are:: 51 | 52 | from flask import Flask 53 | from apifairy import APIFairy 54 | 55 | apifairy = APIFairy() 56 | 57 | def create_app(): 58 | app = Flask(__name__) 59 | app.config['APIFAIRY_TITLE'] = 'My API Project' 60 | app.config['APIFAIRY_VERSION'] = '1.0' 61 | apifairy.init_app(app) 62 | return app 63 | 64 | With this project structure, the import name of the Flask application is 65 | ``api.app``. In general, the import name of the application is the value that 66 | is passed as first argument to the ``Flask`` class. In most cases this is the 67 | ``__name__`` Python global variable, which represents the fully qualified 68 | package name of the module in which the application is defined. 69 | 70 | Following this example, APIFairy will first look for project-level 71 | documentation in the ``api.app`` module, which maps to the *api/app.py* file. 72 | Documentation can then be added at the top of this file, as follows:: 73 | 74 | """Welcome to My API Project! 75 | 76 | ## Project Overview 77 | 78 | This is the project overview. 79 | 80 | ## Authentication 81 | 82 | This is how authentication works. 83 | """ 84 | from flask import Flask 85 | from apifairy import APIFairy 86 | 87 | apifairy = APIFairy() 88 | 89 | def create_app(): 90 | app = Flask(__name__) 91 | app.config['APIFAIRY_TITLE'] = 'My API Project' 92 | app.config['APIFAIRY_VERSION'] = '1.0' 93 | apifairy.init_app(app) 94 | return app 95 | 96 | If APIFairy does not find a module docstring in ``api.app``, it will remove the 97 | last component of the import name and try again. Following this example, this 98 | would be ``api``, which is a package, so its docstring can be found in 99 | *api/__init__.py*. 100 | 101 | So the alternative to putting the documentation in *api/app.py* is to leave 102 | this file without a docstring, and instead add the documentation in 103 | *api/__init__.py*. 104 | 105 | Endpoints 106 | --------- 107 | 108 | To document an endpoint, add a docstring to its view function. The first line 109 | of the docstring should be a short summary of the endpoint's purpose. A longer 110 | description can be included starting from the second line. 111 | 112 | Example with just a summary:: 113 | 114 | @users.route('/users', methods=['POST']) 115 | @body(user_schema) 116 | @response(user_schema, 201) 117 | def new(args): 118 | """Register a new user""" 119 | user = User(**args) 120 | db.session.add(user) 121 | db.session.commit() 122 | return user 123 | 124 | Example with summary and longer description:: 125 | 126 | @users.route('/users', methods=['POST']) 127 | @body(user_schema) 128 | @response(user_schema, 201) 129 | def new(args): 130 | """Register a new user 131 | Clients can use this endpoint when they need to register a new user 132 | in the system. 133 | """ 134 | user = User(**args) 135 | db.session.add(user) 136 | db.session.commit() 137 | return user 138 | 139 | As with the project overview, these docstrings can also be written in Markdown. 140 | 141 | Path parameters 142 | --------------- 143 | 144 | For endpoints that have dynamic components in their path, APIFairy will 145 | automatically extract their type directly from the Flask route specification. 146 | A text description of a parameter can be included by adding a string as an 147 | annotation. 148 | 149 | Annotations have been evolving in recent releasees of Python, so the best 150 | format to provide documentation for endpoint parameters depends on which 151 | version of Python you are using. 152 | 153 | The basic method, which works with any recent version of Python, involves 154 | simply adding the documentation as a string annotation to the parameter:: 155 | 156 | @users.route('/users/', methods=['GET']) 157 | @authenticate(token_auth) 158 | @response(user_schema) 159 | def get(id: 'The id of the user to retrieve.'): # noqa: F722 160 | """Retrieve a user by id""" 161 | return db.session.get(User, id) or abort(404) 162 | 163 | While this method works, Python code linters and type checkers will flag the 164 | annotation as invalid, because they expect annotations to be used for type 165 | hints and not for documentation, so it may be necessary to add a ``noqa`` or 166 | similar comment for these errors to be ignored. 167 | 168 | If using Python 3.9 or newer, luckily there is a better option. The 169 | `typing.Annotated `_ 170 | type can be used to provide a type hint for the parameter along with additional 171 | metadata such as a documentation string:: 172 | 173 | from typing import Annotated 174 | 175 | @users.route('/users/', methods=['GET']) 176 | @authenticate(token_auth) 177 | @response(user_schema) 178 | def get(id: Annotated[int, 'The id of the user to retrieve.']): 179 | """Retrieve a user by id""" 180 | return db.session.get(User, id) or abort(404) 181 | 182 | Even if the project does not use type hints, using this format will prevent 183 | linting and typing errors, so it is the preferred way to document a parameter. 184 | 185 | Documentation for parameters can include multiple lines and paragraphs, if 186 | desired. Markdown formatting is also supported by most OpenAPI renderers. 187 | 188 | Schemas 189 | ------- 190 | 191 | Many of the :ref:`APIFairy decorators ` accept Marshmallow 192 | schemas as arguments. These schemas are automatically documented, including 193 | their field types and validation requirements. 194 | 195 | If the application wants to provide additional information, a schema 196 | description can be provided in the ``description`` field of the schema's 197 | metaclass:: 198 | 199 | class UserSchema(ma.SQLAlchemySchema): 200 | class Meta: 201 | model = User 202 | ordered = True 203 | description = 'This schema represents a user.' 204 | 205 | id = ma.auto_field(dump_only=True) 206 | url = ma.String(dump_only=True) 207 | username = ma.auto_field(required=True, 208 | validate=validate.Length(min=3, max=64)) 209 | 210 | Documentation that is specific to a schema field can be added in a 211 | ``description`` argument when the field is declared:: 212 | 213 | class UserSchema(ma.SQLAlchemySchema): 214 | class Meta: 215 | model = User 216 | ordered = True 217 | 218 | id = ma.auto_field(dump_only=True, description="The user's id.") 219 | url = ma.String(dump_only=True, description="The user's unique URL.") 220 | username = ma.auto_field(required=True, 221 | validate=validate.Length(min=3, max=64), 222 | description="The user's username.") 223 | 224 | Query String 225 | ------------ 226 | 227 | APIFairy will automatically document query string parameters for endpoints that 228 | use the :ref:`@arguments` decorator:: 229 | 230 | @users.route('/users', methods=['GET']) 231 | @arguments(pagination_schema) 232 | @response(users_schema) 233 | def get_users(pagination): 234 | """Retrieve all users""" 235 | # ... 236 | 237 | Request Headers 238 | --------------- 239 | 240 | APIFairy also documents request headers that are declared with the 241 | :ref:`@arguments` decorator. Note that this decorator defaults to the query 242 | string, but the `location` argument can be set to `headers` when needed. 243 | 244 | Example:: 245 | 246 | class HeadersSchema(ma.Schema): 247 | x_token = ma.String(data_key='X-Token', required=True) 248 | 249 | @users.route('/users', methods=['GET']) 250 | @arguments(HeadersSchema, location='headers') 251 | @response(users_schema) 252 | def get_users(headers): 253 | """Retrieve all users""" 254 | # ... 255 | 256 | The ``@arguments`` decorator can be given twice when an endpoint needs query 257 | string and header arguments both:: 258 | 259 | @users.route('/users', methods=['GET']) 260 | @arguments(PaginationSchema) 261 | @arguments(HeadersSchema, location='headers') 262 | @response(users_schema) 263 | def all(pagination, headers): 264 | """Retrieve all users""" 265 | # ... 266 | 267 | Responses 268 | --------- 269 | 270 | In addition to the schema documentation, an endpoint response can be given a 271 | text description in a ``description`` argument to the ``@response`` decorator. 272 | 273 | Example:: 274 | 275 | @tokens.route('/tokens', methods=['PUT']) 276 | @body(token_schema) 277 | @response(token_schema, description='Newly issued access and refresh tokens') 278 | def refresh(args): 279 | """Refresh an access token""" 280 | ... 281 | 282 | For endpoints that return information in response headers, the ``headers`` 283 | argument can be used to add these to the documentation:: 284 | 285 | class HeadersSchema(ma.Schema): 286 | x_token = ma.String(data_key='X-Token') 287 | 288 | @tokens.route('/tokens', methods=['PUT']) 289 | @body(token_schema) 290 | @response(token_schema, headers=HeadersSchema) 291 | def refresh(args): 292 | """Refresh an access token""" 293 | ... 294 | 295 | Error Responses 296 | --------------- 297 | 298 | The ``@other_responses`` decorator takes a dictionary argument, where the keys 299 | are the response status codes and the values provide the documentation. 300 | 301 | To add text descriptions to these responses, set the value for each status code 302 | to a descrition string. 303 | 304 | Example:: 305 | 306 | @tokens.route('/tokens', methods=['PUT']) 307 | @body(token_schema) 308 | @response(token_schema, description='Newly issued access and refresh tokens') 309 | @other_responses({401: 'Invalid access or refresh token', 310 | 403: 'Insufficient permissions'}) 311 | def refresh(args): 312 | """Refresh an access token""" 313 | ... 314 | 315 | 316 | To document the error response with a schema, set the value to the schema 317 | instance. 318 | 319 | Example:: 320 | 321 | @tokens.route('/tokens', methods=['PUT']) 322 | @body(token_schema) 323 | @response(token_schema, description='Newly issued access and refresh tokens') 324 | @other_responses({401: invalid_token_schema, 325 | 403: insufficient_permissions_schema}) 326 | def refresh(args): 327 | """Refresh an access token""" 328 | ... 329 | 330 | A schema and a description can both be given as a tuple:: 331 | 332 | @tokens.route('/tokens', methods=['PUT']) 333 | @body(token_schema) 334 | @response(token_schema, description='Newly issued access and refresh tokens') 335 | @other_responses({401: (invalid_token_schema, 'Invalid access or refresh token'), 336 | 403: (insufficient_permissions_schema, 'Insufficient permissions')}) 337 | def refresh(args): 338 | """Refresh an access token""" 339 | ... 340 | 341 | Authentication 342 | -------------- 343 | 344 | APIFairy recognizes the Flask-HTTPAuth authentication object passed to the 345 | ``@authenticate`` decorator and creates the appropriate structure according to 346 | the OpenAPI specification. To add textual documentation, define a subclass of 347 | the Flask-HTTPAuth authentication object and add a docstring with the 348 | documentation to it. 349 | 350 | Example:: 351 | 352 | from flask_httpauth import HTTPBasicAuth 353 | 354 | class DocumentedAuth(HTTPBasicAuth): 355 | """Basic authentication scheme.""" 356 | pass 357 | 358 | basic_auth = DocumentedAuth() 359 | 360 | @tokens.route('/tokens', methods=['POST']) 361 | @authenticate(basic_auth) 362 | @response(token_schema) 363 | @other_responses({401: 'Invalid username or password'}) 364 | def new(): 365 | """Create new access and refresh tokens""" 366 | ... 367 | 368 | Tags and Blueprints 369 | ------------------- 370 | 371 | APIFairy automatically creates OpenAPI tags for all the blueprints defined in 372 | the application, assigns each endpoint to the corresponding tag, and generates 373 | the OpenAPI documentation with the endpoints grouped by their tag. 374 | 375 | The order in which the groups appear can be controlled with the 376 | ``APIFAIRY_TAGS`` configuration variable, which is a list of the blueprint 377 | names in the desired order. Any names that are not included in this list will 378 | exclude the associated endpoints from the documentation. 379 | 380 | A textual description for each blueprint can be provided as a module-level 381 | docstring in the module in which the blueprint is defined. 382 | 383 | Anything else 384 | ------------- 385 | 386 | For any other documentation needs that are not covered by the options listed 387 | above, the application can manually modify the OpenAPI structure. This can be 388 | achieved in a function decorated with the ``@process_apispec`` decorator:: 389 | 390 | @apifairy.process_apispec 391 | def my_apispec_processor(spec): 392 | # modify spec as needed here 393 | return spec 394 | -------------------------------------------------------------------------------- /src/apifairy/core.py: -------------------------------------------------------------------------------- 1 | from json import dumps 2 | import re 3 | import sys 4 | try: 5 | from typing import _AnnotatedAlias 6 | except ImportError: # pragma: no cover 7 | _AnnotatedAlias = None 8 | 9 | from apispec import APISpec 10 | from apispec.ext.marshmallow import MarshmallowPlugin 11 | from flask import current_app, Blueprint, render_template, request 12 | from flask_marshmallow import fields 13 | try: 14 | from flask_marshmallow import sqla 15 | except ImportError: 16 | sqla = None 17 | try: 18 | from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth 19 | except ImportError: # pragma: no cover 20 | HTTPBasicAuth = None 21 | HTTPTokenAuth = None 22 | from packaging.version import Version 23 | from werkzeug.http import HTTP_STATUS_CODES 24 | 25 | from apifairy.decorators import _webhooks 26 | from apifairy.exceptions import ValidationError 27 | from apifairy import fields as apifairy_fields 28 | 29 | 30 | class APIFairy: 31 | def __init__(self, app=None): 32 | self.title = None 33 | self.version = None 34 | self.apispec_path = None 35 | self.ui = None 36 | self.ui_path = None 37 | self.tags = None 38 | 39 | self.apispec_callback = None 40 | self.error_handler_callback = self.default_error_handler 41 | self._apispec = None 42 | if app is not None: # pragma: no cover 43 | self.init_app(app) 44 | 45 | def init_app(self, app): 46 | self.title = app.config.get('APIFAIRY_TITLE', 'No title') 47 | self.version = app.config.get('APIFAIRY_VERSION', 'No version') 48 | self.apispec_path = app.config.get('APIFAIRY_APISPEC_PATH', 49 | '/apispec.json') 50 | self.apispec_version = app.config.get('APIFAIRY_APISPEC_VERSION', None) 51 | self.apispec_decorators = app.config.get( 52 | 'APIFAIRY_APISPEC_DECORATORS', []) 53 | self.ui = app.config.get('APIFAIRY_UI', 'redoc') 54 | self.ui_path = app.config.get('APIFAIRY_UI_PATH', '/docs') 55 | self.ui_decorators = app.config.get('APIFAIRY_UI_DECORATORS', []) 56 | self.tags = app.config.get('APIFAIRY_TAGS') 57 | 58 | bp = Blueprint('apifairy', __name__, template_folder='templates') 59 | 60 | if self.apispec_path: 61 | def json(): 62 | return dumps(self.apispec), 200, \ 63 | {'Content-Type': 'application/json'} 64 | 65 | for decorator in self.apispec_decorators: 66 | json = decorator(json) 67 | bp.add_url_rule(self.apispec_path, 'json', json) 68 | 69 | if self.ui_path: 70 | def docs(): 71 | return render_template(f'apifairy/{self.ui}.html', 72 | title=self.title, version=self.version) 73 | 74 | for decorator in self.ui_decorators: 75 | docs = decorator(docs) 76 | bp.add_url_rule(self.ui_path, 'docs', docs) 77 | 78 | if self.apispec_path or self.ui_path: # pragma: no cover 79 | app.register_blueprint(bp) 80 | 81 | @app.errorhandler(ValidationError) 82 | def http_error(error): 83 | return self.error_handler_callback(error.status_code, 84 | error.messages) 85 | 86 | def process_apispec(self, f): 87 | self.apispec_callback = f 88 | return f 89 | 90 | def error_handler(self, f): 91 | self.error_handler_callback = f 92 | return f 93 | 94 | def default_error_handler(self, status_code, messages): 95 | return {'messages': messages}, status_code 96 | 97 | @property 98 | def apispec(self): 99 | if self._apispec is None: 100 | self._apispec = self._generate_apispec() 101 | if self.apispec_callback: 102 | self._apispec = self.apispec_callback(self._apispec) 103 | return self._apispec 104 | 105 | def _generate_apispec(self): 106 | def resolver(schema): 107 | name = schema.__class__.__name__ 108 | if name.endswith("Schema"): 109 | name = name[:-6] or name 110 | if schema.partial: 111 | name += 'Update' 112 | return name 113 | 114 | # info object 115 | info = {} 116 | module_name = current_app.import_name 117 | while module_name: 118 | module = sys.modules[module_name] 119 | if module.__doc__: # pragma: no cover 120 | info['description'] = module.__doc__.strip() 121 | break 122 | if '.' not in module_name: 123 | module_name = '.' + module_name 124 | module_name = module_name.rsplit('.', 1)[0] 125 | 126 | # servers 127 | servers = [{'url': request.url_root}] 128 | 129 | # tags 130 | tag_names = self.tags 131 | if tag_names is None: # pragma: no branch 132 | # auto-generate tags from blueprints 133 | tag_names = [] 134 | for rule in current_app.url_map.iter_rules(): 135 | view_func = current_app.view_functions[rule.endpoint] 136 | if hasattr(view_func, '_spec'): 137 | if '.' in rule.endpoint: 138 | blueprint = rule.endpoint.rsplit('.', 1)[0] 139 | if blueprint not in tag_names: # pragma: no branch 140 | tag_names.append(blueprint) 141 | tags = {} 142 | for name, blueprint in current_app.blueprints.items(): 143 | if name not in tag_names: 144 | continue 145 | module = sys.modules[blueprint.import_name] 146 | tag = {'name': name.title()} 147 | if module.__doc__: # pragma: no cover 148 | tag['description'] = module.__doc__.strip() 149 | tags[name] = tag 150 | tag_list = [tags[name] for name in tag_names] 151 | ma_plugin = MarshmallowPlugin(schema_name_resolver=resolver) 152 | apispec_version = self.apispec_version 153 | if apispec_version is None: 154 | apispec_version = '3.1.0' if _webhooks else '3.0.3' 155 | version = Version(apispec_version) 156 | if version < Version('3.0.3'): 157 | raise RuntimeError("Must use at openapi version '3.0.3' or newer") 158 | elif version < Version('3.1.0') and _webhooks: 159 | raise RuntimeError("Must use at least openapi version '3.1.0' " 160 | 'when using the @webhook decorator') 161 | spec = APISpec( 162 | title=self.title, 163 | version=self.version, 164 | openapi_version=apispec_version, 165 | plugins=[ma_plugin], 166 | info=info, 167 | servers=servers, 168 | tags=tag_list, 169 | ) 170 | 171 | # configure flask-marshmallow URL types 172 | ma_plugin.converter.field_mapping[fields.URLFor] = ('string', 'url') 173 | ma_plugin.converter.field_mapping[fields.AbsoluteURLFor] = \ 174 | ('string', 'url') 175 | if sqla is not None: # pragma: no cover 176 | ma_plugin.converter.field_mapping[sqla.HyperlinkRelated] = \ 177 | ('string', 'url') 178 | 179 | # configure FileField 180 | ma_plugin.converter.field_mapping[apifairy_fields.FileField] = \ 181 | ('string', 'binary') 182 | 183 | # security schemes 184 | auth_schemes = [] 185 | auth_names = [] 186 | for rule in current_app.url_map.iter_rules(): 187 | view_func = current_app.view_functions[rule.endpoint] 188 | if hasattr(view_func, '_spec'): 189 | auth = view_func._spec.get('auth') 190 | if auth is not None and auth not in auth_schemes: 191 | auth_schemes.append(auth) 192 | if isinstance(auth, HTTPBasicAuth): 193 | name = 'basic_auth' 194 | elif isinstance(auth, HTTPTokenAuth): 195 | if auth.scheme == 'Bearer' and auth.header is None: 196 | name = 'token_auth' 197 | else: 198 | name = 'api_key' 199 | else: # pragma: no cover 200 | raise RuntimeError('Unknown authentication scheme') 201 | if name in auth_names: 202 | apispec_version = 2 203 | new_name = f'{name}_{apispec_version}' 204 | while new_name in auth_names: # pragma: no cover 205 | apispec_version += 1 206 | new_name = f'{name}_{apispec_version}' 207 | name = new_name 208 | auth_names.append(name) 209 | security = {} 210 | security_schemes = {} 211 | for name, auth in zip(auth_names, auth_schemes): 212 | security[auth] = name 213 | if isinstance(auth, HTTPTokenAuth): 214 | if auth.scheme == 'Bearer' and auth.header is None: 215 | security_schemes[name] = { 216 | 'type': 'http', 217 | 'scheme': 'bearer', 218 | } 219 | else: 220 | security_schemes[name] = { 221 | 'type': 'apiKey', 222 | 'name': auth.header, 223 | 'in': 'header', 224 | } 225 | else: 226 | security_schemes[name] = { 227 | 'type': 'http', 228 | 'scheme': 'basic', 229 | } 230 | if auth.__doc__: 231 | security_schemes[name]['description'] = auth.__doc__.strip() 232 | for prefix in ['basic_auth', 'token_auth', 'api_key']: 233 | for name, scheme in security_schemes.items(): 234 | if name.startswith(prefix): 235 | spec.components.security_scheme(name, scheme) 236 | 237 | # paths 238 | paths = {} 239 | rules = list(current_app.url_map.iter_rules()) 240 | rules = sorted(rules, key=lambda rule: len(rule.rule)) 241 | rules += _webhooks.values() 242 | for rule in rules: 243 | operations = {} 244 | is_endpoint = True # False for webhooks 245 | view_func = current_app.view_functions.get(rule.endpoint) 246 | if view_func is None: 247 | is_endpoint = False 248 | view_func = rule.view_func 249 | if not hasattr(view_func, '_spec'): 250 | continue 251 | if '.' in rule.endpoint: 252 | tag, endpoint = rule.endpoint.rsplit('.', 1) 253 | tag = tag.title() 254 | else: 255 | tag = None 256 | endpoint = rule.endpoint 257 | methods = [method for method in rule.methods 258 | if method in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']] 259 | for method in methods: 260 | operation_id = rule.endpoint.replace('.', '_') 261 | if len(methods) > 1: 262 | operation_id = method.lower() + '_' + operation_id 263 | operation = { 264 | 'operationId': operation_id, 265 | 'parameters': [ 266 | {'in': location, 'schema': schema} 267 | for schema, location in view_func._spec.get('args', []) 268 | if location != 'body' 269 | ], 270 | } 271 | if tag: 272 | operation['tags'] = [tag] 273 | docs = [line.strip() for line in ( 274 | view_func.__doc__ or '').strip().split('\n')] 275 | if docs[0]: 276 | operation['summary'] = docs[0] 277 | if len(docs) > 1: 278 | operation['description'] = '\n'.join(docs[1:]).strip() 279 | if view_func._spec.get('response'): 280 | code = str(view_func._spec['status_code']) 281 | operation['responses'] = { 282 | code: { 283 | 'content': { 284 | 'application/json': { 285 | 'schema': view_func._spec.get('response') 286 | } 287 | } 288 | } 289 | } 290 | operation['responses'][code]['description'] = \ 291 | view_func._spec['description'] or HTTP_STATUS_CODES[ 292 | int(code)] 293 | if view_func._spec.get('response_headers'): 294 | schema = view_func._spec.get('response_headers') 295 | if isinstance(schema, type): # pragma: no branch 296 | schema = schema() 297 | headers = ma_plugin.converter.schema2parameters( 298 | schema, location='headers') 299 | operation['responses'][code]['headers'] = { 300 | header['name']: header for header in headers} 301 | else: 302 | operation['responses'] = { 303 | '204': {'description': HTTP_STATUS_CODES[204]}} 304 | 305 | if view_func._spec.get('other_responses'): 306 | for status_code, response in view_func._spec.get( 307 | 'other_responses').items(): 308 | if not isinstance(response, (tuple, list)): 309 | response = (response,) 310 | operation['responses'][status_code] = {} 311 | for r in response: 312 | if isinstance(r, str): 313 | operation['responses'][status_code][ 314 | 'description'] = r 315 | else: 316 | if isinstance(r, type): 317 | r = r() # instantiate the schema 318 | operation['responses'][status_code][ 319 | 'content'] = { 320 | 'application/json': { 321 | 'schema': r 322 | } 323 | } 324 | if 'description' not in operation['responses'][ 325 | status_code]: 326 | operation['responses'][status_code][ 327 | 'description'] = HTTP_STATUS_CODES[ 328 | int(status_code)] 329 | 330 | if view_func._spec.get('body'): 331 | schema = view_func._spec.get('body')[0] 332 | location = view_func._spec.get('body')[1] 333 | media_type = view_func._spec.get('body')[2] 334 | if media_type is None and location == 'form': 335 | has_file = False 336 | for field in schema.dump_fields.values(): 337 | if isinstance(field, apifairy_fields.FileField): 338 | has_file = True 339 | break 340 | media_type = 'application/x-www-form-urlencoded' \ 341 | if not has_file else 'multipart/form-data' 342 | if media_type is None: 343 | media_type = 'application/json' 344 | operation['requestBody'] = { 345 | 'content': { 346 | media_type: { 347 | 'schema': schema, 348 | } 349 | }, 350 | 'required': True, 351 | } 352 | 353 | if view_func._spec.get('auth'): 354 | operation['security'] = [{ 355 | security[view_func._spec['auth']]: view_func._spec[ 356 | 'roles'] 357 | }] 358 | operations[method.lower()] = operation 359 | 360 | if is_endpoint: 361 | path_arguments = re.findall(r'<(([^<:]+:)?([^>]+))>', 362 | rule.rule) 363 | if path_arguments: 364 | annotations = view_func.__annotations__ or {} 365 | arguments = [] 366 | for _, type_, name in path_arguments: 367 | argument = { 368 | 'in': 'path', 369 | 'name': name, 370 | } 371 | if type_ == 'int:': 372 | argument['schema'] = {'type': 'integer'} 373 | elif type_ == 'float:': 374 | argument['schema'] = {'type': 'number'} 375 | else: 376 | argument['schema'] = {'type': 'string'} 377 | if isinstance(annotations.get(name), str): 378 | argument['description'] = annotations[name] 379 | elif _AnnotatedAlias and isinstance( 380 | annotations.get(name), _AnnotatedAlias): 381 | for annotation in annotations[name].__metadata__: 382 | if isinstance(annotation, str): 383 | argument['description'] = annotation 384 | break 385 | arguments.append(argument) 386 | 387 | for method, operation in operations.items(): 388 | operation['parameters'] = arguments + \ 389 | operation['parameters'] 390 | 391 | path = re.sub(r'<([^<:]+:)?', '{', rule.rule).replace('>', '}') 392 | if path not in paths: 393 | paths[path] = operations 394 | else: 395 | paths[path].update(operations) 396 | else: 397 | # apispec does not support webhooks, so here they are added as 398 | # paths, and later they are moved to their own section after 399 | # the spec is generated 400 | paths['webhook:' + endpoint] = operations 401 | for path, operations in paths.items(): 402 | # sort by method before adding them to the spec 403 | sorted_operations = {} 404 | for method in ['get', 'post', 'put', 'patch', 'delete']: 405 | if method in operations: 406 | sorted_operations[method] = operations[method] 407 | spec.path(path=path, operations=sorted_operations) 408 | 409 | spec = spec.to_dict() 410 | 411 | # extract webhooks from paths and add them to the webhooks section 412 | webhooks = { 413 | path[8:]: operations for path, operations in spec['paths'].items() 414 | if path.startswith('webhook:') 415 | } 416 | if webhooks: 417 | paths = { 418 | path: operations for path, operations in spec['paths'].items() 419 | if not path.startswith('webhook:') 420 | } 421 | spec['paths'] = paths 422 | spec['webhooks'] = webhooks 423 | return spec 424 | -------------------------------------------------------------------------------- /tests/test_apifairy.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | import sys 3 | try: 4 | from typing import Annotated 5 | except ImportError: 6 | Annotated = None 7 | import unittest 8 | import pytest 9 | 10 | from flask import Flask, Blueprint, request, session, abort 11 | from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth 12 | from flask_marshmallow import Marshmallow 13 | from marshmallow import EXCLUDE 14 | from openapi_spec_validator import validate_spec 15 | 16 | from apifairy import APIFairy, body, arguments, response, authenticate, \ 17 | other_responses, webhook, FileField 18 | 19 | ma = Marshmallow() 20 | 21 | 22 | class Schema(ma.Schema): 23 | class Meta: 24 | unknown = EXCLUDE 25 | 26 | id = ma.Integer(dump_default=123) 27 | name = ma.Str(required=True) 28 | 29 | 30 | class Schema2(ma.Schema): 31 | class Meta: 32 | unknown = EXCLUDE 33 | 34 | id2 = ma.Integer(dump_default=123) 35 | name2 = ma.Str(required=True) 36 | 37 | 38 | class FooSchema(ma.Schema): 39 | id = ma.Integer(dump_default=123) 40 | name = ma.Str() 41 | 42 | 43 | class QuerySchema(ma.Schema): 44 | id = ma.Integer(load_default=1) 45 | 46 | 47 | class FormSchema(ma.Schema): 48 | csrf = ma.Str(required=True) 49 | name = ma.Str(required=True) 50 | age = ma.Int() 51 | 52 | 53 | class FormUploadSchema(ma.Schema): 54 | name = ma.Str() 55 | file = FileField(required=True) 56 | 57 | 58 | class FormUploadSchema2(ma.Schema): 59 | name = ma.Str() 60 | files = ma.List(FileField(), required=True) 61 | 62 | 63 | class HeaderSchema(ma.Schema): 64 | x_token = ma.Str(data_key='X-Token', required=True) 65 | 66 | 67 | class TestAPIFairy(unittest.TestCase): 68 | def create_app(self, config=None): 69 | app = Flask(__name__) 70 | app.config['APIFAIRY_TITLE'] = 'Foo' 71 | app.config['APIFAIRY_VERSION'] = '1.0' 72 | if config: 73 | app.config.update(config) 74 | ma.init_app(app) 75 | apifairy = APIFairy(app) 76 | return app, apifairy 77 | 78 | def test_apispec(self): 79 | app, apifairy = self.create_app() 80 | auth = HTTPBasicAuth() 81 | 82 | @apifairy.process_apispec 83 | def edit_apispec(apispec): 84 | assert apispec['openapi'] == '3.0.3' 85 | apispec['openapi'] = '3.0.2' 86 | return apispec 87 | 88 | @auth.verify_password 89 | def verify_password(username, password): 90 | if username == 'foo' and password == 'bar': 91 | return {'user': 'foo'} 92 | elif username == 'bar' and password == 'foo': 93 | return {'user': 'bar'} 94 | 95 | @auth.get_user_roles 96 | def get_roles(user): 97 | if user['user'] == 'bar': 98 | return 'admin' 99 | return 'normal' 100 | 101 | @app.route('/foo') 102 | @authenticate(auth) 103 | @arguments(QuerySchema) 104 | @body(Schema) 105 | @response(Schema) 106 | @other_responses({404: 'foo not found'}) 107 | def foo(): 108 | return {'id': 123, 'name': auth.current_user()['user']} 109 | 110 | client = app.test_client() 111 | 112 | rv = client.get('/apispec.json') 113 | assert rv.status_code == 200 114 | validate_spec(rv.json) 115 | assert rv.json['openapi'] == '3.0.2' 116 | assert rv.json['info']['title'] == 'Foo' 117 | assert rv.json['info']['version'] == '1.0' 118 | 119 | assert apifairy.apispec is apifairy.apispec 120 | 121 | rv = client.get('/docs') 122 | assert rv.status_code == 200 123 | assert b'redoc.standalone.js' in rv.data 124 | 125 | def test_custom_apispec_path(self): 126 | app, _ = self.create_app(config={'APIFAIRY_APISPEC_PATH': '/foo'}) 127 | 128 | client = app.test_client() 129 | rv = client.get('/apispec.json') 130 | assert rv.status_code == 404 131 | rv = client.get('/foo') 132 | assert rv.status_code == 200 133 | assert set(rv.json.keys()) == { 134 | 'openapi', 'info', 'servers', 'paths', 'tags'} 135 | 136 | def test_no_apispec_path(self): 137 | app, _ = self.create_app(config={'APIFAIRY_APISPEC_PATH': None}) 138 | 139 | client = app.test_client() 140 | rv = client.get('/apispec.json') 141 | assert rv.status_code == 404 142 | 143 | def test_custom_apispec_version(self): 144 | app, _ = self.create_app(config={'APIFAIRY_APISPEC_VERSION': '3.1.0'}) 145 | 146 | client = app.test_client() 147 | rv = client.get('/apispec.json') 148 | assert rv.status_code == 200 149 | assert set(rv.json.keys()) == { 150 | 'openapi', 'info', 'servers', 'paths', 'tags'} 151 | assert rv.json['openapi'] == '3.1.0' 152 | 153 | def test_custom_apispec_default_version(self): 154 | app, _ = self.create_app() 155 | 156 | client = app.test_client() 157 | rv = client.get('/apispec.json') 158 | assert rv.status_code == 200 159 | assert set(rv.json.keys()) == { 160 | 'openapi', 'info', 'servers', 'paths', 'tags'} 161 | assert rv.json['openapi'] == '3.0.3' 162 | 163 | def test_custom_apispec_invalid_version_old(self): 164 | app, _ = self.create_app( 165 | config={'APIFAIRY_APISPEC_VERSION': '3.0.2'}) 166 | 167 | client = app.test_client() 168 | rv = client.get('/apispec.json') 169 | assert rv.status_code == 500 170 | 171 | def test_custom_apispec_invalid_version_new(self): 172 | app, _ = self.create_app( 173 | config={'APIFAIRY_APISPEC_VERSION': '6.1.0'}) 174 | 175 | client = app.test_client() 176 | rv = client.get('/apispec.json') 177 | assert rv.status_code == 500 178 | 179 | def test_custom_apispec_non_semver_version(self): 180 | app, _ = self.create_app( 181 | config={'APIFAIRY_APISPEC_VERSION': 'invalid'}) 182 | 183 | client = app.test_client() 184 | rv = client.get('/apispec.json') 185 | assert rv.status_code == 500 186 | 187 | def test_ui(self): 188 | app, _ = self.create_app(config={'APIFAIRY_UI': 'swagger_ui'}) 189 | 190 | client = app.test_client() 191 | rv = client.get('/docs') 192 | assert rv.status_code == 200 193 | assert b'redoc.standalone.js' not in rv.data 194 | assert b'swagger-ui-bundle.js' in rv.data 195 | 196 | def test_custom_ui_path(self): 197 | app, _ = self.create_app(config={'APIFAIRY_UI_PATH': '/foo'}) 198 | 199 | client = app.test_client() 200 | rv = client.get('/docs') 201 | assert rv.status_code == 404 202 | rv = client.get('/foo') 203 | assert rv.status_code == 200 204 | assert b'redoc.standalone.js' in rv.data 205 | 206 | def test_no_ui_path(self): 207 | app, _ = self.create_app(config={'APIFAIRY_UI_PATH': None}) 208 | 209 | client = app.test_client() 210 | rv = client.get('/docs') 211 | assert rv.status_code == 404 212 | 213 | def test_apispec_ui_decorators(self): 214 | def auth(f): 215 | def wrapper(*args, **kwargs): 216 | if request.headers.get('X-Token') != 'foo' and \ 217 | session.get('X-Token') != 'foo': 218 | abort(401) 219 | return f(*args, **kwargs) 220 | return wrapper 221 | 222 | def more_auth(f): 223 | def wrapper(*args, **kwargs): 224 | if request.headers.get('X-Key') != 'bar': 225 | abort(401) 226 | session['X-Token'] = 'foo' 227 | return f(*args, **kwargs) 228 | return wrapper 229 | 230 | app, apifairy = self.create_app(config={ 231 | 'APIFAIRY_APISPEC_DECORATORS': [auth], 232 | 'APIFAIRY_UI_DECORATORS': [auth, more_auth]}) 233 | app.secret_key = 'secret' 234 | 235 | client = app.test_client() 236 | rv = client.get('/apispec.json') 237 | assert rv.status_code == 401 238 | rv = client.get('/docs') 239 | assert rv.status_code == 401 240 | rv = client.get('/apispec.json', headers={'X-Token': 'foo'}) 241 | assert rv.status_code == 200 242 | rv = client.get('/docs', headers={'X-Key': 'bar'}) 243 | assert rv.status_code == 200 244 | 245 | def test_body(self): 246 | app, _ = self.create_app() 247 | 248 | @app.route('/foo', methods=['POST']) 249 | @body(Schema()) 250 | def foo(schema): 251 | return schema 252 | 253 | client = app.test_client() 254 | 255 | rv = client.post('/foo') 256 | assert rv.status_code == 400 257 | assert rv.json == { 258 | 'messages': { 259 | 'json': {'name': ['Missing data for required field.']} 260 | } 261 | } 262 | 263 | rv = client.post('/foo', json={'id': 1}) 264 | assert rv.status_code == 400 265 | assert rv.json == { 266 | 'messages': { 267 | 'json': {'name': ['Missing data for required field.']} 268 | } 269 | } 270 | 271 | rv = client.post('/foo', json={'id': 1, 'name': 'bar'}) 272 | assert rv.status_code == 200 273 | assert rv.json == {'id': 1, 'name': 'bar'} 274 | 275 | rv = client.post('/foo', json={'name': 'bar'}) 276 | assert rv.status_code == 200 277 | assert rv.json == {'name': 'bar'} 278 | 279 | def test_body_form(self): 280 | app, _ = self.create_app() 281 | 282 | @app.route('/form', methods=['POST']) 283 | @body(FormSchema(), location='form') 284 | def foo(schema): 285 | return schema 286 | 287 | client = app.test_client() 288 | 289 | rv = client.post('/form') 290 | assert rv.status_code == 400 291 | assert rv.json == { 292 | 'messages': { 293 | 'form': { 294 | 'csrf': ['Missing data for required field.'], 295 | 'name': ['Missing data for required field.'], 296 | } 297 | } 298 | } 299 | 300 | rv = client.post('/form', data={'csrf': 'foo', 'age': '12'}) 301 | assert rv.status_code == 400 302 | assert rv.json == { 303 | 'messages': { 304 | 'form': {'name': ['Missing data for required field.']} 305 | } 306 | } 307 | 308 | rv = client.post('/form', data={'csrf': 'foo', 'name': 'bar'}) 309 | assert rv.status_code == 200 310 | assert rv.json == {'csrf': 'foo', 'name': 'bar'} 311 | 312 | rv = client.post('/form', data={'csrf': 'foo', 'name': 'bar', 313 | 'age': '12'}) 314 | assert rv.status_code == 200 315 | assert rv.json == {'csrf': 'foo', 'name': 'bar', 'age': 12} 316 | 317 | def test_body_form_upload(self): 318 | app, _ = self.create_app() 319 | 320 | @app.route('/form', methods=['POST']) 321 | @body(FormUploadSchema(), location='form') 322 | def foo(schema): 323 | return {'name': schema.get('name'), 324 | 'len': len(schema['file'].read())} 325 | 326 | client = app.test_client() 327 | 328 | rv = client.post('/form') 329 | assert rv.status_code == 400 330 | assert rv.json == { 331 | 'messages': { 332 | 'form': {'file': ['Missing data for required field.']} 333 | } 334 | } 335 | 336 | rv = client.post('/form', data={'name': 'foo'}, 337 | content_type='multipart/form-data') 338 | assert rv.status_code == 400 339 | assert rv.json == { 340 | 'messages': { 341 | 'form': {'file': ['Missing data for required field.']} 342 | } 343 | } 344 | 345 | rv = client.post('/form', data={'file': 'foo'}, 346 | content_type='multipart/form-data') 347 | assert rv.status_code == 400 348 | assert rv.json == { 349 | 'messages': { 350 | 'form': {'file': ['Not a file.']} 351 | } 352 | } 353 | 354 | rv = client.post('/form', data={'name': 'foo', 355 | 'file': (BytesIO(b'bar'), 'test.txt')}) 356 | assert rv.status_code == 200 357 | assert rv.json == {'name': 'foo', 'len': 3} 358 | 359 | rv = client.post('/form', data={'file': (BytesIO(b'bar'), 'test.txt')}) 360 | assert rv.status_code == 200 361 | assert rv.json == {'name': None, 'len': 3} 362 | 363 | def test_body_custom_error_handler(self): 364 | app, apifairy = self.create_app() 365 | 366 | @apifairy.error_handler 367 | def error_handler(status_code, messages): 368 | return {'errors': messages}, status_code 369 | 370 | @app.route('/foo', methods=['POST']) 371 | @body(Schema()) 372 | def foo(schema): 373 | return schema 374 | 375 | client = app.test_client() 376 | 377 | rv = client.post('/foo') 378 | assert rv.status_code == 400 379 | assert rv.json == { 380 | 'errors': { 381 | 'json': {'name': ['Missing data for required field.']} 382 | } 383 | } 384 | 385 | def test_query(self): 386 | app, _ = self.create_app() 387 | 388 | @app.route('/foo', methods=['POST']) 389 | @arguments(Schema()) 390 | @arguments(Schema2()) 391 | def foo(schema, schema2): 392 | return {'name': schema['name'], 'name2': schema2['name2']} 393 | 394 | client = app.test_client() 395 | 396 | rv = client.post('/foo') 397 | assert rv.status_code == 400 398 | assert rv.json == { 399 | 'messages': { 400 | 'query': {'name': ['Missing data for required field.']} 401 | } 402 | } 403 | 404 | rv = client.post('/foo?id=1&name=bar') 405 | assert rv.status_code == 400 406 | assert rv.json == { 407 | 'messages': { 408 | 'query': {'name2': ['Missing data for required field.']} 409 | } 410 | } 411 | 412 | rv = client.post('/foo?id=1&name=bar&id2=2&name2=baz') 413 | assert rv.status_code == 200 414 | assert rv.json == {'name': 'bar', 'name2': 'baz'} 415 | 416 | rv = client.post('/foo?name=bar&name2=baz') 417 | assert rv.status_code == 200 418 | assert rv.json == {'name': 'bar', 'name2': 'baz'} 419 | 420 | def test_response(self): 421 | app, _ = self.create_app() 422 | 423 | @app.route('/foo') 424 | @response(Schema()) 425 | def foo(): 426 | return {'name': 'bar'} 427 | 428 | @app.route('/bar') 429 | @response(Schema(), status_code=201) 430 | def bar(): 431 | return {'name': 'foo'} 432 | 433 | @app.route('/baz') 434 | @arguments(QuerySchema) 435 | @response(Schema(), status_code=201) 436 | def baz(query): 437 | if query['id'] == 1: 438 | return {'name': 'foo'}, 202 439 | elif query['id'] == 2: 440 | return {'name': 'foo'}, {'Location': '/baz'} 441 | elif query['id'] == 3: 442 | return {'name': 'foo'}, 202, {'Location': '/baz'} 443 | return ({'name': 'foo'},) 444 | 445 | client = app.test_client() 446 | 447 | rv = client.get('/foo') 448 | assert rv.status_code == 200 449 | assert rv.json == {'id': 123, 'name': 'bar'} 450 | 451 | rv = client.get('/bar') 452 | assert rv.status_code == 201 453 | assert rv.json == {'id': 123, 'name': 'foo'} 454 | 455 | rv = client.get('/baz') 456 | assert rv.status_code == 202 457 | assert rv.json == {'id': 123, 'name': 'foo'} 458 | assert 'Location' not in rv.headers 459 | 460 | rv = client.get('/baz?id=2') 461 | assert rv.status_code == 201 462 | assert rv.json == {'id': 123, 'name': 'foo'} 463 | assert rv.headers['Location'] in ['http://localhost/baz', '/baz'] 464 | 465 | rv = client.get('/baz?id=3') 466 | assert rv.status_code == 202 467 | assert rv.json == {'id': 123, 'name': 'foo'} 468 | assert rv.headers['Location'] in ['http://localhost/baz', '/baz'] 469 | 470 | rv = client.get('/baz?id=4') 471 | assert rv.status_code == 201 472 | assert rv.json == {'id': 123, 'name': 'foo'} 473 | assert 'Location' not in rv.headers 474 | 475 | def test_basic_auth(self): 476 | app, _ = self.create_app() 477 | auth = HTTPBasicAuth() 478 | 479 | @auth.verify_password 480 | def verify_password(username, password): 481 | if username == 'foo' and password == 'bar': 482 | return {'user': 'foo'} 483 | elif username == 'bar' and password == 'foo': 484 | return {'user': 'bar'} 485 | 486 | @auth.get_user_roles 487 | def get_roles(user): 488 | if user['user'] == 'bar': 489 | return 'admin' 490 | return 'normal' 491 | 492 | @app.route('/foo') 493 | @authenticate(auth) 494 | def foo(): 495 | return auth.current_user() 496 | 497 | @app.route('/bar') 498 | @authenticate(auth, role='admin') 499 | def bar(): 500 | return auth.current_user() 501 | 502 | client = app.test_client() 503 | 504 | rv = client.get('/foo') 505 | assert rv.status_code == 401 506 | 507 | rv = client.get('/foo', 508 | headers={'Authorization': 'Basic Zm9vOmJhcg=='}) 509 | assert rv.status_code == 200 510 | assert rv.json == {'user': 'foo'} 511 | 512 | rv = client.get('/bar', 513 | headers={'Authorization': 'Basic Zm9vOmJhcg=='}) 514 | assert rv.status_code == 403 515 | 516 | rv = client.get('/foo', 517 | headers={'Authorization': 'Basic YmFyOmZvbw=='}) 518 | assert rv.status_code == 200 519 | assert rv.json == {'user': 'bar'} 520 | 521 | rv = client.get('/bar', 522 | headers={'Authorization': 'Basic YmFyOmZvbw=='}) 523 | assert rv.status_code == 200 524 | assert rv.json == {'user': 'bar'} 525 | 526 | rv = client.get('/apispec.json') 527 | assert rv.status_code == 200 528 | assert rv.json['components']['securitySchemes'] == { 529 | 'basic_auth': {'scheme': 'basic', 'type': 'http'}, 530 | } 531 | assert rv.json['paths']['/foo']['get']['security'] == [ 532 | {'basic_auth': []}] 533 | assert rv.json['paths']['/bar']['get']['security'] == [ 534 | {'basic_auth': ['admin']}] 535 | 536 | def test_token_auth(self): 537 | app, _ = self.create_app() 538 | auth = HTTPTokenAuth() 539 | 540 | @auth.verify_token 541 | def verify_token(token): 542 | if token == 'foo': 543 | return {'user': 'foo'} 544 | elif token == 'bar': 545 | return {'user': 'bar'} 546 | 547 | @auth.get_user_roles 548 | def get_roles(user): 549 | if user['user'] == 'bar': 550 | return 'admin' 551 | return 'normal' 552 | 553 | @app.route('/foo') 554 | @authenticate(auth) 555 | def foo(): 556 | return auth.current_user() 557 | 558 | @app.route('/bar') 559 | @authenticate(auth, role='admin') 560 | def bar(): 561 | return auth.current_user() 562 | 563 | client = app.test_client() 564 | 565 | rv = client.get('/foo') 566 | assert rv.status_code == 401 567 | 568 | rv = client.get('/foo', 569 | headers={'Authorization': 'Bearer foo'}) 570 | assert rv.status_code == 200 571 | assert rv.json == {'user': 'foo'} 572 | 573 | rv = client.get('/bar', 574 | headers={'Authorization': 'Bearer foo'}) 575 | assert rv.status_code == 403 576 | 577 | rv = client.get('/foo', 578 | headers={'Authorization': 'Bearer bar'}) 579 | assert rv.status_code == 200 580 | assert rv.json == {'user': 'bar'} 581 | 582 | rv = client.get('/bar', 583 | headers={'Authorization': 'Bearer bar'}) 584 | assert rv.status_code == 200 585 | assert rv.json == {'user': 'bar'} 586 | 587 | rv = client.get('/apispec.json') 588 | assert rv.status_code == 200 589 | assert rv.json['components']['securitySchemes'] == { 590 | 'token_auth': {'scheme': 'bearer', 'type': 'http'}, 591 | } 592 | assert rv.json['paths']['/foo']['get']['security'] == [ 593 | {'token_auth': []}] 594 | assert rv.json['paths']['/bar']['get']['security'] == [ 595 | {'token_auth': ['admin']}] 596 | 597 | def test_multiple_auth(self): 598 | app, _ = self.create_app() 599 | auth = HTTPTokenAuth() 600 | auth.__doc__ = 'auth documentation' 601 | auth2 = HTTPTokenAuth(header='X-Token') 602 | 603 | class MyHTTPTokenAuth(HTTPTokenAuth): 604 | """custom auth documentation""" 605 | pass 606 | 607 | auth3 = MyHTTPTokenAuth() 608 | 609 | @app.route('/foo') 610 | @authenticate(auth) 611 | def foo(): 612 | return auth.current_user() 613 | 614 | @app.route('/bar') 615 | @authenticate(auth2) 616 | def bar(): 617 | return auth.current_user() 618 | 619 | @app.route('/baz') 620 | @authenticate(auth3) 621 | def baz(): 622 | return auth.current_user() 623 | 624 | client = app.test_client() 625 | 626 | rv = client.get('/apispec.json') 627 | assert rv.status_code == 200 628 | assert rv.json['components']['securitySchemes'] == { 629 | 'token_auth': {'scheme': 'bearer', 'type': 'http', 630 | 'description': 'auth documentation'}, 631 | 'token_auth_2': {'scheme': 'bearer', 'type': 'http', 632 | 'description': 'custom auth documentation'}, 633 | 'api_key': {'type': 'apiKey', 'name': 'X-Token', 'in': 'header'}, 634 | } 635 | assert rv.json['paths']['/foo']['get']['security'] == [ 636 | {'token_auth': []}] 637 | assert rv.json['paths']['/bar']['get']['security'] == [ 638 | {'api_key': []}] 639 | assert rv.json['paths']['/baz']['get']['security'] == [ 640 | {'token_auth_2': []}] 641 | 642 | def test_apispec_schemas(self): 643 | app, apifairy = self.create_app() 644 | 645 | @app.route('/foo') 646 | @response(Schema(partial=True)) 647 | def foo(): 648 | pass 649 | 650 | @app.route('/bar') 651 | @response(Schema2(many=True)) 652 | def bar(): 653 | pass 654 | 655 | @app.route('/baz') 656 | @response(FooSchema) 657 | def baz(): 658 | pass 659 | 660 | with app.test_request_context(): 661 | apispec = apifairy.apispec 662 | assert len(apispec['components']['schemas']) == 3 663 | assert 'SchemaUpdate' in apispec['components']['schemas'] 664 | assert 'Schema2' in apispec['components']['schemas'] 665 | assert 'Foo' in apispec['components']['schemas'] 666 | 667 | def test_endpoints(self): 668 | app, apifairy = self.create_app() 669 | 670 | @app.route('/users') 671 | @response(Schema) 672 | def get_users(): 673 | """get users.""" 674 | pass 675 | 676 | @app.route('/users', methods=['POST', 'PUT']) 677 | @body(FormSchema, location='form') 678 | @response(Schema, status_code=201) 679 | @other_responses({400: (Schema2, 'bad request'), 680 | 401: ('unauthorized', FooSchema()), 681 | 403: 'forbidden', 682 | 404: Schema2(many=True)}) 683 | def new_user(): 684 | """new user. 685 | modify user. 686 | """ 687 | pass 688 | 689 | @app.route('/upload', methods=['POST']) 690 | @body(FormUploadSchema, location='form') 691 | def upload(): 692 | """upload file.""" 693 | pass 694 | 695 | @app.route('/uploads', methods=['POST']) 696 | @body(FormUploadSchema2, location='form', 697 | media_type='multipart/form-data') 698 | def uploads(): 699 | """upload files.""" 700 | pass 701 | 702 | @app.route('/tokens', methods=['POST']) 703 | @response(Schema, headers=HeaderSchema) 704 | def token(): 705 | """get a token.""" 706 | pass 707 | 708 | client = app.test_client() 709 | 710 | rv = client.get('/apispec.json') 711 | assert rv.status_code == 200 712 | 713 | assert rv.json['paths']['/users']['get']['operationId'] == 'get_users' 714 | assert list(rv.json['paths']['/users']['get']['responses']) == ['200'] 715 | assert rv.json['paths']['/users']['get']['summary'] == 'get users.' 716 | assert 'description' not in rv.json['paths']['/users']['get'] 717 | 718 | assert rv.json['paths']['/users']['post']['operationId'] == \ 719 | 'post_new_user' 720 | assert list(rv.json['paths']['/users']['post']['responses']) == \ 721 | ['201', '400', '401', '403', '404'] 722 | assert rv.json['paths']['/users']['post']['summary'] == 'new user.' 723 | assert rv.json['paths']['/users']['post']['description'] == \ 724 | 'modify user.' 725 | assert 'application/x-www-form-urlencoded' in \ 726 | rv.json['paths']['/users']['post']['requestBody']['content'] 727 | 728 | assert rv.json['paths']['/users']['put']['operationId'] == \ 729 | 'put_new_user' 730 | assert list(rv.json['paths']['/users']['put']['responses']) == \ 731 | ['201', '400', '401', '403', '404'] 732 | assert rv.json['paths']['/users']['put']['summary'] == 'new user.' 733 | assert rv.json['paths']['/users']['put']['description'] == \ 734 | 'modify user.' 735 | 736 | assert rv.json['paths']['/upload']['post']['operationId'] == 'upload' 737 | assert list(rv.json['paths']['/upload']['post']['responses']) == \ 738 | ['204'] 739 | assert rv.json['paths']['/upload']['post']['summary'] == 'upload file.' 740 | assert 'description' not in rv.json['paths']['/upload']['post'] 741 | assert 'multipart/form-data' in \ 742 | rv.json['paths']['/upload']['post']['requestBody']['content'] 743 | 744 | assert rv.json['paths']['/uploads']['post']['operationId'] == 'uploads' 745 | assert list(rv.json['paths']['/uploads']['post']['responses']) == \ 746 | ['204'] 747 | assert rv.json['paths']['/uploads']['post']['summary'] == \ 748 | 'upload files.' 749 | assert 'description' not in rv.json['paths']['/uploads']['post'] 750 | assert 'multipart/form-data' in \ 751 | rv.json['paths']['/uploads']['post']['requestBody']['content'] 752 | 753 | assert rv.json['paths']['/tokens']['post']['operationId'] == 'token' 754 | assert list(rv.json['paths']['/tokens']['post']['responses']) == \ 755 | ['200'] 756 | assert rv.json['paths']['/tokens']['post']['summary'] == 'get a token.' 757 | assert 'description' not in rv.json['paths']['/tokens']['post'] 758 | assert 'headers' in \ 759 | rv.json['paths']['/tokens']['post']['responses']['200'] 760 | assert 'X-Token' in \ 761 | rv.json['paths']['/tokens']['post']['responses']['200']['headers'] 762 | 763 | r201 = { 764 | 'content': { 765 | 'application/json': { 766 | 'schema': {'$ref': '#/components/schemas/Schema'} 767 | } 768 | }, 769 | 'description': 'Created' 770 | } 771 | assert rv.json['paths']['/users']['post']['responses']['201'] == r201 772 | assert rv.json['paths']['/users']['put']['responses']['201'] == r201 773 | 774 | r400 = { 775 | 'content': { 776 | 'application/json': { 777 | 'schema': {'$ref': '#/components/schemas/Schema2'} 778 | } 779 | }, 780 | 'description': 'bad request' 781 | } 782 | assert rv.json['paths']['/users']['post']['responses']['400'] == r400 783 | assert rv.json['paths']['/users']['put']['responses']['400'] == r400 784 | 785 | r401 = { 786 | 'content': { 787 | 'application/json': { 788 | 'schema': {'$ref': '#/components/schemas/Foo'} 789 | } 790 | }, 791 | 'description': 'unauthorized' 792 | } 793 | assert rv.json['paths']['/users']['post']['responses']['401'] == r401 794 | assert rv.json['paths']['/users']['put']['responses']['401'] == r401 795 | 796 | r403 = {'description': 'forbidden'} 797 | assert rv.json['paths']['/users']['post']['responses']['403'] == r403 798 | assert rv.json['paths']['/users']['put']['responses']['403'] == r403 799 | 800 | r404 = { 801 | 'content': { 802 | 'application/json': { 803 | 'schema': { 804 | 'items': {'$ref': '#/components/schemas/Schema2'}, 805 | 'type': 'array' 806 | } 807 | } 808 | }, 809 | 'description': 'Not Found' 810 | } 811 | assert rv.json['paths']['/users']['post']['responses']['404'] == r404 812 | assert rv.json['paths']['/users']['put']['responses']['404'] == r404 813 | 814 | def test_apispec_path_parameters(self): 815 | app, apifairy = self.create_app() 816 | 817 | @app.route('/strings/') 818 | @response(Schema) 819 | def get_string(some_string: 'some_string docs'): # noqa: F722 820 | pass 821 | 822 | @app.route('/floats/', methods=['POST']) 823 | @response(Schema) 824 | def get_float(some_float: float): 825 | pass 826 | 827 | if Annotated: 828 | @app.route('/integers/', methods=['PUT']) 829 | @response(Schema) 830 | def get_integer(some_integer: Annotated[int, 1, 831 | 'some_integer docs']): 832 | pass 833 | 834 | @app.route('/users//articles/') 835 | @response(Schema) 836 | def get_article(user_id: Annotated[int, 1], article_id): 837 | pass 838 | else: 839 | @app.route('/integers/', methods=['PUT']) 840 | @response(Schema) 841 | def get_integer(some_integer: 'some_integer docs'): # noqa: F722 842 | pass 843 | 844 | @app.route('/users//articles/') 845 | @response(Schema) 846 | def get_article(user_id, article_id): 847 | pass 848 | 849 | client = app.test_client() 850 | 851 | rv = client.get('/apispec.json') 852 | assert rv.status_code == 200 853 | validate_spec(rv.json) 854 | 855 | assert rv.json['paths']['/strings/{some_string}'][ 856 | 'get']['parameters'][0]['in'] == 'path' 857 | assert rv.json['paths']['/strings/{some_string}'][ 858 | 'get']['parameters'][0]['description'] == 'some_string docs' 859 | assert rv.json['paths']['/strings/{some_string}'][ 860 | 'get']['parameters'][0]['name'] == 'some_string' 861 | assert rv.json['paths']['/strings/{some_string}'][ 862 | 'get']['parameters'][0]['schema']['type'] == 'string' 863 | 864 | assert rv.json['paths']['/floats/{some_float}'][ 865 | 'post']['parameters'][0]['in'] == 'path' 866 | assert 'description' not in rv.json['paths']['/floats/{some_float}'][ 867 | 'post']['parameters'][0] 868 | assert rv.json['paths']['/floats/{some_float}'][ 869 | 'post']['parameters'][0]['schema']['type'] == 'number' 870 | assert rv.json['paths']['/floats/{some_float}'][ 871 | 'post']['parameters'][0]['schema']['type'] == 'number' 872 | 873 | assert rv.json['paths']['/integers/{some_integer}'][ 874 | 'put']['parameters'][0]['in'] == 'path' 875 | assert rv.json['paths']['/integers/{some_integer}'][ 876 | 'put']['parameters'][0]['description'] == 'some_integer docs' 877 | assert rv.json['paths']['/integers/{some_integer}'][ 878 | 'put']['parameters'][0]['schema']['type'] == 'integer' 879 | 880 | assert rv.json['paths']['/users/{user_id}/articles/{article_id}'][ 881 | 'get']['parameters'][0]['in'] == 'path' 882 | assert rv.json['paths']['/users/{user_id}/articles/{article_id}'][ 883 | 'get']['parameters'][0]['name'] == 'user_id' 884 | assert 'description' not in rv.json['paths'][ 885 | '/users/{user_id}/articles/{article_id}']['get']['parameters'][0] 886 | assert rv.json['paths']['/users/{user_id}/articles/{article_id}'][ 887 | 'get']['parameters'][1]['in'] == 'path' 888 | assert rv.json['paths']['/users/{user_id}/articles/{article_id}'][ 889 | 'get']['parameters'][1]['name'] == 'article_id' 890 | assert 'description' not in rv.json['paths'][ 891 | '/users/{user_id}/articles/{article_id}']['get']['parameters'][1] 892 | 893 | def test_path_arguments_detection(self): 894 | app, apifairy = self.create_app() 895 | 896 | @app.route('/') 897 | @response(Schema) 898 | def pattern1(): 899 | pass 900 | 901 | @app.route('/foo/') 902 | @response(Schema) 903 | def pattern2(): 904 | pass 905 | 906 | @app.route('//bar') 907 | @response(Schema) 908 | def pattern3(): 909 | pass 910 | 911 | @app.route('///baz') 912 | @response(Schema) 913 | def pattern4(): 914 | pass 915 | 916 | @app.route('/foo//') 917 | @response(Schema) 918 | def pattern5(): 919 | pass 920 | 921 | @app.route('///') 922 | @response(Schema) 923 | def pattern6(): 924 | pass 925 | 926 | client = app.test_client() 927 | 928 | rv = client.get('/apispec.json') 929 | assert rv.status_code == 200 930 | validate_spec(rv.json) 931 | assert '/{foo}' in rv.json['paths'] 932 | assert '/foo/{bar}' in rv.json['paths'] 933 | assert '/{foo}/bar' in rv.json['paths'] 934 | assert '/{foo}/{bar}/baz' in rv.json['paths'] 935 | assert '/foo/{bar}/{baz}' in rv.json['paths'] 936 | assert '/{foo}/{bar}/{baz}' in rv.json['paths'] 937 | assert rv.json['paths']['/{foo}/{bar}/{baz}']['get'][ 938 | 'parameters'][0]['schema']['type'] == 'integer' 939 | assert rv.json['paths']['/{foo}/{bar}/{baz}']['get'][ 940 | 'parameters'][1]['schema']['type'] == 'string' 941 | assert rv.json['paths']['/{foo}/{bar}/{baz}']['get'][ 942 | 'parameters'][2]['schema']['type'] == 'number' 943 | 944 | def test_path_tags_with_nesting_blueprints(self): 945 | if not hasattr(Blueprint, 'register_blueprint'): 946 | pytest.skip('This test requires Flask 2.0 or higher.') 947 | 948 | app, apifairy = self.create_app() 949 | 950 | parent_bp = Blueprint('parent', __name__, url_prefix='/parent') 951 | child_bp = Blueprint('child', __name__, url_prefix='/child') 952 | 953 | @parent_bp.route('/') 954 | @response(Schema) 955 | def foo(): 956 | pass 957 | 958 | @child_bp.route('/') 959 | @response(Schema) 960 | def bar(): 961 | pass 962 | 963 | parent_bp.register_blueprint(child_bp) 964 | app.register_blueprint(parent_bp) 965 | 966 | client = app.test_client() 967 | 968 | rv = client.get('/apispec.json') 969 | assert rv.status_code == 200 970 | validate_spec(rv.json) 971 | assert {'name': 'Parent'} in rv.json['tags'] 972 | assert {'name': 'Parent.Child'} in rv.json['tags'] 973 | assert rv.json['paths']['/parent/']['get']['tags'] == ['Parent'] 974 | assert rv.json['paths']['/parent/child/']['get'][ 975 | 'tags'] == ['Parent.Child'] 976 | 977 | def test_async_views(self): 978 | if not sys.version_info >= (3, 7): 979 | pytest.skip('This test requires Python 3.7 or higher.') 980 | 981 | app, apifairy = self.create_app() 982 | auth = HTTPBasicAuth() 983 | 984 | @auth.verify_password 985 | def verify_password(username, password): 986 | if username == 'foo' and password == 'bar': 987 | return {'user': 'foo'} 988 | elif username == 'bar' and password == 'foo': 989 | return {'user': 'bar'} 990 | 991 | @auth.get_user_roles 992 | def get_roles(user): 993 | if user['user'] == 'bar': 994 | return 'admin' 995 | return 'normal' 996 | 997 | @app.route('/foo', methods=['POST']) 998 | @authenticate(auth) 999 | @arguments(QuerySchema) 1000 | @body(Schema) 1001 | @response(Schema) 1002 | @other_responses({404: 'foo not found'}) 1003 | async def foo(query, body): 1004 | return {'id': query['id'], 'name': auth.current_user()['user']} 1005 | 1006 | client = app.test_client() 1007 | 1008 | rv = client.get('/apispec.json') 1009 | assert rv.status_code == 200 1010 | validate_spec(rv.json) 1011 | assert rv.json['openapi'] == '3.0.3' 1012 | assert rv.json['info']['title'] == 'Foo' 1013 | assert rv.json['info']['version'] == '1.0' 1014 | 1015 | assert apifairy.apispec is apifairy.apispec 1016 | 1017 | rv = client.get('/docs') 1018 | assert rv.status_code == 200 1019 | assert b'redoc.standalone.js' in rv.data 1020 | 1021 | rv = client.post('/foo') 1022 | assert rv.status_code == 401 1023 | 1024 | rv = client.post( 1025 | '/foo', headers={'Authorization': 'Basic Zm9vOmJhcg=='}) 1026 | assert rv.json['messages']['json']['name'] == \ 1027 | ['Missing data for required field.'] 1028 | assert rv.status_code == 400 1029 | 1030 | rv = client.post('/foo', json={'name': 'john'}, 1031 | headers={'Authorization': 'Basic Zm9vOmJhcg=='}) 1032 | assert rv.status_code == 200 1033 | assert rv.json == {'id': 1, 'name': 'foo'} 1034 | 1035 | rv = client.post('/foo?id=2', json={'name': 'john'}, 1036 | headers={'Authorization': 'Basic Zm9vOmJhcg=='}) 1037 | assert rv.status_code == 200 1038 | assert rv.json == {'id': 2, 'name': 'foo'} 1039 | 1040 | def test_webhook(self): 1041 | app, apifairy = self.create_app() 1042 | bp = Blueprint('bp', __name__) 1043 | 1044 | @webhook 1045 | @body(Schema) 1046 | def default_webhook(): 1047 | pass 1048 | 1049 | @webhook(endpoint='my-endpoint') 1050 | @body(Schema) 1051 | def custom_endpoint(): 1052 | pass 1053 | 1054 | @webhook(method='POST') 1055 | @body(Schema) 1056 | def post_webhook(): 1057 | pass 1058 | 1059 | @webhook(endpoint='tag.tagged-webhook') 1060 | @body(Schema) 1061 | def tagged_webhook(): 1062 | pass 1063 | 1064 | @webhook(blueprint=bp) 1065 | @body(Schema) 1066 | def blueprint_webhook(): 1067 | pass 1068 | 1069 | app.register_blueprint(bp) 1070 | client = app.test_client() 1071 | 1072 | rv = client.get('/apispec.json') 1073 | assert rv.status_code == 200 1074 | validate_spec(rv.json) 1075 | assert rv.json['openapi'] == '3.1.0' 1076 | assert 'default_webhook' in rv.json['webhooks'] 1077 | assert 'get' in rv.json['webhooks']['default_webhook'] 1078 | assert 'my-endpoint' in rv.json['webhooks'] 1079 | assert 'get' in rv.json['webhooks']['my-endpoint'] 1080 | assert 'post_webhook' in rv.json['webhooks'] 1081 | assert 'post' in rv.json['webhooks']['post_webhook'] 1082 | assert 'tagged-webhook' in rv.json['webhooks'] 1083 | assert 'get' in rv.json['webhooks']['tagged-webhook'] 1084 | assert 'blueprint_webhook' in rv.json['webhooks'] 1085 | assert 'get' in rv.json['webhooks']['blueprint_webhook'] 1086 | 1087 | def test_webhook_invalid_apispec_version(self): 1088 | app, apifairy = self.create_app( 1089 | config={'APIFAIRY_APISPEC_VERSION': '3.0.3'}) 1090 | 1091 | @webhook 1092 | @body(Schema) 1093 | def unsupported_webhook(): 1094 | pass 1095 | 1096 | client = app.test_client() 1097 | rv = client.get('/apispec.json') 1098 | assert rv.status_code == 500 1099 | 1100 | def test_webhook_duplicate(self): 1101 | app, apifairy = self.create_app() 1102 | 1103 | def add_webhooks(): 1104 | @webhook 1105 | @body(Schema) 1106 | def default_webhook(): 1107 | pass 1108 | 1109 | @webhook(endpoint='default_webhook') 1110 | @body(Schema) 1111 | def another_webhook(): 1112 | pass 1113 | 1114 | with pytest.raises(ValueError): 1115 | add_webhooks() 1116 | --------------------------------------------------------------------------------