├── .codecov.yml ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .project ├── .pydevproject ├── .readthedocs.yaml ├── CHANGELOG.rst ├── LICENSE ├── README.md ├── docs ├── Makefile ├── conf.py ├── index.rst ├── make.bat └── source │ ├── api_module.rst │ ├── api_reference.rst │ ├── decorators.rst │ ├── query_builder.rst │ └── resources.rst ├── environment.devenv.yml ├── news └── TEMPLATE ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── src └── flask_restalchemy │ ├── __init__.py │ ├── api.py │ ├── conftest.py │ ├── decorators │ ├── __init__.py │ └── request_hooks.py │ ├── resources │ ├── __init__.py │ ├── querybuilder.py │ └── resources.py │ ├── serialization.py │ └── tests │ ├── __init__.py │ ├── employer_serializer.py │ ├── sample_model.py │ ├── test_api.py │ ├── test_api │ ├── test_get.yml │ └── test_get_collection.yml │ ├── test_api_processors.py │ ├── test_api_relations.py │ ├── test_api_relations │ ├── test_get_collection.yml │ ├── test_get_item.yml │ └── test_property.yml │ ├── test_blueprint.py │ ├── test_child_resource.py │ ├── test_decorators.py │ ├── test_query_callback.py │ ├── test_query_callback │ ├── test_get_collection_model.yml │ ├── test_get_collection_property.yml │ ├── test_get_collection_relation_protoss.yml │ └── test_get_collection_relation_terrans.yml │ ├── test_querybuilder.py │ ├── test_simple_api.py │ └── test_swagger_spec_gen.py └── tox.ini /.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: yes 4 | 5 | coverage: 6 | precision: 2 7 | round: down 8 | range: "80...100" 9 | 10 | status: 11 | project: yes 12 | patch: yes 13 | changes: no 14 | 15 | parsers: 16 | gcov: 17 | branch_detection: 18 | conditional: yes 19 | loop: yes 20 | method: no 21 | macro: no 22 | 23 | comment: 24 | layout: "header, diff" 25 | behavior: default 26 | require_changes: no 27 | 28 | ignore: 29 | - "setup.py" 30 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - rb-* 8 | tags: 9 | - v* 10 | 11 | pull_request: 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ${{ matrix.os }} 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | python: ["3.6", "3.7", "3.8", "3.9", "3.10"] 22 | os: [ubuntu-20.04, windows-latest] 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Set up Python 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: ${{ matrix.python }} 30 | - name: Install tox 31 | run: | 32 | python -m pip install --upgrade pip 33 | python -m pip install tox tox-gh-actions 34 | - name: Test with tox 35 | run: tox 36 | - name: Upload codecov 37 | uses: codecov/codecov-action@v3 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | fail_ci_if_error: true 41 | 42 | deploy: 43 | if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') && github.repository == 'ESSS/flask-restalchemy' 44 | 45 | runs-on: ubuntu-20.04 46 | 47 | needs: build 48 | 49 | steps: 50 | - uses: actions/checkout@v2 51 | 52 | - name: Set up Python 53 | uses: actions/setup-python@v2 54 | with: 55 | python-version: "3.6" 56 | 57 | - name: Build Package 58 | run: | 59 | python -m pip install --upgrade pip wheel setuptools 60 | python setup.py sdist bdist_wheel 61 | - name: Publish package to PyPI 62 | uses: pypa/gh-action-pypi-publish@release/v1 63 | with: 64 | user: __token__ 65 | password: ${{ secrets.pypi_token }} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | *.py[cod] 3 | __pycache__/ 4 | .*cache 5 | *.egg-info 6 | *.eggs 7 | docs/_build/ 8 | .~* 9 | *$py.class 10 | build/ 11 | dist/ 12 | rever/ 13 | 14 | # Project settings 15 | .idea/ 16 | 17 | # env generated files 18 | environment.yml 19 | venv 20 | .tox/ 21 | .coverage 22 | coverage.xml 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 19.3b0 4 | hooks: 5 | - id: black 6 | args: [--safe, --quiet] 7 | language_version: python3 8 | additional_dependencies: [click==8.0.2] 9 | - repo: https://github.com/asottile/blacken-docs 10 | rev: v1.0.0 11 | hooks: 12 | - id: blacken-docs 13 | additional_dependencies: [black==19.3b0] 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v1.3.0 16 | hooks: 17 | - id: trailing-whitespace 18 | - id: end-of-file-fixer 19 | - id: debug-statements 20 | - repo: local 21 | hooks: 22 | - id: rst 23 | name: rst 24 | entry: rst-lint --encoding utf-8 25 | files: ^(CHANGELOG.rst|HOWTORELEASE.rst|README.rst)$ 26 | language: python 27 | additional_dependencies: [pygments, restructuredtext_lint] 28 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | flask-restalchemy 4 | 5 | 6 | 7 | 8 | 9 | org.python.pydev.PyDevBuilder 10 | 11 | 12 | 13 | 14 | 15 | org.python.pydev.pythonNature 16 | 17 | 18 | -------------------------------------------------------------------------------- /.pydevproject: -------------------------------------------------------------------------------- 1 | 2 | 3 | Default 4 | python 3.6 5 | 6 | /${PROJECT_DIR_NAME} 7 | 8 | 9 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | 13 | 14 | # Build documentation in the "docs/" directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ============================ 2 | Flask-RESTAlchemy Change Log 3 | ============================ 4 | 5 | .. current developments 6 | 7 | v0.14.1 8 | ==================== 9 | 10 | **Fixed:** 11 | 12 | * Fix license placement on setup.py 13 | 14 | v0.14.0 15 | ==================== 16 | 17 | **Added:** 18 | 19 | * Added api decorator 'route' to register a url rule 20 | 21 | 22 | v0.13.0 23 | ==================== 24 | 25 | **Changed:** 26 | 27 | * Removed flask-restful dependency 28 | 29 | **Added:** 30 | 31 | * Added support for request decorators 32 | * Added new field NestedModelListField 33 | * Added support for free functions rules 34 | * Added a way to specify the http methods available on an endpoint 35 | 36 | **Fixed:** 37 | 38 | * Fix bug that was removing target objects when undoing a relationship 39 | * Fix bug that was causing the session not be passed to the serializers 40 | 41 | v0.12.1 42 | ==================== 43 | 44 | 45 | 46 | v0.12.0 47 | ==================== 48 | 49 | **Changed:** 50 | 51 | Change the way Column Serializers are registered: by using Api class method 52 | `register_column_serializer` (instead of ModelSerializer.DEFAULT_SERIALIZER class attribute). 53 | 54 | 55 | 56 | v0.11.1 57 | ==================== 58 | 59 | **Fixed:** 60 | 61 | When adding the same property twice with different url an error occurred due to the endpoint provided to the RESTFul be 62 | the same. Now add_property have an optional parameter 'endpoint_name' to enable specify a different endpoint and 63 | defaults to the provided 'url_rule' 64 | 65 | v0.11.0 66 | ==================== 67 | 68 | **Changed:** 69 | 70 | * NestedAttributesField parameters must now be typed 71 | 72 | **Fixed:** 73 | 74 | * Swagger spec generation 75 | 76 | v0.10.4 77 | ==================== 78 | 79 | **Added:** 80 | 81 | * Support for append an existent item to a relation end point 82 | 83 | 84 | v0.10.3 85 | ==================== 86 | 87 | v0.10.2 88 | ==================== 89 | 90 | v0.10.1 91 | ==================== 92 | 93 | **Added:** 94 | 95 | * Support for pagination and query on property end points 96 | 97 | v0.10.0 98 | ==================== 99 | 100 | **Added:** 101 | 102 | * add pre/post commit hooks to put 103 | 104 | v0.9.2 105 | ==================== 106 | 107 | **Added:** 108 | 109 | * Support for serialize Enum columns 110 | * Support for pagination on relationships 111 | 112 | 113 | **Changed:** 114 | 115 | * Now the auto generated end point name of a relationship uses the relationship name instead of the related model name 116 | 117 | **Fixed:** 118 | 119 | * Fix changelog auto generation 120 | 121 | 122 | v0.9.0 123 | ==================== 124 | 125 | **Added:** 126 | 127 | * Add pagination and filter support for relationships 128 | * Add documentation with sphinx 129 | 130 | **Changed:** 131 | 132 | 133 | v0.8.5 134 | ==================== 135 | 136 | **Changed:** 137 | 138 | * Change the way relation is checked on query related object 139 | 140 | v0.8.3 141 | ==================== 142 | 143 | **Changed:** 144 | 145 | * Make `query_from_request` reusable 146 | 147 | v0.8.2 148 | ==================== 149 | 150 | **Added:** 151 | 152 | * Enable deletion of an item from a relation that uses secondary table 153 | 154 | 155 | v0.8.1 156 | ==================== 157 | 158 | **Added:** 159 | 160 | * Support Flask Blueprints 161 | 162 | v0.8.0 163 | ==================== 164 | 165 | **Added:** 166 | 167 | * Added support to a unit definition for the Measure column 168 | 169 | v0.7.1 170 | ==================== 171 | 172 | **Added:** 173 | 174 | * Support custom hooks before and after commit data to the DB 175 | 176 | v0.6.0 177 | ==================== 178 | 179 | **Changed:** 180 | 181 | * Do not add Zulu TZ on naive datetimes 182 | * Rename package from flask-rest-orm to flask-restalchemy 183 | 184 | v0.5.0 185 | ==================== 186 | 187 | **Added:** 188 | 189 | * Support filters and pagination 190 | 191 | v0.4.1 192 | ==================== 193 | 194 | **Added:** 195 | 196 | * Support custom implementation of DateTime columns 197 | 198 | v0.4.2 199 | ==================== 200 | 201 | **Fixed:** 202 | 203 | * Support Zulu time zone 204 | 205 | v0.4.1 206 | ==================== 207 | 208 | **Added:** 209 | 210 | * Added PrimaryKeyField to serialized only the Foreign key of a model 211 | 212 | **Fixed:** 213 | 214 | * Update classifiers by removing Python 2 support 215 | 216 | v0.4.0 217 | ==================== 218 | 219 | **Changed:** 220 | 221 | * Replace marshmallow serializers with our own serializer implementation 222 | * More robust serialization of dates and times 223 | 224 | v0.3.0 225 | ==================== 226 | 227 | **Added:** 228 | 229 | * Added collection name parameter on add_model method 230 | * Compatibility with python 3.5 231 | * Enable custom endpoint 232 | 233 | v0.2.0 234 | ==================== 235 | 236 | **Added:** 237 | 238 | * Added query filters and limits 239 | 240 | v0.1.0 241 | ==================== 242 | 243 | **Added:** 244 | 245 | * First release version 246 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018, ESSS 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask-RESTAlchemy # 2 | 3 | [![build](https://github.com/ESSS/serialchemy/workflows/build/badge.svg)](https://github.com/ESSS/serialchemy/actions) 4 | [![codecov](https://codecov.io/gh/ESSS/flask-restalchemy/branch/master/graph/badge.svg)](https://codecov.io/gh/ESSS/flask-restalchemy) 5 | [![black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 6 | [![black](https://img.shields.io/readthedocs/flask-restalchemy.svg)](https://flask-restalchemy.readthedocs.io/en/latest) 7 | 8 | A Flask extension to build REST APIs. It dismiss the need of building *Schema* classes, 9 | since usually all the information needed to serialize an SQLAlchemy instance is in the model 10 | itself. 11 | 12 | By adding a model to the API, all its properties will be exposed: 13 | 14 | ```python 15 | class User(Base): 16 | 17 | __tablename__ = "User" 18 | 19 | id = Column(Integer, primary_key=True) 20 | firstname = Column(String) 21 | lastname = Column(String) 22 | email = Column(String) 23 | password = Column(String) 24 | 25 | 26 | api = Api(flask_app) 27 | api.add_model(User, "/user") 28 | ``` 29 | 30 | To change the way properties are serialized, declare only the one that needs a non-default 31 | behaviour: 32 | 33 | ```python 34 | from serialchemy import ModelSerializer, Field 35 | 36 | 37 | class UserSerializer(ModelSerializer): 38 | 39 | password = Field(load_only=True) 40 | 41 | 42 | api = Api(flask_app) 43 | api.add_model(User, "/user", serializer_class=UserSerializer) 44 | ``` 45 | 46 | ### Release 47 | A reminder for the maintainers on how to make a new release. 48 | 49 | Note that the VERSION should folow the semantic versioning as X.Y.Z 50 | Ex.: v1.0.5 51 | 52 | 1. Create a ``release-VERSION`` branch from ``upstream/master``. 53 | 2. Update ``CHANGELOG.rst``. 54 | 3. Push a branch with the changes. 55 | 4. Once all builds pass, push a ``VERSION`` tag to ``upstream``. 56 | 5. Merge the PR. 57 | 58 | [Changelog]: https://regro.github.io/rever-docs/devguide.html#changelog 59 | -------------------------------------------------------------------------------- /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-RESTAlchemy 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) 21 | -------------------------------------------------------------------------------- /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("../src/")) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "Flask-RESTAlchemy" 24 | copyright = '2018, ESSS' 25 | author = "ESSS" 26 | 27 | # The short X.Y version 28 | version = "" 29 | # The full version, including alpha/beta/rc tags 30 | release = "0.5.0" 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 = [ 43 | "sphinx.ext.autodoc", 44 | "sphinx.ext.autosummary", 45 | "sphinx.ext.intersphinx", 46 | "sphinx.ext.todo", 47 | "sphinx.ext.viewcode", 48 | ] 49 | 50 | # Add any paths that contain templates here, relative to this directory. 51 | templates_path = ["_templates"] 52 | 53 | # The suffix(es) of source filenames. 54 | # You can specify multiple suffix as a list of string: 55 | # 56 | # source_suffix = ['.rst', '.md'] 57 | source_suffix = ".rst" 58 | 59 | # The master toctree document. 60 | master_doc = "index" 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # 65 | # This is also used if you do content translation via gettext catalogs. 66 | # Usually you set "language" from the command line for these cases. 67 | language = None 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | # This pattern also affects html_static_path and html_extra_path . 72 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "test/*", "tests/*"] 73 | 74 | # The name of the Pygments (syntax highlighting) style to use. 75 | pygments_style = "sphinx" 76 | 77 | 78 | # -- Options for HTML output ------------------------------------------------- 79 | 80 | # The theme to use for HTML and HTML Help pages. See the documentation for 81 | # a list of builtin themes. 82 | # 83 | html_theme = "alabaster" 84 | 85 | # Theme options are theme-specific and customize the look and feel of a theme 86 | # further. For a list of options available for each theme, see the 87 | # documentation. 88 | # 89 | # html_theme_options = {} 90 | 91 | # Add any paths that contain custom static files (such as style sheets) here, 92 | # relative to this directory. They are copied after the builtin static files, 93 | # so a file named "default.css" will overwrite the builtin "default.css". 94 | html_static_path = [""] 95 | 96 | # Custom sidebar templates, must be a dictionary that maps document names 97 | # to template names. 98 | # 99 | # The default sidebars (for documents that don't match any pattern) are 100 | # defined by theme itself. Builtin themes are using these templates by 101 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 102 | # 'searchbox.html']``. 103 | # 104 | # html_sidebars = {} 105 | 106 | 107 | # -- Options for HTMLHelp output --------------------------------------------- 108 | 109 | # Output file base name for HTML help builder. 110 | htmlhelp_basename = "Flask-RESTAlchemydoc" 111 | 112 | 113 | # -- Options for LaTeX output ------------------------------------------------ 114 | 115 | latex_elements = { 116 | # The paper size ('letterpaper' or 'a4paper'). 117 | # 118 | # 'papersize': 'letterpaper', 119 | # The font size ('10pt', '11pt' or '12pt'). 120 | # 121 | # 'pointsize': '10pt', 122 | # Additional stuff for the LaTeX preamble. 123 | # 124 | # 'preamble': '', 125 | # Latex figure (float) alignment 126 | # 127 | # 'figure_align': 'htbp', 128 | } 129 | 130 | # Grouping the document tree into LaTeX files. List of tuples 131 | # (source start file, target name, title, 132 | # author, documentclass [howto, manual, or own class]). 133 | latex_documents = [ 134 | ( 135 | master_doc, 136 | "Flask-RESTAlchemy.tex", 137 | "Flask-RESTAlchemy Documentation", 138 | "ESSS", 139 | "manual", 140 | ) 141 | ] 142 | 143 | 144 | # -- Options for manual page output ------------------------------------------ 145 | 146 | # One entry per manual page. List of tuples 147 | # (source start file, name, description, authors, manual section). 148 | man_pages = [ 149 | (master_doc, "flask-restalchemy", "Flask-RESTAlchemy Documentation", [author], 1) 150 | ] 151 | 152 | 153 | # -- Options for Texinfo output ---------------------------------------------- 154 | 155 | # Grouping the document tree into Texinfo files. List of tuples 156 | # (source start file, target name, title, author, 157 | # dir menu entry, description, category) 158 | texinfo_documents = [ 159 | ( 160 | master_doc, 161 | "Flask-RESTAlchemy", 162 | "Flask-RESTAlchemy Documentation", 163 | author, 164 | "Flask-RESTAlchemy", 165 | "One line description of project.", 166 | "Miscellaneous", 167 | ) 168 | ] 169 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Flask-RESTAlchemy documentation master file, created by 2 | sphinx-quickstart on Fri Mar 23 18:56: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 | Flask-RESTAlchemy's! 7 | ==================== 8 | 9 | A Flask extension to build REST APIs based on `SQLAlchemy`_ models. Exported models expose all their properties by 10 | default, making the definition of *Schema* classes optional. 11 | 12 | Installation 13 | ------------ 14 | 15 | Install Flask-RESTAlchemy with `pip`: 16 | 17 | .. code-block:: shell 18 | 19 | pip install flask-restalchemy 20 | 21 | or `conda` (package available on `conda-forge`_): 22 | 23 | .. code-block:: shell 24 | 25 | conda install flask-restalchemy -c conda-forge 26 | 27 | 28 | Minimal App 29 | ----------- 30 | 31 | Let's define a very simple *SQLAlchemy* model using `flask-sqlalchemy`_: 32 | 33 | .. code-block:: python 34 | 35 | from flask_sqlalchemy import SQLAlchemy 36 | from sqlalchemy import Column, String, Integer 37 | 38 | db = SQLAlchemy() 39 | 40 | 41 | class Hero(db.Model): 42 | id = Column(Integer, primary_key=True) 43 | name = Column(String) 44 | secret_name = Column(String) 45 | 46 | Expose SQLAlchemy models in a very simple way is one of the aims of Flask-RESTAlchemy. Just instantiate an `Api` object 47 | and use `Api.add_model` to expose a model through an endpoint: 48 | 49 | .. code-block:: python 50 | 51 | from flask import Flask 52 | from flask_restalchemy import Api 53 | 54 | app = Flask("tour-of-heroes") 55 | 56 | 57 | @app.route("/create_db", methods=["POST"]) 58 | def create_db(): 59 | db.create_all() 60 | return "DB created" 61 | 62 | 63 | # Set an SQLite in-memory database 64 | app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" 65 | db.init_app(app) # Must be called before Api object creation 66 | 67 | api = Api(app) 68 | api.add_model(Hero, "/heroes") 69 | 70 | if __name__ == "__main__": 71 | app.run() 72 | 73 | `Api.add_model` creates methods GET and POST for the `Heroes` collection at ``/heroes`` and methods GET, PUT and DELETE 74 | for ``/heroes/:id`` Let's see it in action: 75 | 76 | .. code-block:: shell 77 | 78 | $ python app.py 79 | * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) 80 | 81 | $ curl -X POST http://localhost:5000/create_db 82 | DB Created 83 | 84 | $ curl -H "Content-Type:application/json" -d "{\"name\":\"Mr. Nice\"}" http://localhost:5000/heroes 85 | $ curl -X GET http://localhost:5000/heroes/1 86 | { 87 | "id": 1, 88 | "name": "Mr. Nice", 89 | secret_name: "", 90 | } 91 | 92 | Serializers could be used to override the default serialization of models: 93 | 94 | .. code-block:: python 95 | 96 | class HeroSerializer(ModelSerializer): 97 | 98 | secret_name = Field(load_only=True) 99 | 100 | 101 | api = Api(app) 102 | api.add_model(Hero, "/heroes", serializer_class=HeroSerializer) 103 | 104 | In the above example, `secret_name` property will not be exposed on a GET, but can be updated in a PUT or POST. 105 | 106 | .. _conda-forge: https://conda-forge.org 107 | .. _flask-sqlalchemy: http://lask-sqlalchemy.pocoo.org 108 | .. _SQLAlchemy: http://www.sqlalchemy.org 109 | 110 | Documentation 111 | ============= 112 | 113 | .. toctree:: 114 | :maxdepth: 2 115 | 116 | source/api_reference 117 | -------------------------------------------------------------------------------- /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-RESTAlchemy 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 | -------------------------------------------------------------------------------- /docs/source/api_module.rst: -------------------------------------------------------------------------------- 1 | Api 2 | === 3 | 4 | 5 | The ``Api`` class 6 | ----------------- 7 | 8 | .. autoclass:: flask_restalchemy.Api 9 | :members: 10 | :undoc-members: 11 | :show-inheritance: 12 | -------------------------------------------------------------------------------- /docs/source/api_reference.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ========================== 3 | 4 | 5 | .. toctree:: 6 | :maxdepth: 4 7 | 8 | api_module 9 | resources 10 | query_builder 11 | decorators 12 | -------------------------------------------------------------------------------- /docs/source/decorators.rst: -------------------------------------------------------------------------------- 1 | Decorators 2 | ========== 3 | 4 | 5 | .. automodule:: flask_restalchemy.decorators.request_hooks 6 | :members: 7 | :undoc-members: 8 | :show-inheritance: 9 | -------------------------------------------------------------------------------- /docs/source/query_builder.rst: -------------------------------------------------------------------------------- 1 | Query builder 2 | ============= 3 | 4 | 5 | .. automodule:: flask_restalchemy.resources.querybuilder 6 | :members: 7 | :undoc-members: 8 | :show-inheritance: 9 | -------------------------------------------------------------------------------- /docs/source/resources.rst: -------------------------------------------------------------------------------- 1 | Resources 2 | ========= 3 | 4 | 5 | The ``BaseResource`` class 6 | -------------------------- 7 | 8 | .. autoclass:: flask_restalchemy.resources.BaseResource 9 | :members: 10 | :undoc-members: 11 | :show-inheritance: 12 | 13 | The ``BaseModelResource`` class 14 | ------------------------------- 15 | 16 | .. autoclass:: flask_restalchemy.resources.BaseModelResource 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | 21 | The ``ModelResource`` class 22 | --------------------------- 23 | 24 | .. autoclass:: flask_restalchemy.resources.ModelResource 25 | :members: 26 | :undoc-members: 27 | :show-inheritance: 28 | 29 | The ``ToManyRelationResource`` class 30 | ------------------------------------ 31 | 32 | .. autoclass:: flask_restalchemy.resources.ToManyRelationResource 33 | :members: 34 | :undoc-members: 35 | :show-inheritance: 36 | 37 | 38 | The ``CollectionPropertyResource`` class 39 | ---------------------------------------- 40 | 41 | .. autoclass:: flask_restalchemy.resources.CollectionPropertyResource 42 | :members: 43 | :undoc-members: 44 | :show-inheritance: 45 | -------------------------------------------------------------------------------- /environment.devenv.yml: -------------------------------------------------------------------------------- 1 | {% set PY = os.environ['CONDA_PY'] | int %} 2 | name: flask-restalchemy-py{{ PY }} 3 | 4 | dependencies: 5 | {% if PY==36 %} 6 | - python ==3.6.11 7 | {% elif PY==310 %} 8 | - python ==3.10.4 # [win] 9 | - python ==3.10.2 # [linux] 10 | {% endif %} 11 | - sqlalchemy ==1.3.15 # [PY==36] 12 | - sqlalchemy ==1.4.44 # [PY==310] 13 | - flask>=1.0 14 | - sqlalchemy-utils>=0.30 15 | - flask-sqlalchemy>=2.3.0 16 | - pytest 17 | - pytest-regressions 18 | - pytest-mock>=1.10.0 19 | - tox 20 | 21 | environment: 22 | PYTHONPATH: 23 | - {{ root }}/src 24 | - {{ root }}/../serialchemy/src 25 | -------------------------------------------------------------------------------- /news/TEMPLATE: -------------------------------------------------------------------------------- 1 | **Added:** None 2 | 3 | **Changed:** None 4 | 5 | **Deprecated:** None 6 | 7 | **Removed:** None 8 | 9 | **Fixed:** 10 | 11 | Type checking registration on `register_column_serializer` method 12 | 13 | **Security:** None 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=45", 4 | "setuptools_scm[toml]>=6.2", 5 | "wheel", 6 | ] 7 | build-backend = "setuptools.build_meta" 8 | 9 | [tool.setuptools_scm] 10 | git_describe_command = "git describe --dirty --tags --long --exclude RFDAP*" 11 | 12 | [tool.black] 13 | line-length = 100 14 | skip-string-normalization = true -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask==1.0.2 2 | sqlalchemy==1.3.0 3 | sqlalchemy_utils>=0.30 4 | flask-sqlalchemy>=2.3.0 5 | serialchemy>=0.3.0 6 | pytest==4.0.2 7 | pytest-datadir 8 | pytest-regressions==1.0.5 9 | pytest-mock>=1.10.0 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | long_description = file: README.md 3 | long_description_content_type= text/markdown 4 | 5 | [bdist_wheel] 6 | python_tag = py3 7 | 8 | [flake8] 9 | exclude = docs 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | install_requires = [ 4 | "flask-sqlalchemy >= 2.3.0", 5 | "sqlalchemy_utils>=0.30", 6 | "flask>=1.0.0", 7 | "sqlalchemy>=1.3,<2", 8 | "serialchemy>=0.3.0", 9 | ] 10 | 11 | extras_require = { 12 | "docs": ["sphinx >= 1.4", "sphinx_rtd_theme"], 13 | "testing": ["codecov", "pytest", "pytest-cov", "pytest-mock", "pytest-regressions", "tox"], 14 | } 15 | 16 | setup( 17 | name="flask-restalchemy", 18 | use_scm_version={"git_describe_command": "git describe --dirty --tags --long --match v*"}, 19 | setup_requires=["setuptools_scm"], 20 | packages=find_packages("src"), 21 | package_dir={"": "src"}, 22 | package_data={"": ["**/*.yml"]}, 23 | url="https://github.com/ESSS/flask-restalchemy", 24 | license="MIT", 25 | author="ESSS", 26 | author_email="foss@esss.co", 27 | description="Flask extension to build REST APIs based on SQLAlchemy models ", 28 | keywords="flask sqlalchemy orm", 29 | license_files=('LICENSE',), 30 | install_requires=install_requires, 31 | extras_require=extras_require, 32 | python_requires=">=3.6", 33 | classifiers=[ 34 | "Environment :: Web Environment", 35 | "Development Status :: 3 - Alpha", 36 | "Intended Audience :: Developers", 37 | "License :: OSI Approved :: MIT License", 38 | "Operating System :: OS Independent", 39 | "Programming Language :: Python :: 3.6", 40 | "Programming Language :: Python :: 3.7", 41 | "Topic :: Database :: Front-Ends", 42 | "Topic :: Internet :: WWW/HTTP", 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /src/flask_restalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import Api 2 | -------------------------------------------------------------------------------- /src/flask_restalchemy/api.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | 3 | from flask import current_app 4 | 5 | from .serialization import ColumnSerializer, ModelSerializer 6 | from .resources.resources import ( 7 | ToManyRelationResource, 8 | ModelResource, 9 | CollectionPropertyResource, 10 | BaseResource, 11 | ViewFunctionResource, 12 | ) 13 | 14 | 15 | class Api: 16 | """ 17 | :param (Flask|Blueprint) blueprint: Flask application or Blueprint 18 | 19 | :param callable request_decorators: request decorators for this API object (see 20 | Flask-Restful decorators docs for more information) 21 | """ 22 | 23 | def __init__(self, blueprint=None, request_decorators=None): 24 | """Constructor""" 25 | # noinspection PyPackageRequirements 26 | self.default_mediatype = "application/json" 27 | self._blueprint = blueprint 28 | self._db = None 29 | self._api_request_decorators = ResourceDecorators(request_decorators) 30 | 31 | def init_app(self, blueprint): 32 | self._blueprint = blueprint 33 | 34 | def add_model( 35 | self, 36 | model, 37 | url=None, 38 | serializer_class=None, 39 | view_name=None, 40 | request_decorators=None, 41 | methods=None, 42 | query_modifier=None, 43 | ): 44 | """ 45 | Create API endpoints for the given SQLAlchemy declarative class. 46 | 47 | :param class model: the SQLAlchemy declarative class 48 | 49 | :param string url: one or more url routes to match for the resource, standard flask routing 50 | rules apply. Defaults to model name in lower case. 51 | 52 | :param string view_name: custom name for the collection endpoint url definition, if 53 | not set the model table name will be used 54 | 55 | :param Type[ModelSerializer] serializer_class: If `None`, a default serializer will be 56 | created. 57 | 58 | :param list|dict request_decorators: decorators to be applied to HTTP methods. Could be a 59 | list of decorators or a dict mapping HTTP method types to a list of decorators (dict 60 | keys should be 'get', 'post' or 'put'). 61 | 62 | :param list methods: A list with verbs to be used, if None, default will use all 63 | 64 | :param callable query_modifier: function that returns a query and expects a `model` as parameter that 65 | should be used to create the query and expects a `parent_query` to be incremented with the callback query 66 | function. The method signature should look like this: query_callback(resource_model, parent_query) 67 | """ 68 | view_name = view_name or model.__tablename__ 69 | if not serializer_class: 70 | serializer = self.create_default_serializer(model) 71 | else: 72 | serializer = serializer_class(model) 73 | url = url if url is not None else "/" + view_name.lower() 74 | 75 | view_init_args = (model, serializer, self.get_db_session, query_modifier) 76 | decorators = self._create_decorators(request_decorators) 77 | self.add_resource( 78 | ModelResource, 79 | url, 80 | view_name, 81 | view_init_args, 82 | decorators=decorators, 83 | methods=methods, 84 | ) 85 | 86 | def add_relation( 87 | self, 88 | relation_property, 89 | url_rule=None, 90 | serializer_class=None, 91 | request_decorators=None, 92 | endpoint_name=None, 93 | methods=None, 94 | query_modifier=None, 95 | ): 96 | """ 97 | Create API endpoints for the given SQLAlchemy relationship. 98 | 99 | :param relation_property: model relationship representing the collection to receive the 100 | CRUD operations. 101 | 102 | :param string url_rule: one or more url routes to match for the resource, standard 103 | flask routing rules apply. Defaults to model name in lower case. 104 | 105 | :param Type[ModelSerializer] serializer_class: If `None`, a default serializer will be created. 106 | 107 | :param list|dict request_decorators: decorators to be applied to HTTP methods. Could be a list of decorators 108 | or a dict mapping HTTP verb types to a list of decorators (dict keys should be 'get', 'post' or 'put'). 109 | See https://flask-restful.readthedocs.io/en/latest/extending.html#resource-method-decorators for more 110 | details. 111 | 112 | :param string endpoint_name: endpoint name (defaults to :meth:`{model_collection_name}-{related_collection_name}-relation` 113 | Can be used to reference this route in :class:`fields.Url` fields 114 | 115 | :param list methods: A list with verbs to be used, if None, default will use all 116 | 117 | :param callable query_modifier: function that returns a query and expects a `model` as parameter that 118 | should be used to create the query and expects a `parent_query` to be incremented with the callback query 119 | function. The method signature should look like this: query_callback(resource_model, parent_query) 120 | """ 121 | model = relation_property.prop.mapper.class_ 122 | related_model = relation_property.class_ 123 | view_name = f"{model.__name__}_{related_model.__name__}".lower() 124 | 125 | if not serializer_class: 126 | serializer = self.create_default_serializer(model) 127 | else: 128 | serializer = serializer_class(model) 129 | if url_rule: 130 | assert "" in url_rule 131 | else: 132 | parent_endpoint = related_model.__tablename__.lower() 133 | url_rule = "/{}//{}".format( 134 | parent_endpoint, relation_property.key 135 | ) 136 | endpoint_name = endpoint_name or url_rule 137 | 138 | view_init_args = ( 139 | relation_property, 140 | serializer, 141 | self.get_db_session, 142 | query_modifier, 143 | ) 144 | self.add_resource( 145 | ToManyRelationResource, 146 | url_rule, 147 | view_name, 148 | view_init_args, 149 | decorators=self._create_decorators(request_decorators), 150 | methods=methods, 151 | ) 152 | 153 | def add_property( 154 | self, 155 | property_type, 156 | model, 157 | property_name, 158 | url_rule=None, 159 | serializer_class=None, 160 | request_decorators=[], 161 | endpoint_name=None, 162 | methods=None, 163 | query_modifier=None, 164 | ): 165 | if not serializer_class: 166 | serializer = self.create_default_serializer(property_type) 167 | else: 168 | serializer = serializer_class(property_type) 169 | view_name = f"{model.__name__}_{property_name}".lower() 170 | if url_rule: 171 | assert "" in url_rule 172 | else: 173 | parent_endpoint = model.__tablename__.lower() 174 | url_rule = "/{}//{}".format( 175 | parent_endpoint, property_name.lower() 176 | ) 177 | 178 | endpoint = endpoint_name or url_rule 179 | 180 | view_init_args = ( 181 | property_type, 182 | model, 183 | property_name, 184 | serializer, 185 | self.get_db_session, 186 | query_modifier, 187 | ) 188 | self.add_resource( 189 | CollectionPropertyResource, 190 | url_rule, 191 | view_name, 192 | view_init_args, 193 | decorators=self._create_decorators(request_decorators), 194 | methods=methods, 195 | ) 196 | 197 | def add_resource( 198 | self, 199 | resource_class, 200 | url_rule, 201 | view_name, 202 | resource_init_args=(), 203 | resource_init_kwargs=None, 204 | decorators=None, 205 | methods=None, 206 | ): 207 | if not issubclass(resource_class, BaseResource): 208 | raise TypeError("Resource must inherit BaseResource") 209 | if resource_init_kwargs is None: 210 | resource_init_kwargs = {} 211 | else: 212 | assert ( 213 | "request_decorators" not in resource_init_kwargs 214 | ), "Use add_resource 'decorators' parameter" 215 | resource_init_kwargs["request_decorators"] = self._create_decorators(decorators) 216 | view_func = resource_class.as_view( 217 | view_name, *resource_init_args, **resource_init_kwargs 218 | ) 219 | self.register_view(view_func, url_rule, methods=methods) 220 | 221 | def register_view(self, view_func, url, pk="id", pk_type="int", methods=None): 222 | """ 223 | Configure URL rule as specified by HTTP verbs passed as parameter. 224 | 225 | :param view_func: the function to call when serving a request to the 226 | provided endpoint 227 | 228 | :param url: one or more url routes to match for the resource, standard flask routing rules 229 | apply. Defaults to model name in lower case. 230 | 231 | :param pk: primary key 232 | 233 | :param pk_type: primary key type 234 | 235 | :param list[str] methods: verbs to be accepted by view 236 | """ 237 | app = self._blueprint 238 | if methods is None: 239 | app.add_url_rule( 240 | url, defaults={pk: None}, view_func=view_func, methods=["GET"] 241 | ) 242 | app.add_url_rule(url, view_func=view_func, methods=["POST"]) 243 | app.add_url_rule( 244 | f"{url}/<{pk_type}:{pk}>", 245 | view_func=view_func, 246 | methods=["GET", "PUT", "DELETE"], 247 | ) 248 | else: 249 | if "GET_COLLECTION" in methods: 250 | methods.remove("GET_COLLECTION") 251 | app.add_url_rule( 252 | url, defaults={pk: None}, view_func=view_func, methods=["GET"] 253 | ) 254 | if "POST" in methods: 255 | methods.remove("POST") 256 | app.add_url_rule(url, view_func=view_func, methods=["POST"]) 257 | if methods: 258 | app.add_url_rule( 259 | f"{url}/<{pk_type}:{pk}>", view_func=view_func, methods=methods 260 | ) 261 | 262 | def route(self, rule, endpoint=None, **kwargs): 263 | """ 264 | A decorator that is used to register a view function for a 265 | given URL rule. This does the same thing as :meth:`add_url_rule` 266 | but is intended for decorator usage:: 267 | 268 | @api.route('/') 269 | def index(): 270 | return 'Hello World' 271 | 272 | :param rule: the URL rule as string 273 | :param endpoint: the endpoint for the registered URL rule. 274 | Uses the name of the view function by default 275 | :param kwargs: the options to be forwarded to the underlying 276 | :meth:`add_url_rule`. 277 | """ 278 | 279 | def decorator(f): 280 | rule_endpoint = endpoint or f.__name__ 281 | self.add_url_rule(rule, rule_endpoint, f, **kwargs) 282 | return f 283 | 284 | return decorator 285 | 286 | def add_url_rule( 287 | self, rule, endpoint, view_func, methods=None, request_decorators=() 288 | ): 289 | """ 290 | This is almost the same as `Flask.add_url_rule` method, but with added support for 291 | decorators. 292 | 293 | :param rule: the URL rule as string 294 | 295 | :param endpoint: the endpoint for the registered URL rule. Flask 296 | itself assumes the name of the view function as 297 | endpoint 298 | 299 | :param view_func: the function to call when serving a request to the 300 | provided endpoint 301 | 302 | :param list[str] methods: verbs to be accepted by view 303 | 304 | :param list|dict request_decorators: decorators to be applied to HTTP methods. Could be a list of decorators 305 | or a dict mapping HTTP verb types to a list of decorators (dict keys should be 'get', 'post' or 'put'). 306 | See https://flask-restful.readthedocs.io/en/latest/extending.html#resource-method-decorators for more 307 | details. 308 | """ 309 | app = self._blueprint 310 | resource_as_view = ViewFunctionResource.as_view( 311 | endpoint, 312 | view_func, 313 | request_decorators=self._create_decorators(request_decorators), 314 | ) 315 | app.add_url_rule(rule, view_func=resource_as_view, methods=methods) 316 | 317 | def _create_decorators(self, request_decorators): 318 | merged_request_decorators = ResourceDecorators(request_decorators) 319 | merged_request_decorators.merge(self._api_request_decorators) 320 | return merged_request_decorators 321 | 322 | @staticmethod 323 | def create_default_serializer(model_class): 324 | """ 325 | Create a default serializer for the given SQLAlchemy declarative class. Recipe based on 326 | https://marshmallow-sqlalchemy.readthedocs.io/en/latest/recipes.html#automatically-generating-schemas-for-sqlalchemy-models 327 | 328 | :param model_class: the SQLAlchemy mapped class 329 | 330 | :rtype: class 331 | """ 332 | return ModelSerializer(model_class) 333 | 334 | def get_db_session(self): 335 | """ef register_view(self, view_func, url, pk='id', pk_type='int', methods=None): 336 | Returns an SQLAlchemy object session. Used by flask-restful Resources to access 337 | the database. 338 | """ 339 | if not self._db: 340 | # Get the Flask application 341 | flask_app = current_app 342 | assert flask_app and flask_app.extensions, "Flask App not initialized yet" 343 | self._db = flask_app.extensions["sqlalchemy"].db 344 | return self._db.session 345 | 346 | @classmethod 347 | def register_column_serializer(cls, serializer_class, predicate): 348 | """ 349 | Register a serializer for a given column to be used globally by ModelSerializers 350 | 351 | :param Type[ColumnSerializer] serializer_class: the Serializer class 352 | 353 | :param callable predicate: a function that receives a column type and returns True if the 354 | given serializer is valid for that column 355 | """ 356 | if not issubclass(serializer_class, ColumnSerializer): 357 | raise TypeError("Invalid serializer class") 358 | ModelSerializer.EXTRA_SERIALIZERS.append((serializer_class, predicate)) 359 | 360 | 361 | class ResourceDecorators(Mapping): 362 | """ 363 | API decorators can be set at the API instance level or per resource added. This class helps 364 | to manage and merge decorators added with both strategies. 365 | """ 366 | 367 | def __init__(self, request_decorators=None): 368 | self._verb_decorators = {} 369 | for verb in ["ALL", "GET", "POST", "PUT", "DELETE"]: 370 | self._verb_decorators[verb] = [] 371 | if request_decorators: 372 | self.merge(request_decorators) 373 | 374 | def merge(self, request_decorators): 375 | if callable(request_decorators): 376 | self._verb_decorators["ALL"].append(request_decorators) 377 | elif isinstance(request_decorators, list): 378 | self._verb_decorators["ALL"].extend(request_decorators) 379 | elif isinstance(request_decorators, (dict, ResourceDecorators)): 380 | for verb, decorator_value in request_decorators.items(): 381 | if callable(decorator_value): 382 | self._verb_decorators[verb].append(decorator_value) 383 | elif isinstance(decorator_value, list): 384 | self._verb_decorators[verb].extend(decorator_value) 385 | else: 386 | raise TypeError() 387 | else: 388 | TypeError() 389 | 390 | def __getitem__(self, verb): 391 | return self._verb_decorators[verb] 392 | 393 | def __iter__(self): 394 | return iter(self._verb_decorators) 395 | 396 | def __len__(self): 397 | return len(self._verb_decorators) 398 | -------------------------------------------------------------------------------- /src/flask_restalchemy/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from flask import Flask 5 | 6 | from flask_restalchemy.tests.sample_model import db 7 | 8 | 9 | @pytest.fixture 10 | def client(flask_app, db_session): 11 | return flask_app.test_client() 12 | 13 | 14 | @pytest.fixture() 15 | def flask_app(): 16 | app = Flask("flask_sqlapi_sample") 17 | app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 18 | app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" 19 | app.config["PROPAGATE_EXCEPTIONS"] = True 20 | db.init_app(app) 21 | with app.app_context(): 22 | yield app 23 | 24 | 25 | @pytest.fixture() 26 | def db_session(flask_app): 27 | db.create_all() 28 | yield db.session 29 | db.session.remove() 30 | db.drop_all() 31 | -------------------------------------------------------------------------------- /src/flask_restalchemy/decorators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ESSS/flask-restalchemy/2e0e8e6c3768590620f06330d0bc02dbe697d4cd/src/flask_restalchemy/decorators/__init__.py -------------------------------------------------------------------------------- /src/flask_restalchemy/decorators/request_hooks.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | 4 | def before_request(_callable): 5 | def create_decorator(func): 6 | @wraps(func) 7 | def request_decorator(*args, **kw): 8 | _callable(*args, **kw) 9 | return func(*args, **kw) 10 | 11 | return request_decorator 12 | 13 | return create_decorator 14 | 15 | 16 | def after_request(_callable): 17 | def create_decorator(func): 18 | @wraps(func) 19 | def request_decorator(*args, **kw): 20 | response = func(*args, **kw) 21 | decorated_response = _callable(response, **kw) 22 | return decorated_response or response 23 | 24 | return request_decorator 25 | 26 | return create_decorator 27 | -------------------------------------------------------------------------------- /src/flask_restalchemy/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from .resources import ( 2 | ModelResource, 3 | BaseResource, 4 | BaseModelResource, 5 | CollectionPropertyResource, 6 | ToManyRelationResource, 7 | load_request_json, 8 | create_response_from_query, 9 | ) 10 | -------------------------------------------------------------------------------- /src/flask_restalchemy/resources/querybuilder.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import desc, or_, and_, func 2 | import json 3 | import operator 4 | from sqlalchemy.ext.associationproxy import AssociationProxyInstance 5 | 6 | CASE_INSENSITIVE_ORDER_BY_ENABLED = True 7 | 8 | 9 | def create_collection_query(parent_query, model_class, model_serializer, args): 10 | """ 11 | Build a query using query parameters in the http URL, disposed on the request args. 12 | The default logical operator is AND, but you can set the OR as in the following examples: 13 | 14 | a) OR -> ?filter={"$or":{"name": {"startswith": "Terrans 1"},"location": "Location 1"}} 15 | b) AND -> ?filter={"$and":{"name": {"ilike": "%Terrans 1%"},"location": "Location 1"}} 16 | or ?filter={"name": {"ilike": "%Terrans 1%"},"location": {"eq": "Location 1"}} 17 | 18 | Ordered search is available using 'order_by='. The minus sign ("-") could be 19 | used to set descending order. 20 | 21 | :param parent_query: 22 | SQLAlchemy query instance 23 | 24 | :param class model_class: 25 | SQLAlchemy model class representing a database resource 26 | 27 | :param model_serializer: 28 | instance of model serializer 29 | 30 | :param args: 31 | arguments of the Flask http request 32 | 33 | :rtype: query 34 | :return: SQLAlchemy query instance 35 | """ 36 | 37 | def build_filter_operator(column_name, request_filter, serializer): 38 | if column_name == "$or": 39 | return or_( 40 | build_filter_operator(attr, value, serializer) 41 | for attr, value in request_filter.items() 42 | ) 43 | elif column_name == "$and": 44 | return and_( 45 | build_filter_operator(attr, value, serializer) 46 | for attr, value in request_filter.items() 47 | ) 48 | if isinstance(request_filter, dict): 49 | op_name = next(iter(request_filter)) 50 | return get_operator( 51 | getattr(model_class, column_name), 52 | op_name, 53 | request_filter.get(op_name), 54 | get_field_serializer_or_none(serializer, column_name), 55 | ) 56 | return get_operator( 57 | getattr(model_class, column_name), 58 | None, 59 | request_filter, 60 | get_field_serializer_or_none(serializer, column_name), 61 | ) 62 | 63 | res_query = parent_query 64 | if "filter" in args: 65 | filters = json.loads(args["filter"]) 66 | for attr, value in filters.items(): 67 | res_query = res_query.filter( 68 | build_filter_operator(attr, value, model_serializer) 69 | ) 70 | if "order_by" in args: 71 | fields = args["order_by"].split(",") 72 | for field in fields: 73 | field_name = field.lstrip("-") 74 | column = getattr(model_class, field_name) 75 | # Join with the associated table and define column as the associated property to support sorting 76 | if isinstance(column, AssociationProxyInstance): 77 | res_query = res_query.outerjoin(column.target_class) 78 | column = column.remote_attr 79 | if CASE_INSENSITIVE_ORDER_BY_ENABLED and str(column.type) == "VARCHAR": 80 | column = func.lower(column) 81 | if field[0] == "-": 82 | column = desc(column) 83 | res_query = res_query.order_by(column) 84 | # limit and pagination have to be done after order_by 85 | if "limit" in args: 86 | limit = args["limit"] 87 | res_query = res_query.limit(limit) 88 | 89 | return res_query 90 | 91 | 92 | # Filter operators defined on SQLAlchemy ColumnElement 93 | SQLA_OPERATORS = { 94 | "like": "like", 95 | "notlike": "notlike", 96 | "ilike": "ilike", 97 | "notilike": "notilike", 98 | "is": "is_", 99 | "isnot": "isnot", 100 | "match": "match", 101 | "startswith": "startswith", 102 | "endswith": "endswith", 103 | "contains": "contains", 104 | "in": "in_", 105 | "notin": "notin_", 106 | "between": "between", 107 | "eq": "__eq__", 108 | "ne": "__ne__", 109 | "gt": "__gt__", 110 | "ge": "__ge__", 111 | "lt": "__lt__", 112 | "le": "__le__", 113 | } 114 | 115 | 116 | def parse_value(value, serializer): 117 | if not serializer: 118 | return value 119 | if isinstance(value, list): 120 | return [serializer.load(item) for item in value] 121 | return serializer.load(value) 122 | 123 | 124 | def get_operator(column, op_name, value, serializer): 125 | """ 126 | :param column: 127 | SQLAlchemy ColumnElement 128 | 129 | :param op_name: 130 | Key of OPERATORS or COMPARE_OPERATORS 131 | 132 | :param value: 133 | value to be applied to the operator 134 | 135 | :rtype: ColumnOperators 136 | :return: 137 | returns a boolean, comparison, and other operators for ColumnElement expressions. 138 | ref: http://docs.sqlalchemy.org/en/latest/core/sqlelement.html#sqlalchemy.sql.operators.ColumnOperators 139 | """ 140 | if not op_name: 141 | return column.operate(operator.eq, parse_value(value, serializer)) 142 | elif op_name in SQLA_OPERATORS: 143 | op = SQLA_OPERATORS.get(op_name) 144 | if op == "between": 145 | return column.between( 146 | parse_value(value[0], serializer), parse_value(value[1], serializer) 147 | ) 148 | return getattr(column, op)(parse_value(value, serializer)) 149 | else: 150 | raise ValueError(f"Unknown operator {op_name}") 151 | 152 | 153 | def get_field_serializer_or_none(serializer, field_name): 154 | field = serializer.fields.get(field_name) 155 | if not field: 156 | return None 157 | return field.serializer 158 | -------------------------------------------------------------------------------- /src/flask_restalchemy/resources/resources.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from flask import request, json, jsonify, Response 4 | from flask.views import MethodView 5 | from sqlalchemy.orm import load_only 6 | from sqlalchemy.orm.collections import InstrumentedList 7 | 8 | from flask_restalchemy.serialization import ModelSerializer 9 | from .querybuilder import create_collection_query 10 | 11 | 12 | class BaseResource(MethodView): 13 | """The Base class for resources 14 | 15 | :param dict|list request_decorators: a list of decorators 16 | """ 17 | 18 | def __init__(self, request_decorators=None): 19 | if not request_decorators: 20 | return 21 | for verb, decorator_list in request_decorators.items(): 22 | for decorator in decorator_list: 23 | if verb == "ALL": 24 | self.dispatch_request = decorator(self.dispatch_request) 25 | else: 26 | verb_method_name = verb.lower() 27 | decorated_method = decorator(getattr(self, verb_method_name)) 28 | setattr(self, verb_method_name, decorated_method) 29 | 30 | def dispatch_request(self, *args, **kwargs): 31 | view_response = super().dispatch_request(*args, **kwargs) 32 | data, code, header = unpack(view_response) 33 | if isinstance(data, Response): 34 | return data 35 | elif isinstance(data, str): 36 | return data, code, header 37 | else: 38 | return jsonify(data), code, header 39 | 40 | 41 | class ViewFunctionResource(BaseResource): 42 | 43 | """ 44 | Class created to provide url rules for free functions. 45 | 46 | :param callable func: function to be called 47 | 48 | :param dict request_decorators: dictionary of decorators for the function 49 | """ 50 | 51 | def __init__(self, func, request_decorators=None): 52 | super().__init__(request_decorators) 53 | self.func = func 54 | 55 | def get(self, *args, **kwargs): 56 | return self.func(*args, **kwargs) 57 | 58 | def post(self, *args, **kwargs): 59 | return self.func(*args, **kwargs) 60 | 61 | def put(self, *args, **kwargs): 62 | return self.func(*args, **kwargs) 63 | 64 | def delete(self, *args, **kwargs): 65 | return self.func(*args, **kwargs) 66 | 67 | 68 | class BaseModelResource(BaseResource): 69 | """The Base class for ORM resources 70 | 71 | :param class declarative_model: the SQLAlchemy declarative class. 72 | 73 | :param ModelSerializer serializer: schema for serialization. If `None`, a default serializer will be created. 74 | 75 | :param callable session_getter: a callable that returns the DB session. A callable is used since a reference to 76 | DB session may not be available on the resource initialization. 77 | 78 | :param callable query_modifier: function that returns a query and expects a `model` as parameter that 79 | should be used to create the query and expects a `parent_query` to be incremented with the callback query 80 | function. The method signature should look like this: query_callback(parent_query, resource_model) 81 | 82 | :param dict|list request_decorators: a list of decorators 83 | """ 84 | 85 | def __init__( 86 | self, 87 | declarative_model, 88 | serializer, 89 | session_getter, 90 | query_modifier=None, 91 | request_decorators=None, 92 | ): 93 | """Constructor 94 | """ 95 | 96 | super().__init__(request_decorators) 97 | self._resource_model = declarative_model 98 | self._serializer = serializer 99 | self._serializer.strict = True 100 | assert isinstance( 101 | self._serializer, ModelSerializer 102 | ), f"Invalid serializer instance: {serializer}" 103 | self._session_getter = session_getter 104 | self._query_modifier = query_modifier 105 | 106 | def _save_model(self, model): 107 | session = self._session_getter() 108 | session.add(model) 109 | session.commit() 110 | 111 | def _save_serialized(self, serialized_data, existing_model=None): 112 | model = self._serializer.load( 113 | serialized_data, existing_model, self._session_getter() 114 | ) 115 | self._save_model(model) 116 | return self._serializer.dump(model) 117 | 118 | @property 119 | def _db_session(self): 120 | return self._session_getter() 121 | 122 | 123 | class ModelResource(BaseModelResource): 124 | def get(self, id=None): 125 | if id is not None: 126 | model = self._resource_model.query.get(id) 127 | if model is None: 128 | return NOT_FOUND_ERROR, 404 129 | return self._serializer.dump(model) 130 | else: 131 | query = self._resource_model.query 132 | if self._query_modifier: 133 | query = self._query_modifier(query, self._resource_model) 134 | query = create_collection_query( 135 | query, self._resource_model, self._serializer, request.args 136 | ) 137 | 138 | return create_response_from_query(query, self._serializer) 139 | 140 | def post(self): 141 | serialized = load_request_json() 142 | saved = self._save_serialized(serialized) 143 | return saved, 201 144 | 145 | def put(self, id): 146 | model = self._resource_model.query.get(id) 147 | if model is None: 148 | return NOT_FOUND_ERROR, 404 149 | 150 | serialized = self._serializer.dump(model) 151 | request_data = load_request_json() 152 | serialized.update(request_data) 153 | result = self._save_serialized(serialized, existing_model=model) 154 | return result 155 | 156 | def delete(self, id): 157 | model = self._resource_model.query.get(id) 158 | if model is None: 159 | return NOT_FOUND_ERROR, 404 160 | session = self._db_session 161 | session.delete(model) 162 | session.flush() 163 | session.commit() 164 | return "", 204 165 | 166 | 167 | class ToManyRelationResource(BaseModelResource): 168 | """Resource class that receives an SQLAlchemy relationship define the API to provide 169 | LIST and CREATE over data of the child model associated with a specific 170 | element of the parent model. 171 | 172 | :param relationship relation_property: the SQLAlchemy relationship. 173 | 174 | :param ModelSerializer serializer: schema for serialization. If `None`, a default serializer will be created. 175 | 176 | :param callable session_getter: a callable that returns the DB session. A callable is used since a reference to 177 | DB session may not be available on the resource initialization. 178 | 179 | :param callable query_modifier: function that returns a query and expects a `model` as parameter that 180 | should be used to create the query and expects a `parent_query` to be incremented with the callback query 181 | function. The method signature should look like this: query_callback(parent_query, resource_model) 182 | 183 | :param dict|list request_decorators: a list of decorators 184 | """ 185 | 186 | def __init__( 187 | self, 188 | relation_property, 189 | serializer, 190 | session_getter, 191 | query_modifier=None, 192 | request_decorators=None, 193 | ): 194 | """Constructor 195 | """ 196 | resource_model = relation_property.prop.mapper.class_ 197 | super().__init__( 198 | resource_model, 199 | serializer, 200 | session_getter, 201 | query_modifier=query_modifier, 202 | request_decorators=request_decorators, 203 | ) 204 | self._relation_property = relation_property 205 | self._related_model = relation_property.class_ 206 | 207 | def get(self, relation_id, id=None): 208 | if id: 209 | requested_obj = self._query_related_obj(relation_id, id) 210 | if not requested_obj: 211 | return NOT_FOUND_ERROR, 404 212 | return self._serializer.dump(requested_obj), 200 213 | else: 214 | session = self._db_session 215 | # using options(load_only('id')) avoid unintended subquerying, as all we want is 216 | # check if the element exists 217 | related_obj = ( 218 | session.query(self._related_model) 219 | .options(load_only("id")) 220 | .get(relation_id) 221 | ) 222 | if related_obj is None: 223 | return NOT_FOUND_ERROR, 404 224 | 225 | # TODO: Is there a more efficient way than using getattr? 226 | relation_list_or_query = getattr(related_obj, self._relation_property.key) 227 | if isinstance(relation_list_or_query, InstrumentedList) or not hasattr( 228 | relation_list_or_query, "paginate" 229 | ): 230 | warnings.warn( 231 | "Warnning: relationship does not support pagination nor filter." 232 | 'Use flask-sqlalchemy relationship with lazy="dynamic".' 233 | ) 234 | collection = [ 235 | self._serializer.dump(item) for item in relation_list_or_query 236 | ] 237 | else: 238 | query = relation_list_or_query 239 | if self._query_modifier: 240 | query = self._query_modifier(query, self._resource_model) 241 | query = create_collection_query( 242 | query, self._resource_model, self._serializer, request.args 243 | ) 244 | collection = create_response_from_query(query, self._serializer) 245 | return collection 246 | 247 | def post(self, relation_id): 248 | session = self._db_session 249 | related_obj = session.query(self._related_model).get(relation_id) 250 | if not related_obj: 251 | return NOT_FOUND_ERROR, 404 252 | collection = getattr(related_obj, self._relation_property.key) 253 | data_dict = load_request_json() 254 | resource_id = data_dict.get("id", None) 255 | 256 | if resource_id is not None: 257 | model = session.query(self._resource_model).get(resource_id) 258 | if model is None: 259 | return NOT_FOUND_ERROR, 404 260 | status_code = 200 261 | else: 262 | model = self._serializer.load(data_dict, session=session) 263 | status_code = 201 264 | session.add(model) 265 | collection.append(model) 266 | self._save_model(model) 267 | saved = self._serializer.dump(model) 268 | return saved, status_code 269 | 270 | def put(self, relation_id, id): 271 | request_data = load_request_json() 272 | requested_obj = self._query_related_obj(relation_id, id) 273 | if not requested_obj: 274 | return NOT_FOUND_ERROR, 404 275 | serialized = self._serializer.dump(requested_obj) 276 | serialized.update(request_data) 277 | saved = self._save_serialized(serialized, requested_obj) 278 | return saved 279 | 280 | def delete(self, relation_id, id): 281 | session = self._db_session 282 | requested_obj = self._query_related_obj(relation_id, id) 283 | if not requested_obj: 284 | return NOT_FOUND_ERROR, 404 285 | related_obj = session.query(self._related_model).get(relation_id) 286 | collection = getattr(related_obj, self._relation_property.key) 287 | collection.remove(requested_obj) 288 | session.delete(requested_obj) 289 | session.commit() 290 | return "", 204 291 | 292 | def _query_related_obj(self, relation_id, id): 293 | """ 294 | Query resource model by ID but also add the relationship as a query constrain. 295 | 296 | :param relation_id: id of the related model 297 | :param id: id of the model being required 298 | :return: model with 'id' that has a related model with 'related_id' 299 | """ 300 | 301 | # This checks if there is a parent with the related child on its relation property 302 | related = ( 303 | self._db_session.query(self._related_model) 304 | .options(load_only("id")) 305 | .filter( 306 | self._related_model.id == relation_id, 307 | self._relation_property.any(id=id), 308 | ) 309 | .one_or_none() 310 | ) 311 | 312 | if related is None: 313 | return None 314 | 315 | return self._db_session.query(self._resource_model).get(id) 316 | 317 | 318 | class CollectionPropertyResource(ToManyRelationResource): 319 | def __init__( 320 | self, 321 | declarative_model, 322 | related_model, 323 | property_name, 324 | serializer, 325 | session_getter, 326 | query_modifier=None, 327 | request_decorators=None, 328 | ): 329 | super(ToManyRelationResource, self).__init__( 330 | declarative_model, 331 | serializer, 332 | session_getter, 333 | query_modifier=query_modifier, 334 | request_decorators=request_decorators, 335 | ) 336 | self._related_model = related_model 337 | self._property_name = property_name 338 | 339 | def get(self, relation_id, id=None): 340 | session = self._db_session 341 | related_obj = session.query(self._related_model).get(relation_id) 342 | if related_obj is None: 343 | return NOT_FOUND_ERROR, 404 344 | relation_list_or_query = getattr(related_obj, self._property_name) 345 | if isinstance(relation_list_or_query, InstrumentedList) or not hasattr( 346 | relation_list_or_query, "paginate" 347 | ): 348 | warnings.warn( 349 | "Warnning: property " 350 | + self._property_name 351 | + " does not support pagination nor filter." 352 | " Use flask-sqlalchemy and make your property return a query object" 353 | ) 354 | collection = [ 355 | self._serializer.dump(item) for item in relation_list_or_query 356 | ] 357 | else: 358 | query = relation_list_or_query 359 | if self._query_modifier: 360 | query = self._query_modifier(query, self._related_model) 361 | query = create_collection_query( 362 | query, self._resource_model, self._serializer, request.args 363 | ) 364 | collection = create_response_from_query(query, self._serializer) 365 | return collection 366 | 367 | def post(self, relation_id): 368 | return "POST not allowed for property resources", 405 369 | 370 | 371 | def load_request_json(): 372 | """ 373 | Returns request data as dict. 374 | 375 | :rtype: dict 376 | """ 377 | if request.data: 378 | return json.loads(request.data.decode("utf-8")) 379 | else: 380 | return request.form.to_dict() 381 | 382 | 383 | def unpack(value): 384 | """ 385 | Return a three tuple of data, code, and headers 386 | 387 | :param value: 388 | :return: 389 | """ 390 | if not isinstance(value, tuple): 391 | return value, 200, {} 392 | 393 | try: 394 | data, code, headers = value 395 | return data, code, headers 396 | except ValueError: 397 | pass 398 | 399 | try: 400 | data, code = value 401 | return data, code, {} 402 | except ValueError: 403 | pass 404 | 405 | return value, 200, {} 406 | 407 | 408 | def create_response_from_query(query, serializer): 409 | if "page" in request.args: 410 | data = query.paginate() 411 | return { 412 | "page": data.page, 413 | "per_page": data.per_page, 414 | "count": data.total, 415 | "results": [serializer.dump(item) for item in data.items], 416 | } 417 | else: 418 | data = query.all() 419 | return [serializer.dump(item) for item in data] 420 | 421 | 422 | NOT_FOUND_ERROR = "Resource not found in the database!" 423 | -------------------------------------------------------------------------------- /src/flask_restalchemy/serialization.py: -------------------------------------------------------------------------------- 1 | from serialchemy import ( 2 | Field, 3 | PrimaryKeyField, 4 | NestedAttributesField, 5 | NestedModelField, 6 | NestedModelListField, 7 | ) 8 | from serialchemy import ColumnSerializer, ModelSerializer 9 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ESSS/flask-restalchemy/2e0e8e6c3768590620f06330d0bc02dbe697d4cd/src/flask_restalchemy/tests/__init__.py -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/employer_serializer.py: -------------------------------------------------------------------------------- 1 | from flask_restalchemy.serialization import ModelSerializer, Field, NestedModelField 2 | from flask_restalchemy.tests.sample_model import Address 3 | 4 | 5 | class EmployeeSerializer(ModelSerializer): 6 | 7 | password = Field(load_only=True) 8 | created_at = Field(dump_only=True) 9 | company_name = Field(dump_only=True) 10 | address = NestedModelField(Address) 11 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/sample_model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from flask_sqlalchemy import SQLAlchemy 4 | from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, select, Table 5 | from sqlalchemy.orm import column_property, object_session 6 | from sqlalchemy.ext.associationproxy import association_proxy 7 | 8 | db = SQLAlchemy() 9 | Base = db.Model 10 | 11 | relationship = db.relationship 12 | 13 | 14 | class Company(Base): 15 | 16 | __tablename__ = "Company" 17 | 18 | id = Column(Integer, primary_key=True) 19 | name = Column(String) 20 | location = Column(String) 21 | employees = relationship("Employee", lazy="dynamic") 22 | 23 | 24 | class Department(Base): 25 | 26 | __tablename__ = "Department" 27 | 28 | id = Column(Integer, primary_key=True) 29 | name = Column(String) 30 | 31 | 32 | class Address(Base): 33 | 34 | __tablename__ = "Address" 35 | 36 | id = Column(Integer, primary_key=True) 37 | street = Column(String) 38 | number = Column(String) 39 | zip = Column(String) 40 | city = Column(String) 41 | state = Column(String) 42 | 43 | 44 | class ContactType(Base): 45 | 46 | __tablename__ = "ContactType" 47 | 48 | id = Column(Integer, primary_key=True) 49 | label = Column(String(15)) 50 | 51 | 52 | class Contact(Base): 53 | 54 | __tablename__ = "Contact" 55 | 56 | id = Column(Integer, primary_key=True) 57 | type = relationship(ContactType) 58 | type_id = Column(ForeignKey("ContactType.id")) 59 | value = Column(String) 60 | employee_id = Column(ForeignKey("Employee.id")) 61 | 62 | 63 | class Employee(Base): 64 | 65 | __tablename__ = "Employee" 66 | 67 | id = Column(Integer, primary_key=True) 68 | firstname = Column(String) 69 | lastname = Column(String) 70 | email = Column(String) 71 | admission = Column(DateTime, default=datetime(2000, 1, 1)) 72 | company_id = Column(ForeignKey("Company.id")) 73 | company = relationship(Company, back_populates="employees") 74 | company_name = column_property( 75 | select([Company.name]).where(Company.id == company_id) 76 | ) 77 | address_id = Column(ForeignKey("Address.id")) 78 | address = relationship(Address) 79 | city = association_proxy("address", "city") 80 | departments = relationship( 81 | "Department", secondary="employee_department", lazy="dynamic" 82 | ) 83 | contacts = relationship(Contact, cascade="all, delete-orphan") 84 | 85 | password = Column(String) 86 | created_at = Column(DateTime, default=datetime(2000, 1, 2)) 87 | 88 | @property 89 | def colleagues(self): 90 | return ( 91 | object_session(self) 92 | .query(Employee) 93 | .filter(Employee.company_id == self.company_id) 94 | ) 95 | 96 | 97 | employee_department = Table( 98 | "employee_department", 99 | Base.metadata, 100 | Column("employee_id", Integer, ForeignKey("Employee.id")), 101 | Column("department_id", Integer, ForeignKey("Department.id")), 102 | ) 103 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | 4 | import pytest 5 | from flask_restalchemy.serialization import ( 6 | ModelSerializer, 7 | Field, 8 | NestedModelField, 9 | NestedModelListField, 10 | ) 11 | 12 | from flask_restalchemy import Api 13 | from flask_restalchemy.tests.sample_model import ( 14 | Employee, 15 | Company, 16 | Address, 17 | Contact, 18 | ContactType, 19 | ) 20 | from flask_restalchemy.resources.resources import ViewFunctionResource 21 | 22 | 23 | class EmployeeSerializer(ModelSerializer): 24 | 25 | password = Field(load_only=True) 26 | created_at = Field(dump_only=True) 27 | company_name = Field(dump_only=True) 28 | address = NestedModelField(Address) 29 | contacts = NestedModelListField(Contact) 30 | 31 | 32 | @pytest.fixture(autouse=True) 33 | def sample_api(flask_app): 34 | api = Api(flask_app) 35 | api.add_model(Company) 36 | api.add_model(Company, view_name="alt_company") 37 | api.add_model(Employee, serializer_class=EmployeeSerializer) 38 | 39 | 40 | @pytest.fixture(autouse=True) 41 | def create_test_sample(db_session): 42 | contact_type1 = ContactType(label="Phone") 43 | contact_type2 = ContactType(label="Email") 44 | 45 | company = Company(id=5, name="Terrans") 46 | emp1 = Employee(id=1, firstname="Jim", lastname="Raynor", company=company) 47 | emp2 = Employee(id=2, firstname="Sarah", lastname="Kerrigan", company=company) 48 | 49 | addr1 = Address(street="5 Av", number="943", city="Tarsonis") 50 | emp1.address = addr1 51 | 52 | db_session.add(contact_type1) 53 | db_session.add(contact_type2) 54 | db_session.add(company) 55 | db_session.add(emp1) 56 | db_session.add(emp2) 57 | db_session.commit() 58 | 59 | 60 | def test_get(client, data_regression): 61 | resp = client.get("/employee/1") 62 | assert resp.status_code == 200 63 | serialized = resp.get_json() 64 | data_regression.check(serialized) 65 | assert "password" not in serialized 66 | resp = client.get("/employee/10239") 67 | assert resp.status_code == 404 68 | 69 | 70 | def test_get_collection(client, data_regression): 71 | resp = client.get("/employee") 72 | assert resp.status_code == 200 73 | serialized = resp.get_json() 74 | data_regression.check(serialized) 75 | 76 | 77 | def test_post(client): 78 | contacts = [ 79 | {"type_id": 1, "value": "0000-0000"}, 80 | {"type_id": 2, "value": "test@mail.co"}, 81 | ] 82 | post_data = { 83 | "id": 3, 84 | "firstname": "Tychus", 85 | "lastname": "Findlay", 86 | "admission": "2002-02-02T00:00:00+0300", 87 | "contacts": contacts, 88 | } 89 | resp = client.post("/employee", data=json.dumps(post_data)) 90 | assert resp.status_code == 201 91 | emp3 = Employee.query.get(3) 92 | contact1 = emp3.contacts[0] 93 | assert emp3.id == 3 94 | assert emp3.firstname == "Tychus" 95 | assert emp3.lastname == "Findlay" 96 | assert emp3.admission == datetime(2002, 2, 2) 97 | assert contact1 98 | assert contact1.value == "0000-0000" 99 | 100 | 101 | def test_post_default_serializer(client): 102 | resp = client.post("/company", data={"name": "Mangsk Corp"}) 103 | assert resp.status_code == 201 104 | 105 | 106 | def test_put(client): 107 | resp = client.put("/employee/1", data={"firstname": "Jimmy"}) 108 | assert resp.status_code == 200 109 | emp3 = Employee.query.get(1) 110 | assert emp3.firstname == "Jimmy" 111 | 112 | 113 | def test_alternative_url(client): 114 | resp = client.get("/alt_company/5") 115 | assert resp.status_code == 200 116 | data = resp.get_json() 117 | assert data["name"] == "Terrans" 118 | 119 | 120 | def test_url_rule(flask_app, client): 121 | def hello_world(name): 122 | return f"hello {name}" 123 | 124 | api = Api(flask_app) 125 | api.add_url_rule("/", "index", hello_world) 126 | resp = client.get("/raynor") 127 | assert resp.status_code == 200 128 | assert resp.data == b"hello raynor" 129 | 130 | resp = client.post("/kerrigan") 131 | assert resp.status_code == 200 132 | assert resp.data == b"hello kerrigan" 133 | 134 | resp = client.put("/artanis") 135 | assert resp.status_code == 200 136 | assert resp.data == b"hello artanis" 137 | 138 | resp = client.delete("/zeratul") 139 | assert resp.status_code == 200 140 | assert resp.data == b"hello zeratul" 141 | 142 | 143 | def test_route(flask_app, client): 144 | 145 | api = Api(flask_app) 146 | 147 | @api.route("/hello/") 148 | def hello_world(name): 149 | return f"hello {name}" 150 | 151 | resp = client.get("/hello/raynor") 152 | assert resp.status_code == 200 153 | assert resp.data == b"hello raynor" 154 | 155 | resp = client.post("/hello/kerrigan") 156 | assert resp.status_code == 200 157 | assert resp.data == b"hello kerrigan" 158 | 159 | 160 | @pytest.mark.parametrize( 161 | "methods", 162 | [ 163 | ["GET_COLLECTION", "GET", "POST", "PUT", "DELETE"], 164 | ["GET_COLLECTION"], 165 | ["POST"], 166 | ["GET_COLLECTION", "POST"], 167 | ["PUT", "DELETE"], 168 | ], 169 | ) 170 | def test_http_verbs(flask_app, client, methods): 171 | def ping(*args, **kwargs): 172 | return "ping" 173 | 174 | api = Api(flask_app) 175 | api.add_resource( 176 | ViewFunctionResource, 177 | "/ping", 178 | "ping", 179 | resource_init_args=(ping,), 180 | methods=methods, 181 | ) 182 | resp = client.get("/ping") 183 | assert resp.status_code == 200 if "GET_COLLECTION" in methods else 405 184 | resp = client.get("/ping/1") 185 | assert resp.status_code == 200 if "GET" in methods else 405 186 | resp = client.post("/ping") 187 | assert resp.status_code == 200 if "POST" in methods else 405 188 | resp = client.delete("/ping/1") 189 | assert resp.status_code == 200 if "PUT" in methods else 405 190 | resp = client.put("/ping/1") 191 | assert resp.status_code == 200 if "DELETE" in methods else 405 192 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_api/test_get.yml: -------------------------------------------------------------------------------- 1 | address: 2 | city: Tarsonis 3 | id: 1 4 | number: '943' 5 | state: null 6 | street: 5 Av 7 | zip: null 8 | address_id: 1 9 | admission: '2000-01-01T00:00:00' 10 | company_id: 5 11 | company_name: Terrans 12 | contacts: [] 13 | created_at: '2000-01-02T00:00:00' 14 | email: null 15 | firstname: Jim 16 | id: 1 17 | lastname: Raynor 18 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_api/test_get_collection.yml: -------------------------------------------------------------------------------- 1 | - address: 2 | city: Tarsonis 3 | id: 1 4 | number: '943' 5 | state: null 6 | street: 5 Av 7 | zip: null 8 | address_id: 1 9 | admission: '2000-01-01T00:00:00' 10 | company_id: 5 11 | company_name: Terrans 12 | contacts: [] 13 | created_at: '2000-01-02T00:00:00' 14 | email: null 15 | firstname: Jim 16 | id: 1 17 | lastname: Raynor 18 | - address: null 19 | address_id: null 20 | admission: '2000-01-01T00:00:00' 21 | company_id: 5 22 | company_name: Terrans 23 | contacts: [] 24 | created_at: '2000-01-02T00:00:00' 25 | email: null 26 | firstname: Sarah 27 | id: 2 28 | lastname: Kerrigan 29 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_api_processors.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import call 3 | 4 | import pytest 5 | 6 | from flask_restalchemy import Api 7 | from flask_restalchemy.decorators.request_hooks import before_request, after_request 8 | from flask_restalchemy.tests.sample_model import Employee, Company, Address 9 | 10 | 11 | @pytest.fixture 12 | def sample_api(flask_app): 13 | return Api(flask_app) 14 | 15 | 16 | @pytest.fixture(autouse=True) 17 | def create_test_sample(db_session): 18 | company = Company(id=5, name="Terrans") 19 | emp1 = Employee(id=1, firstname="Jim", lastname="Raynor", company=company) 20 | 21 | addr1 = Address(street="5 Av", number="943", city="Tarsonis") 22 | emp1.address = addr1 23 | 24 | db_session.add(company) 25 | db_session.add(emp1) 26 | db_session.commit() 27 | 28 | 29 | @pytest.mark.parametrize("decorator_verb", ["ALL", "GET"]) 30 | def test_get_item_preprocessor(sample_api, client, mocker, decorator_verb): 31 | pre_processor_mock = mocker.Mock(return_value=None) 32 | sample_api.add_model( 33 | Employee, 34 | request_decorators={decorator_verb: before_request(pre_processor_mock)}, 35 | ) 36 | 37 | resp = client.get("/employee/1") 38 | assert resp.status_code == 200 39 | pre_processor_mock.assert_called_once_with(id=1) 40 | resp = client.post("/employee", data=json.dumps({"firstname": "Jeff"})) 41 | assert resp.status_code == 201 42 | # 2 calls if all verbs were decorated, otherwise test only for GET call 43 | assert pre_processor_mock.call_count == 2 if decorator_verb == "all" else 1 44 | 45 | 46 | def test_get_collection_preprocessor(sample_api, client, mocker): 47 | pre_processor_mock = mocker.Mock(return_value=None) 48 | sample_api.add_model( 49 | Employee, request_decorators=before_request(pre_processor_mock) 50 | ) 51 | 52 | resp = client.get("/employee") 53 | assert resp.status_code == 200 54 | assert pre_processor_mock.call_args == call(id=None) 55 | 56 | resp = client.post("/employee", data=json.dumps({"firstname": "Jeff"})) 57 | assert resp.status_code == 201 58 | assert pre_processor_mock.call_args == call() 59 | 60 | resp = client.put("/employee/1", data=json.dumps({"lastname": "R."})) 61 | assert resp.status_code == 200 62 | assert pre_processor_mock.call_args == call(id=1) 63 | 64 | assert pre_processor_mock.call_count == 3 65 | 66 | 67 | def test_post_processors(sample_api, client, mocker): 68 | pre_mock = mocker.Mock(return_value=None) 69 | post_mock = mocker.Mock(return_value=None) 70 | sample_api.add_model( 71 | Employee, 72 | request_decorators={ 73 | "ALL": after_request(post_mock), 74 | "POST": before_request(pre_mock), 75 | }, 76 | ) 77 | 78 | data = {"firstname": "Ana", "lastname": "Queen"} 79 | resp = client.post("/employee", data=json.dumps(data)) 80 | assert resp.status_code == 201 81 | assert pre_mock.call_count == 1 82 | 83 | employee_id = resp.get_json()["id"] 84 | assert employee_id 85 | assert post_mock.call_count == 1 86 | post_mock_args = post_mock.call_args[0] 87 | assert post_mock_args[0][1] == 201 88 | assert post_mock_args[0][0].data == resp.data 89 | 90 | 91 | def test_put_preprocessors(sample_api, client, mocker): 92 | pre_mock = mocker.Mock(return_value=None) 93 | post_mock = mocker.Mock(return_value=None) 94 | sample_api.add_model( 95 | Employee, 96 | request_decorators={ 97 | "PUT": [before_request(pre_mock), after_request(post_mock)] 98 | }, 99 | ) 100 | 101 | data = {"firstname": "Ana", "lastname": "Queen"} 102 | resp = client.put("/employee/1", data=json.dumps(data)) 103 | assert resp.status_code == 200 104 | assert pre_mock.call_count == 1 105 | assert pre_mock.call_args == call(id=1) 106 | 107 | assert post_mock.call_count == 1 108 | 109 | 110 | def test_delete_preprocessors(sample_api, client, mocker): 111 | pre_mock = mocker.Mock(return_value=None) 112 | post_mock = mocker.Mock(return_value=None) 113 | sample_api.add_model( 114 | Employee, 115 | request_decorators={ 116 | "DELETE": [before_request(pre_mock), after_request(post_mock)] 117 | }, 118 | ) 119 | 120 | resp = client.delete("/employee/1") 121 | assert resp.status_code == 204 122 | assert pre_mock.call_args == call(id=1) 123 | assert post_mock.call_args == call(("", 204), id=1) 124 | 125 | 126 | def test_property_get_collection_processor(sample_api, client, mocker): 127 | pre_mock = mocker.Mock(return_value=None) 128 | sample_api.add_property( 129 | Employee, 130 | Employee, 131 | "colleagues", 132 | request_decorators={"GET": before_request(pre_mock)}, 133 | ) 134 | 135 | resp = client.get("/employee/1/colleagues") 136 | assert resp.status_code == 200 137 | pre_mock.assert_called_once_with(id=None, relation_id=1) 138 | 139 | 140 | def test_relation_get_item_preprocessor(sample_api, client, mocker): 141 | pre_mock = mocker.Mock(return_value=None) 142 | sample_api.add_relation( 143 | Company.employees, request_decorators={"GET": before_request(pre_mock)} 144 | ) 145 | 146 | resp = client.get("/company/5/employees/1") 147 | assert resp.status_code == 200 148 | pre_mock.assert_called_once_with(relation_id=5, id=1) 149 | 150 | 151 | def test_relation_get_collection_preprocessor(sample_api, client, mocker): 152 | pre_mock = mocker.Mock(return_value=None) 153 | sample_api.add_relation( 154 | Company.employees, request_decorators={"GET": before_request(pre_mock)} 155 | ) 156 | 157 | resp = client.get("/company/5/employees") 158 | assert resp.status_code == 200 159 | pre_mock.assert_called_once_with(relation_id=5, id=None) 160 | 161 | 162 | def test_relation_post_processors(sample_api, client, mocker): 163 | pre_mock = mocker.Mock(return_value=None) 164 | post_mock = mocker.Mock(return_value=None) 165 | sample_api.add_relation( 166 | Company.employees, 167 | request_decorators={ 168 | "POST": [before_request(pre_mock), after_request(post_mock)] 169 | }, 170 | ) 171 | 172 | data = {"firstname": "Ana", "lastname": "Queen"} 173 | resp = client.post("/company/5/employees", data=json.dumps(data)) 174 | assert resp.status_code == 201 175 | pre_mock.assert_called_once_with(relation_id=5) 176 | assert post_mock.call_count == 1 177 | assert post_mock.call_args[1] == {"relation_id": 5} 178 | 179 | 180 | def test_relation_put_preprocessors(sample_api, client, mocker): 181 | pre_mock = mocker.Mock(return_value=None) 182 | post_mock = mocker.Mock(return_value=None) 183 | sample_api.add_relation( 184 | Company.employees, 185 | request_decorators={ 186 | "PUT": [before_request(pre_mock), after_request(post_mock)] 187 | }, 188 | ) 189 | 190 | data = {"firstname": "Ana", "lastname": "Queen"} 191 | resp = client.put("/company/5/employees/1", data=json.dumps(data)) 192 | assert resp.status_code == 200 193 | assert pre_mock.call_args == call(relation_id=5, id=1) 194 | assert post_mock.call_count == 1 195 | assert post_mock.call_args[1] == {"relation_id": 5, "id": 1} 196 | 197 | 198 | def test_relation_delete_preprocessors(sample_api, client, mocker): 199 | pre_mock = mocker.Mock(return_value=None) 200 | post_mock = mocker.Mock(return_value=None) 201 | sample_api.add_relation( 202 | Company.employees, 203 | request_decorators={ 204 | "DELETE": [before_request(pre_mock), after_request(post_mock)] 205 | }, 206 | ) 207 | 208 | resp = client.delete("/company/5/employees/1") 209 | assert resp.status_code == 204 210 | assert pre_mock.call_count == 1 211 | assert post_mock.call_count == 1 212 | assert post_mock.call_args[1] == {"relation_id": 5, "id": 1} 213 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_api_relations.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import json 3 | from flask_restalchemy.serialization import ModelSerializer, Field, NestedModelField 4 | 5 | from flask_restalchemy import Api 6 | from flask_restalchemy.tests.sample_model import Employee, Company, Department, Address 7 | 8 | 9 | class EmployeeSerializer(ModelSerializer): 10 | 11 | password = Field(load_only=True) 12 | created_at = Field(dump_only=True) 13 | company_name = Field(dump_only=True) 14 | address = NestedModelField(Address) 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def create_test_sample(db_session): 19 | protoss = Company(id=1, name="Protoss") 20 | terrans = Company(id=3, name="Terrans") 21 | raynor = Employee(id=9, firstname="Jim", lastname="Raynor", company=terrans) 22 | kerrigan = Employee(id=3, firstname="Sarah", lastname="Kerrigan", company=terrans) 23 | dept1 = Department(name="Marines") 24 | dept2 = Department(name="Heroes") 25 | raynor.departments.append(dept1) 26 | raynor.departments.append(dept2) 27 | 28 | db_session.add(protoss) 29 | db_session.add(terrans) 30 | db_session.add(dept1) 31 | db_session.add(dept2) 32 | db_session.add(raynor) 33 | db_session.add(kerrigan) 34 | db_session.commit() 35 | 36 | 37 | @pytest.fixture(autouse=True) 38 | def sample_api(flask_app): 39 | api = Api(flask_app) 40 | api.add_model(Company) 41 | api.add_model(Employee) 42 | api.add_relation(Company.employees, serializer_class=EmployeeSerializer) 43 | api.add_property( 44 | Employee, Employee, "colleagues", serializer_class=EmployeeSerializer 45 | ) 46 | api.add_relation(Employee.departments) 47 | return api 48 | 49 | 50 | def test_get_collection(client, data_regression): 51 | resp = client.get("/company/3/employees") 52 | assert resp.status_code == 200 53 | assert resp.is_json 54 | data = resp.get_json() 55 | data_regression.check(data) 56 | 57 | 58 | def test_get_item(client, data_regression): 59 | resp = client.get("/company/3/employees/3") 60 | assert resp.status_code == 200 61 | assert resp.is_json 62 | data = resp.get_json() 63 | data_regression.check(data) 64 | 65 | assert client.get("/company/5/employees/999").status_code == 404 66 | 67 | 68 | def test_post_item(client): 69 | post_data = { 70 | "firstname": "Tychus", 71 | "lastname": "Findlay", 72 | "admission": "2002-02-02T00:00:00+0300", 73 | } 74 | resp = client.post("/company/3/employees", data=post_data) 75 | assert resp.status_code == 201 76 | assert resp.is_json 77 | saved_id = resp.get_json()["id"] 78 | 79 | new_employee = Employee.query.get(saved_id) 80 | assert new_employee.firstname == "Tychus" 81 | assert new_employee.company.id == 3 82 | 83 | 84 | def test_put_item(client): 85 | resp = client.put("/company/3/employees/3", data={"lastname": "K."}) 86 | assert resp.status_code == 200 87 | 88 | sarah = Employee.query.filter(Employee.firstname == "Sarah").one() 89 | assert sarah.lastname == "K." 90 | 91 | 92 | def test_delete_item(client): 93 | company = Company.query.get(3) 94 | assert [emp.firstname for emp in company.employees] == ["Sarah", "Jim"] 95 | 96 | resp = client.delete("/company/3/employees/9") 97 | assert resp.status_code == 204 98 | assert [emp.firstname for emp in company.employees] == ["Sarah"] 99 | 100 | assert Employee.query.filter_by(firstname="Jim").first() is None 101 | 102 | assert client.delete("/company/5/employees/999").status_code == 404 103 | 104 | 105 | def test_post_append_existent(client): 106 | resp = client.post("/employee", data={"firstname": "Tychus", "lastname": "Findlay"}) 107 | assert resp.status_code == 201 108 | data = resp.get_json() 109 | empl_id = data["id"] 110 | thychus = Employee.query.get(empl_id) 111 | assert thychus.company_name is None 112 | 113 | resp = client.post("/company/3/employees", data={"id": empl_id}) 114 | assert resp.status_code == 200 115 | 116 | thychus = Employee.query.get(empl_id) 117 | assert thychus.company_name == "Terrans" 118 | 119 | resp = client.post("/company/3/employees", data={"id": 1000}) 120 | assert resp.status_code == 404 121 | 122 | 123 | def test_property(client, data_regression): 124 | resp = client.get("/employee/9/colleagues") 125 | assert resp.status_code == 200 126 | response_data = resp.get_json() 127 | data_regression.check(response_data) 128 | 129 | resp = client.post("/employee/9/colleagues") 130 | assert resp.status_code == 405 131 | 132 | 133 | def test_property_pagination(client): 134 | 135 | for i in range(20): 136 | client.post("/company/3/employees", data={"firstname": f"Jimmy {i}"}) 137 | 138 | response = client.get("/employee/9/colleagues?order_by=id&limit=5") 139 | assert response.status_code == 200 140 | response_data = response.get_json() 141 | assert len(response_data) == 5 142 | assert response_data[0]["firstname"] == "Sarah" 143 | 144 | response = client.get( 145 | "/employee/9/colleagues?filter={}".format( 146 | json.dumps({"firstname": {"eq": "Sarah"}}) 147 | ) 148 | ) 149 | assert response.status_code == 200 150 | response_data = response.get_json() 151 | assert len(response_data) == 1 152 | assert "firstname" in response_data[0] 153 | assert response_data[0]["firstname"] == "Sarah" 154 | 155 | response = client.get("/employee/9/colleagues?page=1&per_page=10") 156 | assert response.status_code == 200 157 | response_data = response.get_json() 158 | assert len(response_data.get("results")) == 10 159 | 160 | 161 | def test_delete_on_relation_with_secondary(client): 162 | jim = Employee.query.get(9) 163 | assert jim is not None 164 | dep = jim.departments[0] 165 | 166 | sarah = Employee.query.get(3) 167 | assert jim is not None 168 | assert dep not in sarah.departments 169 | 170 | resp = client.get("/employee/3/departments") 171 | assert resp.status_code == 200 172 | 173 | resp = client.delete("/employee/3/departments/" + str(dep.id)) 174 | assert resp.status_code == 404 175 | 176 | resp = client.delete("/employee/9/departments/" + str(dep.id)) 177 | assert resp.status_code == 204 178 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_api_relations/test_get_collection.yml: -------------------------------------------------------------------------------- 1 | - address: null 2 | address_id: null 3 | admission: '2000-01-01T00:00:00' 4 | company_id: 3 5 | company_name: Terrans 6 | created_at: '2000-01-02T00:00:00' 7 | email: null 8 | firstname: Sarah 9 | id: 3 10 | lastname: Kerrigan 11 | - address: null 12 | address_id: null 13 | admission: '2000-01-01T00:00:00' 14 | company_id: 3 15 | company_name: Terrans 16 | created_at: '2000-01-02T00:00:00' 17 | email: null 18 | firstname: Jim 19 | id: 9 20 | lastname: Raynor 21 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_api_relations/test_get_item.yml: -------------------------------------------------------------------------------- 1 | address: null 2 | address_id: null 3 | admission: '2000-01-01T00:00:00' 4 | company_id: 3 5 | company_name: Terrans 6 | created_at: '2000-01-02T00:00:00' 7 | email: null 8 | firstname: Sarah 9 | id: 3 10 | lastname: Kerrigan 11 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_api_relations/test_property.yml: -------------------------------------------------------------------------------- 1 | - address: null 2 | address_id: null 3 | admission: '2000-01-01T00:00:00' 4 | company_id: 3 5 | company_name: Terrans 6 | created_at: '2000-01-02T00:00:00' 7 | email: null 8 | firstname: Sarah 9 | id: 3 10 | lastname: Kerrigan 11 | - address: null 12 | address_id: null 13 | admission: '2000-01-01T00:00:00' 14 | company_id: 3 15 | company_name: Terrans 16 | created_at: '2000-01-02T00:00:00' 17 | email: null 18 | firstname: Jim 19 | id: 9 20 | lastname: Raynor 21 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_blueprint.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import Blueprint 3 | from flask_restalchemy import Api 4 | from flask_restalchemy.tests.sample_model import Company, Employee 5 | 6 | 7 | @pytest.fixture(autouse=True) 8 | def blueprint(flask_app): 9 | test_blueprint = Blueprint("test", __name__, url_prefix="/bp") 10 | api = Api(test_blueprint) 11 | api.add_model(Company) 12 | api.add_model(Employee) 13 | flask_app.register_blueprint(test_blueprint) 14 | 15 | 16 | def test_blueprint_api(client): 17 | resp = client.post("/bp/company", data={"name": "Mangsk Corp"}) 18 | assert resp.status_code == 201 19 | 20 | resp = client.post("/bp/company", data={"name": "Mangsk Corp"}) 21 | assert resp.status_code == 201 22 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_child_resource.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ESSS/flask-restalchemy/2e0e8e6c3768590620f06330d0bc02dbe697d4cd/src/flask_restalchemy/tests/test_child_resource.py -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from flask import request 4 | from werkzeug.exceptions import abort 5 | 6 | from flask_restalchemy import Api 7 | from flask_restalchemy.tests.sample_model import Address, Company 8 | from flask_restalchemy.resources.resources import ViewFunctionResource 9 | 10 | 11 | def auth_required(func): 12 | @wraps(func) 13 | def authenticate(*args, **kw): 14 | if not request.headers.get("auth"): 15 | abort(403) 16 | return func(*args, **kw) 17 | 18 | return authenticate 19 | 20 | 21 | def post_hook(func): 22 | @wraps(func) 23 | def authenticate(*args, **kw): 24 | response = func(*args, **kw) 25 | if isinstance(response, str): 26 | response = response + "post_hook" 27 | return response 28 | 29 | return authenticate 30 | 31 | 32 | def test_resource_decorators(client, flask_app): 33 | api = Api(flask_app) 34 | api.add_model(Company, request_decorators=[auth_required]) 35 | api.add_model(Address) 36 | 37 | assert client.get("/company").status_code == 403 38 | assert client.post("/company", data={"name": "Terran"}).status_code == 403 39 | assert client.get("/address").status_code == 200 40 | assert client.post("/address", data={"street": "5 Av"}).status_code == 201 41 | 42 | resp = client.post( 43 | "/company", data={"id": 2, "name": "Protoss"}, headers={"auth": True} 44 | ) 45 | assert resp.status_code == 201 46 | assert client.get("/company/2").status_code == 403 47 | assert client.get("/company/2", headers={"auth": True}).status_code == 200 48 | 49 | 50 | def test_api_decorators(client, flask_app): 51 | api = Api(flask_app, request_decorators=[auth_required]) 52 | api.add_model(Company) 53 | api.add_model(Address, request_decorators=[post_hook]) 54 | assert client.get("/company").status_code == 403 55 | 56 | assert ( 57 | client.post( 58 | "/company", data={"name": "Terran"}, headers={"auth": True} 59 | ).status_code 60 | == 201 61 | ) 62 | assert client.get("/company", headers={"auth": True}).status_code == 200 63 | 64 | response = client.post("/address", headers={"auth": True}) 65 | 66 | 67 | def test_add_rule_decorators(client, flask_app): 68 | def hello_world(): 69 | return "hello world" 70 | 71 | api = Api(flask_app, request_decorators=[auth_required]) 72 | api.add_url_rule( 73 | "/", "index", hello_world, request_decorators={"POST": [post_hook]} 74 | ) 75 | resp = client.get("/") 76 | assert resp.status_code == 403 77 | resp = client.post("/", headers={"auth": True}) 78 | assert resp.status_code == 200 79 | assert resp.data == b"hello worldpost_hook" 80 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_query_callback.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import json, request 3 | from flask_restalchemy.serialization import ModelSerializer, Field, NestedModelField 4 | 5 | from flask_restalchemy import Api 6 | from flask_restalchemy.tests.sample_model import ( 7 | Employee, 8 | Company, 9 | Department, 10 | Address, 11 | employee_department, 12 | db, 13 | ) 14 | 15 | 16 | class EmployeeSerializer(ModelSerializer): 17 | 18 | password = Field(load_only=True) 19 | created_at = Field(dump_only=True) 20 | company_name = Field(dump_only=True) 21 | address = NestedModelField(Address) 22 | 23 | 24 | @pytest.fixture(autouse=True) 25 | def create_test_sample(db_session): 26 | protoss = Company(id=1, name="Protoss") 27 | zerg = Company(id=2, name="Zerg") 28 | terrans = Company(id=3, name="Terrans") 29 | 30 | basic = Department(id=100, name="Basic") 31 | heroes = Department(id=200, name="Heroes") 32 | 33 | raynor = Employee( 34 | id=11, 35 | firstname="Jim", 36 | lastname="Raynor", 37 | company=terrans, 38 | departments=[basic, heroes], 39 | ) 40 | marine = Employee( 41 | id=12, firstname="John", lastname="Doe", company=terrans, departments=[basic] 42 | ) 43 | kerrigan = Employee( 44 | id=13, 45 | firstname="Sarah", 46 | lastname="Kerrigan", 47 | company=terrans, 48 | departments=[heroes], 49 | ) 50 | 51 | tassadar = Employee( 52 | id=21, 53 | firstname="Tassadar", 54 | lastname="The Warrior", 55 | company=protoss, 56 | departments=[heroes], 57 | ) 58 | zealot = Employee( 59 | id=22, firstname="Aiur", lastname="Zealot", company=protoss, departments=[basic] 60 | ) 61 | 62 | zergling = Employee( 63 | id=31, firstname="Rush", lastname="Lings", company=zerg, departments=[basic] 64 | ) 65 | 66 | db_session.add_all([protoss, zerg, terrans]) 67 | db_session.add_all([basic, heroes]) 68 | db_session.add_all([raynor, kerrigan, marine, tassadar, zealot, zergling]) 69 | db_session.commit() 70 | 71 | 72 | @pytest.fixture() 73 | def sample_api(flask_app): 74 | api = Api(flask_app) 75 | api.add_model(Employee) 76 | return api 77 | 78 | 79 | def sample_relation_query(parent_query, model): 80 | """ 81 | Query callback to get the collection of all Employee that has Department 'Basic' 82 | :param model: 83 | :param parent_query: 84 | :return: 85 | """ 86 | subquery = ( 87 | db.session.query(employee_department.c.employee_id) 88 | .filter(employee_department.c.department_id == 100) 89 | .subquery() 90 | ) 91 | if parent_query: 92 | query = parent_query.filter(model.id.in_(subquery)) 93 | else: 94 | query = model.query.filter(model.id.in_(subquery)) 95 | return query 96 | 97 | 98 | def sample_model_query(parent_query, model): 99 | """ 100 | Query callback to get the collection of all Companies only has 'Basic' 101 | :param model: 102 | :param parent_query: 103 | :return: 104 | """ 105 | all_heroes_subquery = ( 106 | db.session.query(employee_department.c.employee_id) 107 | .filter(employee_department.c.department_id == 200) 108 | .subquery() 109 | ) 110 | all_companies_heroes_only_subquery = ( 111 | db.session.query(Employee.company_id) 112 | .filter(Employee.id.in_(all_heroes_subquery)) 113 | .subquery() 114 | ) 115 | if parent_query: 116 | query = parent_query.filter( 117 | ~model.id.in_(all_companies_heroes_only_subquery) 118 | ) # ~ indicates NOT IN 119 | else: 120 | query = model.query.filter(~model.id.in_(all_companies_heroes_only_subquery)) 121 | return query 122 | 123 | 124 | def sample_property_query(parent_query, model): 125 | """ 126 | Query callback to get the collection of colleagues but not it self 127 | :param model: 128 | :param parent_query: 129 | :return: 130 | """ 131 | self_id = request.view_args["relation_id"] 132 | if parent_query: 133 | query = parent_query.filter(model.id != self_id) 134 | else: 135 | query = model.query.filter_by(model.id != self_id) 136 | return query 137 | 138 | 139 | def test_get_collection_relation_terrans(client, sample_api, data_regression): 140 | sample_api.add_model(Company) 141 | sample_api.add_relation( 142 | Company.employees, 143 | serializer_class=EmployeeSerializer, 144 | query_modifier=sample_relation_query, 145 | ) 146 | resp = client.get("/company/3/employees") 147 | assert resp.status_code == 200 148 | assert len(resp.json) == 2 149 | data = resp.get_json() 150 | data_regression.check(data) 151 | 152 | 153 | def test_get_collection_relation_protoss(client, sample_api, data_regression): 154 | sample_api.add_model(Company) 155 | sample_api.add_relation( 156 | Company.employees, 157 | serializer_class=EmployeeSerializer, 158 | query_modifier=sample_relation_query, 159 | ) 160 | resp = client.get("/company/1/employees") 161 | assert resp.status_code == 200 162 | assert len(resp.json) == 1 163 | data = resp.get_json() 164 | data_regression.check(data) 165 | 166 | 167 | def test_get_collection_model(client, sample_api, data_regression): 168 | sample_api.add_model(Company, query_modifier=sample_model_query) 169 | resp = client.get("/company") 170 | assert resp.status_code == 200 171 | assert len(resp.json) == 1 172 | data = resp.get_json() 173 | data_regression.check(data) 174 | 175 | 176 | def test_get_collection_property(client, sample_api, data_regression): 177 | sample_api.add_property( 178 | Employee, 179 | Employee, 180 | "colleagues", 181 | serializer_class=EmployeeSerializer, 182 | query_modifier=sample_property_query, 183 | ) 184 | resp = client.get("/employee/11/colleagues") 185 | assert resp.status_code == 200 186 | assert len(resp.json) == 2 187 | data = resp.get_json() 188 | data_regression.check(data) 189 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_query_callback/test_get_collection_model.yml: -------------------------------------------------------------------------------- 1 | - id: 2 2 | location: null 3 | name: Zerg 4 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_query_callback/test_get_collection_property.yml: -------------------------------------------------------------------------------- 1 | - address: null 2 | address_id: null 3 | admission: '2000-01-01T00:00:00' 4 | company_id: 3 5 | company_name: Terrans 6 | created_at: '2000-01-02T00:00:00' 7 | email: null 8 | firstname: John 9 | id: 12 10 | lastname: Doe 11 | - address: null 12 | address_id: null 13 | admission: '2000-01-01T00:00:00' 14 | company_id: 3 15 | company_name: Terrans 16 | created_at: '2000-01-02T00:00:00' 17 | email: null 18 | firstname: Sarah 19 | id: 13 20 | lastname: Kerrigan 21 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_query_callback/test_get_collection_relation_protoss.yml: -------------------------------------------------------------------------------- 1 | - address: null 2 | address_id: null 3 | admission: '2000-01-01T00:00:00' 4 | company_id: 1 5 | company_name: Protoss 6 | created_at: '2000-01-02T00:00:00' 7 | email: null 8 | firstname: Aiur 9 | id: 22 10 | lastname: Zealot 11 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_query_callback/test_get_collection_relation_terrans.yml: -------------------------------------------------------------------------------- 1 | - address: null 2 | address_id: null 3 | admission: '2000-01-01T00:00:00' 4 | company_id: 3 5 | company_name: Terrans 6 | created_at: '2000-01-02T00:00:00' 7 | email: null 8 | firstname: Jim 9 | id: 11 10 | lastname: Raynor 11 | - address: null 12 | address_id: null 13 | admission: '2000-01-01T00:00:00' 14 | company_id: 3 15 | company_name: Terrans 16 | created_at: '2000-01-02T00:00:00' 17 | email: null 18 | firstname: John 19 | id: 12 20 | lastname: Doe 21 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_querybuilder.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | from flask_restalchemy import Api 6 | from flask_restalchemy.tests.employer_serializer import EmployeeSerializer 7 | from flask_restalchemy.tests.sample_model import Company, Employee, Address 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def init_test_data(flask_app, db_session): 12 | for name, location in CLIENTS: 13 | company = Company(name=name, location=location) 14 | db_session.add(company) 15 | 16 | address = Address(street="5th Av.") 17 | emp1 = Employee(firstname="John", lastname="Doe", address=address) 18 | db_session.add(address) 19 | db_session.add(emp1) 20 | db_session.commit() 21 | 22 | api = Api(flask_app) 23 | api.add_model(Company) 24 | api.add_model(Employee, serializer_class=EmployeeSerializer) 25 | api.add_relation(Company.employees, serializer_class=EmployeeSerializer) 26 | return api 27 | 28 | 29 | def test_order(client): 30 | response = client.get("/company?order_by=name") 31 | data_list = response.get_json() 32 | assert data_list[0]["name"] == "abe" 33 | assert data_list[-1]["name"] == "Von" 34 | 35 | response = client.get("/company?order_by=-name") 36 | data_list = response.get_json() 37 | assert data_list[0]["name"] == "Von" 38 | assert data_list[1]["name"] == "vanessa" 39 | 40 | 41 | def test_order_by_relation(client, db_session): 42 | company = Company(name="Headquarters", location="Metropolis") 43 | db_session.add(company) 44 | db_session.flush() 45 | 46 | address_1 = Address(city="Gotham") 47 | address_2 = Address(city="Wakanda") 48 | address_3 = Address(city="Asgard") 49 | db_session.add(address_1) 50 | db_session.add(address_2) 51 | db_session.add(address_3) 52 | db_session.flush() 53 | 54 | employee_1 = Employee(firstname="Batman", company_id=company.id, address=address_1) 55 | employee_2 = Employee(firstname="Tchala", company_id=company.id, address=address_2) 56 | employee_3 = Employee(firstname="Thor", company_id=company.id, address=address_3) 57 | 58 | db_session.add(employee_1) 59 | db_session.add(employee_2) 60 | db_session.add(employee_3) 61 | db_session.commit() 62 | 63 | response = client.get(f"/company/{company.id}/employees") 64 | data_list = response.json 65 | assert len(data_list) == 3 66 | assert data_list[0]["address"]["city"] == "Gotham" 67 | assert data_list[1]["address"]["city"] == "Wakanda" 68 | assert data_list[2]["address"]["city"] == "Asgard" 69 | 70 | response = client.get(f"/company/{company.id}/employees?order_by=city") 71 | data_list = response.json 72 | assert data_list[0]["address"]["city"] == "Asgard" 73 | assert data_list[1]["address"]["city"] == "Gotham" 74 | assert data_list[2]["address"]["city"] == "Wakanda" 75 | 76 | response = client.get(f"/company/{company.id}/employees?order_by=-city") 77 | data_list = response.json 78 | assert data_list[0]["address"]["city"] == "Wakanda" 79 | assert data_list[1]["address"]["city"] == "Gotham" 80 | assert data_list[2]["address"]["city"] == "Asgard" 81 | 82 | 83 | def test_filter(client): 84 | response = client.get("/company") 85 | data_list = response.get_json() 86 | assert len(data_list) == 22 87 | 88 | response = client.get("/company?limit=5") 89 | data_list = response.get_json() 90 | assert len(data_list) == 5 91 | 92 | response = client.get('/company?filter={"name": "Alvin"}') 93 | data_list = response.get_json() 94 | assert len(data_list) == 1 95 | 96 | response = client.get('/company?filter={"name": {"eq": "Alvin"}}') 97 | data_list = response.get_json() 98 | assert len(data_list) == 1 99 | 100 | response = client.get( 101 | '/company?filter={"$or": {"name": "Alvin", "location": "pace"} }' 102 | ) 103 | data_list = response.get_json() 104 | assert len(data_list) == 2 105 | 106 | # test if AND is the default Logical Operator 107 | response = client.get('/company?filter={"name": "Keren", "location": "vilify"}') 108 | data_list = response.get_json() 109 | assert len(data_list) == 1 110 | 111 | response = client.get('/company?filter={"name": {"in": ["Alvin", "Keren"]} }') 112 | data_list = response.get_json() 113 | assert len(data_list) == 2 114 | 115 | response = client.get('/company?filter={"name": {"endswith": "a"} }') 116 | data_list = response.get_json() 117 | assert len(data_list) == 7 118 | 119 | response = client.get('/company?limit=2&filter={"name": {"endswith": "a"} }') 120 | data_list = response.get_json() 121 | assert len(data_list) == 2 122 | 123 | response = client.get('/company?limit=2&filter={"name": {"startswith": "Co"} }') 124 | data_list = response.get_json() 125 | assert len(data_list) == 2 126 | 127 | with pytest.raises(ValueError, match="Unknown operator unknown_operator"): 128 | client.get( 129 | "/company?filter={}".format( 130 | json.dumps({"name": {"unknown_operator": "Terr"}}) 131 | ) 132 | ) 133 | 134 | 135 | def test_pagination(client): 136 | response = client.get("/company?page=1&per_page=50") 137 | data_list = response.get_json() 138 | assert len(data_list.get("results")) == 22 139 | 140 | response = client.get("/company?page=1&per_page=5") 141 | data_list = response.get_json() 142 | assert len(data_list.get("results")) == 5 143 | 144 | response = client.get("/company?page=4&per_page=6") 145 | data_list = response.get_json() 146 | assert len(data_list.get("results")) == 4 147 | 148 | 149 | def test_relations_pagination(client): 150 | response = client.post("/company", data={"name": "Terrans 1"}) 151 | assert response.status_code == 201 152 | company_id = response.get_json()["id"] 153 | 154 | for i in range(20): 155 | client.post( 156 | f"/company/{company_id}/employees", data={"firstname": f"Jimmy {i}"} 157 | ) 158 | 159 | response = client.get( 160 | "/company/{}/employees?filter={}".format( 161 | company_id, json.dumps({"firstname": {"eq": "Jimmy 1"}}) 162 | ) 163 | ) 164 | assert response.status_code == 200 165 | data_list = response.get_json() 166 | assert len(data_list) == 1 167 | assert "firstname" in data_list[0] 168 | assert data_list[0]["firstname"] == "Jimmy 1" 169 | 170 | response = client.get(f"/company/{company_id}/employees?page=1&per_page=5") 171 | assert response.status_code == 200 172 | data_list = response.get_json() 173 | assert len(data_list.get("results")) == 5 174 | 175 | 176 | CLIENTS = [ 177 | ("Tyson", "syncretise"), 178 | ("Shandi", "pace"), 179 | ("Lurlene", "meteor"), 180 | ("Cornelius", "revengefulness"), 181 | ("Steven", "monosodium"), 182 | ("Von", "outswirl"), 183 | ("Lucille", "alcatraz"), 184 | ("Shawanda", "genotypic"), 185 | ("Erna", "preornamental"), 186 | ("Gonzalo", "unromanticized"), 187 | ("Glory", "cowtail"), 188 | ("Keren", "vilify"), 189 | ("Alvin", "genesis"), 190 | ("Melita", "numberless"), 191 | ("Lakia", "irretentive"), 192 | ("Joie", "peptonizer"), 193 | ("Jacqulyn", "underbidding"), 194 | ("Julius", "alcmene"), 195 | ("Coretta", "furcated"), 196 | ("Laverna", "mastaba"), 197 | ("abe", "abesee"), 198 | ("vanessa", "vans"), 199 | ] 200 | -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_simple_api.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ESSS/flask-restalchemy/2e0e8e6c3768590620f06330d0bc02dbe697d4cd/src/flask_restalchemy/tests/test_simple_api.py -------------------------------------------------------------------------------- /src/flask_restalchemy/tests/test_swagger_spec_gen.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flask_restalchemy.tests.sample_model import Employee 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "method, expected_response_codes, expected_tag", 8 | [ 9 | pytest.param("get", {"200", "400", "404"}, "Employee", marks=pytest.mark.xfail), 10 | pytest.param("put", {"204", "400", "404"}, "Employee", marks=pytest.mark.xfail), 11 | pytest.param( 12 | "delete", {"204", "400", "404"}, "Employee", marks=pytest.mark.xfail 13 | ), 14 | ], 15 | ) 16 | def test_item_resource_spec_gen(method, expected_response_codes, expected_tag): 17 | serializer = EmployeeSerializer(Employee) 18 | resource = item_resource_factory(ItemResource, serializer) 19 | specs = getattr(resource, method).specs_dict 20 | assert specs["tags"] == [expected_tag] 21 | assert specs["responses"].keys() == expected_response_codes 22 | 23 | 24 | @pytest.mark.parametrize( 25 | "method, expected_response_codes, expected_tag", 26 | [ 27 | pytest.param("get", {"200"}, "Employee", marks=pytest.mark.xfail), 28 | pytest.param("post", {"201", "405"}, "Employee", marks=pytest.mark.xfail), 29 | ], 30 | ) 31 | def test_collection_resource_spec_gen(method, expected_response_codes, expected_tag): 32 | serializer = EmployeeSerializer(Employee) 33 | resource = collection_resource_factory(CollectionResource, serializer) 34 | specs = getattr(resource, method).specs_dict 35 | assert specs["tags"] == [expected_tag] 36 | assert specs["responses"].keys() == expected_response_codes 37 | 38 | 39 | @pytest.mark.xfail 40 | def test_schema(): 41 | resource = item_resource_factory(ItemResource, EmployeeSerializer(Employee)) 42 | specs_get_method = resource.get.specs_dict 43 | schema_props = specs_get_method["definitions"]["Employee"]["properties"] 44 | assert set(schema_props.keys()).issuperset({"created_at", "company_name"}) 45 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{36,37,38,39,310}-sqla{13,14}, linting, docs 3 | isolated_build = true 4 | 5 | [gh-actions] 6 | python = 7 | 3.6: py36 8 | 3.7: py37 9 | 3.8: py38 10 | 3.9: py39 11 | 3.10: py310 12 | 13 | [testenv] 14 | extras = testing 15 | commands = 16 | pytest --cov={envsitepackagesdir}/flask_restalchemy --cov-report=xml --pyargs flask_restalchemy 17 | deps = 18 | sqla13: sqlalchemy>=1.3,<1.4 19 | sqla14: sqlalchemy>=1.4,<2 20 | 21 | [testenv:docs] 22 | skipsdist = True 23 | usedevelop = True 24 | changedir = docs 25 | extras = docs 26 | commands = 27 | sphinx-build -W -b html . _build 28 | 29 | 30 | [testenv:linting] 31 | skip_install = True 32 | basepython = python3.7 33 | deps = pre-commit>=1.11.0 34 | commands = pre-commit run --all-files --show-diff-on-failure 35 | --------------------------------------------------------------------------------