Full Code of miguelgrinberg/APIFairy for AI

main ce903c3f7c9b cached
33 files
126.2 KB
31.8k tokens
86 symbols
1 requests
Download .txt
Repository: miguelgrinberg/APIFairy
Branch: main
Commit: ce903c3f7c9b
Files: 33
Total size: 126.2 KB

Directory structure:
gitextract_w4lwbgyf/

├── .github/
│   └── workflows/
│       └── tests.yml
├── .gitignore
├── .readthedocs.yaml
├── CHANGES.md
├── LICENSE
├── MANIFEST.in
├── README.md
├── bin/
│   ├── mkchangelog.py
│   └── release
├── docs/
│   ├── Makefile
│   ├── apifairy_class.rst
│   ├── conf.py
│   ├── decorators.rst
│   ├── guide.rst
│   ├── index.rst
│   ├── intro.rst
│   └── make.bat
├── examples/
│   ├── README.md
│   ├── app.py
│   └── app_with_class_views.py
├── pyproject.toml
├── src/
│   └── apifairy/
│       ├── __init__.py
│       ├── core.py
│       ├── decorators.py
│       ├── exceptions.py
│       ├── fields.py
│       └── templates/
│           └── apifairy/
│               ├── elements.html
│               ├── rapidoc.html
│               ├── redoc.html
│               └── swagger_ui.html
├── tests/
│   ├── __init__.py
│   └── test_apifairy.py
└── tox.ini

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/workflows/tests.yml
================================================
name: build
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
jobs:
  lint:
    name: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v3
      - run: python -m pip install --upgrade pip wheel
      - run: pip install tox tox-gh-actions
      - run: tox -eflake8
      - run: tox -edocs
  tests:
    name: tests
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        python: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 'pypy-3.11']
      fail-fast: false
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v3
        with:
          python-version: ${{ matrix.python }}
      - run: python -m pip install --upgrade pip wheel
      - run: pip install tox tox-gh-actions
      - run: tox
  coverage:
    name: coverage
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v3
      - run: python -m pip install --upgrade pip wheel
      - run: pip install tox tox-gh-actions
      - run: tox
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage.xml
          fail_ci_if_error: true
          token: ${{ secrets.CODECOV_TOKEN }}


================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
.python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

mise.toml
requirements*.txt


================================================
FILE: .readthedocs.yaml
================================================
version: 2

build:
  os: ubuntu-22.04
  tools:
    python: "3.11"

sphinx:
  configuration: docs/conf.py

python:
  install:
    - method: pip
      path: .
      extra_requirements:
        - docs


================================================
FILE: CHANGES.md
================================================
# APIFairy Change Log

**Release 1.5.1** - 2025-11-14

- 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**!)

**Release 1.5.0** - 2025-10-28

- 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**!)
- 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))

**Release 1.4.0** - 2024-01-15

