├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── feature_request.md │ └── question.md ├── PULL_REQUEST_TEMPLATE │ └── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── black.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pyup.yml ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── bumpr.rc ├── coverage.rc ├── doc ├── Makefile ├── _static │ ├── apple-180.png │ ├── favicon-128.png │ ├── favicon-196.png │ ├── favicon-512.png │ ├── favicon-64.png │ ├── favicon.ico │ ├── logo-512-nobg.png │ ├── logo-512.png │ └── screenshot-apidoc-quickstart.png ├── _themes │ └── restx │ │ ├── badges.html │ │ ├── layout.html │ │ ├── static │ │ └── restx.css │ │ └── theme.conf ├── api.rst ├── conf.py ├── configuration.rst ├── contributing.rst ├── errors.rst ├── example.rst ├── index.rst ├── installation.rst ├── logging.rst ├── make.bat ├── marshalling.rst ├── mask.rst ├── parsing.rst ├── postman.rst ├── quickstart.rst ├── scaling.rst └── swagger.rst ├── examples ├── resource_class_kwargs ├── todo.py ├── todo_blueprint.py ├── todo_simple.py ├── todomvc.py ├── xml_representation.py └── zoo_app │ ├── complex.py │ ├── requirements.txt │ └── zoo │ ├── __init__.py │ ├── cat.py │ └── dog.py ├── flask_restx ├── __about__.py ├── __init__.py ├── _http.py ├── api.py ├── apidoc.py ├── cors.py ├── errors.py ├── fields.py ├── inputs.py ├── marshalling.py ├── mask.py ├── model.py ├── namespace.py ├── postman.py ├── representations.py ├── reqparse.py ├── resource.py ├── schemas │ ├── __init__.py │ └── oas-2.0.json ├── swagger.py ├── templates │ ├── swagger-ui-css.html │ ├── swagger-ui-libs.html │ └── swagger-ui.html └── utils.py ├── package.json ├── readthedocs.pip ├── requirements ├── develop.pip ├── doc.pip ├── install.pip └── test.pip ├── setup.cfg ├── setup.py ├── tasks.py ├── tests ├── __init__.py ├── benchmarks │ ├── bench_marshalling.py │ └── bench_swagger.py ├── conftest.py ├── legacy │ ├── test_api_legacy.py │ └── test_api_with_blueprint.py ├── postman-v1.schema.json ├── test_accept.py ├── test_api.py ├── test_apidoc.py ├── test_cors.py ├── test_errors.py ├── test_fields.py ├── test_fields_mask.py ├── test_inputs.py ├── test_logging.py ├── test_marshalling.py ├── test_model.py ├── test_namespace.py ├── test_payload.py ├── test_postman.py ├── test_reqparse.py ├── test_schemas.py ├── test_swagger.py ├── test_swagger_utils.py └── test_utils.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | # Matches multiple files with brace expansion notation 13 | # Set default charset 14 | [*.{js,py}] 15 | charset = utf-8 16 | 17 | # 4 space indentation 18 | [*.py] 19 | indent_style = space 20 | indent_size = 4 21 | max_line_length = 120 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Tell us how Flask-RESTX is broken 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### ***** **BEFORE LOGGING AN ISSUE** ***** 11 | 12 | - Is this something you can **debug and fix**? Send a pull request! Bug fixes and documentation fixes are welcome. 13 | - Please check if a similar issue already exists or has been closed before. Seriously, nobody here is getting paid. Help us out and take five minutes to make sure you aren't submitting a duplicate. 14 | - Please review the [guidelines for contributing](https://github.com/python-restx/flask-restx/blob/master/CONTRIBUTING.rst) 15 | 16 | ### **Code** 17 | 18 | ```python 19 | from your_code import your_buggy_implementation 20 | ``` 21 | 22 | ### **Repro Steps** (if applicable) 23 | 1. ... 24 | 2. ... 25 | 3. Broken! 26 | 27 | ### **Expected Behavior** 28 | A description of what you expected to happen. 29 | 30 | ### **Actual Behavior** 31 | A description of the unexpected, buggy behavior. 32 | 33 | ### **Error Messages/Stack Trace** 34 | If applicable, add the stack trace produced by the error 35 | 36 | ### **Environment** 37 | - Python version 38 | - Flask version 39 | - Flask-RESTX version 40 | - Other installed Flask extensions 41 | 42 | ### **Additional Context** 43 | 44 | This is your last chance to provide any pertinent details, don't let this opportunity pass you by! 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Ask a question** 11 | A clear and concise question 12 | 13 | **Additional context** 14 | Add any other context or screenshots about the feature request here. 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Proposed changes 2 | 3 | At a high level, describe your reasoning for making these changes. If you are fixing a bug or resolving a feature request, **please include a link to the issue**. 4 | 5 | ## Types of changes 6 | 7 | What types of changes does your code introduce? 8 | _Put an `x` in the boxes that apply_ 9 | 10 | - [ ] Bugfix (non-breaking change which fixes an issue) 11 | - [ ] New feature (non-breaking change which adds functionality) 12 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 13 | 14 | ## Checklist 15 | 16 | _Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ 17 | 18 | - [ ] I have read the [guidelines for contributing](https://github.com/python-restx/flask-restx/blob/master/CONTRIBUTING.rst) 19 | - [ ] All unit tests pass on my local version with my changes 20 | - [ ] I have added tests that prove my fix is effective or that my feature works 21 | - [ ] I have added necessary documentation (if appropriate) 22 | 23 | ## Further comments 24 | 25 | If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... 26 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: psf/black@stable 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Set up Python 3.9 11 | uses: actions/setup-python@v1 12 | with: 13 | python-version: 3.9 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install ".[dev]" wheel 20 | - name: Fetch web assets 21 | run: inv assets 22 | - name: Publish 23 | env: 24 | TWINE_USERNAME: "__token__" 25 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 26 | run: | 27 | python setup.py sdist bdist_wheel 28 | twine upload dist/* 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - "*" 6 | push: 7 | branches: 8 | - "*" 9 | schedule: 10 | - cron: "0 1 * * *" 11 | workflow_dispatch: 12 | jobs: 13 | unit-tests: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python-version: ["3.9", "3.10", "3.11", "pypy3.9", "3.12"] 19 | flask: ["<3.0.0", ">=3.0.0"] 20 | steps: 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | allow-prereleases: true 26 | - name: Checkout code 27 | uses: actions/checkout@v3 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install "flask${{ matrix.flask }}" 32 | pip install ".[test]" 33 | - name: Test with inv 34 | run: inv cover qa 35 | - name: Codecov 36 | uses: codecov/codecov-action@v1 37 | with: 38 | file: ./coverage.xml 39 | bench: 40 | needs: unit-tests 41 | runs-on: ubuntu-latest 42 | if: github.event_name == 'pull_request' 43 | steps: 44 | - name: Set up Python 3.9 45 | uses: actions/setup-python@v4 46 | with: 47 | python-version: "3.9" 48 | - name: Checkout ${{ github.base_ref }} 49 | uses: actions/checkout@v3 50 | with: 51 | ref: ${{ github.base_ref}} 52 | path: base 53 | - name: Checkout ${{ github.ref }} 54 | uses: actions/checkout@v3 55 | with: 56 | ref: ${{ github.ref}} 57 | path: ref 58 | - name: Install dev dependencies 59 | run: | 60 | python -m pip install --upgrade pip 61 | pip install -e "base[dev]" 62 | - name: Install ci dependencies for ${{ github.base_ref }} 63 | run: pip install -e "base[ci]" 64 | - name: Benchmarks for ${{ github.base_ref }} 65 | run: | 66 | cd base 67 | inv benchmark --max-time 4 --save 68 | mv .benchmarks ../ref/ 69 | - name: Install ci dependencies for ${{ github.ref }} 70 | run: pip install -e "ref[ci]" 71 | - name: Benchmarks for ${{ github.ref }} 72 | run: | 73 | cd ref 74 | inv benchmark --max-time 4 --compare 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | cover 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # Installer logs 27 | pip-log.txt 28 | pip-delete-this-directory.txt 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .coverage 34 | .cache 35 | nosetests.xml 36 | coverage.xml 37 | prof/ 38 | histograms/ 39 | .benchmarks 40 | 41 | # Translations 42 | *.mo 43 | 44 | # Atom 45 | *.cson 46 | 47 | # Mr Developer 48 | .mr.developer.cfg 49 | .project 50 | .pydevproject 51 | 52 | # Rope 53 | .ropeproject 54 | 55 | # Django stuff: 56 | *.log 57 | *.pot 58 | 59 | # Sphinx documentation 60 | doc/_build/ 61 | 62 | # Specifics 63 | flask_restx/static 64 | node_modules 65 | 66 | # pyenv 67 | .python-version 68 | 69 | # Jet Brains 70 | .idea 71 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | # configure updates globally 2 | # default: all 3 | # allowed: all, insecure, False 4 | # update: all 5 | 6 | # configure dependency pinning globally 7 | # default: True 8 | # allowed: True, False 9 | pin: False 10 | 11 | # set the default branch 12 | # default: empty, the default branch on GitHub 13 | # branch: dev 14 | 15 | # update schedule 16 | # default: empty 17 | # allowed: "every day", "every week", .. 18 | # schedule: "every day" 19 | 20 | # search for requirement files 21 | # default: True 22 | # allowed: True, False 23 | # search: True 24 | 25 | # Specify requirement files by hand, default is empty 26 | # default: empty 27 | # allowed: list 28 | # requirements: 29 | # - requirements/staging.txt: 30 | # # update all dependencies and pin them 31 | # update: all 32 | # pin: True 33 | # - requirements/dev.txt: 34 | # # don't update dependencies, use global 'pin' default 35 | # update: False 36 | # - requirements/prod.txt: 37 | # # update insecure only, pin all 38 | # update: insecure 39 | # pin: True 40 | 41 | # add a label to pull requests, default is not set 42 | # requires private repo permissions, even on public repos 43 | # default: empty 44 | label_prs: update 45 | 46 | # assign users to pull requests, default is not set 47 | # requires private repo permissions, even on public repos 48 | # default: empty 49 | # assignees: 50 | # - carl 51 | # - carlsen 52 | 53 | # configure the branch prefix the bot is using 54 | # default: pyup- 55 | branch_prefix: pyup/ 56 | 57 | # set a global prefix for PRs 58 | # default: empty 59 | pr_prefix: "[PyUP]" 60 | 61 | # allow to close stale PRs 62 | # default: True 63 | close_prs: True 64 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | flask-restx is open-source and very open to contributions. 5 | 6 | If you're part of a corporation with an NDA, and you may require updating the license. 7 | See Updating Copyright below 8 | 9 | Submitting issues 10 | ----------------- 11 | 12 | Issues are contributions in a way so don't hesitate 13 | to submit reports on the `official bugtracker`_. 14 | 15 | Provide as much informations as possible to specify the issues: 16 | 17 | - the flask-restx version used 18 | - a stacktrace 19 | - installed applications list 20 | - a code sample to reproduce the issue 21 | - ... 22 | 23 | 24 | Submitting patches (bugfix, features, ...) 25 | ------------------------------------------ 26 | 27 | If you want to contribute some code: 28 | 29 | 1. fork the `official flask-restx repository`_ 30 | 2. Ensure an issue is opened for your feature or bug 31 | 3. create a branch with an explicit name (like ``my-new-feature`` or ``issue-XX``) 32 | 4. do your work in it 33 | 5. Commit your changes. Ensure the commit message includes the issue. Also, if contributing from a corporation, be sure to add a comment with the Copyright information 34 | 6. rebase it on the master branch from the official repository (cleanup your history by performing an interactive rebase) 35 | 7. add your change to the changelog 36 | 8. submit your pull-request 37 | 9. 2 Maintainers should review the code for bugfix and features. 1 maintainer for minor changes (such as docs) 38 | 10. After review, a maintainer a will merge the PR. Maintainers should not merge their own PRs 39 | 40 | There are some rules to follow: 41 | 42 | - your contribution should be documented (if needed) 43 | - your contribution should be tested and the test suite should pass successfully 44 | - your code should be properly formatted (use ``black .`` to format) 45 | - your contribution should support both Python 2 and 3 (use ``tox`` to test) 46 | 47 | You need to install some dependencies to develop on flask-restx: 48 | 49 | .. code-block:: console 50 | 51 | $ pip install -e .[dev] 52 | 53 | An `Invoke `_ ``tasks.py`` is provided to simplify the common tasks: 54 | 55 | .. code-block:: console 56 | 57 | $ inv -l 58 | Available tasks: 59 | 60 | all Run tests, reports and packaging 61 | assets Fetch web assets -- Swagger. Requires NPM (see below) 62 | clean Cleanup all build artifacts 63 | cover Run tests suite with coverage 64 | demo Run the demo 65 | dist Package for distribution 66 | doc Build the documentation 67 | qa Run a quality report 68 | test Run tests suite 69 | tox Run tests against Python versions 70 | 71 | To ensure everything is fine before submission, use ``tox``. 72 | It will run the test suite on all the supported Python version 73 | and ensure the documentation is generating. 74 | 75 | .. code-block:: console 76 | 77 | $ tox 78 | 79 | You also need to ensure your code is compliant with the flask-restx coding standards: 80 | 81 | .. code-block:: console 82 | 83 | $ inv qa 84 | 85 | To ensure everything is fine before committing, you can launch the all in one command: 86 | 87 | .. code-block:: console 88 | 89 | $ inv qa tox 90 | 91 | It will ensure the code meet the coding conventions, runs on every version on python 92 | and the documentation is properly generating. 93 | 94 | .. _official flask-restx repository: https://github.com/python-restx/flask-restx 95 | .. _official bugtracker: https://github.com/python-restx/flask-restx/issues 96 | 97 | Running a local Swagger Server 98 | ------------------------------ 99 | 100 | For local development, you may wish to run a local server. running the following will install a swagger server 101 | 102 | .. code-block:: console 103 | 104 | $ inv assets 105 | 106 | NOTE: You'll need `NPM `_ installed to do this. 107 | If you're new to NPM, also check out `nvm `_ 108 | 109 | Release process 110 | --------------- 111 | 112 | The new releases are pushed on `Pypi.org `_ automatically 113 | from `GitHub Actions `_ when we add a new tag (unless the 114 | tests are failing). 115 | 116 | In order to prepare a new release, you can use `bumpr `_ 117 | which automates a few things. 118 | You first need to install it, then run the ``bumpr`` command. You can then refer 119 | to the `documentation `_ 120 | for further details. 121 | For instance, you would run ``bumpr -m`` (replace ``-m`` with ``-p`` or ``-M`` 122 | depending the expected version). 123 | 124 | Updating Copyright 125 | ------------------ 126 | 127 | If you're a part of a corporation with an NDA, you may be required to update the 128 | LICENSE file. This should be discussed and agreed upon by the project maintainers. 129 | 130 | 1. Check with your legal department first. 131 | 2. Add an appropriate line to the LICENSE file. 132 | 3. When making a commit, add the specific copyright notice. 133 | 134 | Double check with your legal department about their regulations. Not all changes 135 | constitute new or unique work. 136 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Original work Copyright (c) 2013 Twilio, Inc 4 | Modified work Copyright (c) 2014 Axel Haustant 5 | Modified work Copyright (c) 2020 python-restx Authors 6 | 7 | All rights reserved. 8 | 9 | Redistribution and use in source and binary forms, with or without 10 | modification, are permitted provided that the following conditions are met: 11 | 12 | * Redistributions of source code must retain the above copyright notice, this 13 | list of conditions and the following disclaimer. 14 | 15 | * Redistributions in binary form must reproduce the above copyright notice, 16 | this list of conditions and the following disclaimer in the documentation 17 | and/or other materials provided with the distribution. 18 | 19 | * Neither the name of the copyright holder nor the names of its 20 | contributors may be used to endorse or promote products derived from 21 | this software without specific prior written permission. 22 | 23 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 24 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 25 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 26 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 27 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 28 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 29 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 30 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 31 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 32 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 33 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst MANIFEST.in LICENSE 2 | recursive-include flask_restx * 3 | recursive-include requirements *.pip 4 | 5 | global-exclude *.pyc 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Flask RESTX 3 | =========== 4 | 5 | .. image:: https://github.com/python-restx/flask-restx/workflows/Tests/badge.svg?branch=master&event=push 6 | :target: https://github.com/python-restx/flask-restx/actions?query=workflow%3ATests 7 | :alt: Tests status 8 | .. image:: https://codecov.io/gh/python-restx/flask-restx/branch/master/graph/badge.svg 9 | :target: https://codecov.io/gh/python-restx/flask-restx 10 | :alt: Code coverage 11 | .. image:: https://readthedocs.org/projects/flask-restx/badge/?version=latest 12 | :target: https://flask-restx.readthedocs.io/en/latest/ 13 | :alt: Documentation status 14 | .. image:: https://img.shields.io/pypi/l/flask-restx.svg 15 | :target: https://pypi.org/project/flask-restx 16 | :alt: License 17 | .. image:: https://img.shields.io/pypi/pyversions/flask-restx.svg 18 | :target: https://pypi.org/project/flask-restx 19 | :alt: Supported Python versions 20 | .. image:: https://badges.gitter.im/Join%20Chat.svg 21 | :target: https://gitter.im/python-restx?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge 22 | :alt: Join the chat at https://gitter.im/python-restx 23 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 24 | :target: https://github.com/psf/black 25 | :alt: Code style: black 26 | 27 | 28 | Flask-RESTX is a community driven fork of `Flask-RESTPlus `_. 29 | 30 | 31 | Flask-RESTX is an extension for `Flask`_ that adds support for quickly building REST APIs. 32 | Flask-RESTX encourages best practices with minimal setup. 33 | If you are familiar with Flask, Flask-RESTX should be easy to pick up. 34 | It provides a coherent collection of decorators and tools to describe your API 35 | and expose its documentation properly using `Swagger`_. 36 | 37 | 38 | Compatibility 39 | ============= 40 | 41 | Flask-RESTX requires Python 3.9+. 42 | 43 | On Flask Compatibility 44 | ====================== 45 | 46 | Flask and Werkzeug moved to versions 2.0 in March 2020. This caused a breaking change in Flask-RESTX. 47 | 48 | .. list-table:: RESTX and Flask / Werkzeug Compatibility 49 | :widths: 25 25 25 50 | :header-rows: 1 51 | 52 | 53 | * - Flask-RESTX version 54 | - Flask version 55 | - Note 56 | * - <= 0.3.0 57 | - < 2.0.0 58 | - unpinned in Flask-RESTX. Pin your projects! 59 | * - == 0.4.0 60 | - < 2.0.0 61 | - pinned in Flask-RESTX. 62 | * - >= 0.5.0 63 | - < 3.0.0 64 | - unpinned, import statements wrapped for compatibility 65 | * - == 1.2.0 66 | - < 3.0.0 67 | - pinned in Flask-RESTX. 68 | * - >= 1.3.0 69 | - >= 2.0.0 (Flask >= 3.0.0 support) 70 | - unpinned, import statements wrapped for compatibility 71 | * - trunk branch in Github 72 | - >= 2.0.0 (Flask >= 3.0.0 support) 73 | - unpinned, will address issues faster than releases. 74 | 75 | Installation 76 | ============ 77 | 78 | You can install Flask-RESTX with pip: 79 | 80 | .. code-block:: console 81 | 82 | $ pip install flask-restx 83 | 84 | or with easy_install: 85 | 86 | .. code-block:: console 87 | 88 | $ easy_install flask-restx 89 | 90 | 91 | Quick start 92 | =========== 93 | 94 | With Flask-RESTX, you only import the api instance to route and document your endpoints. 95 | 96 | .. code-block:: python 97 | 98 | from flask import Flask 99 | from flask_restx import Api, Resource, fields 100 | 101 | app = Flask(__name__) 102 | api = Api(app, version='1.0', title='TodoMVC API', 103 | description='A simple TodoMVC API', 104 | ) 105 | 106 | ns = api.namespace('todos', description='TODO operations') 107 | 108 | todo = api.model('Todo', { 109 | 'id': fields.Integer(readonly=True, description='The task unique identifier'), 110 | 'task': fields.String(required=True, description='The task details') 111 | }) 112 | 113 | 114 | class TodoDAO(object): 115 | def __init__(self): 116 | self.counter = 0 117 | self.todos = [] 118 | 119 | def get(self, id): 120 | for todo in self.todos: 121 | if todo['id'] == id: 122 | return todo 123 | api.abort(404, "Todo {} doesn't exist".format(id)) 124 | 125 | def create(self, data): 126 | todo = data 127 | todo['id'] = self.counter = self.counter + 1 128 | self.todos.append(todo) 129 | return todo 130 | 131 | def update(self, id, data): 132 | todo = self.get(id) 133 | todo.update(data) 134 | return todo 135 | 136 | def delete(self, id): 137 | todo = self.get(id) 138 | self.todos.remove(todo) 139 | 140 | 141 | DAO = TodoDAO() 142 | DAO.create({'task': 'Build an API'}) 143 | DAO.create({'task': '?????'}) 144 | DAO.create({'task': 'profit!'}) 145 | 146 | 147 | @ns.route('/') 148 | class TodoList(Resource): 149 | '''Shows a list of all todos, and lets you POST to add new tasks''' 150 | @ns.doc('list_todos') 151 | @ns.marshal_list_with(todo) 152 | def get(self): 153 | '''List all tasks''' 154 | return DAO.todos 155 | 156 | @ns.doc('create_todo') 157 | @ns.expect(todo) 158 | @ns.marshal_with(todo, code=201) 159 | def post(self): 160 | '''Create a new task''' 161 | return DAO.create(api.payload), 201 162 | 163 | 164 | @ns.route('/') 165 | @ns.response(404, 'Todo not found') 166 | @ns.param('id', 'The task identifier') 167 | class Todo(Resource): 168 | '''Show a single todo item and lets you delete them''' 169 | @ns.doc('get_todo') 170 | @ns.marshal_with(todo) 171 | def get(self, id): 172 | '''Fetch a given resource''' 173 | return DAO.get(id) 174 | 175 | @ns.doc('delete_todo') 176 | @ns.response(204, 'Todo deleted') 177 | def delete(self, id): 178 | '''Delete a task given its identifier''' 179 | DAO.delete(id) 180 | return '', 204 181 | 182 | @ns.expect(todo) 183 | @ns.marshal_with(todo) 184 | def put(self, id): 185 | '''Update a task given its identifier''' 186 | return DAO.update(id, api.payload) 187 | 188 | 189 | if __name__ == '__main__': 190 | app.run(debug=True) 191 | 192 | 193 | Contributors 194 | ============ 195 | 196 | Flask-RESTX is brought to you by @python-restx. Since early 2019 @SteadBytes, 197 | @a-luna, @j5awry, @ziirish volunteered to help @python-restx keep the project up 198 | and running, they did so for a long time! Since the beginning of 2023, the project 199 | is maintained by @peter-doggart with help from @ziirish. 200 | Of course everyone is welcome to contribute and we will be happy to review your 201 | PR's or answer to your issues. 202 | 203 | 204 | Documentation 205 | ============= 206 | 207 | The documentation is hosted `on Read the Docs `_ 208 | 209 | 210 | .. _Flask: https://flask.palletsprojects.com/ 211 | .. _Swagger: https://swagger.io/ 212 | 213 | 214 | Contribution 215 | ============ 216 | Want to contribute! That's awesome! Check out `CONTRIBUTING.rst! `_ 217 | -------------------------------------------------------------------------------- /bumpr.rc: -------------------------------------------------------------------------------- 1 | [bumpr] 2 | file = flask_restx/__about__.py 3 | vcs = git 4 | commit = true 5 | tag = true 6 | push = true 7 | tests = tox -e py38 8 | clean = 9 | inv clean 10 | files = 11 | README.rst 12 | 13 | [bump] 14 | unsuffix = true 15 | 16 | [prepare] 17 | part = patch 18 | suffix = dev 19 | 20 | [readthedoc] 21 | id = flask-restx 22 | 23 | [replace] 24 | dev = ?branch=master 25 | stable = ?tag={version} 26 | -------------------------------------------------------------------------------- /coverage.rc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = flask_restx 3 | branch = True 4 | omit = 5 | /tests/* 6 | 7 | [report] 8 | # Regexes for lines to exclude from consideration 9 | exclude_lines = 10 | # Have to re-enable the standard pragma 11 | pragma: no cover 12 | 13 | # Don't complain about missing debug-only code: 14 | def __repr__ 15 | if self\.debug 16 | 17 | # Don't complain if tests don't hit defensive assertion code: 18 | raise AssertionError 19 | raise NotImplementedError 20 | 21 | # Don't complain if non-runnable code isn't run: 22 | if 0: 23 | if __name__ == .__main__.: 24 | 25 | ignore_errors = True 26 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Flask-RESTX.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Flask-RESTX.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Flask-RESTX" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Flask-RESTX" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /doc/_static/apple-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-restx/flask-restx/ffb079c696e7901b0b27526810ff32c52beb8aa0/doc/_static/apple-180.png -------------------------------------------------------------------------------- /doc/_static/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-restx/flask-restx/ffb079c696e7901b0b27526810ff32c52beb8aa0/doc/_static/favicon-128.png -------------------------------------------------------------------------------- /doc/_static/favicon-196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-restx/flask-restx/ffb079c696e7901b0b27526810ff32c52beb8aa0/doc/_static/favicon-196.png -------------------------------------------------------------------------------- /doc/_static/favicon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-restx/flask-restx/ffb079c696e7901b0b27526810ff32c52beb8aa0/doc/_static/favicon-512.png -------------------------------------------------------------------------------- /doc/_static/favicon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-restx/flask-restx/ffb079c696e7901b0b27526810ff32c52beb8aa0/doc/_static/favicon-64.png -------------------------------------------------------------------------------- /doc/_static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-restx/flask-restx/ffb079c696e7901b0b27526810ff32c52beb8aa0/doc/_static/favicon.ico -------------------------------------------------------------------------------- /doc/_static/logo-512-nobg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-restx/flask-restx/ffb079c696e7901b0b27526810ff32c52beb8aa0/doc/_static/logo-512-nobg.png -------------------------------------------------------------------------------- /doc/_static/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-restx/flask-restx/ffb079c696e7901b0b27526810ff32c52beb8aa0/doc/_static/logo-512.png -------------------------------------------------------------------------------- /doc/_static/screenshot-apidoc-quickstart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-restx/flask-restx/ffb079c696e7901b0b27526810ff32c52beb8aa0/doc/_static/screenshot-apidoc-quickstart.png -------------------------------------------------------------------------------- /doc/_themes/restx/badges.html: -------------------------------------------------------------------------------- 1 | 2 | {% if theme_badges %} 3 |
4 | {% for badge, target, alt in theme_badges %} 5 |

