├── 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 | [](https://github.com/miguelgrinberg/apifairy/actions) [](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 | 
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 |
--------------------------------------------------------------------------------