- Remove use of deprecated `flask.__version__` ([commit](https://github.com/miguelgrinberg/apifairy/commit/a21ecba2fc6dbcdbb7e25c44933116bcaea8aaa4))
- Handle breaking changes in `webargs.use_args` decorator ([commit](https://github.com/miguelgrinberg/apifairy/commit/943d30303bbdcaabda028ada8e1b2fee0132e7fa))
- 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))
- 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**!)
- 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))
- Add Python 3.12 to builds ([commit](https://github.com/miguelgrinberg/apifairy/commit/2f3b99c19b1ddaf197b6eb7cf74d645375a42c0f))
- Migrate Python package metadata to pyproject.toml ([commit](https://github.com/miguelgrinberg/apifairy/commit/38d765b6a492a3c40cbf4fdff6e235be84c67111))

**Release 1.3.0** - 2022-11-13

- Support for documenting webhooks, per OpenAPI 3.1.0 spec ([commit](https://github.com/miguelgrinberg/apifairy/commit/f5b3843a7097c0d2a297e6074c2c1837521a4077))
- Add Python 3.11 to test builds ([commit](https://github.com/miguelgrinberg/apifairy/commit/0d11acb143a6661f0a0d0b1e857a7626ba066f1d))
- Stop testing Python 3.6 ([commit](https://github.com/miguelgrinberg/apifairy/commit/e17f702566792bdb045faebb21f1f682bca79b28))

**Release 1.2.0** - 2022-10-06

- Documentation of request and response headers [#63](https://github.com/miguelgrinberg/apifairy/issues/63) ([commit](https://github.com/miguelgrinberg/apifairy/commit/c2a9ec2cc5608f5c26c30428d964b964d00c8b8f))

**Release 1.1.0** - 2022-09-22

- 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))
- 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))
- Fixing some typos in documentation [#53](https://github.com/miguelgrinberg/apifairy/issues/53) ([commit](https://github.com/miguelgrinberg/apifairy/commit/972eb76d9494aceb0ca9d159a3d2ebf59f7e0603)) (thanks **GustavMauler**!)
- Add link to Microblog API example in readme ([commit](https://github.com/miguelgrinberg/apifairy/commit/6bcdf2ff74008b37aab0f723343469713a6998fb))
- Updated readme with a screenshot ([commit](https://github.com/miguelgrinberg/apifairy/commit/71d9e96a3abd34b6e528ab43679ac2b781c66dbe))

**Release 1.0.0** - 2022-08-02

- Document path parameters with string annotations ([commit](https://github.com/miguelgrinberg/apifairy/commit/4cade08b60ba4336fcfaf01e63b3ad4b72a8fccc))
- Support for `typing.Annotated` in path parameter documentation ([commit](https://github.com/miguelgrinberg/apifairy/commit/aa090a0a1d06c298f81efaa3d0b10a844097caae))
- Correct handling of custom blueprint ordering ([commit](https://github.com/miguelgrinberg/apifairy/commit/1ac7938c5c1288da953231818e567fe740b65ba6))
- Documentation on how to add manually written documentation ([commit](https://github.com/miguelgrinberg/apifairy/commit/5bfda7e62891b84dfbd63ecaef83bc4191c99272))

**Release 0.9.2** - 2022-07-20

- Form and file upload support [#35](https://github.com/miguelgrinberg/apifairy/issues/35) ([commit](https://github.com/miguelgrinberg/apifairy/commit/59dfb3c252119beb982adef2346c76592ef14528))
- Additional unit testing coverage ([commit](https://github.com/miguelgrinberg/apifairy/commit/407cf6ba724b6f4c5b90bae8685fee0697f16146))
- Add Python 3.10 and PyPy 3.8 to builds ([commit](https://github.com/miguelgrinberg/apifairy/commit/66ad682d602f2551d0f075678b63b3f338ec6a28))

**Release 0.9.1** - 2022-01-11

- 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))
- Set page title in rapidoc and elements templates ([commit](https://github.com/miguelgrinberg/apifairy/commit/95352b1c430183166a77459983190894c6596122))

**Release 0.9.0** - 2021-12-14

- Better ordering for authentication schemes ([commit](https://github.com/miguelgrinberg/apifairy/commit/a6067f8eeb1fe429935e75c0ca71389caed4754f))
- Added rapidoc template ([commit](https://github.com/miguelgrinberg/apifairy/commit/ff9a161bc9edfe7e88f1b6f658ea12f2ae91a0e2))
- Added Elements template ([commit](https://github.com/miguelgrinberg/apifairy/commit/d2ff0543cbf4ed8f293c48b1839445b3deacbf3d))
- Documented how to create a custom documentation endpoint ([commit](https://github.com/miguelgrinberg/apifairy/commit/47d13793fa06a9f23eca5435478f42b103c980b3))

**Release 0.8.2** - 2021-08-30

- One more change needed to include HTML files in package ([commit](https://github.com/miguelgrinberg/apifairy/commit/7ed49227de57afbd51dbea5bd2b1e24ff12f733f))

**Release 0.8.1** - 2021-08-30

- Add the documentation templates back into the package [#2](https://github.com/miguelgrinberg/apifairy/issues/2) ([commit](https://github.com/miguelgrinberg/apifairy/commit/7e0115cd5706652d7208bfafb8b47e8fe84b5de7))

**Release 0.8.0** - 2021-08-07

- Add `servers` section ([commit](https://github.com/miguelgrinberg/apifairy/commit/6d5d614ff0dc9ef7666191f4ca7c9e9139518d99))
- Add `operationId` for each endpoint ([commit](https://github.com/miguelgrinberg/apifairy/commit/198855f810b4f97b7f3e61c0cf602e31ab2e0fa8))
- Add default description for responses ([commit](https://github.com/miguelgrinberg/apifairy/commit/73ec17f13933c5d4a55a81d5131706a531f88dfb))
- Remove indentation spaces from docstrings [#30](https://github.com/miguelgrinberg/apifairy/issues/30) ([commit](https://github.com/miguelgrinberg/apifairy/commit/30ef9983bf0c5bb31451cdcc2d5d91447d3cf80e))
- Support Flask 2 async views ([commit](https://github.com/miguelgrinberg/apifairy/commit/bae399aa76d13ebf167a5933f50ddbb5f3923039))
- Support nested blueprints ([commit](https://github.com/miguelgrinberg/apifairy/commit/c5883a626631744c8ec28782bf852c738169dd8f)) (thanks **Grey Li**!)
- Improved project structure ([commit](https://github.com/miguelgrinberg/apifairy/commit/1fbd5a59d3c8aa4e2ea38331c750e41f3164bd3f))

**Release 0.7.0** - 2021-05-24

- 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**!)
- Use default status code when route returns a one-element tutple ([commit](https://github.com/miguelgrinberg/apifairy/commit/c895739ce51ea8165de8cd20e322dea7fd2c4645))
- 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**!)
- Fix path arguments order ([commit](https://github.com/miguelgrinberg/apifairy/commit/6793feb36c893212966eeaf4c9bea2b753e3d142)) (thanks **Grey Li**!)
- Fix path arguments regex [#16](https://github.com/miguelgrinberg/apifairy/issues/16) ([commit](https://github.com/miguelgrinberg/apifairy/commit/7c81c154698dfab0a3c49613ea9885c2ea81be51)) (thanks **Grey Li**!)
- Fix detection of view docstring [#8](https://github.com/miguelgrinberg/apifairy/issues/8) ([commit](https://github.com/miguelgrinberg/apifairy/commit/4dd8568f037b27a54bb1b57a4ea27580f97cf786)) (thanks **Grey Li**!)
- 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**!)
- Document the process_apispec decorator ([commit](https://github.com/miguelgrinberg/apifairy/commit/fd22e11302da82e4aed58e5793efa997d113dc74))
- 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**!)
- Fix typo in exception message [#20](https://github.com/miguelgrinberg/apifairy/issues/20) ([commit](https://github.com/miguelgrinberg/apifairy/commit/217a7fc976b860daa07199c297c7086b63e341be)) (thanks **Grey Li**!)
- 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**!)
- 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**!)
- Move builds to GitHub actions ([commit](https://github.com/miguelgrinberg/apifairy/commit/b8cec62a7d719b6dd51b69dbf8f983b61459be94))

**Release 0.6.2** - 2020-10-10

- Documentation updates ([commit](https://github.com/miguelgrinberg/apifairy/commit/ae72b2abc850ecf58c47603fac39fc92fd5c76ec))

**Release 0.6.1** - 2020-10-05

- Fixed release script to include HTML templates
- Rename blueprint to `apifairy`

**Release 0.6.0** - 2020-10-03

- More unit test coverage
- Configuration through Flask's `config` object
- Error handling

**Release 0.5.0** - 2020-09-28

- First public release!


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2020 Miguel Grinberg

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: MANIFEST.in
================================================
include README.md LICENSE tox.ini
recursive-include docs *
recursive-exclude docs/_build *
recursive-include tests *
exclude **/*.pyc


================================================
FILE: README.md
================================================
# APIFairy

[![Build status](https://github.com/miguelgrinberg/apifairy/workflows/build/badge.svg)](https://github.com/miguelgrinberg/apifairy/actions) [![codecov](https://codecov.io/gh/miguelgrinberg/apifairy/branch/main/graph/badge.svg)](https://codecov.io/gh/miguelgrinberg/APIFairy)

APIFairy is a minimalistic API framework built on top of Flask, and with the
support of Marshmallow schemas. Using a familiar decorator syntax you can
generate a live documentation site directly from your source code.

Check out [Microblog-API](https://github.com/miguelgrinberg/microblog-api) to
see APIFairy in action in a non-trivial project.

![APIFairy example](docs/_static/apispec-example.png)

Resources
---------

- [Documentation](http://apifairy.readthedocs.io/en/latest/)
- [PyPI](https://pypi.python.org/pypi/APIFairy)
- [Change Log](https://github.com/miguelgrinberg/APIFairy/blob/main/CHANGES.md)


================================================
FILE: bin/mkchangelog.py
================================================
import datetime
import re
import sys
import git

URL = 'https://github.com/miguelgrinberg/apifairy'
merges = {}


def format_message(commit):
    if commit.message.startswith('Version '):
        return ''
    if '#nolog' in commit.message:
        return ''
    if commit.message.startswith('Merge pull request'):
        pr = commit.message.split('#')[1].split(' ')[0]
        message = ' '.join([line for line in [line.strip() for line in commit.message.split('\n')[1:]] if line])
        merges[message] = pr
        return ''
    if commit.message.startswith('Release '):
        return '\n**{message}** - {date}\n'.format(
            message=commit.message.strip(),
            date=datetime.datetime.fromtimestamp(commit.committed_date).strftime('%Y-%m-%d'))
    message = ' '.join([line for line in [line.strip() for line in commit.message.split('\n')] if line])
    if message in merges:
        message += ' #' + merges[message]
    message = re.sub('\\(.*(#[0-9]+)\\)', '\\1', message)
    message = re.sub('Fixes (#[0-9]+)', '\\1', message)
    message = re.sub('fixes (#[0-9]+)', '\\1', message)
    message = re.sub('#([0-9]+)', '[#\\1]({url}/issues/\\1)'.format(url=URL), message)
    message += ' ([commit]({url}/commit/{sha}))'.format(url=URL, sha=str(commit))
    if commit.author.name != 'Miguel Grinberg':
        message += ' (thanks **{name}**!)'.format(name=commit.author.name)
    return '- ' + message


def main(all=False):
    repo = git.Repo()

    for commit in repo.iter_commits():
        if not all and commit.message.startswith('Release '):
            break
        message = format_message(commit)
        if message:
            print(message)


if __name__ == '__main__':
    main(all=len(sys.argv) > 1 and sys.argv[1] == 'all')


================================================
FILE: bin/release
================================================
#!/bin/bash -ex

VERSION="$1"
VERSION_FILE=apifairy/__init__.py

if [[ "$VERSION" == "" ]]; then
    echo "Usage: $0 <version>"
    exit 1
fi

# update change log
head -n 2 CHANGES.md > _CHANGES.md
echo "**Release $VERSION** - $(date +%F)" >> _CHANGES.md
echo "" >> _CHANGES.md
pip install gitpython
python bin/mkchangelog.py >> _CHANGES.md
echo "" >> _CHANGES.md
len=$(wc -l < CHANGES.md)
tail -n $(expr $len - 2) CHANGES.md >> _CHANGES.md
vim _CHANGES.md
set +e
grep -q ABORT _CHANGES.md
if [[ "$?" == "0" ]]; then
    rm _CHANGES.md
    echo "Aborted."
    exit 1
fi
set -e
mv _CHANGES.md CHANGES.md

sed -i "" "s/^__version__ = '.*'$/__version__ = '$VERSION'/" $VERSION_FILE
rm -rf dist
pip install --upgrade pip wheel twine
python setup.py sdist bdist_wheel --universal

git add $VERSION_FILE CHANGES.md
git commit -m "Release $VERSION"
git tag -f v$VERSION
git push --tags origin master

read -p "Press any key to submit to PyPI or Ctrl-C to abort..." -n1 -s
twine upload dist/*

NEW_VERSION="${VERSION%.*}.$((${VERSION##*.}+1))dev"
sed -i "" "s/^__version__ = '.*'$/__version__ = '$NEW_VERSION'/" $VERSION_FILE
git add $VERSION_FILE
git commit -m "Version $NEW_VERSION"
git push origin master
echo "Development is now open on version $NEW_VERSION!"


================================================
FILE: docs/Makefile
================================================
# Minimal makefile for Sphinx documentation
#

# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS    ?=
SPHINXBUILD   ?= sphinx-build
SOURCEDIR     = .
BUILDDIR      = _build

# Put it first so that "make" without argument is like "make help".
help:
	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

.PHONY: help Makefile

# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)


================================================
FILE: docs/apifairy_class.rst
================================================
.. APIFairy documentation master file, created by
   sphinx-quickstart on Sun Sep 27 17:34:58 2020.
   You can adapt this file completely to your liking, but it should at least
   contain the root `toctree` directive.

The APIFairy Class
==================

The main function of the ``APIFairy`` instance is to gather all the information
registered by the decorators and generate an `OpenAPI 3.x
<https://swagger.io/specification/>`_ compliant schema with it. This schema is
then used to render the documentation site using one of the available
open-source documentation projects that are compatible with this specification.

In addition to ducmentation, ``APIFairy`` allows the application to
install a custom error handler to be used when a schema validation error occurs
in routes decorated with the ``@body`` or ``@arguments`` decorators. It also
registers routes to serve the OpenAPI definition in JSON format and a
documentation site based on one of the supported third-party documentation
projects.

APIFairy.apispec
----------------

The ``apispec`` property returns the complete OpenAPI definition for the
project as a Python dictionary. The information used to build this data is
obtained from several places:

- The project's name and version are obtained from the ``APIFAIRY_TITLE`` and
  ``APIFAIRY_VERSION`` configuration items respectively.
- The top-level documentation for the project, which appears above the API
  definitions, is obtained from the main module's docstring. Markdown can be
  used to organize this content in sections and use rich-text formatting.
- The paths are obtained from all the Flask routes that have been decorated
  with at least one of the five decorators from this project. Routes that have
  not been decorated with these decorators are not included in the
  documentation.
- The schemas and security schemes are collected from decorator usages.
- Each path is documented using the information provided in the decorators,
  plus the route definition for Flask and the docstring of the view function.
  The first line of the docstring is used as a summary and the remaining lines
  as a description.
- If a route belongs to a blueprint, the corresponding path is tagged with the
  blueprint name. Paths are grouped by their tag, which ensures that routes
  from each blueprint are rendered together in their own section. The
  ``APIFAIRY_TAGS`` configuration item can be used to provide a custom ordering
  for tags.
- Each security scheme is documented by inspecting the Flask-HTTPAuth object,
  plus the contents of the ``__doc__`` property if it exists.

APIFairy.process_apispec
------------------------

The ``process_apispec`` decorator can be used to register a custom function
that receives the generated OpenAPI definition as its single argument. The
function can make changes and adjustments to it and return the modified
definition, which will then be rendered::

    @apifairy.process_apispec
    def my_apispec_processor(spec):
        # modify spec as needed here
        return spec

APIFairy.error_handler
----------------------

The ``error_handler`` method can be used to register a custom error handler
function that will be invoked whenever a validation error is raised by the
webargs project. This method can be used as a decorator as follows::

    @apifairy.error_handler
    def my_error_handler(status_code, messages):
        return {'code': status_code, 'messages': messages}, status_code

The ``status_code`` argument is the suggested HTTP status code, which is
typically 400 for a "bad request" response. The ``messages`` argument is a
dictionary with all the validation error messages that were found, organized as
a dictionary with the following structure::

    "location1": {
        "field1": ["message1", "message2", ...],
        "field2": [ ... ],
        ...
    },
    "location2": { ... },
    ...

The location keys can be ``'json'`` for the request body or ``'query'`` for the
query string.

The return value of the error handling function is interpreted as a standard
Flask response, and returned to the client as such.


================================================
FILE: docs/conf.py
================================================
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html

# -- Path setup --------------------------------------------------------------

# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))


# -- Project information -----------------------------------------------------

project = 'APIFairy'
copyright = '2020, Miguel Grinberg'
author = 'Miguel Grinberg'


# -- General configuration ---------------------------------------------------

# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
    'sphinx.ext.autosectionlabel',
]

# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']

# The master toctree document.
master_doc = 'index'

# -- Options for HTML output -------------------------------------------------

# The theme to use for HTML and HTML Help pages.  See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'

# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']

html_theme_options = {
    'description': ('A minimalistic API framework built on top of Flask, '
                    'Marshmallow and friends.'),
    'fixed_sidebar': True,
    'github_user': 'miguelgrinberg',
    'github_repo': 'APIFairy',
    'github_button': True,
    'github_type': 'star',
    'github_banner': True,
}


================================================
FILE: docs/decorators.rst
================================================
.. APIFairy documentation master file, created by
   sphinx-quickstart on Sun Sep 27 17:34:58 2020.
   You can adapt this file completely to your liking, but it should at least
   contain the root `toctree` directive.

Decorator Reference
===================

The core functionality of APIFairy is accessed through its five decorators,
which are used to define what the inputs and outputs of each endpoint are.

@arguments
----------

The ``arguments`` decorator specifies input arguments, given in the query
string of the request URL. The only argument this decorator requires is the
schema definition for the input data, which can be given as a schema class or
instance::

    from apifairy import arguments

    class PaginationSchema(ma.Schema):
       page = ma.Int(missing=1)
       limit = ma.Int(missing=10)

    @app.route('/api/user/<int:id>/followers')
    @arguments(PaginationSchema)
    def get_followers(pagination, id):
        page = pagination['page']
        limit = pagination['limit']
        # ...

The decorator will deserialize and validate the input data and will only
invoke the view function when the arguments are valid. In the case of a
validation error, the error handler is invoked to generate an error response
to the client.

The deserialized input data is passed to the view function as a positional
argument. Note that Flask passes path arguments as keyword arguments, so the
argument from this decorator must be defined first, as seen in the example
above. When multiple input decorators are used, the positional arguments are
given in the same order as the decorators.

Using multiple inputs
~~~~~~~~~~~~~~~~~~~~~

The ``arguments`` decorator can be given multiple times, but in that case the
schemas must have their ``unknown`` attribute set to ``EXCLUDE``, so that the
arguments from the different schemas are assigned properly::

    class PaginationSchema(ma.Schema):
        page = ma.Int(missing=1)
        limit = ma.Int(missing=10)

    class FilterSchema(ma.Schema):
        f = ma.Str()

    @app.route('/api/user/<int:id>/followers')
    @arguments(PaginationSchema(unknown=ma.EXCLUDE))
    @arguments(FilterSchema(unknown=ma.EXCLUDE))
    def get_followers(pagination, filter, id):
        page = pagination['page']
        limit = pagination['limit']
        f = filter.get('filter')
        # ...

Note that in this example the ``filter`` argument does not have ``missing`` or
``required`` attributes, so it will be considered optional. If the query string
does not include it, then ``filter`` will be empty.

Lists
~~~~~

A list can be defined in the usual way using Marshmallow's ``List`` field::

    class Filter(ma.Schema):
        f = ma.List(ma.Str())

    @app.route('/test')
    @arguments(Filter())
    def test(filter):
        f = filter.get('f', [])
        # ...

The client then must repeat the argument as many times as needed in the query
string. For example, the URL *http://localhost:5000/test?f=foo&f=bar* would
set the ``filter`` argument to ``{'f': ['foo', 'bar']}``.

Advanced Usage
~~~~~~~~~~~~~~

The ``arguments`` decorator is a thin wrapper around the ``use_args``
decorator from the `webargs <https://webargs.readthedocs.io/>`_ project with
the ``location`` argument set to ``query``. Any additional options are passed
directly into ``use_args``, which among other things allow the use of other
locations for input arguments besides the query string.

@body
-----

The ``body`` decorator defines the structure of the body of the request. The
only required argument to this decorator is the schema definition for the
request body, which can be given as a schema class or instance::

    from apifairy import body

    class UserSchema(ma.Schema):
        id = ma.Int()
        username = ma.Str(required=True)
        email = ma.Str(required=True)
        about_me = ma.Str(missing='')

    @app.route('/users', methods=['POST'])
    @body(UserSchema)
    def create_user(user):
        # ...

The decorator will deserialize and validate the input data and will only
invoke the view function when the data passes validation. In the case of a
validation error, the error handler is invoked to generate an error response
to the client.

The deserialized input data is passed to the view function as a positional
argument. Note that Flask passes path arguments as keyword arguments, so the
argument from this decorator must be defined first. When multiple input
decorators are used, the positional arguments are given in the same order as
the decorators.

Forms
~~~~~

This decorator can also be used to configure an endpoint to accept form data,
by adding the optional ``location`` argument set to ``form``::

    from apifairy import body

    class UserSchema(ma.Schema):
        id = ma.Int()
        username = ma.Str(required=True)
        email = ma.Str(required=True)
        about_me = ma.Str(missing='')

    @app.route('/users', methods=['POST'])
    @body(UserSchema, location='form')
    def create_user(user):
        # ...

File uploads can be declared with the ``FileField`` field type, which returns
a standard ``FileStorage`` object from Flask::

    from apifairy import body
    from apifairy.fields import FileField

    class UserSchema(ma.Schema):
        id = ma.Int()
        username = ma.Str(required=True)
        avatar = FileField()

    @app.route('/users', methods=['POST'])
    @body(UserSchema, location='form')
    def create_user(user):
        # ...

The ``FileField`` field type can also be combined with Marshmallow's ``List``
to accept a list of files. But for this to work, the ``media_type`` argument
needs to be added to the ``@body`` decorator to ensure that the request is
parsed as a multipart form::

    from apifairy import body
    from apifairy.fields import FileField

    class UserSchema(ma.Schema):
        id = ma.Int()
        username = ma.Str(required=True)
        files = ma.List(FileField())

    @app.route('/users', methods=['POST'])
    @body(UserSchema, location='form', media_type='multipart/form-data')
    def create_user(user):
        # ...

Advanced Usage
~~~~~~~~~~~~~~

The ``body`` decorator is a thin wrapper around the ``use_args`` decorator
from the `webargs <https://webargs.readthedocs.io/>`_ project with
the ``location`` argument set to ``json`` or ``form``. Any additional options
are passed directly into ``use_args``.

@response
---------

The ``response`` decorator specifies the structure of the endpoint response.
The only required argument to this decorator is the schema that defines the
response, which can be given as a schema class or instance::

    from apifairy import response

    @app.route('/users/<int:id>')
    @response(UserSchema)
    def get_user(id):
        return User.query.get_or_404(id)

The decorator performs the serialization of the returned object or dictionary
to JSON through the schema's ``jsonify()`` method.

This decorator accepts two optional arguments. The ``status_code`` argument is
used to specify the HTTP status code for the response, when it is not the
default of 200. The ``description`` argument is used to provide a text
description of this response to be added to the documentation::

    @app.route('/users', methods=['POST'])
    @body(UserSchema)
    @response(UserSchema, status_code=201, description='A user was created.')
    def create_user(user):
        # ...
        
@other_responses
----------------

The ``other_responses`` decorator is used to specify additional responses the
endpoint can return, usually as a result of an error condition. The only
argument to this decorator is a dictionary with the keys set to numeric HTTP
status codes. In its simplest form, the values of the dictionary are strings
that describe each response::

    from apifairy import response, other_responses

    @app.route('/users/<int:id>')
    @response(UserSchema)
    @other_responses({400: 'Invalid request.', 404: 'User not found.'})
    def get_user(id):
        # ...

If desired a schema can be provided for each response instead::

    from apifairy import response, other_responses

    @app.route('/users/<int:id>')
    @response(UserSchema)
    @other_responses({400: BadRequestSchema, 404: UserNotFoundSchema})
    def get_user(id):
        # ...

Finally, a schema and a description can both be given as a tuple::

    from apifairy import response, other_responses

    @app.route('/users/<int:id>')
    @response(UserSchema)
    @other_responses({400: (BadRequestSchema, 'Invalid request.'),
                      404: (UserNotFoundSchema, 'User not found.')})
    def get_user(id):
        # ...

This decorator does not perform any validation or formatting of error
responses, it just adds the information provided to the documentation.

@authenticate
-------------

The ``authenticate`` decorator is used to specify the authentication and
authorization requirements of the endpoint. The only required argument for
this decorator is an authentication object from the `Flask-HTTPAuth
<https://flask-httpauth.readthedocs.io/>`_ extension::

    from flask_httpauth import HTTPBasicAuth
    from apifairy import authenticate

    auth = HTTPBasicAuth()

    @app.route('/users/<int:id>')
    @authenticate(auth)
    @response(UserSchema)
    def get_user(id):
        return User.query.get_or_404(id)

The decorator invokes the ``login_required`` method of the authentication
object, and also adds an Authentication section to the documentation.

If the roles feature of Flask-HTTPAuth is used, the documentation will include
the required role(s) for each endpoint. Any keyword arguments given to the
``authenticate`` decorator, including the ``role`` argument, are passed
through to Flask-HTTPAuth.

@webhook
--------

The ``webhook`` decorator is used to document a webhook, which is an endpoint
that must be implemented by the API client for the server to invoke as a
callback or notification. OpenAPI added support for webhooks in its 3.1.0
version.

Webhooks are defined with a dummy function that is never invoked. After the
``webhook`` decorator is applied, the ``arguments``, ``body``, ``response``
and ``other_responses`` decorators can be used to document the inputs and
outputs.

Example::

    from apifairy import webhook, body

    @webhook
    @body(ResultsSchema)
    def results():
        pass

The ``webhook`` decorator accepts three optional arguments. The ``method``
argument is used to specify the HTTP method that the server will use to invoke
the webhook. If this argument is not specified, ``GET`` is used.

The ``blueprint`` argument is used to optionally specify a blueprint with which
this webhook should be grouped. This adds the a tag with the blueprint's name,
which will make most documentation renderers add the webhook definition in the
same section as the endpoints in the blueprint.

The ``endpoint`` argument can be used to explicitly provide the endpoint name
under which the webhook should be documented. If this argument is not given,
the endpoint name is the name of the webhook function.

The next example shows webhook definition using a ``POST`` HTTP method, added
to a ``users`` blueprint::

    from apifairy import webhook, body

    @webhook(method='POST', blueprint=users)
    @body(ResultsSchema)
    def results():
        pass


================================================
FILE: docs/guide.rst
================================================
Documenting your API with APIFairy
==================================

APIFairy can discover and document your API through its
:ref:`decorators <Decorator Reference>`, but in most cases you'll want to
complement automatically generated documentation with manually written notes.
The following sections describe all the places where APIFairy looks for text to
attach to your project's documentation.

Project Title and Version
-------------------------

The title and version of your project are defined in the Flask configuration
object::

    app = Flask(__name__)
    app.config['APIFAIRY_TITLE'] = 'My API Project'
    app.config['APIFAIRY_VERSION'] = '1.0'

Project Overview
----------------

Most API documentation sites include one or more sections that provide general
project information for developers, such as how to authenticate, how
pagination works, or what is the structure of error responses. APIFairy looks
for project description text to attach to the documentation in module-level
docstrings in all the packages and modules referenced in the Flask
application's import name, starting from the right side.

While different OpenAPI documentation renderers may have different expectations
for the formatting of this text, it is fairly common for documentation to be
written in Markdown format, with support for long, multi-line text.

To help clarify how this works, consider a project with the following
structure:

- my_api_project/
   - api/
      - __init__.py
      - app.py
      - routes.py
   - project.py

The contents of *project.py* are::

    from api.app import create_app

    app = create_app()

The contents of *api/app.py* are::

    from flask import Flask
    from apifairy import APIFairy

    apifairy = APIFairy()

    def create_app():
        app = Flask(__name__)
        app.config['APIFAIRY_TITLE'] = 'My API Project'
        app.config['APIFAIRY_VERSION'] = '1.0'
        apifairy.init_app(app)
        return app

With this project structure, the import name of the Flask application is
``api.app``. In general, the import name of the application is the value that
is passed as first argument to the ``Flask`` class. In most cases this is the
``__name__`` Python global variable, which represents the fully qualified
package name of the module in which the application is defined.

Following this example, APIFairy will first look for project-level
documentation in the ``api.app`` module, which maps to the *api/app.py* file.
Documentation can then be added at the top of this file, as follows::

    """Welcome to My API Project!

    ## Project Overview

    This is the project overview.

    ## Authentication

    This is how authentication works.
    """
    from flask import Flask
    from apifairy import APIFairy

    apifairy = APIFairy()

    def create_app():
        app = Flask(__name__)
        app.config['APIFAIRY_TITLE'] = 'My API Project'
        app.config['APIFAIRY_VERSION'] = '1.0'
        apifairy.init_app(app)
        return app

If APIFairy does not find a module docstring in ``api.app``, it will remove the
last component of the import name and try again. Following this example, this
would be ``api``, which is a package, so its docstring can be found in
*api/__init__.py*.

So the alternative to putting the documentation in *api/app.py* is to leave
this file without a docstring, and instead add the documentation in
*api/__init__.py*.

Endpoints
---------

To document an endpoint, add a docstring to its view function. The first line
of the docstring should be a short summary of the endpoint's purpose. A longer
description can be included starting from the second line.

Example with just a summary::

    @users.route('/users', methods=['POST'])
    @body(user_schema)
    @response(user_schema, 201)
    def new(args):
        """Register a new user"""
        user = User(**args)
        db.session.add(user)
        db.session.commit()
        return user

Example with summary and longer description::

    @users.route('/users', methods=['POST'])
    @body(user_schema)
    @response(user_schema, 201)
    def new(args):
        """Register a new user
        Clients can use this endpoint when they need to register a new user
        in the system.
        """
        user = User(**args)
        db.session.add(user)
        db.session.commit()
        return user

As with the project overview, these docstrings can also be written in Markdown.

Path parameters
---------------

For endpoints that have dynamic components in their path, APIFairy will
automatically extract their type directly from the Flask route specification.
A text description of a parameter can be included by adding a string as an
annotation.

Annotations have been evolving in recent releasees of Python, so the best
format to provide documentation for endpoint parameters depends on which
version of Python you are using.

The basic method, which works with any recent version of Python, involves
simply adding the documentation as a string annotation to the parameter::

    @users.route('/users/<int:id>', methods=['GET'])
    @authenticate(token_auth)
    @response(user_schema)
    def get(id: 'The id of the user to retrieve.'):  # noqa: F722
        """Retrieve a user by id"""
        return db.session.get(User, id) or abort(404)

While this method works, Python code linters and type checkers will flag the
annotation as invalid, because they expect annotations to be used for type
hints and not for documentation, so it may be necessary to add a ``noqa`` or
similar comment for these errors to be ignored.

If using Python 3.9 or newer, luckily there is a better option. The
`typing.Annotated <https://docs.python.org/3/library/typing.html#typing.Annotated>`_
type can be used to provide a type hint for the parameter along with additional
metadata such as a documentation string::

    from typing import Annotated

    @users.route('/users/<int:id>', methods=['GET'])
    @authenticate(token_auth)
    @response(user_schema)
    def get(id: Annotated[int, 'The id of the user to retrieve.']):
        """Retrieve a user by id"""
        return db.session.get(User, id) or abort(404)

Even if the project does not use type hints, using this format will prevent
linting and typing errors, so it is the preferred way to document a parameter.

Documentation for parameters can include multiple lines and paragraphs, if
desired. Markdown formatting is also supported by most OpenAPI renderers.

Schemas
-------

Many of the :ref:`APIFairy decorators <Decorator Reference>` accept Marshmallow
schemas as arguments. These schemas are automatically documented, including
their field types and validation requirements.

If the application wants to provide additional information, a schema
description can be provided in the ``description`` field of the schema's
metaclass::

    class UserSchema(ma.SQLAlchemySchema):
        class Meta:
            model = User
            ordered = True
            description = 'This schema represents a user.'

        id = ma.auto_field(dump_only=True)
        url = ma.String(dump_only=True)
        username = ma.auto_field(required=True,
                                 validate=validate.Length(min=3, max=64))

Documentation that is specific to a schema field can be added in a
``description`` argument when the field is declared::

    class UserSchema(ma.SQLAlchemySchema):
        class Meta:
            model = User
            ordered = True

        id = ma.auto_field(dump_only=True, description="The user's id.")
        url = ma.String(dump_only=True, description="The user's unique URL.")
        username = ma.auto_field(required=True,
                                 validate=validate.Length(min=3, max=64),
                                 description="The user's username.")

Query String
------------

APIFairy will automatically document query string parameters for endpoints that
use the :ref:`@arguments` decorator::

    @users.route('/users', methods=['GET'])
    @arguments(pagination_schema)
    @response(users_schema)
    def get_users(pagination):
        """Retrieve all users"""
        # ...

Request Headers
---------------

APIFairy also documents request headers that are declared with the
:ref:`@arguments` decorator. Note that this decorator defaults to the query
string, but the `location` argument can be set to `headers` when needed.

Example::

    class HeadersSchema(ma.Schema):
        x_token = ma.String(data_key='X-Token', required=True)

    @users.route('/users', methods=['GET'])
    @arguments(HeadersSchema, location='headers')
    @response(users_schema)
    def get_users(headers):
        """Retrieve all users"""
        # ...

The ``@arguments`` decorator can be given twice when an endpoint needs query
string and header arguments both::

    @users.route('/users', methods=['GET'])
    @arguments(PaginationSchema)
    @arguments(HeadersSchema, location='headers')
    @response(users_schema)
    def all(pagination, headers):
        """Retrieve all users"""
        # ...

Responses
---------

In addition to the schema documentation, an endpoint response can be given a
text description in a ``description`` argument to the ``@response`` decorator.

Example::

    @tokens.route('/tokens', methods=['PUT'])
    @body(token_schema)
    @response(token_schema, description='Newly issued access and refresh tokens')
    def refresh(args):
        """Refresh an access token"""
        ...

For endpoints that return information in response headers, the ``headers``
argument can be used to add these to the documentation::

    class HeadersSchema(ma.Schema):
        x_token = ma.String(data_key='X-Token')

    @tokens.route('/tokens', methods=['PUT'])
    @body(token_schema)
    @response(token_schema, headers=HeadersSchema)
    def refresh(args):
        """Refresh an access token"""
        ...

Error Responses
---------------

The ``@other_responses`` decorator takes a dictionary argument, where the keys
are the response status codes and the values provide the documentation.

To add text descriptions to these responses, set the value for each status code
to a descrition string.

Example::

    @tokens.route('/tokens', methods=['PUT'])
    @body(token_schema)
    @response(token_schema, description='Newly issued access and refresh tokens')
    @other_responses({401: 'Invalid access or refresh token',
                      403: 'Insufficient permissions'})
    def refresh(args):
        """Refresh an access token"""
        ...


To document the error response with a schema, set the value to the schema
instance.

Example::

    @tokens.route('/tokens', methods=['PUT'])
    @body(token_schema)
    @response(token_schema, description='Newly issued access and refresh tokens')
    @other_responses({401: invalid_token_schema,
                      403: insufficient_permissions_schema})
    def refresh(args):
        """Refresh an access token"""
        ...

A schema and a description can both be given as a tuple::

    @tokens.route('/tokens', methods=['PUT'])
    @body(token_schema)
    @response(token_schema, description='Newly issued access and refresh tokens')
    @other_responses({401: (invalid_token_schema, 'Invalid access or refresh token'),
                      403: (insufficient_permissions_schema, 'Insufficient permissions')})
    def refresh(args):
        """Refresh an access token"""
        ...

Authentication
--------------

APIFairy recognizes the Flask-HTTPAuth authentication object passed to the
``@authenticate`` decorator and creates the appropriate structure according to
the OpenAPI specification. To add textual documentation, define a subclass of
the Flask-HTTPAuth authentication object and add a docstring with the
documentation to it.

Example::

    from flask_httpauth import HTTPBasicAuth

    class DocumentedAuth(HTTPBasicAuth):
        """Basic authentication scheme."""
        pass

    basic_auth = DocumentedAuth()

    @tokens.route('/tokens', methods=['POST'])
    @authenticate(basic_auth)
    @response(token_schema)
    @other_responses({401: 'Invalid username or password'})
    def new():
        """Create new access and refresh tokens"""
        ...

Tags and Blueprints
-------------------

APIFairy automatically creates OpenAPI tags for all the blueprints defined in
the application, assigns each endpoint to the corresponding tag, and generates
the OpenAPI documentation with the endpoints grouped by their tag.

The order in which the groups appear can be controlled with the
``APIFAIRY_TAGS`` configuration variable, which is a list of the blueprint
names in the desired order. Any names that are not included in this list will
exclude the associated endpoints from the documentation.

A textual description for each blueprint can be provided as a module-level
docstring in the module in which the blueprint is defined.

Anything else
-------------

For any other documentation needs that are not covered by the options listed
above, the application can manually modify the OpenAPI structure. This can be
achieved in a function decorated with the ``@process_apispec`` decorator::

    @apifairy.process_apispec
    def my_apispec_processor(spec):
        # modify spec as needed here
        return spec


================================================
FILE: docs/index.rst
================================================
.. APIFairy documentation master file, created by
   sphinx-quickstart on Sun Sep 27 17:34:58 2020.
   You can adapt this file completely to your liking, but it should at least
   contain the root `toctree` directive.

APIFairy
========

Welcome to the documentation for APIFairy, the minimalistic API framework for
Flask.

.. toctree::
   :maxdepth: 2

   intro
   guide
   decorators
   apifairy_class


================================================
FILE: docs/intro.rst
================================================
.. APIFairy documentation master file, created by
   sphinx-quickstart on Sun Sep 27 17:34:58 2020.
   You can adapt this file completely to your liking, but it should at least
   contain the root `toctree` directive.

Getting Started
===============

APIFairy is a minimalistic API framework for Flask with the following goals:

- Give you a way to specify what the input arguments for each endpoint are,
  and automatically validate them for you.
- Give you a way to specify what the response format for each endpoint is, and
  automatically serialize these responses for you.
- Automatically generate API documentation for your project.
- Introduce the least amount of rules. You should be able to code your
  endpoints in the style that you like.

Below you can see an example API endpoint augmented with
APIFairy decorators::

    from apifairy import authenticate, body, response, other_responses

    # ...

    @posts_blueprint.route('/posts/<int:id>', methods=['PUT'])
    @authenticate(token_auth)
    @body(update_post_schema)
    @response(post_schema)
    @other_responses({404: 'Post not found'})
    def put(updated_post, id):
        """Edit a post."""
        post = Post.query.get_or_404(id)
        for attr, value in updated_post.items():
            setattr(post, attr, value)
        db.session.commit()
        return post

APIFairy's decorators are simple wrappers for existing solutions. In the
example above, ``token_auth`` is an intialized authentication object from the
Flask-HTTPAuth extension, and ``post_schema`` and ``update_post_schema`` are
Flask-Marshmallow schema objects. Using the decorator wrappers allow APIFairy
to automatically generate documentation using the OpenAPI 3.x standard. Below
is a screenshot of the documentation for the above endpoint:

.. image:: _static/apispec-example.png
  :width: 100%
  :alt: Automatic documentation example

Installation
------------

APIFairy is installed with ``pip``::

    pip install apifairy

Once installed, this package is initialized as a standard Flask extension::

    from flask import Flask
    from apifairy import APIFairy

    app = Flask(__name__)
    apifairy = APIFairy(app)

The two-phase initialization style is also supported::

    from flask import Flask
    from apifairy import APIFairy

    apifairy = APIFairy()

    def create_app():
        app = Flask(__name__)
        apifairy.init_app(app)
        return app

Once APIFairy is initialized, automatically generated documentation can be
accessed at the */docs* URL. The raw OpenAPI documentation data in JSON format
can be accessed at the */apispec.json* URL. Both URLs can be changed in the
configuration if desired.

Configuration
-------------

APIFairy imports its configuration from the Flask configuration object.
The available options are shown in the table below.

=============================== ====== =============== =======================================================================================================
Name                            Type   Default         Description
=============================== ====== =============== =======================================================================================================
``APIFAIRY_TITLE``              String No title        The API's title.
``APIFAIRY_VERSION``            String No version      The API's version.
``APIFAIRY_APISPEC_PATH``       String */apispec.json* The URL path where the JSON OpenAPI specification for this project is served.
``APIFAIRY_APISPEC_VERSION``    String ``None``        The version of the OpenAPI specification to generate for this project.
``APIFAIRY_APISPEC_DECORATORS`` List   []              A list of decorators to apply to the JSON OpenAPI endpoint.
``APIFAIRY_UI``                 String redoc           The documentation format to use. Supported formats are "redoc", "swagger_ui", "rapidoc" and "elements".
``APIFAIRY_UI_PATH``            String */docs*         The URL path where the documentation is served.
``APIFAIRY_UI_DECORATORS``      List   []              A list of decorators to apply to the documentation endpoint.
``APIFAIRY_TAGS``               List   ``None``        A list of tags to include in the documentation, in the desired order.
=============================== ====== =============== =======================================================================================================

Using a Custom Documentation Endpoint
-------------------------------------

APIFairy provides templates for a few popular open source OpenAPI documentation renderers:

- ``swagger_ui``: `Swagger UI <https://github.com/swagger-api/swagger-ui>`_
- ``redoc``: `ReDoc <https://github.com/Redocly/redoc>`_
- ``rapidoc``: `RapiDoc <https://github.com/mrin9/RapiDoc>`_
- ``elements``: `Elements <https://github.com/stoplightio/elements>`_

If neither of these work for your project, or if you would like to configure
any of these differently, you can set the ``APIFAIRY_UI_PATH`` to ``None`` in
the configuration to disable the default documentation endpoint, and then
implement your own.

The stock documentation options offered by this package are implemented as
Jinja2 templates, which you can `view on GitHub <https://github.com/miguelgrinberg/APIFairy/tree/main/src/apifairy/templates/apifairy>`_.
To implement a custom documentation, just create an endpoint in your Flask
application and render your own template, using the
``{{ url_for('apifairy.json') }}`` expression where your documentation
renderer needs the API specification URL.

.. note::
    When using a custom documentation endpoint, the ``APIFAIRY_UI_PATH`` and
    ``APIFAIRY_UI_DECORATORS`` configuration options are ignored.

While less useful, the JSON OpenAPI specification endpoint can also be
customized by setting the ``APIFAIRY_APISPEC_PATH`` configuration option to
``None``. If a custom version of this endpoint is used, then the documentation
endpoint must also be provided by the application.


================================================
FILE: docs/make.bat
================================================
@ECHO OFF

pushd %~dp0

REM Command file for Sphinx documentation

if "%SPHINXBUILD%" == "" (
	set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build

if "%1" == "" goto help

%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
	echo.
	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
	echo.installed, then set the SPHINXBUILD environment variable to point
	echo.to the full path of the 'sphinx-build' executable. Alternatively you
	echo.may add the Sphinx directory to PATH.
	echo.
	echo.If you don't have Sphinx installed, grab it from
	echo.http://sphinx-doc.org/
	exit /b 1
)

%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end

:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%

:end
popd


================================================
FILE: examples/README.md
================================================
# Examples

For a non-trivial example that uses Flask, Marshmallow and APIFairy together,
see the [microblog-api](https://github.com/miguelgrinberg/microblog-api) project.

This directory contains simpler examples that can be used as a starting point
when building your own project.


================================================
FILE: examples/app.py
================================================
"""Welcome to the APIFairy Simple Example project!

## Overview

This is a short and simple example that demonstrates many of the features of
APIFairy.
"""
from typing import Annotated
from uuid import uuid4
from flask import Flask, abort
from flask_marshmallow import Marshmallow
from apifairy import APIFairy, body, response, other_responses

app = Flask(__name__)
app.config['APIFAIRY_TITLE'] = 'APIFairy Simple Example'
app.config['APIFAIRY_VERSION'] = '1.0'
ma = Marshmallow(app)
apifairy = APIFairy(app)
users = []


class UserSchema(ma.Schema):
    class Meta:
        description = 'This schema represents a user'

    id = ma.String(dump_only=True, metadata={"description": "The user's id"})
    username = ma.String(required=True, metadata={"description": "The user's username"})
    first_name = ma.String(metadata={"description": "The user's first name"})
    last_name = ma.String(metadata={"description": "The user's last name"})
    age = ma.Integer(metadata={"description": "The user's age"})
    password = ma.String(load_only=True, metadata={"description": "The user's password"})


@app.get('/users')
@response(UserSchema(many=True), description="The users")
def get_users():
    """Return all the users."""
    return users


@app.post('/users')
@body(UserSchema)
@response(UserSchema, description="The new user")
@other_responses({400: 'Duplicate username or validation error'})
def new_user(user):
    """Create a new user."""
    if any([u['username'] == user['username'] for u in users]):
        abort(400)
    new_id = uuid4().hex
    user['id'] = new_id
    users.append(user)
    return user


@app.get('/users/<id>')
@response(UserSchema, description="The requested user")
@other_responses({404: 'User not found'})
def get_user(id: Annotated[str, 'The id of the user']):
    """Return a user."""
    user = [u for u in users if u['id'] == id]
    if not user:
        abort(404)
    return user[0]


@app.errorhandler(400)
def bad_request(e):
    return {'code': 400, 'error': 'bad request'}


@app.errorhandler(404)
def not_found(e):
    return {'code': 404, 'error': 'not found'}


@apifairy.error_handler
def validation_error(status_code, messages):
    return {'code': status_code, 'error': 'validation error',
            'messages': messages['json']}


================================================
FILE: examples/app_with_class_views.py
================================================
"""Welcome to the APIFairy Simple Example project!

## Overview

This is a short and simple example that demonstrates many of the features of
APIFairy. The difference between this version of the example and `app.py` is
that in this example class-based views are used.
"""
from typing import Annotated
from uuid import uuid4
from flask import Flask, abort
from flask.views import MethodView
from flask_marshmallow import Marshmallow
from apifairy import APIFairy, body, response, other_responses

app = Flask(__name__)
app.config['APIFAIRY_TITLE'] = 'APIFairy Simple Example'
app.config['APIFAIRY_VERSION'] = '1.0'
ma = Marshmallow(app)
apifairy = APIFairy(app)
users = []


class UserSchema(ma.Schema):
    class Meta:
        description = 'This schema represents a user'

    id = ma.String(dump_only=True, metadata={"description": "The user's id"})
    username = ma.String(required=True, metadata={"description": "The user's username"})
    first_name = ma.String(metadata={"description": "The user's first name"})
    last_name = ma.String(metadata={"description": "The user's last name"})
    age = ma.Integer(metadata={"description": "The user's age"})
    password = ma.String(load_only=True, metadata={"description": "The user's password"})


class GetUsersEndpoint(MethodView):
    decorators= [
        response(UserSchema(many=True), description="The users"),
    ]

    def get(self):
        """Return all the users."""
        return users
     

class NewUserEndpoint(MethodView):
    decorators = [
        other_responses({400: 'Duplicate username or validation error'}),
        response(UserSchema, description="The new user"),
        body(UserSchema),
    ]

    # important note: endpoints like this one that take arguments from APIFairy
    # are currently broken, due to a bug in Flask
    # see https://github.com/pallets/flask/issues/5199 
    def post(self, user):
        """Create a new user."""
        if any([u['username'] == user['username'] for u in users]):
            abort(400)
        new_id = uuid4().hex
        user['id'] = new_id
        users.append(user)
        return user


class UserEndpoint(MethodView):
    decorators = [
        response(UserSchema, description="The requested user"),
        other_responses({404: 'User not found'}),
    ]

    def get(self, id: Annotated[str, 'The id of the user']):
        """Return a user."""
        user = [u for u in users if u['id'] == id]
        if not user:
            abort(404)
        return user[0]


app.add_url_rule("/users", view_func=GetUsersEndpoint.as_view("get_users"))
app.add_url_rule("/users", view_func=NewUserEndpoint.as_view("new_user"))
app.add_url_rule("/user/<id>", view_func=UserEndpoint.as_view("get_user"))


@app.errorhandler(400)
def bad_request(e):
    return {'code': 400, 'error': 'bad request'}


@app.errorhandler(404)
def not_found(e):
    return {'code': 404, 'error': 'not found'}


@apifairy.error_handler
def validation_error(status_code, messages):
    return {'code': status_code, 'error': 'validation error',
            'messages': messages['json']}


================================================
FILE: pyproject.toml
================================================
[project]
name = "apifairy"
version = "1.5.2.dev0"
authors = [
    { name = "Miguel Grinberg", email = "miguel.grinberg@gmail.com" },
]
description = "A minimalistic API framework built on top of Flask, Marshmallow and friends."
classifiers = [
    "Intended Audience :: Developers",
    "Programming Language :: Python :: 3",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
]
requires-python = ">=3.9"
dependencies = [
    "flask >= 1.1.0",
    "flask-marshmallow",
    "webargs >= 8.3.0",
    "flask-httpauth >= 4",
    "apispec >= 4",
]

[project.readme]
file = "README.md"
content-type = "text/markdown"

[project.urls]
Homepage = "https://github.com/miguelgrinberg/apifairy"
"Bug Tracker" = "https://github.com/miguelgrinberg/apifairy/issues"

[project.optional-dependencies]
docs = [
    "sphinx",
]
dev = [
    "tox",
]

[tool.setuptools]
zip-safe = false
include-package-data = false

[tool.setuptools.package-dir]
"" = "src"

[tool.setuptools.packages.find]
where = [
    "src",
]
namespaces = false

[tool.setuptools.package-data]
apifairy = [
    "templates/apifairy/*.html",
]

[build-system]
requires = [
    "setuptools>=61.2",
]
build-backend = "setuptools.build_meta"


================================================
FILE: src/apifairy/__init__.py
================================================
from .core import APIFairy  # noqa: F401
from .decorators import authenticate, arguments, body, response, \
    other_responses, webhook  # noqa: F401
from .fields import FileField  # noqa: F401


================================================
FILE: src/apifairy/core.py
================================================
from json import dumps
import re
import sys
try:
    from typing import _AnnotatedAlias
except ImportError:  # pragma: no cover
    _AnnotatedAlias = None

from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from flask import current_app, Blueprint, render_template, request
from flask_marshmallow import fields
try:
    from flask_marshmallow import sqla
except ImportError:
    sqla = None
try:
    from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
except ImportError:  # pragma: no cover
    HTTPBasicAuth = None
    HTTPTokenAuth = None
from packaging.version import Version
from werkzeug.http import HTTP_STATUS_CODES

from apifairy.decorators import _webhooks
from apifairy.exceptions import ValidationError
from apifairy import fields as apifairy_fields


class APIFairy:
    def __init__(self, app=None):
        self.title = None
        self.version = None
        self.apispec_path = None
        self.ui = None
        self.ui_path = None
        self.tags = None

        self.apispec_callback = None
        self.error_handler_callback = self.default_error_handler
        self._apispec = None
        if app is not None:  # pragma: no cover
            self.init_app(app)

    def init_app(self, app):
        self.title = app.config.get('APIFAIRY_TITLE', 'No title')
        self.version = app.config.get('APIFAIRY_VERSION', 'No version')
        self.apispec_path = app.config.get('APIFAIRY_APISPEC_PATH',
                                           '/apispec.json')
        self.apispec_version = app.config.get('APIFAIRY_APISPEC_VERSION', None)
        self.apispec_decorators = app.config.get(
            'APIFAIRY_APISPEC_DECORATORS', [])
        self.ui = app.config.get('APIFAIRY_UI', 'redoc')
        self.ui_path = app.config.get('APIFAIRY_UI_PATH', '/docs')
        self.ui_decorators = app.config.get('APIFAIRY_UI_DECORATORS', [])
        self.tags = app.config.get('APIFAIRY_TAGS')

        bp = Blueprint('apifairy', __name__, template_folder='templates')

        if self.apispec_path:
            def json():
                return dumps(self.apispec), 200, \
                    {'Content-Type': 'application/json'}

            for decorator in self.apispec_decorators:
                json = decorator(json)
            bp.add_url_rule(self.apispec_path, 'json', json)

        if self.ui_path:
            def docs():
                return render_template(f'apifairy/{self.ui}.html',
                                       title=self.title, version=self.version)

            for decorator in self.ui_decorators:
                docs = decorator(docs)
            bp.add_url_rule(self.ui_path, 'docs', docs)

        if self.apispec_path or self.ui_path:  # pragma: no cover
            app.register_blueprint(bp)

        @app.errorhandler(ValidationError)
        def http_error(error):
            return self.error_handler_callback(error.status_code,
                                               error.messages)

    def process_apispec(self, f):
        self.apispec_callback = f
        return f

    def error_handler(self, f):
        self.error_handler_callback = f
        return f

    def default_error_handler(self, status_code, messages):
        return {'messages': messages}, status_code

    @property
    def apispec(self):
        if self._apispec is None:
            self._apispec = self._generate_apispec()
            if self.apispec_callback:
                self._apispec = self.apispec_callback(self._apispec)
        return self._apispec

    def _generate_apispec(self):
        def resolver(schema):
            name = schema.__class__.__name__
            if name.endswith("Schema"):
                name = name[:-6] or name
            if schema.partial:
                name += 'Update'
            return name

        # info object
        info = {}
        module_name = current_app.import_name
        while module_name:
            module = sys.modules[module_name]
            if module.__doc__:  # pragma: no cover
                info['description'] = module.__doc__.strip()
                break
            if '.' not in module_name:
                module_name = '.' + module_name
            module_name = module_name.rsplit('.', 1)[0]

        # servers
        servers = [{'url': request.url_root}]

        # tags
        tag_names = self.tags
        if tag_names is None:  # pragma: no branch
            # auto-generate tags from blueprints
            tag_names = []
            for rule in current_app.url_map.iter_rules():
                view_func = current_app.view_functions[rule.endpoint]
                if hasattr(view_func, '_spec'):
                    if '.' in rule.endpoint:
                        blueprint = rule.endpoint.rsplit('.', 1)[0]
                        if blueprint not in tag_names:  # pragma: no branch
                            tag_names.append(blueprint)
        tags = {}
        for name, blueprint in current_app.blueprints.items():
            if name not in tag_names:
                continue
            module = sys.modules[blueprint.import_name]
            tag = {'name': name.title()}
            if module.__doc__:  # pragma: no cover
                tag['description'] = module.__doc__.strip()
            tags[name] = tag
        tag_list = [tags[name] for name in tag_names]
        ma_plugin = MarshmallowPlugin(schema_name_resolver=resolver)
        apispec_version = self.apispec_version
        if apispec_version is None:
            apispec_version = '3.1.0' if _webhooks else '3.0.3'
        version = Version(apispec_version)
        if version < Version('3.0.3'):
            raise RuntimeError("Must use at openapi version '3.0.3' or newer")
        elif version < Version('3.1.0') and _webhooks:
            raise RuntimeError("Must use at least openapi version '3.1.0' "
                               'when using the @webhook decorator')
        spec = APISpec(
            title=self.title,
            version=self.version,
            openapi_version=apispec_version,
            plugins=[ma_plugin],
            info=info,
            servers=servers,
            tags=tag_list,
        )

        # configure flask-marshmallow URL types
        ma_plugin.converter.field_mapping[fields.URLFor] = ('string', 'url')
        ma_plugin.converter.field_mapping[fields.AbsoluteURLFor] = \
            ('string', 'url')
        if sqla is not None:  # pragma: no cover
            ma_plugin.converter.field_mapping[sqla.HyperlinkRelated] = \
                ('string', 'url')

        # configure FileField
        ma_plugin.converter.field_mapping[apifairy_fields.FileField] = \
            ('string', 'binary')

        # security schemes
        auth_schemes = []
        auth_names = []
        for rule in current_app.url_map.iter_rules():
            view_func = current_app.view_functions[rule.endpoint]
            if hasattr(view_func, '_spec'):
                auth = view_func._spec.get('auth')
                if auth is not None and auth not in auth_schemes:
                    auth_schemes.append(auth)
                    if isinstance(auth, HTTPBasicAuth):
                        name = 'basic_auth'
                    elif isinstance(auth, HTTPTokenAuth):
                        if auth.scheme == 'Bearer' and auth.header is None:
                            name = 'token_auth'
                        else:
                            name = 'api_key'
                    else:  # pragma: no cover
                        raise RuntimeError('Unknown authentication scheme')
                    if name in auth_names:
                        apispec_version = 2
                        new_name = f'{name}_{apispec_version}'
                        while new_name in auth_names:  # pragma: no cover
                            apispec_version += 1
                            new_name = f'{name}_{apispec_version}'
                        name = new_name
                    auth_names.append(name)
        security = {}
        security_schemes = {}
        for name, auth in zip(auth_names, auth_schemes):
            security[auth] = name
            if isinstance(auth, HTTPTokenAuth):
                if auth.scheme == 'Bearer' and auth.header is None:
                    security_schemes[name] = {
                        'type': 'http',
                        'scheme': 'bearer',
                    }
                else:
                    security_schemes[name] = {
                        'type': 'apiKey',
                        'name': auth.header,
                        'in': 'header',
                    }
            else:
                security_schemes[name] = {
                    'type': 'http',
                    'scheme': 'basic',
                }
            if auth.__doc__:
                security_schemes[name]['description'] = auth.__doc__.strip()
        for prefix in ['basic_auth', 'token_auth', 'api_key']:
            for name, scheme in security_schemes.items():
                if name.startswith(prefix):
                    spec.components.security_scheme(name, scheme)

        # paths
        paths = {}
        rules = list(current_app.url_map.iter_rules())
        rules = sorted(rules, key=lambda rule: len(rule.rule))
        rules += _webhooks.values()
        for rule in rules:
            operations = {}
            is_endpoint = True  # False for webhooks
            view_func = current_app.view_functions.get(rule.endpoint)
            if view_func is None:
                is_endpoint = False
                view_func = rule.view_func
            if not hasattr(view_func, '_spec'):
                continue
            if '.' in rule.endpoint:
                tag, endpoint = rule.endpoint.rsplit('.', 1)
                tag = tag.title()
            else:
                tag = None
                endpoint = rule.endpoint
            methods = [method for method in rule.methods
                       if method in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']]
            for method in methods:
                operation_id = rule.endpoint.replace('.', '_')
                if len(methods) > 1:
                    operation_id = method.lower() + '_' + operation_id
                operation = {
                    'operationId': operation_id,
                    'parameters': [
                        {'in': location, 'schema': schema}
                        for schema, location in view_func._spec.get('args', [])
                        if location != 'body'
                    ],
                }
                if tag:
                    operation['tags'] = [tag]
                docs = [line.strip() for line in (
                    view_func.__doc__ or '').strip().split('\n')]
                if docs[0]:
                    operation['summary'] = docs[0]
                if len(docs) > 1:
                    operation['description'] = '\n'.join(docs[1:]).strip()
                if view_func._spec.get('response'):
                    code = str(view_func._spec['status_code'])
                    operation['responses'] = {
                        code: {
                            'content': {
                                'application/json': {
                                    'schema': view_func._spec.get('response')
                                }
                            }
                        }
                    }
                    operation['responses'][code]['description'] = \
                        view_func._spec['description'] or HTTP_STATUS_CODES[
                            int(code)]
                    if view_func._spec.get('response_headers'):
                        schema = view_func._spec.get('response_headers')
                        if isinstance(schema, type):  # pragma: no branch
                            schema = schema()
                        headers = ma_plugin.converter.schema2parameters(
                            schema, location='headers')
                        operation['responses'][code]['headers'] = {
                            header['name']: header for header in headers}
                else:
                    operation['responses'] = {
                        '204': {'description': HTTP_STATUS_CODES[204]}}

                if view_func._spec.get('other_responses'):
                    for status_code, response in view_func._spec.get(
                            'other_responses').items():
                        if not isinstance(response, (tuple, list)):
                            response = (response,)
                        operation['responses'][status_code] = {}
                        for r in response:
                            if isinstance(r, str):
                                operation['responses'][status_code][
                                    'description'] = r
                            else:
                                if isinstance(r, type):
                                    r = r()  # instantiate the schema
                                operation['responses'][status_code][
                                    'content'] = {
                                        'application/json': {
                                            'schema': r
                                        }
                                    }
                        if 'description' not in operation['responses'][
                                status_code]:
                            operation['responses'][status_code][
                                'description'] = HTTP_STATUS_CODES[
                                    int(status_code)]

                if view_func._spec.get('body'):
                    schema = view_func._spec.get('body')[0]
                    location = view_func._spec.get('body')[1]
                    media_type = view_func._spec.get('body')[2]
                    if media_type is None and location == 'form':
                        has_file = False
                        for field in schema.dump_fields.values():
                            if isinstance(field, apifairy_fields.FileField):
                                has_file = True
                                break
                        media_type = 'application/x-www-form-urlencoded' \
                            if not has_file else 'multipart/form-data'
                    if media_type is None:
                        media_type = 'application/json'
                    operation['requestBody'] = {
                        'content': {
                            media_type: {
                                'schema': schema,
                            }
                        },
                        'required': True,
                    }

                if view_func._spec.get('auth'):
                    operation['security'] = [{
                        security[view_func._spec['auth']]: view_func._spec[
                            'roles']
                    }]
                operations[method.lower()] = operation

            if is_endpoint:
                path_arguments = re.findall(r'<(([^<:]+:)?([^>]+))>',
                                            rule.rule)
                if path_arguments:
                    annotations = view_func.__annotations__ or {}
                    arguments = []
                    for _, type_, name in path_arguments:
                        argument = {
                            'in': 'path',
                            'name': name,
                        }
                        if type_ == 'int:':
                            argument['schema'] = {'type': 'integer'}
                        elif type_ == 'float:':
                            argument['schema'] = {'type': 'number'}
                        else:
                            argument['schema'] = {'type': 'string'}
                        if isinstance(annotations.get(name), str):
                            argument['description'] = annotations[name]
                        elif _AnnotatedAlias and isinstance(
                                annotations.get(name), _AnnotatedAlias):
                            for annotation in annotations[name].__metadata__:
                                if isinstance(annotation, str):
                                    argument['description'] = annotation
                                    break
                        arguments.append(argument)

                    for method, operation in operations.items():
                        operation['parameters'] = arguments + \
                            operation['parameters']

                path = re.sub(r'<([^<:]+:)?', '{', rule.rule).replace('>', '}')
                if path not in paths:
                    paths[path] = operations
                else:
                    paths[path].update(operations)
            else:
                # apispec does not support webhooks, so here they are added as
                # paths, and later they are moved to their own section after
                # the spec is generated
                paths['webhook:' + endpoint] = operations
        for path, operations in paths.items():
            # sort by method before adding them to the spec
            sorted_operations = {}
            for method in ['get', 'post', 'put', 'patch', 'delete']:
                if method in operations:
                    sorted_operations[method] = operations[method]
            spec.path(path=path, operations=sorted_operations)

        spec = spec.to_dict()

        # extract webhooks from paths and add them to the webhooks section
        webhooks = {
            path[8:]: operations for path, operations in spec['paths'].items()
            if path.startswith('webhook:')
        }
        if webhooks:
            paths = {
                path: operations for path, operations in spec['paths'].items()
                if not path.startswith('webhook:')
            }
            spec['paths'] = paths
            spec['webhooks'] = webhooks
        return spec


================================================
FILE: src/apifairy/decorators.py
================================================
from functools import wraps

from flask import current_app, Response
from webargs.flaskparser import FlaskParser as BaseFlaskParser

from apifairy.exceptions import ValidationError


class FlaskParser(BaseFlaskParser):
    USE_ARGS_POSITIONAL = False
    DEFAULT_VALIDATION_STATUS = 400

    def load_form(self, req, schema):
        return {**self.load_files(req, schema),
                **super().load_form(req, schema)}

    def handle_error(self, error, req, schema, *, error_status_code,
                     error_headers):
        raise ValidationError(
            error_status_code or self.DEFAULT_VALIDATION_STATUS,
            error.messages)


parser = FlaskParser()
use_args = parser.use_args
_webhooks = {}


def _ensure_sync(f):
    if hasattr(f, '_sync_ensured'):
        return f

    @wraps(f)
    def wrapper(*args, **kwargs):
        if hasattr(current_app, 'ensure_sync'):
            return current_app.ensure_sync(f)(*args, **kwargs)
        else:  # pragma: no cover
            return f(*args, **kwargs)

    wrapper._sync_ensured = True
    return wrapper


def _annotate(f, **kwargs):
    if not hasattr(f, '_spec'):
        f._spec = {}
    for key, value in kwargs.items():
        f._spec[key] = value


def authenticate(auth, **kwargs):
    def decorator(f):
        roles = kwargs.get('role')
        if not isinstance(roles, list):  # pragma: no cover
            roles = [roles] if roles is not None else []
        f = _ensure_sync(f)
        _annotate(f, auth=auth, roles=roles)
        return auth.login_required(**kwargs)(f)
    return decorator


def arguments(schema, location='query', **kwargs):
    if isinstance(schema, type):  # pragma: no cover
        schema = schema()

    def decorator(f):
        f = _ensure_sync(f)
        if not hasattr(f, '_spec') or f._spec.get('args') is None:
            _annotate(f, args=[])
        f._spec['args'].append((schema, location))
        arg_name = f'{location}_{schema.__class__.__name__}_args'

        @wraps(f)
        def _f(*args, **kwargs):
            location_args = kwargs.pop(arg_name, {})
            return f(*args, location_args, **kwargs)

        return use_args(schema, location=location, arg_name=arg_name,
                        **kwargs)(_f)
    return decorator


def body(schema, location='json', media_type=None, **kwargs):
    if isinstance(schema, type):  # pragma: no cover
        schema = schema()

    def decorator(f):
        f = _ensure_sync(f)
        _annotate(f, body=(schema, location, media_type))
        arg_name = f'{location}_{schema.__class__.__name__}_args'

        @wraps(f)
        def _f(*args, **kwargs):
            location_args = kwargs.pop(arg_name, {})
            return f(*args, location_args, **kwargs)

        return use_args(schema, location=location, arg_name=arg_name,
                        **kwargs)(_f)
    return decorator


def response(schema, status_code=200, description=None, headers=None):
    if isinstance(schema, type):  # pragma: no cover
        schema = schema()

    def decorator(f):
        f = _ensure_sync(f)
        _annotate(f, response=schema, status_code=status_code,
                  description=description, response_headers=headers)

        @wraps(f)
        def _response(*args, **kwargs):
            rv = f(*args, **kwargs)
            if isinstance(rv, Response):  # pragma: no cover
                raise RuntimeError(
                    'The @response decorator cannot handle Response objects.')
            if isinstance(rv, tuple):
                json = schema.jsonify(rv[0])
                if len(rv) == 2:
                    if not isinstance(rv[1], int):
                        rv = (json, status_code, rv[1])
                    else:
                        rv = (json, rv[1])
                elif len(rv) >= 3:
                    rv = (json, rv[1], rv[2])
                else:
                    rv = (json, status_code)
                return rv
            else:
                return schema.jsonify(rv), status_code
        return _response
    return decorator


def other_responses(responses):
    def decorator(f):
        f = _ensure_sync(f)
        _annotate(f, other_responses=responses)
        return f
    return decorator


def webhook(method='GET', blueprint=None, endpoint=None):
    def decorator(f):
        class WebhookRule:
            def __init__(self, view_func, endpoint, methods):
                self.view_func = view_func
                self.endpoint = endpoint
                self.methods = methods

        nonlocal endpoint
        endpoint = endpoint or f.__name__
        if blueprint is not None:
            endpoint = blueprint.name + '.' + endpoint
        if endpoint not in _webhooks:
            _webhooks[endpoint] = WebhookRule(f, endpoint, methods=[method])
        else:
            raise ValueError(f'Webhook {endpoint} has been defined twice')
        return f

    if callable(method) and blueprint is None and endpoint is None:
        # invoked as a decorator without arguments
        f = method
        method = 'GET'
        return decorator(f)
    else:
        # invoked as a decorator with arguments
        return decorator


================================================
FILE: src/apifairy/exceptions.py
================================================
class ValidationError(Exception):
    def __init__(self, status_code, messages):
        self.status_code = status_code
        self.messages = messages


================================================
FILE: src/apifairy/fields.py
================================================
from marshmallow import ValidationError
from marshmallow.fields import Field
from werkzeug.datastructures import FileStorage


class FileField(Field):
    def _deserialize(self, value, attr, data, **kwargs):
        if not isinstance(value, FileStorage):
            raise ValidationError('Not a file.')
        return value


================================================
FILE: src/apifairy/templates/apifairy/elements.html
================================================
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>{{ title }} {{ version }}</title>
    <!-- Embed elements Elements via Web Component -->
    <script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css">
  </head>
  <body>
    <elements-api
      apiDescriptionUrl="{{ url_for('apifairy.json') }}"
      router="hash"
      layout="sidebar"
    />
  </body>
</html>


================================================
FILE: src/apifairy/templates/apifairy/rapidoc.html
================================================
<!doctype html> <!-- Important: must specify -->
<html>
  <head>
    <title>{{ title }} {{ version }}</title>
    <meta charset="utf-8"> <!-- Important: rapi-doc uses utf8 characters -->
    <script type="module" src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
  </head>
  <body>
    <rapi-doc spec-url="{{ url_for('apifairy.json') }}" show-header="false">
    </rapi-doc>
  </body>
</html>


================================================
FILE: src/apifairy/templates/apifairy/redoc.html
================================================
<!DOCTYPE html>
<html>
  <head>
    <title>{{ title }} {{ version }}</title>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">

    <style>
      body {
        margin: 0;
        padding: 0;
      }
    </style>
  </head>
  <body>
    <redoc spec-url="{{ url_for('apifairy.json') }}"></redoc>
    <script src="https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"> </script>
  </body>
</html>


================================================
FILE: src/apifairy/templates/apifairy/swagger_ui.html
================================================
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>{{ title }} {{ version }}</title>
    <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" >
    <style>
      html
      {
        box-sizing: border-box;
        overflow: -moz-scrollbars-vertical;
        overflow-y: scroll;
      }

      *,
      *:before,
      *:after
      {
        box-sizing: inherit;
      }

      body
      {
        margin:0;
        background: #fafafa;
      }
    </style>
  </head>

  <body>
    <div id="swagger-ui"></div>

    <script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
    <script src="https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"></script>
    <script>
      window.onload = function() {
      const ui = SwaggerUIBundle({
        url: "{{ url_for('apifairy.json') }}",
        dom_id: '#swagger-ui',
        deepLinking: true,
        presets: [
          SwaggerUIBundle.presets.apis,
          SwaggerUIStandalonePreset
        ],
        plugins: [
          SwaggerUIBundle.plugins.DownloadUrl
        ],
        layout: "BaseLayout"
      })
    }
    </script>
  </body>
</html>


================================================
FILE: tests/__init__.py
================================================


================================================
FILE: tests/test_apifairy.py
================================================
from io import BytesIO
import sys
try:
    from typing import Annotated
except ImportError:
    Annotated = None
import unittest
import pytest

from flask import Flask, Blueprint, request, session, abort
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
from flask_marshmallow import Marshmallow
from marshmallow import EXCLUDE
from openapi_spec_validator import validate_spec

from apifairy import APIFairy, body, arguments, response, authenticate, \
    other_responses, webhook, FileField

ma = Marshmallow()


class Schema(ma.Schema):
    class Meta:
        unknown = EXCLUDE

    id = ma.Integer(dump_default=123)
    name = ma.Str(required=True)


class Schema2(ma.Schema):
    class Meta:
        unknown = EXCLUDE

    id2 = ma.Integer(dump_default=123)
    name2 = ma.Str(required=True)


class FooSchema(ma.Schema):
    id = ma.Integer(dump_default=123)
    name = ma.Str()


class QuerySchema(ma.Schema):
    id = ma.Integer(load_default=1)


class FormSchema(ma.Schema):
    csrf = ma.Str(required=True)
    name = ma.Str(required=True)
    age = ma.Int()


class FormUploadSchema(ma.Schema):
    name = ma.Str()
    file = FileField(required=True)


class FormUploadSchema2(ma.Schema):
    name = ma.Str()
    files = ma.List(FileField(), required=True)


class HeaderSchema(ma.Schema):
    x_token = ma.Str(data_key='X-Token', required=True)


class TestAPIFairy(unittest.TestCase):
    def create_app(self, config=None):
        app = Flask(__name__)
        app.config['APIFAIRY_TITLE'] = 'Foo'
        app.config['APIFAIRY_VERSION'] = '1.0'
        if config:
            app.config.update(config)
        ma.init_app(app)
        apifairy = APIFairy(app)
        return app, apifairy

    def test_apispec(self):
        app, apifairy = self.create_app()
        auth = HTTPBasicAuth()

        @apifairy.process_apispec
        def edit_apispec(apispec):
            assert apispec['openapi'] == '3.0.3'
            apispec['openapi'] = '3.0.2'
            return apispec

        @auth.verify_password
        def verify_password(username, password):
            if username == 'foo' and password == 'bar':
                return {'user': 'foo'}
            elif username == 'bar' and password == 'foo':
                return {'user': 'bar'}

        @auth.get_user_roles
        def get_roles(user):
            if user['user'] == 'bar':
                return 'admin'
            return 'normal'

        @app.route('/foo')
        @authenticate(auth)
        @arguments(QuerySchema)
        @body(Schema)
        @response(Schema)
        @other_responses({404: 'foo not found'})
        def foo():
            return {'id': 123, 'name': auth.current_user()['user']}

        client = app.test_client()

        rv = client.get('/apispec.json')
        assert rv.status_code == 200
        validate_spec(rv.json)
        assert rv.json['openapi'] == '3.0.2'
        assert rv.json['info']['title'] == 'Foo'
        assert rv.json['info']['version'] == '1.0'

        assert apifairy.apispec is apifairy.apispec

        rv = client.get('/docs')
        assert rv.status_code == 200
        assert b'redoc.standalone.js' in rv.data

    def test_custom_apispec_path(self):
        app, _ = self.create_app(config={'APIFAIRY_APISPEC_PATH': '/foo'})

        client = app.test_client()
        rv = client.get('/apispec.json')
        assert rv.status_code == 404
        rv = client.get('/foo')
        assert rv.status_code == 200
        assert set(rv.json.keys()) == {
            'openapi', 'info', 'servers', 'paths', 'tags'}

    def test_no_apispec_path(self):
        app, _ = self.create_app(config={'APIFAIRY_APISPEC_PATH': None})

        client = app.test_client()
        rv = client.get('/apispec.json')
        assert rv.status_code == 404

    def test_custom_apispec_version(self):
        app, _ = self.create_app(config={'APIFAIRY_APISPEC_VERSION': '3.1.0'})

        client = app.test_client()
        rv = client.get('/apispec.json')
        assert rv.status_code == 200
        assert set(rv.json.keys()) == {
            'openapi', 'info', 'servers', 'paths', 'tags'}
        assert rv.json['openapi'] == '3.1.0'

    def test_custom_apispec_default_version(self):
        app, _ = self.create_app()

        client = app.test_client()
        rv = client.get('/apispec.json')
        assert rv.status_code == 200
        assert set(rv.json.keys()) == {
            'openapi', 'info', 'servers', 'paths', 'tags'}
        assert rv.json['openapi'] == '3.0.3'

    def test_custom_apispec_invalid_version_old(self):
        app, _ = self.create_app(
                config={'APIFAIRY_APISPEC_VERSION': '3.0.2'})

        client = app.test_client()
        rv = client.get('/apispec.json')
        assert rv.status_code == 500

    def test_custom_apispec_invalid_version_new(self):
        app, _ = self.create_app(
                config={'APIFAIRY_APISPEC_VERSION': '6.1.0'})

        client = app.test_client()
        rv = client.get('/apispec.json')
        assert rv.status_code == 500

    def test_custom_apispec_non_semver_version(self):
        app, _ = self.create_app(
                config={'APIFAIRY_APISPEC_VERSION': 'invalid'})

        client = app.test_client()
        rv = client.get('/apispec.json')
        assert rv.status_code == 500

    def test_ui(self):
        app, _ = self.create_app(config={'APIFAIRY_UI': 'swagger_ui'})

        client = app.test_client()
        rv = client.get('/docs')
        assert rv.status_code == 200
        assert b'redoc.standalone.js' not in rv.data
        assert b'swagger-ui-bundle.js' in rv.data

    def test_custom_ui_path(self):
        app, _ = self.create_app(config={'APIFAIRY_UI_PATH': '/foo'})

        client = app.test_client()
        rv = client.get('/docs')
        assert rv.status_code == 404
        rv = client.get('/foo')
        assert rv.status_code == 200
        assert b'redoc.standalone.js' in rv.data

    def test_no_ui_path(self):
        app, _ = self.create_app(config={'APIFAIRY_UI_PATH': None})

        client = app.test_client()
        rv = client.get('/docs')
        assert rv.status_code == 404

    def test_apispec_ui_decorators(self):
        def auth(f):
            def wrapper(*args, **kwargs):
                if request.headers.get('X-Token') != 'foo' and \
                        session.get('X-Token') != 'foo':
                    abort(401)
                return f(*args, **kwargs)
            return wrapper

        def more_auth(f):
            def wrapper(*args, **kwargs):
                if request.headers.get('X-Key') != 'bar':
                    abort(401)
                session['X-Token'] = 'foo'
                return f(*args, **kwargs)
            return wrapper

        app, apifairy = self.create_app(config={
            'APIFAIRY_APISPEC_DECORATORS': [auth],
            'APIFAIRY_UI_DECORATORS': [auth, more_auth]})
        app.secret_key = 'secret'

        client = app.test_client()
        rv = client.get('/apispec.json')
        assert rv.status_code == 401
        rv = client.get('/docs')
        assert rv.status_code == 401
        rv = client.get('/apispec.json', headers={'X-Token': 'foo'})
        assert rv.status_code == 200
        rv = client.get('/docs', headers={'X-Key': 'bar'})
        assert rv.status_code == 200

    def test_body(self):
        app, _ = self.create_app()

        @app.route('/foo', methods=['POST'])
        @body(Schema())
        def foo(schema):
            return schema

        client = app.test_client()

        rv = client.post('/foo')
        assert rv.status_code == 400
        assert rv.json == {
            'messages': {
                'json': {'name': ['Missing data for required field.']}
            }
        }

        rv = client.post('/foo', json={'id': 1})
        assert rv.status_code == 400
        assert rv.json == {
            'messages': {
                'json': {'name': ['Missing data for required field.']}
            }
        }

        rv = client.post('/foo', json={'id': 1, 'name': 'bar'})
        assert rv.status_code == 200
        assert rv.json == {'id': 1, 'name': 'bar'}

        rv = client.post('/foo', json={'name': 'bar'})
        assert rv.status_code == 200
        assert rv.json == {'name': 'bar'}

    def test_body_form(self):
        app, _ = self.create_app()

        @app.route('/form', methods=['POST'])
        @body(FormSchema(), location='form')
        def foo(schema):
            return schema

        client = app.test_client()

        rv = client.post('/form')
        assert rv.status_code == 400
        assert rv.json == {
            'messages': {
                'form': {
                    'csrf': ['Missing data for required field.'],
                    'name': ['Missing data for required field.'],
                }
            }
        }

        rv = client.post('/form', data={'csrf': 'foo', 'age': '12'})
        assert rv.status_code == 400
        assert rv.json == {
            'messages': {
                'form': {'name': ['Missing data for required field.']}
            }
        }

        rv = client.post('/form', data={'csrf': 'foo', 'name': 'bar'})
        assert rv.status_code == 200
        assert rv.json == {'csrf': 'foo', 'name': 'bar'}

        rv = client.post('/form', data={'csrf': 'foo', 'name': 'bar',
                                        'age': '12'})
        assert rv.status_code == 200
        assert rv.json == {'csrf': 'foo', 'name': 'bar', 'age': 12}

    def test_body_form_upload(self):
        app, _ = self.create_app()

        @app.route('/form', methods=['POST'])
        @body(FormUploadSchema(), location='form')
        def foo(schema):
            return {'name': schema.get('name'),
                    'len': len(schema['file'].read())}

        client = app.test_client()

        rv = client.post('/form')
        assert rv.status_code == 400
        assert rv.json == {
            'messages': {
                'form': {'file': ['Missing data for required field.']}
            }
        }

        rv = client.post('/form', data={'name': 'foo'},
                         content_type='multipart/form-data')
        assert rv.status_code == 400
        assert rv.json == {
            'messages': {
                'form': {'file': ['Missing data for required field.']}
            }
        }

        rv = client.post('/form', data={'file': 'foo'},
                         content_type='multipart/form-data')
        assert rv.status_code == 400
        assert rv.json == {
            'messages': {
                'form': {'file': ['Not a file.']}
            }
        }

        rv = client.post('/form', data={'name': 'foo',
                                        'file': (BytesIO(b'bar'), 'test.txt')})
        assert rv.status_code == 200
        assert rv.json == {'name': 'foo', 'len': 3}

        rv = client.post('/form', data={'file': (BytesIO(b'bar'), 'test.txt')})
        assert rv.status_code == 200
        assert rv.json == {'name': None, 'len': 3}

    def test_body_custom_error_handler(self):
        app, apifairy = self.create_app()

        @apifairy.error_handler
        def error_handler(status_code, messages):
            return {'errors': messages}, status_code

        @app.route('/foo', methods=['POST'])
        @body(Schema())
        def foo(schema):
            return schema

        client = app.test_client()

        rv = client.post('/foo')
        assert rv.status_code == 400
        assert rv.json == {
            'errors': {
                'json': {'name': ['Missing data for required field.']}
            }
        }

    def test_query(self):
        app, _ = self.create_app()

        @app.route('/foo', methods=['POST'])
        @arguments(Schema())
        @arguments(Schema2())
        def foo(schema, schema2):
            return {'name': schema['name'], 'name2': schema2['name2']}

        client = app.test_client()

        rv = client.post('/foo')
        assert rv.status_code == 400
        assert rv.json == {
            'messages': {
                'query': {'name': ['Missing data for required field.']}
            }
        }

        rv = client.post('/foo?id=1&name=bar')
        assert rv.status_code == 400
        assert rv.json == {
            'messages': {
                'query': {'name2': ['Missing data for required field.']}
            }
        }

        rv = client.post('/foo?id=1&name=bar&id2=2&name2=baz')
        assert rv.status_code == 200
        assert rv.json == {'name': 'bar', 'name2': 'baz'}

        rv = client.post('/foo?name=bar&name2=baz')
        assert rv.status_code == 200
        assert rv.json == {'name': 'bar', 'name2': 'baz'}

    def test_response(self):
        app, _ = self.create_app()

        @app.route('/foo')
        @response(Schema())
        def foo():
            return {'name': 'bar'}

        @app.route('/bar')
        @response(Schema(), status_code=201)
        def bar():
            return {'name': 'foo'}

        @app.route('/baz')
        @arguments(QuerySchema)
        @response(Schema(), status_code=201)
        def baz(query):
            if query['id'] == 1:
                return {'name': 'foo'}, 202
            elif query['id'] == 2:
                return {'name': 'foo'}, {'Location': '/baz'}
            elif query['id'] == 3:
                return {'name': 'foo'}, 202, {'Location': '/baz'}
            return ({'name': 'foo'},)

        client = app.test_client()

        rv = client.get('/foo')
        assert rv.status_code == 200
        assert rv.json == {'id': 123, 'name': 'bar'}

        rv = client.get('/bar')
        assert rv.status_code == 201
        assert rv.json == {'id': 123, 'name': 'foo'}

        rv = client.get('/baz')
        assert rv.status_code == 202
        assert rv.json == {'id': 123, 'name': 'foo'}
        assert 'Location' not in rv.headers

        rv = client.get('/baz?id=2')
        assert rv.status_code == 201
        assert rv.json == {'id': 123, 'name': 'foo'}
        assert rv.headers['Location'] in ['http://localhost/baz', '/baz']

        rv = client.get('/baz?id=3')
        assert rv.status_code == 202
        assert rv.json == {'id': 123, 'name': 'foo'}
        assert rv.headers['Location'] in ['http://localhost/baz', '/baz']

        rv = client.get('/baz?id=4')
        assert rv.status_code == 201
        assert rv.json == {'id': 123, 'name': 'foo'}
        assert 'Location' not in rv.headers

    def test_basic_auth(self):
        app, _ = self.create_app()
        auth = HTTPBasicAuth()

        @auth.verify_password
        def verify_password(username, password):
            if username == 'foo' and password == 'bar':
                return {'user': 'foo'}
            elif username == 'bar' and password == 'foo':
                return {'user': 'bar'}

        @auth.get_user_roles
        def get_roles(user):
            if user['user'] == 'bar':
                return 'admin'
            return 'normal'

        @app.route('/foo')
        @authenticate(auth)
        def foo():
            return auth.current_user()

        @app.route('/bar')
        @authenticate(auth, role='admin')
        def bar():
            return auth.current_user()

        client = app.test_client()

        rv = client.get('/foo')
        assert rv.status_code == 401

        rv = client.get('/foo',
                        headers={'Authorization': 'Basic Zm9vOmJhcg=='})
        assert rv.status_code == 200
        assert rv.json == {'user': 'foo'}

        rv = client.get('/bar',
                        headers={'Authorization': 'Basic Zm9vOmJhcg=='})
        assert rv.status_code == 403

        rv = client.get('/foo',
                        headers={'Authorization': 'Basic YmFyOmZvbw=='})
        assert rv.status_code == 200
        assert rv.json == {'user': 'bar'}

        rv = client.get('/bar',
                        headers={'Authorization': 'Basic YmFyOmZvbw=='})
        assert rv.status_code == 200
        assert rv.json == {'user': 'bar'}

        rv = client.get('/apispec.json')
        assert rv.status_code == 200
        assert rv.json['components']['securitySchemes'] == {
            'basic_auth': {'scheme': 'basic', 'type': 'http'},
        }
        assert rv.json['paths']['/foo']['get']['security'] == [
            {'basic_auth': []}]
        assert rv.json['paths']['/bar']['get']['security'] == [
            {'basic_auth': ['admin']}]

    def test_token_auth(self):
        app, _ = self.create_app()
        auth = HTTPTokenAuth()

        @auth.verify_token
        def verify_token(token):
            if token == 'foo':
                return {'user': 'foo'}
            elif token == 'bar':
                return {'user': 'bar'}

        @auth.get_user_roles
        def get_roles(user):
            if user['user'] == 'bar':
                return 'admin'
            return 'normal'

        @app.route('/foo')
        @authenticate(auth)
        def foo():
            return auth.current_user()

        @app.route('/bar')
        @authenticate(auth, role='admin')
        def bar():
            return auth.current_user()

        client = app.test_client()

        rv = client.get('/foo')
        assert rv.status_code == 401

        rv = client.get('/foo',
                        headers={'Authorization': 'Bearer foo'})
        assert rv.status_code == 200
        assert rv.json == {'user': 'foo'}

        rv = client.get('/bar',
                        headers={'Authorization': 'Bearer foo'})
        assert rv.status_code == 403

        rv = client.get('/foo',
                        headers={'Authorization': 'Bearer bar'})
        assert rv.status_code == 200
        assert rv.json == {'user': 'bar'}

        rv = client.get('/bar',
                        headers={'Authorization': 'Bearer bar'})
        assert rv.status_code == 200
        assert rv.json == {'user': 'bar'}

        rv = client.get('/apispec.json')
        assert rv.status_code == 200
        assert rv.json['components']['securitySchemes'] == {
            'token_auth': {'scheme': 'bearer', 'type': 'http'},
        }
        assert rv.json['paths']['/foo']['get']['security'] == [
            {'token_auth': []}]
        assert rv.json['paths']['/bar']['get']['security'] == [
            {'token_auth': ['admin']}]

    def test_multiple_auth(self):
        app, _ = self.create_app()
        auth = HTTPTokenAuth()
        auth.__doc__ = 'auth documentation'
        auth2 = HTTPTokenAuth(header='X-Token')

        class MyHTTPTokenAuth(HTTPTokenAuth):
            """custom auth documentation"""
            pass

        auth3 = MyHTTPTokenAuth()

        @app.route('/foo')
        @authenticate(auth)
        def foo():
            return auth.current_user()

        @app.route('/bar')
        @authenticate(auth2)
        def bar():
            return auth.current_user()

        @app.route('/baz')
        @authenticate(auth3)
        def baz():
            return auth.current_user()

        client = app.test_client()

        rv = client.get('/apispec.json')
        assert rv.status_code == 200
        assert rv.json['components']['securitySchemes'] == {
            'token_auth': {'scheme': 'bearer', 'type': 'http',
                           'description': 'auth documentation'},
            'token_auth_2': {'scheme': 'bearer', 'type': 'http',
                             'description': 'custom auth documentation'},
            'api_key': {'type': 'apiKey', 'name': 'X-Token', 'in': 'header'},
        }
        assert rv.json['paths']['/foo']['get']['security'] == [
            {'token_auth': []}]
        assert rv.json['paths']['/bar']['get']['security'] == [
            {'api_key': []}]
        assert rv.json['paths']['/baz']['get']['security'] == [
            {'token_auth_2': []}]

    def test_apispec_schemas(self):
        app, apifairy = self.create_app()

        @app.route('/foo')
        @response(Schema(partial=True))
        def foo():
            pass

        @app.route('/bar')
        @response(Schema2(many=True))
        def bar():
            pass

        @app.route('/baz')
        @response(FooSchema)
        def baz():
            pass

        with app.test_request_context():
            apispec = apifairy.apispec
        assert len(apispec['components']['schemas']) == 3
        assert 'SchemaUpdate' in apispec['components']['schemas']
        assert 'Schema2' in apispec['components']['schemas']
        assert 'Foo' in apispec['components']['schemas']

    def test_endpoints(self):
        app, apifairy = self.create_app()

        @app.route('/users')
        @response(Schema)
        def get_users():
            """get users."""
            pass

        @app.route('/users', methods=['POST', 'PUT'])
        @body(FormSchema, location='form')
        @response(Schema, status_code=201)
        @other_responses({400: (Schema2, 'bad request'),
                          401: ('unauthorized', FooSchema()),
                          403: 'forbidden',
                          404: Schema2(many=True)})
        def new_user():
            """new user.
            modify user.
            """
            pass

        @app.route('/upload', methods=['POST'])
        @body(FormUploadSchema, location='form')
        def upload():
            """upload file."""
            pass

        @app.route('/uploads', methods=['POST'])
        @body(FormUploadSchema2, location='form',
              media_type='multipart/form-data')
        def uploads():
            """upload files."""
            pass

        @app.route('/tokens', methods=['POST'])
        @response(Schema, headers=HeaderSchema)
        def token():
            """get a token."""
            pass

        client = app.test_client()

        rv = client.get('/apispec.json')
        assert rv.status_code == 200

        assert rv.json['paths']['/users']['get']['operationId'] == 'get_users'
        assert list(rv.json['paths']['/users']['get']['responses']) == ['200']
        assert rv.json['paths']['/users']['get']['summary'] == 'get users.'
        assert 'description' not in rv.json['paths']['/users']['get']

        assert rv.json['paths']['/users']['post']['operationId'] == \
            'post_new_user'
        assert list(rv.json['paths']['/users']['post']['responses']) == \
            ['201', '400', '401', '403', '404']
        assert rv.json['paths']['/users']['post']['summary'] == 'new user.'
        assert rv.json['paths']['/users']['post']['description'] == \
            'modify user.'
        assert 'application/x-www-form-urlencoded' in \
            rv.json['paths']['/users']['post']['requestBody']['content']

        assert rv.json['paths']['/users']['put']['operationId'] == \
            'put_new_user'
        assert list(rv.json['paths']['/users']['put']['responses']) == \
            ['201', '400', '401', '403', '404']
        assert rv.json['paths']['/users']['put']['summary'] == 'new user.'
        assert rv.json['paths']['/users']['put']['description'] == \
            'modify user.'

        assert rv.json['paths']['/upload']['post']['operationId'] == 'upload'
        assert list(rv.json['paths']['/upload']['post']['responses']) == \
            ['204']
        assert rv.json['paths']['/upload']['post']['summary'] == 'upload file.'
        assert 'description' not in rv.json['paths']['/upload']['post']
        assert 'multipart/form-data' in \
            rv.json['paths']['/upload']['post']['requestBody']['content']

        assert rv.json['paths']['/uploads']['post']['operationId'] == 'uploads'
        assert list(rv.json['paths']['/uploads']['post']['responses']) == \
            ['204']
        assert rv.json['paths']['/uploads']['post']['summary'] == \
            'upload files.'
        assert 'description' not in rv.json['paths']['/uploads']['post']
        assert 'multipart/form-data' in \
            rv.json['paths']['/uploads']['post']['requestBody']['content']

        assert rv.json['paths']['/tokens']['post']['operationId'] == 'token'
        assert list(rv.json['paths']['/tokens']['post']['responses']) == \
            ['200']
        assert rv.json['paths']['/tokens']['post']['summary'] == 'get a token.'
        assert 'description' not in rv.json['paths']['/tokens']['post']
        assert 'headers' in \
            rv.json['paths']['/tokens']['post']['responses']['200']
        assert 'X-Token' in \
            rv.json['paths']['/tokens']['post']['responses']['200']['headers']

        r201 = {
            'content': {
                'application/json': {
                    'schema': {'$ref': '#/components/schemas/Schema'}
                }
            },
            'description': 'Created'
        }
        assert rv.json['paths']['/users']['post']['responses']['201'] == r201
        assert rv.json['paths']['/users']['put']['responses']['201'] == r201

        r400 = {
            'content': {
                'application/json': {
                    'schema': {'$ref': '#/components/schemas/Schema2'}
                }
            },
            'description': 'bad request'
        }
        assert rv.json['paths']['/users']['post']['responses']['400'] == r400
        assert rv.json['paths']['/users']['put']['responses']['400'] == r400

        r401 = {
            'content': {
                'application/json': {
                    'schema': {'$ref': '#/components/schemas/Foo'}
                }
            },
            'description': 'unauthorized'
        }
        assert rv.json['paths']['/users']['post']['responses']['401'] == r401
        assert rv.json['paths']['/users']['put']['responses']['401'] == r401

        r403 = {'description': 'forbidden'}
        assert rv.json['paths']['/users']['post']['responses']['403'] == r403
        assert rv.json['paths']['/users']['put']['responses']['403'] == r403

        r404 = {
            'content': {
                'application/json': {
                    'schema': {
                        'items': {'$ref': '#/components/schemas/Schema2'},
                        'type': 'array'
                    }
                }
            },
            'description': 'Not Found'
        }
        assert rv.json['paths']['/users']['post']['responses']['404'] == r404
        assert rv.json['paths']['/users']['put']['responses']['404'] == r404

    def test_apispec_path_parameters(self):
        app, apifairy = self.create_app()

        @app.route('/strings/<some_string>')
        @response(Schema)
        def get_string(some_string: 'some_string docs'):  # noqa: F722
            pass

        @app.route('/floats/<float:some_float>', methods=['POST'])
        @response(Schema)
        def get_float(some_float: float):
            pass

        if Annotated:
            @app.route('/integers/<int:some_integer>', methods=['PUT'])
            @response(Schema)
            def get_integer(some_integer: Annotated[int, 1,
                                                    'some_integer docs']):
                pass

            @app.route('/users/<int:user_id>/articles/<int:article_id>')
            @response(Schema)
            def get_article(user_id: Annotated[int, 1], article_id):
                pass
        else:
            @app.route('/integers/<int:some_integer>', methods=['PUT'])
            @response(Schema)
            def get_integer(some_integer: 'some_integer docs'):  # noqa: F722
                pass

            @app.route('/users/<int:user_id>/articles/<int:article_id>')
            @response(Schema)
            def get_article(user_id, article_id):
                pass

        client = app.test_client()

        rv = client.get('/apispec.json')
        assert rv.status_code == 200
        validate_spec(rv.json)

        assert rv.json['paths']['/strings/{some_string}'][
            'get']['parameters'][0]['in'] == 'path'
        assert rv.json['paths']['/strings/{some_string}'][
            'get']['parameters'][0]['description'] == 'some_string docs'
        assert rv.json['paths']['/strings/{some_string}'][
            'get']['parameters'][0]['name'] == 'some_string'
        assert rv.json['paths']['/strings/{some_string}'][
            'get']['parameters'][0]['schema']['type'] == 'string'

        assert rv.json['paths']['/floats/{some_float}'][
            'post']['parameters'][0]['in'] == 'path'
        assert 'description' not in rv.json['paths']['/floats/{some_float}'][
            'post']['parameters'][0]
        assert rv.json['paths']['/floats/{some_float}'][
            'post']['parameters'][0]['schema']['type'] == 'number'
        assert rv.json['paths']['/floats/{some_float}'][
            'post']['parameters'][0]['schema']['type'] == 'number'

        assert rv.json['paths']['/integers/{some_integer}'][
            'put']['parameters'][0]['in'] == 'path'
        assert rv.json['paths']['/integers/{some_integer}'][
            'put']['parameters'][0]['description'] == 'some_integer docs'
        assert rv.json['paths']['/integers/{some_integer}'][
            'put']['parameters'][0]['schema']['type'] == 'integer'

        assert rv.json['paths']['/users/{user_id}/articles/{article_id}'][
            'get']['parameters'][0]['in'] == 'path'
        assert rv.json['paths']['/users/{user_id}/articles/{article_id}'][
            'get']['parameters'][0]['name'] == 'user_id'
        assert 'description' not in rv.json['paths'][
            '/users/{user_id}/articles/{article_id}']['get']['parameters'][0]
        assert rv.json['paths']['/users/{user_id}/articles/{article_id}'][
            'get']['parameters'][1]['in'] == 'path'
        assert rv.json['paths']['/users/{user_id}/articles/{article_id}'][
            'get']['parameters'][1]['name'] == 'article_id'
        assert 'description' not in rv.json['paths'][
            '/users/{user_id}/articles/{article_id}']['get']['parameters'][1]

    def test_path_arguments_detection(self):
        app, apifairy = self.create_app()

        @app.route('/<foo>')
        @response(Schema)
        def pattern1():
            pass

        @app.route('/foo/<bar>')
        @response(Schema)
        def pattern2():
            pass

        @app.route('/<foo>/bar')
        @response(Schema)
        def pattern3():
            pass

        @app.route('/<int:foo>/<bar>/baz')
        @response(Schema)
        def pattern4():
            pass

        @app.route('/foo/<int:bar>/<int:baz>')
        @response(Schema)
        def pattern5():
            pass

        @app.route('/<int:foo>/<bar>/<float:baz>')
        @response(Schema)
        def pattern6():
            pass

        client = app.test_client()

        rv = client.get('/apispec.json')
        assert rv.status_code == 200
        validate_spec(rv.json)
        assert '/{foo}' in rv.json['paths']
        assert '/foo/{bar}' in rv.json['paths']
        assert '/{foo}/bar' in rv.json['paths']
        assert '/{foo}/{bar}/baz' in rv.json['paths']
        assert '/foo/{bar}/{baz}' in rv.json['paths']
        assert '/{foo}/{bar}/{baz}' in rv.json['paths']
        assert rv.json['paths']['/{foo}/{bar}/{baz}']['get'][
            'parameters'][0]['schema']['type'] == 'integer'
        assert rv.json['paths']['/{foo}/{bar}/{baz}']['get'][
            'parameters'][1]['schema']['type'] == 'string'
        assert rv.json['paths']['/{foo}/{bar}/{baz}']['get'][
            'parameters'][2]['schema']['type'] == 'number'

    def test_path_tags_with_nesting_blueprints(self):
        if not hasattr(Blueprint, 'register_blueprint'):
            pytest.skip('This test requires Flask 2.0 or higher.')

        app, apifairy = self.create_app()

        parent_bp = Blueprint('parent', __name__, url_prefix='/parent')
        child_bp = Blueprint('child', __name__, url_prefix='/child')

        @parent_bp.route('/')
        @response(Schema)
        def foo():
            pass

        @child_bp.route('/')
        @response(Schema)
        def bar():
            pass

        parent_bp.register_blueprint(child_bp)
        app.register_blueprint(parent_bp)

        client = app.test_client()

        rv = client.get('/apispec.json')
        assert rv.status_code == 200
        validate_spec(rv.json)
        assert {'name': 'Parent'} in rv.json['tags']
        assert {'name': 'Parent.Child'} in rv.json['tags']
        assert rv.json['paths']['/parent/']['get']['tags'] == ['Parent']
        assert rv.json['paths']['/parent/child/']['get'][
            'tags'] == ['Parent.Child']

    def test_async_views(self):
        if not sys.version_info >= (3, 7):
            pytest.skip('This test requires Python 3.7 or higher.')

        app, apifairy = self.create_app()
        auth = HTTPBasicAuth()

        @auth.verify_password
        def verify_password(username, password):
            if username == 'foo' and password == 'bar':
                return {'user': 'foo'}
            elif username == 'bar' and password == 'foo':
                return {'user': 'bar'}

        @auth.get_user_roles
        def get_roles(user):
            if user['user'] == 'bar':
                return 'admin'
            return 'normal'

        @app.route('/foo', methods=['POST'])
        @authenticate(auth)
        @arguments(QuerySchema)
        @body(Schema)
        @response(Schema)
        @other_responses({404: 'foo not found'})
        async def foo(query, body):
            return {'id': query['id'], 'name': auth.current_user()['user']}

        client = app.test_client()

        rv = client.get('/apispec.json')
        assert rv.status_code == 200
        validate_spec(rv.json)
        assert rv.json['openapi'] == '3.0.3'
        assert rv.json['info']['title'] == 'Foo'
        assert rv.json['info']['version'] == '1.0'

        assert apifairy.apispec is apifairy.apispec

        rv = client.get('/docs')
        assert rv.status_code == 200
        assert b'redoc.standalone.js' in rv.data

        rv = client.post('/foo')
        assert rv.status_code == 401

        rv = client.post(
            '/foo', headers={'Authorization': 'Basic Zm9vOmJhcg=='})
        assert rv.json['messages']['json']['name'] == \
            ['Missing data for required field.']
        assert rv.status_code == 400

        rv = client.post('/foo', json={'name': 'john'},
                         headers={'Authorization': 'Basic Zm9vOmJhcg=='})
        assert rv.status_code == 200
        assert rv.json == {'id': 1, 'name': 'foo'}

        rv = client.post('/foo?id=2', json={'name': 'john'},
                         headers={'Authorization': 'Basic Zm9vOmJhcg=='})
        assert rv.status_code == 200
        assert rv.json == {'id': 2, 'name': 'foo'}

    def test_webhook(self):
        app, apifairy = self.create_app()
        bp = Blueprint('bp', __name__)

        @webhook
        @body(Schema)
        def default_webhook():
            pass

        @webhook(endpoint='my-endpoint')
        @body(Schema)
        def custom_endpoint():
            pass

        @webhook(method='POST')
        @body(Schema)
        def post_webhook():
            pass

        @webhook(endpoint='tag.tagged-webhook')
        @body(Schema)
        def tagged_webhook():
            pass

        @webhook(blueprint=bp)
        @body(Schema)
        def blueprint_webhook():
            pass

        app.register_blueprint(bp)
        client = app.test_client()

        rv = client.get('/apispec.json')
        assert rv.status_code == 200
        validate_spec(rv.json)
        assert rv.json['openapi'] == '3.1.0'
        assert 'default_webhook' in rv.json['webhooks']
        assert 'get' in rv.json['webhooks']['default_webhook']
        assert 'my-endpoint' in rv.json['webhooks']
        assert 'get' in rv.json['webhooks']['my-endpoint']
        assert 'post_webhook' in rv.json['webhooks']
        assert 'post' in rv.json['webhooks']['post_webhook']
        assert 'tagged-webhook' in rv.json['webhooks']
        assert 'get' in rv.json['webhooks']['tagged-webhook']
        assert 'blueprint_webhook' in rv.json['webhooks']
        assert 'get' in rv.json['webhooks']['blueprint_webhook']

    def test_webhook_invalid_apispec_version(self):
        app, apifairy = self.create_app(
                config={'APIFAIRY_APISPEC_VERSION': '3.0.3'})

        @webhook
        @body(Schema)
        def unsupported_webhook():
            pass

        client = app.test_client()
        rv = client.get('/apispec.json')
        assert rv.status_code == 500

    def test_webhook_duplicate(self):
        app, apifairy = self.create_app()

        def add_webhooks():
            @webhook
            @body(Schema)
            def default_webhook():
                pass

            @webhook(endpoint='default_webhook')
            @body(Schema)
            def another_webhook():
                pass

        with pytest.raises(ValueError):
            add_webhooks()


================================================
FILE: tox.ini
================================================
[tox]
envlist=flake8,py39,py310,py311,py312,py313,py314,pypy3,docs
skip_missing_interpreters=True

[gh-actions]
python =
    3.9: py39
    3.10: py310
    3.11: py311
    3.12: py312
    3.13: py313
    3.14: py314
    pypy-3: pypy3

[testenv]
commands=
    pip install -e .
    pytest -p no:logging --cov=apifairy --cov-branch --cov-report=term-missing --cov-report=xml
deps=
    asgiref
    pytest
    pytest-cov
    openapi-spec-validator

[testenv:flake8]
deps=
    flake8
commands=
    flake8 --exclude=".*" src/apifairy tests

[testenv:docs]
changedir=docs
deps=
    sphinx
allowlist_externals=
    make
commands=
    make html
Download .txt
gitextract_w4lwbgyf/

├── .github/
│   └── workflows/
│       └── tests.yml
├── .gitignore
├── .readthedocs.yaml
├── CHANGES.md
├── LICENSE
├── MANIFEST.in
├── README.md
├── bin/
│   ├── mkchangelog.py
│   └── release
├── docs/
│   ├── Makefile
│   ├── apifairy_class.rst
│   ├── conf.py
│   ├── decorators.rst
│   ├── guide.rst
│   ├── index.rst
│   ├── intro.rst
│   └── make.bat
├── examples/
│   ├── README.md
│   ├── app.py
│   └── app_with_class_views.py
├── pyproject.toml
├── src/
│   └── apifairy/
│       ├── __init__.py
│       ├── core.py
│       ├── decorators.py
│       ├── exceptions.py
│       ├── fields.py
│       └── templates/
│           └── apifairy/
│               ├── elements.html
│               ├── rapidoc.html
│               ├── redoc.html
│               └── swagger_ui.html
├── tests/
│   ├── __init__.py
│   └── test_apifairy.py
└── tox.ini
Download .txt
SYMBOL INDEX (86 symbols across 8 files)

FILE: bin/mkchangelog.py
  function format_message (line 10) | def format_message(commit):
  function main (line 37) | def main(all=False):

FILE: examples/app.py
  class UserSchema (line 22) | class UserSchema(ma.Schema):
    class Meta (line 23) | class Meta:
  function get_users (line 36) | def get_users():
  function new_user (line 45) | def new_user(user):
  function get_user (line 58) | def get_user(id: Annotated[str, 'The id of the user']):
  function bad_request (line 67) | def bad_request(e):
  function not_found (line 72) | def not_found(e):
  function validation_error (line 77) | def validation_error(status_code, messages):

FILE: examples/app_with_class_views.py
  class UserSchema (line 24) | class UserSchema(ma.Schema):
    class Meta (line 25) | class Meta:
  class GetUsersEndpoint (line 36) | class GetUsersEndpoint(MethodView):
    method get (line 41) | def get(self):
  class NewUserEndpoint (line 46) | class NewUserEndpoint(MethodView):
    method post (line 56) | def post(self, user):
  class UserEndpoint (line 66) | class UserEndpoint(MethodView):
    method get (line 72) | def get(self, id: Annotated[str, 'The id of the user']):
  function bad_request (line 86) | def bad_request(e):
  function not_found (line 91) | def not_found(e):
  function validation_error (line 96) | def validation_error(status_code, messages):

FILE: src/apifairy/core.py
  class APIFairy (line 30) | class APIFairy:
    method __init__ (line 31) | def __init__(self, app=None):
    method init_app (line 45) | def init_app(self, app):
    method process_apispec (line 86) | def process_apispec(self, f):
    method error_handler (line 90) | def error_handler(self, f):
    method default_error_handler (line 94) | def default_error_handler(self, status_code, messages):
    method apispec (line 98) | def apispec(self):
    method _generate_apispec (line 105) | def _generate_apispec(self):

FILE: src/apifairy/decorators.py
  class FlaskParser (line 9) | class FlaskParser(BaseFlaskParser):
    method load_form (line 13) | def load_form(self, req, schema):
    method handle_error (line 17) | def handle_error(self, error, req, schema, *, error_status_code,
  function _ensure_sync (line 29) | def _ensure_sync(f):
  function _annotate (line 44) | def _annotate(f, **kwargs):
  function authenticate (line 51) | def authenticate(auth, **kwargs):
  function arguments (line 62) | def arguments(schema, location='query', **kwargs):
  function body (line 83) | def body(schema, location='json', media_type=None, **kwargs):
  function response (line 102) | def response(schema, status_code=200, description=None, headers=None):
  function other_responses (line 135) | def other_responses(responses):
  function webhook (line 143) | def webhook(method='GET', blueprint=None, endpoint=None):

FILE: src/apifairy/exceptions.py
  class ValidationError (line 1) | class ValidationError(Exception):
    method __init__ (line 2) | def __init__(self, status_code, messages):

FILE: src/apifairy/fields.py
  class FileField (line 6) | class FileField(Field):
    method _deserialize (line 7) | def _deserialize(self, value, attr, data, **kwargs):

FILE: tests/test_apifairy.py
  class Schema (line 22) | class Schema(ma.Schema):
    class Meta (line 23) | class Meta:
  class Schema2 (line 30) | class Schema2(ma.Schema):
    class Meta (line 31) | class Meta:
  class FooSchema (line 38) | class FooSchema(ma.Schema):
  class QuerySchema (line 43) | class QuerySchema(ma.Schema):
  class FormSchema (line 47) | class FormSchema(ma.Schema):
  class FormUploadSchema (line 53) | class FormUploadSchema(ma.Schema):
  class FormUploadSchema2 (line 58) | class FormUploadSchema2(ma.Schema):
  class HeaderSchema (line 63) | class HeaderSchema(ma.Schema):
  class TestAPIFairy (line 67) | class TestAPIFairy(unittest.TestCase):
    method create_app (line 68) | def create_app(self, config=None):
    method test_apispec (line 78) | def test_apispec(self):
    method test_custom_apispec_path (line 125) | def test_custom_apispec_path(self):
    method test_no_apispec_path (line 136) | def test_no_apispec_path(self):
    method test_custom_apispec_version (line 143) | def test_custom_apispec_version(self):
    method test_custom_apispec_default_version (line 153) | def test_custom_apispec_default_version(self):
    method test_custom_apispec_invalid_version_old (line 163) | def test_custom_apispec_invalid_version_old(self):
    method test_custom_apispec_invalid_version_new (line 171) | def test_custom_apispec_invalid_version_new(self):
    method test_custom_apispec_non_semver_version (line 179) | def test_custom_apispec_non_semver_version(self):
    method test_ui (line 187) | def test_ui(self):
    method test_custom_ui_path (line 196) | def test_custom_ui_path(self):
    method test_no_ui_path (line 206) | def test_no_ui_path(self):
    method test_apispec_ui_decorators (line 213) | def test_apispec_ui_decorators(self):
    method test_body (line 245) | def test_body(self):
    method test_body_form (line 279) | def test_body_form(self):
    method test_body_form_upload (line 317) | def test_body_form_upload(self):
    method test_body_custom_error_handler (line 363) | def test_body_custom_error_handler(self):
    method test_query (line 385) | def test_query(self):
    method test_response (line 420) | def test_response(self):
    method test_basic_auth (line 475) | def test_basic_auth(self):
    method test_token_auth (line 536) | def test_token_auth(self):
    method test_multiple_auth (line 597) | def test_multiple_auth(self):
    method test_apispec_schemas (line 642) | def test_apispec_schemas(self):
    method test_endpoints (line 667) | def test_endpoints(self):
    method test_apispec_path_parameters (line 814) | def test_apispec_path_parameters(self):
    method test_path_arguments_detection (line 893) | def test_path_arguments_detection(self):
    method test_path_tags_with_nesting_blueprints (line 944) | def test_path_tags_with_nesting_blueprints(self):
    method test_async_views (line 977) | def test_async_views(self):
    method test_webhook (line 1040) | def test_webhook(self):
    method test_webhook_invalid_apispec_version (line 1087) | def test_webhook_invalid_apispec_version(self):
    method test_webhook_duplicate (line 1100) | def test_webhook_duplicate(self):
Condensed preview — 33 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (135K chars).
[
  {
    "path": ".github/workflows/tests.yml",
    "chars": 1305,
    "preview": "name: build\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\njobs:\n  lint:\n    name: li"
  },
  {
    "path": ".gitignore",
    "chars": 1828,
    "preview": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packagi"
  },
  {
    "path": ".readthedocs.yaml",
    "chars": 198,
    "preview": "version: 2\n\nbuild:\n  os: ubuntu-22.04\n  tools:\n    python: \"3.11\"\n\nsphinx:\n  configuration: docs/conf.py\n\npython:\n  inst"
  },
  {
    "path": "CHANGES.md",
    "chars": 10833,
    "preview": "# APIFairy Change Log\n\n**Release 1.5.1** - 2025-11-14\n\n- Pin redoc CDN package to v2 [#94](https://github.com/miguelgrin"
  },
  {
    "path": "LICENSE",
    "chars": 1072,
    "preview": "MIT License\n\nCopyright (c) 2020 Miguel Grinberg\n\nPermission is hereby granted, free of charge, to any person obtaining a"
  },
  {
    "path": "MANIFEST.in",
    "chars": 134,
    "preview": "include README.md LICENSE tox.ini\nrecursive-include docs *\nrecursive-exclude docs/_build *\nrecursive-include tests *\nexc"
  },
  {
    "path": "README.md",
    "chars": 900,
    "preview": "# APIFairy\n\n[![Build status](https://github.com/miguelgrinberg/apifairy/workflows/build/badge.svg)](https://github.com/m"
  },
  {
    "path": "bin/mkchangelog.py",
    "chars": 1767,
    "preview": "import datetime\nimport re\nimport sys\nimport git\n\nURL = 'https://github.com/miguelgrinberg/apifairy'\nmerges = {}\n\n\ndef fo"
  },
  {
    "path": "bin/release",
    "chars": 1256,
    "preview": "#!/bin/bash -ex\n\nVERSION=\"$1\"\nVERSION_FILE=apifairy/__init__.py\n\nif [[ \"$VERSION\" == \"\" ]]; then\n    echo \"Usage: $0 <ve"
  },
  {
    "path": "docs/Makefile",
    "chars": 634,
    "preview": "# Minimal makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line, and also\n# from the "
  },
  {
    "path": "docs/apifairy_class.rst",
    "chars": 4109,
    "preview": ".. APIFairy documentation master file, created by\n   sphinx-quickstart on Sun Sep 27 17:34:58 2020.\n   You can adapt thi"
  },
  {
    "path": "docs/conf.py",
    "chars": 2277,
    "preview": "# Configuration file for the Sphinx documentation builder.\n#\n# This file only contains a selection of the most common op"
  },
  {
    "path": "docs/decorators.rst",
    "chars": 11296,
    "preview": ".. APIFairy documentation master file, created by\n   sphinx-quickstart on Sun Sep 27 17:34:58 2020.\n   You can adapt thi"
  },
  {
    "path": "docs/guide.rst",
    "chars": 13263,
    "preview": "Documenting your API with APIFairy\n==================================\n\nAPIFairy can discover and document your API throu"
  },
  {
    "path": "docs/index.rst",
    "chars": 404,
    "preview": ".. APIFairy documentation master file, created by\n   sphinx-quickstart on Sun Sep 27 17:34:58 2020.\n   You can adapt thi"
  },
  {
    "path": "docs/intro.rst",
    "chars": 5972,
    "preview": ".. APIFairy documentation master file, created by\n   sphinx-quickstart on Sun Sep 27 17:34:58 2020.\n   You can adapt thi"
  },
  {
    "path": "docs/make.bat",
    "chars": 795,
    "preview": "@ECHO OFF\r\n\r\npushd %~dp0\r\n\r\nREM Command file for Sphinx documentation\r\n\r\nif \"%SPHINXBUILD%\" == \"\" (\r\n\tset SPHINXBUILD=sp"
  },
  {
    "path": "examples/README.md",
    "chars": 283,
    "preview": "# Examples\n\nFor a non-trivial example that uses Flask, Marshmallow and APIFairy together,\nsee the [microblog-api](https:"
  },
  {
    "path": "examples/app.py",
    "chars": 2286,
    "preview": "\"\"\"Welcome to the APIFairy Simple Example project!\n\n## Overview\n\nThis is a short and simple example that demonstrates ma"
  },
  {
    "path": "examples/app_with_class_views.py",
    "chars": 3089,
    "preview": "\"\"\"Welcome to the APIFairy Simple Example project!\n\n## Overview\n\nThis is a short and simple example that demonstrates ma"
  },
  {
    "path": "pyproject.toml",
    "chars": 1224,
    "preview": "[project]\nname = \"apifairy\"\nversion = \"1.5.2.dev0\"\nauthors = [\n    { name = \"Miguel Grinberg\", email = \"miguel.grinberg@"
  },
  {
    "path": "src/apifairy/__init__.py",
    "chars": 195,
    "preview": "from .core import APIFairy  # noqa: F401\nfrom .decorators import authenticate, arguments, body, response, \\\n    other_re"
  },
  {
    "path": "src/apifairy/core.py",
    "chars": 18078,
    "preview": "from json import dumps\nimport re\nimport sys\ntry:\n    from typing import _AnnotatedAlias\nexcept ImportError:  # pragma: n"
  },
  {
    "path": "src/apifairy/decorators.py",
    "chars": 5187,
    "preview": "from functools import wraps\n\nfrom flask import current_app, Response\nfrom webargs.flaskparser import FlaskParser as Base"
  },
  {
    "path": "src/apifairy/exceptions.py",
    "chars": 153,
    "preview": "class ValidationError(Exception):\n    def __init__(self, status_code, messages):\n        self.status_code = status_code\n"
  },
  {
    "path": "src/apifairy/fields.py",
    "chars": 325,
    "preview": "from marshmallow import ValidationError\nfrom marshmallow.fields import Field\nfrom werkzeug.datastructures import FileSto"
  },
  {
    "path": "src/apifairy/templates/apifairy/elements.html",
    "chars": 598,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-wid"
  },
  {
    "path": "src/apifairy/templates/apifairy/rapidoc.html",
    "chars": 405,
    "preview": "<!doctype html> <!-- Important: must specify -->\n<html>\n  <head>\n    <title>{{ title }} {{ version }}</title>\n    <meta "
  },
  {
    "path": "src/apifairy/templates/apifairy/redoc.html",
    "chars": 574,
    "preview": "<!DOCTYPE html>\n<html>\n  <head>\n    <title>{{ title }} {{ version }}</title>\n    <meta charset=\"utf-8\"/>\n    <meta name="
  },
  {
    "path": "src/apifairy/templates/apifairy/swagger_ui.html",
    "chars": 1207,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <title>{{ title }} {{ version }}</title>\n    <l"
  },
  {
    "path": "tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/test_apifairy.py",
    "chars": 36938,
    "preview": "from io import BytesIO\nimport sys\ntry:\n    from typing import Annotated\nexcept ImportError:\n    Annotated = None\nimport "
  },
  {
    "path": "tox.ini",
    "chars": 634,
    "preview": "[tox]\nenvlist=flake8,py39,py310,py311,py312,py313,py314,pypy3,docs\nskip_missing_interpreters=True\n\n[gh-actions]\npython ="
  }
]

About this extraction

This page contains the full source code of the miguelgrinberg/APIFairy GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 33 files (126.2 KB), approximately 31.8k tokens, and a symbol index with 86 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!