{{alt}}

6 | {% endfor %} 7 | {% endif %} 8 | -------------------------------------------------------------------------------- /doc/_themes/restx/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "alabaster/layout.html" %} 2 | 3 | {%- block extrahead %} 4 | {% if theme_favicons %} 5 | {% for size, file in theme_favicons.items() %} 6 | 7 | {% endfor %} 8 | {% endif %} 9 | {{ super() }} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /doc/_themes/restx/static/restx.css: -------------------------------------------------------------------------------- 1 | @import url("alabaster.css"); 2 | 3 | .sphinxsidebar p.badge a { 4 | border: none; 5 | } 6 | 7 | .sphinxsidebar hr.badges { 8 | border: 0; 9 | border-bottom: 1px dashed #aaa; 10 | background: none; 11 | /*width: 100%;*/ 12 | } 13 | -------------------------------------------------------------------------------- /doc/_themes/restx/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = alabaster 3 | stylesheet = restx.css 4 | 5 | [options] 6 | favicons= 7 | badges= 8 | -------------------------------------------------------------------------------- /doc/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | API 4 | === 5 | 6 | .. currentmodule:: flask_restx 7 | 8 | Core 9 | ---- 10 | 11 | .. autoclass:: Api 12 | :members: 13 | :inherited-members: 14 | 15 | .. autoclass:: Namespace 16 | :members: 17 | 18 | 19 | .. autoclass:: Resource 20 | :members: 21 | :inherited-members: 22 | 23 | 24 | Models 25 | ------ 26 | 27 | .. autoclass:: flask_restx.Model 28 | :members: 29 | 30 | All fields accept a ``required`` boolean and a ``description`` string in ``kwargs``. 31 | 32 | .. automodule:: flask_restx.fields 33 | :members: 34 | 35 | 36 | Serialization 37 | ------------- 38 | .. currentmodule:: flask_restx 39 | 40 | .. autofunction:: marshal 41 | 42 | .. autofunction:: marshal_with 43 | 44 | .. autofunction:: marshal_with_field 45 | 46 | .. autoclass:: flask_restx.mask.Mask 47 | :members: 48 | 49 | .. autofunction:: flask_restx.mask.apply 50 | 51 | 52 | Request parsing 53 | --------------- 54 | 55 | .. automodule:: flask_restx.reqparse 56 | :members: 57 | 58 | Inputs 59 | ~~~~~~ 60 | 61 | .. automodule:: flask_restx.inputs 62 | :members: 63 | 64 | 65 | Errors 66 | ------ 67 | 68 | .. automodule:: flask_restx.errors 69 | :members: 70 | 71 | .. autoexception:: flask_restx.fields.MarshallingError 72 | 73 | .. autoexception:: flask_restx.mask.MaskError 74 | 75 | .. autoexception:: flask_restx.mask.ParseError 76 | 77 | 78 | Schemas 79 | ------- 80 | 81 | .. automodule:: flask_restx.schemas 82 | :members: 83 | 84 | 85 | Internals 86 | --------- 87 | 88 | These are internal classes or helpers. 89 | Most of the time you shouldn't have to deal directly with them. 90 | 91 | .. autoclass:: flask_restx.api.SwaggerView 92 | 93 | .. autoclass:: flask_restx.swagger.Swagger 94 | 95 | .. autoclass:: flask_restx.postman.PostmanCollectionV1 96 | 97 | .. automodule:: flask_restx.utils 98 | :members: 99 | -------------------------------------------------------------------------------- /doc/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | Flask-RESTX provides the following `Flask configuration values `_: 5 | 6 | Note: Values with no additional description should be covered in more detail 7 | elsewhere in the documentation. If not, please open an issue on GitHub. 8 | 9 | .. py:data:: RESTX_JSON 10 | 11 | Provide global configuration options for JSON serialisation as a :class:`dict` 12 | of :func:`json.dumps` keyword arguments. 13 | 14 | .. py:data:: RESTX_VALIDATE 15 | 16 | Whether to enforce payload validation by default when using the 17 | ``@api.expect()`` decorator. See the `@api.expect() 18 | `__ documentation for details. 19 | This setting defaults to ``False``. 20 | 21 | .. py:data:: RESTX_MASK_HEADER 22 | 23 | Choose the name of the *Header* that will contain the masks to apply to your 24 | answer. See the `Fields masks `__ documentation for details. 25 | This setting defaults to ``X-Fields``. 26 | 27 | .. py:data:: RESTX_MASK_SWAGGER 28 | 29 | Whether to enable the mask documentation in your swagger or not. See the 30 | `mask usage `__ documentation for details. 31 | This setting defaults to ``True``. 32 | 33 | .. py:data:: RESTX_INCLUDE_ALL_MODELS 34 | 35 | This option allows you to include all defined models in the generated Swagger 36 | documentation, even if they are not explicitly used in either ``expect`` nor 37 | ``marshal_with`` decorators. 38 | This setting defaults to ``False``. 39 | 40 | .. py:data:: BUNDLE_ERRORS 41 | 42 | Bundle all the validation errors instead of returning only the first one 43 | encountered. See the `Error Handling `__ section 44 | of the documentation for details. 45 | This setting defaults to ``False``. 46 | 47 | .. py:data:: ERROR_404_HELP 48 | 49 | .. py:data:: HTTP_BASIC_AUTH_REALM 50 | 51 | .. py:data:: SWAGGER_VALIDATOR_URL 52 | 53 | .. py:data:: SWAGGER_UI_DOC_EXPANSION 54 | 55 | .. py:data:: SWAGGER_UI_OPERATION_ID 56 | 57 | .. py:data:: SWAGGER_UI_REQUEST_DURATION 58 | 59 | .. py:data:: SWAGGER_UI_OAUTH_APP_NAME 60 | 61 | .. py:data:: SWAGGER_UI_OAUTH_CLIENT_ID 62 | 63 | .. py:data:: SWAGGER_UI_OAUTH_REALM 64 | 65 | .. py:data:: SWAGGER_SUPPORTED_SUBMIT_METHODS 66 | -------------------------------------------------------------------------------- /doc/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /doc/errors.rst: -------------------------------------------------------------------------------- 1 | Error handling 2 | ============== 3 | 4 | .. currentmodule:: flask_restx 5 | 6 | HTTPException handling 7 | ---------------------- 8 | 9 | Werkzeug HTTPException are automatically properly seriliazed 10 | reusing the description attribute. 11 | 12 | .. code-block:: python 13 | 14 | from werkzeug.exceptions import BadRequest 15 | raise BadRequest() 16 | 17 | will return a 400 HTTP code and output 18 | 19 | .. code-block:: json 20 | 21 | { 22 | "message": "The browser (or proxy) sent a request that this server could not understand." 23 | } 24 | 25 | whereas this: 26 | 27 | .. code-block:: python 28 | 29 | from werkzeug.exceptions import BadRequest 30 | raise BadRequest('My custom message') 31 | 32 | will output 33 | 34 | .. code-block:: json 35 | 36 | { 37 | "message": "My custom message" 38 | } 39 | 40 | You can attach extras attributes to the output by providing a data attribute to your exception. 41 | 42 | .. code-block:: python 43 | 44 | from werkzeug.exceptions import BadRequest 45 | e = BadRequest('My custom message') 46 | e.data = {'custom': 'value'} 47 | raise e 48 | 49 | will output 50 | 51 | .. code-block:: json 52 | 53 | { 54 | "message": "My custom message", 55 | "custom": "value" 56 | } 57 | 58 | The Flask abort helper 59 | ---------------------- 60 | 61 | The :meth:`abort ` helper 62 | properly wraps errors into a :exc:`~werkzeug.exceptions.HTTPException` 63 | so it will have the same behavior. 64 | 65 | .. code-block:: python 66 | 67 | from flask import abort 68 | abort(400) 69 | 70 | will return a 400 HTTP code and output 71 | 72 | .. code-block:: json 73 | 74 | { 75 | "message": "The browser (or proxy) sent a request that this server could not understand." 76 | } 77 | 78 | whereas this: 79 | 80 | .. code-block:: python 81 | 82 | from flask import abort 83 | abort(400, 'My custom message') 84 | 85 | will output 86 | 87 | .. code-block:: json 88 | 89 | { 90 | "message": "My custom message" 91 | } 92 | 93 | 94 | The Flask-RESTX abort helper 95 | ------------------------------- 96 | 97 | The :func:`errors.abort` and the :meth:`Namespace.abort` helpers 98 | works like the original Flask :func:`flask.abort` 99 | but it will also add the keyword arguments to the response. 100 | 101 | .. code-block:: python 102 | 103 | from flask_restx import abort 104 | abort(400, custom='value') 105 | 106 | will return a 400 HTTP code and output 107 | 108 | .. code-block:: json 109 | 110 | { 111 | "message": "The browser (or proxy) sent a request that this server could not understand.", 112 | "custom": "value" 113 | } 114 | 115 | whereas this: 116 | 117 | .. code-block:: python 118 | 119 | from flask import abort 120 | abort(400, 'My custom message', custom='value') 121 | 122 | will output 123 | 124 | .. code-block:: json 125 | 126 | { 127 | "message": "My custom message", 128 | "custom": "value" 129 | } 130 | 131 | 132 | The ``@api.errorhandler`` decorator 133 | ----------------------------------- 134 | 135 | The :meth:`@api.errorhandler ` decorator 136 | allows you to register a specific handler for a given exception (or any exceptions inherited from it), in the same manner 137 | that you can do with Flask/Blueprint :meth:`@errorhandler ` decorator. 138 | 139 | .. code-block:: python 140 | 141 | @api.errorhandler(RootException) 142 | def handle_root_exception(error): 143 | '''Return a custom message and 400 status code''' 144 | return {'message': 'What you want'}, 400 145 | 146 | 147 | @api.errorhandler(CustomException) 148 | def handle_custom_exception(error): 149 | '''Return a custom message and 400 status code''' 150 | return {'message': 'What you want'}, 400 151 | 152 | 153 | @api.errorhandler(AnotherException) 154 | def handle_another_exception(error): 155 | '''Return a custom message and 500 status code''' 156 | return {'message': error.specific} 157 | 158 | 159 | @api.errorhandler(FakeException) 160 | def handle_fake_exception_with_header(error): 161 | '''Return a custom message and 400 status code''' 162 | return {'message': error.message}, 400, {'My-Header': 'Value'} 163 | 164 | 165 | @api.errorhandler(NoResultFound) 166 | def handle_no_result_exception(error): 167 | '''Return a custom not found error message and 404 status code''' 168 | return {'message': error.specific}, 404 169 | 170 | 171 | .. note :: 172 | 173 | A "NoResultFound" error with description is required by the OpenAPI 2.0 spec. The docstring in the error handle function is output in the swagger.json as the description. 174 | 175 | You can also document the error: 176 | 177 | .. code-block:: python 178 | 179 | @api.errorhandler(FakeException) 180 | @api.marshal_with(error_fields, code=400) 181 | @api.header('My-Header', 'Some description') 182 | def handle_fake_exception_with_header(error): 183 | '''This is a custom error''' 184 | return {'message': error.message}, 400, {'My-Header': 'Value'} 185 | 186 | 187 | @api.route('/test/') 188 | class TestResource(Resource): 189 | def get(self): 190 | ''' 191 | Do something 192 | 193 | :raises CustomException: In case of something 194 | ''' 195 | pass 196 | 197 | In this example, the ``:raise:`` docstring will be automatically extracted 198 | and the response 400 will be documented properly. 199 | 200 | 201 | It also allows for overriding the default error handler when used without parameter: 202 | 203 | .. code-block:: python 204 | 205 | @api.errorhandler 206 | def default_error_handler(error): 207 | '''Default error handler''' 208 | return {'message': str(error)}, getattr(error, 'code', 500) 209 | 210 | .. note :: 211 | 212 | Flask-RESTX will return a message in the error response by default. 213 | If a custom response is required as an error and the message field is not needed, 214 | it can be disabled by setting ``ERROR_INCLUDE_MESSAGE`` to ``False`` in your application config. 215 | 216 | Error handlers can also be registered on namespaces. An error handler registered on a namespace 217 | will override one registered on the api. 218 | 219 | 220 | .. code-block:: python 221 | 222 | ns = Namespace('cats', description='Cats related operations') 223 | 224 | @ns.errorhandler 225 | def specific_namespace_error_handler(error): 226 | '''Namespace error handler''' 227 | return {'message': str(error)}, getattr(error, 'code', 500) 228 | -------------------------------------------------------------------------------- /doc/example.rst: -------------------------------------------------------------------------------- 1 | Full example 2 | ============ 3 | 4 | Here is a full example of a `TodoMVC `_ API. 5 | 6 | .. code-block:: python 7 | 8 | from flask import Flask 9 | from flask_restx import Api, Resource, fields 10 | from werkzeug.middleware.proxy_fix import ProxyFix 11 | 12 | app = Flask(__name__) 13 | app.wsgi_app = ProxyFix(app.wsgi_app) 14 | api = Api(app, version='1.0', title='TodoMVC API', 15 | description='A simple TodoMVC API', 16 | ) 17 | 18 | ns = api.namespace('todos', description='TODO operations') 19 | 20 | todo = api.model('Todo', { 21 | 'id': fields.Integer(readonly=True, description='The task unique identifier'), 22 | 'task': fields.String(required=True, description='The task details') 23 | }) 24 | 25 | 26 | class TodoDAO(object): 27 | def __init__(self): 28 | self.counter = 0 29 | self.todos = [] 30 | 31 | def get(self, id): 32 | for todo in self.todos: 33 | if todo['id'] == id: 34 | return todo 35 | api.abort(404, "Todo {} doesn't exist".format(id)) 36 | 37 | def create(self, data): 38 | todo = data 39 | todo['id'] = self.counter = self.counter + 1 40 | self.todos.append(todo) 41 | return todo 42 | 43 | def update(self, id, data): 44 | todo = self.get(id) 45 | todo.update(data) 46 | return todo 47 | 48 | def delete(self, id): 49 | todo = self.get(id) 50 | self.todos.remove(todo) 51 | 52 | 53 | DAO = TodoDAO() 54 | DAO.create({'task': 'Build an API'}) 55 | DAO.create({'task': '?????'}) 56 | DAO.create({'task': 'profit!'}) 57 | 58 | 59 | @ns.route('/') 60 | class TodoList(Resource): 61 | '''Shows a list of all todos, and lets you POST to add new tasks''' 62 | @ns.doc('list_todos') 63 | @ns.marshal_list_with(todo) 64 | def get(self): 65 | '''List all tasks''' 66 | return DAO.todos 67 | 68 | @ns.doc('create_todo') 69 | @ns.expect(todo) 70 | @ns.marshal_with(todo, code=201) 71 | def post(self): 72 | '''Create a new task''' 73 | return DAO.create(api.payload), 201 74 | 75 | 76 | @ns.route('/') 77 | @ns.response(404, 'Todo not found') 78 | @ns.param('id', 'The task identifier') 79 | class Todo(Resource): 80 | '''Show a single todo item and lets you delete them''' 81 | @ns.doc('get_todo') 82 | @ns.marshal_with(todo) 83 | def get(self, id): 84 | '''Fetch a given resource''' 85 | return DAO.get(id) 86 | 87 | @ns.doc('delete_todo') 88 | @ns.response(204, 'Todo deleted') 89 | def delete(self, id): 90 | '''Delete a task given its identifier''' 91 | DAO.delete(id) 92 | return '', 204 93 | 94 | @ns.expect(todo) 95 | @ns.marshal_with(todo) 96 | def put(self, id): 97 | '''Update a task given its identifier''' 98 | return DAO.update(id, api.payload) 99 | 100 | 101 | if __name__ == '__main__': 102 | app.run(debug=True) 103 | 104 | 105 | 106 | You can find other examples in the `github repository examples folder`_. 107 | 108 | .. _github repository examples folder: https://github.com/python-restx/flask-restx/tree/master/examples 109 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. Flask-RESTX documentation master file, created by 2 | sphinx-quickstart on Wed Aug 13 17:07:14 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Flask-RESTX's documentation! 7 | ======================================= 8 | 9 | Flask-RESTX is an extension for Flask that adds support for quickly building REST APIs. 10 | Flask-RESTX encourages best practices with minimal setup. 11 | If you are familiar with Flask, Flask-RESTX should be easy to pick up. 12 | It provides a coherent collection of decorators and tools to describe your API 13 | and expose its documentation properly (using Swagger). 14 | 15 | Flask-RESTX is a community driven fork of `Flask-RESTPlus 16 | `_ 17 | 18 | 19 | Why did we fork? 20 | ================ 21 | 22 | The community has decided to fork the project due to lack of response from the 23 | original author @noirbizarre. We have been discussing this eventuality for 24 | `a long time `_. 25 | 26 | Things evolved a bit since that discussion and a few of us have been granted 27 | maintainers access to the github project, but only the original author has 28 | access rights on the PyPi project. As such, we been unable to make any actual 29 | releases. To prevent this project from dying out, we have forked it to continue 30 | development and to support our users. 31 | 32 | 33 | Compatibility 34 | ============= 35 | 36 | Flask-RESTX requires Python 3.9+. 37 | 38 | 39 | Installation 40 | ============ 41 | 42 | You can install Flask-RESTX with pip: 43 | 44 | .. code-block:: console 45 | 46 | $ pip install flask-restx 47 | 48 | or with easy_install: 49 | 50 | .. code-block:: console 51 | 52 | $ easy_install flask-restx 53 | 54 | 55 | Documentation 56 | ============= 57 | 58 | This part of the documentation will show you how to get started in using 59 | Flask-RESTX with Flask. 60 | 61 | .. toctree:: 62 | :maxdepth: 2 63 | 64 | installation 65 | quickstart 66 | marshalling 67 | parsing 68 | errors 69 | mask 70 | swagger 71 | logging 72 | postman 73 | scaling 74 | example 75 | configuration 76 | 77 | 78 | API Reference 79 | ------------- 80 | 81 | If you are looking for information on a specific function, class or 82 | method, this part of the documentation is for you. 83 | 84 | .. toctree:: 85 | :maxdepth: 2 86 | 87 | api 88 | 89 | Additional Notes 90 | ---------------- 91 | 92 | .. toctree:: 93 | :maxdepth: 2 94 | 95 | contributing 96 | 97 | 98 | Indices and tables 99 | ================== 100 | 101 | * :ref:`genindex` 102 | * :ref:`modindex` 103 | * :ref:`search` 104 | -------------------------------------------------------------------------------- /doc/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============ 5 | 6 | Install Flask-RESTX with ``pip``: 7 | 8 | .. code-block:: console 9 | 10 | pip install flask-restx 11 | 12 | 13 | The development version can be downloaded from 14 | `GitHub `_. 15 | 16 | .. code-block:: console 17 | 18 | git clone https://github.com/python-restx/flask-restx.git 19 | cd flask-restx 20 | pip install -e .[dev,test] 21 | 22 | 23 | Flask-RESTX requires Python version 3.9+. 24 | It's also working with PyPy and PyPy3. 25 | -------------------------------------------------------------------------------- /doc/logging.rst: -------------------------------------------------------------------------------- 1 | Logging 2 | =============== 3 | 4 | Flask-RESTX extends `Flask's logging `_ 5 | by providing each ``API`` and ``Namespace`` it's own standard Python :class:`logging.Logger` instance. 6 | This allows separation of logging on a per namespace basis to allow more fine-grained detail and configuration. 7 | 8 | By default, these loggers inherit configuration from the Flask application object logger. 9 | 10 | .. code-block:: python 11 | 12 | import logging 13 | 14 | import flask 15 | 16 | from flask_restx import Api, Resource 17 | 18 | # configure root logger 19 | logging.basicConfig(level=logging.INFO) 20 | 21 | app = flask.Flask(__name__) 22 | 23 | api = Api(app) 24 | 25 | 26 | # each of these loggers uses configuration from app.logger 27 | ns1 = api.namespace('api/v1', description='test') 28 | ns2 = api.namespace('api/v2', description='test') 29 | 30 | 31 | @ns1.route('/my-resource') 32 | class MyResource(Resource): 33 | def get(self): 34 | # will log 35 | ns1.logger.info("hello from ns1") 36 | return {"message": "hello"} 37 | 38 | 39 | @ns2.route('/my-resource') 40 | class MyNewResource(Resource): 41 | def get(self): 42 | # won't log due to INFO log level from app.logger 43 | ns2.logger.debug("hello from ns2") 44 | return {"message": "hello"} 45 | 46 | 47 | Loggers can be configured individually to override the configuration from the Flask 48 | application object logger. In the above example, ``ns2`` log level can be set to 49 | ``DEBUG`` individually: 50 | 51 | .. code-block:: python 52 | 53 | # ns1 will have log level INFO from app.logger 54 | ns1 = api.namespace('api/v1', description='test') 55 | 56 | # ns2 will have log level DEBUG 57 | ns2 = api.namespace('api/v2', description='test') 58 | ns2.logger.setLevel(logging.DEBUG) 59 | 60 | 61 | @ns1.route('/my-resource') 62 | class MyResource(Resource): 63 | def get(self): 64 | # will log 65 | ns1.logger.info("hello from ns1") 66 | return {"message": "hello"} 67 | 68 | 69 | @ns2.route('/my-resource') 70 | class MyNewResource(Resource): 71 | def get(self): 72 | # will log 73 | ns2.logger.debug("hello from ns2") 74 | return {"message": "hello"} 75 | 76 | 77 | Adding additional handlers: 78 | 79 | 80 | .. code-block:: python 81 | 82 | # configure a file handler for ns1 only 83 | ns1 = api.namespace('api/v1') 84 | fh = logging.FileHandler("v1.log") 85 | ns1.logger.addHandler(fh) 86 | 87 | ns2 = api.namespace('api/v2') 88 | 89 | 90 | @ns1.route('/my-resource') 91 | class MyResource(Resource): 92 | def get(self): 93 | # will log to *both* v1.log file and app.logger handlers 94 | ns1.logger.info("hello from ns1") 95 | return {"message": "hello"} 96 | 97 | 98 | @ns2.route('/my-resource') 99 | class MyNewResource(Resource): 100 | def get(self): 101 | # will log to *only* app.logger handlers 102 | ns2.logger.info("hello from ns2") 103 | return {"message": "hello"} -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.https://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Flask-RESTX.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Flask-RESTX.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /doc/mask.rst: -------------------------------------------------------------------------------- 1 | Fields masks 2 | ============ 3 | 4 | Flask-RESTX support partial object fetching (aka. fields mask) 5 | by supplying a custom header in the request. 6 | 7 | By default the header is ``X-Fields`` 8 | but it can be changed with the ``RESTX_MASK_HEADER`` parameter. 9 | 10 | Syntax 11 | ------ 12 | 13 | The syntax is actually quite simple. 14 | You just provide a coma separated list of field names, 15 | optionally wrapped in brackets. 16 | 17 | .. code-block:: python 18 | 19 | # These two mask are equivalents 20 | mask = '{name,age}' 21 | # or 22 | mask = 'name,age' 23 | data = requests.get('/some/url/', headers={'X-Fields': mask}) 24 | assert len(data) == 2 25 | assert 'name' in data 26 | assert 'age' in data 27 | 28 | To specify a nested fields mask, 29 | simply provide it in bracket following the field name: 30 | 31 | .. code-block:: python 32 | 33 | mask = '{name, age, pet{name}}' 34 | 35 | Nesting specification works with nested object or list of objects: 36 | 37 | .. code-block:: python 38 | 39 | # Will apply the mask {name} to each pet 40 | # in the pets list. 41 | mask = '{name, age, pets{name}}' 42 | 43 | There is a special star token meaning "all remaining fields". 44 | It allows to only specify nested filtering: 45 | 46 | .. code-block:: python 47 | 48 | # Will apply the mask {name} to each pet 49 | # in the pets list and take all other root fields 50 | # without filtering. 51 | mask = '{pets{name},*}' 52 | 53 | # Will not filter anything 54 | mask = '*' 55 | 56 | 57 | Usage 58 | ----- 59 | 60 | By default, each time you use ``api.marshal`` or ``@api.marshal_with``, 61 | the mask will be automatically applied if the header is present. 62 | 63 | The header will be exposed as a Swagger parameter each time you use the 64 | ``@api.marshal_with`` decorator. 65 | 66 | As Swagger does not permit exposing a global header once 67 | it can make your Swagger specifications a lot more verbose. 68 | You can disable this behavior by setting ``RESTX_MASK_SWAGGER`` to ``False``. 69 | 70 | You can also specify a default mask that will be applied if no header mask is found. 71 | 72 | .. code-block:: python 73 | 74 | class MyResource(Resource): 75 | @api.marshal_with(my_model, mask='name,age') 76 | def get(self): 77 | pass 78 | 79 | 80 | Default mask can also be handled at model level: 81 | 82 | .. code-block:: python 83 | 84 | model = api.model('Person', { 85 | 'name': fields.String, 86 | 'age': fields.Integer, 87 | 'boolean': fields.Boolean, 88 | }, mask='{name,age}') 89 | 90 | 91 | It will be exposed into the model `x-mask` vendor field: 92 | 93 | .. code-block:: JSON 94 | 95 | {"definitions": { 96 | "Test": { 97 | "properties": { 98 | "age": {"type": "integer"}, 99 | "boolean": {"type": "boolean"}, 100 | "name": {"type": "string"} 101 | }, 102 | "x-mask": "{name,age}" 103 | } 104 | }} 105 | 106 | To override default masks, you need to give another mask or pass `*` as mask. 107 | -------------------------------------------------------------------------------- /doc/postman.rst: -------------------------------------------------------------------------------- 1 | Postman 2 | ======= 3 | 4 | To help you testing, you can export your API as a `Postman`_ collection. 5 | 6 | .. code-block:: python 7 | 8 | from flask import json 9 | 10 | from myapp import api 11 | 12 | urlvars = False # Build query strings in URLs 13 | swagger = True # Export Swagger specifications 14 | data = api.as_postman(urlvars=urlvars, swagger=swagger) 15 | print(json.dumps(data)) 16 | 17 | 18 | .. _Postman: https://www.getpostman.com/ 19 | -------------------------------------------------------------------------------- /doc/scaling.rst: -------------------------------------------------------------------------------- 1 | .. _scaling: 2 | 3 | Scaling your project 4 | ==================== 5 | 6 | .. currentmodule:: flask_restx 7 | 8 | This page covers building a slightly more complex Flask-RESTX app that will 9 | cover out some best practices when setting up a real-world Flask-RESTX-based API. 10 | The :ref:`quickstart` section is great for getting started with your first Flask-RESTX app, 11 | so if you're new to Flask-RESTX you'd be better off checking that out first. 12 | 13 | 14 | Multiple namespaces 15 | ------------------- 16 | 17 | There are many different ways to organize your Flask-RESTX app, 18 | but here we'll describe one that scales pretty well with larger apps 19 | and maintains a nice level of organization. 20 | 21 | Flask-RESTX provides a way to use almost the same pattern as Flask's `blueprint`. 22 | The main idea is to split your app into reusable namespaces. 23 | 24 | Here's an example directory structure:: 25 | 26 | project/ 27 | ├── app.py 28 | ├── core 29 | │   ├── __init__.py 30 | │   ├── utils.py 31 | │   └── ... 32 | └── apis 33 | ├── __init__.py 34 | ├── namespace1.py 35 | ├── namespace2.py 36 | ├── ... 37 | └── namespaceX.py 38 | 39 | 40 | The `app` module will serve as a main application entry point following one of the classic 41 | Flask patterns (See :doc:`flask:patterns/packages` and :doc:`flask:patterns/appfactories`). 42 | 43 | The `core` module is an example, it contains the business logic. 44 | In fact, you call it whatever you want, and there can be many packages. 45 | 46 | The `apis` package will be your main API entry point that you need to import and register on the application, 47 | whereas the namespaces modules are reusable namespaces designed like you would do with Flask's Blueprint. 48 | 49 | A namespace module contains models and resources declarations. 50 | For example: 51 | 52 | .. code-block:: Python 53 | 54 | from flask_restx import Namespace, Resource, fields 55 | 56 | api = Namespace('cats', description='Cats related operations') 57 | 58 | cat = api.model('Cat', { 59 | 'id': fields.String(required=True, description='The cat identifier'), 60 | 'name': fields.String(required=True, description='The cat name'), 61 | }) 62 | 63 | CATS = [ 64 | {'id': 'felix', 'name': 'Felix'}, 65 | ] 66 | 67 | @api.route('/') 68 | class CatList(Resource): 69 | @api.doc('list_cats') 70 | @api.marshal_list_with(cat) 71 | def get(self): 72 | '''List all cats''' 73 | return CATS 74 | 75 | @api.route('/') 76 | @api.param('id', 'The cat identifier') 77 | @api.response(404, 'Cat not found') 78 | class Cat(Resource): 79 | @api.doc('get_cat') 80 | @api.marshal_with(cat) 81 | def get(self, id): 82 | '''Fetch a cat given its identifier''' 83 | for cat in CATS: 84 | if cat['id'] == id: 85 | return cat 86 | api.abort(404) 87 | 88 | 89 | The `apis.__init__` module should aggregate them: 90 | 91 | .. code-block:: Python 92 | 93 | from flask_restx import Api 94 | 95 | from .namespace1 import api as ns1 96 | from .namespace2 import api as ns2 97 | # ... 98 | from .namespaceX import api as nsX 99 | 100 | api = Api( 101 | title='My Title', 102 | version='1.0', 103 | description='A description', 104 | # All API metadatas 105 | ) 106 | 107 | api.add_namespace(ns1) 108 | api.add_namespace(ns2) 109 | # ... 110 | api.add_namespace(nsX) 111 | 112 | 113 | You can define custom url-prefixes for namespaces during registering them in your API. 114 | You don't have to bind url-prefix while declaration of Namespace object. 115 | 116 | .. code-block:: Python 117 | 118 | from flask_restx import Api 119 | 120 | from .namespace1 import api as ns1 121 | from .namespace2 import api as ns2 122 | # ... 123 | from .namespaceX import api as nsX 124 | 125 | api = Api( 126 | title='My Title', 127 | version='1.0', 128 | description='A description', 129 | # All API metadatas 130 | ) 131 | 132 | api.add_namespace(ns1, path='/prefix/of/ns1') 133 | api.add_namespace(ns2, path='/prefix/of/ns2') 134 | # ... 135 | api.add_namespace(nsX, path='/prefix/of/nsX') 136 | 137 | 138 | Using this pattern, you simply have to register your API in `app.py` like that: 139 | 140 | .. code-block:: Python 141 | 142 | from flask import Flask 143 | from apis import api 144 | 145 | app = Flask(__name__) 146 | api.init_app(app) 147 | 148 | app.run(debug=True) 149 | 150 | 151 | Use With Blueprints 152 | ------------------- 153 | 154 | See :doc:`flask:blueprints` in the Flask documentation for what blueprints are and why you should use them. 155 | Here's an example of how to link an :class:`Api` up to a :class:`~flask.Blueprint`. Nested Blueprints are 156 | not supported. 157 | 158 | .. code-block:: python 159 | 160 | from flask import Blueprint 161 | from flask_restx import Api 162 | 163 | blueprint = Blueprint('api', __name__) 164 | api = Api(blueprint) 165 | # ... 166 | 167 | Using a `blueprint` will allow you to mount your API on any url prefix and/or subdomain 168 | in you application: 169 | 170 | 171 | .. code-block:: Python 172 | 173 | from flask import Flask 174 | from apis import blueprint as api 175 | 176 | app = Flask(__name__) 177 | app.register_blueprint(api, url_prefix='/api/1') 178 | app.run(debug=True) 179 | 180 | .. note :: 181 | 182 | Calling :meth:`Api.init_app` is not required here because registering the 183 | blueprint with the app takes care of setting up the routing for the application. 184 | 185 | .. note:: 186 | 187 | When using blueprints, remember to use the blueprint name with :func:`~flask.url_for`: 188 | 189 | .. code-block:: python 190 | 191 | # without blueprint 192 | url_for('my_api_endpoint') 193 | 194 | # with blueprint 195 | url_for('api.my_api_endpoint') 196 | 197 | 198 | Multiple APIs with reusable namespaces 199 | -------------------------------------- 200 | 201 | Sometimes you need to maintain multiple versions of an API. 202 | If you built your API using namespaces composition, 203 | it's quite simple to scale it to multiple APIs. 204 | 205 | Given the previous layout, we can migrate it to the following directory structure:: 206 | 207 | project/ 208 | ├── app.py 209 | ├── apiv1.py 210 | ├── apiv2.py 211 | └── apis 212 | ├── __init__.py 213 | ├── namespace1.py 214 | ├── namespace2.py 215 | ├── ... 216 | └── namespaceX.py 217 | 218 | Each `apis/namespaceX` module will have the following pattern: 219 | 220 | .. code-block:: python 221 | 222 | from flask_restx import Namespace, Resource 223 | 224 | api = Namespace('mynamespace', 'Namespace Description' ) 225 | 226 | @api.route("/") 227 | class Myclass(Resource): 228 | def get(self): 229 | return {} 230 | 231 | Each `apivX` module will have the following pattern: 232 | 233 | .. code-block:: python 234 | 235 | from flask import Blueprint 236 | from flask_restx import Api 237 | 238 | api = Api(blueprint) 239 | 240 | from .apis.namespace1 import api as ns1 241 | from .apis.namespace2 import api as ns2 242 | # ... 243 | from .apis.namespaceX import api as nsX 244 | 245 | blueprint = Blueprint('api', __name__, url_prefix='/api/1') 246 | api = Api(blueprint, 247 | title='My Title', 248 | version='1.0', 249 | description='A description', 250 | # All API metadatas 251 | ) 252 | 253 | api.add_namespace(ns1) 254 | api.add_namespace(ns2) 255 | # ... 256 | api.add_namespace(nsX) 257 | 258 | And the app will simply mount them: 259 | 260 | .. code-block:: Python 261 | 262 | from flask import Flask 263 | from api1 import blueprint as api1 264 | from apiX import blueprint as apiX 265 | 266 | app = Flask(__name__) 267 | app.register_blueprint(api1) 268 | app.register_blueprint(apiX) 269 | app.run(debug=True) 270 | 271 | 272 | These are only proposals and you can do whatever suits your needs. 273 | Look at the `github repository examples folder`_ for more complete examples. 274 | 275 | .. _github repository examples folder: https://github.com/python-restx/flask-restx/tree/master/examples 276 | -------------------------------------------------------------------------------- /examples/resource_class_kwargs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | from flask_restx import Resource, Namespace 6 | from flask import Blueprint, Flask 7 | from flask_restx import Api, fields, Model 8 | 9 | ### models.py contain models for data validation ### 10 | # just a simple model 11 | MyTest = Model('MyTest', { 12 | 'data': fields.String(required=True,readonly=True), 13 | }) 14 | 15 | ### namespaces.py contains definition of routes 16 | namesp = Namespace(name="tests", validate=True) 17 | # register model 18 | namesp.models[MyTest.name] = MyTest 19 | @namesp.route('/') 20 | class get_session(Resource): 21 | 22 | def __init__(self, api=None, *args, **kwargs): 23 | # sessions is a black box dependency 24 | self.answer_service = kwargs['answer_service'] 25 | super().__init__(api,*args, **kwargs) 26 | 27 | @namesp.marshal_with(MyTest) 28 | def get(self, message): 29 | # ducktyping 30 | # any used answer_service must implement this method somehow 31 | return self.answer_service.answer(message) 32 | 33 | 34 | ### managers.py contain logic what should happen on request ### 35 | 36 | # loosly coupled and independent from communication 37 | # could be implemented with database, log file what so ever 38 | class AnswerService: 39 | def __init__(self,msg): 40 | self.msg=msg 41 | def answer(self, request:str): 42 | return {'data': request+self.msg} 43 | 44 | #### main.py ### 45 | blueprint = Blueprint("api", __name__, url_prefix="/api/v1") 46 | 47 | api = Api( 48 | blueprint, 49 | version="1.0", 50 | doc="/ui", 51 | validate=False, 52 | ) 53 | 54 | # main glues communication and managers together 55 | ans= AnswerService('~nice to meet you') 56 | injected_objects={'answer_service': ans} 57 | 58 | ### could also be defined without namespace ### 59 | #api.models[MyTest.name] = MyTest 60 | #api.add_resource(get_session, '/answer', 61 | # resource_class_kwargs=injected_objects) 62 | 63 | 64 | # inject the objects containing logic here 65 | for res in namesp.resources: 66 | res.kwargs['resource_class_kwargs'] = injected_objects 67 | print(res) 68 | # finally add namespace to api 69 | api.add_namespace(namesp) 70 | 71 | app = Flask('test') 72 | from flask import redirect 73 | @app.route('/', methods=['POST', 'GET']) 74 | def home(): 75 | return redirect('/api/v1/ui') 76 | 77 | app.register_blueprint(blueprint) 78 | app.run(debug=False, port=8002) 79 | -------------------------------------------------------------------------------- /examples/todo.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_restx import Api, Resource, fields 3 | from werkzeug.middleware.proxy_fix import ProxyFix 4 | 5 | app = Flask(__name__) 6 | app.wsgi_app = ProxyFix(app.wsgi_app) 7 | api = Api( 8 | app, 9 | version="1.0", 10 | title="Todo API", 11 | description="A simple TODO API", 12 | ) 13 | 14 | ns = api.namespace("todos", description="TODO operations") 15 | 16 | TODOS = { 17 | "todo1": {"task": "build an API"}, 18 | "todo2": {"task": "?????"}, 19 | "todo3": {"task": "profit!"}, 20 | } 21 | 22 | todo = api.model( 23 | "Todo", {"task": fields.String(required=True, description="The task details")} 24 | ) 25 | 26 | listed_todo = api.model( 27 | "ListedTodo", 28 | { 29 | "id": fields.String(required=True, description="The todo ID"), 30 | "todo": fields.Nested(todo, description="The Todo"), 31 | }, 32 | ) 33 | 34 | 35 | def abort_if_todo_doesnt_exist(todo_id): 36 | if todo_id not in TODOS: 37 | api.abort(404, "Todo {} doesn't exist".format(todo_id)) 38 | 39 | 40 | parser = api.parser() 41 | parser.add_argument( 42 | "task", type=str, required=True, help="The task details", location="form" 43 | ) 44 | 45 | 46 | @ns.route("/") 47 | @api.doc(responses={404: "Todo not found"}, params={"todo_id": "The Todo ID"}) 48 | class Todo(Resource): 49 | """Show a single todo item and lets you delete them""" 50 | 51 | @api.doc(description="todo_id should be in {0}".format(", ".join(TODOS.keys()))) 52 | @api.marshal_with(todo) 53 | def get(self, todo_id): 54 | """Fetch a given resource""" 55 | abort_if_todo_doesnt_exist(todo_id) 56 | return TODOS[todo_id] 57 | 58 | @api.doc(responses={204: "Todo deleted"}) 59 | def delete(self, todo_id): 60 | """Delete a given resource""" 61 | abort_if_todo_doesnt_exist(todo_id) 62 | del TODOS[todo_id] 63 | return "", 204 64 | 65 | @api.doc(parser=parser) 66 | @api.marshal_with(todo) 67 | def put(self, todo_id): 68 | """Update a given resource""" 69 | args = parser.parse_args() 70 | task = {"task": args["task"]} 71 | TODOS[todo_id] = task 72 | return task 73 | 74 | 75 | @ns.route("/") 76 | class TodoList(Resource): 77 | """Shows a list of all todos, and lets you POST to add new tasks""" 78 | 79 | @api.marshal_list_with(listed_todo) 80 | def get(self): 81 | """List all todos""" 82 | return [{"id": id, "todo": todo} for id, todo in TODOS.items()] 83 | 84 | @api.doc(parser=parser) 85 | @api.marshal_with(todo, code=201) 86 | def post(self): 87 | """Create a todo""" 88 | args = parser.parse_args() 89 | todo_id = "todo%d" % (len(TODOS) + 1) 90 | TODOS[todo_id] = {"task": args["task"]} 91 | return TODOS[todo_id], 201 92 | 93 | 94 | if __name__ == "__main__": 95 | app.run(debug=True) 96 | -------------------------------------------------------------------------------- /examples/todo_blueprint.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, Blueprint 2 | from flask_restx import Api, Resource, fields 3 | 4 | api_v1 = Blueprint("api", __name__, url_prefix="/api/1") 5 | 6 | api = Api( 7 | api_v1, 8 | version="1.0", 9 | title="Todo API", 10 | description="A simple TODO API", 11 | ) 12 | 13 | ns = api.namespace("todos", description="TODO operations") 14 | 15 | TODOS = { 16 | "todo1": {"task": "build an API"}, 17 | "todo2": {"task": "?????"}, 18 | "todo3": {"task": "profit!"}, 19 | } 20 | 21 | todo = api.model( 22 | "Todo", {"task": fields.String(required=True, description="The task details")} 23 | ) 24 | 25 | listed_todo = api.model( 26 | "ListedTodo", 27 | { 28 | "id": fields.String(required=True, description="The todo ID"), 29 | "todo": fields.Nested(todo, description="The Todo"), 30 | }, 31 | ) 32 | 33 | 34 | def abort_if_todo_doesnt_exist(todo_id): 35 | if todo_id not in TODOS: 36 | api.abort(404, "Todo {} doesn't exist".format(todo_id)) 37 | 38 | 39 | parser = api.parser() 40 | parser.add_argument( 41 | "task", type=str, required=True, help="The task details", location="form" 42 | ) 43 | 44 | 45 | @ns.route("/") 46 | @api.doc(responses={404: "Todo not found"}, params={"todo_id": "The Todo ID"}) 47 | class Todo(Resource): 48 | """Show a single todo item and lets you delete them""" 49 | 50 | @api.doc(description="todo_id should be in {0}".format(", ".join(TODOS.keys()))) 51 | @api.marshal_with(todo) 52 | def get(self, todo_id): 53 | """Fetch a given resource""" 54 | abort_if_todo_doesnt_exist(todo_id) 55 | return TODOS[todo_id] 56 | 57 | @api.doc(responses={204: "Todo deleted"}) 58 | def delete(self, todo_id): 59 | """Delete a given resource""" 60 | abort_if_todo_doesnt_exist(todo_id) 61 | del TODOS[todo_id] 62 | return "", 204 63 | 64 | @api.doc(parser=parser) 65 | @api.marshal_with(todo) 66 | def put(self, todo_id): 67 | """Update a given resource""" 68 | args = parser.parse_args() 69 | task = {"task": args["task"]} 70 | TODOS[todo_id] = task 71 | return task 72 | 73 | 74 | @ns.route("/") 75 | class TodoList(Resource): 76 | """Shows a list of all todos, and lets you POST to add new tasks""" 77 | 78 | @api.marshal_list_with(listed_todo) 79 | def get(self): 80 | """List all todos""" 81 | return [{"id": id, "todo": todo} for id, todo in TODOS.items()] 82 | 83 | @api.doc(parser=parser) 84 | @api.marshal_with(todo, code=201) 85 | def post(self): 86 | """Create a todo""" 87 | args = parser.parse_args() 88 | todo_id = "todo%d" % (len(TODOS) + 1) 89 | TODOS[todo_id] = {"task": args["task"]} 90 | return TODOS[todo_id], 201 91 | 92 | 93 | if __name__ == "__main__": 94 | app = Flask(__name__) 95 | app.register_blueprint(api_v1) 96 | app.run(debug=True) 97 | -------------------------------------------------------------------------------- /examples/todo_simple.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request 2 | from flask_restx import Resource, Api 3 | 4 | app = Flask(__name__) 5 | api = Api(app) 6 | 7 | todos = {} 8 | 9 | 10 | @api.route("/") 11 | class TodoSimple(Resource): 12 | """ 13 | You can try this example as follow: 14 | $ curl http://localhost:5000/todo1 -d "data=Remember the milk" -X PUT 15 | $ curl http://localhost:5000/todo1 16 | {"todo1": "Remember the milk"} 17 | $ curl http://localhost:5000/todo2 -d "data=Change my breakpads" -X PUT 18 | $ curl http://localhost:5000/todo2 19 | {"todo2": "Change my breakpads"} 20 | 21 | Or from python if you have requests : 22 | >>> from requests import put, get 23 | >>> put('http://localhost:5000/todo1', data={'data': 'Remember the milk'}).json 24 | {u'todo1': u'Remember the milk'} 25 | >>> get('http://localhost:5000/todo1').json 26 | {u'todo1': u'Remember the milk'} 27 | >>> put('http://localhost:5000/todo2', data={'data': 'Change my breakpads'}).json 28 | {u'todo2': u'Change my breakpads'} 29 | >>> get('http://localhost:5000/todo2').json 30 | {u'todo2': u'Change my breakpads'} 31 | 32 | """ 33 | 34 | def get(self, todo_id): 35 | return {todo_id: todos[todo_id]} 36 | 37 | def put(self, todo_id): 38 | todos[todo_id] = request.form["data"] 39 | return {todo_id: todos[todo_id]} 40 | 41 | 42 | if __name__ == "__main__": 43 | app.run(debug=False) 44 | -------------------------------------------------------------------------------- /examples/todomvc.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_restx import Api, Resource, fields 3 | from werkzeug.middleware.proxy_fix import ProxyFix 4 | 5 | app = Flask(__name__) 6 | app.wsgi_app = ProxyFix(app.wsgi_app) 7 | api = Api( 8 | app, 9 | version="1.0", 10 | title="TodoMVC API", 11 | description="A simple TodoMVC API", 12 | ) 13 | 14 | ns = api.namespace("todos", description="TODO operations") 15 | 16 | todo = api.model( 17 | "Todo", 18 | { 19 | "id": fields.Integer(readonly=True, description="The task unique identifier"), 20 | "task": fields.String(required=True, description="The task details"), 21 | }, 22 | ) 23 | 24 | 25 | class TodoDAO(object): 26 | def __init__(self): 27 | self.counter = 0 28 | self.todos = [] 29 | 30 | def get(self, id): 31 | for todo in self.todos: 32 | if todo["id"] == id: 33 | return todo 34 | api.abort(404, "Todo {} doesn't exist".format(id)) 35 | 36 | def create(self, data): 37 | todo = data 38 | todo["id"] = self.counter = self.counter + 1 39 | self.todos.append(todo) 40 | return todo 41 | 42 | def update(self, id, data): 43 | todo = self.get(id) 44 | todo.update(data) 45 | return todo 46 | 47 | def delete(self, id): 48 | todo = self.get(id) 49 | self.todos.remove(todo) 50 | 51 | 52 | todo_dao = TodoDAO() 53 | todo_dao.create({"task": "Build an API"}) 54 | todo_dao.create({"task": "?????"}) 55 | todo_dao.create({"task": "profit!"}) 56 | 57 | 58 | @ns.route("/") 59 | class TodoList(Resource): 60 | """Shows a list of all todos, and lets you POST to add new tasks""" 61 | 62 | @ns.doc("list_todos") 63 | @ns.marshal_list_with(todo) 64 | def get(self): 65 | """List all tasks""" 66 | return todo_dao.todos 67 | 68 | @ns.doc("create_todo") 69 | @ns.expect(todo) 70 | @ns.marshal_with(todo, code=201) 71 | def post(self): 72 | """Create a new task""" 73 | return todo_dao.create(api.payload), 201 74 | 75 | 76 | @ns.route("/") 77 | @ns.response(404, "Todo not found") 78 | @ns.param("id", "The task identifier") 79 | class Todo(Resource): 80 | """Show a single todo item and lets you delete them""" 81 | 82 | @ns.doc("get_todo") 83 | @ns.marshal_with(todo) 84 | def get(self, id): 85 | """Fetch a given resource""" 86 | return todo_dao.get(id) 87 | 88 | @ns.doc("delete_todo") 89 | @ns.response(204, "Todo deleted") 90 | def delete(self, id): 91 | """Delete a task given its identifier""" 92 | todo_dao.delete(id) 93 | return "", 204 94 | 95 | @ns.expect(todo) 96 | @ns.marshal_with(todo) 97 | def put(self, id): 98 | """Update a task given its identifier""" 99 | return todo_dao.update(id, api.payload) 100 | 101 | 102 | if __name__ == "__main__": 103 | app.run(debug=True) 104 | -------------------------------------------------------------------------------- /examples/xml_representation.py: -------------------------------------------------------------------------------- 1 | # needs: pip install python-simplexml 2 | from simplexml import dumps 3 | from flask import make_response, Flask 4 | from flask_restx import Api, Resource, fields 5 | 6 | 7 | def output_xml(data, code, headers=None): 8 | """Makes a Flask response with a XML encoded body""" 9 | resp = make_response(dumps({"response": data}), code) 10 | resp.headers.extend(headers or {}) 11 | return resp 12 | 13 | 14 | app = Flask(__name__) 15 | api = Api(app, default_mediatype="application/xml") 16 | api.representations["application/xml"] = output_xml 17 | 18 | hello_fields = api.model("Hello", {"entry": fields.String}) 19 | 20 | 21 | @api.route("/") 22 | class Hello(Resource): 23 | """ 24 | # you need requests 25 | >>> from requests import get 26 | >>> get('http://localhost:5000/me').content # default_mediatype 27 | 'me' 28 | >>> get('http://localhost:5000/me', headers={"accept":"application/json"}).content 29 | '{"hello": "me"}' 30 | >>> get('http://localhost:5000/me', headers={"accept":"application/xml"}).content 31 | 'me' 32 | """ 33 | 34 | @api.doc(model=hello_fields, params={"entry": "The entry to wrap"}) 35 | def get(self, entry): 36 | """Get a wrapped entry""" 37 | return {"hello": entry} 38 | 39 | 40 | if __name__ == "__main__": 41 | app.run(debug=True) 42 | -------------------------------------------------------------------------------- /examples/zoo_app/complex.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from werkzeug.middleware.proxy_fix import ProxyFix 3 | 4 | from zoo import api 5 | 6 | app = Flask(__name__) 7 | app.wsgi_app = ProxyFix(app.wsgi_app) 8 | 9 | api.init_app(app) 10 | 11 | app.run(debug=True) 12 | -------------------------------------------------------------------------------- /examples/zoo_app/requirements.txt: -------------------------------------------------------------------------------- 1 | aniso8601==9.0.1 2 | attrs==21.2.0 3 | click==7.1.2 4 | Flask==1.1.4 5 | flask-restx==0.5.1 6 | itsdangerous==1.1.0 7 | Jinja2==2.11.3 8 | jsonschema==3.2.0 9 | MarkupSafe==2.0.1 10 | pyrsistent==0.17.3 11 | six==1.16.0 12 | Werkzeug==2.2.3 13 | 14 | -------------------------------------------------------------------------------- /examples/zoo_app/zoo/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_restx import Api 2 | 3 | from .cat import api as cat_api 4 | from .dog import api as dog_api 5 | 6 | api = Api( 7 | title="Zoo API", 8 | version="1.0", 9 | description="A simple demo API", 10 | ) 11 | 12 | api.add_namespace(cat_api) 13 | api.add_namespace(dog_api) 14 | -------------------------------------------------------------------------------- /examples/zoo_app/zoo/cat.py: -------------------------------------------------------------------------------- 1 | from flask_restx import Namespace, Resource, fields 2 | 3 | api = Namespace("cats", description="Cats related operations") 4 | 5 | cat = api.model( 6 | "Cat", 7 | { 8 | "id": fields.String(required=True, description="The cat identifier"), 9 | "name": fields.String(required=True, description="The cat name"), 10 | }, 11 | ) 12 | 13 | CATS = [ 14 | {"id": "felix", "name": "Felix"}, 15 | ] 16 | 17 | 18 | @api.route("/") 19 | class CatList(Resource): 20 | @api.doc("list_cats") 21 | @api.marshal_list_with(cat) 22 | def get(self): 23 | """List all cats""" 24 | return CATS 25 | 26 | 27 | @api.route("/") 28 | @api.param("id", "The cat identifier") 29 | @api.response(404, "Cat not found") 30 | class Cat(Resource): 31 | @api.doc("get_cat") 32 | @api.marshal_with(cat) 33 | def get(self, id): 34 | """Fetch a cat given its identifier""" 35 | for cat in CATS: 36 | if cat["id"] == id: 37 | return cat 38 | api.abort(404) 39 | -------------------------------------------------------------------------------- /examples/zoo_app/zoo/dog.py: -------------------------------------------------------------------------------- 1 | from flask_restx import Namespace, Resource, fields 2 | 3 | api = Namespace("dogs", description="Dogs related operations") 4 | 5 | dog = api.model( 6 | "Dog", 7 | { 8 | "id": fields.String(required=True, description="The dog identifier"), 9 | "name": fields.String(required=True, description="The dog name"), 10 | }, 11 | ) 12 | 13 | DOGS = [ 14 | {"id": "medor", "name": "Medor"}, 15 | ] 16 | 17 | 18 | @api.route("/") 19 | class DogList(Resource): 20 | @api.doc("list_dogs") 21 | @api.marshal_list_with(dog) 22 | def get(self): 23 | """List all dogs""" 24 | return DOGS 25 | 26 | 27 | @api.route("/") 28 | @api.param("id", "The dog identifier") 29 | @api.response(404, "Dog not found") 30 | class Dog(Resource): 31 | @api.doc("get_dog") 32 | @api.marshal_with(dog) 33 | def get(self, id): 34 | """Fetch a dog given its identifier""" 35 | for dog in DOGS: 36 | if dog["id"] == id: 37 | return dog 38 | api.abort(404) 39 | -------------------------------------------------------------------------------- /flask_restx/__about__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __version__ = "1.3.1.dev" 3 | __description__ = ( 4 | "Fully featured framework for fast, easy and documented API development with Flask" 5 | ) 6 | -------------------------------------------------------------------------------- /flask_restx/__init__.py: -------------------------------------------------------------------------------- 1 | from . import fields, reqparse, apidoc, inputs, cors 2 | from .api import Api # noqa 3 | from .marshalling import marshal, marshal_with, marshal_with_field # noqa 4 | from .mask import Mask 5 | from .model import Model, OrderedModel, SchemaModel # noqa 6 | from .namespace import Namespace # noqa 7 | from .resource import Resource # noqa 8 | from .errors import abort, RestError, SpecsError, ValidationError 9 | from .swagger import Swagger 10 | from .__about__ import __version__, __description__ 11 | 12 | __all__ = ( 13 | "__version__", 14 | "__description__", 15 | "Api", 16 | "Resource", 17 | "apidoc", 18 | "marshal", 19 | "marshal_with", 20 | "marshal_with_field", 21 | "Mask", 22 | "Model", 23 | "Namespace", 24 | "OrderedModel", 25 | "SchemaModel", 26 | "abort", 27 | "cors", 28 | "fields", 29 | "inputs", 30 | "reqparse", 31 | "RestError", 32 | "SpecsError", 33 | "Swagger", 34 | "ValidationError", 35 | ) 36 | -------------------------------------------------------------------------------- /flask_restx/_http.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | This file is backported from Python 3.5 http built-in module. 4 | """ 5 | 6 | from enum import IntEnum 7 | 8 | 9 | class HTTPStatus(IntEnum): 10 | """HTTP status codes and reason phrases 11 | 12 | Status codes from the following RFCs are all observed: 13 | 14 | * RFC 7231: Hypertext Transfer Protocol (HTTP/1.1), obsoletes 2616 15 | * RFC 6585: Additional HTTP Status Codes 16 | * RFC 3229: Delta encoding in HTTP 17 | * RFC 4918: HTTP Extensions for WebDAV, obsoletes 2518 18 | * RFC 5842: Binding Extensions to WebDAV 19 | * RFC 7238: Permanent Redirect 20 | * RFC 2295: Transparent Content Negotiation in HTTP 21 | * RFC 2774: An HTTP Extension Framework 22 | """ 23 | 24 | def __new__(cls, value, phrase, description=""): 25 | obj = int.__new__(cls, value) 26 | obj._value_ = value 27 | 28 | obj.phrase = phrase 29 | obj.description = description 30 | return obj 31 | 32 | def __str__(self): 33 | return str(self.value) 34 | 35 | # informational 36 | CONTINUE = 100, "Continue", "Request received, please continue" 37 | SWITCHING_PROTOCOLS = ( 38 | 101, 39 | "Switching Protocols", 40 | "Switching to new protocol; obey Upgrade header", 41 | ) 42 | PROCESSING = 102, "Processing" 43 | 44 | # success 45 | OK = 200, "OK", "Request fulfilled, document follows" 46 | CREATED = 201, "Created", "Document created, URL follows" 47 | ACCEPTED = (202, "Accepted", "Request accepted, processing continues off-line") 48 | NON_AUTHORITATIVE_INFORMATION = ( 49 | 203, 50 | "Non-Authoritative Information", 51 | "Request fulfilled from cache", 52 | ) 53 | NO_CONTENT = 204, "No Content", "Request fulfilled, nothing follows" 54 | RESET_CONTENT = 205, "Reset Content", "Clear input form for further input" 55 | PARTIAL_CONTENT = 206, "Partial Content", "Partial content follows" 56 | MULTI_STATUS = 207, "Multi-Status" 57 | ALREADY_REPORTED = 208, "Already Reported" 58 | IM_USED = 226, "IM Used" 59 | 60 | # redirection 61 | MULTIPLE_CHOICES = ( 62 | 300, 63 | "Multiple Choices", 64 | "Object has several resources -- see URI list", 65 | ) 66 | MOVED_PERMANENTLY = ( 67 | 301, 68 | "Moved Permanently", 69 | "Object moved permanently -- see URI list", 70 | ) 71 | FOUND = 302, "Found", "Object moved temporarily -- see URI list" 72 | SEE_OTHER = 303, "See Other", "Object moved -- see Method and URL list" 73 | NOT_MODIFIED = (304, "Not Modified", "Document has not changed since given time") 74 | USE_PROXY = ( 75 | 305, 76 | "Use Proxy", 77 | "You must use proxy specified in Location to access this resource", 78 | ) 79 | TEMPORARY_REDIRECT = ( 80 | 307, 81 | "Temporary Redirect", 82 | "Object moved temporarily -- see URI list", 83 | ) 84 | PERMANENT_REDIRECT = ( 85 | 308, 86 | "Permanent Redirect", 87 | "Object moved temporarily -- see URI list", 88 | ) 89 | 90 | # client error 91 | BAD_REQUEST = (400, "Bad Request", "Bad request syntax or unsupported method") 92 | UNAUTHORIZED = (401, "Unauthorized", "No permission -- see authorization schemes") 93 | PAYMENT_REQUIRED = (402, "Payment Required", "No payment -- see charging schemes") 94 | FORBIDDEN = (403, "Forbidden", "Request forbidden -- authorization will not help") 95 | NOT_FOUND = (404, "Not Found", "Nothing matches the given URI") 96 | METHOD_NOT_ALLOWED = ( 97 | 405, 98 | "Method Not Allowed", 99 | "Specified method is invalid for this resource", 100 | ) 101 | NOT_ACCEPTABLE = (406, "Not Acceptable", "URI not available in preferred format") 102 | PROXY_AUTHENTICATION_REQUIRED = ( 103 | 407, 104 | "Proxy Authentication Required", 105 | "You must authenticate with this proxy before proceeding", 106 | ) 107 | REQUEST_TIMEOUT = (408, "Request Timeout", "Request timed out; try again later") 108 | CONFLICT = 409, "Conflict", "Request conflict" 109 | GONE = (410, "Gone", "URI no longer exists and has been permanently removed") 110 | LENGTH_REQUIRED = (411, "Length Required", "Client must specify Content-Length") 111 | PRECONDITION_FAILED = ( 112 | 412, 113 | "Precondition Failed", 114 | "Precondition in headers is false", 115 | ) 116 | REQUEST_ENTITY_TOO_LARGE = (413, "Request Entity Too Large", "Entity is too large") 117 | REQUEST_URI_TOO_LONG = (414, "Request-URI Too Long", "URI is too long") 118 | UNSUPPORTED_MEDIA_TYPE = ( 119 | 415, 120 | "Unsupported Media Type", 121 | "Entity body in unsupported format", 122 | ) 123 | REQUESTED_RANGE_NOT_SATISFIABLE = ( 124 | 416, 125 | "Requested Range Not Satisfiable", 126 | "Cannot satisfy request range", 127 | ) 128 | EXPECTATION_FAILED = ( 129 | 417, 130 | "Expectation Failed", 131 | "Expect condition could not be satisfied", 132 | ) 133 | UNPROCESSABLE_ENTITY = 422, "Unprocessable Entity" 134 | LOCKED = 423, "Locked" 135 | FAILED_DEPENDENCY = 424, "Failed Dependency" 136 | UPGRADE_REQUIRED = 426, "Upgrade Required" 137 | PRECONDITION_REQUIRED = ( 138 | 428, 139 | "Precondition Required", 140 | "The origin server requires the request to be conditional", 141 | ) 142 | TOO_MANY_REQUESTS = ( 143 | 429, 144 | "Too Many Requests", 145 | "The user has sent too many requests in " 146 | 'a given amount of time ("rate limiting")', 147 | ) 148 | REQUEST_HEADER_FIELDS_TOO_LARGE = ( 149 | 431, 150 | "Request Header Fields Too Large", 151 | "The server is unwilling to process the request because its header " 152 | "fields are too large", 153 | ) 154 | 155 | # server errors 156 | INTERNAL_SERVER_ERROR = ( 157 | 500, 158 | "Internal Server Error", 159 | "Server got itself in trouble", 160 | ) 161 | NOT_IMPLEMENTED = (501, "Not Implemented", "Server does not support this operation") 162 | BAD_GATEWAY = (502, "Bad Gateway", "Invalid responses from another server/proxy") 163 | SERVICE_UNAVAILABLE = ( 164 | 503, 165 | "Service Unavailable", 166 | "The server cannot process the request due to a high load", 167 | ) 168 | GATEWAY_TIMEOUT = ( 169 | 504, 170 | "Gateway Timeout", 171 | "The gateway server did not receive a timely response", 172 | ) 173 | HTTP_VERSION_NOT_SUPPORTED = ( 174 | 505, 175 | "HTTP Version Not Supported", 176 | "Cannot fulfill request", 177 | ) 178 | VARIANT_ALSO_NEGOTIATES = 506, "Variant Also Negotiates" 179 | INSUFFICIENT_STORAGE = 507, "Insufficient Storage" 180 | LOOP_DETECTED = 508, "Loop Detected" 181 | NOT_EXTENDED = 510, "Not Extended" 182 | NETWORK_AUTHENTICATION_REQUIRED = ( 183 | 511, 184 | "Network Authentication Required", 185 | "The client needs to authenticate to gain network access", 186 | ) 187 | -------------------------------------------------------------------------------- /flask_restx/apidoc.py: -------------------------------------------------------------------------------- 1 | from flask import url_for, Blueprint, render_template 2 | 3 | 4 | class Apidoc(Blueprint): 5 | """ 6 | Allow to know if the blueprint has already been registered 7 | until https://github.com/mitsuhiko/flask/pull/1301 is merged 8 | """ 9 | 10 | def __init__(self, *args, **kwargs): 11 | self.registered = False 12 | super(Apidoc, self).__init__(*args, **kwargs) 13 | 14 | def register(self, *args, **kwargs): 15 | super(Apidoc, self).register(*args, **kwargs) 16 | self.registered = True 17 | 18 | 19 | apidoc = Apidoc( 20 | "restx_doc", 21 | __name__, 22 | template_folder="templates", 23 | static_folder="static", 24 | static_url_path="/swaggerui", 25 | ) 26 | 27 | 28 | @apidoc.add_app_template_global 29 | def swagger_static(filename): 30 | return url_for("restx_doc.static", filename=filename) 31 | 32 | 33 | def ui_for(api): 34 | """Render a SwaggerUI for a given API""" 35 | return render_template("swagger-ui.html", title=api.title, specs_url=api.specs_url) 36 | -------------------------------------------------------------------------------- /flask_restx/cors.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from flask import make_response, request, current_app 3 | from functools import update_wrapper 4 | 5 | 6 | def crossdomain( 7 | origin=None, 8 | methods=None, 9 | headers=None, 10 | expose_headers=None, 11 | max_age=21600, 12 | attach_to_all=True, 13 | automatic_options=True, 14 | credentials=False, 15 | ): 16 | """ 17 | https://web.archive.org/web/20190128010149/http://flask.pocoo.org/snippets/56/ 18 | """ 19 | if methods is not None: 20 | methods = ", ".join(sorted(x.upper() for x in methods)) 21 | if headers is not None and not isinstance(headers, str): 22 | headers = ", ".join(x.upper() for x in headers) 23 | if expose_headers is not None and not isinstance(expose_headers, str): 24 | expose_headers = ", ".join(x.upper() for x in expose_headers) 25 | if not isinstance(origin, str): 26 | origin = ", ".join(origin) 27 | if isinstance(max_age, timedelta): 28 | max_age = max_age.total_seconds() 29 | 30 | def get_methods(): 31 | if methods is not None: 32 | return methods 33 | 34 | options_resp = current_app.make_default_options_response() 35 | return options_resp.headers["allow"] 36 | 37 | def decorator(f): 38 | def wrapped_function(*args, **kwargs): 39 | if automatic_options and request.method == "OPTIONS": 40 | resp = current_app.make_default_options_response() 41 | else: 42 | resp = make_response(f(*args, **kwargs)) 43 | if not attach_to_all and request.method != "OPTIONS": 44 | return resp 45 | 46 | h = resp.headers 47 | 48 | h["Access-Control-Allow-Origin"] = origin 49 | h["Access-Control-Allow-Methods"] = get_methods() 50 | h["Access-Control-Max-Age"] = str(max_age) 51 | if credentials: 52 | h["Access-Control-Allow-Credentials"] = "true" 53 | if headers is not None: 54 | h["Access-Control-Allow-Headers"] = headers 55 | if expose_headers is not None: 56 | h["Access-Control-Expose-Headers"] = expose_headers 57 | return resp 58 | 59 | f.provide_automatic_options = False 60 | return update_wrapper(wrapped_function, f) 61 | 62 | return decorator 63 | -------------------------------------------------------------------------------- /flask_restx/errors.py: -------------------------------------------------------------------------------- 1 | import flask 2 | 3 | from werkzeug.exceptions import HTTPException 4 | 5 | from ._http import HTTPStatus 6 | 7 | __all__ = ( 8 | "abort", 9 | "RestError", 10 | "ValidationError", 11 | "SpecsError", 12 | ) 13 | 14 | 15 | def abort(code=HTTPStatus.INTERNAL_SERVER_ERROR, message=None, **kwargs): 16 | """ 17 | Properly abort the current request. 18 | 19 | Raise a `HTTPException` for the given status `code`. 20 | Attach any keyword arguments to the exception for later processing. 21 | 22 | :param int code: The associated HTTP status code 23 | :param str message: An optional details message 24 | :param kwargs: Any additional data to pass to the error payload 25 | :raise HTTPException: 26 | """ 27 | try: 28 | flask.abort(code) 29 | except HTTPException as e: 30 | if message: 31 | kwargs["message"] = str(message) 32 | if kwargs: 33 | e.data = kwargs 34 | raise 35 | 36 | 37 | class RestError(Exception): 38 | """Base class for all Flask-RESTX Errors""" 39 | 40 | def __init__(self, msg): 41 | self.msg = msg 42 | 43 | def __str__(self): 44 | return self.msg 45 | 46 | 47 | class ValidationError(RestError): 48 | """A helper class for validation errors.""" 49 | 50 | pass 51 | 52 | 53 | class SpecsError(RestError): 54 | """A helper class for incoherent specifications.""" 55 | 56 | pass 57 | -------------------------------------------------------------------------------- /flask_restx/mask.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | from collections import OrderedDict 5 | from inspect import isclass 6 | 7 | from .errors import RestError 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | LEXER = re.compile(r"\{|\}|\,|[\w_:\-\*]+") 12 | 13 | 14 | class MaskError(RestError): 15 | """Raised when an error occurs on mask""" 16 | 17 | pass 18 | 19 | 20 | class ParseError(MaskError): 21 | """Raised when the mask parsing failed""" 22 | 23 | pass 24 | 25 | 26 | class Mask(OrderedDict): 27 | """ 28 | Hold a parsed mask. 29 | 30 | :param str|dict|Mask mask: A mask, parsed or not 31 | :param bool skip: If ``True``, missing fields won't appear in result 32 | """ 33 | 34 | def __init__(self, mask=None, skip=False, **kwargs): 35 | self.skip = skip 36 | if isinstance(mask, str): 37 | super(Mask, self).__init__() 38 | self.parse(mask) 39 | elif isinstance(mask, (dict, OrderedDict)): 40 | super(Mask, self).__init__(mask, **kwargs) 41 | else: 42 | self.skip = skip 43 | super(Mask, self).__init__(**kwargs) 44 | 45 | def parse(self, mask): 46 | """ 47 | Parse a fields mask. 48 | Expect something in the form:: 49 | 50 | {field,nested{nested_field,another},last} 51 | 52 | External brackets are optionals so it can also be written:: 53 | 54 | field,nested{nested_field,another},last 55 | 56 | All extras characters will be ignored. 57 | 58 | :param str mask: the mask string to parse 59 | :raises ParseError: when a mask is unparseable/invalid 60 | 61 | """ 62 | if not mask: 63 | return 64 | 65 | mask = self.clean(mask) 66 | fields = self 67 | previous = None 68 | stack = [] 69 | 70 | for token in LEXER.findall(mask): 71 | if token == "{": 72 | if previous not in fields: 73 | raise ParseError("Unexpected opening bracket") 74 | fields[previous] = Mask(skip=self.skip) 75 | stack.append(fields) 76 | fields = fields[previous] 77 | elif token == "}": 78 | if not stack: 79 | raise ParseError("Unexpected closing bracket") 80 | fields = stack.pop() 81 | elif token == ",": 82 | if previous in (",", "{", None): 83 | raise ParseError("Unexpected comma") 84 | else: 85 | fields[token] = True 86 | 87 | previous = token 88 | 89 | if stack: 90 | raise ParseError("Missing closing bracket") 91 | 92 | def clean(self, mask): 93 | """Remove unnecessary characters""" 94 | mask = mask.replace("\n", "").strip() 95 | # External brackets are optional 96 | if mask[0] == "{": 97 | if mask[-1] != "}": 98 | raise ParseError("Missing closing bracket") 99 | mask = mask[1:-1] 100 | return mask 101 | 102 | def apply(self, data): 103 | """ 104 | Apply a fields mask to the data. 105 | 106 | :param data: The data or model to apply mask on 107 | :raises MaskError: when unable to apply the mask 108 | 109 | """ 110 | from . import fields 111 | 112 | # Should handle lists 113 | if isinstance(data, (list, tuple, set)): 114 | return [self.apply(d) for d in data] 115 | elif isinstance(data, (fields.Nested, fields.List, fields.Polymorph)): 116 | return data.clone(self) 117 | elif type(data) == fields.Raw: 118 | return fields.Raw(default=data.default, attribute=data.attribute, mask=self) 119 | elif data == fields.Raw: 120 | return fields.Raw(mask=self) 121 | elif ( 122 | isinstance(data, fields.Raw) 123 | or isclass(data) 124 | and issubclass(data, fields.Raw) 125 | ): 126 | # Not possible to apply a mask on these remaining fields types 127 | raise MaskError("Mask is inconsistent with model") 128 | # Should handle objects 129 | elif not isinstance(data, (dict, OrderedDict)) and hasattr(data, "__dict__"): 130 | data = data.__dict__ 131 | 132 | return self.filter_data(data) 133 | 134 | def filter_data(self, data): 135 | """ 136 | Handle the data filtering given a parsed mask 137 | 138 | :param dict data: the raw data to filter 139 | :param list mask: a parsed mask to filter against 140 | :param bool skip: whether or not to skip missing fields 141 | 142 | """ 143 | out = {} 144 | for field, content in self.items(): 145 | if field == "*": 146 | continue 147 | elif isinstance(content, Mask): 148 | nested = data.get(field, None) 149 | if self.skip and nested is None: 150 | continue 151 | elif nested is None: 152 | out[field] = None 153 | else: 154 | out[field] = content.apply(nested) 155 | elif self.skip and field not in data: 156 | continue 157 | else: 158 | out[field] = data.get(field, None) 159 | 160 | if "*" in self.keys(): 161 | for key, value in data.items(): 162 | if key not in out: 163 | out[key] = value 164 | return out 165 | 166 | def __str__(self): 167 | return "{{{0}}}".format( 168 | ",".join( 169 | [ 170 | "".join((k, str(v))) if isinstance(v, Mask) else k 171 | for k, v in self.items() 172 | ] 173 | ) 174 | ) 175 | 176 | 177 | def apply(data, mask, skip=False): 178 | """ 179 | Apply a fields mask to the data. 180 | 181 | :param data: The data or model to apply mask on 182 | :param str|Mask mask: the mask (parsed or not) to apply on data 183 | :param bool skip: If rue, missing field won't appear in result 184 | :raises MaskError: when unable to apply the mask 185 | 186 | """ 187 | return Mask(mask, skip).apply(data) 188 | -------------------------------------------------------------------------------- /flask_restx/model.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import re 3 | import warnings 4 | 5 | from collections import OrderedDict 6 | 7 | from collections.abc import MutableMapping 8 | from werkzeug.utils import cached_property 9 | 10 | from .mask import Mask 11 | from .errors import abort 12 | 13 | from jsonschema import Draft4Validator 14 | from jsonschema.exceptions import ValidationError 15 | 16 | from .utils import not_none 17 | from ._http import HTTPStatus 18 | 19 | 20 | RE_REQUIRED = re.compile(r"u?\'(?P.*)\' is a required property", re.I | re.U) 21 | 22 | 23 | def instance(cls): 24 | if isinstance(cls, type): 25 | return cls() 26 | return cls 27 | 28 | 29 | class ModelBase(object): 30 | """ 31 | Handles validation and swagger style inheritance for both subclasses. 32 | Subclass must define `schema` attribute. 33 | 34 | :param str name: The model public name 35 | """ 36 | 37 | def __init__(self, name, *args, **kwargs): 38 | super(ModelBase, self).__init__(*args, **kwargs) 39 | self.__apidoc__ = {"name": name} 40 | self.name = name 41 | self.__parents__ = [] 42 | 43 | def instance_inherit(name, *parents): 44 | return self.__class__.inherit(name, self, *parents) 45 | 46 | self.inherit = instance_inherit 47 | 48 | @property 49 | def ancestors(self): 50 | """ 51 | Return the ancestors tree 52 | """ 53 | ancestors = [p.ancestors for p in self.__parents__] 54 | return set.union(set([self.name]), *ancestors) 55 | 56 | def get_parent(self, name): 57 | if self.name == name: 58 | return self 59 | else: 60 | for parent in self.__parents__: 61 | found = parent.get_parent(name) 62 | if found: 63 | return found 64 | raise ValueError("Parent " + name + " not found") 65 | 66 | @property 67 | def __schema__(self): 68 | schema = self._schema 69 | 70 | if self.__parents__: 71 | refs = [ 72 | {"$ref": "#/definitions/{0}".format(parent.name)} 73 | for parent in self.__parents__ 74 | ] 75 | 76 | return {"allOf": refs + [schema]} 77 | else: 78 | return schema 79 | 80 | @classmethod 81 | def inherit(cls, name, *parents): 82 | """ 83 | Inherit this model (use the Swagger composition pattern aka. allOf) 84 | :param str name: The new model name 85 | :param dict fields: The new model extra fields 86 | """ 87 | model = cls(name, parents[-1]) 88 | model.__parents__ = parents[:-1] 89 | return model 90 | 91 | def validate(self, data, resolver=None, format_checker=None): 92 | validator = Draft4Validator( 93 | self.__schema__, resolver=resolver, format_checker=format_checker 94 | ) 95 | try: 96 | validator.validate(data) 97 | except ValidationError: 98 | abort( 99 | HTTPStatus.BAD_REQUEST, 100 | message="Input payload validation failed", 101 | errors=dict(self.format_error(e) for e in validator.iter_errors(data)), 102 | ) 103 | 104 | def format_error(self, error): 105 | path = list(error.path) 106 | if error.validator == "required": 107 | name = RE_REQUIRED.match(error.message).group("name") 108 | path.append(name) 109 | key = ".".join(str(p) for p in path) 110 | return key, error.message 111 | 112 | def __unicode__(self): 113 | return "Model({name},{{{fields}}})".format( 114 | name=self.name, fields=",".join(self.keys()) 115 | ) 116 | 117 | __str__ = __unicode__ 118 | 119 | 120 | class RawModel(ModelBase): 121 | """ 122 | A thin wrapper on ordered fields dict to store API doc metadata. 123 | Can also be used for response marshalling. 124 | 125 | :param str name: The model public name 126 | :param str mask: an optional default model mask 127 | :param bool strict: validation should raise error when there is param not provided in schema 128 | """ 129 | 130 | wrapper = dict 131 | 132 | def __init__(self, name, *args, **kwargs): 133 | self.__mask__ = kwargs.pop("mask", None) 134 | self.__strict__ = kwargs.pop("strict", False) 135 | if self.__mask__ and not isinstance(self.__mask__, Mask): 136 | self.__mask__ = Mask(self.__mask__) 137 | super(RawModel, self).__init__(name, *args, **kwargs) 138 | 139 | def instance_clone(name, *parents): 140 | return self.__class__.clone(name, self, *parents) 141 | 142 | self.clone = instance_clone 143 | 144 | @property 145 | def _schema(self): 146 | properties = self.wrapper() 147 | required = set() 148 | discriminator = None 149 | for name, field in self.items(): 150 | field = instance(field) 151 | properties[name] = field.__schema__ 152 | if field.required: 153 | required.add(name) 154 | if getattr(field, "discriminator", False): 155 | discriminator = name 156 | 157 | definition = { 158 | "required": sorted(list(required)) or None, 159 | "properties": properties, 160 | "discriminator": discriminator, 161 | "x-mask": str(self.__mask__) if self.__mask__ else None, 162 | "type": "object", 163 | } 164 | 165 | if self.__strict__: 166 | definition["additionalProperties"] = False 167 | 168 | return not_none(definition) 169 | 170 | @cached_property 171 | def resolved(self): 172 | """ 173 | Resolve real fields before submitting them to marshal 174 | """ 175 | # Duplicate fields 176 | resolved = copy.deepcopy(self) 177 | 178 | # Recursively copy parent fields if necessary 179 | for parent in self.__parents__: 180 | resolved.update(parent.resolved) 181 | 182 | # Handle discriminator 183 | candidates = [f for f in resolved.values() if getattr(f, "discriminator", None)] 184 | # Ensure the is only one discriminator 185 | if len(candidates) > 1: 186 | raise ValueError("There can only be one discriminator by schema") 187 | # Ensure discriminator always output the model name 188 | elif len(candidates) == 1: 189 | candidates[0].default = self.name 190 | 191 | return resolved 192 | 193 | def extend(self, name, fields): 194 | """ 195 | Extend this model (Duplicate all fields) 196 | 197 | :param str name: The new model name 198 | :param dict fields: The new model extra fields 199 | 200 | :deprecated: since 0.9. Use :meth:`clone` instead. 201 | """ 202 | warnings.warn( 203 | "extend is is deprecated, use clone instead", 204 | DeprecationWarning, 205 | stacklevel=2, 206 | ) 207 | if isinstance(fields, (list, tuple)): 208 | return self.clone(name, *fields) 209 | else: 210 | return self.clone(name, fields) 211 | 212 | @classmethod 213 | def clone(cls, name, *parents): 214 | """ 215 | Clone these models (Duplicate all fields) 216 | 217 | It can be used from the class 218 | 219 | >>> model = Model.clone(fields_1, fields_2) 220 | 221 | or from an Instanciated model 222 | 223 | >>> new_model = model.clone(fields_1, fields_2) 224 | 225 | :param str name: The new model name 226 | :param dict parents: The new model extra fields 227 | """ 228 | fields = cls.wrapper() 229 | for parent in parents: 230 | fields.update(copy.deepcopy(parent)) 231 | return cls(name, fields) 232 | 233 | def __deepcopy__(self, memo): 234 | obj = self.__class__( 235 | self.name, 236 | [(key, copy.deepcopy(value, memo)) for key, value in self.items()], 237 | mask=self.__mask__, 238 | strict=self.__strict__, 239 | ) 240 | obj.__parents__ = self.__parents__ 241 | return obj 242 | 243 | 244 | class Model(RawModel, dict, MutableMapping): 245 | """ 246 | A thin wrapper on fields dict to store API doc metadata. 247 | Can also be used for response marshalling. 248 | 249 | :param str name: The model public name 250 | :param str mask: an optional default model mask 251 | """ 252 | 253 | pass 254 | 255 | 256 | class OrderedModel(RawModel, OrderedDict, MutableMapping): 257 | """ 258 | A thin wrapper on ordered fields dict to store API doc metadata. 259 | Can also be used for response marshalling. 260 | 261 | :param str name: The model public name 262 | :param str mask: an optional default model mask 263 | """ 264 | 265 | wrapper = OrderedDict 266 | 267 | 268 | class SchemaModel(ModelBase): 269 | """ 270 | Stores API doc metadata based on a json schema. 271 | 272 | :param str name: The model public name 273 | :param dict schema: The json schema we are documenting 274 | """ 275 | 276 | def __init__(self, name, schema=None): 277 | super(SchemaModel, self).__init__(name) 278 | self._schema = schema or {} 279 | 280 | def __unicode__(self): 281 | return "SchemaModel({name},{schema})".format( 282 | name=self.name, schema=self._schema 283 | ) 284 | 285 | __str__ = __unicode__ 286 | -------------------------------------------------------------------------------- /flask_restx/postman.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from time import time 3 | from uuid import uuid5, NAMESPACE_URL 4 | 5 | from urllib.parse import urlencode 6 | 7 | 8 | def clean(data): 9 | """Remove all keys where value is None""" 10 | return dict((k, v) for k, v in data.items() if v is not None) 11 | 12 | 13 | DEFAULT_VARS = { 14 | "string": "", 15 | "integer": 0, 16 | "number": 0, 17 | } 18 | 19 | 20 | class Request(object): 21 | """Wraps a Swagger operation into a Postman Request""" 22 | 23 | def __init__(self, collection, path, params, method, operation): 24 | self.collection = collection 25 | self.path = path 26 | self.params = params 27 | self.method = method.upper() 28 | self.operation = operation 29 | 30 | @property 31 | def id(self): 32 | seed = str(" ".join((self.method, self.url))) 33 | return str(uuid5(self.collection.uuid, seed)) 34 | 35 | @property 36 | def url(self): 37 | return self.collection.api.base_url.rstrip("/") + self.path 38 | 39 | @property 40 | def headers(self): 41 | headers = {} 42 | # Handle content-type 43 | if self.method != "GET": 44 | consumes = self.collection.api.__schema__.get("consumes", []) 45 | consumes = self.operation.get("consumes", consumes) 46 | if len(consumes): 47 | headers["Content-Type"] = consumes[-1] 48 | 49 | # Add all parameters headers 50 | for param in self.operation.get("parameters", []): 51 | if param["in"] == "header": 52 | headers[param["name"]] = param.get("default", "") 53 | 54 | # Add security headers if needed (global then local) 55 | for security in self.collection.api.__schema__.get("security", []): 56 | for key, header in self.collection.apikeys.items(): 57 | if key in security: 58 | headers[header] = "" 59 | for security in self.operation.get("security", []): 60 | for key, header in self.collection.apikeys.items(): 61 | if key in security: 62 | headers[header] = "" 63 | 64 | lines = [":".join(line) for line in headers.items()] 65 | return "\n".join(lines) 66 | 67 | @property 68 | def folder(self): 69 | if "tags" not in self.operation or len(self.operation["tags"]) == 0: 70 | return 71 | tag = self.operation["tags"][0] 72 | for folder in self.collection.folders: 73 | if folder.tag == tag: 74 | return folder.id 75 | 76 | def as_dict(self, urlvars=False): 77 | url, variables = self.process_url(urlvars) 78 | return clean( 79 | { 80 | "id": self.id, 81 | "method": self.method, 82 | "name": self.operation["operationId"], 83 | "description": self.operation.get("summary"), 84 | "url": url, 85 | "headers": self.headers, 86 | "collectionId": self.collection.id, 87 | "folder": self.folder, 88 | "pathVariables": variables, 89 | "time": int(time()), 90 | } 91 | ) 92 | 93 | def process_url(self, urlvars=False): 94 | url = self.url 95 | path_vars = {} 96 | url_vars = {} 97 | params = dict((p["name"], p) for p in self.params) 98 | params.update( 99 | dict((p["name"], p) for p in self.operation.get("parameters", [])) 100 | ) 101 | if not params: 102 | return url, None 103 | for name, param in params.items(): 104 | if param["in"] == "path": 105 | url = url.replace("{%s}" % name, ":%s" % name) 106 | path_vars[name] = DEFAULT_VARS.get(param["type"], "") 107 | elif param["in"] == "query" and urlvars: 108 | default = DEFAULT_VARS.get(param["type"], "") 109 | url_vars[name] = param.get("default", default) 110 | if url_vars: 111 | url = "?".join((url, urlencode(url_vars))) 112 | return url, path_vars 113 | 114 | 115 | class Folder(object): 116 | def __init__(self, collection, tag): 117 | self.collection = collection 118 | self.tag = tag["name"] 119 | self.description = tag["description"] 120 | 121 | @property 122 | def id(self): 123 | return str(uuid5(self.collection.uuid, str(self.tag))) 124 | 125 | @property 126 | def order(self): 127 | return [r.id for r in self.collection.requests if r.folder == self.id] 128 | 129 | def as_dict(self): 130 | return clean( 131 | { 132 | "id": self.id, 133 | "name": self.tag, 134 | "description": self.description, 135 | "order": self.order, 136 | "collectionId": self.collection.id, 137 | } 138 | ) 139 | 140 | 141 | class PostmanCollectionV1(object): 142 | """Postman Collection (V1 format) serializer""" 143 | 144 | def __init__(self, api, swagger=False): 145 | self.api = api 146 | self.swagger = swagger 147 | 148 | @property 149 | def uuid(self): 150 | return uuid5(NAMESPACE_URL, self.api.base_url) 151 | 152 | @property 153 | def id(self): 154 | return str(self.uuid) 155 | 156 | @property 157 | def requests(self): 158 | if self.swagger: 159 | # First request is Swagger specifications 160 | yield Request( 161 | self, 162 | "/swagger.json", 163 | {}, 164 | "get", 165 | { 166 | "operationId": "Swagger specifications", 167 | "summary": "The API Swagger specifications as JSON", 168 | }, 169 | ) 170 | # Then iter over API paths and methods 171 | for path, operations in self.api.__schema__["paths"].items(): 172 | path_params = operations.get("parameters", []) 173 | 174 | for method, operation in operations.items(): 175 | if method != "parameters": 176 | yield Request(self, path, path_params, method, operation) 177 | 178 | @property 179 | def folders(self): 180 | for tag in self.api.__schema__["tags"]: 181 | yield Folder(self, tag) 182 | 183 | @property 184 | def apikeys(self): 185 | return dict( 186 | (name, secdef["name"]) 187 | for name, secdef in self.api.__schema__.get("securityDefinitions").items() 188 | if secdef.get("in") == "header" and secdef.get("type") == "apiKey" 189 | ) 190 | 191 | def as_dict(self, urlvars=False): 192 | return clean( 193 | { 194 | "id": self.id, 195 | "name": " ".join((self.api.title, self.api.version)), 196 | "description": self.api.description, 197 | "order": [r.id for r in self.requests if not r.folder], 198 | "requests": [r.as_dict(urlvars=urlvars) for r in self.requests], 199 | "folders": [f.as_dict() for f in self.folders], 200 | "timestamp": int(time()), 201 | } 202 | ) 203 | -------------------------------------------------------------------------------- /flask_restx/representations.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ujson import dumps 3 | except ImportError: 4 | from json import dumps 5 | 6 | from flask import make_response, current_app 7 | 8 | 9 | def output_json(data, code, headers=None): 10 | """Makes a Flask response with a JSON encoded body""" 11 | 12 | settings = current_app.config.get("RESTX_JSON", {}) 13 | 14 | # If we're in debug mode, and the indent is not set, we set it to a 15 | # reasonable value here. Note that this won't override any existing value 16 | # that was set. 17 | if current_app.debug: 18 | settings.setdefault("indent", 4) 19 | 20 | # always end the json dumps with a new line 21 | # see https://github.com/mitsuhiko/flask/pull/1262 22 | dumped = dumps(data, **settings) + "\n" 23 | 24 | resp = make_response(dumped, code) 25 | resp.headers.extend(headers or {}) 26 | return resp 27 | -------------------------------------------------------------------------------- /flask_restx/resource.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from flask.views import MethodView 3 | 4 | 5 | from .model import ModelBase 6 | 7 | from .utils import unpack, BaseResponse 8 | 9 | 10 | class Resource(MethodView): 11 | """ 12 | Represents an abstract RESTX resource. 13 | 14 | Concrete resources should extend from this class 15 | and expose methods for each supported HTTP method. 16 | If a resource is invoked with an unsupported HTTP method, 17 | the API will return a response with status 405 Method Not Allowed. 18 | Otherwise the appropriate method is called and passed all arguments 19 | from the url rule used when adding the resource to an Api instance. 20 | See :meth:`~flask_restx.Api.add_resource` for details. 21 | """ 22 | 23 | representations = None 24 | method_decorators = [] 25 | 26 | def __init__(self, api=None, *args, **kwargs): 27 | self.api = api 28 | 29 | def dispatch_request(self, *args, **kwargs): 30 | # Taken from flask 31 | meth = getattr(self, request.method.lower(), None) 32 | if meth is None and request.method == "HEAD": 33 | meth = getattr(self, "get", None) 34 | assert meth is not None, "Unimplemented method %r" % request.method 35 | 36 | for decorator in self.method_decorators: 37 | meth = decorator(meth) 38 | 39 | self.validate_payload(meth) 40 | 41 | resp = meth(*args, **kwargs) 42 | 43 | if isinstance(resp, BaseResponse): 44 | return resp 45 | 46 | representations = self.representations or {} 47 | 48 | mediatype = request.accept_mimetypes.best_match(representations, default=None) 49 | if mediatype in representations: 50 | data, code, headers = unpack(resp) 51 | resp = representations[mediatype](data, code, headers) 52 | resp.headers["Content-Type"] = mediatype 53 | return resp 54 | 55 | return resp 56 | 57 | def __validate_payload(self, expect, collection=False): 58 | """ 59 | :param ModelBase expect: the expected model for the input payload 60 | :param bool collection: False if a single object of a resource is 61 | expected, True if a collection of objects of a resource is expected. 62 | """ 63 | # TODO: proper content negotiation 64 | data = request.get_json() 65 | if collection: 66 | data = data if isinstance(data, list) else [data] 67 | for obj in data: 68 | expect.validate(obj, self.api.refresolver, self.api.format_checker) 69 | else: 70 | expect.validate(data, self.api.refresolver, self.api.format_checker) 71 | 72 | def validate_payload(self, func): 73 | """Perform a payload validation on expected model if necessary""" 74 | if getattr(func, "__apidoc__", False) is not False: 75 | doc = func.__apidoc__ 76 | validate = doc.get("validate", None) 77 | validate = validate if validate is not None else self.api._validate 78 | if validate: 79 | for expect in doc.get("expect", []): 80 | # TODO: handle third party handlers 81 | if isinstance(expect, list) and len(expect) == 1: 82 | if isinstance(expect[0], ModelBase): 83 | self.__validate_payload(expect[0], collection=True) 84 | if isinstance(expect, ModelBase): 85 | self.__validate_payload(expect, collection=False) 86 | -------------------------------------------------------------------------------- /flask_restx/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module give access to OpenAPI specifications schemas 3 | and allows to validate specs against them. 4 | 5 | .. versionadded:: 0.12.1 6 | """ 7 | 8 | import io 9 | import json 10 | 11 | import importlib_resources 12 | 13 | from collections.abc import Mapping 14 | from jsonschema import Draft4Validator 15 | 16 | from flask_restx import errors 17 | 18 | 19 | class SchemaValidationError(errors.ValidationError): 20 | """ 21 | Raised when specification is not valid 22 | 23 | .. versionadded:: 0.12.1 24 | """ 25 | 26 | def __init__(self, msg, errors=None): 27 | super(SchemaValidationError, self).__init__(msg) 28 | self.errors = errors 29 | 30 | def __str__(self): 31 | msg = [self.msg] 32 | for error in sorted(self.errors, key=lambda e: e.path): 33 | path = ".".join(error.path) 34 | msg.append("- {}: {}".format(path, error.message)) 35 | for suberror in sorted(error.context, key=lambda e: e.schema_path): 36 | path = ".".join(suberror.schema_path) 37 | msg.append(" - {}: {}".format(path, suberror.message)) 38 | return "\n".join(msg) 39 | 40 | __unicode__ = __str__ 41 | 42 | 43 | class LazySchema(Mapping): 44 | """ 45 | A thin wrapper around schema file lazy loading the data on first access 46 | 47 | :param filename str: The package relative json schema filename 48 | :param validator: The jsonschema validator class version 49 | 50 | .. versionadded:: 0.12.1 51 | """ 52 | 53 | def __init__(self, filename, validator=Draft4Validator): 54 | super(LazySchema, self).__init__() 55 | self.filename = filename 56 | self._schema = None 57 | self._validator = validator 58 | 59 | def _load(self): 60 | if not self._schema: 61 | ref = importlib_resources.files(__name__) / self.filename 62 | 63 | with io.open(ref) as infile: 64 | self._schema = json.load(infile) 65 | 66 | def __getitem__(self, key): 67 | self._load() 68 | return self._schema.__getitem__(key) 69 | 70 | def __iter__(self): 71 | self._load() 72 | return self._schema.__iter__() 73 | 74 | def __len__(self): 75 | self._load() 76 | return self._schema.__len__() 77 | 78 | @property 79 | def validator(self): 80 | """The jsonschema validator to validate against""" 81 | return self._validator(self) 82 | 83 | 84 | #: OpenAPI 2.0 specification schema 85 | OAS_20 = LazySchema("oas-2.0.json") 86 | 87 | #: Map supported OpenAPI versions to their JSON schema 88 | VERSIONS = { 89 | "2.0": OAS_20, 90 | } 91 | 92 | 93 | def validate(data): 94 | """ 95 | Validate an OpenAPI specification. 96 | 97 | Supported OpenAPI versions: 2.0 98 | 99 | :param data dict: The specification to validate 100 | :returns boolean: True if the specification is valid 101 | :raises SchemaValidationError: when the specification is invalid 102 | :raises flask_restx.errors.SpecsError: when it's not possible to determinate 103 | the schema to validate against 104 | 105 | .. versionadded:: 0.12.1 106 | """ 107 | if "swagger" not in data: 108 | raise errors.SpecsError("Unable to determinate OpenAPI schema version") 109 | 110 | version = data["swagger"] 111 | if version not in VERSIONS: 112 | raise errors.SpecsError('Unknown OpenAPI schema version "{}"'.format(version)) 113 | 114 | validator = VERSIONS[version].validator 115 | 116 | validation_errors = list(validator.iter_errors(data)) 117 | if validation_errors: 118 | raise SchemaValidationError( 119 | "OpenAPI {} validation failed".format(version), errors=validation_errors 120 | ) 121 | return True 122 | -------------------------------------------------------------------------------- /flask_restx/templates/swagger-ui-css.html: -------------------------------------------------------------------------------- 1 | {# 3 | 5 | 7 | 9 | 11 | #} 12 | 13 | 14 | 33 | -------------------------------------------------------------------------------- /flask_restx/templates/swagger-ui-libs.html: -------------------------------------------------------------------------------- 1 | {# 2 | {% if config.SWAGGER_UI_LANGUAGES %} 3 | 4 | {% for lang in config.SWAGGER_UI_LANGUAGES %} 5 | {% set filename = 'lang/{0}.js'.format(lang) %} 6 | 7 | {% endfor%} 8 | {% endif %} 9 | #} 10 | 11 | 12 | -------------------------------------------------------------------------------- /flask_restx/templates/swagger-ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | {% include 'swagger-ui-css.html' %} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 | 49 | {% include 'swagger-ui-libs.html' %} 50 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /flask_restx/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import warnings 3 | import typing 4 | 5 | from collections import OrderedDict 6 | from copy import deepcopy 7 | 8 | from ._http import HTTPStatus 9 | 10 | 11 | FIRST_CAP_RE = re.compile("(.)([A-Z][a-z]+)") 12 | ALL_CAP_RE = re.compile("([a-z0-9])([A-Z])") 13 | 14 | 15 | __all__ = ( 16 | "merge", 17 | "camel_to_dash", 18 | "default_id", 19 | "not_none", 20 | "not_none_sorted", 21 | "unpack", 22 | "BaseResponse", 23 | "import_check_view_func", 24 | ) 25 | 26 | 27 | def import_werkzeug_response(): 28 | """Resolve `werkzeug` `Response` class import because 29 | `BaseResponse` was renamed in version 2.* to `Response`""" 30 | import importlib.metadata 31 | 32 | werkzeug_major = int(importlib.metadata.version("werkzeug").split(".")[0]) 33 | if werkzeug_major < 2: 34 | from werkzeug.wrappers import BaseResponse 35 | 36 | return BaseResponse 37 | 38 | from werkzeug.wrappers import Response 39 | 40 | return Response 41 | 42 | 43 | BaseResponse = import_werkzeug_response() 44 | 45 | 46 | class FlaskCompatibilityWarning(DeprecationWarning): 47 | pass 48 | 49 | 50 | def merge(first, second): 51 | """ 52 | Recursively merges two dictionaries. 53 | 54 | Second dictionary values will take precedence over those from the first one. 55 | Nested dictionaries are merged too. 56 | 57 | :param dict first: The first dictionary 58 | :param dict second: The second dictionary 59 | :return: the resulting merged dictionary 60 | :rtype: dict 61 | """ 62 | if not isinstance(second, dict): 63 | return second 64 | result = deepcopy(first) 65 | for key, value in second.items(): 66 | if key in result and isinstance(result[key], dict): 67 | result[key] = merge(result[key], value) 68 | else: 69 | result[key] = deepcopy(value) 70 | return result 71 | 72 | 73 | def camel_to_dash(value): 74 | """ 75 | Transform a CamelCase string into a low_dashed one 76 | 77 | :param str value: a CamelCase string to transform 78 | :return: the low_dashed string 79 | :rtype: str 80 | """ 81 | first_cap = FIRST_CAP_RE.sub(r"\1_\2", value) 82 | return ALL_CAP_RE.sub(r"\1_\2", first_cap).lower() 83 | 84 | 85 | def default_id(resource, method): 86 | """Default operation ID generator""" 87 | return "{0}_{1}".format(method, camel_to_dash(resource)) 88 | 89 | 90 | def not_none(data): 91 | """ 92 | Remove all keys where value is None 93 | 94 | :param dict data: A dictionary with potentially some values set to None 95 | :return: The same dictionary without the keys with values to ``None`` 96 | :rtype: dict 97 | """ 98 | return dict((k, v) for k, v in data.items() if v is not None) 99 | 100 | 101 | def not_none_sorted(data): 102 | """ 103 | Remove all keys where value is None 104 | 105 | :param OrderedDict data: A dictionary with potentially some values set to None 106 | :return: The same dictionary without the keys with values to ``None`` 107 | :rtype: OrderedDict 108 | """ 109 | return OrderedDict((k, v) for k, v in sorted(data.items()) if v is not None) 110 | 111 | 112 | def unpack(response, default_code=HTTPStatus.OK): 113 | """ 114 | Unpack a Flask standard response. 115 | 116 | Flask response can be: 117 | - a single value 118 | - a 2-tuple ``(value, code)`` 119 | - a 3-tuple ``(value, code, headers)`` 120 | 121 | .. warning:: 122 | 123 | When using this function, you must ensure that the tuple is not the response data. 124 | To do so, prefer returning list instead of tuple for listings. 125 | 126 | :param response: A Flask style response 127 | :param int default_code: The HTTP code to use as default if none is provided 128 | :return: a 3-tuple ``(data, code, headers)`` 129 | :rtype: tuple 130 | :raise ValueError: if the response does not have one of the expected format 131 | """ 132 | if not isinstance(response, tuple): 133 | # data only 134 | return response, default_code, {} 135 | elif len(response) == 1: 136 | # data only as tuple 137 | return response[0], default_code, {} 138 | elif len(response) == 2: 139 | # data and code 140 | data, code = response 141 | return data, code, {} 142 | elif len(response) == 3: 143 | # data, code and headers 144 | data, code, headers = response 145 | return data, code or default_code, headers 146 | else: 147 | raise ValueError("Too many response values") 148 | 149 | 150 | def to_view_name(view_func: typing.Callable) -> str: 151 | """Helper that returns the default endpoint for a given 152 | function. This always is the function name. 153 | 154 | Note: copy of simple flask internal helper 155 | """ 156 | assert view_func is not None, "expected view func if endpoint is not provided." 157 | return view_func.__name__ 158 | 159 | 160 | def import_check_view_func(): 161 | """ 162 | Resolve import flask _endpoint_from_view_func. 163 | 164 | Show warning if function cannot be found and provide copy of last known implementation. 165 | 166 | Note: This helper method exists because reoccurring problem with flask function, but 167 | actual method body remaining the same in each flask version. 168 | """ 169 | import importlib.metadata 170 | 171 | flask_version = importlib.metadata.version("flask").split(".") 172 | try: 173 | if flask_version[0] == "1": 174 | from flask.helpers import _endpoint_from_view_func 175 | elif flask_version[0] == "2": 176 | from flask.scaffold import _endpoint_from_view_func 177 | elif flask_version[0] == "3": 178 | from flask.sansio.scaffold import _endpoint_from_view_func 179 | else: 180 | warnings.simplefilter("once", FlaskCompatibilityWarning) 181 | _endpoint_from_view_func = None 182 | except ImportError: 183 | warnings.simplefilter("once", FlaskCompatibilityWarning) 184 | _endpoint_from_view_func = None 185 | if _endpoint_from_view_func is None: 186 | _endpoint_from_view_func = to_view_name 187 | return _endpoint_from_view_func 188 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flask-restx", 3 | "version": "1.3.0", 4 | "description": "Fully featured framework for fast, easy and documented API development with Flask", 5 | "repository": "python-restx/flask-restx", 6 | "keywords": [ 7 | "swagger", 8 | "flask" 9 | ], 10 | "author": "python-restx authors", 11 | "license": "BSD-3-Clause", 12 | "dependencies": { 13 | "swagger-ui-dist": "^4.15.0", 14 | "typeface-droid-sans": "0.0.40" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /readthedocs.pip: -------------------------------------------------------------------------------- 1 | sphinx 2 | alabaster 3 | sphinx_issues 4 | -------------------------------------------------------------------------------- /requirements/develop.pip: -------------------------------------------------------------------------------- 1 | tox 2 | black 3 | -------------------------------------------------------------------------------- /requirements/doc.pip: -------------------------------------------------------------------------------- 1 | alabaster==0.7.12 2 | Sphinx==5.3.0 3 | sphinx-issues==3.0.1 4 | -------------------------------------------------------------------------------- /requirements/install.pip: -------------------------------------------------------------------------------- 1 | aniso8601>=0.82 2 | jsonschema 3 | Flask>=0.8, !=2.0.0 4 | werkzeug!=2.0.0 5 | importlib_resources 6 | -------------------------------------------------------------------------------- /requirements/test.pip: -------------------------------------------------------------------------------- 1 | blinker 2 | Faker==2.0.0 3 | mock==3.0.5 4 | pytest==7.0.1 5 | pytest-benchmark==3.4.1 6 | pytest-cov==4.0.0 7 | pytest-flask==1.3.0 8 | pytest-mock==3.6.1 9 | pytest-profiling==1.7.0 10 | invoke==2.2.0 11 | twine==3.8.0 12 | setuptools 13 | backports.zoneinfo;python_version<"3.9" 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [tool:pytest] 5 | testpaths = tests 6 | python_files = test_*.py bench_*.py 7 | python_functions = test_* bench_* 8 | python_classes = *Test *Benchmark 9 | markers = 10 | api: test requiring an initialized API 11 | request_context: switch the request 12 | 13 | [ossaudit] 14 | 15 | # The issue is fixed since the v40.8.0 of setuptools, but 16 | # the python3.5 and python3.6 use the old versions. 17 | # https://ossindex.sonatype.org/vuln/06e60262-8241-42ef-8f64-e3d72091de19 18 | # Ignore it until we suppor python < 3.7 19 | ignore-ids = 06e60262-8241-42ef-8f64-e3d72091de19 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # flake8: noqa 4 | 5 | import io 6 | import os 7 | import re 8 | import sys 9 | 10 | from setuptools import setup, find_packages 11 | 12 | RE_REQUIREMENT = re.compile(r"^\s*-r\s*(?P.*)$") 13 | 14 | PYPI_RST_FILTERS = ( 15 | # Replace Python crossreferences by simple monospace 16 | (r":(?:class|func|meth|mod|attr|obj|exc|data|const):`~(?:\w+\.)*(\w+)`", r"``\1``"), 17 | (r":(?:class|func|meth|mod|attr|obj|exc|data|const):`([^`]+)`", r"``\1``"), 18 | # replace doc references 19 | ( 20 | r":doc:`(.+) <(.*)>`", 21 | r"`\1 `_", 22 | ), 23 | # replace issues references 24 | ( 25 | r":issue:`(.+?)`", 26 | r"`#\1 `_", 27 | ), 28 | # replace pr references 29 | (r":pr:`(.+?)`", r"`#\1 `_"), 30 | # replace commit references 31 | ( 32 | r":commit:`(.+?)`", 33 | r"`#\1 `_", 34 | ), 35 | # Drop unrecognized currentmodule 36 | (r"\.\. currentmodule:: .*", ""), 37 | ) 38 | 39 | 40 | def rst(filename): 41 | """ 42 | Load rst file and sanitize it for PyPI. 43 | Remove unsupported github tags: 44 | - code-block directive 45 | - all badges 46 | """ 47 | content = io.open(filename).read() 48 | for regex, replacement in PYPI_RST_FILTERS: 49 | content = re.sub(regex, replacement, content) 50 | return content 51 | 52 | 53 | def pip(filename): 54 | """Parse pip reqs file and transform it to setuptools requirements.""" 55 | requirements = [] 56 | for line in io.open(os.path.join("requirements", "{0}.pip".format(filename))): 57 | line = line.strip() 58 | if not line or "://" in line or line.startswith("#"): 59 | continue 60 | requirements.append(line) 61 | return requirements 62 | 63 | 64 | long_description = "\n".join((rst("README.rst"), "")) 65 | 66 | 67 | exec( 68 | compile(open("flask_restx/__about__.py").read(), "flask_restx/__about__.py", "exec") 69 | ) 70 | 71 | install_requires = pip("install") 72 | doc_require = pip("doc") 73 | tests_require = pip("test") 74 | dev_require = tests_require + pip("develop") 75 | 76 | setup( 77 | name="flask-restx", 78 | version=__version__, 79 | description=__description__, 80 | long_description=long_description, 81 | url="https://github.com/python-restx/flask-restx", 82 | author="python-restx Authors", 83 | packages=find_packages(exclude=["tests", "tests.*"]), 84 | include_package_data=True, 85 | install_requires=install_requires, 86 | tests_require=tests_require, 87 | dev_require=dev_require, 88 | extras_require={ 89 | "test": tests_require, 90 | "doc": doc_require, 91 | "dev": dev_require, 92 | }, 93 | license="BSD-3-Clause", 94 | zip_safe=False, 95 | keywords="flask restx rest api swagger openapi", 96 | classifiers=[ 97 | "Development Status :: 5 - Production/Stable", 98 | "Programming Language :: Python", 99 | "Environment :: Web Environment", 100 | "Operating System :: OS Independent", 101 | "Intended Audience :: Developers", 102 | "Topic :: System :: Software Distribution", 103 | "Programming Language :: Python", 104 | "Programming Language :: Python :: 3", 105 | "Programming Language :: Python :: 3.9", 106 | "Programming Language :: Python :: 3.10", 107 | "Programming Language :: Python :: 3.11", 108 | "Programming Language :: Python :: 3.12", 109 | "Programming Language :: Python :: Implementation :: PyPy", 110 | "Topic :: Software Development :: Libraries :: Python Modules", 111 | "License :: OSI Approved :: BSD License", 112 | ], 113 | python_requires=">=3.9", 114 | ) 115 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from datetime import datetime 5 | 6 | from invoke import task 7 | 8 | ROOT = os.path.dirname(__file__) 9 | 10 | CLEAN_PATTERNS = [ 11 | "build", 12 | "dist", 13 | "cover", 14 | "docs/_build", 15 | "**/*.pyc", 16 | ".tox", 17 | "**/__pycache__", 18 | "reports", 19 | "*.egg-info", 20 | ] 21 | 22 | 23 | def color(code): 24 | """A simple ANSI color wrapper factory""" 25 | return lambda t: "\033[{0}{1}\033[0;m".format(code, t) 26 | 27 | 28 | green = color("1;32m") 29 | red = color("1;31m") 30 | blue = color("1;30m") 31 | cyan = color("1;36m") 32 | purple = color("1;35m") 33 | white = color("1;39m") 34 | 35 | 36 | def header(text): 37 | """Display an header""" 38 | print(" ".join((blue(">>"), cyan(text)))) 39 | sys.stdout.flush() 40 | 41 | 42 | def info(text, *args, **kwargs): 43 | """Display informations""" 44 | text = text.format(*args, **kwargs) 45 | print(" ".join((purple(">>>"), text))) 46 | sys.stdout.flush() 47 | 48 | 49 | def success(text): 50 | """Display a success message""" 51 | print(" ".join((green(">>"), white(text)))) 52 | sys.stdout.flush() 53 | 54 | 55 | def error(text): 56 | """Display an error message""" 57 | print(red("✘ {0}".format(text))) 58 | sys.stdout.flush() 59 | 60 | 61 | def exit(text=None, code=-1): 62 | if text: 63 | error(text) 64 | sys.exit(-1) 65 | 66 | 67 | def build_args(*args): 68 | return " ".join(a for a in args if a) 69 | 70 | 71 | @task 72 | def clean(ctx): 73 | """Cleanup all build artifacts""" 74 | header(clean.__doc__) 75 | with ctx.cd(ROOT): 76 | for pattern in CLEAN_PATTERNS: 77 | info("Removing {0}", pattern) 78 | ctx.run("rm -rf {0}".format(pattern)) 79 | 80 | 81 | @task 82 | def deps(ctx): 83 | """Install or update development dependencies""" 84 | header(deps.__doc__) 85 | with ctx.cd(ROOT): 86 | ctx.run( 87 | "pip install -r requirements/develop.pip -r requirements/doc.pip", pty=True 88 | ) 89 | 90 | 91 | @task 92 | def demo(ctx): 93 | """Run the demo""" 94 | header(demo.__doc__) 95 | with ctx.cd(ROOT): 96 | ctx.run("python examples/todo.py") 97 | 98 | 99 | @task 100 | def test(ctx, profile=False): 101 | """Run tests suite""" 102 | header(test.__doc__) 103 | kwargs = build_args( 104 | "--benchmark-skip", 105 | "--profile" if profile else None, 106 | ) 107 | with ctx.cd(ROOT): 108 | ctx.run("pytest {0}".format(kwargs), pty=True) 109 | 110 | 111 | @task 112 | def benchmark( 113 | ctx, 114 | max_time=2, 115 | save=False, 116 | compare=False, 117 | histogram=False, 118 | profile=False, 119 | tox=False, 120 | ): 121 | """Run benchmarks""" 122 | header(benchmark.__doc__) 123 | ts = datetime.now() 124 | kwargs = build_args( 125 | "--benchmark-max-time={0}".format(max_time), 126 | "--benchmark-autosave" if save else None, 127 | "--benchmark-compare" if compare else None, 128 | ( 129 | "--benchmark-histogram=histograms/{0:%Y%m%d-%H%M%S}".format(ts) 130 | if histogram 131 | else None 132 | ), 133 | "--benchmark-cprofile=tottime" if profile else None, 134 | ) 135 | cmd = "pytest tests/benchmarks {0}".format(kwargs) 136 | if tox: 137 | envs = ctx.run("tox -l", hide=True).stdout.splitlines() 138 | envs = ",".join(e for e in envs if e != "doc") 139 | cmd = "tox -e {envs} -- {cmd}".format(envs=envs, cmd=cmd) 140 | ctx.run(cmd, pty=True) 141 | 142 | 143 | @task 144 | def cover(ctx, html=False): 145 | """Run tests suite with coverage""" 146 | header(cover.__doc__) 147 | extra = "--cov-report html" if html else "" 148 | with ctx.cd(ROOT): 149 | ctx.run( 150 | "pytest --benchmark-skip --cov flask_restx --cov-report term --cov-report xml {0}".format( 151 | extra 152 | ), 153 | pty=True, 154 | ) 155 | 156 | 157 | @task 158 | def tox(ctx): 159 | """Run tests against Python versions""" 160 | header(tox.__doc__) 161 | ctx.run("tox", pty=True) 162 | 163 | 164 | @task 165 | def qa(ctx): 166 | """Run a quality report""" 167 | header(qa.__doc__) 168 | with ctx.cd(ROOT): 169 | info("Ensure PyPI can render README and CHANGELOG") 170 | info("Building dist package") 171 | dist = ctx.run("python setup.py sdist", pty=True, warn=False, hide=True) 172 | if dist.failed: 173 | error("Unable to build sdist package") 174 | exit("Quality check failed", dist.return_code) 175 | readme_results = ctx.run("twine check dist/*", pty=True, warn=True, hide=True) 176 | if readme_results.failed: 177 | print(readme_results.stdout) 178 | error("README and/or CHANGELOG is not renderable by PyPI") 179 | else: 180 | success("README and CHANGELOG are renderable by PyPI") 181 | if readme_results.failed: 182 | exit("Quality check failed", readme_results.return_code) 183 | success("Quality check OK") 184 | 185 | 186 | @task 187 | def doc(ctx): 188 | """Build the documentation""" 189 | header(doc.__doc__) 190 | with ctx.cd(os.path.join(ROOT, "doc")): 191 | ctx.run("make html", pty=True) 192 | 193 | 194 | @task 195 | def assets(ctx): 196 | """Fetch web assets""" 197 | header(assets.__doc__) 198 | with ctx.cd(ROOT): 199 | ctx.run("npm install") 200 | ctx.run("mkdir -p flask_restx/static") 201 | ctx.run( 202 | "cp node_modules/swagger-ui-dist/{swagger-ui*.{css,js}{,.map},favicon*.png,oauth2-redirect.html} flask_restx/static" 203 | ) 204 | # Until next release we need to install droid sans separately 205 | ctx.run( 206 | "cp node_modules/typeface-droid-sans/index.css flask_restx/static/droid-sans.css" 207 | ) 208 | ctx.run("cp -R node_modules/typeface-droid-sans/files flask_restx/static/") 209 | 210 | 211 | @task 212 | def dist(ctx): 213 | """Package for distribution""" 214 | header(dist.__doc__) 215 | with ctx.cd(ROOT): 216 | ctx.run("python setup.py bdist_wheel", pty=True) 217 | 218 | 219 | @task(clean, deps, test, doc, qa, assets, dist, default=True) 220 | def all(ctx): 221 | """Run tests, reports and packaging""" 222 | pass 223 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-restx/flask-restx/ffb079c696e7901b0b27526810ff32c52beb8aa0/tests/__init__.py -------------------------------------------------------------------------------- /tests/benchmarks/bench_marshalling.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from faker import Faker 4 | 5 | from flask_restx import marshal, fields 6 | 7 | fake = Faker() 8 | 9 | person_fields = {"name": fields.String, "age": fields.Integer} 10 | 11 | family_fields = { 12 | "father": fields.Nested(person_fields), 13 | "mother": fields.Nested(person_fields), 14 | "children": fields.List(fields.Nested(person_fields)), 15 | } 16 | 17 | 18 | def person(): 19 | return {"name": fake.name(), "age": fake.pyint()} 20 | 21 | 22 | def family(): 23 | return {"father": person(), "mother": person(), "children": [person(), person()]} 24 | 25 | 26 | def marshal_simple(): 27 | return marshal(person(), person_fields) 28 | 29 | 30 | def marshal_nested(): 31 | return marshal(family(), family_fields) 32 | 33 | 34 | def marshal_simple_with_mask(app): 35 | with app.test_request_context("/", headers={"X-Fields": "name"}): 36 | return marshal(person(), person_fields) 37 | 38 | 39 | def marshal_nested_with_mask(app): 40 | with app.test_request_context("/", headers={"X-Fields": "father,children{name}"}): 41 | return marshal(family(), family_fields) 42 | 43 | 44 | @pytest.mark.benchmark(group="marshalling") 45 | class MarshallingBenchmark(object): 46 | def bench_marshal_simple(self, benchmark): 47 | benchmark(marshal_simple) 48 | 49 | def bench_marshal_nested(self, benchmark): 50 | benchmark(marshal_nested) 51 | 52 | def bench_marshal_simple_with_mask(self, app, benchmark): 53 | benchmark(marshal_simple_with_mask, app) 54 | 55 | def bench_marshal_nested_with_mask(self, app, benchmark): 56 | benchmark(marshal_nested_with_mask, app) 57 | -------------------------------------------------------------------------------- /tests/benchmarks/bench_swagger.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_restx import fields, Api, Resource 4 | from flask_restx.swagger import Swagger 5 | 6 | api = Api() 7 | 8 | person = api.model("Person", {"name": fields.String, "age": fields.Integer}) 9 | 10 | family = api.model( 11 | "Family", 12 | { 13 | "name": fields.String, 14 | "father": fields.Nested(person), 15 | "mother": fields.Nested(person), 16 | "children": fields.List(fields.Nested(person)), 17 | }, 18 | ) 19 | 20 | 21 | @api.route("/families", endpoint="families") 22 | class Families(Resource): 23 | @api.marshal_with(family) 24 | def get(self): 25 | """List all families""" 26 | pass 27 | 28 | @api.marshal_with(family) 29 | @api.response(201, "Family created") 30 | def post(self): 31 | """Create a new family""" 32 | pass 33 | 34 | 35 | @api.route("/families//", endpoint="family") 36 | @api.response(404, "Family not found") 37 | class Family(Resource): 38 | @api.marshal_with(family) 39 | def get(self): 40 | """Get a family given its name""" 41 | pass 42 | 43 | @api.marshal_with(family) 44 | def put(self): 45 | """Update a family given its name""" 46 | pass 47 | 48 | 49 | @api.route("/persons", endpoint="persons") 50 | class Persons(Resource): 51 | @api.marshal_with(person) 52 | def get(self): 53 | """List all persons""" 54 | pass 55 | 56 | @api.marshal_with(person) 57 | @api.response(201, "Person created") 58 | def post(self): 59 | """Create a new person""" 60 | pass 61 | 62 | 63 | @api.route("/persons//", endpoint="person") 64 | @api.response(404, "Person not found") 65 | class Person(Resource): 66 | @api.marshal_with(person) 67 | def get(self): 68 | """Get a person given its name""" 69 | pass 70 | 71 | @api.marshal_with(person) 72 | def put(self): 73 | """Update a person given its name""" 74 | pass 75 | 76 | 77 | def swagger_specs(app): 78 | with app.test_request_context("/"): 79 | return Swagger(api).as_dict() 80 | 81 | 82 | def swagger_specs_cached(app): 83 | with app.test_request_context("/"): 84 | return api.__schema__ 85 | 86 | 87 | @pytest.mark.benchmark(group="swagger") 88 | class SwaggerBenchmark(object): 89 | @pytest.fixture(autouse=True) 90 | def register(self, app): 91 | api.init_app(app) 92 | 93 | def bench_swagger_specs(self, app, benchmark): 94 | benchmark(swagger_specs, app) 95 | 96 | def bench_swagger_specs_cached(self, app, benchmark): 97 | benchmark(swagger_specs_cached, app) 98 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | 4 | from flask import Flask, Blueprint 5 | from flask.testing import FlaskClient 6 | 7 | import flask_restx as restx 8 | 9 | 10 | class TestClient(FlaskClient): 11 | # Borrowed from https://pythonadventures.wordpress.com/2016/03/06/detect-duplicate-keys-in-a-json-file/ 12 | # Thank you to Wordpress author @ubuntuincident, aka Jabba Laci. 13 | def dict_raise_on_duplicates(self, ordered_pairs): 14 | """Reject duplicate keys.""" 15 | d = {} 16 | for k, v in ordered_pairs: 17 | if k in d: 18 | raise ValueError("duplicate key: %r" % (k,)) 19 | else: 20 | d[k] = v 21 | return d 22 | 23 | def get_json(self, url, status=200, **kwargs): 24 | response = self.get(url, **kwargs) 25 | assert response.status_code == status 26 | assert response.content_type == "application/json" 27 | return json.loads( 28 | response.data.decode("utf8"), 29 | object_pairs_hook=self.dict_raise_on_duplicates, 30 | ) 31 | 32 | def post_json(self, url, data, status=200, **kwargs): 33 | response = self.post( 34 | url, data=json.dumps(data), headers={"content-type": "application/json"} 35 | ) 36 | assert response.status_code == status 37 | assert response.content_type == "application/json" 38 | return json.loads(response.data.decode("utf8")) 39 | 40 | def get_specs(self, prefix="", status=200, **kwargs): 41 | """Get a Swagger specification for a RESTX API""" 42 | return self.get_json("{0}/swagger.json".format(prefix), status=status, **kwargs) 43 | 44 | 45 | @pytest.fixture 46 | def app(): 47 | app = Flask(__name__, subdomain_matching=True) 48 | app.test_client_class = TestClient 49 | yield app 50 | 51 | 52 | @pytest.fixture 53 | def api(request, app): 54 | marker = request.node.get_closest_marker("api") 55 | bpkwargs = {} 56 | kwargs = {} 57 | if marker: 58 | if "prefix" in marker.kwargs: 59 | bpkwargs["url_prefix"] = marker.kwargs.pop("prefix") 60 | if "subdomain" in marker.kwargs: 61 | bpkwargs["subdomain"] = marker.kwargs.pop("subdomain") 62 | kwargs = marker.kwargs 63 | blueprint = Blueprint("api", __name__, **bpkwargs) 64 | api = restx.Api(blueprint, **kwargs) 65 | app.register_blueprint(blueprint) 66 | yield api 67 | 68 | 69 | @pytest.fixture 70 | def mock_app(mocker): 71 | app = mocker.Mock(Flask) 72 | # mock Flask app object doesn't have any real loggers -> mock logging 73 | # set up on Api object 74 | mocker.patch.object(restx.Api, "_configure_namespace_logger") 75 | app.view_functions = {} 76 | app.extensions = {} 77 | app.config = {} 78 | return app 79 | 80 | 81 | @pytest.fixture(autouse=True) 82 | def _push_custom_request_context(request): 83 | app = request.getfixturevalue("app") 84 | options = request.node.get_closest_marker("request_context") 85 | 86 | if options is None: 87 | return 88 | 89 | ctx = app.test_request_context(*options.args, **options.kwargs) 90 | ctx.push() 91 | 92 | def teardown(): 93 | ctx.pop() 94 | 95 | request.addfinalizer(teardown) 96 | -------------------------------------------------------------------------------- /tests/legacy/test_api_with_blueprint.py: -------------------------------------------------------------------------------- 1 | import flask 2 | 3 | from flask import Blueprint, request 4 | 5 | import flask_restx as restx 6 | 7 | 8 | # Add a dummy Resource to verify that the app is properly set. 9 | class HelloWorld(restx.Resource): 10 | def get(self): 11 | return {} 12 | 13 | 14 | class GoodbyeWorld(restx.Resource): 15 | def __init__(self, err): 16 | self.err = err 17 | 18 | def get(self): 19 | flask.abort(self.err) 20 | 21 | 22 | class APIWithBlueprintTest(object): 23 | def test_api_base(self, app): 24 | blueprint = Blueprint("test", __name__) 25 | api = restx.Api(blueprint) 26 | app.register_blueprint(blueprint) 27 | assert api.urls == {} 28 | assert api.prefix == "" 29 | assert api.default_mediatype == "application/json" 30 | 31 | def test_api_delayed_initialization(self, app): 32 | blueprint = Blueprint("test", __name__) 33 | api = restx.Api() 34 | api.init_app(blueprint) 35 | app.register_blueprint(blueprint) 36 | api.add_resource(HelloWorld, "/", endpoint="hello") 37 | 38 | def test_add_resource_endpoint(self, app, mocker): 39 | blueprint = Blueprint("test", __name__) 40 | api = restx.Api(blueprint) 41 | view = mocker.Mock(**{"as_view.return_value.__name__": str("test_view")}) 42 | api.add_resource(view, "/foo", endpoint="bar") 43 | app.register_blueprint(blueprint) 44 | view.as_view.assert_called_with("bar", api) 45 | 46 | def test_add_resource_endpoint_after_registration(self, app, mocker): 47 | blueprint = Blueprint("test", __name__) 48 | api = restx.Api(blueprint) 49 | app.register_blueprint(blueprint) 50 | view = mocker.Mock(**{"as_view.return_value.__name__": str("test_view")}) 51 | api.add_resource(view, "/foo", endpoint="bar") 52 | view.as_view.assert_called_with("bar", api) 53 | 54 | def test_url_with_api_prefix(self, app): 55 | blueprint = Blueprint("test", __name__) 56 | api = restx.Api(blueprint, prefix="/api") 57 | api.add_resource(HelloWorld, "/hi", endpoint="hello") 58 | app.register_blueprint(blueprint) 59 | with app.test_request_context("/api/hi"): 60 | assert request.endpoint == "test.hello" 61 | 62 | def test_url_with_blueprint_prefix(self, app): 63 | blueprint = Blueprint("test", __name__, url_prefix="/bp") 64 | api = restx.Api(blueprint) 65 | api.add_resource(HelloWorld, "/hi", endpoint="hello") 66 | app.register_blueprint(blueprint) 67 | with app.test_request_context("/bp/hi"): 68 | assert request.endpoint == "test.hello" 69 | 70 | def test_url_with_registration_prefix(self, app): 71 | blueprint = Blueprint("test", __name__) 72 | api = restx.Api(blueprint) 73 | api.add_resource(HelloWorld, "/hi", endpoint="hello") 74 | app.register_blueprint(blueprint, url_prefix="/reg") 75 | with app.test_request_context("/reg/hi"): 76 | assert request.endpoint == "test.hello" 77 | 78 | def test_registration_prefix_overrides_blueprint_prefix(self, app): 79 | blueprint = Blueprint("test", __name__, url_prefix="/bp") 80 | api = restx.Api(blueprint) 81 | api.add_resource(HelloWorld, "/hi", endpoint="hello") 82 | app.register_blueprint(blueprint, url_prefix="/reg") 83 | with app.test_request_context("/reg/hi"): 84 | assert request.endpoint == "test.hello" 85 | 86 | def test_url_with_api_and_blueprint_prefix(self, app): 87 | blueprint = Blueprint("test", __name__, url_prefix="/bp") 88 | api = restx.Api(blueprint, prefix="/api") 89 | api.add_resource(HelloWorld, "/hi", endpoint="hello") 90 | app.register_blueprint(blueprint) 91 | with app.test_request_context("/bp/api/hi"): 92 | assert request.endpoint == "test.hello" 93 | 94 | def test_error_routing(self, app, mocker): 95 | blueprint = Blueprint("test", __name__) 96 | api = restx.Api(blueprint) 97 | api.add_resource(HelloWorld, "/hi", endpoint="hello") 98 | api.add_resource(GoodbyeWorld(404), "/bye", endpoint="bye") 99 | app.register_blueprint(blueprint) 100 | with app.test_request_context("/hi", method="POST"): 101 | assert api._should_use_fr_error_handler() is True 102 | assert api._has_fr_route() is True 103 | with app.test_request_context("/bye"): 104 | api._should_use_fr_error_handler = mocker.Mock(return_value=False) 105 | assert api._has_fr_route() is True 106 | 107 | def test_non_blueprint_rest_error_routing(self, app, mocker): 108 | blueprint = Blueprint("test", __name__) 109 | api = restx.Api(blueprint) 110 | api.add_resource(HelloWorld, "/hi", endpoint="hello") 111 | api.add_resource(GoodbyeWorld(404), "/bye", endpoint="bye") 112 | app.register_blueprint(blueprint, url_prefix="/blueprint") 113 | api2 = restx.Api(app) 114 | api2.add_resource(HelloWorld(api), "/hi", endpoint="hello") 115 | api2.add_resource(GoodbyeWorld(404), "/bye", endpoint="bye") 116 | with app.test_request_context("/hi", method="POST"): 117 | assert api._should_use_fr_error_handler() is False 118 | assert api2._should_use_fr_error_handler() is True 119 | assert api._has_fr_route() is False 120 | assert api2._has_fr_route() is True 121 | with app.test_request_context("/blueprint/hi", method="POST"): 122 | assert api._should_use_fr_error_handler() is True 123 | assert api2._should_use_fr_error_handler() is False 124 | assert api._has_fr_route() is True 125 | assert api2._has_fr_route() is False 126 | api._should_use_fr_error_handler = mocker.Mock(return_value=False) 127 | api2._should_use_fr_error_handler = mocker.Mock(return_value=False) 128 | with app.test_request_context("/bye"): 129 | assert api._has_fr_route() is False 130 | assert api2._has_fr_route() is True 131 | with app.test_request_context("/blueprint/bye"): 132 | assert api._has_fr_route() is True 133 | assert api2._has_fr_route() is False 134 | 135 | def test_non_blueprint_non_rest_error_routing(self, app, mocker): 136 | blueprint = Blueprint("test", __name__) 137 | api = restx.Api(blueprint) 138 | api.add_resource(HelloWorld, "/hi", endpoint="hello") 139 | api.add_resource(GoodbyeWorld(404), "/bye", endpoint="bye") 140 | app.register_blueprint(blueprint, url_prefix="/blueprint") 141 | 142 | @app.route("/hi") 143 | def hi(): 144 | return "hi" 145 | 146 | @app.route("/bye") 147 | def bye(): 148 | flask.abort(404) 149 | 150 | with app.test_request_context("/hi", method="POST"): 151 | assert api._should_use_fr_error_handler() is False 152 | assert api._has_fr_route() is False 153 | with app.test_request_context("/blueprint/hi", method="POST"): 154 | assert api._should_use_fr_error_handler() is True 155 | assert api._has_fr_route() is True 156 | api._should_use_fr_error_handler = mocker.Mock(return_value=False) 157 | with app.test_request_context("/bye"): 158 | assert api._has_fr_route() is False 159 | with app.test_request_context("/blueprint/bye"): 160 | assert api._has_fr_route() is True 161 | -------------------------------------------------------------------------------- /tests/test_accept.py: -------------------------------------------------------------------------------- 1 | import flask_restx as restx 2 | 3 | 4 | class Foo(restx.Resource): 5 | def get(self): 6 | return "data" 7 | 8 | 9 | class ErrorsTest(object): 10 | def test_accept_default_application_json(self, app, client): 11 | api = restx.Api(app) 12 | api.add_resource(Foo, "/test/") 13 | 14 | res = client.get("/test/", headers={"Accept": None}) 15 | assert res.status_code == 200 16 | assert res.content_type == "application/json" 17 | 18 | def test_accept_application_json_by_default(self, app, client): 19 | api = restx.Api(app) 20 | api.add_resource(Foo, "/test/") 21 | 22 | res = client.get("/test/", headers=[("Accept", "application/json")]) 23 | assert res.status_code == 200 24 | assert res.content_type == "application/json" 25 | 26 | def test_accept_no_default_match_acceptable(self, app, client): 27 | api = restx.Api(app, default_mediatype=None) 28 | api.add_resource(Foo, "/test/") 29 | 30 | res = client.get("/test/", headers=[("Accept", "application/json")]) 31 | assert res.status_code == 200 32 | assert res.content_type == "application/json" 33 | 34 | def test_accept_default_override_accept(self, app, client): 35 | api = restx.Api(app) 36 | api.add_resource(Foo, "/test/") 37 | 38 | res = client.get("/test/", headers=[("Accept", "text/plain")]) 39 | assert res.status_code == 200 40 | assert res.content_type == "application/json" 41 | 42 | def test_accept_default_any_pick_first(self, app, client): 43 | api = restx.Api(app) 44 | 45 | @api.representation("text/plain") 46 | def text_rep(data, status_code, headers=None): 47 | resp = app.make_response((str(data), status_code, headers)) 48 | return resp 49 | 50 | api.add_resource(Foo, "/test/") 51 | 52 | res = client.get("/test/", headers=[("Accept", "*/*")]) 53 | assert res.status_code == 200 54 | assert res.content_type == "application/json" 55 | 56 | def test_accept_no_default_no_match_not_acceptable(self, app, client): 57 | api = restx.Api(app, default_mediatype=None) 58 | api.add_resource(Foo, "/test/") 59 | 60 | res = client.get("/test/", headers=[("Accept", "text/plain")]) 61 | assert res.status_code == 406 62 | assert res.content_type == "application/json" 63 | 64 | def test_accept_no_default_custom_repr_match(self, app, client): 65 | api = restx.Api(app, default_mediatype=None) 66 | api.representations = {} 67 | 68 | @api.representation("text/plain") 69 | def text_rep(data, status_code, headers=None): 70 | resp = app.make_response((str(data), status_code, headers)) 71 | return resp 72 | 73 | api.add_resource(Foo, "/test/") 74 | 75 | res = client.get("/test/", headers=[("Accept", "text/plain")]) 76 | assert res.status_code == 200 77 | assert res.content_type == "text/plain" 78 | 79 | def test_accept_no_default_custom_repr_not_acceptable(self, app, client): 80 | api = restx.Api(app, default_mediatype=None) 81 | api.representations = {} 82 | 83 | @api.representation("text/plain") 84 | def text_rep(data, status_code, headers=None): 85 | resp = app.make_response((str(data), status_code, headers)) 86 | return resp 87 | 88 | api.add_resource(Foo, "/test/") 89 | 90 | res = client.get("/test/", headers=[("Accept", "application/json")]) 91 | assert res.status_code == 406 92 | assert res.content_type == "text/plain" 93 | 94 | def test_accept_no_default_match_q0_not_acceptable(self, app, client): 95 | """ 96 | q=0 should be considered NotAcceptable, 97 | but this depends on werkzeug >= 1.0 which is not yet released 98 | so this test is expected to fail until we depend on werkzeug >= 1.0 99 | """ 100 | api = restx.Api(app, default_mediatype=None) 101 | api.add_resource(Foo, "/test/") 102 | 103 | res = client.get("/test/", headers=[("Accept", "application/json; q=0")]) 104 | assert res.status_code == 406 105 | assert res.content_type == "application/json" 106 | 107 | def test_accept_no_default_accept_highest_quality_of_two(self, app, client): 108 | api = restx.Api(app, default_mediatype=None) 109 | 110 | @api.representation("text/plain") 111 | def text_rep(data, status_code, headers=None): 112 | resp = app.make_response((str(data), status_code, headers)) 113 | return resp 114 | 115 | api.add_resource(Foo, "/test/") 116 | 117 | res = client.get( 118 | "/test/", headers=[("Accept", "application/json; q=0.1, text/plain; q=1.0")] 119 | ) 120 | assert res.status_code == 200 121 | assert res.content_type == "text/plain" 122 | 123 | def test_accept_no_default_accept_highest_quality_of_three(self, app, client): 124 | api = restx.Api(app, default_mediatype=None) 125 | 126 | @api.representation("text/html") 127 | @api.representation("text/plain") 128 | def text_rep(data, status_code, headers=None): 129 | resp = app.make_response((str(data), status_code, headers)) 130 | return resp 131 | 132 | api.add_resource(Foo, "/test/") 133 | 134 | res = client.get( 135 | "/test/", 136 | headers=[ 137 | ( 138 | "Accept", 139 | "application/json; q=0.1, text/plain; q=0.3, text/html; q=0.2", 140 | ) 141 | ], 142 | ) 143 | assert res.status_code == 200 144 | assert res.content_type == "text/plain" 145 | 146 | def test_accept_no_default_no_representations(self, app, client): 147 | api = restx.Api(app, default_mediatype=None) 148 | api.representations = {} 149 | 150 | api.add_resource(Foo, "/test/") 151 | 152 | res = client.get("/test/", headers=[("Accept", "text/plain")]) 153 | assert res.status_code == 406 154 | assert res.content_type == "text/plain" 155 | 156 | def test_accept_invalid_default_no_representations(self, app, client): 157 | api = restx.Api(app, default_mediatype="nonexistant/mediatype") 158 | api.representations = {} 159 | 160 | api.add_resource(Foo, "/test/") 161 | 162 | res = client.get("/test/", headers=[("Accept", "text/plain")]) 163 | assert res.status_code == 500 164 | -------------------------------------------------------------------------------- /tests/test_apidoc.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask import url_for, Blueprint 4 | from werkzeug.routing import BuildError 5 | 6 | import flask_restx as restx 7 | 8 | 9 | class APIDocTest(object): 10 | def test_default_apidoc_on_root(self, app, client): 11 | restx.Api(app, version="1.0") 12 | 13 | assert url_for("doc") == url_for("root") 14 | 15 | response = client.get(url_for("doc")) 16 | assert response.status_code == 200 17 | assert response.content_type == "text/html; charset=utf-8" 18 | 19 | def test_default_apidoc_on_root_lazy(self, app, client): 20 | api = restx.Api(version="1.0") 21 | api.init_app(app) 22 | 23 | assert url_for("doc") == url_for("root") 24 | 25 | response = client.get(url_for("doc")) 26 | assert response.status_code == 200 27 | assert response.content_type == "text/html; charset=utf-8" 28 | 29 | def test_default_apidoc_on_root_with_blueprint(self, app, client): 30 | blueprint = Blueprint("api", __name__, url_prefix="/api") 31 | restx.Api(blueprint, version="1.0") 32 | app.register_blueprint(blueprint) 33 | 34 | assert url_for("api.doc") == url_for("api.root") 35 | 36 | response = client.get(url_for("api.doc")) 37 | assert response.status_code == 200 38 | assert response.content_type == "text/html; charset=utf-8" 39 | 40 | def test_apidoc_with_custom_validator(self, app, client): 41 | app.config["SWAGGER_VALIDATOR_URL"] = "http://somewhere.com/validator" 42 | restx.Api(app, version="1.0") 43 | 44 | response = client.get(url_for("doc")) 45 | assert response.status_code == 200 46 | assert response.content_type == "text/html; charset=utf-8" 47 | assert 'validatorUrl: "http://somewhere.com/validator" || null,' in str( 48 | response.data 49 | ) 50 | 51 | def test_apidoc_doc_expansion_parameter(self, app, client): 52 | restx.Api(app) 53 | 54 | response = client.get(url_for("doc")) 55 | assert 'docExpansion: "none"' in str(response.data) 56 | 57 | app.config["SWAGGER_UI_DOC_EXPANSION"] = "list" 58 | response = client.get(url_for("doc")) 59 | assert 'docExpansion: "list"' in str(response.data) 60 | 61 | app.config["SWAGGER_UI_DOC_EXPANSION"] = "full" 62 | response = client.get(url_for("doc")) 63 | assert 'docExpansion: "full"' in str(response.data) 64 | 65 | def test_apidoc_doc_display_operation_id(self, app, client): 66 | restx.Api(app) 67 | 68 | response = client.get(url_for("doc")) 69 | assert "displayOperationId: false" in str(response.data) 70 | 71 | app.config["SWAGGER_UI_OPERATION_ID"] = False 72 | response = client.get(url_for("doc")) 73 | assert "displayOperationId: false" in str(response.data) 74 | 75 | app.config["SWAGGER_UI_OPERATION_ID"] = True 76 | response = client.get(url_for("doc")) 77 | assert "displayOperationId: true" in str(response.data) 78 | 79 | def test_apidoc_doc_display_request_duration(self, app, client): 80 | restx.Api(app) 81 | 82 | response = client.get(url_for("doc")) 83 | assert "displayRequestDuration: false" in str(response.data) 84 | 85 | app.config["SWAGGER_UI_REQUEST_DURATION"] = False 86 | response = client.get(url_for("doc")) 87 | assert "displayRequestDuration: false" in str(response.data) 88 | 89 | app.config["SWAGGER_UI_REQUEST_DURATION"] = True 90 | response = client.get(url_for("doc")) 91 | assert "displayRequestDuration: true" in str(response.data) 92 | 93 | def test_custom_apidoc_url(self, app, client): 94 | restx.Api(app, version="1.0", doc="/doc/") 95 | 96 | doc_url = url_for("doc") 97 | root_url = url_for("root") 98 | 99 | assert doc_url != root_url 100 | 101 | response = client.get(root_url) 102 | assert response.status_code == 404 103 | 104 | assert doc_url == "/doc/" 105 | response = client.get(doc_url) 106 | assert response.status_code == 200 107 | assert response.content_type == "text/html; charset=utf-8" 108 | 109 | def test_custom_api_prefix(self, app, client): 110 | prefix = "/api" 111 | api = restx.Api(app, prefix=prefix) 112 | api.namespace("resource") 113 | assert url_for("root") == prefix 114 | 115 | def test_custom_apidoc_page(self, app, client): 116 | api = restx.Api(app, version="1.0") 117 | content = "My Custom API Doc" 118 | 119 | @api.documentation 120 | def api_doc(): 121 | return content 122 | 123 | response = client.get(url_for("doc")) 124 | assert response.status_code == 200 125 | assert response.data.decode("utf8") == content 126 | 127 | def test_custom_apidoc_page_lazy(self, app, client): 128 | blueprint = Blueprint("api", __name__, url_prefix="/api") 129 | api = restx.Api(blueprint, version="1.0") 130 | content = "My Custom API Doc" 131 | 132 | @api.documentation 133 | def api_doc(): 134 | return content 135 | 136 | app.register_blueprint(blueprint) 137 | 138 | response = client.get(url_for("api.doc")) 139 | assert response.status_code == 200 140 | assert response.data.decode("utf8") == content 141 | 142 | def test_disabled_apidoc(self, app, client): 143 | restx.Api(app, version="1.0", doc=False) 144 | 145 | with pytest.raises(BuildError): 146 | url_for("doc") 147 | 148 | response = client.get(url_for("root")) 149 | assert response.status_code == 404 150 | -------------------------------------------------------------------------------- /tests/test_cors.py: -------------------------------------------------------------------------------- 1 | from flask_restx import Api, Resource, cors 2 | 3 | 4 | class ErrorsTest(object): 5 | def test_crossdomain(self, app, client): 6 | class Foo(Resource): 7 | @cors.crossdomain(origin="*") 8 | def get(self): 9 | return "data" 10 | 11 | api = Api(app) 12 | api.add_resource(Foo, "/test/") 13 | 14 | res = client.get("/test/") 15 | assert res.status_code == 200 16 | assert res.headers["Access-Control-Allow-Origin"] == "*" 17 | assert res.headers["Access-Control-Max-Age"] == "21600" 18 | assert "HEAD" in res.headers["Access-Control-Allow-Methods"] 19 | assert "OPTIONS" in res.headers["Access-Control-Allow-Methods"] 20 | assert "GET" in res.headers["Access-Control-Allow-Methods"] 21 | 22 | def test_access_control_expose_headers(self, app, client): 23 | class Foo(Resource): 24 | @cors.crossdomain( 25 | origin="*", expose_headers=["X-My-Header", "X-Another-Header"] 26 | ) 27 | def get(self): 28 | return "data" 29 | 30 | api = Api(app) 31 | api.add_resource(Foo, "/test/") 32 | 33 | res = client.get("/test/") 34 | assert res.status_code == 200 35 | assert "X-MY-HEADER" in res.headers["Access-Control-Expose-Headers"] 36 | assert "X-ANOTHER-HEADER" in res.headers["Access-Control-Expose-Headers"] 37 | 38 | def test_no_crossdomain(self, app, client): 39 | class Foo(Resource): 40 | def get(self): 41 | return "data" 42 | 43 | api = Api(app) 44 | api.add_resource(Foo, "/test/") 45 | 46 | res = client.get("/test/") 47 | assert res.status_code == 200 48 | assert "Access-Control-Allow-Origin" not in res.headers 49 | assert "Access-Control-Allow-Methods" not in res.headers 50 | assert "Access-Control-Max-Age" not in res.headers 51 | -------------------------------------------------------------------------------- /tests/test_logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import flask_restx as restx 4 | 5 | 6 | class LoggingTest(object): 7 | def test_namespace_loggers_log_to_flask_app_logger(self, app, client, caplog): 8 | # capture Flask app logs 9 | caplog.set_level(logging.INFO, logger=app.logger.name) 10 | 11 | api = restx.Api(app) 12 | ns1 = api.namespace("ns1", path="/ns1") 13 | ns2 = api.namespace("ns2", path="/ns2") 14 | 15 | @ns1.route("/") 16 | class Ns1(restx.Resource): 17 | def get(self): 18 | ns1.logger.info("hello from ns1") 19 | pass 20 | 21 | @ns2.route("/") 22 | class Ns2(restx.Resource): 23 | def get(self): 24 | ns2.logger.info("hello from ns2") 25 | pass 26 | 27 | # debug log not shown 28 | client.get("/ns1/") 29 | matching = [r for r in caplog.records if r.message == "hello from ns1"] 30 | assert len(matching) == 1 31 | 32 | # info log shown 33 | client.get("/ns2/") 34 | matching = [r for r in caplog.records if r.message == "hello from ns2"] 35 | assert len(matching) == 1 36 | 37 | def test_defaults_to_app_level(self, app, client, caplog): 38 | caplog.set_level(logging.INFO, logger=app.logger.name) 39 | 40 | api = restx.Api(app) 41 | ns1 = api.namespace("ns1", path="/ns1") 42 | ns2 = api.namespace("ns2", path="/ns2") 43 | 44 | @ns1.route("/") 45 | class Ns1(restx.Resource): 46 | def get(self): 47 | ns1.logger.debug("hello from ns1") 48 | pass 49 | 50 | @ns2.route("/") 51 | class Ns2(restx.Resource): 52 | def get(self): 53 | ns2.logger.info("hello from ns2") 54 | pass 55 | 56 | # debug log not shown 57 | client.get("/ns1/") 58 | matching = [r for r in caplog.records if r.message == "hello from ns1"] 59 | assert len(matching) == 0 60 | 61 | # info log shown 62 | client.get("/ns2/") 63 | matching = [r for r in caplog.records if r.message == "hello from ns2"] 64 | assert len(matching) == 1 65 | 66 | def test_override_app_level(self, app, client, caplog): 67 | caplog.set_level(logging.DEBUG, logger=app.logger.name) 68 | 69 | api = restx.Api(app) 70 | ns1 = api.namespace("ns1", path="/ns1") 71 | ns1.logger.setLevel(logging.DEBUG) 72 | ns2 = api.namespace("ns2", path="/ns2") 73 | ns2.logger.setLevel(logging.INFO) 74 | 75 | @ns1.route("/") 76 | class Ns1(restx.Resource): 77 | def get(self): 78 | ns1.logger.debug("hello from ns1") 79 | pass 80 | 81 | @ns2.route("/") 82 | class Ns2(restx.Resource): 83 | def get(self): 84 | ns2.logger.debug("hello from ns2") 85 | pass 86 | 87 | # debug log shown from ns1 88 | client.get("/ns1/") 89 | matching = [r for r in caplog.records if r.message == "hello from ns1"] 90 | assert len(matching) == 1 91 | 92 | # debug not shown from ns2 93 | client.get("/ns2/") 94 | matching = [r for r in caplog.records if r.message == "hello from ns2"] 95 | assert len(matching) == 0 96 | 97 | def test_namespace_additional_handler(self, app, client, caplog, tmp_path): 98 | caplog.set_level(logging.INFO, logger=app.logger.name) 99 | log_file = tmp_path / "v1.log" 100 | 101 | api = restx.Api(app) 102 | ns1 = api.namespace("ns1", path="/ns1") 103 | # set up a file handler for ns1 only 104 | # FileHandler only supports Path object on Python >= 3.6 -> cast to str 105 | fh = logging.FileHandler(str(log_file)) 106 | ns1.logger.addHandler(fh) 107 | 108 | ns2 = api.namespace("ns2", path="/ns2") 109 | 110 | @ns1.route("/") 111 | class Ns1(restx.Resource): 112 | def get(self): 113 | ns1.logger.info("hello from ns1") 114 | pass 115 | 116 | @ns2.route("/") 117 | class Ns2(restx.Resource): 118 | def get(self): 119 | ns2.logger.info("hello from ns2") 120 | pass 121 | 122 | # ns1 logs go to stdout and log_file 123 | client.get("/ns1/") 124 | matching = [r for r in caplog.records if r.message == "hello from ns1"] 125 | assert len(matching) == 1 126 | with log_file.open() as f: 127 | assert "hello from ns1" in f.read() 128 | 129 | # ns2 logs go to stdout only 130 | client.get("/ns2/") 131 | matching = [r for r in caplog.records if r.message == "hello from ns2"] 132 | assert len(matching) == 1 133 | with log_file.open() as f: 134 | assert "hello from ns1" in f.read() 135 | -------------------------------------------------------------------------------- /tests/test_namespace.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | 4 | import flask_restx as restx 5 | 6 | from flask_restx import Namespace, Model, OrderedModel 7 | 8 | 9 | class NamespaceTest(object): 10 | def test_parser(self): 11 | api = Namespace("test") 12 | assert isinstance(api.parser(), restx.reqparse.RequestParser) 13 | 14 | def test_doc_decorator(self): 15 | api = Namespace("test") 16 | params = {"q": {"description": "some description"}} 17 | 18 | @api.doc(params=params) 19 | class TestResource(restx.Resource): 20 | pass 21 | 22 | assert hasattr(TestResource, "__apidoc__") 23 | assert TestResource.__apidoc__ == {"params": params} 24 | 25 | def test_doc_with_inheritance(self): 26 | api = Namespace("test") 27 | base_params = { 28 | "q": { 29 | "description": "some description", 30 | "type": "string", 31 | "paramType": "query", 32 | } 33 | } 34 | child_params = { 35 | "q": {"description": "some new description"}, 36 | "other": {"description": "another param"}, 37 | } 38 | 39 | @api.doc(params=base_params) 40 | class BaseResource(restx.Resource): 41 | pass 42 | 43 | @api.doc(params=child_params) 44 | class TestResource(BaseResource): 45 | pass 46 | 47 | assert TestResource.__apidoc__ == { 48 | "params": { 49 | "q": { 50 | "description": "some new description", 51 | "type": "string", 52 | "paramType": "query", 53 | }, 54 | "other": {"description": "another param"}, 55 | } 56 | } 57 | 58 | def test_model(self): 59 | api = Namespace("test") 60 | api.model("Person", {}) 61 | assert "Person" in api.models 62 | assert isinstance(api.models["Person"], Model) 63 | 64 | def test_ordered_model(self): 65 | api = Namespace("test", ordered=True) 66 | api.model("Person", {}) 67 | assert "Person" in api.models 68 | assert isinstance(api.models["Person"], OrderedModel) 69 | 70 | def test_schema_model(self): 71 | api = Namespace("test") 72 | api.schema_model("Person", {}) 73 | assert "Person" in api.models 74 | 75 | def test_clone(self): 76 | api = Namespace("test") 77 | parent = api.model("Parent", {}) 78 | api.clone("Child", parent, {}) 79 | 80 | assert "Child" in api.models 81 | assert "Parent" in api.models 82 | 83 | def test_clone_with_multiple_parents(self): 84 | api = Namespace("test") 85 | grand_parent = api.model("GrandParent", {}) 86 | parent = api.model("Parent", {}) 87 | api.clone("Child", grand_parent, parent, {}) 88 | 89 | assert "Child" in api.models 90 | assert "Parent" in api.models 91 | assert "GrandParent" in api.models 92 | 93 | def test_inherit(self): 94 | authorizations = { 95 | "apikey": {"type": "apiKey", "in": "header", "name": "X-API-KEY"} 96 | } 97 | api = Namespace("test", authorizations=authorizations) 98 | parent = api.model("Parent", {}) 99 | api.inherit("Child", parent, {}) 100 | 101 | assert "Parent" in api.models 102 | assert "Child" in api.models 103 | assert api.authorizations == authorizations 104 | 105 | def test_inherit_from_multiple_parents(self): 106 | api = Namespace("test") 107 | grand_parent = api.model("GrandParent", {}) 108 | parent = api.model("Parent", {}) 109 | api.inherit("Child", grand_parent, parent, {}) 110 | 111 | assert "GrandParent" in api.models 112 | assert "Parent" in api.models 113 | assert "Child" in api.models 114 | 115 | def test_api_payload(self, app, client): 116 | api = restx.Api(app, validate=True) 117 | ns = restx.Namespace("apples") 118 | api.add_namespace(ns) 119 | 120 | fields = ns.model( 121 | "Person", 122 | { 123 | "name": restx.fields.String(required=True), 124 | "age": restx.fields.Integer, 125 | "birthdate": restx.fields.DateTime, 126 | }, 127 | ) 128 | 129 | @ns.route("/validation/") 130 | class Payload(restx.Resource): 131 | payload = None 132 | 133 | @ns.expect(fields) 134 | def post(self): 135 | Payload.payload = ns.payload 136 | return {} 137 | 138 | data = { 139 | "name": "John Doe", 140 | "age": 15, 141 | } 142 | 143 | client.post_json("/apples/validation/", data) 144 | 145 | assert Payload.payload == data 146 | 147 | def test_api_payload_strict_verification(self, app, client): 148 | api = restx.Api(app, validate=True) 149 | ns = restx.Namespace("apples") 150 | api.add_namespace(ns) 151 | 152 | fields = ns.model( 153 | "Person", 154 | { 155 | "name": restx.fields.String(required=True), 156 | "age": restx.fields.Integer, 157 | "birthdate": restx.fields.DateTime, 158 | }, 159 | strict=True, 160 | ) 161 | 162 | @ns.route("/validation/") 163 | class Payload(restx.Resource): 164 | payload = None 165 | 166 | @ns.expect(fields) 167 | def post(self): 168 | Payload.payload = ns.payload 169 | return {} 170 | 171 | data = { 172 | "name": "John Doe", 173 | "agge": 15, # typo 174 | } 175 | 176 | resp = client.post_json("/apples/validation/", data, status=400) 177 | assert re.match( 178 | "Additional properties are not allowed \(u*'agge' was unexpected\)", 179 | resp["errors"][""], 180 | ) 181 | -------------------------------------------------------------------------------- /tests/test_schemas.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jsonschema import ValidationError 4 | 5 | from flask_restx import errors, schemas 6 | 7 | 8 | class SchemasTest: 9 | def test_lazyness(self): 10 | schema = schemas.LazySchema("oas-2.0.json") 11 | assert schema._schema is None 12 | 13 | "" in schema # Trigger load 14 | assert schema._schema is not None 15 | assert isinstance(schema._schema, dict) 16 | 17 | def test_oas2_schema_is_present(self): 18 | assert hasattr(schemas, "OAS_20") 19 | assert isinstance(schemas.OAS_20, schemas.LazySchema) 20 | 21 | 22 | class ValidationTest: 23 | def test_oas_20_valid(self): 24 | assert schemas.validate( 25 | { 26 | "swagger": "2.0", 27 | "info": { 28 | "title": "An empty minimal specification", 29 | "version": "1.0", 30 | }, 31 | "paths": {}, 32 | } 33 | ) 34 | 35 | def test_oas_20_invalid(self): 36 | with pytest.raises(schemas.SchemaValidationError) as excinfo: 37 | schemas.validate( 38 | { 39 | "swagger": "2.0", 40 | "should": "not be here", 41 | } 42 | ) 43 | for error in excinfo.value.errors: 44 | assert isinstance(error, ValidationError) 45 | 46 | def test_unknown_schema(self): 47 | with pytest.raises(errors.SpecsError): 48 | schemas.validate({"valid": "no"}) 49 | 50 | def test_unknown_version(self): 51 | with pytest.raises(errors.SpecsError): 52 | schemas.validate({"swagger": "42.0"}) 53 | -------------------------------------------------------------------------------- /tests/test_swagger_utils.py: -------------------------------------------------------------------------------- 1 | from flask_restx.swagger import extract_path, extract_path_params, parse_docstring 2 | 3 | 4 | class ExtractPathTest(object): 5 | def test_extract_static_path(self): 6 | path = "/test" 7 | assert extract_path(path) == "/test" 8 | 9 | def test_extract_path_with_a_single_simple_parameter(self): 10 | path = "/test/" 11 | assert extract_path(path) == "/test/{parameter}" 12 | 13 | def test_extract_path_with_a_single_typed_parameter(self): 14 | path = "/test/" 15 | assert extract_path(path) == "/test/{parameter}" 16 | 17 | def test_extract_path_with_a_single_typed_parameter_with_arguments(self): 18 | path = "/test/" 19 | assert extract_path(path) == "/test/{parameter}" 20 | 21 | def test_extract_path_with_multiple_parameters(self): 22 | path = "/test///" 23 | assert extract_path(path) == "/test/{parameter}/{other}/" 24 | 25 | 26 | class ExtractPathParamsTestCase(object): 27 | def test_extract_static_path(self): 28 | path = "/test" 29 | assert extract_path_params(path) == {} 30 | 31 | def test_extract_single_simple_parameter(self): 32 | path = "/test/" 33 | assert extract_path_params(path) == { 34 | "parameter": { 35 | "name": "parameter", 36 | "type": "string", 37 | "in": "path", 38 | "required": True, 39 | } 40 | } 41 | 42 | def test_single_int_parameter(self): 43 | path = "/test/" 44 | assert extract_path_params(path) == { 45 | "parameter": { 46 | "name": "parameter", 47 | "type": "integer", 48 | "in": "path", 49 | "required": True, 50 | } 51 | } 52 | 53 | def test_single_float_parameter(self): 54 | path = "/test/" 55 | assert extract_path_params(path) == { 56 | "parameter": { 57 | "name": "parameter", 58 | "type": "number", 59 | "in": "path", 60 | "required": True, 61 | } 62 | } 63 | 64 | def test_extract_path_with_multiple_parameters(self): 65 | path = "/test///" 66 | assert extract_path_params(path) == { 67 | "parameter": { 68 | "name": "parameter", 69 | "type": "string", 70 | "in": "path", 71 | "required": True, 72 | }, 73 | "other": { 74 | "name": "other", 75 | "type": "integer", 76 | "in": "path", 77 | "required": True, 78 | }, 79 | } 80 | 81 | def test_extract_parameter_with_arguments(self): 82 | path = "/test/" 83 | assert extract_path_params(path) == { 84 | "parameter": { 85 | "name": "parameter", 86 | "type": "string", 87 | "in": "path", 88 | "required": True, 89 | } 90 | } 91 | 92 | # def test_extract_registered_converters(self): 93 | # class ListConverter(BaseConverter): 94 | # def to_python(self, value): 95 | # return value.split(',') 96 | 97 | # def to_url(self, values): 98 | # return ','.join(super(ListConverter, self).to_url(value) for value in values) 99 | 100 | # self.app.url_map.converters['list'] = ListConverter 101 | 102 | # path = '/test/' 103 | # with self.context(): 104 | # self.assertEqual(extract_path_params(path), [{ 105 | # 'name': 'parameters', 106 | # 'type': 'number', 107 | # 'in': 'path', 108 | # 'required': True 109 | # }]) 110 | 111 | 112 | class ParseDocstringTest(object): 113 | def test_empty(self): 114 | def without_doc(): 115 | pass 116 | 117 | parsed = parse_docstring(without_doc) 118 | 119 | assert parsed["raw"] is None 120 | assert parsed["summary"] is None 121 | assert parsed["details"] is None 122 | assert parsed["returns"] is None 123 | assert parsed["raises"] == {} 124 | assert parsed["params"] == [] 125 | 126 | def test_single_line(self): 127 | def func(): 128 | """Some summary""" 129 | pass 130 | 131 | parsed = parse_docstring(func) 132 | 133 | assert parsed["raw"] == "Some summary" 134 | assert parsed["summary"] == "Some summary" 135 | assert parsed["details"] is None 136 | assert parsed["returns"] is None 137 | assert parsed["raises"] == {} 138 | assert parsed["params"] == [] 139 | 140 | def test_multi_line(self): 141 | def func(): 142 | """ 143 | Some summary 144 | Some details 145 | """ 146 | pass 147 | 148 | parsed = parse_docstring(func) 149 | 150 | assert parsed["raw"] == "Some summary\nSome details" 151 | assert parsed["summary"] == "Some summary" 152 | assert parsed["details"] == "Some details" 153 | assert parsed["returns"] is None 154 | assert parsed["raises"] == {} 155 | assert parsed["params"] == [] 156 | 157 | def test_multi_line_and_dot(self): 158 | def func(): 159 | """ 160 | Some summary. bla bla 161 | Some details 162 | """ 163 | pass 164 | 165 | parsed = parse_docstring(func) 166 | 167 | assert parsed["raw"] == "Some summary. bla bla\nSome details" 168 | assert parsed["summary"] == "Some summary" 169 | assert parsed["details"] == "bla bla\nSome details" 170 | assert parsed["returns"] is None 171 | assert parsed["raises"] == {} 172 | assert parsed["params"] == [] 173 | 174 | def test_raises(self): 175 | def func(): 176 | """ 177 | Some summary. 178 | :raises SomeException: in case of something 179 | """ 180 | pass 181 | 182 | parsed = parse_docstring(func) 183 | 184 | assert ( 185 | parsed["raw"] 186 | == "Some summary.\n:raises SomeException: in case of something" 187 | ) 188 | assert parsed["summary"] == "Some summary" 189 | assert parsed["details"] is None 190 | assert parsed["returns"] is None 191 | assert parsed["params"] == [] 192 | assert parsed["raises"] == {"SomeException": "in case of something"} 193 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_restx import utils 4 | 5 | 6 | class MergeTestCase(object): 7 | def test_merge_simple_dicts_without_precedence(self): 8 | a = {"a": "value"} 9 | b = {"b": "other value"} 10 | assert utils.merge(a, b) == {"a": "value", "b": "other value"} 11 | 12 | def test_merge_simple_dicts_with_precedence(self): 13 | a = {"a": "value", "ab": "overwritten"} 14 | b = {"b": "other value", "ab": "keep"} 15 | assert utils.merge(a, b) == {"a": "value", "b": "other value", "ab": "keep"} 16 | 17 | def test_recursions(self): 18 | a = { 19 | "a": "value", 20 | "ab": "overwritten", 21 | "nested_a": {"a": "nested"}, 22 | "nested_a_b": {"a": "a only", "ab": "overwritten"}, 23 | } 24 | b = { 25 | "b": "other value", 26 | "ab": "keep", 27 | "nested_b": {"b": "nested"}, 28 | "nested_a_b": {"b": "b only", "ab": "keep"}, 29 | } 30 | assert utils.merge(a, b) == { 31 | "a": "value", 32 | "b": "other value", 33 | "ab": "keep", 34 | "nested_a": {"a": "nested"}, 35 | "nested_b": {"b": "nested"}, 36 | "nested_a_b": {"a": "a only", "b": "b only", "ab": "keep"}, 37 | } 38 | 39 | def test_recursions_with_empty(self): 40 | a = {} 41 | b = { 42 | "b": "other value", 43 | "ab": "keep", 44 | "nested_b": {"b": "nested"}, 45 | "nested_a_b": {"b": "b only", "ab": "keep"}, 46 | } 47 | assert utils.merge(a, b) == b 48 | 49 | 50 | class UnpackImportResponse(object): 51 | def test_import_werkzeug_response(self): 52 | assert utils.import_werkzeug_response() != None 53 | 54 | 55 | class CamelToDashTestCase(object): 56 | def test_no_transform(self): 57 | assert utils.camel_to_dash("test") == "test" 58 | 59 | @pytest.mark.parametrize( 60 | "value,expected", 61 | [ 62 | ("aValue", "a_value"), 63 | ("aLongValue", "a_long_value"), 64 | ("Upper", "upper"), 65 | ("UpperCase", "upper_case"), 66 | ], 67 | ) 68 | def test_transform(self, value, expected): 69 | assert utils.camel_to_dash(value) == expected 70 | 71 | 72 | class UnpackTest(object): 73 | def test_single_value(self): 74 | data, code, headers = utils.unpack("test") 75 | assert data == "test" 76 | assert code == 200 77 | assert headers == {} 78 | 79 | def test_single_value_with_default_code(self): 80 | data, code, headers = utils.unpack("test", 500) 81 | assert data == "test" 82 | assert code == 500 83 | assert headers == {} 84 | 85 | def test_value_code(self): 86 | data, code, headers = utils.unpack(("test", 201)) 87 | assert data == "test" 88 | assert code == 201 89 | assert headers == {} 90 | 91 | def test_value_code_headers(self): 92 | data, code, headers = utils.unpack(("test", 201, {"Header": "value"})) 93 | assert data == "test" 94 | assert code == 201 95 | assert headers == {"Header": "value"} 96 | 97 | def test_value_headers_default_code(self): 98 | data, code, headers = utils.unpack(("test", None, {"Header": "value"})) 99 | assert data == "test" 100 | assert code == 200 101 | assert headers == {"Header": "value"} 102 | 103 | def test_too_many_values(self): 104 | with pytest.raises(ValueError): 105 | utils.unpack((None, None, None, None)) 106 | 107 | 108 | class ToViewNameTest(object): 109 | def test_none(self): 110 | with pytest.raises(AssertionError): 111 | _ = utils.to_view_name(None) 112 | 113 | def test_name(self): 114 | assert utils.to_view_name(self.test_none) == self.test_none.__name__ 115 | 116 | 117 | class ImportCheckViewFuncTest(object): 118 | def test_callable(self): 119 | assert callable(utils.import_check_view_func()) 120 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = 8 | py{39, 310, 311}-flask2, 9 | py{311, 312}-flask3 10 | pypy3.9 11 | doc 12 | 13 | [testenv] 14 | commands = {posargs:inv test qa} 15 | deps = 16 | flask2: flask<3.0.0 17 | flask3: flask>=3.0.0 18 | -r{toxinidir}/requirements/test.pip 19 | -r{toxinidir}/requirements/develop.pip 20 | 21 | [testenv:doc] 22 | changedir = doc 23 | deps = .[doc] 24 | commands = sphinx-build -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 25 | --------------------------------------------------------------------------------