├── 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 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 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 | --------------------------------------------------------------------------------