├── tests
├── __init__.py
├── examples
│ ├── __init__.py
│ └── test_todo.py
├── swagger_generation
│ ├── __init__.py
│ ├── registries
│ │ ├── __init__.py
│ │ └── exploded_query_string.py
│ ├── test_optional_converters.py
│ ├── test_swagger_generator_hidden_api.py
│ ├── test_generator_utils.py
│ └── test_swagger_generator.py
├── helpers.py
├── test_request_utils.py
└── test_deprecation_utils.py
├── examples
├── __init__.py
└── todo
│ ├── __init__.py
│ ├── todo
│ ├── __init__.py
│ ├── handlers
│ │ ├── __init__.py
│ │ └── todo_handlers.py
│ ├── database.py
│ ├── converters.py
│ ├── app.py
│ └── schemas.py
│ ├── wsgi.py
│ ├── generate_output.py
│ └── todo_output.md
├── flask_rebar
├── py.typed
├── utils
│ ├── __init__.py
│ ├── defaults.py
│ ├── marshmallow_objects_helpers.py
│ ├── deprecation.py
│ └── request_utils.py
├── swagger_ui
│ ├── __init__.py
│ ├── static
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── index.css
│ │ ├── swagger-initializer.js
│ │ ├── index.html
│ │ └── oauth2-redirect.html
│ ├── blueprint.py
│ └── templates
│ │ └── index.html.jinja2
├── authenticators
│ ├── __init__.py
│ ├── base.py
│ └── header_api_key.py
├── request_utils.py
├── swagger_generation
│ ├── swagger_generator
│ │ └── __init__.py
│ ├── __init__.py
│ ├── swagger_words.py
│ ├── swagger_objects.py
│ ├── swagger_generator_base.py
│ └── authenticator_to_swagger.py
├── testing
│ └── __init__.py
├── __init__.py
├── compat.py
├── messages.py
├── errors.py
└── validation.py
├── setup.cfg
├── docs
├── changelog.rst
├── contributing.rst
├── quickstart
│ ├── installation.rst
│ ├── api_versioning.rst
│ └── authentication.rst
├── tutorials.rst
├── Makefile
├── make.bat
├── why.rst
├── version_history.rst
├── index.rst
├── meeting_notes
│ └── roadmap_2020Jan29.rst
├── api_reference.rst
├── conf.py
└── recipes.rst
├── CODEOWNERS
├── MANIFEST.in
├── .bumpversion.cfg
├── .flake8
├── AUTHORS
├── .pre-commit-config.yaml
├── .readthedocs.yml
├── pyproject.toml
├── .github
├── workflows
│ ├── tag.yml
│ └── pullrequests.yml
└── stale.yml
├── .gitignore
├── LICENSE
├── setup.py
├── .gitchangelog.rc
├── SECURITY.md
├── CODEOFCONDUCT.md
├── README.rst
└── CONTRIBUTING.rst
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/flask_rebar/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/todo/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/examples/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/todo/todo/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/swagger_generation/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/todo/todo/handlers/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [bdist_wheel]
2 | universal = 1
3 |
--------------------------------------------------------------------------------
/tests/swagger_generation/registries/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../CHANGELOG.rst
2 |
--------------------------------------------------------------------------------
/docs/contributing.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../CONTRIBUTING.rst
2 |
--------------------------------------------------------------------------------
/flask_rebar/utils/__init__.py:
--------------------------------------------------------------------------------
1 | class USE_DEFAULT:
2 | pass
3 |
--------------------------------------------------------------------------------
/flask_rebar/utils/defaults.py:
--------------------------------------------------------------------------------
1 | class USE_DEFAULT:
2 | pass
3 |
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # review_type: MultiReviewer/0
2 | * @plangrid/api-platform
3 |
--------------------------------------------------------------------------------
/examples/todo/wsgi.py:
--------------------------------------------------------------------------------
1 | from todo.app_init import create_app
2 |
3 | app = create_app()
4 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_ui/__init__.py:
--------------------------------------------------------------------------------
1 | from flask_rebar.swagger_ui.blueprint import create_swagger_ui_blueprint
2 |
--------------------------------------------------------------------------------
/examples/todo/todo/database.py:
--------------------------------------------------------------------------------
1 | # Just a mock database, for demonstration purposes
2 | todo_id_sequence = 0
3 | todo_database = {}
4 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_ui/static/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/plangrid/flask-rebar/HEAD/flask_rebar/swagger_ui/static/favicon-16x16.png
--------------------------------------------------------------------------------
/flask_rebar/swagger_ui/static/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/plangrid/flask-rebar/HEAD/flask_rebar/swagger_ui/static/favicon-32x32.png
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | recursive-include flask_rebar/swagger_ui/static *.png *.html *.css *.js *.map
2 | recursive-include flask_rebar/swagger_ui/templates *.jinja2
3 |
--------------------------------------------------------------------------------
/.bumpversion.cfg:
--------------------------------------------------------------------------------
1 | [bumpversion]
2 | current_version = 3.3.2
3 | commit = True
4 | tag = True
5 |
6 | [bumpversion:file:setup.py]
7 | search = version="{current_version}",
8 | replace = version="{new_version}",
9 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 120
3 | exclude = .git/*, build/*, examples/*, venv/*, setup.py
4 | per-file-ignores =
5 | flask_rebar/__init__.py: F401 flask_rebar/*/__init__.py: F401
6 | extend-ignore = E501
7 |
--------------------------------------------------------------------------------
/AUTHORS:
--------------------------------------------------------------------------------
1 | Flask-Rebar is developed and maintained by the PlanGrid team and community
2 | contributors. It was created by Barak Alon.
3 |
4 | A full list of contributors is available from git with::
5 |
6 | git shortlog -sne
7 |
--------------------------------------------------------------------------------
/flask_rebar/authenticators/__init__.py:
--------------------------------------------------------------------------------
1 | from flask_rebar.authenticators.base import Authenticator
2 | from flask_rebar.utils.defaults import USE_DEFAULT
3 | from flask_rebar.authenticators.header_api_key import HeaderApiKeyAuthenticator
4 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_ui/static/index.css:
--------------------------------------------------------------------------------
1 | html {
2 | box-sizing: border-box;
3 | overflow: -moz-scrollbars-vertical;
4 | overflow-y: scroll;
5 | }
6 |
7 | *,
8 | *:before,
9 | *:after {
10 | box-sizing: inherit;
11 | }
12 |
13 | body {
14 | margin: 0;
15 | background: #fafafa;
16 | }
17 |
--------------------------------------------------------------------------------
/tests/helpers.py:
--------------------------------------------------------------------------------
1 | from flask import json
2 | from werkzeug.utils import cached_property
3 |
4 |
5 | class JsonResponseMixin:
6 | """
7 | Mixin with testing helper methods
8 | """
9 |
10 | @cached_property
11 | def json(self):
12 | return json.loads(self.data)
13 |
14 |
15 | def make_test_response(response_class):
16 | return type("TestResponse", (response_class, JsonResponseMixin), {})
17 |
--------------------------------------------------------------------------------
/docs/quickstart/installation.rst:
--------------------------------------------------------------------------------
1 | Installation
2 | ------------
3 |
4 | Flask-Rebar can be installed with ``pip``:
5 |
6 | .. code-block:: bash
7 |
8 | pip install flask-rebar
9 |
10 | Flask-Rebar depends on Flask and Marshmallow. Flask has `fantastic documentation `_ on setting up a development environment for Python, so if you're new to this sort of thing, check that out.
11 |
--------------------------------------------------------------------------------
/flask_rebar/request_utils.py:
--------------------------------------------------------------------------------
1 | from flask_rebar.utils.request_utils import * # noqa
2 |
3 | """
4 | This file is here as a temporary import-only stub/shim in order to not break any existing imports
5 | of the following form, e.g.,:
6 | from flask_rebar.request_utils import marshal
7 |
8 | When we release 2.0 and can make breaking changes, this stub should be removed as we are
9 | consolidating "utility" modules such as request_utils.py into flask_rebar.utils
10 | """
11 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_generation/swagger_generator/__init__.py:
--------------------------------------------------------------------------------
1 | # hack to reintroduce backward-compatibility as SwaggerV2Generator used to live in swagger_generator.py
2 |
3 | from flask_rebar.swagger_generation.swagger_generator_base import SwaggerGenerator
4 | from flask_rebar.swagger_generation.swagger_generator_v2 import (
5 | SwaggerV2Generator,
6 | ) # noqa
7 | from flask_rebar.swagger_generation.swagger_generator_v3 import (
8 | SwaggerV3Generator,
9 | ) # noqa
10 |
--------------------------------------------------------------------------------
/docs/tutorials.rst:
--------------------------------------------------------------------------------
1 | Tutorials
2 | =========
3 |
4 | Our amazing community has some fantastic write ups on using Flask-Rebar.
5 | If you're new to Rebar and looking for some more concrete examples of how to use it check out some of these tutorials
6 |
7 | Creating a Production Ready Python REST Backend with Flask-Rebar
8 | -------------------------------------------------------------------
9 | - `Part 1 `_
10 | - `Part 2 `_
11 |
12 |
13 | .. note::
14 | If you have a Flask-Rebar tutorial consider opening a PR to add it!
15 |
--------------------------------------------------------------------------------
/flask_rebar/testing/__init__.py:
--------------------------------------------------------------------------------
1 | import jsonschema
2 | from typing import Any, Dict
3 |
4 | from flask_rebar.testing.swagger_jsonschema import SWAGGER_V2_JSONSCHEMA
5 |
6 |
7 | def validate_swagger(
8 | swagger: Dict[str, Any], schema: Dict[str, Any] = SWAGGER_V2_JSONSCHEMA
9 | ) -> None:
10 | """
11 | Validates that a dictionary is a valid Swagger spec.
12 |
13 | :param dict swagger: The swagger spec
14 | :param dict schema: The JSON Schema to use to validate the swagger spec
15 | :raises: jsonschema.ValidationError
16 | """
17 | jsonschema.validate(instance=swagger, schema=schema)
18 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | # NOTE: it is not recommended to use mutable revs e.g. "stable" - run pre-commit autoupdate instead
3 | # ref: https://github.com/ambv/black/issues/420
4 | - repo: https://github.com/ambv/black
5 | rev: 23.7.0
6 | hooks:
7 | - id: black
8 | - repo: https://github.com/pycqa/flake8
9 | rev: 6.0.0
10 | hooks:
11 | - id: flake8
12 | - repo: https://github.com/pre-commit/mirrors-mypy
13 | rev: v1.8.0
14 | hooks:
15 | - id: mypy
16 | - repo: https://github.com/Lucas-C/pre-commit-hooks-markup
17 | rev: v1.0.1
18 | hooks:
19 | - id: rst-linter
20 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_generation/__init__.py:
--------------------------------------------------------------------------------
1 | from flask_rebar.swagger_generation.swagger_objects import ExternalDocumentation
2 | from flask_rebar.swagger_generation.swagger_generator_v2 import SwaggerV2Generator
3 | from flask_rebar.swagger_generation.swagger_generator_v3 import SwaggerV3Generator
4 | from flask_rebar.swagger_generation.swagger_objects import Tag
5 | from flask_rebar.swagger_generation.swagger_objects import Server
6 | from flask_rebar.swagger_generation.swagger_objects import ServerVariable
7 | from flask_rebar.swagger_generation.marshmallow_to_swagger import sets_swagger_attr
8 | from flask_rebar.swagger_generation.marshmallow_to_swagger import ConverterRegistry
9 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_ui/static/swagger-initializer.js:
--------------------------------------------------------------------------------
1 | window.onload = function() {
2 | //
3 |
4 | // the following lines will be replaced by docker/configurator, when it runs in a docker-container
5 | window.ui = SwaggerUIBundle({
6 | url: "https://petstore.swagger.io/v2/swagger.json",
7 | dom_id: '#swagger-ui',
8 | deepLinking: true,
9 | presets: [
10 | SwaggerUIBundle.presets.apis,
11 | SwaggerUIStandalonePreset
12 | ],
13 | plugins: [
14 | SwaggerUIBundle.plugins.DownloadUrl
15 | ],
16 | layout: "StandaloneLayout"
17 | });
18 |
19 | //
20 | };
21 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | SPHINXPROJ = Flask-Rebar
8 | SOURCEDIR = .
9 | BUILDDIR = _build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
--------------------------------------------------------------------------------
/.readthedocs.yml:
--------------------------------------------------------------------------------
1 | # .readthedocs.yml
2 | # Read the Docs configuration file
3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
4 |
5 | # Required
6 | version: 2
7 |
8 | # Build documentation in the docs/ directory with Sphinx
9 | sphinx:
10 | configuration: docs/conf.py
11 |
12 | # Build documentation with MkDocs
13 | #mkdocs:
14 | # configuration: mkdocs.yml
15 |
16 | # Optionally build your docs in additional formats such as PDF and ePub
17 | formats: all
18 |
19 | # Optionally set the version of Python and requirements required to build your docs
20 | python:
21 | version: 3.11
22 | install:
23 | - method: pip
24 | path: .
25 | extra_requirements:
26 | - dev
27 |
28 |
--------------------------------------------------------------------------------
/flask_rebar/__init__.py:
--------------------------------------------------------------------------------
1 | from flask_rebar.utils.request_utils import marshal, response
2 |
3 | from flask_rebar.rebar import (
4 | Rebar,
5 | HandlerRegistry,
6 | get_validated_args,
7 | get_validated_body,
8 | get_validated_headers,
9 | )
10 |
11 | from flask_rebar.authenticators import HeaderApiKeyAuthenticator
12 |
13 | from flask_rebar.validation import ResponseSchema, RequestSchema
14 |
15 | from flask_rebar.swagger_generation.swagger_generator_v2 import SwaggerV2Generator
16 | from flask_rebar.swagger_generation.swagger_generator_v3 import SwaggerV3Generator
17 | from flask_rebar.swagger_generation.swagger_objects import (
18 | ExternalDocumentation,
19 | Tag,
20 | Server,
21 | ServerVariable,
22 | )
23 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.black]
2 | exclude = '''
3 | /(
4 | \.pytest_cache
5 | | \.git
6 | | \.venv
7 | )/
8 | '''
9 |
10 | [tool.mypy]
11 | exclude = ['build', 'docs', 'examples']
12 | disallow_untyped_defs = true
13 | check_untyped_defs = true
14 |
15 | [[tool.mypy.overrides]]
16 | module = [
17 | "marshmallow_enum",
18 | "marshmallow_objects",
19 | "parametrize"
20 | ]
21 | ignore_missing_imports = true
22 |
23 | [[tool.mypy.overrides]]
24 | module = ["tests.*"]
25 | disallow_untyped_defs = false
26 | check_untyped_defs = false
27 |
28 | [tool.pytest.ini_options]
29 | filterwarnings = [
30 | "error"
31 | ]
32 |
33 | [build-system]
34 | requires = [
35 | "setuptools >= 35.0.2",
36 | ]
37 | build-backend = "setuptools.build_meta"
38 |
--------------------------------------------------------------------------------
/examples/todo/todo/converters.py:
--------------------------------------------------------------------------------
1 | import enum
2 |
3 | from flask_rebar.swagger_generation import swagger_words as sw
4 | from werkzeug.routing import BaseConverter
5 | from werkzeug.routing import ValidationError
6 |
7 |
8 | class TodoType(str, enum.Enum):
9 | user = "user"
10 | group = "group"
11 |
12 |
13 | class TodoTypeConverter(BaseConverter):
14 | def to_python(self, value):
15 | try:
16 | return TodoType(value)
17 | except ValueError:
18 | raise ValidationError()
19 |
20 | def to_url(self, obj):
21 | return obj.value
22 |
23 | @staticmethod
24 | def to_swagger():
25 | return {
26 | sw.type_: sw.string,
27 | sw.enum: [t.value for t in TodoType],
28 | }
29 |
--------------------------------------------------------------------------------
/.github/workflows/tag.yml:
--------------------------------------------------------------------------------
1 | name: flask-rebar Release Publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*"
7 |
8 | jobs:
9 | build-n-publish:
10 | name: Build and publish Python 🐍 distributions 📦 to PyPI
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@master
14 | - name: Set up Python 3.11
15 | uses: actions/setup-python@v1
16 | with:
17 | python-version: 3.11
18 | - name: Install pep517
19 | run: |
20 | python -m pip install pep517 --user
21 | - name: Build a binary wheel and a source tarball
22 | run: |
23 | python -m pep517.build .
24 | - name: Publish distribution 📦 to PyPI
25 | uses: pypa/gh-action-pypi-publish@release/v1
26 | with:
27 | password: ${{ secrets.pypi_password }}
28 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_ui/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Swagger UI
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/flask_rebar/authenticators/base.py:
--------------------------------------------------------------------------------
1 | """
2 | Base Authenticator
3 | ~~~~~~~~~~~~~~~~~~
4 |
5 | Base class for authenticators.
6 |
7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 |
11 |
12 | class Authenticator:
13 | """
14 | Abstract authenticator class. Custom authentication methods should
15 | extend this class.
16 | """
17 |
18 | def authenticate(self) -> None:
19 | """
20 | Implementations of :class:`Authenticator` should override this method.
21 |
22 | This will be called before a request handler is called, and should raise
23 | an :class:`flask_rebar.errors.HttpJsonError` is authentication fails.
24 |
25 | Otherwise the return value is ignored.
26 |
27 | :raises: :class:`flask_rebar.errors.Unauthorized`
28 | """
29 | raise NotImplementedError
30 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=.
11 | set BUILDDIR=_build
12 | set SPHINXPROJ=Flask-Rebar
13 |
14 | if "%1" == "" goto help
15 |
16 | %SPHINXBUILD% >NUL 2>NUL
17 | if errorlevel 9009 (
18 | echo.
19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
20 | echo.installed, then set the SPHINXBUILD environment variable to point
21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
22 | echo.may add the Sphinx directory to PATH.
23 | echo.
24 | echo.If you don't have Sphinx installed, grab it from
25 | echo.http://sphinx-doc.org/
26 | exit /b 1
27 | )
28 |
29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
30 | goto end
31 |
32 | :help
33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
34 |
35 | :end
36 | popd
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.py[cod]
2 |
3 | # C extensions
4 | *.so
5 |
6 | *.zip
7 |
8 | # Packages
9 | *.egg
10 | *.egg-info
11 | .eggs
12 | dist
13 | build
14 | eggs
15 | parts
16 | bin
17 | var
18 | sdist
19 | develop-eggs
20 | .installed.cfg
21 | lib
22 | lib64
23 |
24 | # Installer logs
25 | pip-log.txt
26 |
27 | # Unit test / coverage reports
28 | .coverage
29 | .tox
30 | nosetests.xml
31 |
32 | # Translations
33 | *.mo
34 |
35 | # Mr Developer
36 | .mr.developer.cfg
37 | .project
38 | .pydevproject
39 |
40 | # Complexity
41 | output/*.html
42 | output/*/index.html
43 |
44 | # Sphinx
45 | docs/_build
46 |
47 | .DS_Store
48 |
49 |
50 | # pycharm
51 | .idea
52 |
53 | # pip?
54 | src
55 |
56 | .coverage
57 |
58 | #pyenv
59 | .python-version
60 |
61 | # virtualenv
62 | .venv
63 | venv
64 | .Python
65 | include/
66 | man/
67 | # merge artifacts
68 | *.orig
69 |
70 | #emacs
71 | \#*
72 | \.#*
73 |
74 | #vim
75 | *.swp
76 |
77 | # pytest
78 | .pytest_cache
79 |
80 | #vscode
81 | .vscode/
82 | pip-wheel-metadata
83 |
84 | #mypy
85 | .mypy_cache
86 |
--------------------------------------------------------------------------------
/.github/stale.yml:
--------------------------------------------------------------------------------
1 | # Number of days of inactivity before an issue becomes stale
2 | daysUntilStale: 30
3 | # Number of days of inactivity before a stale issue is closed
4 | daysUntilClose: 14
5 | # Issues with these labels will never be considered stale
6 | #exemptLabels:
7 | # - pinned
8 | # - security
9 | # Label to use when marking an issue as stale
10 | staleLabel: Stale
11 | # Comment to post when closing a stale issue. Set to `false` to disable
12 | closeComment: false
13 |
14 | pulls:
15 | # Comment to post when marking an pull request as stale. Set to `false` to disable
16 | markComment: >
17 | This PR has been automatically marked as stale because it has not had
18 | recent activity. It will be closed if no further activity occurs. Thank you
19 | for your contributions.
20 | issues:
21 | # Comment to post when marking an issue as stale. Set to `false` to disable
22 | markComment: >
23 | This issue has been automatically marked as stale because it has not had
24 | recent activity. It will be closed if no further activity occurs. Thank you
25 | for your contributions.
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018-present PlanGrid
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/flask_rebar/utils/marshmallow_objects_helpers.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Optional, Type
2 | from marshmallow import Schema
3 |
4 | try:
5 | import marshmallow_objects as mo
6 |
7 | MARSHMALLOW_OBJECTS = True
8 | except ImportError:
9 | MARSHMALLOW_OBJECTS = False
10 |
11 |
12 | def get_marshmallow_objects_schema(model: Any) -> Optional[Type[Schema]]:
13 | if MARSHMALLOW_OBJECTS and (
14 | isinstance(model, mo.Model) or issubclass(model, mo.Model)
15 | ):
16 | return model.__get_schema_class__()
17 | else:
18 | return None
19 |
20 |
21 | if MARSHMALLOW_OBJECTS:
22 |
23 | class NestedTitledModel(mo.NestedModel):
24 | """
25 | Use this class instead of marshmallow_object.NestedModel if you need to supply
26 | __swagger_title__ to override the default of {MyModelClass}Schema
27 | """
28 |
29 | def __init__(self, nested: Type[mo.Model], title: str, **kwargs: Any) -> None:
30 | super().__init__(nested, **kwargs)
31 | self.schema.__swagger_title__ = title
32 |
33 | else:
34 |
35 | class NestedTitledModel: # type: ignore
36 | """
37 | This version of NestedTitledModel will exist if marshmallow-objects is not present
38 | """
39 |
40 | def __init__(self) -> None:
41 | raise ImportError(
42 | "To use NestedTitledModel you must install marshmallow-objects"
43 | )
44 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_ui/blueprint.py:
--------------------------------------------------------------------------------
1 | """
2 | Swagger UI Blueprint
3 | ~~~~~~~~~~~~~~~~~~~~
4 |
5 | Flask Blueprint for adding Swagger UI to an API.
6 |
7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | from flask import Blueprint, render_template
11 |
12 |
13 | def create_swagger_ui_blueprint(
14 | ui_url: str,
15 | swagger_url: str,
16 | name: str = "swagger_ui",
17 | page_title: str = "Swagger UI",
18 | ) -> Blueprint:
19 | """
20 | Create a blueprint for adding Swagger UI to a service.
21 |
22 | :param str ui_url:
23 | The path where the Swagger UI will be served from.
24 | All static files will be served from here as well.
25 | :param str swagger_url:
26 | The path (or full URL) where the Swagger UI can retrieve
27 | the Swagger specification.
28 | :param str name:
29 | A name for the blueprint. This is useful if the API is
30 | hosting multiple instances of the Swagger UI.
31 | :param str page_title:
32 | Name to use as the title for the HTML page.
33 | :rtype: flask.Blueprint
34 | """
35 | blueprint = Blueprint(
36 | name=name,
37 | import_name=__name__,
38 | static_folder="static",
39 | template_folder="templates",
40 | url_prefix=ui_url,
41 | )
42 |
43 | template_context = {
44 | "blueprint_name": name,
45 | "swagger_url": swagger_url,
46 | "page_title": page_title,
47 | }
48 |
49 | @blueprint.route("/")
50 | @blueprint.route("")
51 | def show() -> str:
52 | return render_template("index.html.jinja2", **template_context)
53 |
54 | return blueprint
55 |
--------------------------------------------------------------------------------
/examples/todo/todo/app.py:
--------------------------------------------------------------------------------
1 | from flask import Flask
2 | from flask_rebar import HeaderApiKeyAuthenticator
3 | from marshmallow import fields, pre_dump, pre_load
4 |
5 | from flask_rebar import (
6 | Rebar,
7 | errors,
8 | HeaderApiKeyAuthenticator,
9 | Tag,
10 | SwaggerV2Generator,
11 | )
12 | from flask_rebar.validation import RequestSchema, ResponseSchema
13 |
14 | from .converters import TodoTypeConverter
15 |
16 |
17 | rebar = Rebar()
18 |
19 | # Rebar will create a default swagger generator if none is specified.
20 | # However, if you want more control over how the swagger is generated, you can
21 | # provide your own.
22 | # Here we've specified additional metadata for operation tags.
23 | generator = SwaggerV2Generator(
24 | tags=[Tag(name="todo", description="Operations for managing TODO items.")]
25 | )
26 |
27 | registry = rebar.create_handler_registry(
28 | swagger_generator=generator,
29 | handlers="todo.handlers",
30 | )
31 |
32 |
33 | def create_app():
34 | app = Flask(__name__)
35 |
36 | # register new type for url mapping
37 | app.url_map.converters["todo_types"] = TodoTypeConverter
38 | generator.register_flask_converter_to_swagger_type("todo_types", TodoTypeConverter)
39 |
40 | authenticator = HeaderApiKeyAuthenticator(header="X-MyApp-Key")
41 | # The HeaderApiKeyAuthenticator does super simple authentication, designed for
42 | # service-to-service authentication inside of a protected network, by looking for a
43 | # shared secret in the specified header. Here we define what that shared secret is.
44 | authenticator.register_key(key="my-api-key")
45 | registry.set_default_authenticator(authenticator=authenticator)
46 |
47 | rebar.init_app(app=app)
48 |
49 | return app
50 |
51 |
52 | if __name__ == "__main__":
53 | create_app().run()
54 |
--------------------------------------------------------------------------------
/tests/swagger_generation/test_optional_converters.py:
--------------------------------------------------------------------------------
1 | from unittest import mock, TestCase
2 | from importlib import reload
3 | from importlib import metadata
4 |
5 | import pytest
6 |
7 |
8 | # HAAAAACKS - using importlib.reload will invalidate pre-existing imports in other test modules,
9 | # even when imported "as" something else.. so we'll just use pytest-order to ensure this test always runs LAST.
10 | @pytest.mark.order(-1)
11 | class TestOptionalConverters(TestCase):
12 | def test_optional_enum_converter(self):
13 | import flask_rebar.swagger_generation.marshmallow_to_swagger as _m_to_s
14 |
15 | # by default these should be there because tests are run with extras installed
16 | self.assertIsNotNone(_m_to_s.EnumField)
17 | self.assertTrue(
18 | any(
19 | [
20 | type(conv) is _m_to_s.EnumConverter
21 | for conv in _m_to_s._common_converters()
22 | ]
23 | )
24 | )
25 |
26 | # simulate marshmallow_enum not installed:
27 | marsh_version_tuple = tuple(
28 | [int(digit) for digit in metadata.version("marshmallow").split(".")]
29 | )
30 | if marsh_version_tuple < (3, 18, 0):
31 | with mock.patch("marshmallow_enum.EnumField", new=None):
32 | reload(_m_to_s)
33 | self.assertIsNone(_m_to_s.EnumField)
34 | self.assertFalse(
35 | any(
36 | [
37 | type(conv) is _m_to_s.EnumConverter
38 | for conv in _m_to_s._common_converters()
39 | ]
40 | )
41 | )
42 | else:
43 | self.assertEqual(_m_to_s.EnumField.__module__, "marshmallow.fields")
44 |
--------------------------------------------------------------------------------
/docs/why.rst:
--------------------------------------------------------------------------------
1 | Why Flask-Rebar?
2 | ================
3 |
4 | There are number of packages out there that solve a similar problem. Here are just a few:
5 |
6 | * `Connexion `_
7 | * `Flask-RESTful `_
8 | * `flask-apispec `_
9 | * `Flasgger `_
10 |
11 | These are all great projects, and one might work better for your use case. Flask-Rebar solves a similar problem with its own twist on the approach:
12 |
13 | Marshmallow for validation *and* marshaling
14 | -------------------------------------------
15 |
16 | Some approaches use Marshmallow only for marshaling, and provide a secondary schema module for request validation.
17 |
18 | Flask-Rebar is Marshmallow first. Marshmallow is a well developed, well supported package, and Flask-Rebar is built on top of it from the get go.
19 |
20 |
21 | Swagger as a side effect
22 | ------------------------
23 |
24 | Some approaches generate code *from* a Swagger specification, or generate Swagger from docstrings. Flask-Rebar aims to make Swagger (a.k.a. OpenAPI) a byproduct of writing application code with Marshmallow and Flask.
25 |
26 | This is really nice if you prefer the rich validation/transformation functionality of Marshmallow over Swagger's more limited set.
27 |
28 | It also alleviates the need to manually keep an API's documentation in sync with the actual application code - the schemas used by the application are the same schemas used to generate Swagger.
29 |
30 | It's also not always practical - Flask-Rebar sometimes has to expose some Swagger specific things in its interface. C'est la vie.
31 |
32 | And since Marshmallow can be more powerful than Swagger, it also means its possible to have validation logic that can't be represented in Swagger. Flask-Rebar assumes this is inevitable, and assumes that it's OK for an API to raise a 400 error that Swagger wasn't expecting.
33 |
--------------------------------------------------------------------------------
/.github/workflows/pullrequests.yml:
--------------------------------------------------------------------------------
1 | name: flask-rebar Pull Request Tests
2 |
3 | on:
4 | - pull_request
5 |
6 | jobs:
7 | tests:
8 | name: Testing on Python ${{ matrix.python }}
9 | runs-on: ubuntu-latest
10 | strategy:
11 | max-parallel: 20
12 | fail-fast: false
13 | matrix:
14 | python:
15 | - 3.8
16 | - 3.9
17 | - "3.10"
18 | - 3.11
19 | marshmallow:
20 | - marshmallow==3.17.*
21 | - marshmallow==3.18.*
22 | - marshmallow>3.18.0
23 | flask:
24 | - flask=='2.2.*' werkzeug=='2.2.*'
25 | - flask=='2.3.*' werkzeug=='2.3.*'
26 | - flask=='3.0.*' werkzeug=='3.0.*'
27 |
28 | steps:
29 | - uses: actions/checkout@v3
30 | - name: Set up Python:${{ matrix.python }}
31 | uses: actions/setup-python@v4
32 | with:
33 | python-version: ${{ matrix.python }}
34 | - name: "Test with ${{matrix.libraries}}"
35 | run: |
36 | python -m pip install -U pip
37 | python -m pip install '.[dev,enum]' ${{matrix.flask}} ${{matrix.marshmallow}}
38 | python -m pip freeze
39 | - name: Run Tests
40 | run: |
41 | python -m pytest -v -ra --junitxml=pytest.xml
42 | - name: Publish Test Report
43 | uses: mikepenz/action-junit-report@v3
44 | if: always()
45 | with:
46 | report_paths: pytest.xml
47 | check_name: "JUnit Report python${{matrix.python}} ${{matrix.marshmallow}} ${{matrix.flask}}"
48 |
49 | formatter:
50 | name: Format using Black
51 | runs-on: ubuntu-latest
52 | steps:
53 | - uses: actions/checkout@v3
54 | - name: Set up Python 3.11
55 | uses: actions/setup-python@v4
56 | with:
57 | python-version: 3.11
58 | - name: "Install dependencies"
59 | run: |
60 | python -m pip install -U pip
61 | python -m pip install '.[dev]'
62 | python -m pip freeze
63 | - name: Run black
64 | run: |
65 | python -m black --check --diff .
66 | - name: Run flake8
67 | run: |
68 | python -m flake8 .
69 |
--------------------------------------------------------------------------------
/docs/quickstart/api_versioning.rst:
--------------------------------------------------------------------------------
1 | API Versioning
2 | --------------
3 |
4 | URL Prefixing
5 | =============
6 |
7 | There are many ways to do API versioning. Flask-Rebar encourages a simple and very common approach - URL prefixing.
8 |
9 | .. code-block:: python
10 |
11 | from flask import Flask
12 | from flask_rebar import Rebar
13 |
14 |
15 | rebar = Rebar()
16 | v1_registry = rebar.create_handler_registry(prefix='/v1')
17 | v2_registry = rebar.create_handler_registry(prefix='/v2')
18 |
19 | @v1_registry.handles(rule='/foos')
20 | @v2_registry.handles(rule='/foos')
21 | def get_foos():
22 | ...
23 |
24 | @v1_registry.handles(rule='/bar')
25 | def get_bars():
26 | ...
27 |
28 | @v2_registry.handles(rule='/bar')
29 | def get_bars():
30 | ...
31 |
32 | app = Flask(__name__)
33 | rebar.init_app(app)
34 |
35 | Here we have two registries, and both get registered when calling ``rebar.init_app``.
36 |
37 | The same handler function can be used for multiple registries.
38 |
39 | This will produce a separate Swagger specification and UI instance per API version, which Flask-Rebar encourages for better support with tools like `swagger-codegen `_.
40 |
41 |
42 | Cloning a Registry
43 | ==================
44 |
45 | While its recommended to start versioning an API from the get go, sometimes we don't. In that case, it's a common practice to assume that no version prefix is the same as version 1 of the API in order to maintain backwards compatibility for clients that might already be calling non-prefixed endpoints.
46 |
47 | Flask-Rebar supports copying an entire registry and changing the URL prefix:
48 |
49 | .. code-block:: python
50 |
51 | from flask import Flask
52 | from flask_rebar import Rebar
53 |
54 |
55 | rebar = Rebar()
56 | registry = rebar.create_handler_registry()
57 |
58 | @registry.handles(rule='/foos')
59 | def get_foos():
60 | ...
61 |
62 | v1_registry = registry.clone()
63 | v1_registry.prefix = '/v1'
64 | rebar.add_handler_registry(v1_registry)
65 |
66 | app = Flask(__name__)
67 | rebar.init_app(app)
68 |
--------------------------------------------------------------------------------
/tests/swagger_generation/test_swagger_generator_hidden_api.py:
--------------------------------------------------------------------------------
1 | """
2 | Test Swagger Generation
3 | ~~~~~~~~~~~~~~~~~~~~~~~
4 |
5 | Tests for converting a handler registry to a Swagger specification.
6 |
7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | import json
11 | import pytest
12 | from flask_rebar.testing import validate_swagger
13 | from flask_rebar.testing.swagger_jsonschema import (
14 | SWAGGER_V2_JSONSCHEMA,
15 | SWAGGER_V3_JSONSCHEMA,
16 | )
17 |
18 | from tests.swagger_generation.registries import hidden_api
19 |
20 |
21 | def _assert_dicts_equal(a, b):
22 | result = json.dumps(a, indent=2, sort_keys=True)
23 | expected = json.dumps(b, indent=2, sort_keys=True)
24 |
25 | assert result == expected
26 |
27 |
28 | @pytest.mark.parametrize(
29 | "registry, swagger_generator, expected_swagger",
30 | [
31 | (
32 | hidden_api.registry,
33 | hidden_api.swagger_v2_generator,
34 | hidden_api.EXPECTED_SWAGGER_V2,
35 | ),
36 | (
37 | hidden_api.registry,
38 | hidden_api.normal_swagger_v3_generator,
39 | hidden_api.SWAGGER_V3_WITHOUT_HIDDEN,
40 | ),
41 | (
42 | hidden_api.registry,
43 | hidden_api.swagger_v3_generator_with_hidden,
44 | hidden_api.SWAGGER_V3_WITH_HIDDEN,
45 | ),
46 | ],
47 | )
48 | def test_swagger_generators(registry, swagger_generator, expected_swagger):
49 | open_api_version = swagger_generator.get_open_api_version()
50 | if open_api_version == "2.0":
51 | swagger_jsonschema = SWAGGER_V2_JSONSCHEMA
52 | elif open_api_version == "3.1.0":
53 | swagger_jsonschema = SWAGGER_V3_JSONSCHEMA
54 | else:
55 | raise ValueError(f"Unknown swagger_version: {open_api_version}")
56 |
57 | validate_swagger(expected_swagger, schema=swagger_jsonschema)
58 |
59 | swagger = swagger_generator.generate(registry)
60 |
61 | result = json.dumps(swagger, indent=2, sort_keys=True)
62 | expected = json.dumps(expected_swagger, indent=2, sort_keys=True)
63 |
64 | assert result == expected
65 |
--------------------------------------------------------------------------------
/examples/todo/todo/schemas.py:
--------------------------------------------------------------------------------
1 | # Rebar relies heavily on Marshmallow.
2 | # These schemas will be used to validate incoming data, marshal outgoing
3 | # data, and to automatically generate a Swagger specification.
4 |
5 | from flask_rebar.validation import RequestSchema, ResponseSchema
6 | from flask_rebar.swagger_generation.marshmallow_to_swagger import EnumField
7 | from marshmallow import fields, pre_dump, pre_load
8 |
9 | from .converters import TodoType
10 |
11 |
12 | class CreateTodoSchema(RequestSchema):
13 | complete = fields.Boolean(required=True)
14 | description = fields.String(required=True)
15 | type = EnumField(TodoType, load_default=TodoType.user)
16 |
17 |
18 | class UpdateTodoSchema(CreateTodoSchema):
19 | # This schema provides an example of one way to re-use another schema while making some fields optional
20 | # a "partial" schema in Marshmallow parlance:
21 | def __init__(self, **kwargs):
22 | super_kwargs = dict(kwargs)
23 | partial_arg = super_kwargs.pop("partial", True)
24 | # Note: if you only want to mark some fields as partial, pass partial= a collection of field names, e.g.,:
25 | # partial_arg = super_kwargs.pop('partial', ('description', ))
26 | super().__init__(partial=partial_arg, **super_kwargs)
27 |
28 |
29 | class GetTodoListSchema(RequestSchema):
30 | complete = fields.Boolean()
31 |
32 |
33 | class TodoSchema(ResponseSchema):
34 | id = fields.Integer(required=True)
35 | complete = fields.Boolean(required=True)
36 | description = fields.String(required=True)
37 | type = EnumField(TodoType, required=True)
38 |
39 |
40 | class TodoResourceSchema(ResponseSchema):
41 | data = fields.Nested(TodoSchema)
42 |
43 | @pre_dump
44 | @pre_load
45 | def envelope_in_data(self, data, **kwargs):
46 | if type(data) is not dict or "data" not in data.keys():
47 | return {"data": data}
48 | else:
49 | return data
50 |
51 |
52 | class TodoListSchema(ResponseSchema):
53 | data = fields.Nested(TodoSchema, many=True)
54 |
55 | @pre_dump
56 | @pre_load
57 | def envelope_in_data(self, data, **kwargs):
58 | if type(data) is not dict or "data" not in data.keys():
59 | return {"data": data}
60 | else:
61 | return data
62 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from setuptools import setup, find_packages
3 |
4 |
5 | # packages required for local development and testing
6 | development = [
7 | "black==23.7.0",
8 | "bumpversion==0.6.0",
9 | "click>=8.1.3,<9.0.0",
10 | "flake8==6.0.0",
11 | "gitchangelog>=3.0.4,<4.0.0",
12 | "jsonschema==4.18.4",
13 | "marshmallow-objects~=2.3",
14 | "mypy==1.8.0",
15 | "parametrize==0.1.1",
16 | "pre-commit>=1.14.4",
17 | "pytest~=7.4",
18 | "pytest-order~=1.0",
19 | "Sphinx>=6.0.0,<7.0.0",
20 | "sphinx_rtd_theme==1.2.2",
21 | "types-jsonschema==4.17.0.10",
22 | "types-setuptools==68.0.0.3",
23 | "flask[async]>=2,<4",
24 | ]
25 |
26 | install_requires = [
27 | "Flask>=1.0,<4",
28 | "marshmallow>=3.0,<4",
29 | "typing-extensions>=4.8,<5;python_version<'3.10'",
30 | "Werkzeug>=2.2,<4",
31 | ]
32 |
33 | if __name__ == "__main__":
34 | setup(
35 | name="flask-rebar",
36 | version="3.3.2",
37 | author="Barak Alon",
38 | author_email="barak.s.alon@gmail.com",
39 | description="Flask-Rebar combines flask, marshmallow, and swagger for robust REST services.",
40 | long_description=open("README.rst").read(),
41 | keywords=["flask", "rest", "marshmallow", "openapi", "swagger"],
42 | license="MIT",
43 | packages=find_packages(exclude=("test*", "examples")),
44 | package_data={"flask_rebar": ["py.typed"]},
45 | include_package_data=True,
46 | extras_require={
47 | "dev": development,
48 | "enum": ["marshmallow-enum~=1.5"],
49 | "async": ["flask[async]>=2,<4"],
50 | },
51 | install_requires=install_requires,
52 | url="https://github.com/plangrid/flask-rebar",
53 | classifiers=[
54 | "Environment :: Web Environment",
55 | "Framework :: Flask",
56 | "Intended Audience :: Developers",
57 | "License :: OSI Approved :: MIT License",
58 | "Operating System :: OS Independent",
59 | "Programming Language :: Python",
60 | "Programming Language :: Python :: 3.8",
61 | "Programming Language :: Python :: 3.9",
62 | "Programming Language :: Python :: 3.10",
63 | "Programming Language :: Python :: 3.11",
64 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
65 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
66 | "Topic :: Software Development :: Libraries :: Application Frameworks",
67 | "Topic :: Software Development :: Libraries :: Python Modules",
68 | ],
69 | )
70 |
--------------------------------------------------------------------------------
/.gitchangelog.rc:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8; mode: python -*-
2 | ## To make this config more readable we've removed all the detailed comments about what's going on
3 | ## here. To see explanations of each config in this file go to https://github.com/vaab/gitchangelog/blob/master/.gitchangelog.rc.
4 |
5 |
6 | ignore_regexps = [
7 | r'@minor', r'!minor',
8 | r'@cosmetic', r'!cosmetic',
9 | r'@refactor', r'!refactor',
10 | r'@wip', r'!wip',
11 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[p|P]kg:',
12 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[d|D]ev:',
13 | r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$',
14 | r'^$', ## ignore commits with empty messages,
15 | r'Bump version', ## ignore bumpversion commits
16 | ]
17 |
18 |
19 | section_regexps = [
20 | ('New', [
21 | r'^[nN]ew\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
22 | ]),
23 | ('Changes', [
24 | r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
25 | ]),
26 | ('Fix', [
27 | r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
28 | ]),
29 |
30 | ('Other', None ## Match all lines
31 | ),
32 | ]
33 |
34 |
35 | body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip
36 |
37 |
38 | subject_process = (strip |
39 | ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') |
40 | SetIfEmpty("No commit message.") | ucfirst | final_dot)
41 |
42 |
43 | tag_filter_regexp = r'^v[0-9]+\.[0-9]+(\.[0-9]+)?$'
44 |
45 |
46 | unreleased_version_label = "(unreleased)"
47 |
48 |
49 | output_engine = rest_py
50 |
51 |
52 | include_merge = True
53 |
54 |
55 | INSERT_POINT_REGEX = r'''(?isxu)
56 | ^
57 | (
58 | \s*Changelog\s*(\n|\r\n|\r) ## ``Changelog`` line
59 | ==+\s*(\n|\r\n|\r){2} ## ``=========`` rest underline
60 | )
61 | ( ## Match all between changelog and release rev
62 | (
63 | (?!
64 | (?<=(\n|\r)) ## look back for newline
65 | %(rev)s ## revision
66 | \s+
67 | \([0-9]+-[0-9]{2}-[0-9]{2}\)(\n|\r\n|\r) ## date
68 | --+(\n|\r\n|\r) ## ``---`` underline
69 | )
70 | .
71 | )*
72 | )
73 | (?P%(rev)s)
74 | ''' % {'rev': r"v[0-9]+\.[0-9]+(\.[0-9]+)?"}
75 |
76 |
77 | publish = FileRegexSubst(
78 | "CHANGELOG.rst",
79 | INSERT_POINT_REGEX,
80 | r"\1\o\g",
81 | )
82 |
83 |
84 | revs = [
85 | Caret(
86 | FileFirstRegexMatch(
87 | "CHANGELOG.rst",
88 | r'(?Pv[0-9]+\.[0-9]+(\.[0-9]+)?)\s+',
89 | )
90 | ),
91 | "HEAD"
92 | ]
93 |
--------------------------------------------------------------------------------
/flask_rebar/authenticators/header_api_key.py:
--------------------------------------------------------------------------------
1 | """
2 | Header API Key Authenticator
3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4 |
5 | Authenticator for API key passed in header.
6 |
7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | from typing import Dict
11 |
12 | from flask import request, g
13 | from hmac import compare_digest
14 |
15 | from flask_rebar import errors, messages
16 | from flask_rebar.authenticators.base import Authenticator
17 |
18 |
19 | def get_authenticated_app_name() -> str:
20 | return g.authenticated_app_name
21 |
22 |
23 | class HeaderApiKeyAuthenticator(Authenticator):
24 | """
25 | Authenticates based on a small set of shared secrets, passed via a header.
26 |
27 | This allows multiple client applications to be registered with their own
28 | keys.
29 | This also allows multiple keys to be registered for a single client
30 | application.
31 |
32 | :param str header:
33 | The header where clients where client applications must include
34 | their secret.
35 | :param str name:
36 | A name for this authenticator. This should be unique across
37 | authenticators.
38 | """
39 |
40 | # This authenticator allows multiple applications to have different keys.
41 | # This is the default name, if someone doesn't need about this feature.
42 | DEFAULT_APP_NAME = "default"
43 |
44 | def __init__(self, header: str, name: str = "sharedSecret") -> None:
45 | self.header = header
46 | self.keys: Dict[str, str] = {}
47 | self.name = name
48 |
49 | @property
50 | def authenticated_app_name(self) -> str:
51 | return get_authenticated_app_name()
52 |
53 | def register_key(self, key: str, app_name: str = DEFAULT_APP_NAME) -> None:
54 | """
55 | Register a client application's shared secret.
56 |
57 | :param str app_name:
58 | Name for the application. Since an application can have multiple
59 | shared secrets, this does not need to be unique.
60 | :param str key:
61 | The shared secret.
62 | """
63 | self.keys[key] = app_name
64 |
65 | def authenticate(self) -> None:
66 | if self.header not in request.headers:
67 | raise errors.Unauthorized(messages.missing_auth_token)
68 |
69 | token = request.headers[self.header]
70 |
71 | for key, app_name in self.keys.items():
72 | if compare_digest(str(token), key):
73 | g.authenticated_app_name = app_name
74 | break
75 | else:
76 | raise errors.Unauthorized(messages.invalid_auth_token)
77 |
--------------------------------------------------------------------------------
/flask_rebar/compat.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 | from typing import Dict
3 |
4 | import marshmallow
5 | from marshmallow.fields import Field
6 | from marshmallow.schema import Schema
7 |
8 | from flask import current_app
9 | from flask_rebar.validation import filter_dump_only, RequireOnDumpMixin
10 |
11 |
12 | def set_data_key(field: Field, key: str) -> Field:
13 | field.data_key = key
14 | return field
15 |
16 |
17 | def get_data_key(field: Field) -> str:
18 | if field.data_key:
19 | return field.data_key
20 | if field.name is None:
21 | raise ValueError("Field name cannot be None")
22 | return field.name
23 |
24 |
25 | def load(schema: Schema, data: Dict[str, Any]) -> Dict[str, Any]:
26 | return schema.load(data)
27 |
28 |
29 | def dump(schema: Schema, data: Dict[str, Any]) -> Dict[str, Any]:
30 | """
31 | Our wrapper for Schema.dump that includes optional validation.
32 | Note that as of Flask-Rebar 2.x (hence Marshmallow 3.x), Marshmallow's default behavior is to NOT validate on dump
33 | Accordingly, we are making validation "opt-in" here, which can be controlled at schema level with
34 | RequireOnDumpMixin or globally via validate_on_dump attribute of Rebar instance
35 | """
36 | try:
37 | force_validation = current_app.extensions["rebar"]["instance"].validate_on_dump
38 | except (
39 | RuntimeError
40 | ): # running outside app context (some unit test cases, potentially ad hoc scripts)
41 | force_validation = False
42 |
43 | if isinstance(schema, RequireOnDumpMixin) or force_validation:
44 | try:
45 | # We do an initial schema.dump here in order to support arbitrary data objects (e.g., ORM objects, etc.)
46 | # and give us something we can pass to .load below
47 | # Since marshmallow 3 doesn't validate on dump, this has the effect of stripping unknown fields.
48 | result = schema.dump(data)
49 | except marshmallow.ValidationError:
50 | raise
51 | except Exception as e:
52 | raise marshmallow.ValidationError(str(e))
53 |
54 | # filter out "dump_only" fields before we call load - we are only calling load to validate data we are dumping
55 | # (We use load because that's how Marshmallow docs recommend doing this sort of validation, presumably because
56 | # @pre_load massaging of data could make otherwise invalid data valid.
57 | filtered = filter_dump_only(schema, result)
58 | schema.load(filtered.loadable) # trigger validation
59 | else:
60 | result = schema.dump(data)
61 | return result
62 |
63 |
64 | def exclude_unknown_fields(schema: Schema) -> Schema:
65 | schema.unknown = marshmallow.EXCLUDE
66 | return schema
67 |
--------------------------------------------------------------------------------
/tests/test_request_utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Test Request Utilities
3 | ~~~~~~~~~~~~~~~~~~~~~~
4 |
5 | Tests for the request utilities.
6 |
7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | import unittest
11 | from tests.helpers import make_test_response
12 |
13 | from flask import Flask
14 | from marshmallow import fields, ValidationError
15 |
16 | from flask_rebar import validation, response, marshal
17 |
18 |
19 | class TestResponseFormatting(unittest.TestCase):
20 | def setUp(self):
21 | self.app = self.create_app()
22 | self.app.response_class = make_test_response(self.app.response_class)
23 |
24 | def create_app(self):
25 | app = Flask(__name__)
26 |
27 | return app
28 |
29 | def test_single_resource_response(self):
30 | @self.app.route("/single_resource")
31 | def handler():
32 | return response(data={"foo": "bar"})
33 |
34 | resp = self.app.test_client().get("/single_resource")
35 | self.assertEqual(resp.status_code, 200)
36 | self.assertEqual(resp.json, {"foo": "bar"})
37 | self.assertEqual(resp.content_type, "application/json")
38 |
39 | def test_single_resource_response_with_status_code(self):
40 | @self.app.route("/single_resource")
41 | def handler():
42 | return response(data={"foo": "bar"}, status_code=201)
43 |
44 | resp = self.app.test_client().get("/single_resource")
45 | self.assertEqual(resp.status_code, 201)
46 | self.assertEqual(resp.json, {"foo": "bar"})
47 | self.assertEqual(resp.content_type, "application/json")
48 |
49 | def test_single_resource_response_with_headers(self):
50 | header_key = "X-Foo"
51 | header_value = "bar"
52 |
53 | @self.app.route("/single_resource")
54 | def handler():
55 | return response(data={"foo": "bar"}, headers={header_key: header_value})
56 |
57 | resp = self.app.test_client().get("/single_resource")
58 | self.assertEqual(resp.headers[header_key], header_value)
59 | self.assertEqual(resp.json, {"foo": "bar"})
60 | self.assertEqual(resp.content_type, "application/json")
61 |
62 |
63 | class SchemaForMarshaling(validation.ResponseSchema):
64 | foo = fields.Integer()
65 |
66 |
67 | class TestMarshal(unittest.TestCase):
68 | def test_marshal(self):
69 | marshaled = marshal(data={"foo": 1}, schema=SchemaForMarshaling)
70 | self.assertEqual(marshaled, {"foo": 1})
71 |
72 | # Also works with an instance of the schema
73 | marshaled = marshal(data={"foo": 1}, schema=SchemaForMarshaling())
74 |
75 | self.assertEqual(marshaled, {"foo": 1})
76 |
77 | def test_marshal_errors(self):
78 | with self.assertRaises(ValidationError):
79 | marshal(data={"foo": "bar"}, schema=SchemaForMarshaling)
80 |
--------------------------------------------------------------------------------
/docs/version_history.rst:
--------------------------------------------------------------------------------
1 | Version History
2 | ---------------
3 |
4 | This Version History provides a high-level overview of changes in major versions. It is intended as a supplement
5 | for :doc:`changelog`. In this document we highlight major changes, especially breaking changes. If you notice a breaking
6 | change that we neglected to note here, please let us know (or open a PR to add it to this doc)!
7 |
8 | Version 2.0 (2021-07-26)
9 | ========================
10 |
11 | Errata
12 | ******
13 | Version 2.0.0 included a couple of bugs related to the upgrade from Marshmallow 2 to 3. While the fix for one of those
14 | (removal of ``DisallowExtraFieldsMixin``) might technically be considered a "breaking change" requiring a new major
15 | version, we deemed it acceptable to bend the rules of semantic versioning since that mixin **never actually functioned**
16 | in 2.0.0.
17 |
18 | * Removed support for versions < 3.6 of Python
19 | * Removed support for versions < 1.0 of Flask, and added support for Flask 2.x; we now support only Flask 1.x and 2.x.
20 | * Removed support for versions < 3.0 of Marshmallow; we now support only Marshmallow 3.x
21 | * (2.0.1) Removed ``flask_rebar.validation.DisallowExtraFieldsMixin`` - with Marshmallow 3, this is now default behavior and this mixin was broken in 2.0.0
22 | * We now generate appropriate OpenAPI spec based on ``Schema``'s ``Meta`` (ref https://marshmallow.readthedocs.io/en/stable/quickstart.html#handling-unknown-fields)
23 | * Removed support for previously deprecated parameter names (https://github.com/plangrid/flask-rebar/pull/246/files)
24 | * In methods that register handlers, ``marshal_schema`` is now ``response_body_schema`` and the former name is no longer supported
25 | * ``AuthenticatorConverterRegistry`` no longer accepts a ``converters`` parameter when instantiating. Use ``register_type`` on an instance to add a converter
26 | * Standardized registration of custom swagger authenticator converters (https://github.com/plangrid/flask-rebar/pull/216)
27 | * Use of "converter functions" is no longer supported; use a class that derives from ``AuthenticatorConverter`` instead.
28 | * Added "rebar-internal" error codes (https://github.com/plangrid/flask-rebar/pull/245)
29 | * Can be used programmatically to differentiate between different "flavors" of errors (for example, the specific reason behind a ``400 Bad Request``)
30 | * This gets added to the JSON we return for errors
31 | * Added support for marshmallow-objects >= 2.3, < 3.0 (https://github.com/plangrid/flask-rebar/pull/243)
32 | * You can now use a marshmallow-objects ``Model`` instead of a marshmallow ``Schema`` when registering your endpoints.
33 | * Add support for "hidden API" endpoints that are by default not exposed in generated OpenAPI spec (https://github.com/plangrid/flask-rebar/pull/191/files)
34 |
35 |
36 | Version 1.0 (2018-03-04)
37 | ========================
38 | The first official release of Flask-Rebar!
39 |
40 |
--------------------------------------------------------------------------------
/flask_rebar/messages.py:
--------------------------------------------------------------------------------
1 | """
2 | Messages
3 | ~~~~~~~~
4 |
5 | Helpers for generating messages that the API returns.
6 |
7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | from collections import namedtuple
11 |
12 |
13 | # machine-friendly equivalents of associated human-friendly messages
14 | class ErrorCode:
15 | BODY_VALIDATION_FAILED = "body_validation_failed"
16 | EMPTY_JSON_BODY = "empty_json_body"
17 | INTERNAL_SERVER_ERROR = "internal_server_error"
18 | INVALID_AUTH_TOKEN = "invalid_auth_token"
19 | INVALID_JSON = "invalid_json"
20 | MISSING_AUTH_TOKEN = "missing_auth_token"
21 | QUERY_STRING_VALIDATION_FAILED = "query_string_validation_failed"
22 | UNSUPPORTED_CONTENT_TYPE = "unsupported_content_type"
23 | HEADER_VALIDATION_FAILED = "header_validation_failed"
24 | REQUIRED_FIELD_MISSING = "required_fields_missing"
25 | REQUIRED_FIELD_EMPTY = "required_field_empty"
26 | UNSUPPORTED_FIELDS = "unsupported_fields"
27 |
28 |
29 | ErrorMessage = namedtuple("ErrorMessage", "message, rebar_error_code")
30 |
31 |
32 | body_validation_failed = ErrorMessage(
33 | "JSON body parameters are invalid.", ErrorCode.BODY_VALIDATION_FAILED
34 | )
35 |
36 | empty_json_body = ErrorMessage(
37 | "Fields must be in JSON body.", ErrorCode.EMPTY_JSON_BODY
38 | )
39 |
40 | internal_server_error = ErrorMessage(
41 | "Sorry, there was an internal error.", ErrorCode.INTERNAL_SERVER_ERROR
42 | )
43 |
44 | invalid_auth_token = ErrorMessage(
45 | "Invalid authentication.", ErrorCode.INVALID_AUTH_TOKEN
46 | )
47 |
48 | invalid_json = ErrorMessage("Failed to decode JSON body.", ErrorCode.INVALID_JSON)
49 |
50 | missing_auth_token = ErrorMessage(
51 | "No auth token provided.", ErrorCode.MISSING_AUTH_TOKEN
52 | )
53 |
54 | query_string_validation_failed = ErrorMessage(
55 | "Query string parameters are invalid.", ErrorCode.QUERY_STRING_VALIDATION_FAILED
56 | )
57 |
58 | unsupported_content_type = ErrorMessage(
59 | "Only payloads with 'content-type' 'application/json' are supported.",
60 | ErrorCode.UNSUPPORTED_CONTENT_TYPE,
61 | )
62 |
63 | header_validation_failed = ErrorMessage(
64 | "Header parameters are invalid", ErrorCode.HEADER_VALIDATION_FAILED
65 | )
66 |
67 |
68 | def required_field_missing(field_name: str) -> ErrorMessage:
69 | return ErrorMessage(
70 | f"Required field missing: {field_name}",
71 | ErrorCode.REQUIRED_FIELD_MISSING,
72 | )
73 |
74 |
75 | def required_field_empty(field_name: str) -> ErrorMessage:
76 | return ErrorMessage(
77 | f"Value for required field cannot be None: {field_name}",
78 | ErrorCode.REQUIRED_FIELD_EMPTY,
79 | )
80 |
81 |
82 | def unsupported_fields(field_names: str) -> ErrorMessage:
83 | return ErrorMessage(
84 | "Unexpected field: {}".format(",".join(field_names)),
85 | ErrorCode.UNSUPPORTED_FIELDS,
86 | )
87 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Reporting Security Issues
2 |
3 | The Autodesk team and community take security bugs in flask-rebar seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
4 |
5 | ## Reporting a Security Vulnerability to Autodesk
6 |
7 | This vulnerability disclosure policy applies to any vulnerabilities you are considering reporting to Autodesk.
8 |
9 | We recommend reading this vulnerability disclosure policy fully before you report a vulnerability and always acting in compliance with it.
10 |
11 | We value those who take the time and effort to report security vulnerabilities according to this policy. However, we do not offer monetary rewards for vulnerability disclosures.
12 |
13 | If you believe you have found a security vulnerability relating to Autodesk's systems or products, please submit a vulnerability report to [Autodesk HackerOne program](https://hackerone.com/autodesk) or [psirt@autodesk.com](mailto:psirt@autodesk.com). Otherwise, submit a report an incident at [Autodesk Trust Center](https://www.autodesk.com/trust/security).
14 |
15 | In your report please include details of:
16 |
17 | - The software package, website, IP or page where the vulnerability can be observed.
18 | - A brief description of the type of vulnerability, for example; "XSS vulnerability".
19 | - Steps to reproduce. These should be a benign, non-destructive, proof of concept. This helps to ensure that the report can be triaged quickly and accurately. It also reduces the likelihood of duplicate reports, or malicious exploitation of some vulnerabilities, such as sub-domain takeovers.
20 |
21 | Autodesk will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
22 |
23 | ## Responsible Disclosure
24 |
25 | We appreciate the responsible disclosure of security vulnerabilities. Please allow us a reasonable amount of time to address the issue before making it public.
26 |
27 | ## Supported Versions
28 |
29 | | Version | Supported |
30 | |-----------|--------------------|
31 | | 3.0+ | :white_check_mark: |
32 | | <=2.4.1 | :x: |
33 |
34 | ## Learning More About Security
35 |
36 | To learn more about Autodesk Security, please see the [Autodesk Trust Center](https://www.autodesk.com/trust/security).
37 |
38 | ## Receiving Security Information From Autodesk
39 |
40 | Technical security information about our products and services is distributed through several channels.
41 |
42 | - Autodesk distributes information to customers about security vulnerabilities via https://autodesk.com and [Autodesk Trust Center](https://www.autodesk.com/trust/security).
43 | - Autodesk may issue release notes and security bulletins detailing security vulnerabilities, workarounds, remediations and any indicators of compromise to aid incident response teams.
--------------------------------------------------------------------------------
/flask_rebar/swagger_ui/static/oauth2-redirect.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Swagger UI: OAuth2 Redirect
5 |
6 |
7 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/docs/quickstart/authentication.rst:
--------------------------------------------------------------------------------
1 | Authentication
2 | --------------
3 |
4 | Authenticator Interface
5 | =======================
6 |
7 | Flask-Rebar has very basic support for authentication - an authenticator just needs to implement ``flask_rebar.authenticators.Authenticator``, which is just a class with an ``authenticate`` method that will be called before a handler function.
8 |
9 |
10 | Header API Key Authentication
11 | =============================
12 |
13 | Flask-Rebar ships with a ``HeaderApiKeyAuthenticator``.
14 |
15 | .. code-block:: python
16 |
17 | from flask_rebar import HeaderApiKeyAuthenticator
18 |
19 | authenticator = HeaderApiKeyAuthenticator(header='X-MyApp-ApiKey')
20 |
21 | @registry.handles(
22 | rule='/todos/',
23 | method='GET',
24 | authenticators=authenticator,
25 | )
26 | def get_todo(id):
27 | ...
28 |
29 | authenticator.register_key(key='my-secret-api-key')
30 |
31 | # Probably a good idea to include a second valid value to make key rotation
32 | # possible without downtime
33 | authenticator.register_key(key='my-secret-api-key-backup')
34 |
35 | The ``X-MyApp-ApiKey`` header must now match ``my-secret-api-key``, or else a ``401`` error will be returned.
36 |
37 | This also supports very lightweight way to identify clients based on the value of the api key:
38 |
39 | .. code-block:: python
40 |
41 | from flask import g
42 |
43 | authenticator = HeaderApiKeyAuthenticator(header='X-MyApp-ApiKey')
44 |
45 | @registry.handles(
46 | rule='/todos/',
47 | method='GET',
48 | authenticators=authenticator,
49 | )
50 | def get_todo(id):
51 | app_name = authenticator.authenticated_app_name
52 | if app_name == 'client_1':
53 | raise errors.Forbidden()
54 | ...
55 |
56 | authenticator.register_key(key='key1', app_name='client_1')
57 | authenticator.register_key(key='key2', app_name='client_2')
58 |
59 | This is meant to differentiate between a small set of client applications, and will not scale to a large set of keys and/or applications.
60 |
61 | An authenticator can be added as the default headers schema for all handlers via the registry:
62 |
63 | .. code-block:: python
64 |
65 | registry.set_default_authenticator(authenticator)
66 |
67 | This default can be extended for any particular handler by passing flask_rebar.authenticators.USE_DEFAULT as one of the authenticators.
68 | This default can be overriden in any particular handler by setting ``authenticators`` to something else, including ``None`` to bypass any authentication.
69 |
70 | This Header API Key authentication mechanism was designed to work for services behind some sort of reverse proxy that is handling the harder bits of client authentication.
71 |
72 | Extensions for Authentication
73 | =============================
74 | For situations that require something more robust than the basic header-based authentication, Flask-Rebar can be extended. For example, see the following open-source Flask-Rebar extension(s):
75 |
76 | * `Flask-Rebar-Auth0 `_ - `Auth0 `_ authenticator for Flask-Rebar
77 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_generation/swagger_words.py:
--------------------------------------------------------------------------------
1 | """
2 | Swagger Words
3 | ~~~~~~~~~~~~~
4 |
5 | Python friendly aliases to reserved Swagger words.
6 |
7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | from typing import Final
11 |
12 | additional_properties: Final = "additionalProperties"
13 | all_of: Final = "allOf"
14 | allow_empty_value: Final = "allowEmptyValue"
15 | any_of: Final = "anyOf"
16 | api_key: Final = "apiKey"
17 | array: Final = "array"
18 | basic: Final = "basic"
19 | binary: Final = "binary"
20 | body: Final = "body"
21 | boolean: Final = "boolean"
22 | byte: Final = "byte"
23 | collection_format: Final = "collectionFormat"
24 | components: Final = "components"
25 | consumes: Final = "consumes"
26 | content: Final = "content"
27 | csv: Final = "csv"
28 | date: Final = "date"
29 | date_time: Final = "date-time"
30 | default: Final = "default"
31 | definitions: Final = "definitions"
32 | description: Final = "description"
33 | double: Final = "double"
34 | enum: Final = "enum"
35 | external_docs: Final = "externalDocs"
36 | exclusive_maximum: Final = "exclusiveMaximum"
37 | exclusive_minimum: Final = "exclusiveMinimum"
38 | explode: Final = "explode"
39 | float_: Final = "float"
40 | form: Final = "form"
41 | format_: Final = "format"
42 | header: Final = "header"
43 | host: Final = "host"
44 | in_: Final = "in"
45 | info: Final = "info"
46 | integer: Final = "integer"
47 | int32: Final = "int32"
48 | int64: Final = "int64"
49 | items: Final = "items"
50 | max_items: Final = "maxItems"
51 | max_length: Final = "maxLength"
52 | max_properties: Final = "maxProperties"
53 | maximum: Final = "maximum"
54 | min_items: Final = "minItems"
55 | min_length: Final = "minLength"
56 | min_properties: Final = "minProperties"
57 | minimum: Final = "minimum"
58 | multi: Final = "multi"
59 | multiple_of: Final = "multipleOf"
60 | name: Final = "name"
61 | null: Final = "null"
62 | nullable: Final = "x-nullable"
63 | number: Final = "number"
64 | oauth2: Final = "oauth2"
65 | object_: Final = "object"
66 | one_of: Final = "oneOf"
67 | openapi: Final = "openapi"
68 | operation_id: Final = "operationId"
69 | parameters: Final = "parameters"
70 | password: Final = "password"
71 | path: Final = "path"
72 | paths: Final = "paths"
73 | pattern: Final = "pattern"
74 | produces: Final = "produces"
75 | properties: Final = "properties"
76 | query: Final = "query"
77 | ref: Final = "$ref"
78 | request_body: Final = "requestBody"
79 | required: Final = "required"
80 | responses: Final = "responses"
81 | schema: Final = "schema"
82 | schemas: Final = "schemas"
83 | schemes: Final = "schemes"
84 | security: Final = "security"
85 | security_definitions: Final = "securityDefinitions"
86 | security_schemes: Final = "securitySchemes"
87 | servers: Final = "servers"
88 | simple: Final = "simple"
89 | string: Final = "string"
90 | style: Final = "style"
91 | summary: Final = "summary"
92 | swagger: Final = "swagger"
93 | tags: Final = "tags"
94 | title: Final = "title"
95 | type_: Final = "type"
96 | unique_items: Final = "uniqueItems"
97 | url: Final = "url"
98 | uuid: Final = "uuid"
99 | variables: Final = "variables"
100 | version: Final = "version"
101 |
--------------------------------------------------------------------------------
/examples/todo/generate_output.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 | import json
4 | import contextlib
5 |
6 |
7 | this_directory = os.path.dirname(os.path.realpath(__file__))
8 | todo_app_filepath = os.path.join(this_directory, "todo.py")
9 | todo_output_filepath = os.path.join(this_directory, "todo_output.md")
10 |
11 |
12 | @contextlib.contextmanager
13 | def app():
14 | print("Starting app...")
15 |
16 | app_process = None
17 | try:
18 | app_process = subprocess.Popen(
19 | args=["python", todo_app_filepath],
20 | stdout=subprocess.PIPE,
21 | stderr=subprocess.PIPE,
22 | )
23 |
24 | # Wait for the app to startup by polling the service
25 | up = False
26 | while not up:
27 | try:
28 | subprocess.check_output(
29 | "curl -s http://127.0.0.1:5000/swagger", shell=True
30 | )
31 | up = True
32 | except subprocess.SubprocessError:
33 | pass
34 |
35 | yield
36 |
37 | finally:
38 | if app_process:
39 | app_process.terminate()
40 |
41 |
42 | def main():
43 | output = []
44 |
45 | with app():
46 | output.extend(
47 | [
48 | "# cURL and examples/todo.py",
49 | "Here's a snippet of playing with the application inside todo.py.",
50 | "",
51 | ]
52 | )
53 |
54 | for title, commands in [
55 | ("Swagger for free!", ["curl -s -XGET http://127.0.0.1:5000/swagger"]),
56 | (
57 | "Request validation!",
58 | [
59 | 'curl -s -XPATCH http://127.0.0.1:5000/todos/1 -H "X-MyApp-Key: my-api-key" -H "Content-Type: application/json" -d \'{"complete": "wrong type, for demonstration of validation"}\''
60 | ],
61 | ),
62 | (
63 | "Authentication!",
64 | [
65 | "curl -s -XGET http://127.0.0.1:5000/todos",
66 | 'curl -s -XGET http://127.0.0.1:5000/todos -H "X-MyApp-Key: my-api-key"',
67 | ],
68 | ),
69 | (
70 | "CRUD!",
71 | [
72 | 'curl -s -XPOST http://127.0.0.1:5000/todos -H "X-MyApp-Key: my-api-key" -H "Content-Type: application/json" -d \'{"complete": false, "description": "Find product market fit"}\'',
73 | 'curl -s -XPATCH http://127.0.0.1:5000/todos/1 -H "X-MyApp-Key: my-api-key" -H "Content-Type: application/json" -d \'{"complete": true}\'',
74 | 'curl -s -XGET http://127.0.0.1:5000/todos -H "X-MyApp-Key: my-api-key"',
75 | ],
76 | ),
77 | ]:
78 | output.extend([title, "```"])
79 |
80 | for command in commands:
81 | print(command)
82 |
83 | result = subprocess.check_output(command, shell=True)
84 |
85 | output.extend(
86 | ["$ " + command, json.dumps(json.loads(result), indent=2)]
87 | )
88 |
89 | output.extend(["```", ""])
90 |
91 | print(f"Writing output to {todo_output_filepath}")
92 |
93 | with open(todo_output_filepath, "w") as f:
94 | f.write("\n".join(output))
95 |
96 | print("Done!")
97 |
98 |
99 | if __name__ == "__main__":
100 | main()
101 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. Flask-Rebar documentation master file, created by
2 | sphinx-quickstart on Thu Feb 22 16:45:26 2018.
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-Rebar
7 | ======================
8 |
9 | Welcome to Flask-Rebar's documentation!
10 |
11 | Flask-Rebar combines `flask `_, `marshmallow `_, and `swagger `_ for robust REST services.
12 |
13 |
14 | Features
15 | --------
16 |
17 | * **Request and Response Validation** - Flask-Rebar relies on schemas from the popular Marshmallow package to validate incoming requests and marshal outgoing responses.
18 | * **Automatic Swagger Generation** - The same schemas used for validation and marshaling are used to automatically generate OpenAPI specifications (a.k.a. Swagger). This also means automatic documentation via `Swagger UI `_.
19 | * **Error Handling** - Uncaught exceptions from Flask-Rebar are converted to appropriate HTTP errors.
20 |
21 |
22 | Example
23 | -------
24 |
25 | Here's what a basic Flask-Rebar application looks like:
26 |
27 | .. code-block:: python
28 |
29 | from flask import Flask
30 | from flask_rebar import errors, Rebar
31 | from marshmallow import fields, Schema
32 |
33 | from my_app import database
34 |
35 |
36 | rebar = Rebar()
37 |
38 | # All handler URL rules will be prefixed by '/v1'
39 | registry = rebar.create_handler_registry(prefix='/v1')
40 |
41 | class TodoSchema(Schema):
42 | id = fields.Integer()
43 | complete = fields.Boolean()
44 | description = fields.String()
45 |
46 | # This schema will validate the incoming request's query string
47 | class GetTodosQueryStringSchema(Schema):
48 | complete = fields.Boolean()
49 |
50 | # This schema will marshal the outgoing response
51 | class GetTodosResponseSchema(Schema):
52 | data = fields.Nested(TodoSchema, many=True)
53 |
54 |
55 | @registry.handles(
56 | rule='/todos',
57 | method='GET',
58 | query_string_schema=GetTodosQueryStringSchema(),
59 | response_body_schema=GetTodosResponseSchema(), # For version <= 1.7.0 use marshal_schema
60 | )
61 | def get_todos():
62 | """
63 | This docstring will be rendered as the operation's description in
64 | the auto-generated OpenAPI specification.
65 | """
66 | # The query string has already been validated by `query_string_schema`
67 | complete = rebar.validated_args.get('complete')
68 |
69 | ...
70 |
71 | # Errors are converted to appropriate HTTP errors
72 | raise errors.Forbidden()
73 |
74 | ...
75 |
76 | # The response will be marshaled by `marshal_schema`
77 | return {'data': []}
78 |
79 |
80 | def create_app(name):
81 | app = Flask(name)
82 | rebar.init_app(app)
83 | return app
84 |
85 |
86 | if __name__ == '__main__':
87 | create_app(__name__).run()
88 |
89 |
90 | .. toctree::
91 | :maxdepth: 2
92 | :caption: Guide:
93 |
94 | why
95 | quickstart/installation
96 | quickstart/basics
97 | quickstart/api_versioning
98 | quickstart/swagger_generation
99 | quickstart/authentication
100 | api_reference
101 | tutorials
102 | recipes
103 | contributing
104 | version_history
105 | changelog
106 |
--------------------------------------------------------------------------------
/CODEOFCONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies within all project spaces, and it also applies when
49 | an individual is representing the project or its community in public spaces.
50 | Examples of representing a project or community include using an official
51 | project e-mail address, posting via an official social media account, or acting
52 | as an appointed representative at an online or offline event. Representation of
53 | a project may be further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
78 |
--------------------------------------------------------------------------------
/tests/examples/test_todo.py:
--------------------------------------------------------------------------------
1 | """
2 | Test Todo
3 | ~~~~~~~~~
4 |
5 | Tests for the example todo application.
6 |
7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | import json
11 | import sys
12 |
13 | import unittest
14 |
15 |
16 | class TestTodoApp(unittest.TestCase):
17 | """
18 | Just some super basic tests to make sure our example app appears to still
19 | be working.
20 | """
21 |
22 | @classmethod
23 | def setUpClass(cls):
24 | cls.old_path = sys.path.copy()
25 | sys.path.insert(0, "examples/todo")
26 |
27 | @classmethod
28 | def tearDownClass(cls):
29 | sys.path = cls.old_path
30 |
31 | def setUp(self):
32 | from todo.app import create_app
33 |
34 | self.app = create_app()
35 |
36 | def test_swagger(self):
37 | resp = self.app.test_client().get("/swagger")
38 | self.assertEqual(resp.status_code, 200)
39 |
40 | def test_authentication(self):
41 | resp = self.app.test_client().get("/todos")
42 | self.assertEqual(resp.status_code, 401)
43 | resp = self.app.test_client().get(
44 | "/todos",
45 | headers={"X-MyApp-Key": "my-api-key"},
46 | data=json.dumps({"complete": False}),
47 | )
48 | self.assertEqual(resp.status_code, 200)
49 |
50 | def test_validation(self):
51 | resp = self.app.test_client().patch(
52 | "/todos/1",
53 | headers={"X-MyApp-Key": "my-api-key", "Content-Type": "application/json"},
54 | data=json.dumps({"complete": "not a boolean"}),
55 | )
56 | self.assertEqual(resp.status_code, 400)
57 |
58 | def test_crud(self):
59 | resp = self.app.test_client().post(
60 | "/todos",
61 | headers={"X-MyApp-Key": "my-api-key", "Content-Type": "application/json"},
62 | data=json.dumps(
63 | {"complete": False, "description": "Find product market fit"}
64 | ),
65 | )
66 | self.assertEqual(resp.status_code, 201)
67 |
68 | resp = self.app.test_client().patch(
69 | "/todos/1",
70 | headers={"X-MyApp-Key": "my-api-key", "Content-Type": "application/json"},
71 | data=json.dumps({"complete": True}),
72 | )
73 | self.assertEqual(resp.status_code, 200)
74 |
75 | resp = self.app.test_client().get(
76 | "/todos",
77 | headers={"X-MyApp-Key": "my-api-key", "Content-Type": "application/json"},
78 | )
79 | self.assertEqual(resp.status_code, 200)
80 | self.assertEqual(
81 | resp.json["data"][0],
82 | {
83 | "id": 1,
84 | "complete": True,
85 | "description": "Find product market fit",
86 | "type": "user",
87 | },
88 | )
89 |
90 | resp = self.app.test_client().get(
91 | "/todos/user",
92 | headers={"X-MyApp-Key": "my-api-key", "Content-Type": "application/json"},
93 | )
94 | self.assertEqual(resp.status_code, 200)
95 | self.assertEqual(
96 | resp.json["data"][0],
97 | {
98 | "id": 1,
99 | "complete": True,
100 | "description": "Find product market fit",
101 | "type": "user",
102 | },
103 | )
104 |
105 | resp = self.app.test_client().get(
106 | "/todos/group",
107 | headers={"X-MyApp-Key": "my-api-key", "Content-Type": "application/json"},
108 | )
109 | self.assertEqual(resp.status_code, 200)
110 | self.assertFalse(resp.json["data"])
111 |
--------------------------------------------------------------------------------
/examples/todo/todo/handlers/todo_handlers.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from todo.database import todo_id_sequence, todo_database
4 | from todo.app import rebar
5 | from todo.app import registry
6 | from todo.schemas import (
7 | CreateTodoSchema,
8 | GetTodoListSchema,
9 | TodoResourceSchema,
10 | TodoListSchema,
11 | UpdateTodoSchema,
12 | )
13 |
14 |
15 | @registry.handles(
16 | rule="/todos",
17 | method="POST",
18 | request_body_schema=CreateTodoSchema(),
19 | tags=["todo"],
20 | # This dictionary tells framer which schema to use for which response code.
21 | # This is a little ugly, but tremendously helpful for generating swagger.
22 | response_body_schema={
23 | 201: TodoResourceSchema()
24 | }, # for versions <= 1.7.0, use marshal_schema
25 | )
26 | def create_todo():
27 | global todo_id_sequence, todo_database
28 |
29 | # The body is eagerly validated with the `request_body_schema` provided in
30 | # the decorator. The resulting parameters are now available here:
31 | todo = rebar.validated_body
32 |
33 | todo_id_sequence += 1
34 |
35 | todo["id"] = todo_id_sequence
36 | todo_database[todo_id_sequence] = todo
37 |
38 | # The return value may be an object to encoded as JSON or a tuple where
39 | # the first item is the value to be encoded as JSON and the second is
40 | # the HTTP response code. In the case where no response code is included,
41 | # 200 is assumed.
42 | return todo, 201
43 |
44 |
45 | @registry.handles(
46 | rule="/todos",
47 | method="GET",
48 | query_string_schema=GetTodoListSchema(),
49 | tags=["todo"],
50 | # If the value for this is not a dictionary, the response code is assumed
51 | # to be 200
52 | response_body_schema=TodoListSchema(), # for versions <= 1.7.0, use marshal_schema
53 | )
54 | def get_todos():
55 | global todo_database
56 |
57 | # Just like validated_body, query string parameters are eagerly validated
58 | # and made available here. Flask-toolbox does treats a request body and
59 | # query string parameters as two separate sources, and currently does not
60 | # implement any abstraction on top of them.
61 | args = rebar.validated_args
62 |
63 | todos = todo_database.values()
64 |
65 | if "complete" in args:
66 | todos = [t for t in todos if t["complete"] == args["complete"]]
67 |
68 | return todos
69 |
70 |
71 | @registry.handles(
72 | rule="/todos/",
73 | method="GET",
74 | query_string_schema=GetTodoListSchema(),
75 | tags=["todo"],
76 | # If the value for this is not a dictionary, the response code is assumed
77 | # to be 200
78 | response_body_schema=TodoListSchema(), # for versions <= 1.7.0, use marshal_schema
79 | )
80 | def get_todos_by_type(todo_type):
81 | global todo_database
82 |
83 | # Just like validated_body, query string parameters are eagerly validated
84 | # and made available here. Flask-toolbox does treats a request body and
85 | # query string parameters as two separate sources, and currently does not
86 | # implement any abstraction on top of them.
87 | args = rebar.validated_args
88 |
89 | todos = todo_database.values()
90 |
91 | if "complete" in args:
92 | todos = [t for t in todos if t["complete"] == args["complete"]]
93 | todos = [t for t in todos if t["type"] == todo_type.name]
94 |
95 | return todos
96 |
97 |
98 | @registry.handles(
99 | rule="/todos/",
100 | method="PATCH",
101 | response_body_schema=TodoResourceSchema(), # for versions <= 1.7.0, use marshal_schema
102 | request_body_schema=UpdateTodoSchema(),
103 | tags=["todo"],
104 | )
105 | def update_todo(todo_id):
106 | global todo_database
107 |
108 | if todo_id not in todo_database:
109 | raise errors.NotFound()
110 |
111 | params = rebar.validated_body
112 | todo_database[todo_id].update(params)
113 | todo = todo_database[todo_id]
114 |
115 | return todo
116 |
--------------------------------------------------------------------------------
/tests/swagger_generation/registries/exploded_query_string.py:
--------------------------------------------------------------------------------
1 | import marshmallow
2 |
3 | from flask_rebar import Rebar
4 | from flask_rebar import RequestSchema
5 | from flask_rebar.validation import QueryParamList
6 | from flask_rebar.swagger_generation import SwaggerV2Generator, SwaggerV3Generator
7 |
8 | rebar = Rebar()
9 | registry = rebar.create_handler_registry()
10 |
11 | swagger_v2_generator = SwaggerV2Generator()
12 | swagger_v3_generator = SwaggerV3Generator()
13 |
14 |
15 | class ExplodedQueryStringSchema(RequestSchema):
16 | foos = QueryParamList(
17 | marshmallow.fields.String(),
18 | required=True,
19 | metadata={"description": "foo string"},
20 | )
21 |
22 |
23 | @registry.handles(
24 | rule="/foos",
25 | method="GET",
26 | query_string_schema=ExplodedQueryStringSchema(),
27 | summary="Foos",
28 | )
29 | def get_foos():
30 | pass
31 |
32 |
33 | EXPECTED_SWAGGER_V2 = {
34 | "swagger": "2.0",
35 | "host": "localhost",
36 | "consumes": ["application/json"],
37 | "produces": ["application/json"],
38 | "schemes": [],
39 | "securityDefinitions": {},
40 | "info": {"title": "My API", "version": "1.0.0", "description": ""},
41 | "definitions": {
42 | "Error": {
43 | "additionalProperties": False,
44 | "type": "object",
45 | "title": "Error",
46 | "properties": {"message": {"type": "string"}, "errors": {"type": "object"}},
47 | "required": ["message"],
48 | }
49 | },
50 | "paths": {
51 | "/foos": {
52 | "get": {
53 | "operationId": "get_foos",
54 | "responses": {
55 | "default": {
56 | "description": "Error",
57 | "schema": {"$ref": "#/definitions/Error"},
58 | }
59 | },
60 | "parameters": [
61 | {
62 | "name": "foos",
63 | "in": "query",
64 | "required": True,
65 | "collectionFormat": "multi",
66 | "type": "array",
67 | "items": {"type": "string"},
68 | "description": "foo string",
69 | }
70 | ],
71 | }
72 | }
73 | },
74 | }
75 |
76 | EXPECTED_SWAGGER_V3 = {
77 | "openapi": "3.1.0",
78 | "info": {"title": "My API", "version": "1.0.0", "description": ""},
79 | "components": {
80 | "schemas": {
81 | "Error": {
82 | "additionalProperties": False,
83 | "type": "object",
84 | "title": "Error",
85 | "properties": {
86 | "message": {"type": "string"},
87 | "errors": {"type": "object"},
88 | },
89 | "required": ["message"],
90 | }
91 | }
92 | },
93 | "paths": {
94 | "/foos": {
95 | "get": {
96 | "operationId": "get_foos",
97 | "summary": "Foos",
98 | "responses": {
99 | "default": {
100 | "description": "Error",
101 | "content": {
102 | "application/json": {
103 | "schema": {"$ref": "#/components/schemas/Error"}
104 | }
105 | },
106 | }
107 | },
108 | "parameters": [
109 | {
110 | "name": "foos",
111 | "in": "query",
112 | "required": True,
113 | "description": "foo string",
114 | "schema": {"type": "array", "items": {"type": "string"}},
115 | "explode": True,
116 | }
117 | ],
118 | }
119 | }
120 | },
121 | }
122 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_ui/templates/index.html.jinja2:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{page_title}}
7 |
8 |
9 |
10 |
11 |
30 |
31 |
32 |
33 |
34 |
67 |
68 |
69 |
70 |
71 |
72 |
94 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_generation/swagger_objects.py:
--------------------------------------------------------------------------------
1 | """
2 | Swagger Objects
3 | ~~~~~~~~~~~~~~~
4 |
5 | Python representations of select Swagger Objects.
6 |
7 | These are objects that aren't extractable from Flask or the Flask-Rebar handler registries
8 | and need to be manually set.
9 |
10 | :copyright: Copyright 2019 PlanGrid, Inc., see AUTHORS.
11 | :license: MIT, see LICENSE for details.
12 | """
13 | from typing import Any, Dict, List, Optional, Union
14 |
15 | from flask_rebar.swagger_generation import swagger_words as sw
16 |
17 |
18 | class ExternalDocumentation:
19 | """Represents a Swagger "External Documentation Object"
20 |
21 | :param str url: The URL for the target documentation. Value MUST be in the format of a URL
22 | :param str description: A short description of the target documentation
23 | """
24 |
25 | def __init__(self, url: str, description: Optional[str] = None) -> None:
26 | self.url = url
27 | self.description = description
28 |
29 | def as_swagger(self) -> Dict[str, str]:
30 | """Create a Swagger representation of this object
31 |
32 | :rtype: dict
33 | """
34 | doc = {sw.url: self.url}
35 | if self.description:
36 | doc[sw.description] = self.description
37 | return doc
38 |
39 |
40 | class Tag:
41 | """Represents a Swagger "Tag Object"
42 |
43 | :param str name: The name of the tag
44 | :param str description: A short description for the tag
45 | :param ExternalDocumentation external_docs: Additional external documentation for this tag
46 | """
47 |
48 | def __init__(
49 | self,
50 | name: str,
51 | description: Optional[str] = None,
52 | external_docs: Optional[ExternalDocumentation] = None,
53 | ) -> None:
54 | self.name = name
55 | self.description = description
56 | self.external_docs = external_docs
57 |
58 | def as_swagger(self) -> Dict[str, Union[str, Dict[str, str]]]:
59 | """Create a Swagger representation of this object
60 |
61 | :rtype: dict
62 | """
63 | doc: Dict[str, Union[str, Dict[str, str]]] = {sw.name: self.name}
64 | if self.description:
65 | doc[sw.description] = self.description
66 | if self.external_docs:
67 | doc[sw.external_docs] = self.external_docs.as_swagger()
68 | return doc
69 |
70 |
71 | class ServerVariable:
72 | """Represents a Swagger "Server Variable Object"
73 |
74 | :param str default:
75 | :param str description:
76 | :param list[str] enum:
77 | """
78 |
79 | def __init__(
80 | self,
81 | default: str,
82 | description: Optional[str] = None,
83 | enum: Optional[List[str]] = None,
84 | ) -> None:
85 | self.default = default
86 | self.description = description
87 | self.enum = enum
88 |
89 | def as_swagger(self) -> Dict[str, Union[str, List[str]]]:
90 | """Create a Swagger representation of this object
91 |
92 | :rtype: dict
93 | """
94 | doc: Dict[str, Union[str, List[str]]] = {sw.default: self.default}
95 | if self.description:
96 | doc[sw.description] = self.description
97 | if self.enum:
98 | doc[sw.enum] = self.enum
99 | return doc
100 |
101 |
102 | class Server:
103 | """Represents a Swagger "Server Object"
104 |
105 | :param str url:
106 | :param str description:
107 | :param dict[str, ServerVariable] variables:
108 | """
109 |
110 | def __init__(
111 | self,
112 | url: str,
113 | description: Optional[str] = None,
114 | variables: Optional[Dict[str, ServerVariable]] = None,
115 | ) -> None:
116 | self.url = url
117 | self.description = description
118 | self.variables = variables
119 |
120 | def as_swagger(self) -> Dict[str, Any]:
121 | """Create a Swagger representation of this object
122 |
123 | :rtype: dict
124 | """
125 | doc: Dict[str, Any] = {sw.url: self.url}
126 | if self.description:
127 | doc[sw.description] = self.description
128 | if self.variables:
129 | doc[sw.variables] = {k: v.as_swagger() for k, v in self.variables.items()}
130 | return doc
131 |
--------------------------------------------------------------------------------
/flask_rebar/errors.py:
--------------------------------------------------------------------------------
1 | """
2 | Errors
3 | ~~~~~~
4 |
5 | Exceptions that get transformed to HTTP error responses.
6 |
7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | from flask_rebar.messages import ErrorMessage
11 | from typing import Any, Dict, Optional, Union
12 |
13 |
14 | class HttpJsonError(Exception):
15 | """
16 | Abstract base class for exceptions that will be cause and transformed
17 | into an appropriate HTTP error response with a JSON body.
18 |
19 | These can be raised at any time during the handling of a request,
20 | and the Rebar extension will handling catching it and transforming it.
21 |
22 | This class itself shouldn't be used. Instead, use one of the subclasses.
23 |
24 | :param str msg:
25 | A human readable message to be included in the JSON error response
26 | :param dict additional_data:
27 | Dictionary of additional keys and values to be set in the JSON body.
28 | Note that these keys and values are added to the root object of the
29 | response, not nested under "additional_data".
30 | """
31 |
32 | default_message: str
33 | http_status_code: int
34 |
35 | def __init__(
36 | self,
37 | msg: Optional[Union[str, ErrorMessage]] = None,
38 | additional_data: Optional[Dict[str, Any]] = None,
39 | ) -> None:
40 | self.error_message = msg or self.default_message
41 | self.additional_data = additional_data
42 | super().__init__(self.error_message)
43 |
44 |
45 | class BadRequest(HttpJsonError):
46 | http_status_code, default_message = 400, "Bad Request"
47 |
48 |
49 | class Unauthorized(HttpJsonError):
50 | http_status_code, default_message = 401, "Unauthorized"
51 |
52 |
53 | class PaymentRequired(HttpJsonError):
54 | http_status_code, default_message = 402, "Payment Required"
55 |
56 |
57 | class Forbidden(HttpJsonError):
58 | http_status_code, default_message = 403, "Forbidden"
59 |
60 |
61 | class NotFound(HttpJsonError):
62 | http_status_code, default_message = 404, "Not Found"
63 |
64 |
65 | class MethodNotAllowed(HttpJsonError):
66 | http_status_code, default_message = 405, "Method Not Allowed"
67 |
68 |
69 | class NotAcceptable(HttpJsonError):
70 | http_status_code, default_message = 406, "Not Acceptable"
71 |
72 |
73 | class ProxyAuthenticationRequired(HttpJsonError):
74 | http_status_code, default_message = 407, "Proxy Authentication Required"
75 |
76 |
77 | class RequestTimeout(HttpJsonError):
78 | http_status_code, default_message = 408, "Request Timeout"
79 |
80 |
81 | class Conflict(HttpJsonError):
82 | http_status_code, default_message = 409, "Conflict"
83 |
84 |
85 | class Gone(HttpJsonError):
86 | http_status_code, default_message = 410, "Gone"
87 |
88 |
89 | class LengthRequired(HttpJsonError):
90 | http_status_code, default_message = 411, "Length Required"
91 |
92 |
93 | class PreconditionFailed(HttpJsonError):
94 | http_status_code, default_message = 412, "Precondition Failed"
95 |
96 |
97 | class RequestEntityTooLarge(HttpJsonError):
98 | http_status_code, default_message = 413, "Request Entity Too Large"
99 |
100 |
101 | class RequestUriTooLong(HttpJsonError):
102 | http_status_code, default_message = 414, "Request URI Too Long"
103 |
104 |
105 | class UnsupportedMediaType(HttpJsonError):
106 | http_status_code, default_message = 415, "Unsupported Media Type"
107 |
108 |
109 | class RequestedRangeNotSatisfiable(HttpJsonError):
110 | http_status_code, default_message = 416, "Requested Range Not Satisfiable"
111 |
112 |
113 | class ExpectationFailed(HttpJsonError):
114 | http_status_code, default_message = 417, "Expectation Failed"
115 |
116 |
117 | class UnprocessableEntity(HttpJsonError):
118 | http_status_code, default_message = 422, "Unprocessable Entity"
119 |
120 |
121 | class TooManyRequests(HttpJsonError):
122 | http_status_code, default_message = 429, "Too Many Requests"
123 |
124 |
125 | class InternalError(HttpJsonError):
126 | http_status_code, default_message = 500, "Internal Server Error"
127 |
128 |
129 | class NotImplemented(HttpJsonError):
130 | http_status_code, default_message = 501, "Not Implemented"
131 |
132 |
133 | class BadGateway(HttpJsonError):
134 | http_status_code, default_message = 502, "Bad Gateway"
135 |
136 |
137 | class ServiceUnavailable(HttpJsonError):
138 | http_status_code, default_message = 503, "Service Unavailable"
139 |
140 |
141 | class GatewayTimeout(HttpJsonError):
142 | http_status_code, default_message = 504, "Gateway Timeout"
143 |
--------------------------------------------------------------------------------
/docs/meeting_notes/roadmap_2020Jan29.rst:
--------------------------------------------------------------------------------
1 | V2.0 Roadmap Call 2020-Jan-29
2 | =============================
3 |
4 | Agenda and Notes
5 | ----------------
6 |
7 | * Plans for our next major version release, v2.0
8 |
9 | * Removing support for old versions of Python (notably 2.x)
10 |
11 | * PEP561 compliance (deferred)
12 |
13 | * Adding support for (or moving to?) Marshmallow 3
14 |
15 | * Expose swagger deprecated parameter
16 |
17 | * Hidden APIs
18 |
19 | * Marshmallow
20 |
21 | * Should we remove v2 completely?
22 |
23 | * We NEED to support 3. No reason to not support 2 as well. Removing 2.0 could prevent a lot of projects from upgrading rebar
24 |
25 | * We came to agreement over what should and shouldn’t be in v2.0
26 |
27 | * All v2.0 issues here: https://github.com/plangrid/flask-rebar/issues?q=is%3Aissue+is%3Aopen+label%3Av2.0
28 |
29 | * Items considered general future nice-to-have but can wait until after 2.0 release are tagged v2.1
30 |
31 | * Other "fix as needed/available" Issues remain with no v2.x tag (Editor's note - unless anyone has a pressing need for something in one of those I would guess they'll end up being more 2.2+ unless they just happen to get worked into ongoing changes naturally)
32 |
33 | * Go over open pull requests
34 |
35 | * Went over a few issues
36 |
37 | * Issue around us masking Flask responses
38 |
39 | * What if we want to send 304s and other responses that don’t return the resource?
40 |
41 | * Concerns around “Code first” vs “YAML first”
42 |
43 | * For people who would have to write XML etc., Using YAML is nice, but Python is much better at doing this. Python over YAML.
44 |
45 | * Flask-rebar is traditionally “Code-first”. We get the OpenAPI spec for free. However as time goes on we want more configurable control over the spec.
46 |
47 | * Issue #136 is an example of this: https://github.com/plangrid/flask-rebar/issues/136
48 |
49 | * Can we supply a backdoor that allows you to have more fine grained control of specs if you so choose to?
50 |
51 | * Using extensions of OpenAPI is hard right now in flask-rebar because it has to be code supported first.
52 |
53 | * This is hard since we want rebar to mash OpenAPI, Marshmallow and Flask all together.
54 |
55 | * Flask supports cookie authentication now, but rebar doesn’t
56 |
57 | * https://github.com/plangrid/flask-rebar/issues/131 lots of thread local vars.. would be nice to use objects and have them type annotated nicely.. maybe a nicer way to wrap flask api to do this more cleanly?
58 |
59 | * https://github.com/plangrid/flask-rebar/issues/91 - maybe overkill.. not a lot of demand for full on content negotiation, more just including support for other serialization format.. some question on how content negotiation fits in to openapi
60 |
61 | * https://github.com/plangrid/flask-rebar/issues/12 - Yet another example of an issue around building in finer-grained control over OpenAPI output
62 |
63 | * QOTD: "Just, please don't make me write yaml in docstrings" :D
64 |
65 | * Rough timeline to target v2.0 release?
66 |
67 | * Do we want to schedule a monthly review?
68 |
69 | * Decided to keep meetings ad hoc for now, coordinate more via Discord (see below)
70 |
71 | * Review contribution process, pain points etc.
72 |
73 | * Write-access controls are restrictive - not sure if/what we can do about this but should look into making this as open as possible without causing chaos (or violating any company policies). Would be nice if people could self-assign issues, create sub-projects, etc
74 |
75 | * We didn't really land on any kind of timeline yet - good fodder for ongoing discussion as we increase our capability for live collaboration vs communications via GitHub Issues ;)
76 |
77 | * Discussion around wants/needs – balancing ACS requirements (which tend not to drive Flask-Rebar improvements unless there’s a pressing business need) with the needs of the OSS community (keeping Flask-Rebar useful, current, and relevant)
78 |
79 | * We should add labels for documentation issues/PRs. So we did :)
80 |
81 | * Documentation is rough right now.
82 |
83 | * FastAPI is a great example of what we should be at.
84 |
85 | * Middleware capabilities? Can we just use WSGI for this?
86 |
87 | * Seems like yes?
88 |
89 | * Should we create some channel for this group and the software in general?
90 |
91 | * Flask has a discord. Let’s create a channel in it! (So we did: Join `Pallets Project Discord `_ and find us in the flask-rebar channel!)
92 |
93 | * Should we start using github projects?
94 |
95 | * Would help with prioritizing issues and figuring out how to move forward
96 |
97 | * We should try and arrange meetups at upcoming conferences (Flask conference?)
98 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Flask-Rebar
2 | ===========
3 |
4 | .. image:: https://readthedocs.org/projects/flask-rebar/badge/?version=latest
5 | :target: http://flask-rebar.readthedocs.io/en/latest/?badge=latest
6 | :alt: Documentation Status
7 |
8 | .. image:: https://github.com/plangrid/flask-rebar/actions/workflows/tag.yml/badge.svg
9 | :target: https://github.com/plangrid/flask-rebar/actions/workflows/tag.yml
10 | :alt: CI Status
11 |
12 | .. image:: https://badge.fury.io/py/flask-rebar.svg
13 | :target: https://badge.fury.io/py/flask-rebar
14 | :alt: PyPI status
15 |
16 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg
17 | :target: https://github.com/ambv/black
18 | :alt: Code style
19 |
20 | .. image:: https://img.shields.io/badge/Contributor%20Covenant-v1.4%20adopted-ff69b4.svg
21 | :target: https://www.contributor-covenant.org/
22 | :alt: Code of Conduct
23 |
24 | |
25 |
26 | Flask-Rebar combines `flask `_, `marshmallow `_, and `swagger `_ for robust REST services.
27 |
28 |
29 | Features
30 | --------
31 |
32 | * **Request and Response Validation** - Flask-Rebar relies on schemas from the popular Marshmallow package to validate incoming requests and marshal outgoing responses.
33 | * **Automatic Swagger Generation** - The same schemas used for validation and marshaling are used to automatically generate OpenAPI specifications (a.k.a. Swagger). This also means automatic documentation via `Swagger UI `_.
34 | * **Error Handling** - Uncaught exceptions from Flask-Rebar are converted to appropriate HTTP errors.
35 |
36 |
37 | Example
38 | -------
39 |
40 | .. code-block:: python
41 |
42 | from flask import Flask
43 | from flask_rebar import errors, Rebar
44 | from marshmallow import fields, Schema
45 |
46 | from my_app import database
47 |
48 |
49 | rebar = Rebar()
50 |
51 | # All handler URL rules will be prefixed by '/v1'
52 | registry = rebar.create_handler_registry(prefix='/v1')
53 |
54 | class TodoSchema(Schema):
55 | id = fields.Integer()
56 | complete = fields.Boolean()
57 | description = fields.String()
58 |
59 | # This schema will validate the incoming request's query string
60 | class GetTodosQueryStringSchema(Schema):
61 | complete = fields.Boolean()
62 |
63 | # This schema will marshal the outgoing response
64 | class GetTodosResponseSchema(Schema):
65 | data = fields.Nested(TodoSchema, many=True)
66 |
67 |
68 | @registry.handles(
69 | rule='/todos',
70 | method='GET',
71 | query_string_schema=GetTodosQueryStringSchema(),
72 | response_body_schema=GetTodosResponseSchema(), # for versions <= 1.7.0, use marshal_schema
73 | )
74 | def get_todos():
75 | """
76 | This docstring will be rendered as the operation's description in
77 | the auto-generated OpenAPI specification.
78 | """
79 | # The query string has already been validated by `query_string_schema`
80 | complete = rebar.validated_args.get('complete')
81 |
82 | ...
83 |
84 | # Errors are converted to appropriate HTTP errors
85 | raise errors.Forbidden()
86 |
87 | ...
88 |
89 | # The response will be marshaled by `marshal_schema`
90 | return {'data': []}
91 |
92 |
93 | def create_app(name):
94 | app = Flask(name)
95 | rebar.init_app(app)
96 | return app
97 |
98 |
99 | if __name__ == '__main__':
100 | create_app(__name__).run()
101 |
102 |
103 | For a more complete example, check out the example app at `examples/todo.py `_. Some example requests to this example app can be found at `examples/todo_output.md `_.
104 |
105 |
106 | Installation
107 | ------------
108 |
109 | .. code-block::
110 |
111 | pip install flask-rebar
112 |
113 |
114 | Replacing static swagger-ui files
115 | ---------------------------------
116 |
117 | If you'd like to replace swagger-ui's static files (`flask_rebar/swagger_ui/static`) with those of the latest release,
118 | run the following from the root of the project.
119 |
120 | .. code-block::
121 |
122 | curl -L https://api.github.com/repos/swagger-api/swagger-ui/tarball | tar -xv --directory=flask_rebar/swagger_ui/static --strip-components=2 "*/dist/"
123 |
124 | Documentation
125 | -------------
126 |
127 | More extensive documentation can be found `here `_.
128 |
129 |
130 | Extensions
131 | ----------
132 |
133 | Flask-Rebar is extensible! Here are some open source extensions:
134 |
135 | * `Flask-Rebar-Auth0 `_ - `Auth0 `_ authenticator for Flask-Rebar
136 |
137 |
138 | Contributing
139 | ------------
140 |
141 | There is still work to be done, and contributions are encouraged! Check out the `contribution guide `_ for more information.
142 |
--------------------------------------------------------------------------------
/docs/api_reference.rst:
--------------------------------------------------------------------------------
1 | API Reference
2 | =============
3 |
4 | This part of the documentation covers most of the interfaces for Flask-Rebar.
5 |
6 |
7 | Rebar Extension
8 | ---------------
9 |
10 | .. autoclass:: flask_rebar.Rebar
11 | :members:
12 |
13 |
14 | Handler Registry
15 | ----------------
16 |
17 | .. autoclass:: flask_rebar.HandlerRegistry
18 | :members:
19 |
20 |
21 | Authenticator Objects
22 | ---------------------
23 |
24 | .. autoclass:: flask_rebar.authenticators.Authenticator
25 | :members:
26 |
27 | .. autoclass:: flask_rebar.HeaderApiKeyAuthenticator
28 | :members:
29 |
30 |
31 | Base Generation
32 | ---------------
33 |
34 | .. autoclass:: flask_rebar.swagger_generation.swagger_generator_base.SwaggerGenerator
35 | :members:
36 |
37 | Swagger V2 Generation
38 | ---------------------
39 |
40 | .. autoclass:: flask_rebar.SwaggerV2Generator
41 | :members:
42 |
43 | .. autoclass:: flask_rebar.Tag
44 | :members:
45 |
46 | .. autoclass:: flask_rebar.ExternalDocumentation
47 | :members:
48 |
49 | .. autofunction:: flask_rebar.swagger_generation.sets_swagger_attr
50 |
51 | .. autoclass:: flask_rebar.swagger_generation.ConverterRegistry
52 | :members:
53 |
54 |
55 | Helpers
56 | -------
57 |
58 | .. autoclass:: flask_rebar.ResponseSchema
59 | .. autoclass:: flask_rebar.RequestSchema
60 | .. autofunction:: flask_rebar.get_validated_args
61 | .. autofunction:: flask_rebar.get_validated_body
62 | .. autofunction:: flask_rebar.marshal
63 | .. autofunction:: flask_rebar.response
64 |
65 |
66 | Exceptions
67 | ----------
68 |
69 | .. autoclass:: flask_rebar.errors.HttpJsonError
70 | :members:
71 |
72 | .. autoclass:: flask_rebar.errors.BadRequest
73 |
74 | .. autoattribute:: http_status_code
75 | .. autoattribute:: default_message
76 |
77 | .. autoclass:: flask_rebar.errors.Unauthorized
78 |
79 | .. autoattribute:: http_status_code
80 | .. autoattribute:: default_message
81 |
82 | .. autoclass:: flask_rebar.errors.PaymentRequired
83 |
84 | .. autoattribute:: http_status_code
85 | .. autoattribute:: default_message
86 |
87 | .. autoclass:: flask_rebar.errors.Forbidden
88 |
89 | .. autoattribute:: http_status_code
90 | .. autoattribute:: default_message
91 |
92 | .. autoclass:: flask_rebar.errors.NotFound
93 |
94 | .. autoattribute:: http_status_code
95 | .. autoattribute:: default_message
96 |
97 | .. autoclass:: flask_rebar.errors.MethodNotAllowed
98 |
99 | .. autoattribute:: http_status_code
100 | .. autoattribute:: default_message
101 |
102 | .. autoclass:: flask_rebar.errors.NotAcceptable
103 |
104 | .. autoattribute:: http_status_code
105 | .. autoattribute:: default_message
106 |
107 | .. autoclass:: flask_rebar.errors.ProxyAuthenticationRequired
108 |
109 | .. autoattribute:: http_status_code
110 | .. autoattribute:: default_message
111 |
112 | .. autoclass:: flask_rebar.errors.RequestTimeout
113 |
114 | .. autoattribute:: http_status_code
115 | .. autoattribute:: default_message
116 |
117 | .. autoclass:: flask_rebar.errors.Conflict
118 |
119 | .. autoattribute:: http_status_code
120 | .. autoattribute:: default_message
121 |
122 | .. autoclass:: flask_rebar.errors.Gone
123 |
124 | .. autoattribute:: http_status_code
125 | .. autoattribute:: default_message
126 |
127 | .. autoclass:: flask_rebar.errors.LengthRequired
128 |
129 | .. autoattribute:: http_status_code
130 | .. autoattribute:: default_message
131 |
132 | .. autoclass:: flask_rebar.errors.PreconditionFailed
133 |
134 | .. autoattribute:: http_status_code
135 | .. autoattribute:: default_message
136 |
137 | .. autoclass:: flask_rebar.errors.RequestEntityTooLarge
138 |
139 | .. autoattribute:: http_status_code
140 | .. autoattribute:: default_message
141 |
142 | .. autoclass:: flask_rebar.errors.RequestUriTooLong
143 |
144 | .. autoattribute:: http_status_code
145 | .. autoattribute:: default_message
146 |
147 | .. autoclass:: flask_rebar.errors.UnsupportedMediaType
148 |
149 | .. autoattribute:: http_status_code
150 | .. autoattribute:: default_message
151 |
152 | .. autoclass:: flask_rebar.errors.RequestedRangeNotSatisfiable
153 |
154 | .. autoattribute:: http_status_code
155 | .. autoattribute:: default_message
156 |
157 | .. autoclass:: flask_rebar.errors.ExpectationFailed
158 |
159 | .. autoattribute:: http_status_code
160 | .. autoattribute:: default_message
161 |
162 | .. autoclass:: flask_rebar.errors.UnprocessableEntity
163 |
164 | .. autoattribute:: http_status_code
165 | .. autoattribute:: default_message
166 |
167 | .. autoclass:: flask_rebar.errors.InternalError
168 |
169 | .. autoattribute:: http_status_code
170 | .. autoattribute:: default_message
171 |
172 | .. autoclass:: flask_rebar.errors.NotImplemented
173 |
174 | .. autoattribute:: http_status_code
175 | .. autoattribute:: default_message
176 |
177 | .. autoclass:: flask_rebar.errors.BadGateway
178 |
179 | .. autoattribute:: http_status_code
180 | .. autoattribute:: default_message
181 |
182 | .. autoclass:: flask_rebar.errors.ServiceUnavailable
183 |
184 | .. autoattribute:: http_status_code
185 | .. autoattribute:: default_message
186 |
187 | .. autoclass:: flask_rebar.errors.GatewayTimeout
188 |
189 | .. autoattribute:: http_status_code
190 | .. autoattribute:: default_message
191 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Configuration file for the Sphinx documentation builder.
4 | #
5 | # This file does only contain a selection of the most common options. For a
6 | # full list see the documentation:
7 | # http://www.sphinx-doc.org/en/stable/config
8 |
9 | # -- Path setup --------------------------------------------------------------
10 |
11 | # If extensions (or modules to document with autodoc) are in another directory,
12 | # add these directories to sys.path here. If the directory is relative to the
13 | # documentation root, use os.path.abspath to make it absolute, like shown here.
14 | #
15 | import os
16 | import sys
17 |
18 | sys.path.insert(0, os.path.abspath(".."))
19 |
20 |
21 | # -- Project information -----------------------------------------------------
22 |
23 | project = "Flask-Rebar"
24 | copyright = "2018-2019, PlanGrid"
25 | author = "Barak Alon, et al."
26 |
27 | # The short X.Y version
28 | version = ""
29 | # The full version, including alpha/beta/rc tags
30 | release = ""
31 |
32 |
33 | # -- General configuration ---------------------------------------------------
34 |
35 | # If your documentation needs a minimal Sphinx version, state it here.
36 | #
37 | # needs_sphinx = '1.0'
38 |
39 | # Add any Sphinx extension module names here, as strings. They can be
40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
41 | # ones.
42 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.autosectionlabel"]
43 |
44 | autodoc_member_order = "bysource"
45 |
46 |
47 | # Add any paths that contain templates here, relative to this directory.
48 | templates_path = ["_templates"]
49 |
50 | # The suffix(es) of source filenames.
51 | # You can specify multiple suffix as a list of string:
52 | #
53 | source_suffix = [".rst"]
54 |
55 | # The master toctree document.
56 | master_doc = "index"
57 |
58 | # The language for content autogenerated by Sphinx. Refer to documentation
59 | # for a list of supported languages.
60 | #
61 | # This is also used if you do content translation via gettext catalogs.
62 | # Usually you set "language" from the command line for these cases.
63 | language = None
64 |
65 | # List of patterns, relative to source directory, that match files and
66 | # directories to ignore when looking for source files.
67 | # This pattern also affects html_static_path and html_extra_path .
68 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
69 |
70 | # The name of the Pygments (syntax highlighting) style to use.
71 | pygments_style = "sphinx"
72 |
73 |
74 | # -- Options for HTML output -------------------------------------------------
75 |
76 | # The theme to use for HTML and HTML Help pages. See the documentation for
77 | # a list of builtin themes.
78 | #
79 | html_theme = "sphinx_rtd_theme"
80 |
81 | # Theme options are theme-specific and customize the look and feel of a theme
82 | # further. For a list of options available for each theme, see the
83 | # documentation.
84 | #
85 | # html_theme_options = {}
86 |
87 | # Add any paths that contain custom static files (such as style sheets) here,
88 | # relative to this directory. They are copied after the builtin static files,
89 | # so a file named "default.css" will overwrite the builtin "default.css".
90 | html_static_path = ["_static"]
91 |
92 | # Custom sidebar templates, must be a dictionary that maps document names
93 | # to template names.
94 | #
95 | # The default sidebars (for documents that don't match any pattern) are
96 | # defined by theme itself. Builtin themes are using these templates by
97 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
98 | # 'searchbox.html']``.
99 | #
100 | # html_sidebars = {}
101 |
102 |
103 | # -- Options for HTMLHelp output ---------------------------------------------
104 |
105 | # Output file base name for HTML help builder.
106 | htmlhelp_basename = "Flask-Rebardoc"
107 |
108 |
109 | # -- Options for LaTeX output ------------------------------------------------
110 |
111 | latex_elements = {
112 | # The paper size ('letterpaper' or 'a4paper').
113 | #
114 | # 'papersize': 'letterpaper',
115 | # The font size ('10pt', '11pt' or '12pt').
116 | #
117 | # 'pointsize': '10pt',
118 | # Additional stuff for the LaTeX preamble.
119 | #
120 | # 'preamble': '',
121 | # Latex figure (float) alignment
122 | #
123 | # 'figure_align': 'htbp',
124 | }
125 |
126 | # Grouping the document tree into LaTeX files. List of tuples
127 | # (source start file, target name, title,
128 | # author, documentclass [howto, manual, or own class]).
129 | latex_documents = [
130 | (
131 | master_doc,
132 | "Flask-Rebar.tex",
133 | "Flask-Rebar Documentation",
134 | "Barak Alon et al.",
135 | "manual",
136 | )
137 | ]
138 |
139 |
140 | # -- Options for manual page output ------------------------------------------
141 |
142 | # One entry per manual page. List of tuples
143 | # (source start file, name, description, authors, manual section).
144 | man_pages = [(master_doc, "flask-rebar", "Flask-Rebar Documentation", [author], 1)]
145 |
146 |
147 | # -- Options for Texinfo output ----------------------------------------------
148 |
149 | # Grouping the document tree into Texinfo files. List of tuples
150 | # (source start file, target name, title, author,
151 | # dir menu entry, description, category)
152 | texinfo_documents = [
153 | (
154 | master_doc,
155 | "Flask-Rebar",
156 | "Flask-Rebar Documentation",
157 | author,
158 | "Flask-Rebar",
159 | "One line description of project.",
160 | "Miscellaneous",
161 | )
162 | ]
163 |
164 |
165 | # -- Extension configuration -------------------------------------------------
166 |
--------------------------------------------------------------------------------
/flask_rebar/validation.py:
--------------------------------------------------------------------------------
1 | """
2 | Validation
3 | ~~~~~~~~~~
4 |
5 | Helpful extensions for Marshmallow objects.
6 |
7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | from collections import namedtuple
11 | from typing import Any, List, Optional, Mapping, Union
12 |
13 | from marshmallow import Schema
14 | from marshmallow import fields
15 | from werkzeug.datastructures import MultiDict
16 |
17 | FilterResult = namedtuple("FilterResult", "loadable, dump_only")
18 |
19 |
20 | def filter_dump_only(
21 | schema: Schema, data: Union[Mapping[str, Any], List[Mapping[str, Any]]]
22 | ) -> FilterResult:
23 | """
24 | Return a filtered copy of data in which any items matching a "dump_only" field are separated
25 | :param schema: Instance of a Schema class
26 | :param data: Mapping or List of Mappings with data
27 | :return: FilterResult
28 | """
29 | # Note as of marshmallow 3.13.0, Schema.dump_only is NOT populated if fields are declared as dump_only inline,
30 | # so we'll calculate "dump_only" ourselves. ref: https://github.com/marshmallow-code/marshmallow/issues/1857
31 | output_to_input_keys = {
32 | field.data_key: key for key, field in schema.fields.items() if field.data_key
33 | }
34 | dump_only_fields = schema.dump_fields.keys() - schema.load_fields.keys()
35 | if isinstance(data, Mapping):
36 | dump_only = dict()
37 | non_dump_only = dict()
38 | # get our dump_only fields directly, and candidates for loadable:
39 | for k, v in data.items():
40 | if output_to_input_keys.get(k, k) in dump_only_fields:
41 | dump_only[k] = v
42 | else:
43 | non_dump_only[k] = v
44 |
45 | # construct loadable (a subset of non_dump_only, with recursive filter of nested dump_only fields)
46 | loadable = dict()
47 | for k, v in non_dump_only.items():
48 | field = schema.fields[output_to_input_keys.get(k, k)]
49 | # see if we have a nested schema (using either Nested(many=True) or List(Nested())
50 | field_schema = None
51 | if isinstance(field, fields.Nested):
52 | field_schema = field.schema
53 | elif isinstance(field, fields.List) and isinstance(
54 | field.inner, fields.Nested
55 | ):
56 | field_schema = field.inner.schema
57 | if field_schema is None:
58 | loadable[k] = v
59 | else:
60 | field_filtered = filter_dump_only(field_schema, v)
61 | loadable[k] = field_filtered.loadable
62 | dump_only[k] = field_filtered.dump_only
63 | return FilterResult(loadable=loadable, dump_only=dump_only)
64 | elif isinstance(data, list):
65 | processed_items = [filter_dump_only(schema, item) for item in data]
66 | return FilterResult(
67 | [item.loadable for item in processed_items],
68 | [item.dump_only for item in processed_items],
69 | )
70 | elif data is None:
71 | return FilterResult(loadable=dict(), dump_only=dict())
72 | else:
73 | # I am not aware of any case where we should get something other than a Mapping or list, but just in case
74 | # we can raise a hopefully helpful error if there's some weird Schema that can cause that, so we know
75 | # we need to update this and patch rebar ;)
76 | raise TypeError(f"filter_dump_only doesn't understand data type {type(data)}")
77 |
78 |
79 | class CommaSeparatedList(fields.List):
80 | """
81 | A field class for Marshmallow; use this class when your list will be
82 | deserialized from a comma separated list of values.
83 | e.g. ?foo=bar,baz -> {'foo': ['bar', 'baz']}
84 | """
85 |
86 | def _deserialize(
87 | self,
88 | value: Any,
89 | attr: Optional[str],
90 | data: Optional[Mapping[str, Any]],
91 | **kwargs: Any,
92 | ) -> List[Any]:
93 | if not isinstance(value, list):
94 | value = value.split(",")
95 | return super()._deserialize(value, attr, data)
96 |
97 | def _serialize(
98 | self,
99 | value: List[Any],
100 | attr: Optional[str],
101 | obj: Mapping[str, Any],
102 | **kwargs: Any,
103 | ) -> Any:
104 | # this function clearly returns a str but the superclass fields.List returns the type
105 | # list[Any] | None which is not compatible. should we subclass a generic Field instead?
106 | items = super()._serialize(value, attr, obj)
107 | if items is None:
108 | return None
109 | return ",".join([str(i) for i in items])
110 |
111 |
112 | class QueryParamList(fields.List):
113 | """
114 | A field class for Marshmallow; use this class when your list will be
115 | deserialized from a query string containing the same param multiple
116 | times where each param is an item in the list.
117 | e.g. ?foo=bar&foo=baz -> {'foo': ['bar', 'baz']}
118 | """
119 |
120 | def _deserialize(
121 | self,
122 | value: Any,
123 | attr: Optional[str],
124 | data: Optional[Mapping[str, Any]],
125 | **kwargs: Any,
126 | ) -> List[Any]:
127 | # data is a MultiDict of query params, so pull out all of the items
128 | # with getlist instead of just the first
129 | if not isinstance(data, MultiDict):
130 | raise ValueError(
131 | "{} only deserializes {} instances".format(
132 | self.__class__.__name__, MultiDict
133 | )
134 | )
135 | items = data.getlist(attr)
136 | return super()._deserialize(items, attr, data)
137 |
138 |
139 | class RequireOnDumpMixin:
140 | """
141 | DEPRECATED AND MAY BE REMOVED IN VERSION 3.0
142 | In previous versions, this mixin was used to force validation on dump. As of 2.0.1, that
143 | validation is now fully encapsulated in compat.dump, with the presence of this mixin as one of
144 | the triggers.
145 | """
146 |
147 | pass
148 |
149 |
150 | RequestSchema = Schema
151 |
152 |
153 | class ResponseSchema(RequireOnDumpMixin, Schema):
154 | pass
155 |
156 |
157 | class Error(Schema):
158 | message = fields.String(required=True)
159 | errors = fields.Dict(required=False)
160 |
--------------------------------------------------------------------------------
/CONTRIBUTING.rst:
--------------------------------------------------------------------------------
1 | Contributing
2 | ============
3 |
4 | We're excited about new contributors and want to make it easy for you to help improve this project. If you run into problems, please open a GitHub issue.
5 |
6 | If you want to contribute a fix, or a minor change that doesn't change the API, go ahead and open a pull request; see details on pull requests below.
7 |
8 | If you're interested in making a larger change, we recommend you to open an issue for discussion first. That way we can ensure that the change is within the scope of this project before you put a lot of effort into it.
9 |
10 | Issues
11 | ------
12 | We use GitHub issues to track public bugs and feature requests. Please ensure your description is clear and, if reporting a bug, include sufficient instructions to be able to reproduce the issue.
13 |
14 | Our Commitment to You
15 | ----------------------------------
16 | Our commitment is to review new items promptly, within 3-5 business days as a general goal. Of course, this may vary with factors such as individual workloads, complexity of the issue or pull request, etc. Issues that have been reviewed will have a "triaged" label applied by the reviewer if they are to be kept open.
17 |
18 | If you feel that an issue or pull request may have fallen through the cracks, tag an admin in a comment to bring it to our attention. (You can start with @RookieRick, and/or look up who else has recently merged PRs).
19 |
20 | Process
21 | -------
22 | Flask-Rebar is developed both internally within Autodesk and via open-source contributions. To coordinate and avoid duplication of effort, we use two mechanisms:
23 |
24 | 1. We use the "triaged" label to mark issues as having been reviewed. Unless there are outstanding questions that need to be ironed out, you can assume that if an issue is marked as "triaged," we have generated an internal ticket, meaning someone will *eventually* address it. Timing of this will largely depend on whether there's a driving need within our own codebases that relies on Flask-Rebar.
25 | 2. Because internal ticketing is a black-box to our open source contributors, we will also make use of the "assignee" feature. If someone has picked up an internal ticket, there will be an assignee on the issue. If you see an open issue that doesn't have an assignee and that you would like to tackle please tag a maintainer in a comment requesting assignment, and/or open an early "WIP" pull request so we'll know the issue is already being worked, and can coordinate development efforts as needed.
26 |
27 | Support for Extra Libraries
28 | ---------------------------
29 | Flask-rebar is built to work with Flask and Marshmallow. We also seek to play nice with major "extensions" related to those core technologies by including optional features. Examples include `marshmallow-objects` and `marshmallow-enum`.
30 | If you are adding functionality that relies on any extensions meant to augment core versions of Flask or Marshmallow, there are three things that you as a developer are responsible for before submitting a Pull Request:
31 | 1. Ensure that you are NOT introducing any code changes that would make an extension a requirement. An end-user must be able to `pip install flask-rebar` and use all basic features without requiring any additional libraries.
32 | 2. Ensure that your extra requirements are broken out as a separate item within `extras_require` in `setup.py`.
33 | 3. Update the `pip install` instructions below to add your newly included "extras."
34 |
35 |
36 | Developing
37 | ----------
38 |
39 | We recommend using a `virtual environment `_ for development. Once within a virtual environment install the ``flask_rebar`` package:
40 |
41 | .. code-block:: bash
42 |
43 | pip install .[dev,enum]
44 |
45 | For `zsh` shell users, use
46 |
47 | .. code-block:: bash
48 |
49 | pip install '.[dev,enum]'
50 |
51 |
52 | We use `black` to format code and keep it all consistent within the repo. With that in mind, you'll also want to install the precommit hooks because your build will fail if your code isn't black:
53 |
54 | .. code-block:: bash
55 |
56 | pre-commit install
57 |
58 | To run the test suite with the current version of Python/virtual environment, use pytest:
59 |
60 | .. code-block:: bash
61 |
62 | pytest
63 |
64 | Flask-Rebar supports multiple versions of Python, Flask, and Marshmallow and uses Travis CI to run the test suite with different combinations of dependency versions. These tests are required before a PR is merged.
65 |
66 |
67 | Pull Requests
68 | -------------
69 |
70 | 1. Fork the repo and create your branch from ``master``.
71 | 2. If you've added code that should be tested, add tests.
72 | 3. If you've changed APIs, update the documentation.
73 | 4. Make sure you commit message matches something like `(chg|fix|new): COMMIT_MSG` so `gitchangelog` can correctly generate the entry for your commit.
74 |
75 | Meeting Notes
76 | -------------
77 | Links to notes from team meetings:
78 |
79 | :doc:`meeting_notes/roadmap_2020Jan29`
80 |
81 | Releasing to PyPI
82 | -----------------
83 |
84 | We use GitHub Actions to automate releasing package versions to PyPI.
85 |
86 | .. warning:: These steps must be completed by an administrator. We generally do at least patch releases fairly frequently, but if you have a feature that urgently requires release, feel free to reach out and request one and we'll do our best to accommodate.
87 |
88 |
89 | Flask-Rebar uses `semantic versions `_. Once you know the appropriate version part to bump, use the ``bumpversion`` tool which will bump the package version, add a commit, and tag the commit appropriately. Note, it's not a bad idea to do a manual inspection and any cleanup you deem necessary after running ``gitchangelog`` to ensure it looks good before then committing a "@cosmetic" update.
90 |
91 | .. note:: Before completing the following steps, you will need to temporarily change settings on GitHub under branch protection rules to NOT include administrators. This is required to allow you to push the changelog update.
92 |
93 | .. code-block:: bash
94 |
95 | git checkout master
96 | git pull # just to play it safe and make sure you're up to date
97 | bumpversion patch # or major or minor if applicable
98 | gitchangelog
99 | # STOP HERE: inspect CHANGELOG.rst and clean up as needed before continuing
100 | git commit -a -m "@cosmetic - changelog"
101 |
102 | Then push the new commits and tags:
103 |
104 | .. code-block:: bash
105 |
106 | git push && git push --tags
107 |
108 | Finally, while you're waiting for GitHub to pick up the tagged version, build it, and deploy it to PyPi, don't forget to reset branch protection settings (for normal operation, administrators should be subject to these restrictions to enforce PR code review requirements).
109 |
110 |
111 |
--------------------------------------------------------------------------------
/flask_rebar/utils/deprecation.py:
--------------------------------------------------------------------------------
1 | """
2 | General-Purpose Utilities
3 | ~~~~~~~~~~~~~~~~~~~~~~~~~
4 |
5 | Utilities that are not specific to request-handling (you'll find those in request_utils.py).
6 |
7 | :copyright: Copyright 2019 Autodesk, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | from __future__ import annotations
11 | import functools
12 | import sys
13 | import warnings
14 | from typing import Any, Callable, Dict, NamedTuple, Optional, Tuple, TypeVar, Union
15 |
16 | if sys.version_info >= (3, 10):
17 | from typing import ParamSpec
18 | else:
19 | from typing_extensions import ParamSpec
20 |
21 | from werkzeug.local import LocalProxy as module_property # noqa
22 |
23 |
24 | # ref http://jtushman.github.io/blog/2014/05/02/module-properties/ for background on
25 | # use of werkzeug LocalProxy to simulate "module properties"
26 | # end result: singleton config can be accessed by, e.g.,
27 | # from flask_rebar.utils.deprecation import config as deprecation_config
28 | # deprecation_config.warning_type = YourFavoriteWarning
29 |
30 |
31 | T = TypeVar("T")
32 | P = ParamSpec("P")
33 |
34 |
35 | class DeprecationConfig:
36 | """
37 | Singleton class to allow one-time set of deprecation config controls
38 | """
39 |
40 | __instance = None
41 |
42 | @staticmethod
43 | def getInstance() -> DeprecationConfig:
44 | """Static access method."""
45 | if DeprecationConfig.__instance is None:
46 | return DeprecationConfig()
47 | return DeprecationConfig.__instance
48 |
49 | def __init__(self) -> None:
50 | """Virtually private constructor."""
51 | if DeprecationConfig.__instance is not None:
52 | raise Exception("This class is a singleton!")
53 | else:
54 | DeprecationConfig.__instance = self
55 | self.warning_type = FutureWarning
56 |
57 |
58 | @module_property
59 | def config() -> DeprecationConfig:
60 | return DeprecationConfig.getInstance()
61 |
62 |
63 | def deprecated(
64 | new_func: Optional[Union[str, Tuple[str, str]]] = None,
65 | eol_version: Optional[str] = None,
66 | ) -> Callable:
67 | """
68 | :param Union[str, (str, str)] new_func: Name (or name and end-of-life version) of replacement
69 | :param str eol_version: Version in which this function may no longer work
70 | :return:
71 | Raise a deprecation warning for decorated function.
72 | Tuple is supported for new_func just in case somebody infers it as an option based on the way we
73 | deprecate params..
74 | If tuple form is used for new_func AND eol_version is provided, eol_version will trump whatever is
75 | found in the tuple; caveat emptor
76 | """
77 |
78 | def decorator(f: Callable[P, T]) -> Callable[P, T]:
79 | @functools.wraps(f)
80 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
81 | new, eol, _ = _validated_deprecation_spec(new_func)
82 | eol = eol_version or eol
83 | _deprecation_warning(f.__name__, new, eol, stacklevel=3)
84 | return f(*args, **kwargs)
85 |
86 | return wrapper
87 |
88 | return decorator
89 |
90 |
91 | def deprecated_parameters(**aliases: Any) -> Callable:
92 | """
93 | Adapted from https://stackoverflow.com/a/49802489/977046
94 | :param aliases: Keyword args in the form {old_param_name = Union[new_param_name, (new_param_name, eol_version),
95 | (new_param_name, eol_version, coerce_func)]}
96 | where eol_version is the version in which the alias may case to be recognized and coerce_func is a
97 | function used to coerce old values to new values.
98 | :return: function decorator that will apply aliases to param names and raise DeprecationWarning
99 | """
100 |
101 | def decorator(f: Callable[P, T]) -> Callable[P, T]:
102 | @functools.wraps(f)
103 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
104 | new_kwargs = _remap_kwargs(f.__name__, kwargs, aliases)
105 | return f(*args, **new_kwargs)
106 |
107 | return wrapper
108 |
109 | return decorator
110 |
111 |
112 | class DeprecationSpec(NamedTuple):
113 | new_name: Optional[str]
114 | eol_version: Optional[str]
115 | coerce_func: Optional[Callable]
116 |
117 |
118 | def _validated_deprecation_spec(
119 | spec: Optional[Union[str, Tuple[Any, ...]]]
120 | ) -> DeprecationSpec:
121 | """
122 | :param Union[new_name, (new_name, eol_version), (new_name, eol_version, coerce_func)] spec:
123 | new name and/or expected end-of-life version
124 | :return: (str new_name, str eol_version, func coerce_func),
125 | normalized to tuple and sanitized to deal with malformed inputs
126 | Parse a deprecation spec (string or tuple) to a standardized namedtuple form.
127 | If spec is provided as a bare value (presumably string), we'll treat as new name with no end-of-life version
128 | If spec is provided (likely on accident) as a 1-element tuple, we'll treat same as a bare value
129 | If spec is provided as a tuple with more than 3 elements, we'll simply ignore the extraneous
130 | """
131 | new_name = None
132 | eol_version = None
133 | coerce_func = None
134 | if type(spec) is tuple:
135 | if len(spec) > 0:
136 | new_name = str(spec[0]) if spec[0] else None
137 | if len(spec) > 1:
138 | eol_version = str(spec[1]) if spec[1] else None
139 | if len(spec) > 2:
140 | coerce_func = spec[2]
141 | elif spec:
142 | new_name = str(spec)
143 | validated = DeprecationSpec(new_name, eol_version, coerce_func)
144 | return validated
145 |
146 |
147 | def _remap_kwargs(
148 | func_name: str, kwargs: Dict[str, Any], aliases: Dict[str, str]
149 | ) -> Dict[str, Any]:
150 | """
151 | Adapted from https://stackoverflow.com/a/49802489/977046
152 | """
153 | remapped_args = dict(kwargs)
154 | for alias, new_spec in aliases.items():
155 | if alias in remapped_args:
156 | new, eol_version, coerce_func = _validated_deprecation_spec(new_spec)
157 | if new in remapped_args:
158 | raise TypeError(f"{func_name} received both {alias} and {new}")
159 | else:
160 | _deprecation_warning(alias, new, eol_version, stacklevel=4)
161 | if new:
162 | value = remapped_args.pop(alias)
163 | if coerce_func is not None:
164 | value = coerce_func(value)
165 | remapped_args[new] = value
166 | return remapped_args
167 |
168 |
169 | def _deprecation_warning(
170 | old_name: str,
171 | new_name: Optional[str],
172 | eol_version: Optional[str],
173 | stacklevel: int = 1,
174 | ) -> None:
175 | eol_clause = f" and may be removed in version {eol_version}" if eol_version else ""
176 | replacement_clause = f"; use {new_name}" if new_name else ""
177 | msg = f"{old_name} is deprecated{eol_clause}{replacement_clause}"
178 | warnings.warn(message=msg, category=config.warning_type, stacklevel=stacklevel) # type: ignore
179 |
--------------------------------------------------------------------------------
/examples/todo/todo_output.md:
--------------------------------------------------------------------------------
1 | # cURL and examples/todo.py
2 | Here's a snippet of playing with the application inside todo.py.
3 |
4 | Swagger for free!
5 | ```
6 | $ curl -s -XGET http://127.0.0.1:5000/swagger
7 | {
8 | "consumes": [
9 | "application/json"
10 | ],
11 | "definitions": {
12 | "CreateTodoSchema": {
13 | "properties": {
14 | "complete": {
15 | "type": "boolean"
16 | },
17 | "description": {
18 | "type": "string"
19 | }
20 | },
21 | "required": [
22 | "complete",
23 | "description"
24 | ],
25 | "title": "CreateTodoSchema",
26 | "type": "object"
27 | },
28 | "Error": {
29 | "properties": {
30 | "errors": {
31 | "type": "object"
32 | },
33 | "message": {
34 | "type": "string"
35 | }
36 | },
37 | "required": [
38 | "message"
39 | ],
40 | "title": "Error",
41 | "type": "object"
42 | },
43 | "TodoListSchema": {
44 | "properties": {
45 | "data": {
46 | "items": {
47 | "$ref": "#/definitions/TodoSchema"
48 | },
49 | "type": "array"
50 | }
51 | },
52 | "title": "TodoListSchema",
53 | "type": "object"
54 | },
55 | "TodoResourceSchema": {
56 | "properties": {
57 | "data": {
58 | "$ref": "#/definitions/TodoSchema"
59 | }
60 | },
61 | "title": "TodoResourceSchema",
62 | "type": "object"
63 | },
64 | "TodoSchema": {
65 | "properties": {
66 | "complete": {
67 | "type": "boolean"
68 | },
69 | "description": {
70 | "type": "string"
71 | },
72 | "id": {
73 | "type": "integer"
74 | }
75 | },
76 | "required": [
77 | "id",
78 | "complete",
79 | "description"
80 | ],
81 | "title": "TodoSchema",
82 | "type": "object"
83 | },
84 | "UpdateTodoSchema": {
85 | "properties": {
86 | "complete": {
87 | "type": "boolean"
88 | },
89 | "description": {
90 | "type": "string"
91 | }
92 | },
93 | "title": "UpdateTodoSchema",
94 | "type": "object"
95 | }
96 | },
97 | "host": "127.0.0.1:5000",
98 | "info": {
99 | "description": "",
100 | "title": "My API",
101 | "version": "1.0.0"
102 | },
103 | "paths": {
104 | "/todos": {
105 | "get": {
106 | "operationId": "get_todos",
107 | "parameters": [
108 | {
109 | "in": "query",
110 | "name": "complete",
111 | "required": false,
112 | "type": "boolean"
113 | }
114 | ],
115 | "responses": {
116 | "200": {
117 | "description": "TodoListSchema",
118 | "schema": {
119 | "$ref": "#/definitions/TodoListSchema"
120 | }
121 | },
122 | "default": {
123 | "description": "Error",
124 | "schema": {
125 | "$ref": "#/definitions/Error"
126 | }
127 | }
128 | },
129 | "tags": [
130 | "todo"
131 | ]
132 | },
133 | "post": {
134 | "operationId": "create_todo",
135 | "parameters": [
136 | {
137 | "in": "body",
138 | "name": "CreateTodoSchema",
139 | "required": true,
140 | "schema": {
141 | "$ref": "#/definitions/CreateTodoSchema"
142 | }
143 | }
144 | ],
145 | "responses": {
146 | "201": {
147 | "description": "TodoResourceSchema",
148 | "schema": {
149 | "$ref": "#/definitions/TodoResourceSchema"
150 | }
151 | },
152 | "default": {
153 | "description": "Error",
154 | "schema": {
155 | "$ref": "#/definitions/Error"
156 | }
157 | }
158 | },
159 | "tags": [
160 | "todo"
161 | ]
162 | }
163 | },
164 | "/todos/{todo_id}": {
165 | "parameters": [
166 | {
167 | "in": "path",
168 | "name": "todo_id",
169 | "required": true,
170 | "type": "integer"
171 | }
172 | ],
173 | "patch": {
174 | "operationId": "update_todo",
175 | "parameters": [
176 | {
177 | "in": "body",
178 | "name": "UpdateTodoSchema",
179 | "required": true,
180 | "schema": {
181 | "$ref": "#/definitions/UpdateTodoSchema"
182 | }
183 | }
184 | ],
185 | "responses": {
186 | "200": {
187 | "description": "TodoResourceSchema",
188 | "schema": {
189 | "$ref": "#/definitions/TodoResourceSchema"
190 | }
191 | },
192 | "default": {
193 | "description": "Error",
194 | "schema": {
195 | "$ref": "#/definitions/Error"
196 | }
197 | }
198 | },
199 | "tags": [
200 | "todo"
201 | ]
202 | }
203 | }
204 | },
205 | "produces": [
206 | "application/json"
207 | ],
208 | "schemes": [
209 | "http",
210 | "https"
211 | ],
212 | "security": [
213 | {
214 | "sharedSecret": []
215 | }
216 | ],
217 | "securityDefinitions": {
218 | "sharedSecret": {
219 | "in": "header",
220 | "name": "X-MyApp-Key",
221 | "type": "apiKey"
222 | }
223 | },
224 | "swagger": "2.0",
225 | "tags": [
226 | {
227 | "description": "All operations to managing the todo list portion of the API",
228 | "name": "todo"
229 | }
230 | ]
231 | }
232 | ```
233 |
234 | Request validation!
235 | ```
236 | $ curl -s -XPATCH http://127.0.0.1:5000/todos/1 -H "X-MyApp-Key: my-api-key" -H "Content-Type: application/json" -d '{"complete": "wrong type, for demonstration of validation"}'
237 | {
238 | "errors": {
239 | "complete": "Not a valid boolean."
240 | },
241 | "message": "JSON body parameters are invalid."
242 | }
243 | ```
244 |
245 | Authentication!
246 | ```
247 | $ curl -s -XGET http://127.0.0.1:5000/todos
248 | {
249 | "message": "No auth token provided."
250 | }
251 | $ curl -s -XGET http://127.0.0.1:5000/todos -H "X-MyApp-Key: my-api-key"
252 | {
253 | "data": []
254 | }
255 | ```
256 |
257 | CRUD!
258 | ```
259 | $ curl -s -XPOST http://127.0.0.1:5000/todos -H "X-MyApp-Key: my-api-key" -H "Content-Type: application/json" -d '{"complete": false, "description": "Find product market fit"}'
260 | {
261 | "data": {
262 | "complete": false,
263 | "description": "Find product market fit",
264 | "id": 1
265 | }
266 | }
267 | $ curl -s -XPATCH http://127.0.0.1:5000/todos/1 -H "X-MyApp-Key: my-api-key" -H "Content-Type: application/json" -d '{"complete": true}'
268 | {
269 | "data": {
270 | "complete": true,
271 | "description": "Find product market fit",
272 | "id": 1
273 | }
274 | }
275 | $ curl -s -XGET http://127.0.0.1:5000/todos -H "X-MyApp-Key: my-api-key"
276 | {
277 | "data": [
278 | {
279 | "complete": true,
280 | "description": "Find product market fit",
281 | "id": 1
282 | }
283 | ]
284 | }
285 | ```
286 |
--------------------------------------------------------------------------------
/docs/recipes.rst:
--------------------------------------------------------------------------------
1 | Recipes
2 | -------
3 |
4 | Class Based Views
5 | =================
6 |
7 | Some people prefer basing Flask view functions on classes rather than functions, and other REST frameworks for Flask base themselves on classes.
8 |
9 | First, an opinion: people often prefer classes simply because they are used to them. If you're looking for classes because functions make you uncomfortable, I encourage you to take a moment to reconsider your feelings. Embracing functions, `thread locals `_, and all of Flask's little quirks can feel oh so good.
10 |
11 | With that, there are perfectly valid use cases for class based views, like creating abstract views that can be inherited and customized. This is the main intent of Flask's built-in `pluggable views `_.
12 |
13 | Here is a simple recipe for using Flask-Rebar with these pluggable views:
14 |
15 |
16 | .. code-block:: python
17 |
18 | from flask import Flask
19 | from flask import request
20 | from flask.views import MethodView
21 | from flask_rebar import Rebar
22 |
23 |
24 | rebar = Rebar()
25 | registry = rebar.create_handler_registry()
26 |
27 |
28 | class AbstractResource(MethodView):
29 | def __init__(self, database):
30 | self.database = database
31 |
32 | def get_resource(self, id):
33 | raise NotImplemented
34 |
35 | def get(self, id):
36 | return self.get_resource(id)
37 |
38 | def put(self, id):
39 | resource = self.get_resource(id)
40 | resource.update(rebar.validated_body)
41 | return resource
42 |
43 |
44 | class Todo(AbstractResource):
45 | def get_resource(self, id):
46 | return get_todo(database, id)
47 |
48 |
49 | for method, request_body_schema in [
50 | ("get", None),
51 | ("put", UpdateTodoSchema()),
52 | ]:
53 | registry.add_handler(
54 | func=Todo.as_view(method + "_todo", database=database),
55 | rule="/todos/",
56 | response_body_schema=TodoSchema(), # for versions <= 1.7.0, use marshal_schema
57 | method=method,
58 | request_body_schema=request_body_schema,
59 | )
60 |
61 |
62 | This isn't a super slick, classed based interface for Flask-Rebar, but it *is* a way to use unadulterated Flask views to their full intent with minimal `DRY `_ violations.
63 |
64 |
65 | Combining Security/Authentication
66 | =================================
67 |
68 | Authentication is hard, and complicated. Flask-Rebar supports custom Authenticator classes so that you can make
69 | your authentication as complicated as your heart desires.
70 |
71 | Sometime though you want to combine security requirements.
72 | Maybe an endpoint should allow either an admin user or a user with an "edit" permission,
73 | maybe you want to allow requests to use Auth0 or an Api Key,
74 | maybe you want to only authenticate if it's Sunday and Jupiter is in retrograde?
75 |
76 | Here are some simple recipes for what Flask-Rebar currently supports:
77 |
78 |
79 | Allow a user with either scope "A" OR scope "B"
80 |
81 | .. code-block:: python
82 |
83 | from flask import g
84 | from my_app import authenticator, registry
85 | from my_app.scheme import EditStuffSchema, StuffSchema
86 |
87 |
88 | # Allow a user with the "admin" scope OR the "edit:stuff" scope
89 | @registry.handles(
90 | rule="/stuff//",
91 | method="POST",e
92 | request_body_schema=EditStuffSchema(),
93 | response_body_schema=StuffSchema(),
94 | authenticators=[authenticator.with_scope("admin"), authenticator.with_scope("edit:stuff")]
95 | )
96 | def edit_stuff(thing):
97 | update_stuff(thing, g.validated_body)
98 | return thing
99 |
100 |
101 | Allow a request with either valid Auth0 OR an API-Key
102 |
103 | .. code-block:: python
104 |
105 | from flask import g
106 | from flask_rebar.authenticators import HeaderApiKeyAuthenticator
107 | from flask_rebar_auth0 import get_authenticated_user
108 | from my_app import authenticator, registry
109 |
110 |
111 | # Allow Auth0 or API Key
112 | @registry.handles(
113 | rule="/rate_limit/",
114 | method="GET",
115 | response_body_schema=RateLimitSchema(),
116 | authenticators=[authenticator, HeaderApiKeyAuthenticator("X-API-KEY")]
117 | )
118 | def get_limits():
119 | requester = g.authenticated_app_name or get_authenticated_user()
120 | rate_limit = get_limits_for_app_or_user(requester)
121 | return rate_limit
122 |
123 |
124 | Allow a request with Auth0 AND an API-Key
125 |
126 | .. note::
127 | This currently requires some workarounds. Better support is planned.
128 |
129 | .. code-block:: python
130 |
131 | from flask_rebar.authenticators import HeaderApiKeyAuthenticator
132 | from flask_rebar_auth0 import Auth0Authenticator
133 | from flask_rebar.swagger_generation.authenticator_to_swagger import (
134 | AuthenticatorConverter, authenticator_converter_registry
135 | )
136 | from my_app import app
137 |
138 |
139 | class CombinedAuthenticator(Auth0Authenticator, HeaderApiKeyAuthenticator):
140 |
141 | def __init__(self, app, header):
142 | Auth0Authenticator.__init__(self, app)
143 | HeaderApiKeyAuthenticator.__init__(self, header)
144 |
145 | def authenticate(self):
146 | Auth0Authenticator.authenticate(self)
147 | HeaderApiKeyAuthenticator.authenticate(self)
148 |
149 |
150 | # You need to make sure that the converters already exist before trying to access them
151 | # Create mock/stub authenticators that are used only for lookup
152 | auth0_converter = authenticator_converter_registry._get_converter_for_type(Auth0Authenticator(app))
153 | header_api_converter = authenticator_converter_registry._get_converter_for_type(HeaderApiKeyAuthenticator("header"))
154 |
155 | class CombinedAuthenticatorConverter(AuthenticatorConverter):
156 |
157 | AUTHENTICATOR_TYPE = CombinedAuthenticator
158 |
159 | def get_security_schemes(self, obj, context):
160 | definition = dict()
161 | definition.update(auth0_converter.get_security_schemes(obj, context))
162 | definition.update(header_api_converter.get_security_schemes(obj, context))
163 | return definition
164 |
165 | def get_security_requirements(self, obj, context):
166 | auth_requirement = auth0_converter.get_security_requirements(obj, context)[0]
167 | header_requirement = header_api_converter.get_security_requirements(obj, context)[0]
168 | combined_requirement = dict()
169 | combined_requirement.update(auth_requirement)
170 | combined_requirement.update(header_requirement)
171 |
172 | return [
173 | combined_requirement
174 | ]
175 |
176 |
177 | authenticator_converter_registry.register_type(CombinedAuthenticatorConverter())
178 |
179 |
180 | @registry.handles(
181 | rule="/user/me/api_token",
182 | method="GET",
183 | authenticators=CombinedAuthenticator(app, "X-API-Key")
184 | )
185 | def check_token():
186 | return 200
187 |
188 |
189 | Marshmallow Partial Schemas
190 | ===========================
191 |
192 | Beginning with version 1.12, Flask-Rebar includes support for `Marshmallow "partial" loading `_ of schemas. This is particularly useful if you have a complicated schema with lots of required fields for creating an item (e.g., via a POST endpoint) and want to reuse the schema with some or all fields as optional for an update operation (e.g., via PATCH).
193 |
194 | While you can accomplish this by simply adding a ``partial`` keyword argument when instantiating an existing schema, to avoid confusion in the generated OpenAPI model, we strongly recommend creating a derived schema class as illustrated in the following example:
195 |
196 | .. code-block:: python
197 |
198 | class CreateTodoSchema(RequestSchema):
199 | complete = fields.Boolean(required=True)
200 | description = fields.String(required=True)
201 | created_by = fields.String(required=True)
202 |
203 |
204 | class UpdateTodoSchema(CreateTodoSchema):
205 | def __init__(self, **kwargs):
206 | super_kwargs = dict(kwargs)
207 | partial_arg = super_kwargs.pop('partial', True)
208 | super(UpdateTodoSchema, self).__init__(partial=partial_arg, **super_kwargs)
209 |
210 | The preceeding example makes `all` fields from ``CreateTodoSchema`` optional in the derived ``UpdateTodoSchema`` class by injecting ``partial=True`` as a keyword argument. Marshmallow also supports specifying only some fields as "partial" so if, for example, you wanted to use this approach but make only the ``description`` and ``created_by`` fields optional, you could use something like:
211 |
212 | .. code-block:: python
213 |
214 | class UpdateTodoSchema(CreateTodoSchema):
215 | def __init__(self, **kwargs):
216 | super_kwargs = dict(kwargs)
217 | partial_arg = super_kwargs.pop('partial', ['description', 'created_by'])
218 | super(UpdateTodoSchema, self).__init__(partial=partial_arg, **super_kwargs)
219 |
--------------------------------------------------------------------------------
/tests/swagger_generation/test_generator_utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Test Generator Utilities
3 | ~~~~~~~~~~~~~~~~~~~~~~~~
4 |
5 | Unit tests for the generator utilities.
6 |
7 | :copyright: Copyright 2019 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | import unittest
11 |
12 | from flask_rebar.swagger_generation.generator_utils import PathArgument
13 | from flask_rebar.swagger_generation.generator_utils import flatten
14 | from flask_rebar.swagger_generation.generator_utils import format_path_for_swagger
15 |
16 |
17 | class TestFlatten(unittest.TestCase):
18 | def setUp(self):
19 | super().setUp()
20 | self.maxDiff = None
21 |
22 | def test_flatten(self):
23 | input_ = {
24 | "type": "object",
25 | "title": "x",
26 | "properties": {
27 | "a": {
28 | "type": "object",
29 | "title": "y",
30 | "properties": {"b": {"type": "integer"}},
31 | },
32 | "b": {"type": "string"},
33 | },
34 | }
35 |
36 | expected_schema = {"$ref": "#/definitions/x"}
37 |
38 | expected_definitions = {
39 | "x": {
40 | "type": "object",
41 | "title": "x",
42 | "properties": {
43 | "a": {"$ref": "#/definitions/y"},
44 | "b": {"type": "string"},
45 | },
46 | },
47 | "y": {
48 | "type": "object",
49 | "title": "y",
50 | "properties": {"b": {"type": "integer"}},
51 | },
52 | }
53 |
54 | schema, definitions = flatten(input_, base="#/definitions")
55 | self.assertEqual(schema, expected_schema)
56 | self.assertEqual(definitions, expected_definitions)
57 |
58 | def test_flatten_array(self):
59 | input_ = {
60 | "type": "array",
61 | "title": "x",
62 | "items": {
63 | "type": "array",
64 | "title": "y",
65 | "items": {
66 | "type": "object",
67 | "title": "z",
68 | "properties": {"a": {"type": "integer"}},
69 | },
70 | },
71 | }
72 |
73 | expected_schema = {"$ref": "#/definitions/x"}
74 |
75 | expected_definitions = {
76 | "x": {"type": "array", "title": "x", "items": {"$ref": "#/definitions/y"}},
77 | "y": {"type": "array", "title": "y", "items": {"$ref": "#/definitions/z"}},
78 | "z": {
79 | "type": "object",
80 | "title": "z",
81 | "properties": {"a": {"type": "integer"}},
82 | },
83 | }
84 |
85 | schema, definitions = flatten(input_, base="#/definitions")
86 | self.assertEqual(schema, expected_schema)
87 | self.assertEqual(definitions, expected_definitions)
88 |
89 | def test_flatten_anyof_with_title(self):
90 | input_ = {
91 | "anyOf": [
92 | {
93 | "type": "object",
94 | "title": "a",
95 | "properties": {"a": {"type": "string"}},
96 | },
97 | {
98 | "type": "object",
99 | "title": "b",
100 | "properties": {"b": {"type": "string"}},
101 | },
102 | ],
103 | "title": "union",
104 | }
105 |
106 | expected_schema = {"$ref": "#/definitions/union"}
107 |
108 | expected_definitions = {
109 | "a": {
110 | "type": "object",
111 | "title": "a",
112 | "properties": {"a": {"type": "string"}},
113 | },
114 | "b": {
115 | "type": "object",
116 | "title": "b",
117 | "properties": {"b": {"type": "string"}},
118 | },
119 | "union": {
120 | "anyOf": [{"$ref": "#/definitions/a"}, {"$ref": "#/definitions/b"}],
121 | "title": "union",
122 | },
123 | }
124 |
125 | schema, definitions = flatten(input_, base="#/definitions")
126 | self.assertEqual(schema, expected_schema)
127 | self.assertEqual(definitions, expected_definitions)
128 |
129 | def test_flatten_subschemas(self):
130 | input_ = {
131 | "anyOf": [
132 | {"type": "null"},
133 | {
134 | "type": "object",
135 | "title": "a",
136 | "properties": {"a": {"type": "string"}},
137 | },
138 | {
139 | "type": "array",
140 | "title": "b",
141 | "items": {
142 | "type": "object",
143 | "title": "c",
144 | "properties": {"a": {"type": "string"}},
145 | },
146 | },
147 | {
148 | "anyOf": [
149 | {
150 | "type": "object",
151 | "title": "d",
152 | "properties": {"a": {"type": "string"}},
153 | }
154 | ]
155 | },
156 | ]
157 | }
158 |
159 | expected_schema = {
160 | "anyOf": [
161 | {"type": "null"},
162 | {"$ref": "#/definitions/a"},
163 | {"$ref": "#/definitions/b"},
164 | {"anyOf": [{"$ref": "#/definitions/d"}]},
165 | ]
166 | }
167 |
168 | expected_definitions = {
169 | "a": {
170 | "type": "object",
171 | "title": "a",
172 | "properties": {"a": {"type": "string"}},
173 | },
174 | "b": {"type": "array", "title": "b", "items": {"$ref": "#/definitions/c"}},
175 | "c": {
176 | "type": "object",
177 | "title": "c",
178 | "properties": {"a": {"type": "string"}},
179 | },
180 | "d": {
181 | "type": "object",
182 | "title": "d",
183 | "properties": {"a": {"type": "string"}},
184 | },
185 | }
186 |
187 | schema, definitions = flatten(input_, base="#/definitions")
188 | self.assertEqual(schema, expected_schema)
189 | self.assertEqual(definitions, expected_definitions)
190 |
191 | def test_flatten_creates_refs_when_type_is_list(self):
192 | self.maxDiff = None
193 | input_ = {
194 | "properties": {
195 | "data": {
196 | "items": {
197 | "properties": {"name": {"type": "string"}},
198 | "title": "NestedSchema",
199 | "type": "object",
200 | },
201 | "type": ["array", "null"],
202 | },
203 | },
204 | "title": "ParentAllowNoneTrueSchema",
205 | "type": "object",
206 | }
207 |
208 | expected_schema = {"$ref": "#/definitions/ParentAllowNoneTrueSchema"}
209 |
210 | expected_definitions = {
211 | "NestedSchema": {
212 | "properties": {"name": {"type": "string"}},
213 | "title": "NestedSchema",
214 | "type": "object",
215 | },
216 | "ParentAllowNoneTrueSchema": {
217 | "properties": {
218 | "data": {
219 | "items": {"$ref": "#/definitions/NestedSchema"},
220 | "type": ["array", "null"],
221 | }
222 | },
223 | "title": "ParentAllowNoneTrueSchema",
224 | "type": "object",
225 | },
226 | }
227 |
228 | schema, definitions = flatten(input_, base="#/definitions")
229 | self.assertEqual(schema, expected_schema)
230 | self.assertEqual(definitions, expected_definitions)
231 |
232 |
233 | class TestFormatPathForSwagger(unittest.TestCase):
234 | def test_format_path(self):
235 | res, args = format_path_for_swagger(
236 | "/projects//foos/"
237 | )
238 |
239 | self.assertEqual(res, "/projects/{project_uid}/foos/{foo_uid}")
240 |
241 | self.assertEqual(
242 | args,
243 | (
244 | PathArgument(name="project_uid", type="uuid"),
245 | PathArgument(name="foo_uid", type="string"),
246 | ),
247 | )
248 |
249 | def test_no_args(self):
250 | res, args = format_path_for_swagger("/health")
251 |
252 | self.assertEqual(res, "/health")
253 | self.assertEqual(args, tuple())
254 |
--------------------------------------------------------------------------------
/tests/test_deprecation_utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Test Generic Utilities
3 | ~~~~~~~~~~~~~~~~~~~~~~
4 |
5 | Tests for the generic (i.e., non-request) utilities.
6 |
7 | :copyright: Copyright 2019 Autodesk, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | import unittest
11 | import warnings
12 | from flask_rebar.utils.deprecation import deprecated, deprecated_parameters
13 | from flask_rebar.utils.deprecation import config as deprecation_config
14 |
15 |
16 | @deprecated_parameters(
17 | old_param1="new_param1", # rename with no predicted end-of-life version
18 | old_param2=("new_param2", "v99"), # rename with predicted end-of-life version
19 | old_param3=("new_param3",), # rename with a poorly formed tuple
20 | old_param4=("new_param4", None), # rename with explicitly None end-of-life version
21 | old_param5=(None, "v99.5"), # no rename with explicit end-of-life version
22 | old_param6=None, # deprecated param with no replacement, no specific end-of-life-version
23 | old_param7=(None, None), # same as 6, but for the truly pedantic
24 | old_param8=(), # could imagine someone accidentally doing this.. :P
25 | )
26 | def _add(
27 | new_param1=0,
28 | new_param2=0,
29 | new_param3=0,
30 | new_param4=0,
31 | old_param5=0,
32 | old_param6=0,
33 | old_param7=0,
34 | old_param8=0,
35 | ):
36 | return (
37 | new_param1
38 | + new_param2
39 | + new_param3
40 | + new_param4
41 | + old_param5
42 | + old_param6
43 | + old_param7
44 | + old_param8
45 | )
46 |
47 |
48 | @deprecated()
49 | def _deprecated_func1():
50 | return 1
51 |
52 |
53 | @deprecated("new_func2")
54 | def _deprecated_func2():
55 | return 2
56 |
57 |
58 | @deprecated(("new_func3", "99"))
59 | def _deprecated_func3():
60 | return 3
61 |
62 |
63 | class TestParameterDeprecation(unittest.TestCase):
64 | def test_parameter_deprecation_none(self):
65 | """Function with deprecated params, called with new (or no) names used does not warn"""
66 | with warnings.catch_warnings(record=True) as w:
67 | warnings.simplefilter("always")
68 | # test with unnamed args
69 | result = _add(1, 2)
70 | self.assertEqual(result, 3)
71 | self.assertEqual(len(w), 0)
72 | # test with "new" named args
73 | result = _add(new_param1=3, new_param2=5)
74 | self.assertEqual(result, 8)
75 | self.assertEqual(len(w), 0)
76 |
77 | def test_parameter_deprecation_warnings(self):
78 | """Function with deprecated param names warns (with expiration version if specified)"""
79 | # without version spec
80 | with warnings.catch_warnings(record=True) as w:
81 | warnings.simplefilter("always")
82 | result = _add(old_param1=1, new_param2=2)
83 | self.assertEqual(result, 3)
84 | self.assertEqual(len(w), 1)
85 | self.assertIn("old_param1", str(w[0].message))
86 | self.assertNotIn("new_param2", str(w[0].message))
87 | self.assertIs(w[0].category, FutureWarning)
88 |
89 | # with version spec
90 | with warnings.catch_warnings(record=True) as w:
91 | warnings.simplefilter("always")
92 | result = _add(new_param1=1, old_param2=2)
93 | self.assertEqual(result, 3)
94 | self.assertEqual(len(w), 1)
95 | self.assertNotIn("old_param1", str(w[0].message))
96 | self.assertIn("new_param2", str(w[0].message))
97 | self.assertIn("v99", str(w[0].message))
98 | self.assertIs(w[0].category, FutureWarning)
99 |
100 | # with both
101 | with warnings.catch_warnings(record=True) as w:
102 | warnings.simplefilter("always")
103 | result = _add(old_param1=1, old_param2=2)
104 | self.assertEqual(result, 3)
105 | self.assertEqual(len(w), 2)
106 | msg1 = str(w[0].message)
107 | msg2 = str(w[1].message)
108 | self.assertTrue(
109 | ("old_param1" in msg1 and "old_param2" in msg2)
110 | or ("old_param1" in msg2 and "old_param2" in msg1)
111 | )
112 | self.assertIs(w[0].category, FutureWarning)
113 |
114 | # with both (using poorly formed tuples)
115 | with warnings.catch_warnings(record=True) as w:
116 | warnings.simplefilter("always")
117 | result = _add(old_param3=3, old_param4=4)
118 | self.assertEqual(result, 7)
119 | self.assertEqual(len(w), 2)
120 | msg1 = str(w[0].message)
121 | msg2 = str(w[1].message)
122 | self.assertTrue(
123 | ("old_param3" in msg1 and "old_param4" in msg2)
124 | or ("old_param3" in msg2 and "old_param4" in msg1)
125 | )
126 | self.assertIs(w[0].category, FutureWarning)
127 |
128 | # with no replacement but specific expiration
129 | with warnings.catch_warnings(record=True) as w:
130 | warnings.simplefilter("always")
131 | result = _add(new_param1=1, old_param5=5)
132 | self.assertEqual(result, 6)
133 | self.assertEqual(len(w), 1)
134 | msg = str(w[0].message)
135 | self.assertIn("old_param5 is deprecated", msg)
136 | self.assertIn("v99.5", msg)
137 | self.assertNotIn("new_param", msg)
138 | self.assertIs(w[0].category, FutureWarning)
139 |
140 | # with no replacement (specified as None)
141 | with warnings.catch_warnings(record=True) as w:
142 | warnings.simplefilter("always")
143 | result = _add(new_param1=1, old_param5=5)
144 | self.assertEqual(result, 6)
145 | self.assertEqual(len(w), 1)
146 | msg = str(w[0].message)
147 | self.assertIn("old_param5 is deprecated", msg)
148 | self.assertIn("v99.5", msg)
149 | self.assertNotIn("new_param", msg)
150 | self.assertIs(w[0].category, FutureWarning)
151 |
152 | # with no replacement -- specified as explicit (None, None) and implicit ()
153 | with warnings.catch_warnings(record=True) as w:
154 | warnings.simplefilter("always")
155 | result = _add(old_param7=7, old_param8=8)
156 | self.assertEqual(result, 15)
157 | self.assertEqual(len(w), 2)
158 | msgs = {str(w[0].message), str(w[1].message)}
159 | expected_msgs = {"old_param7 is deprecated", "old_param8 is deprecated"}
160 | self.assertEqual(expected_msgs, msgs)
161 | self.assertIn("v99.5", msg)
162 | self.assertNotIn("new_param", msg)
163 |
164 | def test_parameter_deprecation_warning_type(self):
165 | """Deprecation supports specifying type of warning"""
166 | deprecation_config.warning_type = DeprecationWarning
167 | with warnings.catch_warnings(record=True) as w:
168 | warnings.simplefilter("always")
169 | result = _add(old_param1=50, new_param2=0)
170 | self.assertEqual(result, 50)
171 | self.assertEqual(len(w), 1)
172 | self.assertIs(w[0].category, DeprecationWarning)
173 | # reset (as deprecation_config is "global")
174 | deprecation_config.warning_type = FutureWarning
175 |
176 |
177 | class TestFunctionDeprecation(unittest.TestCase):
178 | def test_bare_deprecation(self):
179 | """Deprecate function with no specified alternative"""
180 | with warnings.catch_warnings(record=True) as w:
181 | warnings.simplefilter("always")
182 | result = _deprecated_func1()
183 | self.assertEqual(result, 1)
184 | self.assertEqual(len(w), 1)
185 | self.assertEqual(str(w[0].message), "_deprecated_func1 is deprecated")
186 |
187 | def test_versionless_replacement(self):
188 | """Deprecate function with specified alternative, no end-of-life version"""
189 | with warnings.catch_warnings(record=True) as w:
190 | warnings.simplefilter("always")
191 | result = _deprecated_func2()
192 | self.assertEqual(result, 2)
193 | self.assertEqual(len(w), 1)
194 | self.assertIn("_deprecated_func2 is deprecated", str(w[0].message))
195 | self.assertIn("use new_func2", str(w[0].message))
196 | self.assertNotIn("version", str(w[0].message))
197 |
198 | def test_versioned_replacement(self):
199 | """Deprecate function with specified alternative, specified end-of-life version"""
200 | with warnings.catch_warnings(record=True) as w:
201 | warnings.simplefilter("always")
202 | result = _deprecated_func3()
203 | self.assertEqual(result, 3)
204 | self.assertEqual(len(w), 1)
205 | self.assertIn("_deprecated_func3 is deprecated", str(w[0].message))
206 | self.assertIn("use new_func3", str(w[0].message))
207 | self.assertIn("version 99", str(w[0].message))
208 |
--------------------------------------------------------------------------------
/tests/swagger_generation/test_swagger_generator.py:
--------------------------------------------------------------------------------
1 | """
2 | Test Swagger Generation
3 | ~~~~~~~~~~~~~~~~~~~~~~~
4 |
5 | Tests for converting a handler registry to a Swagger specification.
6 |
7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | import json
11 |
12 | import marshmallow as m
13 | import pytest
14 |
15 | from flask_rebar.rebar import Rebar
16 | from flask_rebar.swagger_generation import ExternalDocumentation
17 | from flask_rebar.swagger_generation import SwaggerV2Generator
18 | from flask_rebar.swagger_generation import SwaggerV3Generator
19 | from flask_rebar.swagger_generation import Server
20 | from flask_rebar.swagger_generation import ServerVariable
21 | from flask_rebar.swagger_generation import Tag
22 | from flask_rebar.testing import validate_swagger
23 | from flask_rebar.testing.swagger_jsonschema import (
24 | SWAGGER_V2_JSONSCHEMA,
25 | SWAGGER_V3_JSONSCHEMA,
26 | )
27 |
28 | from tests.swagger_generation.registries import (
29 | legacy,
30 | exploded_query_string,
31 | marshmallow_objects,
32 | multiple_authenticators,
33 | )
34 |
35 |
36 | def _assert_dicts_equal(a, b):
37 | result = json.dumps(a, indent=2, sort_keys=True)
38 | expected = json.dumps(b, indent=2, sort_keys=True)
39 |
40 | assert result == expected
41 |
42 |
43 | def test_swagger_v2_generator_non_registry_parameters():
44 | host = "localhost"
45 | schemes = ["http"]
46 | consumes = ["application/json"]
47 | produces = ["application/json"]
48 | title = "Test API"
49 | version = "2.1.0"
50 | description = "Foo Bar Baz"
51 |
52 | class Error(m.Schema):
53 | message = m.fields.String()
54 | details = m.fields.Dict()
55 |
56 | generator = SwaggerV2Generator(
57 | host=host,
58 | schemes=schemes,
59 | consumes=consumes,
60 | produces=produces,
61 | title=title,
62 | version=version,
63 | description=description,
64 | default_response_schema=Error(),
65 | tags=[
66 | Tag(
67 | name="bar",
68 | description="baz",
69 | external_docs=ExternalDocumentation(
70 | url="http://bardocs.com", description="qux"
71 | ),
72 | )
73 | ],
74 | )
75 |
76 | rebar = Rebar()
77 | registry = rebar.create_handler_registry()
78 |
79 | swagger = generator.generate(registry)
80 |
81 | expected_swagger = {
82 | "swagger": "2.0",
83 | "host": host,
84 | "info": {"title": title, "version": version, "description": description},
85 | "schemes": schemes,
86 | "consumes": consumes,
87 | "produces": produces,
88 | "securityDefinitions": {},
89 | "tags": [
90 | {
91 | "name": "bar",
92 | "description": "baz",
93 | "externalDocs": {"url": "http://bardocs.com", "description": "qux"},
94 | }
95 | ],
96 | "paths": {},
97 | "definitions": {
98 | "Error": {
99 | "additionalProperties": False,
100 | "type": "object",
101 | "title": "Error",
102 | "properties": {
103 | "message": {"type": "string"},
104 | "details": {"type": "object"},
105 | },
106 | }
107 | },
108 | }
109 |
110 | validate_swagger(expected_swagger)
111 | _assert_dicts_equal(swagger, expected_swagger)
112 |
113 |
114 | def test_swagger_v3_generator_non_registry_parameters():
115 | title = "Test API"
116 | version = "3.1.0"
117 | description = "testing testing 123"
118 |
119 | class Error(m.Schema):
120 | message = m.fields.String()
121 | details = m.fields.Dict()
122 |
123 | generator = SwaggerV3Generator(
124 | version=version,
125 | title=title,
126 | description=description,
127 | default_response_schema=Error(),
128 | tags=[
129 | Tag(
130 | name="bar",
131 | description="baz",
132 | external_docs=ExternalDocumentation(
133 | url="http://bardocs.com", description="qux"
134 | ),
135 | )
136 | ],
137 | servers=[
138 | Server(
139 | url="https://{username}.gigantic-server.com:{port}/{basePath}",
140 | description="The production API server",
141 | variables={
142 | "username": ServerVariable(
143 | default="demo",
144 | description="this value is assigned by the service provider: `gigantic-server.com`",
145 | ),
146 | "port": ServerVariable(default="8443", enum=["8443", "443"]),
147 | "basePath": ServerVariable(default="v2"),
148 | },
149 | )
150 | ],
151 | )
152 |
153 | rebar = Rebar()
154 | registry = rebar.create_handler_registry()
155 |
156 | swagger = generator.generate(registry)
157 |
158 | expected_swagger = {
159 | "openapi": "3.1.0",
160 | "info": {"title": title, "version": version, "description": description},
161 | "tags": [
162 | {
163 | "name": "bar",
164 | "description": "baz",
165 | "externalDocs": {"url": "http://bardocs.com", "description": "qux"},
166 | }
167 | ],
168 | "servers": [
169 | {
170 | "url": "https://{username}.gigantic-server.com:{port}/{basePath}",
171 | "description": "The production API server",
172 | "variables": {
173 | "username": {
174 | "default": "demo",
175 | "description": "this value is assigned by the service provider: `gigantic-server.com`",
176 | },
177 | "port": {"enum": ["8443", "443"], "default": "8443"},
178 | "basePath": {"default": "v2"},
179 | },
180 | }
181 | ],
182 | "paths": {},
183 | "components": {
184 | "schemas": {
185 | "Error": {
186 | "additionalProperties": False,
187 | "type": "object",
188 | "title": "Error",
189 | "properties": {
190 | "message": {"type": "string"},
191 | "details": {"type": "object"},
192 | },
193 | }
194 | }
195 | },
196 | }
197 |
198 | validate_swagger(expected_swagger, SWAGGER_V3_JSONSCHEMA)
199 | _assert_dicts_equal(swagger, expected_swagger)
200 |
201 |
202 | @pytest.mark.parametrize("generator", [SwaggerV2Generator(), SwaggerV3Generator()])
203 | def test_path_parameter_types_must_be_the_same_for_same_path(generator):
204 | rebar = Rebar()
205 | registry = rebar.create_handler_registry()
206 |
207 | @registry.handles(rule="/foos/", method="GET")
208 | def get_foo(foo_uid):
209 | pass
210 |
211 | @registry.handles(rule="/foos/", method="PATCH")
212 | def update_foo(foo_uid):
213 | pass
214 |
215 | with pytest.raises(ValueError):
216 | generator.generate(registry)
217 |
218 |
219 | @pytest.mark.parametrize(
220 | "registry, swagger_generator, expected_swagger",
221 | [
222 | (legacy.registry, legacy.swagger_v2_generator, legacy.EXPECTED_SWAGGER_V2),
223 | (legacy.registry, legacy.swagger_v3_generator, legacy.EXPECTED_SWAGGER_V3),
224 | (
225 | exploded_query_string.registry,
226 | exploded_query_string.swagger_v2_generator,
227 | exploded_query_string.EXPECTED_SWAGGER_V2,
228 | ),
229 | (
230 | exploded_query_string.registry,
231 | exploded_query_string.swagger_v3_generator,
232 | exploded_query_string.EXPECTED_SWAGGER_V3,
233 | ),
234 | (
235 | multiple_authenticators.registry,
236 | multiple_authenticators.swagger_v2_generator,
237 | multiple_authenticators.EXPECTED_SWAGGER_V2,
238 | ),
239 | (
240 | multiple_authenticators.registry,
241 | multiple_authenticators.swagger_v3_generator,
242 | multiple_authenticators.EXPECTED_SWAGGER_V3,
243 | ),
244 | (
245 | marshmallow_objects.registry,
246 | marshmallow_objects.swagger_v2_generator,
247 | marshmallow_objects.EXPECTED_SWAGGER_V2,
248 | ),
249 | (
250 | marshmallow_objects.registry,
251 | marshmallow_objects.swagger_v3_generator,
252 | marshmallow_objects.EXPECTED_SWAGGER_V3,
253 | ),
254 | ],
255 | )
256 | def test_swagger_generators(registry, swagger_generator, expected_swagger):
257 | open_api_version = swagger_generator.get_open_api_version()
258 | if open_api_version == "2.0":
259 | swagger_jsonschema = SWAGGER_V2_JSONSCHEMA
260 | elif open_api_version == "3.1.0":
261 | swagger_jsonschema = SWAGGER_V3_JSONSCHEMA
262 | else:
263 | raise ValueError(f"Unknown swagger_version: {open_api_version}")
264 |
265 | validate_swagger(expected_swagger, schema=swagger_jsonschema)
266 |
267 | swagger = swagger_generator.generate(registry)
268 |
269 | result = json.dumps(swagger, indent=2, sort_keys=True)
270 | expected = json.dumps(expected_swagger, indent=2, sort_keys=True)
271 |
272 | assert result == expected
273 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_generation/swagger_generator_base.py:
--------------------------------------------------------------------------------
1 | """
2 | Swagger Generator
3 | ~~~~~~~~~~~~~~~~~
4 |
5 | Base classes for swagger generators.
6 |
7 | :copyright: Copyright 2019 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 |
11 | import abc
12 | import functools
13 | from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
14 |
15 | from marshmallow import Schema
16 |
17 | from flask_rebar.swagger_generation import swagger_words as sw
18 | from flask_rebar.swagger_generation.authenticator_to_swagger import (
19 | authenticator_converter_registry as global_authenticator_converter_registry,
20 | )
21 | from flask_rebar.swagger_generation.authenticator_to_swagger import (
22 | AuthenticatorConverter,
23 | AuthenticatorConverterRegistry,
24 | )
25 | from flask_rebar.swagger_generation.marshmallow_to_swagger import (
26 | headers_converter_registry as global_headers_converter_registry,
27 | )
28 | from flask_rebar.swagger_generation.marshmallow_to_swagger import (
29 | query_string_converter_registry as global_query_string_converter_registry,
30 | )
31 | from flask_rebar.swagger_generation.marshmallow_to_swagger import (
32 | request_body_converter_registry as global_request_body_converter_registry,
33 | )
34 | from flask_rebar.swagger_generation.marshmallow_to_swagger import (
35 | response_converter_registry as global_response_converter_registry,
36 | )
37 | from flask_rebar.swagger_generation.marshmallow_to_swagger import ConverterRegistry
38 | from flask_rebar.validation import Error
39 |
40 |
41 | # avoid circular imports
42 | if TYPE_CHECKING:
43 | from flask_rebar.rebar import HandlerRegistry
44 |
45 |
46 | class SwaggerGeneratorI(abc.ABC):
47 | @abc.abstractmethod
48 | def get_open_api_version(self) -> str:
49 | """
50 | Rebar supports multiple OpenAPI specifications.
51 | :return: The OpenAPI specification the generator supports.
52 | """
53 |
54 | @abc.abstractmethod
55 | def generate_swagger(
56 | self, registry: "HandlerRegistry", host: Optional[str] = None
57 | ) -> Dict[str, str]:
58 | """
59 | Generate a swagger definition json object.
60 | :param registry:
61 | :param host:
62 | :return:
63 | """
64 |
65 | @abc.abstractmethod
66 | def register_flask_converter_to_swagger_type(
67 | self, flask_converter: str, swagger_type: Any
68 | ) -> None:
69 | """
70 | Flask has "converters" that convert path arguments to a Python type.
71 |
72 | We need to map these to Swagger types. This allows additional flask
73 | converter types (they're pluggable!) to be mapped to Swagger types.
74 |
75 | Unknown Flask converters will default to string.
76 |
77 | :param str flask_converter:
78 | :param object swagger_type:
79 | """
80 |
81 |
82 | class SwaggerGenerator(SwaggerGeneratorI):
83 | """Base class for SwaggerV2Generator and SwaggerV3Generator.
84 |
85 | Inheritance is a fragile way to share code, but its a convenient one...
86 |
87 | :param int openapi_major_version: Major version of the Swagger specification this will produce
88 | :param str version: Version of the API this swagger is specifying
89 | :param str title: Title of the API this swagger is specifying
90 | :param str description: Descrption of the API this swagger is specifying
91 |
92 | :param ConverterRegistry query_string_converter_registry:
93 | :param ConverterRegistry request_body_converter_registry:
94 | :param ConverterRegistry headers_converter_registry:
95 | :param ConverterRegistry response_converter_registry:
96 | ConverterRegistry instances that will be used to convert Marshmallow schemas
97 | to the corresponding types of swagger objects. These default to the
98 | global registries.
99 |
100 | :param marshmallow.Schema default_response_schema: Schema to use as the default of all responses
101 | """
102 |
103 | _open_api_version: str
104 |
105 | def __init__(
106 | self,
107 | openapi_major_version: int,
108 | version: str = "1.0.0",
109 | title: str = "My API",
110 | description: str = "",
111 | query_string_converter_registry: Optional[ConverterRegistry] = None,
112 | request_body_converter_registry: Optional[ConverterRegistry] = None,
113 | headers_converter_registry: Optional[ConverterRegistry] = None,
114 | response_converter_registry: Optional[ConverterRegistry] = None,
115 | default_response_schema: Schema = Error(),
116 | authenticator_converter_registry: Optional[
117 | AuthenticatorConverterRegistry
118 | ] = None,
119 | include_hidden: bool = False,
120 | ):
121 | self.include_hidden = include_hidden
122 | self.title = title
123 | self.version = version
124 | self.description = description
125 | self._query_string_converter = self._create_converter(
126 | query_string_converter_registry,
127 | global_query_string_converter_registry,
128 | openapi_major_version,
129 | )
130 | self._request_body_converter = self._create_converter(
131 | request_body_converter_registry,
132 | global_request_body_converter_registry,
133 | openapi_major_version,
134 | )
135 | self._headers_converter = self._create_converter(
136 | headers_converter_registry,
137 | global_headers_converter_registry,
138 | openapi_major_version,
139 | )
140 | self._response_converter = self._create_converter(
141 | response_converter_registry,
142 | global_response_converter_registry,
143 | openapi_major_version,
144 | )
145 |
146 | self.flask_converters_to_swagger_types = {
147 | "uuid": sw.string,
148 | "uuid_string": sw.string,
149 | "string": sw.string,
150 | "path": sw.string,
151 | "int": sw.integer,
152 | "float": sw.number,
153 | }
154 |
155 | self.authenticator_converter = self._create_authenticator_converter(
156 | authenticator_converter_registry,
157 | global_authenticator_converter_registry,
158 | openapi_major_version,
159 | )
160 |
161 | self.default_response_schema = default_response_schema
162 |
163 | def _get_info(self) -> Dict[str, str]:
164 | return {
165 | sw.version: self.version,
166 | sw.title: self.title,
167 | sw.description: self.description,
168 | }
169 |
170 | def _create_converter(
171 | self,
172 | converter_registry: Optional[ConverterRegistry],
173 | default_registry: ConverterRegistry,
174 | openapi_major_version: int,
175 | ) -> Callable:
176 | return functools.partial(
177 | (converter_registry or default_registry).convert,
178 | openapi_version=openapi_major_version,
179 | )
180 |
181 | def _create_authenticator_converter(
182 | self,
183 | converter_registry: Optional[AuthenticatorConverterRegistry],
184 | default_registry: AuthenticatorConverterRegistry,
185 | openapi_major_version: int,
186 | ) -> AuthenticatorConverter:
187 | registry: Any = type("authenticator_converter_registry", (), {})
188 | registry.get_security_schemes = functools.partial(
189 | (converter_registry or default_registry).get_security_schemes,
190 | openapi_version=openapi_major_version,
191 | )
192 | registry.get_security_requirements = functools.partial(
193 | (converter_registry or default_registry).get_security_requirements,
194 | openapi_version=openapi_major_version,
195 | )
196 | return registry
197 |
198 | def get_open_api_version(self) -> str:
199 | return self._open_api_version
200 |
201 | def register_flask_converter_to_swagger_type(
202 | self, flask_converter: str, swagger_type: Any
203 | ) -> None:
204 | """
205 | Register a converter for a type in flask to a swagger type.
206 |
207 | This can be used to alter the types of objects that already exist or add
208 | swagger types to objects that are added to the base flask configuration.
209 |
210 | For example, when adding custom path types to the Flask url_map, a
211 | converter can be added for customizing the swagger type.
212 |
213 | .. code-block:: python
214 |
215 | import enum
216 |
217 | from flask_rebar.swagger_generation import swagger_words as sw
218 |
219 |
220 | class TodoType(str, enum.Enum):
221 | user = "user"
222 | group = "group"
223 |
224 |
225 | class TodoTypeConverter:
226 |
227 | @staticmethod
228 | def to_swagger():
229 | return {
230 | sw.type_: sw.string,
231 | sw.enum: [t.value for t in TodoType],
232 | }
233 |
234 | @registry.handles(
235 | rule="/todos/",
236 | )
237 | def get_todos_by_type(type_):
238 | ...
239 |
240 | generator.register_flask_converter_to_swagger_type(
241 | flask_converter="todo_type",
242 | swagger_type=TodoTypeConverter,
243 | )
244 |
245 | With the above example, when something is labeled as a ``todo_type`` in
246 | a path. The correct swagger can be returned.
247 | """
248 | self.flask_converters_to_swagger_types[flask_converter] = swagger_type
249 |
--------------------------------------------------------------------------------
/flask_rebar/swagger_generation/authenticator_to_swagger.py:
--------------------------------------------------------------------------------
1 | from collections import namedtuple
2 | from typing import Type
3 |
4 | from flask_rebar.authenticators import Authenticator, HeaderApiKeyAuthenticator
5 | from .marshmallow_to_swagger import UnregisteredType
6 | from . import swagger_words as sw
7 | from typing import Any, Callable, Dict, Iterable, List, Optional
8 |
9 |
10 | _Context = namedtuple(
11 | "_Context",
12 | [
13 | # The major version of OpenAPI being converter for
14 | "openapi_version"
15 | ],
16 | )
17 |
18 |
19 | class AuthenticatorConverter:
20 | """
21 | Abstract class for objects that convert Authenticator objects to
22 | security JSONSchema.
23 |
24 | When implementing your own AuthenticatorConverter you will need to:
25 |
26 | 1) Set AUTHENTICATOR_TYPE to be your custom Authenticator class.
27 |
28 | 2) Configure get_security_scheme to return a map of Security Scheme Objects.
29 | You should check for the openapi_version in context.openapi_version;
30 | If generation is requested for a version of the OpenAPI specification
31 | you do not intend to support, we recommend raising a NotImplementedError.
32 |
33 | 3) Configure get_security_requirements to return a list of Security Requirement Objects.
34 | You should check for the openapi_version in context.openapi_version;
35 | If generation is requested for a version of the OpenAPI specification
36 | you do not intend to support, we recommend raising a NotImplementedError.
37 |
38 | """
39 |
40 | AUTHENTICATOR_TYPE: Type[Authenticator]
41 |
42 | def get_security_schemes(
43 | self, obj: Authenticator, context: Optional[_Context] = None
44 | ) -> Dict[str, Any]:
45 | """
46 | Get the security schemes for the provided Authenticator object.
47 |
48 | OpenAPI Specification for defining security schemes
49 | 2.0: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#security-definitions-object
50 | 3.1.0: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#fixed-fields-6
51 | (see securitySchemes field)
52 |
53 | OpenAPI Specification for Security Scheme Object
54 | 2.0: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#security-scheme-object
55 | 3.1.0: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#security-scheme-object
56 |
57 |
58 | Example: An authenticator that makes use of an api_key and an application_key scheme
59 | {
60 | "api_key": {
61 | "type: "apiKey",
62 | "in": "header",
63 | "name": "X-API-Key"
64 | },
65 | "application_key" : {
66 | "type": "apiKey",
67 | "in": "query",
68 | "name": "application-key"
69 | }
70 | }
71 |
72 | Note: It is fine for multiple Authenticators to share Security Scheme definitions. Each Authenticator should
73 | return all scheme definitions that it makes use of.
74 |
75 | :param flask.authenticators.Authenticator obj: Authenticator instance to generate swagger for.
76 | You can assume this is of type AUTHENTICATOR_TYPE
77 | :param _Context context: The context swagger is being generated for.
78 | :rtype: dict: Key should be the name for the scheme, the value should be a Security Scheme Object
79 | """
80 | raise NotImplementedError()
81 |
82 | def get_security_requirements(
83 | self, obj: Authenticator, context: Optional[_Context] = None
84 | ) -> List[Any]:
85 | """
86 | Get the security requirements for the provided Authenticator object
87 |
88 | OpenAPI Specification for Security Requirement Object
89 | 2.0: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#security-requirement-object
90 | 3.1.0: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#security-requirement-object
91 |
92 | Example: Require oauth with scope "read:stuff" OR api_key AND application_key
93 | [
94 | {"oauth": ["read:stuff"] },
95 | {"api_key": [], "application_key": []}
96 | ]
97 |
98 | :param flask_rebar.authenticators.Authenticator obj:
99 | :param _Context context:
100 | :rtype: list
101 | """
102 | raise NotImplementedError()
103 |
104 |
105 | def make_class_from_method(
106 | authenticator_class: Type[Authenticator], func: Callable
107 | ) -> Type[AuthenticatorConverter]:
108 | """
109 | Utility to handle converting old-style method converters into new-style AuthenticatorConverters.
110 | """
111 | name = authenticator_class.__name__ + "Converter"
112 | meta = {
113 | "AUTHENTICATOR_TYPE": authenticator_class,
114 | "get_security_schemes": lambda self, obj, context: dict([func(obj)]),
115 | "get_security_requirements": lambda self, obj, context: [{func(obj)[0]: []}],
116 | }
117 | return type(name, (AuthenticatorConverter,), meta)
118 |
119 |
120 | class HeaderApiKeyConverter(AuthenticatorConverter):
121 | AUTHENTICATOR_TYPE = HeaderApiKeyAuthenticator
122 |
123 | def get_security_requirements(
124 | self, obj: Authenticator, context: Optional[_Context] = None
125 | ) -> List[Dict[str, List]]:
126 | """
127 | :param HeaderApiKeyAuthenticator obj:
128 | :param _Context context:
129 | :return: list
130 | """
131 | if not isinstance(obj, HeaderApiKeyAuthenticator):
132 | raise NotImplementedError("Only HeaderApiKeyAuthenticator is supported")
133 | return [{obj.name: []}]
134 |
135 | def get_security_schemes(
136 | self, obj: Authenticator, context: Optional[_Context] = None
137 | ) -> Dict[str, Dict[str, str]]:
138 | """
139 | :param HeaderApiKeyAuthenticator obj:
140 | :param _Context context:
141 | :return: dict
142 | """
143 | if not isinstance(obj, HeaderApiKeyAuthenticator):
144 | raise NotImplementedError("Only HeaderApiKeyAuthenticator is supported")
145 | return {
146 | obj.name: {sw.type_: sw.api_key, sw.in_: sw.header, sw.name: obj.header}
147 | }
148 |
149 |
150 | class AuthenticatorConverterRegistry:
151 | def __init__(self) -> None:
152 | self._type_map: Dict[Type[Authenticator], AuthenticatorConverter] = {}
153 |
154 | def _convert(self, obj: Authenticator, context: _Context) -> None:
155 | pass
156 |
157 | def convert(self, obj: Authenticator, openapi_version: int = 2) -> Dict[str, Any]:
158 | raise RuntimeWarning("Use get_security_schemes or get_security_requirements")
159 |
160 | def register_type(self, converter: AuthenticatorConverter) -> None:
161 | """
162 | Registers a converter.
163 |
164 | :param AuthenticatorConverter converter:
165 | """
166 | self._type_map[converter.AUTHENTICATOR_TYPE] = converter
167 |
168 | def register_types(self, converters: Iterable[AuthenticatorConverter]) -> None:
169 | """
170 | Registers multiple converters.
171 |
172 | :param iterable[AuthenticatorConverter] converters:
173 | """
174 | for converter in converters:
175 | self.register_type(converter)
176 |
177 | def _get_converter_for_type(self, obj: Authenticator) -> AuthenticatorConverter:
178 | """
179 | Locates the registered converter for a given type.
180 | :param obj: instance to convert
181 | :return: converter for type of instance
182 | """
183 | method_resolution_order = obj.__class__.__mro__
184 |
185 | for cls in method_resolution_order:
186 | if cls in self._type_map:
187 | return self._type_map[cls]
188 | else:
189 | raise UnregisteredType(
190 | "No registered type found in method resolution order: {mro}\n"
191 | "Registered types: {types}".format(
192 | mro=method_resolution_order, types=list(self._type_map.keys())
193 | )
194 | )
195 |
196 | def get_security_schemes(
197 | self, authenticator: Authenticator, openapi_version: int = 2
198 | ) -> Dict[str, Any]:
199 | """
200 | Get the security schemes for the provided Authenticator object
201 |
202 | :param flask.authenticators.Authenticator obj:
203 | :param int openapi_version: major version of OpenAPI to convert obj for
204 | :rtype: dict
205 | """
206 | # Remove this once legacy is gone
207 | if not isinstance(authenticator, Authenticator):
208 | return self.get_security_schemes_legacy(registry=authenticator)
209 | return self._get_converter_for_type(authenticator).get_security_schemes(
210 | authenticator, _Context(openapi_version=openapi_version)
211 | )
212 |
213 | def get_security_requirements(
214 | self, authenticator: Authenticator, openapi_version: int = 2
215 | ) -> List[Dict[str, Any]]:
216 | """
217 | Get the security requirements for the provided Authenticator object
218 |
219 | :param flask_rebar.authenticators.Authenticator obj:
220 | :param int openapi_version: major version of OpenAPI to convert obj for
221 | :rtype: list
222 | """
223 | return self._get_converter_for_type(authenticator).get_security_requirements(
224 | authenticator, _Context(openapi_version=openapi_version)
225 | )
226 |
227 |
228 | authenticator_converter_registry = AuthenticatorConverterRegistry()
229 | authenticator_converter_registry.register_types((HeaderApiKeyConverter(),))
230 |
--------------------------------------------------------------------------------
/flask_rebar/utils/request_utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Request Utilities
3 | ~~~~~~~~~~~~~~~~
4 |
5 | Utilities for request handlers.
6 |
7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS.
8 | :license: MIT, see LICENSE for details.
9 | """
10 | import collections
11 | import copy
12 | from typing import Any
13 | from typing import Dict
14 | from typing import Iterator
15 | from typing import List
16 | from typing import NoReturn
17 | from typing import Optional
18 | from typing import Type
19 | from typing import Union
20 | from typing import overload
21 |
22 | import marshmallow
23 | from marshmallow import Schema
24 | from flask import Response
25 | from flask import jsonify
26 | from flask import request
27 | from werkzeug.datastructures import Headers
28 | from werkzeug.exceptions import BadRequest as WerkzeugBadRequest
29 |
30 | from flask_rebar import compat
31 | from flask_rebar import errors
32 | from flask_rebar import messages
33 | from flask_rebar.utils.defaults import USE_DEFAULT
34 | from flask_rebar.utils.marshmallow_objects_helpers import get_marshmallow_objects_schema
35 |
36 |
37 | class HeadersProxy(collections.abc.Mapping):
38 | """
39 | Marshmallow expects objects being deserialized to be instances of `Mapping`.
40 |
41 | This wraps werkzeug's `EnvironHeaders` to ensure that they're an instance of `Mapping`.
42 |
43 | :param werkzeug.datastructures.EnvironHeaders headers:
44 | """
45 |
46 | __slots__ = ("headers",)
47 |
48 | def __init__(self, headers: Headers) -> None:
49 | self.headers = headers
50 |
51 | def __len__(self) -> int:
52 | return len(self.headers)
53 |
54 | def __iter__(self) -> Iterator[str]:
55 | # EnvironHeaders.__iter__ yields tuples of (key, value).
56 | # We want to mimic a dict and yield keys.
57 | return iter(self.headers.keys())
58 |
59 | def __contains__(self, item: Any) -> bool:
60 | return item in self.headers
61 |
62 | def __getitem__(self, key: Any) -> str:
63 | return self.headers[key]
64 |
65 |
66 | def response(
67 | data: Optional[Any],
68 | status_code: int = 200,
69 | headers: Optional[Headers] = None,
70 | mimetype: Optional[str] = None,
71 | ) -> Response:
72 | """
73 | Constructs a flask.jsonify response.
74 |
75 | :param dict data: The JSON body of the response
76 | :param int status_code: HTTP status code to use in the response
77 | :param dict headers: Additional headers to attach to the response
78 | :param str mimetype: Default Content-Type response header
79 | :rtype: flask.Response
80 | """
81 | resp = jsonify(data) if data is not None else Response()
82 |
83 | resp.status_code = status_code
84 |
85 | if mimetype:
86 | if headers is not None:
87 | headers.update({"Content-Type": mimetype})
88 | else:
89 | headers = Headers({"Content-Type": mimetype})
90 |
91 | if headers is not None:
92 | response_headers = dict(resp.headers)
93 | response_headers.update(headers)
94 | resp.headers = Headers(response_headers)
95 |
96 | return resp
97 |
98 |
99 | def marshal(data: Any, schema: Schema) -> Dict[str, Any]:
100 | """
101 | Dumps an object with the given marshmallow.Schema.
102 |
103 | :raises: marshmallow.ValidationError if the given data fails validation
104 | of the schema.
105 | """
106 | schema = normalize_schema(schema)
107 |
108 | return compat.dump(schema=schema, data=data)
109 |
110 |
111 | @overload
112 | def normalize_schema(schema: None) -> None:
113 | ...
114 |
115 |
116 | @overload
117 | def normalize_schema(schema: Type[USE_DEFAULT]) -> Type[USE_DEFAULT]:
118 | ...
119 |
120 |
121 | @overload
122 | def normalize_schema(schema: Union[Schema, Type[Schema]]) -> Schema:
123 | ...
124 |
125 |
126 | def normalize_schema(
127 | schema: Any,
128 | ) -> Union[Schema, Type[Schema], Type[USE_DEFAULT], None]:
129 | """
130 | This allows for either an instance of a marshmallow.Schema or the class
131 | itself to be passed to functions.
132 | For Marshmallow-objects support, if a Model class is passed, return its __schema__
133 |
134 | Possible types:
135 | - schema instance -> return itself
136 | - schema class -> return instance of schema class
137 | - marshmallow-objects Model class -> return schema class (is this right?)
138 | - marshmallow-objects Model instance -> return schema class (is this right?)
139 | - None -> return None
140 | - USE_DEFAULT -> return USE_DEFAULT
141 | """
142 | if schema not in (None, USE_DEFAULT) and not isinstance(schema, marshmallow.Schema):
143 | # See if we were handed a marshmallow_objects Model class or instance:
144 | mo_schema = get_marshmallow_objects_schema(schema)
145 | if mo_schema:
146 | model = schema
147 | schema = mo_schema
148 | # If __swagger_title__ is defined on the Model, propagate that down:
149 | if hasattr(model, "__swagger_title__"):
150 | schema.__swagger_title__ = model.__swagger_title__
151 | else:
152 | # assume we were passed a Schema class (not an instance)
153 | schema = schema()
154 | return schema
155 |
156 |
157 | def raise_400_for_marshmallow_errors(
158 | errs: Dict[str, Any], msg: Union[str, messages.ErrorMessage]
159 | ) -> NoReturn:
160 | """
161 | Throws a 400 error properly formatted from the given marshmallow errors.
162 |
163 | :param dict: Error dictionary as returned by marshmallow
164 | :param Union[str,messages.ErrorMessage] msg: The overall message to use in the response.
165 | :raises: errors.BadRequest
166 | """
167 | if not errs:
168 | raise errors.BadRequest(msg=msg)
169 |
170 | copied = copy.deepcopy(errs)
171 |
172 | _format_marshmallow_errors_for_response_in_place(copied)
173 |
174 | additional_data = {"errors": copied}
175 |
176 | raise errors.BadRequest(msg=msg, additional_data=additional_data)
177 |
178 |
179 | def get_json_body_params_or_400(schema: Schema) -> Dict[str, Any]:
180 | """
181 | Retrieves the JSON body of a request, validating/loading the payload
182 | with a given marshmallow.Schema.
183 |
184 | :param schema:
185 | :rtype: dict
186 | """
187 | body = _get_json_body_or_400()
188 |
189 | return _get_data_or_400(
190 | schema=schema, data=body, message=messages.body_validation_failed
191 | )
192 |
193 |
194 | def get_query_string_params_or_400(schema: Schema) -> Dict[str, Any]:
195 | """
196 | Retrieves the query string of a request, validating/loading the parameters
197 | with a given marshmallow.Schema.
198 |
199 | :param schema:
200 | :rtype: dict
201 | """
202 | # Use the request.args MultiDict in case a validator wants to
203 | # do something with several of the same query param (e.g. ?foo=1&foo=2), in
204 | # which case it will need the getlist method
205 | query_multidict = request.args.copy()
206 |
207 | return _get_data_or_400(
208 | schema=schema,
209 | data=query_multidict,
210 | message=messages.query_string_validation_failed,
211 | )
212 |
213 |
214 | def get_header_params_or_400(schema: Schema) -> Dict[str, Any]:
215 | schema = compat.exclude_unknown_fields(schema)
216 | return _get_data_or_400(
217 | schema=schema,
218 | data=HeadersProxy(request.headers),
219 | message=messages.header_validation_failed,
220 | )
221 |
222 |
223 | def _get_data_or_400(
224 | schema: Schema, data: Any, message: messages.ErrorMessage
225 | ) -> Dict[str, Any]:
226 | schema = normalize_schema(schema)
227 | try:
228 | return compat.load(schema=schema, data=data)
229 | except marshmallow.ValidationError as e:
230 | raise_400_for_marshmallow_errors(errs=e.messages_dict, msg=message)
231 |
232 |
233 | def _get_json_body_or_400() -> Union[List[Any], Dict[str, Any]]:
234 | """
235 | Retrieves the JSON payload of the current request, throwing a 400 error
236 | if the request doesn't include a valid JSON payload.
237 | """
238 | if "application/json" not in request.headers.get("content-type", ""):
239 | raise errors.BadRequest(messages.unsupported_content_type)
240 |
241 | if (not request.data) or (len(request.data) == 0):
242 | raise errors.BadRequest(messages.empty_json_body)
243 |
244 | try:
245 | body = request.get_json()
246 | except WerkzeugBadRequest:
247 | raise errors.BadRequest(messages.invalid_json)
248 |
249 | if not isinstance(body, list) and not isinstance(body, dict):
250 | # request.get_json_from_resp() treats strings as valid JSON, which is technically
251 | # true... but they're not valid objects. So let's throw an error on
252 | # primitive types.
253 | raise errors.BadRequest(messages.invalid_json)
254 |
255 | return body
256 |
257 |
258 | def _format_marshmallow_errors_for_response_in_place(errs: Dict[str, Any]) -> None:
259 | """
260 | Reformats an error dictionary returned by marshmallow to an error
261 | dictionary we can send in a response.
262 |
263 | This transformation happens in place, so make sure to pass in a copy
264 | of the errors...
265 | """
266 | # These are errors on the entire schema, not a specific field
267 | # Let's rename these too something slightly less cryptic
268 | if "_schema" in errs:
269 | errs["_general"] = errs.pop("_schema")
270 |
271 | for field, value in errs.items():
272 | # In most cases we'll only have a single error for a field,
273 | # but marshmallow gives us a list regardless.
274 | # Let's try to reduce the complexity of the error response and convert
275 | # these lists to a single string.
276 | if isinstance(value, list) and len(value) == 1:
277 | errs[field] = value[0]
278 | elif isinstance(value, dict):
279 | # Recurse! Down the rabbit hole...
280 | _format_marshmallow_errors_for_response_in_place(value)
281 |
--------------------------------------------------------------------------------