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 " 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 `_ 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//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//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 `_ 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 `_ 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/') @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/') @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/') @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/') @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 `_ extension:: from flask_httpauth import HTTPBasicAuth from apifairy import authenticate auth = HTTPBasicAuth() @app.route('/users/') @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 `, 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/', 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 `_ 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/', 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 ` 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/', 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 `_ - ``redoc``: `ReDoc `_ - ``rapidoc``: `RapiDoc `_ - ``elements``: `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 `_. 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/') @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/", 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 ================================================ {{ title }} {{ version }} ================================================ FILE: src/apifairy/templates/apifairy/rapidoc.html ================================================ {{ title }} {{ version }} ================================================ FILE: src/apifairy/templates/apifairy/redoc.html ================================================ {{ title }} {{ version }} ================================================ FILE: src/apifairy/templates/apifairy/swagger_ui.html ================================================ {{ title }} {{ version }}
================================================ 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/') @response(Schema) def get_string(some_string: 'some_string docs'): # noqa: F722 pass @app.route('/floats/', methods=['POST']) @response(Schema) def get_float(some_float: float): pass if Annotated: @app.route('/integers/', methods=['PUT']) @response(Schema) def get_integer(some_integer: Annotated[int, 1, 'some_integer docs']): pass @app.route('/users//articles/') @response(Schema) def get_article(user_id: Annotated[int, 1], article_id): pass else: @app.route('/integers/', methods=['PUT']) @response(Schema) def get_integer(some_integer: 'some_integer docs'): # noqa: F722 pass @app.route('/users//articles/') @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('/') @response(Schema) def pattern1(): pass @app.route('/foo/') @response(Schema) def pattern2(): pass @app.route('//bar') @response(Schema) def pattern3(): pass @app.route('///baz') @response(Schema) def pattern4(): pass @app.route('/foo//') @response(Schema) def pattern5(): pass @app.route('///') @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