├── .coveragerc ├── .gitignore ├── .travis.yml ├── CHANGES.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile ├── conf.py ├── errors.rst ├── flask.rst ├── index.rst ├── make.bat ├── models.rst ├── quickstart.rst └── serializer.rst ├── pytest.ini ├── requirements.in ├── requirements.txt ├── setup.py ├── sqlalchemy_jsonapi ├── __init__.py ├── _version.py ├── constants.py ├── declarative │ ├── __init__.py │ └── serializer.py ├── errors.py ├── flaskext.py ├── serializer.py ├── tests │ ├── app.py │ ├── conftest.py │ ├── test_collection_get.py │ ├── test_collection_post.py │ ├── test_related_get.py │ ├── test_relationship_delete.py │ ├── test_relationship_get.py │ ├── test_relationship_patch.py │ ├── test_relationship_post.py │ ├── test_resource_delete.py │ ├── test_resource_get.py │ ├── test_resource_patch.py │ └── test_serializer.py └── unittests │ ├── __init__.py │ ├── declarative_tests │ ├── __init__.py │ └── test_serialize.py │ ├── models.py │ ├── test_errors_user_error.py │ ├── test_serializer_delete_relationship.py │ ├── test_serializer_delete_resource.py │ ├── test_serializer_get_collection.py │ ├── test_serializer_get_related.py │ ├── test_serializer_get_relationship.py │ ├── test_serializer_patch_relationship.py │ ├── test_serializer_patch_resource.py │ ├── test_serializer_post_collection.py │ ├── test_serializer_post_relationship.py │ └── utils │ ├── __init__.py │ └── testcases.py ├── test.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit=sqlalchemy_jsonapi/tests/*, .tox/*, sqlalchemy_jsonapi/unittests/* 3 | [report] 4 | show_missing = True -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io 2 | 3 | ### Python ### 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | # Created by https://www.gitignore.io 63 | 64 | ### VirtualEnv ### 65 | # Virtualenv 66 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 67 | .Python 68 | [Bb]in 69 | [Ii]nclude 70 | [Ll]ib 71 | [Ss]cripts 72 | pyvenv.cfg 73 | pip-selfcheck.json 74 | 75 | # Editors 76 | *.swp 77 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.4" 4 | - "2.7" 5 | - "3.5" 6 | install: 7 | - "pip install ." 8 | - "pip install -r requirements.txt" 9 | script: 10 | - py.test ./sqlalchemy_jsonapi/tests/ 11 | - nosetests ./sqlalchemy_jsonapi/unittests/ -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # SQLAlchemy-JSONAPI Changelog 2 | 3 | ## 4.0.9 4 | 5 | *Unreleased* 6 | 7 | * Fixed bug during testing in delete_relationship where returned resource was missing data key 8 | * Fixed bug during testing in patch_resource where field check was failing 9 | 10 | ## 4.0.8 11 | 12 | *2016-04-32* 13 | 14 | * Fixed bug with relationship not being found during resource patch. 15 | 16 | ## 4.0.5 - 4.0.7 17 | 18 | *2016-03-20* 19 | 20 | * Fixed missing jsonapi_permissions attribute when patching relationships. 21 | * Fixed AttributeError on GET related of a NoneType. 22 | * Fixed where meta was now always being injected into non-204 responses. 23 | 24 | ## 4.0.4 25 | 26 | *2016-02-27* 27 | 28 | * Fixed session being flushed error during POST to collection. 29 | 30 | ## 4.0.1 - 4.0.3 31 | 32 | *2016-01-21* 33 | 34 | * Fixed bug where the wrong map was being checked when creating new resource 35 | * Fixed content-length bug when DELETE is provided for resources 36 | 37 | ## 4.0.0 38 | 39 | *2016-01-20* 40 | 41 | * BREAKING: Keys and types are now dasherized instead of underscored to fit assumptions of spec implementation 42 | * BREAKING: Relationships are now lazy by default. Using the include query parameter will trigger an eager load. 43 | * Added links to relationships 44 | 45 | ## 3.0.2 46 | 47 | *2015-10-05* 48 | 49 | * Removed hard dependency on Flask, as suggested by @bladams in pull request #10 50 | * Fixed autoflush on collection post as proposed by @emilecaron in issue #13 51 | * Fixed issues when encountering integer IDs #11 52 | * Made API Type Name overridable. #9 53 | 54 | ## 3.0.1 55 | 56 | *2015-09-22* 57 | 58 | * Removed an artifact from debugging. Sorry about that to anybody who was confused! 59 | 60 | ## 3.0.0 61 | 62 | *2015-09-20* 63 | 64 | * BREAKING: Implements #8 where `__jsonapi_type__` is replaced with `__jsonapi_type_override__`. This can break previous configurations where `__jsonapi_type__` was used to override the generated type. To fix breaks, just change it to `__jsonapi_type_override__` and it should work better. Thank you, @angelosarto for your contribution on this. 65 | 66 | ## 2.1.11 67 | 68 | *2015-09-20* 69 | 70 | * Fixed issue where not providing a relationships key would cause a POST or PATCH request to fail 71 | 72 | ## 2.1.10 73 | 74 | *2015-09-20* 75 | 76 | * Fixes compatibility with Sentry when running unit tests. 204 errors still need content. 77 | 78 | ## 2.1.9 79 | 80 | *2015-09-20* 81 | 82 | * Fixed issue where incomplete models get committed to multiple relationships before they earn redeeming attributes 83 | 84 | ## 2.1.8 85 | 86 | *2015-09-19* 87 | 88 | * Fixed issue where local columns for relationships were still appearing in responses 89 | 90 | ## 2.1.7 91 | 92 | *2015-09-19* 93 | 94 | * Fixed reference before assignment error 95 | 96 | I apologize for rapid fire updates, but this is being developed alongside another project so it's trying to keep up with the main project. 97 | 98 | ## 2.1.6 99 | 100 | *2015-09-19* 101 | 102 | * Fixed error when TypeError is raised in descriptors 103 | 104 | ## 2.1.5 105 | 106 | *2015-09-19* 107 | 108 | * Permissions and actions can now be provided as sets. 109 | * Fixed problem where users think libraries don't update often enough by pushing out 4th update on a single date 110 | 111 | ## 2.1.4 112 | 113 | *2015-09-19* 114 | 115 | * JSONAPIEncoder is now replaceable in the flask extension 116 | 117 | ## 2.1.3 118 | 119 | *2015-09-19* 120 | 121 | * Trailing slashes are now optional. Idea based on change by @emilecaron. 122 | 123 | ## 2.1.2 124 | 125 | *2015-09-19* 126 | 127 | * Fixed subset comparison that prevented setting relationships when all relationships were used 128 | 129 | ## 2.1.1 130 | 131 | *2015-09-18* 132 | 133 | * Fixed api_key error. My fault, killed pytest too early. 134 | 135 | ## 2.1.0 136 | 137 | *2015-09-18* 138 | 139 | * Added wrapping/chaining of handlers via a simple decorator 140 | 141 | ## 2.0.3 142 | 143 | *2015-09-17* 144 | 145 | * Merged #7 by @angelosarto. Fixed type of related items returned in relationships. 146 | 147 | ## 2.0.2 148 | 149 | *2015-09-16* 150 | 151 | * Fixes Python 2.7 compatibility 152 | 153 | ## 2.0.1 154 | 155 | *2015-09-16* 156 | 157 | * Fixed #6 where flask may conflict with flask package on Python 2.7. 158 | 159 | ## 2.0.0 - Interface Fix 160 | 161 | *2015-08-29* 162 | 163 | * BREAKING Replaced dict params with api_type, obj_id, and rel_key 164 | 165 | ## 1.0.0 - Start of 1.0 Compatibility 166 | 167 | *2015-08-24* 168 | 169 | * BREAKING Complete rewrite for JSON API 1.0 compatibility 170 | * CHANGED Switching to Semantic Versioning 171 | 172 | ## 0.2 - Querying and View Permissions 173 | 174 | *2014-07-31* 175 | 176 | * Changed `to_serialize` in `JSONAPI.serialize` from expecting a list or collection to also expecting a query. 177 | * Added `get_api_key` in `JSONAPI` that generates the key for the main requested resource. 178 | * Added `fields`, `sort`, and `include` to `JSONAPI.serialize`. 179 | * BREAKING Changed `jsonapi_*` properties to have more uniform names. 180 | * Fixed `as_relationship` where columns weren't being set as local_columns due to SQLAlchemy-JSONAPI's developer's mistake. 181 | * Fixed converters where it would return a KeyError. 182 | * Added `jsonapi_can_view()` to `JSONAPIMixin`. 183 | 184 | ## 0.1 - Initial 185 | 186 | *2014-07-20* 187 | 188 | * Added `JSONAPI`, `JSONAPIMixin`, and `as_relationship` 189 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Colton J. Provias 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. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | README.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SQLAlchemy-JSONAPI 2 | 3 | [![Build Status](https://travis-ci.org/ColtonProvias/sqlalchemy-jsonapi.svg?branch=master)](https://travis-ci.org/ColtonProvias/sqlalchemy-jsonapi) 4 | 5 | [JSON API](http://jsonapi.org/) implementation for use with 6 | [SQLAlchemy](http://www.sqlalchemy.org/). 7 | 8 | SQLAlchemy-JSONAPI aims to implement the JSON API spec and to make it as simple 9 | to use and implement as possible. 10 | 11 | * [Documentation](http://sqlalchemy-jsonapi.readthedocs.org) 12 | 13 | ## Installation 14 | 15 | ```shell 16 | pip install sqlalchemy-jsonapi 17 | ``` 18 | 19 | ## Quick usage with Flask-SQLAlchemy 20 | 21 | ```py 22 | # Assuming FlaskSQLAlchemy is db and your Flask app is app: 23 | from sqlalchemy_jsonapi import FlaskJSONAPI 24 | 25 | api = FlaskJSONAPI(app, db) 26 | 27 | # Or, for factory-style applications 28 | api = FlaskJSONAPI() 29 | api.init_app(app, db) 30 | ``` 31 | 32 | ## Quick usage without Flask 33 | 34 | ```py 35 | # Assuming declarative base is called Base 36 | from sqlalchemy_jsonapi import JSONAPI 37 | api = JSONAPI(Base) 38 | 39 | # And assuming a SQLAlchemy session 40 | print(api.get_collection(session, {}, 'resource-type')) 41 | ``` -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/SQLAlchemy-JSONAPI.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/SQLAlchemy-JSONAPI.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/SQLAlchemy-JSONAPI" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/SQLAlchemy-JSONAPI" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # SQLAlchemy-JSONAPI documentation build configuration file, created by 5 | # sphinx-quickstart on Thu Aug 6 19:57:43 2015. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | import shlex 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | #sys.path.insert(0, os.path.abspath('.')) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | #needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', ] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # The suffix(es) of source filenames. 39 | # You can specify multiple suffix as a list of string: 40 | # source_suffix = ['.rst', '.md'] 41 | source_suffix = '.rst' 42 | 43 | # The encoding of source files. 44 | #source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = 'SQLAlchemy-JSONAPI' 51 | copyright = '2015, Colton Provias' 52 | author = 'Colton Provias' 53 | 54 | # The version info for the project you're documenting, acts as replacement for 55 | # |version| and |release|, also used in various other places throughout the 56 | # built documents. 57 | # 58 | # The short X.Y version. 59 | version = '1.0.0' 60 | # The full version, including alpha/beta/rc tags. 61 | release = '1.0.0' 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = None 69 | 70 | # There are two options for replacing |today|: either, you set today to some 71 | # non-false value, then it is used: 72 | #today = '' 73 | # Else, today_fmt is used as the format for a strftime call. 74 | #today_fmt = '%B %d, %Y' 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | exclude_patterns = ['_build'] 79 | 80 | # The reST default role (used for this markup: `text`) to use for all 81 | # documents. 82 | #default_role = None 83 | 84 | # If true, '()' will be appended to :func: etc. cross-reference text. 85 | #add_function_parentheses = True 86 | 87 | # If true, the current module name will be prepended to all description 88 | # unit titles (such as .. function::). 89 | #add_module_names = True 90 | 91 | # If true, sectionauthor and moduleauthor directives will be shown in the 92 | # output. They are ignored by default. 93 | #show_authors = False 94 | 95 | # The name of the Pygments (syntax highlighting) style to use. 96 | pygments_style = 'sphinx' 97 | 98 | # A list of ignored prefixes for module index sorting. 99 | #modindex_common_prefix = [] 100 | 101 | # If true, keep warnings as "system message" paragraphs in the built documents. 102 | #keep_warnings = False 103 | 104 | # If true, `todo` and `todoList` produce output, else they produce nothing. 105 | todo_include_todos = False 106 | 107 | # -- Options for HTML output ---------------------------------------------- 108 | 109 | # The theme to use for HTML and HTML Help pages. See the documentation for 110 | # a list of builtin themes. 111 | #html_theme = 'alabaster' 112 | 113 | # Theme options are theme-specific and customize the look and feel of a theme 114 | # further. For a list of options available for each theme, see the 115 | # documentation. 116 | #html_theme_options = {} 117 | 118 | # Add any paths that contain custom themes here, relative to this directory. 119 | #html_theme_path = [] 120 | 121 | # The name for this set of Sphinx documents. If None, it defaults to 122 | # " v documentation". 123 | #html_title = None 124 | 125 | # A shorter title for the navigation bar. Default is the same as html_title. 126 | #html_short_title = None 127 | 128 | # The name of an image file (relative to this directory) to place at the top 129 | # of the sidebar. 130 | #html_logo = None 131 | 132 | # The name of an image file (within the static path) to use as favicon of the 133 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 134 | # pixels large. 135 | #html_favicon = None 136 | 137 | # Add any paths that contain custom static files (such as style sheets) here, 138 | # relative to this directory. They are copied after the builtin static files, 139 | # so a file named "default.css" will overwrite the builtin "default.css". 140 | html_static_path = ['_static'] 141 | 142 | # Add any extra paths that contain custom files (such as robots.txt or 143 | # .htaccess) here, relative to this directory. These files are copied 144 | # directly to the root of the documentation. 145 | #html_extra_path = [] 146 | 147 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 148 | # using the given strftime format. 149 | #html_last_updated_fmt = '%b %d, %Y' 150 | 151 | # If true, SmartyPants will be used to convert quotes and dashes to 152 | # typographically correct entities. 153 | #html_use_smartypants = True 154 | 155 | # Custom sidebar templates, maps document names to template names. 156 | #html_sidebars = {} 157 | 158 | # Additional templates that should be rendered to pages, maps page names to 159 | # template names. 160 | #html_additional_pages = {} 161 | 162 | # If false, no module index is generated. 163 | #html_domain_indices = True 164 | 165 | # If false, no index is generated. 166 | #html_use_index = True 167 | 168 | # If true, the index is split into individual pages for each letter. 169 | #html_split_index = False 170 | 171 | # If true, links to the reST sources are added to the pages. 172 | #html_show_sourcelink = True 173 | 174 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 175 | #html_show_sphinx = True 176 | 177 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 178 | #html_show_copyright = True 179 | 180 | # If true, an OpenSearch description file will be output, and all pages will 181 | # contain a tag referring to it. The value of this option must be the 182 | # base URL from which the finished HTML is served. 183 | #html_use_opensearch = '' 184 | 185 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 186 | #html_file_suffix = None 187 | 188 | # Language to be used for generating the HTML full-text search index. 189 | # Sphinx supports the following languages: 190 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 191 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 192 | #html_search_language = 'en' 193 | 194 | # A dictionary with options for the search language support, empty by default. 195 | # Now only 'ja' uses this config value 196 | #html_search_options = {'type': 'default'} 197 | 198 | # The name of a javascript file (relative to the configuration directory) that 199 | # implements a search results scorer. If empty, the default will be used. 200 | #html_search_scorer = 'scorer.js' 201 | 202 | # Output file base name for HTML help builder. 203 | htmlhelp_basename = 'SQLAlchemy-JSONAPIdoc' 204 | 205 | # -- Options for LaTeX output --------------------------------------------- 206 | 207 | latex_elements = { 208 | # The paper size ('letterpaper' or 'a4paper'). 209 | #'papersize': 'letterpaper', 210 | # The font size ('10pt', '11pt' or '12pt'). 211 | #'pointsize': '10pt', 212 | # Additional stuff for the LaTeX preamble. 213 | #'preamble': '', 214 | # Latex figure (float) alignment 215 | #'figure_align': 'htbp', 216 | } 217 | 218 | # Grouping the document tree into LaTeX files. List of tuples 219 | # (source start file, target name, title, 220 | # author, documentclass [howto, manual, or own class]). 221 | latex_documents = [ 222 | (master_doc, 'SQLAlchemy-JSONAPI.tex', 'SQLAlchemy-JSONAPI Documentation', 223 | 'Colton Provias', 'manual'), 224 | ] 225 | 226 | # The name of an image file (relative to this directory) to place at the top of 227 | # the title page. 228 | #latex_logo = None 229 | 230 | # For "manual" documents, if this is true, then toplevel headings are parts, 231 | # not chapters. 232 | #latex_use_parts = False 233 | 234 | # If true, show page references after internal links. 235 | #latex_show_pagerefs = False 236 | 237 | # If true, show URL addresses after external links. 238 | #latex_show_urls = False 239 | 240 | # Documents to append as an appendix to all manuals. 241 | #latex_appendices = [] 242 | 243 | # If false, no module index is generated. 244 | #latex_domain_indices = True 245 | 246 | # -- Options for manual page output --------------------------------------- 247 | 248 | # One entry per manual page. List of tuples 249 | # (source start file, name, description, authors, manual section). 250 | man_pages = [ 251 | (master_doc, 'sqlalchemy-jsonapi', 'SQLAlchemy-JSONAPI Documentation', 252 | [author], 1) 253 | ] 254 | 255 | # If true, show URL addresses after external links. 256 | #man_show_urls = False 257 | 258 | # -- Options for Texinfo output ------------------------------------------- 259 | 260 | # Grouping the document tree into Texinfo files. List of tuples 261 | # (source start file, target name, title, author, 262 | # dir menu entry, description, category) 263 | texinfo_documents = [ 264 | (master_doc, 'SQLAlchemy-JSONAPI', 'SQLAlchemy-JSONAPI Documentation', 265 | author, 'SQLAlchemy-JSONAPI', 'One line description of project.', 266 | 'Miscellaneous'), 267 | ] 268 | 269 | # Documents to append as an appendix to all manuals. 270 | #texinfo_appendices = [] 271 | 272 | # If false, no module index is generated. 273 | #texinfo_domain_indices = True 274 | 275 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 276 | #texinfo_show_urls = 'footnote' 277 | 278 | # If true, do not generate a @detailmenu in the "Top" node's menu. 279 | #texinfo_no_detailmenu = False 280 | 281 | # Example configuration for intersphinx: refer to the Python standard library. 282 | intersphinx_mapping = {'https://docs.python.org/': None} 283 | 284 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 285 | 286 | if not on_rtd: 287 | import sphinx_rtd_theme 288 | html_theme = 'sphinx_rtd_theme' 289 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 290 | -------------------------------------------------------------------------------- /docs/errors.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Errors 3 | ====== -------------------------------------------------------------------------------- /docs/flask.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Flask 3 | ===== 4 | 5 | .. currentmodule:: sqlalchemy_jsonapi.flask 6 | 7 | To those who use Flask, setting up SQLAlchemy-JSONAPI can be extremely complex 8 | and frustrating. Let's look at an example:: 9 | 10 | from sqlalchemy_jsonapi import FlaskJSONAPI 11 | 12 | app = Flask(__name__) 13 | db = SQLAlchemy(app) 14 | api = FlaskJSONAPI(app, db) 15 | 16 | And after all that work, you should now have a full working API. 17 | 18 | Signals 19 | ======= 20 | 21 | As Flask makes use of signals via Blinker, it would be appropriate to make use 22 | of them in the Flask module for SQLALchemy-JSONAPI. If a signal receiver 23 | returns a value, it can alter the final response. 24 | 25 | on_request 26 | ---------- 27 | 28 | Triggered before serialization:: 29 | 30 | @api.on_request.connect 31 | def process_api_request(sender, method, endpoint, data, req_args): 32 | # Handle the request 33 | 34 | on_success 35 | ---------- 36 | 37 | Triggered after successful serialization:: 38 | 39 | @api.on_success.connect 40 | def process_api_success(sender, method, endpoint, data, req_args, response): 41 | # Handle the response dictionary 42 | 43 | 44 | on_error 45 | -------- 46 | 47 | Triggered after failed handling:: 48 | 49 | @api.on_error.connect 50 | def process_api_error(sender, method, endpoint, data, req_args, error): 51 | # Handle the error 52 | 53 | on_response 54 | ----------- 55 | 56 | Triggered after rendering of response:: 57 | 58 | @api.on_response.connect 59 | def process_api_response(sender, method, endpoint, data, req_args, rendered_response): 60 | # Handle the rendered response 61 | 62 | Wrapping the Handlers 63 | ===================== 64 | 65 | While signals provide some control, sometimes you want to wrap or override the handler for the particular endpoint and method. This can be done through a specialized decorator that allows you to specify in what cases you want the handler wrapped:: 66 | 67 | @api.wrap_handler(['users'], [Methods.GET], [Endpoints.COLLECTION, Endpoints.RESOURCE]) 68 | def log_it(next, *args, **kwargs): 69 | logging.info('In a wrapped handler!') 70 | return next(*args, **kwargs) 71 | 72 | Handlers are placed into a list and run in order of placement within the list. That means you can perform several layers of checks and override as needed. 73 | 74 | API 75 | === 76 | 77 | .. autoclass:: FlaskJSONAPI 78 | :members: 79 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | SQLAlchemy-JSONAPI 3 | ================== 4 | 5 | Contents: 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | quickstart 11 | models 12 | serializer 13 | flask 14 | errors 15 | 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | 24 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 2> nul 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\SQLAlchemy-JSONAPI.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\SQLAlchemy-JSONAPI.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /docs/models.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | Preparing Your Models 3 | ===================== 4 | 5 | Validation 6 | ========== 7 | SQLAlchemy-JSONAPI makes use of the SQLAlchemy validates decorator:: 8 | 9 | from sqlalchemy.orm import validates 10 | 11 | class User(Base): 12 | email = Column(Unicode(255)) 13 | 14 | @validates('email') 15 | def validate_email(self, key, email): 16 | """ Ultra-strong email validation. """ 17 | assert '@' in email, 'Not an email' 18 | return email 19 | 20 | Now raise your hand if you knew SQLAlchemy had that decorator. Well, now you 21 | know, and it's quite useful! 22 | 23 | Attribute Descriptors 24 | ===================== 25 | 26 | Sometimes, you may need to provide your own getters and setters to attributes:: 27 | 28 | from sqlalchemy_jsonapi import attr_descriptor, AttributeActions 29 | 30 | class User(Base): 31 | id = Column(UUIDType) 32 | # ... 33 | 34 | @attr_descriptor(AttributeActions.GET, 'id') 35 | def id_getter(self): 36 | return str(self.id) 37 | 38 | @attr_descriptor(AttributeActions.SET, 'id') 39 | def id_setter(self, new_id): 40 | self.id = UUID(new_id) 41 | 42 | Note: id is not able to be altered after initial setting in JSON API to keep it 43 | consistent. 44 | 45 | Relationship Descriptors 46 | ======================== 47 | 48 | Relationship's come in two flavors: to-one and to-many (or tranditional and 49 | LDS-flavored if you prefer those terms). To one descriptors have the actions 50 | GET and SET:: 51 | 52 | from sqlalchemy_jsonapi import relationship_descriptor, RelationshipActions 53 | 54 | @relationship_descriptor(RelationshipActions.GET, 'significant_other') 55 | def getter(self): 56 | # ... 57 | 58 | @relationship_descriptor(RelationshipActions.SET, 'significant_other') 59 | def setter(self, value): 60 | # ... 61 | 62 | To-many have GET, APPEND, and DELETE:: 63 | 64 | @relationship_descriptor(RelationshipActions.GET, 'angry_exes') 65 | def getter(self): 66 | # ... 67 | 68 | @relationship_descriptor(RelationshipActions.APPEND, 'angry_exes') 69 | def appender(self): 70 | # ... 71 | 72 | @relationship_descriptor(RelationshipActions.DELETE, 'angry_exes') 73 | def remover(self): 74 | # ... 75 | 76 | 77 | Permission Testing 78 | ================== 79 | 80 | Permissions are a complex challenge in relational databases. While the 81 | solution provided right now is extremely simple, it is almost guaranteed to 82 | evolve and change drastically as this library gets used more in production. 83 | Thus it is advisable that on every major version number increment, you should 84 | check this section for changes to permissions. 85 | 86 | Anyway, there are currently four permissions that are checked: GET, CREATE, 87 | EDIT, and DELETE. Permission tests can be applied module-wide or to specific 88 | fields:: 89 | 90 | @permission_test(Permissions.VIEW) 91 | def can_view(self): 92 | return self.is_published 93 | 94 | @permission_test(Permissions.EDIT, 'slug') 95 | def can_edit_slug(self): 96 | return False 97 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Quickstart 3 | ========== 4 | .. currentmodule:: sqlalchemy_jsonapi 5 | 6 | Installation 7 | ============ 8 | 9 | Installation of SQLAlchemy-JSONAPI can be done via pip:: 10 | 11 | pip install -U sqlalchemy_jsonapi 12 | 13 | Attaching to Declarative Base 14 | ============================= 15 | 16 | To initialize the serializer, you first have to attach it to an instance of 17 | SQLAlchemy's Declarative Base that is connected to your models:: 18 | 19 | from sqlalchemy_jsonapi import JSONAPI 20 | 21 | class User(Base): 22 | __tablename__ = 'users' 23 | id = Column(UUIDType, primary_key=True) 24 | # ... 25 | 26 | class Address(Base): 27 | __tablename__ = 'address' 28 | id = Column(UUIDType, primary_key=True) 29 | user_id = Column(UUIDType, ForeignKey('users.id')) 30 | # ... 31 | 32 | serializer = JSONAPI(Base) 33 | 34 | Serialization 35 | ============= 36 | 37 | Now that your serializer is initialized, you can quickly and easily serialize 38 | your models. Let's do a simple collection serialization:: 39 | 40 | @app.route('/api/users') 41 | def users_list(): 42 | response = serializer.get_collection(db.session, {}, 'users') 43 | return jsonify(response.data) 44 | 45 | The third argument to `get_collection` where `users` is specified is 46 | the model type. This is auto-generated from the model name, but you 47 | can control this using `__jsonapi_type_override__`. 48 | 49 | This is useful when you don't want hyphenated type names. For example, 50 | a model named `UserConfig` will have a generated type of `user-config`. 51 | You can change this declaratively on the model:: 52 | 53 | class UserConfig(Base): 54 | __tablename__ = 'userconfig' 55 | __jsonapi_type_override__ = 'userconfig' 56 | 57 | Deserialization 58 | =============== 59 | 60 | Deserialization is also quick and easy:: 61 | 62 | @app.route('/api/users/', methods=['PATCH']) 63 | def update_user(user_id): 64 | json_data = request.get_json(force=True) 65 | response = serializer.patch_resource(db.session, json_data, 'users', user_id) 66 | return jsonify(response.data) 67 | 68 | If you use Flask, this can be automated and simplified via the included Flask 69 | module. 70 | -------------------------------------------------------------------------------- /docs/serializer.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Serializer 3 | ========== -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = lib include bin docs *.egg .* -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | SQLAlchemy 2 | inflection 3 | flask 4 | blinker 5 | bcrypt 6 | fake-factory 7 | Flask-SQLAlchemy 8 | passlib 9 | pathtools 10 | SQLAlchemy-Utils 11 | pytest 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile --output-file requirements.txt requirements.in 6 | # 7 | 8 | bcrypt==2.0.0 9 | blinker==1.4 10 | cffi==1.7.0 # via bcrypt 11 | click==6.6 # via flask 12 | fake-factory==0.5.2 13 | Flask-SQLAlchemy==2.0 14 | flask==0.11.1 15 | inflection==0.3.1 16 | itsdangerous==0.24 # via flask 17 | jinja2==2.8 # via flask 18 | markupsafe==0.23 # via jinja2 19 | passlib==1.6.5 20 | pathtools==0.1.2 21 | py==1.4.31 # via pytest 22 | pycparser==2.14 # via cffi 23 | pytest==2.7.3 24 | six==1.10.0 # via bcrypt, sqlalchemy-utils 25 | SQLAlchemy-Utils==0.30.16 26 | sqlalchemy==1.0.13 27 | werkzeug==0.11.10 # via flask 28 | nose==1.3.7 29 | coverage==4.3.4 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | SQLAlchemy-JSONAPI 3 | ------------------ 4 | 5 | JSON API serializer for SQLAlchemy that aims to meet the full JSON API spec as 6 | published at http://jsonapi.org/format. Also includes Flask adapter. 7 | 8 | Full documentation is available at: 9 | 10 | https://sqlalchemy-jsonapi.readthedocs.org/ 11 | 12 | GitHub at https://github.com/coltonprovias/sqlalchemy-jsonapi 13 | """ 14 | 15 | from setuptools import setup 16 | import sys 17 | 18 | requirements = ['SQLAlchemy', 'inflection'] 19 | 20 | if sys.version_info[0] != 3 or sys.version_info[1] < 4: 21 | requirements.append('enum34') 22 | 23 | # XXX: deryck (2016 April 6) __version__ is defined twice. 24 | # __version__ is defined here and in sqlalchemy_jsonapi.__version__ 25 | # but we can't import it since __init__ imports literally everything. 26 | # The constants and serializer files depend on enum34 which has to be 27 | # conditionally installed. Once we stop supporting Python 2.7, 28 | # this version string can also be imported as sqlalchemy_jsonapi.__version__. 29 | __version__ = '4.0.9' 30 | 31 | setup(name='SQLAlchemy-JSONAPI', 32 | version=__version__, 33 | url='http://github.com/coltonprovias/sqlalchemy-jsonapi', 34 | license='MIT', 35 | author='Colton J. Provias', 36 | author_email='cj@coltonprovias.com', 37 | description='JSONAPI Mixin for SQLAlchemy', 38 | long_description=__doc__, 39 | packages=['sqlalchemy_jsonapi'], 40 | zip_safe=False, 41 | include_package_data=True, 42 | platforms='any', 43 | install_requires=requirements, 44 | classifiers=[ 45 | 'Development Status :: 5 - Production/Stable', 46 | 'Environment :: Web Environment', 47 | 'Framework :: Flask', 48 | 'Intended Audience :: Developers', 49 | 'License :: OSI Approved :: MIT License', 50 | 'Natural Language :: English', 51 | 'Operating System :: OS Independent', 52 | 'Programming Language :: Python :: 3.4', 53 | 'Programming Language :: Python :: 3.5', 54 | 'Topic :: Software Development :: Libraries :: Python Modules' 55 | ]) 56 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .constants import Endpoint, Method # NOQA 2 | from .serializer import ( # NOQA 3 | ALL_PERMISSIONS, INTERACTIVE_PERMISSIONS, JSONAPI, AttributeActions, 4 | Permissions, RelationshipActions, attr_descriptor, permission_test, 5 | relationship_descriptor) 6 | from ._version import __version__ # NOQA 7 | 8 | try: 9 | from .flaskext import FlaskJSONAPI 10 | except ImportError: 11 | FlaskJSONAPI = None 12 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = '4.0.9' 2 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | SQLAlchemy-JSONAPI 3 | Constants 4 | Colton J. Provias 5 | MIT License 6 | """ 7 | 8 | 9 | try: 10 | from enum import Enum 11 | except ImportError: 12 | from enum34 import Enum 13 | 14 | 15 | class Method(Enum): 16 | """ HTTP Methods used by JSON API """ 17 | 18 | GET = 'GET' 19 | POST = 'POST' 20 | PATCH = 'PATCH' 21 | DELETE = 'DELETE' 22 | 23 | 24 | class Endpoint(Enum): 25 | """ Four paths specified in JSON API """ 26 | 27 | COLLECTION = '/' 28 | RESOURCE = '//' 29 | RELATED = '///' 30 | RELATIONSHIP = '///relationships/' 31 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/declarative/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColtonProvias/sqlalchemy-jsonapi/40f8b5970d44935b27091c2bf3224482d23311bb/sqlalchemy_jsonapi/declarative/__init__.py -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/declarative/serializer.py: -------------------------------------------------------------------------------- 1 | """A serializer for serializing SQLAlchemy models to JSON API spec.""" 2 | 3 | import datetime 4 | 5 | from inflection import dasherize, underscore 6 | 7 | 8 | class JSONAPISerializer(object): 9 | """A JSON API serializer that serializes SQLAlchemy models.""" 10 | model = None 11 | primary_key = 'id' 12 | fields = [] 13 | dasherize = True 14 | 15 | def __init__(self): 16 | """Ensure required members are not defaults.""" 17 | if self.model is None: 18 | raise TypeError("Model cannot be of type 'None'.") 19 | if self.primary_key not in self.fields: 20 | raise ValueError( 21 | "Serializer fields must contain primary key '{}'".format( 22 | self.primary_key)) 23 | 24 | def serialize(self, resources): 25 | """Serialize resource(s) according to json-api spec.""" 26 | serialized = { 27 | 'meta': { 28 | 'sqlalchemy_jsonapi_version': '4.0.9' 29 | }, 30 | 'jsonapi': { 31 | 'version': '1.0' 32 | } 33 | } 34 | # Determine multiple resources by checking for SQLAlchemy query count. 35 | if hasattr(resources, 'count'): 36 | serialized['data'] = [] 37 | for resource in resources: 38 | serialized['data'].append( 39 | self._render_resource(resource)) 40 | else: 41 | serialized['data'] = self._render_resource(resources) 42 | 43 | return serialized 44 | 45 | def _render_resource(self, resource): 46 | """Renders a resource's top level members based on json-api spec. 47 | 48 | Top level members include: 49 | 'id', 'type', 'attributes', 'relationships' 50 | """ 51 | if not resource: 52 | return None 53 | # Must not render a resource that has same named 54 | # attributes as different model. 55 | if not isinstance(resource, self.model): 56 | raise TypeError( 57 | 'Resource(s) type must be the same as the serializer model type.') 58 | 59 | top_level_members = {} 60 | try: 61 | top_level_members['id'] = str(getattr(resource, self.primary_key)) 62 | except AttributeError: 63 | raise 64 | top_level_members['type'] = resource.__tablename__ 65 | top_level_members['attributes'] = self._render_attributes(resource) 66 | top_level_members['relationships'] = self._render_relationships( 67 | resource) 68 | return top_level_members 69 | 70 | def _render_attributes(self, resource): 71 | """Render the resources's attributes.""" 72 | attributes = {} 73 | attrs_to_ignore = set() 74 | 75 | for key, relationship in resource.__mapper__.relationships.items(): 76 | attrs_to_ignore.update(set( 77 | [column.name for column in relationship.local_columns]).union( 78 | {key})) 79 | 80 | if self.dasherize: 81 | mapped_fields = {x: dasherize(underscore(x)) for x in self.fields} 82 | else: 83 | mapped_fields = {x: x for x in self.fields} 84 | 85 | for attribute in self.fields: 86 | if attribute == self.primary_key: 87 | continue 88 | # Per json-api spec, we cannot render foreign keys 89 | # or relationsips in attributes. 90 | if attribute in attrs_to_ignore: 91 | raise AttributeError 92 | try: 93 | value = getattr(resource, attribute) 94 | if isinstance(value, datetime.datetime): 95 | attributes[mapped_fields[attribute]] = value.isoformat() 96 | else: 97 | attributes[mapped_fields[attribute]] = value 98 | except AttributeError: 99 | raise 100 | 101 | return attributes 102 | 103 | def _render_relationships(self, resource): 104 | """Render the resource's relationships.""" 105 | relationships = {} 106 | related_models = resource.__mapper__.relationships.keys() 107 | primary_key_val = getattr(resource, self.primary_key) 108 | if self.dasherize: 109 | mapped_relationships = { 110 | x: dasherize(underscore(x)) for x in related_models} 111 | else: 112 | mapped_relationships = {x: x for x in related_models} 113 | 114 | for model in related_models: 115 | relationships[mapped_relationships[model]] = { 116 | 'links': { 117 | 'self': '/{}/{}/relationships/{}'.format( 118 | resource.__tablename__, 119 | primary_key_val, 120 | mapped_relationships[model]), 121 | 'related': '/{}/{}/{}'.format( 122 | resource.__tablename__, 123 | primary_key_val, 124 | mapped_relationships[model]) 125 | } 126 | } 127 | 128 | return relationships 129 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/errors.py: -------------------------------------------------------------------------------- 1 | import json 2 | from uuid import uuid4 3 | 4 | # Relative import here is to avoid circular import in setup.py. 5 | from ._version import __version__ 6 | 7 | 8 | class BaseError(Exception): 9 | @property 10 | def data(self): 11 | return { 12 | 'errors': [{ 13 | 'id': uuid4(), 14 | 'code': self.code, 15 | 'status': self.status_code, 16 | 'title': self.title, 17 | 'detail': self.detail 18 | }] 19 | } 20 | 21 | 22 | class BadRequestError(BaseError): 23 | title = 'Bad Request' 24 | status_code = 400 25 | code = 'bad_request' 26 | 27 | def __init__(self, detail): 28 | self.detail = detail 29 | 30 | 31 | class NotAnAttributeError(BaseError): 32 | status_code = 409 33 | code = 'not_an_attribute' 34 | title = 'Not An Attribute' 35 | 36 | def __init__(self, model, key): 37 | tmpl = '{} has no attribute {}' 38 | self.detail = tmpl.format(model.__jsonapi_type__, key) 39 | 40 | 41 | class NotSortableError(BaseError): 42 | title = 'Not Sortable' 43 | status_code = 409 44 | code = 'not_sortable' 45 | 46 | def __init__(self, model, attr_name): 47 | tmpl = 'The requested field {} on type {} is not a sortable field.' 48 | self.detail = tmpl.format(model.__jsonapi_type__, attr_name) 49 | 50 | 51 | class PermissionDeniedError(BaseError): 52 | status_code = 403 53 | code = 'permission_denied' 54 | title = 'Permission Denied' 55 | 56 | def __init__(self, permission, model, instance=None, field=None): 57 | tmpl = '{} denied on {}' 58 | self.detail = tmpl.format(permission.name, model.__jsonapi_type__) 59 | if instance is not None: 60 | self.detail += '.' + str(instance.id) 61 | if field is not None: 62 | self.detail += '.' + field 63 | 64 | 65 | class InvalidTypeForEndpointError(BaseError): 66 | status_code = 409 67 | code = 'invalid_type_for_endpoint' 68 | title = 'Invalid Type For Endpoint' 69 | 70 | def __init__(self, expected, got): 71 | self.detail = 'Expected {}, got {}'.format(expected, got) 72 | 73 | 74 | class MissingTypeError(BaseError): 75 | status_code = 409 76 | code = 'missing_type' 77 | title = 'Missing Type' 78 | detail = 'Missing /data/type key in request body' 79 | 80 | 81 | class MissingContentTypeError(BaseError): 82 | status_code = 409 83 | code = 'invalid_conent_type' 84 | title = 'Missing/Invalid Content-Type Header' 85 | detail = 'Content-Type must be application/vnd.api+json' 86 | 87 | 88 | class ValidationError(BaseError): 89 | status_code = 409 90 | code = 'validation_error' 91 | title = 'Validation Failed' 92 | 93 | def __init__(self, detail): 94 | self.detail = detail 95 | 96 | 97 | class ResourceNotFoundError(BaseError): 98 | status_code = 404 99 | code = 'resource_not_found' 100 | title = 'Resource Not Found' 101 | 102 | def __init__(self, model, instance): 103 | self.detail = '{}.{} not found'.format(model, instance) 104 | 105 | 106 | class RelatedResourceNotFoundError(BaseError): 107 | status_code = 404 108 | code = 'related_resource_not_found' 109 | title = 'Related Resource Not Found' 110 | 111 | def __init__(self, api_type, obj_id): 112 | tmpl = 'Related resource {}.{} not found' 113 | self.detail = tmpl.format(api_type, obj_id) 114 | 115 | 116 | class RelationshipNotFoundError(BaseError): 117 | status_code = 404 118 | code = 'relationship_not_found' 119 | title = 'Relationsip Not Found' 120 | 121 | def __init__(self, model, instance, key): 122 | self.detail = '{}.{}.{} not found'.format(model, instance, key) 123 | 124 | 125 | class ToManyExpectedError(BaseError): 126 | status_code = 409 127 | code = 'to_many_expected' 128 | title = 'To-Many Expected' 129 | 130 | def __init__(self, model, instance, relationship): 131 | self.detail = '{}.{}.{} is not a to-many relationship'.format( 132 | model.__jsonapi_type__, instance.id, relationship.key) 133 | 134 | 135 | class ResourceTypeNotFoundError(BaseError): 136 | title = 'Resource Type Not Found' 137 | status_code = 404 138 | code = 'resource_type_not_found' 139 | 140 | def __init__(self, api_type): 141 | tmpl = 'This backend has not been configured to handle resources of '\ 142 | 'type {}.' 143 | self.detail = tmpl.format(api_type) 144 | 145 | 146 | def user_error(status_code, title, detail, pointer): 147 | """Create and return a general user error response that is jsonapi compliant. 148 | 149 | Required args: 150 | status_code: The HTTP status code associated with the problem. 151 | title: A short summary of the problem. 152 | detail: An explanation specific to the occurence of the problem. 153 | pointer: The request path associated with the source of the problem. 154 | """ 155 | response = { 156 | 'errors': [{ 157 | 'status': status_code, 158 | 'source': {'pointer': '{0}'.format(pointer)}, 159 | 'title': title, 160 | 'detail': detail, 161 | }], 162 | 'jsonapi': { 163 | 'version': '1.0' 164 | }, 165 | 'meta': { 166 | 'sqlalchemy_jsonapi_version': __version__ 167 | } 168 | } 169 | return json.dumps(response), status_code 170 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/flaskext.py: -------------------------------------------------------------------------------- 1 | """ 2 | SQLAlchemy-JSONAPI 3 | Flask Adapter 4 | Colton J. Provias 5 | MIT License 6 | """ 7 | 8 | import datetime 9 | import json 10 | import uuid 11 | from functools import wraps 12 | 13 | from blinker import signal 14 | from flask import make_response, request 15 | 16 | from .constants import Endpoint, Method 17 | from .errors import BaseError, MissingContentTypeError 18 | from .serializer import JSONAPI 19 | 20 | 21 | class JSONAPIEncoder(json.JSONEncoder): 22 | """ JSONEncoder Implementation that allows for UUID and datetime """ 23 | 24 | def default(self, value): 25 | """ 26 | Handle UUID, datetime, and callables. 27 | 28 | :param value: Value to encode 29 | """ 30 | if isinstance(value, uuid.UUID): 31 | return str(value) 32 | elif isinstance(value, datetime.datetime): 33 | return value.isoformat() 34 | elif callable(value): 35 | return str(value) 36 | return json.JSONEncoder.default(self, value) 37 | 38 | 39 | #: The views to generate 40 | views = [ 41 | (Method.GET, Endpoint.COLLECTION), (Method.GET, Endpoint.RESOURCE), 42 | (Method.GET, Endpoint.RELATED), (Method.GET, Endpoint.RELATIONSHIP), 43 | (Method.POST, Endpoint.COLLECTION), (Method.POST, Endpoint.RELATIONSHIP), 44 | (Method.PATCH, Endpoint.RESOURCE), (Method.PATCH, Endpoint.RELATIONSHIP), 45 | (Method.DELETE, Endpoint.RESOURCE), (Method.DELETE, Endpoint.RELATIONSHIP) 46 | ] 47 | 48 | 49 | def override(original, results): 50 | """ 51 | If a receiver to a signal returns a value, we override the original value 52 | with the last returned value. 53 | 54 | :param original: The original value 55 | :param results: The results from the signal 56 | """ 57 | overrides = [v for fn, v in results if v is not None] 58 | if len(overrides) == 0: 59 | return original 60 | return overrides[-1] 61 | 62 | 63 | class FlaskJSONAPI(object): 64 | """ Flask Adapter """ 65 | 66 | #: Fires before the serializer is called. Functions should implement the 67 | #: following args: (sender, method, endpoint, data, req_args) 68 | on_request = signal('jsonapi-on-request') 69 | 70 | #: Fires before we return the response. Included args are: 71 | #: (sender, method, endpoint, data, req_args, rendered_response) 72 | on_response = signal('jsonapi-on-response') 73 | 74 | #: Fires after a successful call to the serializer. 75 | #: (sender, method, endpoint, data, req_args, response) 76 | on_success = signal('jsonapi-on-success') 77 | 78 | #: Fires when an error is encountered. 79 | #: (sender, method, endpoint, data, req_args, error) 80 | on_error = signal('jsonapi-on-error') 81 | 82 | #: JSON Encoder to use 83 | json_encoder = JSONAPIEncoder 84 | 85 | def __init__(self, 86 | app=None, 87 | sqla=None, 88 | namespace='api', 89 | route_prefix='/api'): 90 | """ 91 | Initialize the adapter. If app isn't passed here, it should be passed 92 | in init_app. 93 | 94 | :param app: Flask application 95 | :param sqla: Flask-SQLAlchemy instance 96 | :param namespace: Prefixes all generated routes 97 | :param route_prefix: The base path for the generated routes 98 | """ 99 | self.app = app 100 | self.sqla = sqla 101 | self._handler_chains = dict() 102 | 103 | if app is not None: 104 | self._setup_adapter(namespace, route_prefix) 105 | 106 | def init_app(self, app, sqla, namespace='api', route_prefix='/api'): 107 | """ 108 | Initialize the adapter if it hasn't already been initialized. 109 | 110 | :param app: Flask application 111 | :param sqla: Flask-SQLAlchemy instance 112 | :param namespace: Prefixes all generated routes 113 | :param route_prefix: The base path for the generated routes 114 | """ 115 | self.app = app 116 | self.sqla = sqla 117 | 118 | self._setup_adapter(namespace, route_prefix) 119 | 120 | def wrap_handler(self, api_types, methods, endpoints): 121 | """ 122 | Allow for a handler to be wrapped in a chain. 123 | 124 | :param api_types: Types to wrap handlers for 125 | :param methods: Methods to wrap handlers for 126 | :param endpoints: Endpoints to wrap handlers for 127 | """ 128 | 129 | def wrapper(fn): 130 | @wraps(fn) 131 | def wrapped(*args, **kwargs): 132 | return fn(*args, **kwargs) 133 | 134 | for api_type in api_types: 135 | for method in methods: 136 | for endpoint in endpoints: 137 | key = (api_type, method, endpoint) 138 | self._handler_chains.setdefault(key, []) 139 | self._handler_chains[key].append(wrapped) 140 | return wrapped 141 | 142 | return wrapper 143 | 144 | def _call_next(self, handler_chain): 145 | """ 146 | Generates an express-like chain for handling requests. 147 | 148 | :param handler_chain: The current chain of handlers 149 | """ 150 | 151 | def wrapped(*args, **kwargs): 152 | if len(handler_chain) == 1: 153 | return handler_chain[0](*args, **kwargs) 154 | else: 155 | return handler_chain[0](self._call_next(handler_chain[1:]), 156 | *args, **kwargs) 157 | 158 | return wrapped 159 | 160 | def _setup_adapter(self, namespace, route_prefix): 161 | """ 162 | Initialize the serializer and loop through the views to generate them. 163 | 164 | :param namespace: Prefix for generated endpoints 165 | :param route_prefix: Prefix for route patterns 166 | """ 167 | self.serializer = JSONAPI( 168 | self.sqla.Model, prefix='{}://{}{}'.format( 169 | self.app.config['PREFERRED_URL_SCHEME'], 170 | self.app.config['SERVER_NAME'], route_prefix)) 171 | for view in views: 172 | method, endpoint = view 173 | pattern = route_prefix + endpoint.value 174 | name = '{}_{}_{}'.format(namespace, method.name, endpoint.name) 175 | view = self._generate_view(method, endpoint) 176 | self.app.add_url_rule(pattern + '/', 177 | name + '_slashed', 178 | view, 179 | methods=[method.name], 180 | strict_slashes=False) 181 | self.app.add_url_rule(pattern, name, view, methods=[method.name]) 182 | 183 | def _generate_view(self, method, endpoint): 184 | """ 185 | Generate a view for the specified method and endpoint. 186 | 187 | :param method: HTTP Method 188 | :param endpoint: Pattern 189 | """ 190 | 191 | def new_view(**kwargs): 192 | if method == Method.GET: 193 | data = request.args 194 | else: 195 | content_length = request.headers.get('content-length', 0) 196 | if content_length and int(content_length) > 0: 197 | content_type = request.headers.get('content-type', None) 198 | if content_type != 'application/vnd.api+json': 199 | data = MissingContentTypeError().data 200 | data = json.dumps(data, cls=JSONAPIEncoder) 201 | response = make_response(data) 202 | response.status_code = 409 203 | response.content_type = 'application/vnd.api+json' 204 | return response 205 | data = request.get_json(force=True) 206 | else: 207 | data = None 208 | 209 | event_kwargs = { 210 | 'method': method, 211 | 'endpoint': endpoint, 212 | 'data': data, 213 | 'req_args': kwargs 214 | } 215 | results = self.on_request.send(self, **event_kwargs) 216 | data = override(data, results) 217 | 218 | args = [self.sqla.session, data, kwargs['api_type']] 219 | if 'obj_id' in kwargs.keys(): 220 | args.append(kwargs['obj_id']) 221 | if 'relationship' in kwargs.keys(): 222 | args.append(kwargs['relationship']) 223 | 224 | try: 225 | attr = '{}_{}'.format(method.name, endpoint.name).lower() 226 | handler = getattr(self.serializer, attr) 227 | handler_chain = list(self._handler_chains.get(( 228 | kwargs['api_type'], method, endpoint), [])) 229 | handler_chain.append(handler) 230 | chained_handler = self._call_next(handler_chain) 231 | response = chained_handler(*args) 232 | results = self.on_success.send(self, 233 | response=response, 234 | **event_kwargs) 235 | response = override(response, results) 236 | except BaseError as exc: 237 | self.sqla.session.rollback() 238 | results = self.on_error.send(self, error=exc, **event_kwargs) 239 | response = override(exc, results) 240 | rendered_response = make_response('') 241 | if response.status_code != 204: 242 | data = json.dumps(response.data, cls=self.json_encoder) 243 | rendered_response = make_response(data) 244 | rendered_response.status_code = response.status_code 245 | rendered_response.content_type = 'application/vnd.api+json' 246 | results = self.on_response.send(self, 247 | response=rendered_response, 248 | **event_kwargs) 249 | return override(rendered_response, results) 250 | 251 | return new_view 252 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | SQLAlchemy JSONAPI Test App. 3 | 4 | Colton Provias 5 | MIT License 6 | """ 7 | 8 | from uuid import uuid4 9 | 10 | from flask import Flask, request 11 | from flask_sqlalchemy import SQLAlchemy 12 | from sqlalchemy import Boolean, Column, ForeignKey, Unicode, UnicodeText, Table 13 | from sqlalchemy.ext.hybrid import hybrid_property 14 | from sqlalchemy.orm import backref, relationship, validates 15 | from sqlalchemy_jsonapi import ( 16 | FlaskJSONAPI, Permissions, permission_test, Method, Endpoint, 17 | relationship_descriptor, RelationshipActions, 18 | INTERACTIVE_PERMISSIONS) 19 | from sqlalchemy_utils import EmailType, PasswordType, Timestamp, UUIDType 20 | 21 | app = Flask(__name__) 22 | 23 | app.testing = True 24 | 25 | db = SQLAlchemy(app) 26 | 27 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' 28 | app.config['SQLALCHEMY_ECHO'] = False 29 | 30 | 31 | class User(Timestamp, db.Model): 32 | """Quick and dirty user model.""" 33 | 34 | #: If __jsonapi_type__ is not provided, it will use the class name instead. 35 | __tablename__ = 'users' 36 | 37 | id = Column(UUIDType, default=uuid4, primary_key=True) 38 | username = Column(Unicode(30), unique=True, nullable=False) 39 | email = Column(EmailType, nullable=False) 40 | password = Column(PasswordType(schemes=['bcrypt']), 41 | nullable=False, 42 | info={'allow_serialize': False}) 43 | is_admin = Column(Boolean, default=False) 44 | 45 | @hybrid_property 46 | def total_comments(self): 47 | """ 48 | Total number of comments. 49 | 50 | Provides an example of a computed property. 51 | """ 52 | return self.comments.count() 53 | 54 | @validates('email') 55 | def validate_email(self, key, email): 56 | """Strong email validation.""" 57 | assert '@' in email, 'Not an email' 58 | return email 59 | 60 | @validates('username') 61 | def validate_username(self, key, username): 62 | """ 63 | Check the length of the username. 64 | 65 | Here's hoping nobody submits something in unicode that is 31 characters 66 | long!! 67 | """ 68 | assert len(username) >= 4 and len( 69 | username) <= 30, 'Must be 4 to 30 characters long.' 70 | return username 71 | 72 | @validates('password') 73 | def validate_password(self, key, password): 74 | """Validate a password's length.""" 75 | assert len(password) >= 5, 'Password must be 5 characters or longer.' 76 | return password 77 | 78 | @permission_test(Permissions.VIEW, 'password') 79 | def view_password(self): 80 | """ Never let the password be seen. """ 81 | return False 82 | 83 | @permission_test(Permissions.EDIT) 84 | def prevent_edit(self): 85 | """ Prevent editing for no reason. """ 86 | if request.view_args['api_type'] == 'blog-posts': 87 | return True 88 | return False 89 | 90 | @permission_test(Permissions.DELETE) 91 | def allow_delete(self): 92 | """ Just like a popular social media site, we won't delete users. """ 93 | return False 94 | 95 | PostTags = Table('post_tag', db.Model.metadata, 96 | Column('post_id', UUIDType, ForeignKey('posts.id')), 97 | Column('tag_id', UUIDType, ForeignKey('tags.id')) 98 | ) 99 | 100 | class BlogPost(Timestamp, db.Model): 101 | """Post model, as if this is a blog.""" 102 | 103 | __tablename__ = 'posts' 104 | 105 | id = Column(UUIDType, default=uuid4, primary_key=True) 106 | title = Column(Unicode(100), nullable=False) 107 | slug = Column(Unicode(100)) 108 | content = Column(UnicodeText, nullable=False) 109 | is_published = Column(Boolean, default=False) 110 | author_id = Column(UUIDType, ForeignKey('users.id')) 111 | 112 | author = relationship('User', 113 | lazy='joined', 114 | backref=backref('posts', 115 | lazy='dynamic')) 116 | 117 | tags = relationship("BlogTag", 118 | secondary=PostTags, 119 | back_populates="posts") 120 | 121 | @validates('title') 122 | def validate_title(self, key, title): 123 | """Keep titles from getting too long.""" 124 | assert len(title) >= 5 or len( 125 | title) <= 100, 'Must be 5 to 100 characters long.' 126 | return title 127 | 128 | @permission_test(Permissions.VIEW) 129 | def allow_view(self): 130 | """ Hide unpublished. """ 131 | return self.is_published 132 | 133 | @permission_test(INTERACTIVE_PERMISSIONS, 'logs') 134 | def prevent_altering_of_logs(self): 135 | return False 136 | 137 | class BlogTag(Timestamp, db.Model): 138 | """Blogs can have tags now""" 139 | 140 | __tablename__ = 'tags' 141 | 142 | id = Column(UUIDType, default=uuid4, primary_key=True) 143 | slug = Column(Unicode(100), unique=True) 144 | description = Column(UnicodeText) 145 | 146 | posts = relationship("BlogPost", 147 | secondary=PostTags, 148 | back_populates="tags") 149 | 150 | class BlogComment(Timestamp, db.Model): 151 | """Comment for each Post.""" 152 | 153 | __tablename__ = 'comments' 154 | 155 | id = Column(UUIDType, default=uuid4, primary_key=True) 156 | post_id = Column(UUIDType, ForeignKey('posts.id')) 157 | author_id = Column(UUIDType, ForeignKey('users.id'), nullable=False) 158 | content = Column(UnicodeText, nullable=False) 159 | 160 | post = relationship('BlogPost', 161 | lazy='joined', 162 | backref=backref('comments', 163 | lazy='dynamic')) 164 | 165 | @relationship_descriptor(RelationshipActions.GET, 'post') 166 | def post_get(self): 167 | """No-OP Relationship descriptor to exercise relationship_descriptor""" 168 | return self.post 169 | 170 | author = relationship('User', 171 | lazy='joined', 172 | backref=backref('comments', 173 | lazy='dynamic')) 174 | 175 | 176 | class Log(Timestamp, db.Model): 177 | __tablename__ = 'logs' 178 | id = Column(UUIDType, default=uuid4, primary_key=True) 179 | post_id = Column(UUIDType, ForeignKey('posts.id')) 180 | user_id = Column(UUIDType, ForeignKey('users.id')) 181 | 182 | post = relationship('BlogPost', 183 | lazy='joined', 184 | backref=backref('logs', 185 | lazy='dynamic')) 186 | user = relationship('User', 187 | lazy='joined', 188 | backref=backref('logs', 189 | lazy='dynamic')) 190 | 191 | @permission_test(INTERACTIVE_PERMISSIONS) 192 | def block_interactive(cls): 193 | return False 194 | 195 | 196 | api = FlaskJSONAPI(app, db) 197 | 198 | 199 | @api.wrap_handler(['blog-posts'], [Method.GET], [Endpoint.COLLECTION]) 200 | def sample_override(next, *args, **kwargs): 201 | return next(*args, **kwargs) 202 | 203 | 204 | if __name__ == '__main__': 205 | app.run() 206 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | SQLAlchemy-JSONAPI Testing Fixtures. 3 | 4 | Colton J. Provias 5 | MIT License 6 | """ 7 | 8 | import json 9 | 10 | import pytest 11 | from flask import Response 12 | from flask.testing import FlaskClient 13 | from sqlalchemy.orm import sessionmaker 14 | from app import db as db_ 15 | from app import app, User, BlogPost, BlogComment, BlogTag, Log 16 | from faker import Faker 17 | 18 | Session = sessionmaker() 19 | 20 | fake = Faker() 21 | 22 | 23 | @pytest.yield_fixture(scope='session') 24 | def flask_app(): 25 | """Set up the application context for testing.""" 26 | ctx = app.app_context() 27 | ctx.push() 28 | yield app 29 | ctx.pop() 30 | 31 | 32 | @pytest.yield_fixture(scope='session') 33 | def db(flask_app): 34 | """Set up the database as a session-wide fixture.""" 35 | db_.app = flask_app 36 | db_.drop_all() 37 | db_.create_all() 38 | yield db_ 39 | 40 | 41 | @pytest.yield_fixture(scope='function') 42 | def session(request, db): 43 | """Create the transaction for each function so we don't rebuild.""" 44 | connection = db.engine.connect() 45 | transaction = connection.begin() 46 | options = {'bind': connection} 47 | session = db.create_scoped_session(options=options) 48 | yield session 49 | transaction.rollback() 50 | connection.close() 51 | session.remove() 52 | 53 | 54 | class TestingResponse(Response): 55 | def validate(self, status_code, error=None): 56 | print(self.data) 57 | assert self.status_code == status_code 58 | assert self.headers['Content-Type'] == 'application/vnd.api+json' 59 | if status_code != 204: 60 | self.json_data = json.loads(self.data.decode()) 61 | if error: 62 | assert self.status_code == error.status_code 63 | assert self.json_data['errors'][0]['code'] == error.code 64 | assert self.json_data['errors'][0]['status' 65 | ] == error.status_code 66 | return self 67 | 68 | 69 | @pytest.fixture 70 | def client(flask_app): 71 | """Set up the testing client.""" 72 | with FlaskClient(flask_app, 73 | use_cookies=True, 74 | response_wrapper=TestingResponse) as c: 75 | return c 76 | 77 | 78 | @pytest.fixture 79 | def user(session): 80 | new_user = User(email=fake.email(), 81 | password=fake.sentence(), 82 | username=fake.user_name()) 83 | session.add(new_user) 84 | session.commit() 85 | return new_user 86 | 87 | 88 | @pytest.fixture 89 | def post(user, session): 90 | new_post = BlogPost( 91 | author=user, title=fake.sentence(), content=fake.paragraph(), 92 | is_published=True) 93 | session.add(new_post) 94 | session.commit() 95 | return new_post 96 | 97 | 98 | @pytest.fixture 99 | def unpublished_post(user, session): 100 | new_post = BlogPost( 101 | author=user, title=fake.sentence(), content=fake.paragraph(), 102 | is_published=False) 103 | session.add(new_post) 104 | session.commit() 105 | return new_post 106 | 107 | 108 | @pytest.fixture 109 | def bunch_of_posts(user, session): 110 | for x in range(30): 111 | new_post = BlogPost( 112 | author=user, title=fake.sentence(), content=fake.paragraph(), 113 | is_published=fake.boolean()) 114 | session.add(new_post) 115 | new_post.comments.append( 116 | BlogComment(author=user, content=fake.paragraph())) 117 | session.commit() 118 | 119 | @pytest.fixture 120 | def bunch_of_tags(session): 121 | tags = [BlogTag(slug=fake.word(), description=fake.text()) for x in range(3)] 122 | for tag in tags: 123 | session.add(tag) 124 | session.commit() 125 | return tags 126 | 127 | @pytest.fixture 128 | def comment(user, post, session): 129 | new_comment = BlogComment(author=user, post=post, content=fake.paragraph()) 130 | session.add(new_comment) 131 | session.commit() 132 | return new_comment 133 | 134 | 135 | @pytest.fixture 136 | def log(user, post, session): 137 | new_log = Log(user=user, post=post) 138 | session.add(new_log) 139 | session.commit() 140 | return new_log 141 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/test_collection_get.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy_jsonapi.errors import ( 2 | BadRequestError, NotSortableError) 3 | 4 | 5 | def test_200_with_no_querystring(bunch_of_posts, client): 6 | response = client.get('/api/blog-posts').validate(200) 7 | assert response.json_data['data'][0]['type'] == 'blog-posts' 8 | assert response.json_data['data'][0]['id'] 9 | 10 | 11 | def test_200_with_single_included_model(bunch_of_posts, client): 12 | response = client.get('/api/blog-posts/?include=author').validate(200) 13 | assert response.json_data['data'][0]['type'] == 'blog-posts' 14 | assert response.json_data['included'][0]['type'] == 'users' 15 | 16 | 17 | def test_200_with_including_model_and_including_inbetween( 18 | bunch_of_posts, client): 19 | response = client.get( 20 | '/api/blog-comments/?include=post.author').validate(200) 21 | assert response.json_data['data'][0]['type'] == 'blog-comments' 22 | for data in response.json_data['included']: 23 | assert data['type'] in ['blog-posts', 'users'] 24 | 25 | 26 | def test_200_with_multiple_includes(bunch_of_posts, client): 27 | response = client.get( 28 | '/api/blog-posts/?include=comments,author').validate(200) 29 | assert response.json_data['data'][0]['type'] == 'blog-posts' 30 | for data in response.json_data['included']: 31 | assert data['type'] in ['blog-comments', 'users'] 32 | 33 | 34 | def test_200_with_single_field(bunch_of_posts, client): 35 | response = client.get( 36 | '/api/blog-posts/?fields[blog-posts]=title').validate(200) 37 | for item in response.json_data['data']: 38 | assert {'title'} == set(item['attributes'].keys()) 39 | assert len(item['relationships']) == 0 40 | 41 | 42 | def test_200_with_multiple_fields(bunch_of_posts, client): 43 | response = client.get( 44 | '/api/blog-posts/?fields[blog-posts]=title,content,is-published').validate( # NOQA 45 | 200) 46 | for item in response.json_data['data']: 47 | assert {'title', 'content', 'is-published'} == set( 48 | item['attributes'].keys()) 49 | assert len(item['relationships']) == 0 50 | 51 | 52 | def test_200_with_single_field_across_a_relationship(bunch_of_posts, client): 53 | response = client.get( 54 | '/api/blog-posts/?fields[blog-posts]=title,content&fields[blog-comments]=author&include=comments').validate( # NOQA 55 | 200) 56 | for item in response.json_data['data']: 57 | assert {'title', 'content'} == set(item['attributes'].keys()) 58 | assert len(item['relationships']) == 0 59 | for item in response.json_data['included']: 60 | assert len(item['attributes'].keys()) == 0 61 | assert len(item['attributes']) == 0 62 | assert {'author'} == set(item['relationships'].keys()) 63 | 64 | 65 | def test_200_sorted_response(bunch_of_posts, client): 66 | response = client.get('/api/blog-posts/?sort=title').validate(200) 67 | title_list = [x['attributes']['title'] for x in response.json_data['data']] 68 | assert sorted(title_list) == title_list 69 | 70 | 71 | def test_200_descending_sorted_response(bunch_of_posts, client): 72 | response = client.get('/api/blog-posts/?sort=-title').validate(200) 73 | title_list = [x['attributes']['title'] for x in response.json_data['data']] 74 | assert sorted(title_list, key=None, reverse=True) == title_list 75 | 76 | 77 | def test_200_sorted_response_with_multiple_criteria(bunch_of_posts, client): 78 | response = client.get('/api/blog-posts/?sort=title,-created').validate(200) 79 | title_list = [x['attributes']['title'] for x in response.json_data['data']] 80 | assert sorted(title_list, key=None, reverse=False) == title_list 81 | 82 | 83 | def test_409_when_given_relationship_for_sorting(bunch_of_posts, client): 84 | client.get('/api/blog-posts/?sort=author').validate(409, NotSortableError) 85 | 86 | 87 | def test_409_when_given_a_missing_field_for_sorting(bunch_of_posts, client): 88 | client.get('/api/blog-posts/?sort=never_gonna_give_you_up').validate( 89 | 409, NotSortableError) 90 | 91 | 92 | def test_200_paginated_response_by_page(bunch_of_posts, client): 93 | response = client.get( 94 | '/api/blog-posts/?page[number]=2&page[size]=5').validate(200) 95 | assert len(response.json_data['data']) == 5 96 | 97 | 98 | def test_200_paginated_response_by_offset(bunch_of_posts, client): 99 | response = client.get( 100 | '/api/blog-posts/?page[offset]=5&page[limit]=5').validate(200) 101 | assert len(response.json_data['data']) == 5 102 | 103 | 104 | def test_200_when_pagination_is_out_of_range(bunch_of_posts, client): 105 | client.get( 106 | '/api/blog-posts/?page[offset]=999999&page[limit]=5').validate(200) 107 | 108 | 109 | def test_400_when_provided_crap_data_for_pagination(bunch_of_posts, client): 110 | client.get('/api/blog-posts/?page[offset]=5&page[limit]=crap').validate( 111 | 400, BadRequestError) 112 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/test_collection_post.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from sqlalchemy_jsonapi.errors import ( 4 | InvalidTypeForEndpointError, MissingTypeError, PermissionDeniedError, 5 | ValidationError, MissingContentTypeError, BadRequestError) 6 | from faker import Faker 7 | 8 | fake = Faker() 9 | 10 | 11 | def test_200_resource_creation(client): 12 | payload = { 13 | 'data': { 14 | 'type': 'users', 15 | 'attributes': { 16 | 'username': fake.user_name(), 17 | 'email': 'user@example.com', 18 | 'password': 'password' 19 | } 20 | } 21 | } 22 | response = client.post( 23 | '/api/users/', data=json.dumps(payload), 24 | content_type='application/vnd.api+json').validate(201) 25 | assert response.json_data['data']['type'] == 'users' 26 | user_id = response.json_data['data']['id'] 27 | response = client.get('/api/users/{}/'.format(user_id)).validate(200) 28 | 29 | 30 | def test_200_resource_creation_with_relationships(user, client): 31 | payload = { 32 | 'data': { 33 | 'type': 'blog-posts', 34 | 'attributes': { 35 | 'title': 'Some title', 36 | 'content': 'Hello, World!', 37 | 'is-published': True 38 | }, 39 | 'relationships': { 40 | 'author': { 41 | 'data': { 42 | 'type': 'users', 43 | 'id': str(user.id) 44 | } 45 | } 46 | } 47 | } 48 | } 49 | response = client.post( 50 | '/api/blog-posts/', data=json.dumps(payload), 51 | content_type='application/vnd.api+json').validate(201) 52 | assert response.json_data['data']['type'] == 'blog-posts' 53 | post_id = response.json_data['data']['id'] 54 | response = client.get( 55 | '/api/blog-posts/{}/?include=author'.format(post_id)).validate(200) 56 | assert response.json_data['data']['relationships']['author']['data'][ 57 | 'id' 58 | ] == str(user.id) 59 | 60 | def test_200_resource_creation_with_relationship_array(user, bunch_of_tags, client): 61 | payload = { 62 | 'data': { 63 | 'type': 'blog-posts', 64 | 'attributes': { 65 | 'title': 'Some title', 66 | 'content': 'Hello, World!', 67 | 'is-published': True 68 | }, 69 | 'relationships': { 70 | 'author': { 71 | 'data': { 72 | 'type': 'users', 73 | 'id': str(user.id) 74 | } 75 | }, 76 | 'tags': { 77 | 'data': [{ 78 | 'type': 'blog-tags', 79 | 'id': str(tag.id) 80 | } for tag in bunch_of_tags 81 | ] 82 | } 83 | } 84 | } 85 | } 86 | response = client.post( 87 | '/api/blog-posts/', data=json.dumps(payload), 88 | content_type='application/vnd.api+json').validate(201) 89 | assert response.json_data['data']['type'] == 'blog-posts' 90 | post_id = response.json_data['data']['id'] 91 | response = client.get( 92 | '/api/blog-posts/{}/?include=author'.format(post_id)).validate(200) 93 | assert response.json_data['data']['relationships']['author']['data'][ 94 | 'id' 95 | ] == str(user.id) 96 | 97 | 98 | def test_403_when_access_is_denied(client): 99 | payload = {'data': {'type': 'logs'}} 100 | client.post( 101 | '/api/logs/', data=json.dumps(payload), 102 | content_type='application/vnd.api+json').validate( 103 | 403, PermissionDeniedError) 104 | 105 | 106 | def test_409_when_id_already_exists(user, client): 107 | payload = { 108 | 'data': { 109 | 'type': 'users', 110 | 'id': str(user.id), 111 | 'attributes': { 112 | 'username': 'my_user', 113 | 'email': 'user@example.com', 114 | 'password': 'password' 115 | } 116 | } 117 | } 118 | client.post( 119 | '/api/users/', data=json.dumps(payload), 120 | content_type='application/vnd.api+json').validate( 121 | 409, ValidationError) 122 | 123 | 124 | def test_409_when_type_doesnt_match_endpoint(client): 125 | payload = {'data': {'type': 'blog-posts'}} 126 | client.post( 127 | '/api/users/', data=json.dumps(payload), 128 | content_type='application/vnd.api+json').validate( 129 | 409, InvalidTypeForEndpointError) 130 | 131 | 132 | def test_409_when_missing_content_type(client): 133 | client.post('/api/users/', 134 | data='{}').validate(409, MissingContentTypeError) 135 | 136 | 137 | def test_409_when_missing_type(client): 138 | payload = { 139 | 'data': { 140 | 'attributes': { 141 | 'username': 'my_user', 142 | 'email': 'user@example.com', 143 | 'password': 'password' 144 | } 145 | } 146 | } 147 | client.post( 148 | '/api/users/', data=json.dumps(payload), 149 | content_type='application/vnd.api+json').validate( 150 | 409, MissingTypeError) 151 | 152 | 153 | def test_409_for_invalid_value(client): 154 | payload = { 155 | 'data': { 156 | 'type': 'users', 157 | 'attributes': { 158 | 'username': 'my_user', 159 | 'email': 'bad_email', 160 | 'password': 'password' 161 | } 162 | } 163 | } 164 | client.post( 165 | '/api/users/', data=json.dumps(payload), 166 | content_type='application/vnd.api+json').validate( 167 | 409, ValidationError) 168 | 169 | 170 | def test_409_for_wrong_field_name(client): 171 | payload = { 172 | 'data': { 173 | 'type': 'users', 174 | 'attributes': { 175 | 'username': 'my_user', 176 | 'email': 'some@example.com', 177 | 'password': 'password', 178 | 'wrong_field': True 179 | } 180 | } 181 | } 182 | client.post( 183 | '/api/users/', data=json.dumps(payload), 184 | content_type='application/vnd.api+json').validate( 185 | 409, ValidationError) 186 | 187 | 188 | def test_400_for_unknown_relationship_type(user, client): 189 | payload = { 190 | 'data': { 191 | 'type': 'blog-posts', 192 | 'attributes': { 193 | 'title': 'Some title', 194 | 'content': 'Hello, World!', 195 | 'is-published': True 196 | }, 197 | 'relationships': { 198 | 'bogon': { 199 | 'data': { 200 | 'type': 'users', 201 | 'id': str(user.id) 202 | } 203 | } 204 | } 205 | } 206 | } 207 | client.post( 208 | '/api/blog-posts/', data=json.dumps(payload), 209 | content_type='application/vnd.api+json').validate( 210 | 400, BadRequestError) 211 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/test_related_get.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from sqlalchemy_jsonapi.errors import (RelationshipNotFoundError, 4 | ResourceNotFoundError) 5 | 6 | 7 | def test_200_result_of_to_one(post, client): 8 | response = client.get( 9 | '/api/blog-posts/{}/author/'.format(post.id)).validate( 10 | 200) 11 | assert response.json_data['data']['type'] == 'users' 12 | 13 | 14 | def test_200_collection_of_to_many(comment, client): 15 | response = client.get('/api/blog-posts/{}/comments/'.format( 16 | comment.post.id)).validate(200) 17 | assert len(response.json_data['data']) > 0 18 | 19 | 20 | def test_404_when_relationship_not_found(post, client): 21 | client.get('/api/blog-posts/{}/last_comment/'.format( 22 | post.id)).validate(404, RelationshipNotFoundError) 23 | 24 | 25 | def test_404_when_resource_not_found(client): 26 | client.get('/api/blog-posts/{}/comments/'.format(uuid4())).validate( 27 | 404, ResourceNotFoundError) 28 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/test_relationship_delete.py: -------------------------------------------------------------------------------- 1 | import json 2 | from uuid import uuid4 3 | 4 | from sqlalchemy_jsonapi.errors import ( 5 | PermissionDeniedError, RelationshipNotFoundError, ResourceNotFoundError, 6 | MissingContentTypeError, ValidationError) 7 | 8 | 9 | def test_200_on_deletion_from_to_many(comment, client): 10 | payload = {'data': [{'type': 'blog-comments', 'id': str(comment.id)}]} 11 | response = client.delete( 12 | '/api/blog-posts/{}/relationships/comments/'.format( 13 | comment.post.id), 14 | data=json.dumps(payload), 15 | content_type='application/vnd.api+json').validate(200) 16 | for item in response.json_data['data']: 17 | assert {'id', 'type'} == set(item.keys()) 18 | assert payload['data'][0]['id'] not in [str(x['id']) 19 | for x in response.json_data['data'] 20 | ] 21 | 22 | 23 | def test_404_on_resource_not_found(client): 24 | client.delete( 25 | '/api/blog-posts/{}/relationships/comments/'.format(uuid4()), 26 | data='{}', content_type='application/vnd.api+json').validate( 27 | 404, ResourceNotFoundError) 28 | 29 | 30 | def test_404_on_relationship_not_found(post, client): 31 | client.delete( 32 | '/api/blog-posts/{}/relationships/comment/'.format(post.id), 33 | data='{}', content_type='application/vnd.api+json').validate( 34 | 404, RelationshipNotFoundError) 35 | 36 | 37 | def test_403_on_permission_denied(user, client): 38 | client.delete( 39 | '/api/users/{}/relationships/logs/'.format(user.id), 40 | data='{"data": []}', 41 | content_type='application/vnd.api+json').validate( 42 | 403, PermissionDeniedError) 43 | 44 | 45 | def test_409_on_to_one_provided(post, client): 46 | client.delete( 47 | '/api/blog-posts/{}/relationships/author/'.format(post.id), 48 | data='{"data": {}}', 49 | content_type='application/vnd.api+json').validate( 50 | 409, ValidationError) 51 | 52 | 53 | def test_409_missing_content_type_header(post, client): 54 | client.delete( 55 | '/api/blog-posts/{}/relationships/comment/'.format(post.id), 56 | data='{}').validate(409, MissingContentTypeError) 57 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/test_relationship_get.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy_jsonapi.errors import ( 2 | RelationshipNotFoundError, ResourceNotFoundError, PermissionDeniedError) 3 | from uuid import uuid4 4 | 5 | 6 | def test_200_on_to_many(post, client): 7 | response = client.get( 8 | '/api/blog-posts/{}/relationships/comments/'.format( 9 | post.id)).validate(200) 10 | for item in response.json_data['data']: 11 | assert {'id', 'type'} == set(item.keys()) 12 | 13 | 14 | def test_200_on_to_one(post, client): 15 | response = client.get( 16 | '/api/blog-posts/{}/relationships/author/'.format( 17 | post.id)).validate(200) 18 | assert response.json_data['data']['type'] == 'users' 19 | 20 | 21 | def test_404_on_resource_not_found(client): 22 | client.get( 23 | '/api/blog-posts/{}/relationships/comments/'.format(uuid4())).validate( 24 | 404, ResourceNotFoundError) 25 | 26 | 27 | def test_404_on_relationship_not_found(post, client): 28 | client.get( 29 | '/api/blog-posts/{}/relationships/comment/'.format( 30 | post.id)).validate(404, RelationshipNotFoundError) 31 | 32 | 33 | def test_403_on_permission_denied(unpublished_post, client): 34 | client.get( 35 | '/api/blog-posts/{}/relationships/comment/'.format( 36 | unpublished_post.id)).validate(403, PermissionDeniedError) 37 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/test_relationship_patch.py: -------------------------------------------------------------------------------- 1 | import json 2 | from uuid import uuid4 3 | 4 | from sqlalchemy_jsonapi.errors import (PermissionDeniedError, 5 | RelationshipNotFoundError, 6 | ResourceNotFoundError, ValidationError) 7 | 8 | 9 | def test_200_on_to_one_set_to_resource(post, user, client): 10 | payload = {'data': {'type': 'users', 'id': str(user.id)}} 11 | response = client.patch( 12 | '/api/blog-posts/{}/relationships/author/'.format(post.id), 13 | data=json.dumps(payload), 14 | content_type='application/vnd.api+json').validate(200) 15 | assert response.json_data['data']['id'] == str(user.id) 16 | 17 | 18 | def test_200_on_to_one_set_to_null(post, client): 19 | payload = {'data': None} 20 | response = client.patch( 21 | '/api/blog-posts/{}/relationships/author/'.format(post.id), 22 | data=json.dumps(payload), 23 | content_type='application/vnd.api+json').validate(200) 24 | assert response.json_data['data'] is None 25 | 26 | 27 | def test_200_on_to_many_set_to_resources(post, comment, client): 28 | payload = {'data': [{'type': 'blog-comments', 'id': str(comment.id)}]} 29 | response = client.patch( 30 | '/api/blog-posts/{}/relationships/comments/'.format(post.id), 31 | data=json.dumps(payload), 32 | content_type='application/vnd.api+json').validate(200) 33 | assert response.json_data['data'][0]['id'] == str(comment.id) 34 | assert len(response.json_data['data']) == 1 35 | 36 | 37 | def test_200_on_to_many_set_to_empty(post, client): 38 | payload = {'data': []} 39 | response = client.patch( 40 | '/api/blog-posts/{}/relationships/comments/'.format(post.id), 41 | data=json.dumps(payload), 42 | content_type='application/vnd.api+json').validate(200) 43 | assert len(response.json_data['data']) == 0 44 | 45 | 46 | def test_409_on_to_one_set_to_empty_list(post, client): 47 | payload = {'data': []} 48 | client.patch( 49 | '/api/blog-posts/{}/relationships/author/'.format(post.id), 50 | data=json.dumps(payload), 51 | content_type='application/vnd.api+json').validate( 52 | 409, ValidationError) 53 | 54 | 55 | def test_409_on_to_many_set_to_null(post, client): 56 | payload = {'data': None} 57 | client.patch( 58 | '/api/blog-posts/{}/relationships/comments/'.format(post.id), 59 | data=json.dumps(payload), 60 | content_type='application/vnd.api+json').validate( 61 | 409, ValidationError) 62 | 63 | 64 | def test_404_on_resource_not_found(client): 65 | client.patch( 66 | '/api/blog-posts/{}/relationships/comments/'.format(uuid4()), 67 | data='{}', 68 | content_type='application/vnd.api+json').validate( 69 | 404, ResourceNotFoundError) 70 | 71 | 72 | def test_404_on_relationship_not_found(client, post): 73 | client.patch( 74 | '/api/blog-posts/{}/relationships/comment/'.format(post.id), 75 | data='{}', 76 | content_type='application/vnd.api+json').validate( 77 | 404, RelationshipNotFoundError) 78 | 79 | 80 | def test_404_on_related_item_not_found(post, client): 81 | payload = {'data': [{'type': 'blog-comments', 'id': str(uuid4())}]} 82 | client.patch( 83 | '/api/blog-posts/{}/relationships/comments/'.format(post.id), 84 | data=json.dumps(payload), 85 | content_type='application/vnd.api+json').validate( 86 | 404, ResourceNotFoundError) 87 | 88 | 89 | def test_403_on_permission_denied(user, log, client): 90 | payload = {'data': {'type': 'users', 'id': str(user.id)}} 91 | client.patch( 92 | '/api/logs/{}/relationships/user/'.format(log.id), 93 | data=json.dumps(payload), 94 | content_type='application/vnd.api+json').validate( 95 | 403, PermissionDeniedError) 96 | 97 | 98 | def test_403_on_permission_denied_on_related(log, user, client): 99 | payload = {'data': {'type': 'logs', 'id': str(log.id)}} 100 | client.patch( 101 | '/api/users/{}/relationships/logs/'.format(user.id), 102 | data=json.dumps(payload), 103 | content_type='application/vnd.api+json').validate( 104 | 403, PermissionDeniedError) 105 | 106 | 107 | def test_409_on_to_one_with_incompatible_model(post, comment, client): 108 | payload = {'data': {'type': 'blog-comments', 'id': str(comment.id)}} 109 | client.patch( 110 | '/api/blog-posts/{}/relationships/author/'.format(post.id), 111 | data=json.dumps(payload), 112 | content_type='application/vnd.api+json').validate( 113 | 409, ValidationError) 114 | 115 | 116 | def test_409_on_to_many_with_incompatible_model(post, client): 117 | payload = {'data': [{'type': 'blog-posts', 'id': str(post.id)}]} 118 | client.patch( 119 | '/api/blog-posts/{}/relationships/author/'.format(post.id), 120 | data=json.dumps(payload), 121 | content_type='application/vnd.api+json').validate( 122 | 409, ValidationError) 123 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/test_relationship_post.py: -------------------------------------------------------------------------------- 1 | import json 2 | from uuid import uuid4 3 | 4 | from sqlalchemy_jsonapi.errors import ( 5 | ValidationError, ResourceNotFoundError, RelationshipNotFoundError) 6 | 7 | 8 | def test_200_on_to_many(comment, post, client): 9 | payload = {'data': [{'type': 'blog-comments', 'id': str(comment.id)}]} 10 | response = client.post( 11 | '/api/blog-posts/{}/relationships/comments/'.format(post.id), 12 | data=json.dumps(payload), 13 | content_type='application/vnd.api+json').validate(200) 14 | assert str(comment.id) in [str(x['id']) 15 | for x in response.json_data['data']] 16 | 17 | 18 | def test_409_on_hash_instead_of_array_provided(comment, post, client): 19 | payload = {'data': {'type': 'blog-comments', 'id': str(comment.id)}} 20 | client.post( 21 | '/api/blog-posts/{}/relationships/comments/'.format(post.id), 22 | data=json.dumps(payload), 23 | content_type='application/vnd.api+json').validate( 24 | 409, ValidationError) 25 | 26 | 27 | def test_409_on_incompatible_model(user, post, client): 28 | payload = {'data': [{'type': 'users', 'id': str(user.id)}]} 29 | client.post( 30 | '/api/blog-posts/{}/relationships/comments/'.format(post.id), 31 | data=json.dumps(payload), 32 | content_type='application/vnd.api+json').validate( 33 | 409, ValidationError) 34 | 35 | 36 | def test_409_on_to_one_relationship(post, client): 37 | client.post( 38 | '/api/blog-posts/{}/relationships/author/'.format(post.id), 39 | data='{}', 40 | content_type='application/vnd.api+json').validate(409, ValidationError) 41 | 42 | 43 | def test_404_on_resource_not_found(client): 44 | client.post( 45 | '/api/blog-posts/{}/relationships/comments/'.format(uuid4()), 46 | data='{}', 47 | content_type='application/vnd.api+json').validate( 48 | 404, ResourceNotFoundError) 49 | 50 | 51 | def test_404_on_relationship_not_found(post, client): 52 | client.post( 53 | '/api/blog-posts/{}/relationships/comment/'.format(post.id), 54 | data='{}', 55 | content_type='application/vnd.api+json').validate( 56 | 404, RelationshipNotFoundError) 57 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/test_resource_delete.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from sqlalchemy_jsonapi.errors import ( 4 | PermissionDeniedError, ResourceNotFoundError, ResourceTypeNotFoundError) 5 | 6 | 7 | def test_200_on_success(comment, client): 8 | client.delete('/api/blog-comments/{}/'.format(comment.id)).validate(204) 9 | client.get('/api/blog-comments/{}/'.format(comment.id)).validate( 10 | 404, ResourceNotFoundError) 11 | 12 | 13 | def test_404_on_resource_type_not_found(client): 14 | client.delete('/api/nonexistant/somevalue/').validate( 15 | 404, ResourceTypeNotFoundError) 16 | 17 | 18 | def test_403_on_permission_denied(user, client): 19 | client.delete('/api/users/{}/'.format(user.id)).validate( 20 | 403, PermissionDeniedError) 21 | 22 | 23 | def test_404_on_resource_not_found(client): 24 | client.delete('/api/blog-comments/{}/'.format(uuid4())).validate( 25 | 404, ResourceNotFoundError) 26 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/test_resource_get.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy_jsonapi.errors import ( 2 | ResourceNotFoundError, PermissionDeniedError) 3 | from uuid import uuid4 4 | 5 | 6 | def test_200_without_querystring(post, client): 7 | response = client.get('/api/blog-posts/{}/'.format(post.id)).validate(200) 8 | assert response.json_data['data']['type'] == 'blog-posts' 9 | assert response.json_data['data']['id'] 10 | 11 | 12 | def test_404_resource_not_found(client): 13 | client.get('/api/blog-posts/{}/'.format(uuid4())).validate( 14 | 404, ResourceNotFoundError) 15 | 16 | 17 | def test_403_permission_denied(unpublished_post, client): 18 | client.get('/api/blog-posts/{}/'.format(unpublished_post.id)).validate( 19 | 403, PermissionDeniedError) 20 | 21 | 22 | def test_200_with_single_included_model(post, client): 23 | response = client.get('/api/blog-posts/{}/?include=author'.format( 24 | post.id)).validate(200) 25 | assert response.json_data['data']['type'] == 'blog-posts' 26 | assert response.json_data['included'][0]['type'] == 'users' 27 | 28 | 29 | def test_200_with_including_model_and_including_inbetween(comment, client): 30 | response = client.get('/api/blog-comments/{}/?include=post.author'.format( 31 | comment.id)).validate(200) 32 | assert response.json_data['data']['type'] == 'blog-comments' 33 | for data in response.json_data['included']: 34 | assert data['type'] in ['blog-posts', 'users'] 35 | 36 | 37 | def test_200_with_multiple_includes(post, client): 38 | response = client.get('/api/blog-posts/{}/?include=comments,author'.format( 39 | post.id)).validate(200) 40 | assert response.json_data['data']['type'] == 'blog-posts' 41 | for data in response.json_data['included']: 42 | assert data['type'] in ['blog-comments', 'users'] 43 | 44 | 45 | def test_200_with_single_field(post, client): 46 | response = client.get( 47 | '/api/blog-posts/{}/?fields[blog-posts]=title'.format( 48 | post.id)).validate(200) 49 | assert {'title'} == set(response.json_data['data']['attributes'].keys()) 50 | assert len(response.json_data['data']['relationships']) == 0 51 | 52 | 53 | def test_200_with_multiple_fields(post, client): 54 | response = client.get( 55 | '/api/blog-posts/{}/?fields[blog-posts]=title,content'.format( 56 | post.id)).validate(200) 57 | assert {'title', 'content' 58 | } == set(response.json_data['data']['attributes'].keys()) 59 | assert len(response.json_data['data']['relationships']) == 0 60 | 61 | 62 | def test_200_with_single_field_across_a_relationship(post, client): 63 | response = client.get( 64 | '/api/blog-posts/{}/?fields[blog-posts]=title,content&fields[blog-comments]=author&include=comments'.format( # NOQA 65 | post.id)).validate(200) 66 | assert {'title', 'content' 67 | } == set(response.json_data['data']['attributes'].keys()) 68 | assert len(response.json_data['data']['relationships']) == 0 69 | for item in response.json_data['included']: 70 | assert {'title', 'content'} == set(item['attributes'].keys()) 71 | assert len(item['attributes']) == 0 72 | assert {'author'} == set(item['relationships'].keys()) 73 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/test_resource_patch.py: -------------------------------------------------------------------------------- 1 | import json 2 | from uuid import uuid4 3 | 4 | from sqlalchemy_jsonapi.errors import ( 5 | BadRequestError, PermissionDeniedError, ResourceNotFoundError, 6 | ValidationError) 7 | 8 | 9 | def test_200(client, post, user): 10 | payload = { 11 | 'data': { 12 | 'type': 'blog-posts', 13 | 'id': str(post.id), 14 | 'attributes': { 15 | 'title': 'I just lost the game' 16 | }, 17 | 'relationships': { 18 | 'author': { 19 | 'data': { 20 | 'type': 'users', 21 | 'id': str(user.id) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | response = client.patch( 28 | '/api/blog-posts/{}/'.format(post.id), 29 | data=json.dumps(payload), 30 | content_type='application/vnd.api+json').validate(200) 31 | assert response.json_data['data']['id'] == str(post.id) 32 | assert response.json_data['data']['type'] == 'blog-posts' 33 | assert response.json_data['data']['attributes']['title' 34 | ] == 'I just lost the game' 35 | 36 | 37 | def test_400_missing_type(post, client): 38 | client.patch( 39 | '/api/blog-posts/{}/'.format(post.id), 40 | data=json.dumps({}), 41 | content_type='application/vnd.api+json').validate( 42 | 400, BadRequestError) 43 | 44 | 45 | def test_404_resource_not_found(client): 46 | client.patch( 47 | '/api/blog-posts/{}/'.format(uuid4()), 48 | content_type='application/vnd.api+json', 49 | data='{}').validate(404, ResourceNotFoundError) 50 | 51 | 52 | def test_404_related_resource_not_found(client, post): 53 | payload = { 54 | 'data': { 55 | 'type': 'blog-posts', 56 | 'id': str(post.id), 57 | 'relationships': { 58 | 'author': { 59 | 'data': { 60 | 'type': 'users', 61 | 'id': str(uuid4()) 62 | } 63 | } 64 | } 65 | } 66 | } 67 | client.patch( 68 | '/api/blog-posts/{}/'.format(post.id), 69 | data=json.dumps(payload), 70 | content_type='application/vnd.api+json').validate( 71 | 404, ResourceNotFoundError) 72 | 73 | 74 | def test_400_field_not_found(client, post, user): 75 | payload = { 76 | 'data': { 77 | 'type': 'blog-posts', 78 | 'id': str(post.id), 79 | 'relationships': { 80 | 'authors': { 81 | 'data': { 82 | 'type': 'users', 83 | 'id': str(user.id) 84 | } 85 | } 86 | } 87 | } 88 | } 89 | client.patch( 90 | '/api/blog-posts/{}/'.format(post.id), 91 | data=json.dumps(payload), 92 | content_type='application/vnd.api+json').validate( 93 | 400, BadRequestError) 94 | 95 | 96 | def test_409_type_mismatch_to_one(client, post, user): 97 | payload = { 98 | 'data': { 99 | 'type': 'blog-posts', 100 | 'id': str(post.id), 101 | 'relationships': { 102 | 'comments': { 103 | 'data': { 104 | 'type': 'users', 105 | 'id': str(user.id) 106 | } 107 | } 108 | } 109 | } 110 | } 111 | client.patch( 112 | '/api/blog-posts/{}/'.format(post.id), 113 | data=json.dumps(payload), 114 | content_type='application/vnd.api+json').validate( 115 | 409, ValidationError) 116 | 117 | 118 | def test_400_type_mismatch_to_many(client, post, user): 119 | payload = { 120 | 'data': { 121 | 'type': 'blog-posts', 122 | 'id': str(post.id), 123 | 'relationships': { 124 | 'author': [{ 125 | 'data': { 126 | 'type': 'users', 127 | 'id': str(user.id) 128 | } 129 | }] 130 | } 131 | } 132 | } 133 | client.patch( 134 | '/api/blog-posts/{}/'.format(post.id), 135 | data=json.dumps(payload), 136 | content_type='application/vnd.api+json').validate( 137 | 400, BadRequestError) 138 | 139 | 140 | def test_409_validation_failed(client, post, user): 141 | payload = { 142 | 'data': { 143 | 'type': 'blog-posts', 144 | 'id': str(post.id), 145 | 'attributes': { 146 | 'title': None 147 | }, 148 | 'relationships': { 149 | 'author': { 150 | 'data': { 151 | 'type': 'users', 152 | 'id': str(user.id) 153 | } 154 | } 155 | } 156 | } 157 | } 158 | client.patch( 159 | '/api/blog-posts/{}/'.format(post.id), 160 | data=json.dumps(payload), 161 | content_type='application/vnd.api+json').validate( 162 | 409, ValidationError) 163 | 164 | 165 | def test_400_type_does_not_match_endpoint(client, post, user): 166 | payload = { 167 | 'data': { 168 | 'type': 'users', 169 | 'id': str(post.id), 170 | 'attributes': { 171 | 'title': 'I just lost the game' 172 | }, 173 | 'relationships': { 174 | 'author': { 175 | 'data': { 176 | 'type': 'users', 177 | 'id': str(user.id) 178 | } 179 | } 180 | } 181 | } 182 | } 183 | client.patch( 184 | '/api/blog-posts/{}/'.format(post.id), 185 | data=json.dumps(payload), 186 | content_type='application/vnd.api+json').validate( 187 | 400, BadRequestError) 188 | 189 | 190 | def test_403_permission_denied(user, client): 191 | client.patch( 192 | '/api/users/{}/'.format(user.id), 193 | data='{}', 194 | content_type='application/vnd.api+json').validate( 195 | 403, PermissionDeniedError) 196 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/tests/test_serializer.py: -------------------------------------------------------------------------------- 1 | from app import api 2 | import uuid 3 | 4 | 5 | def test_include_different_types_same_id(session, comment): 6 | new_id = uuid.uuid4() 7 | comment.post.id = new_id 8 | comment.author.id = new_id 9 | comment.post_id = new_id 10 | comment.author_id = new_id 11 | session.commit() 12 | 13 | r = api.serializer.get_resource( 14 | session, {'include': 'post,author'}, 'blog-comments', comment.id) 15 | assert len(r.data['included']) == 2 16 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColtonProvias/sqlalchemy-jsonapi/40f8b5970d44935b27091c2bf3224482d23311bb/sqlalchemy_jsonapi/unittests/__init__.py -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/declarative_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColtonProvias/sqlalchemy-jsonapi/40f8b5970d44935b27091c2bf3224482d23311bb/sqlalchemy_jsonapi/unittests/declarative_tests/__init__.py -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/models.py: -------------------------------------------------------------------------------- 1 | """Model file for unit testing.""" 2 | 3 | from sqlalchemy import Column, String, Integer, Text, ForeignKey 4 | from sqlalchemy.ext.declarative import declarative_base 5 | from sqlalchemy.orm import backref, relationship, validates 6 | 7 | from sqlalchemy_jsonapi import ( 8 | Permissions, permission_test, ALL_PERMISSIONS, 9 | JSONAPI, AttributeActions, attr_descriptor 10 | ) 11 | 12 | 13 | Base = declarative_base() 14 | 15 | 16 | class User(Base): 17 | """Simple user model.""" 18 | 19 | __tablename__ = 'users' 20 | id = Column(Integer, primary_key=True) 21 | first = Column(String(50), nullable=False) 22 | last = Column(String(50), nullable=False) 23 | username = Column(String(50), unique=True, nullable=False) 24 | password = Column(String(50), nullable=False) 25 | 26 | @permission_test(Permissions.VIEW, 'password') 27 | def view_password(self): 28 | """Password shall never be seen in a view.""" 29 | return False 30 | 31 | @validates('password', 'username', 'first', 'last') 32 | def empty_attributes_not_allowed(self, key, value): 33 | assert value, 'Empty value not allowed for {0}'.format(key) 34 | return value 35 | 36 | # For demonstration purposes, we want to store 37 | # the first name as SET-ATTR:first in database. 38 | @attr_descriptor(AttributeActions.SET, 'first') 39 | def set_first_to_start_with_set_attr(self, new_first): 40 | self.first = 'SET-ATTR:{0}'.format(new_first) 41 | 42 | # For demonstration purposes, we don't want to show 43 | # how we stored first internally in database. 44 | @attr_descriptor(AttributeActions.GET, 'first') 45 | def get_first_starts_with_get_attr(self): 46 | if 'SET-ATTR:' in self.first: 47 | return self.first[9::] 48 | return self.first 49 | 50 | 51 | class Post(Base): 52 | """A blog post model.""" 53 | 54 | __tablename__ = 'posts' 55 | id = Column(Integer, primary_key=True) 56 | title = Column(String(100), nullable=False) 57 | content = Column(Text, nullable=False) 58 | author_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE')) 59 | 60 | author = relationship('User', 61 | lazy='joined', 62 | backref=backref('posts', 63 | lazy='dynamic', 64 | cascade='all,delete')) 65 | 66 | 67 | class Comment(Base): 68 | """Comment for each Post.""" 69 | 70 | __tablename__ = 'comments' 71 | id = Column(Integer, primary_key=True) 72 | post_id = Column(Integer, ForeignKey('posts.id', ondelete='CASCADE')) 73 | author_id = Column(Integer, ForeignKey('users.id'), nullable=False) 74 | content = Column(Text, nullable=False) 75 | 76 | post = relationship('Post', 77 | lazy='joined', 78 | backref=backref('comments', 79 | lazy='dynamic', cascade='all,delete')) 80 | author = relationship('User', 81 | lazy='joined', 82 | backref=backref('comments', 83 | lazy='dynamic')) 84 | 85 | 86 | class Log(Base): 87 | """Log information model.""" 88 | 89 | __tablename__ = 'logs' 90 | id = Column(Integer, primary_key=True) 91 | user_id = Column(Integer, ForeignKey('users.id')) 92 | 93 | user = relationship( 94 | 'User', lazy='joined', backref=backref('logs', lazy='dynamic')) 95 | 96 | @permission_test(ALL_PERMISSIONS) 97 | def block_interactive(cls): 98 | """Unable to Create, Edit, or Delete a log.""" 99 | return False 100 | 101 | 102 | serializer = JSONAPI(Base) 103 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/test_errors_user_error.py: -------------------------------------------------------------------------------- 1 | """Test for error's user_error.""" 2 | 3 | import json 4 | import unittest 5 | 6 | from sqlalchemy_jsonapi import errors 7 | from sqlalchemy_jsonapi import __version__ 8 | 9 | 10 | class TestUserError(unittest.TestCase): 11 | """Tests for errors.user_error.""" 12 | 13 | def test_user_error(self): 14 | """Create user error succesfully.""" 15 | status_code = 400 16 | title = 'User Error Occured' 17 | detail = 'Testing user error' 18 | pointer = '/test' 19 | 20 | actual = errors.user_error( 21 | status_code, title, detail, pointer) 22 | 23 | data = { 24 | 'errors': [{ 25 | 'status': status_code, 26 | 'source': {'pointer': '{0}'.format(pointer)}, 27 | 'title': title, 28 | 'detail': detail, 29 | }], 30 | 'jsonapi': { 31 | 'version': '1.0' 32 | }, 33 | 'meta': { 34 | 'sqlalchemy_jsonapi_version': __version__ 35 | } 36 | } 37 | expected = json.dumps(data), status_code 38 | self.assertEqual(expected, actual) 39 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/test_serializer_delete_relationship.py: -------------------------------------------------------------------------------- 1 | """Test for serializer's delete_relationship.""" 2 | 3 | from sqlalchemy_jsonapi import errors 4 | 5 | from sqlalchemy_jsonapi.unittests.utils import testcases 6 | from sqlalchemy_jsonapi.unittests import models 7 | 8 | 9 | class DeleteRelationship(testcases.SqlalchemyJsonapiTestCase): 10 | """Tests for serializer.delete_relationship.""" 11 | 12 | def test_delete_one_to_many_relationship_successs(self): 13 | """Delete a relationship from a resource is successful. 14 | 15 | Ensure objects are no longer related in database. 16 | I don't want this comment to be associated with this post 17 | """ 18 | user = models.User( 19 | first='Sally', last='Smith', 20 | password='password', username='SallySmith1') 21 | self.session.add(user) 22 | blog_post = models.Post( 23 | title='This Is A Title', content='This is the content', 24 | author_id=user.id, author=user) 25 | self.session.add(blog_post) 26 | comment = models.Comment( 27 | content='This is a comment', author_id=user.id, 28 | post_id=blog_post.id, author=user, post=blog_post) 29 | self.session.add(comment) 30 | self.session.commit() 31 | payload = { 32 | 'data': [{ 33 | 'type': 'comments', 34 | 'id': comment.id 35 | }] 36 | } 37 | 38 | models.serializer.delete_relationship( 39 | self.session, payload, 'posts', blog_post.id, 'comments') 40 | 41 | updated_comment = self.session.query(models.Comment).get(comment.id) 42 | self.assertEquals(updated_comment.post_id, None) 43 | self.assertEquals(updated_comment.post, None) 44 | 45 | def test_delete_one_to_many_relationship_response_success(self): 46 | """Delete a relationship from a resource is successful returns 200.""" 47 | user = models.User( 48 | first='Sally', last='Smith', 49 | password='password', username='SallySmith1') 50 | self.session.add(user) 51 | blog_post = models.Post( 52 | title='This Is A Title', content='This is the content', 53 | author_id=user.id, author=user) 54 | self.session.add(blog_post) 55 | comment = models.Comment( 56 | content='This is a comment', author_id=user.id, 57 | post_id=blog_post.id, author=user, post=blog_post) 58 | self.session.add(comment) 59 | self.session.commit() 60 | payload = { 61 | 'data': [{ 62 | 'type': 'comments', 63 | 'id': comment.id 64 | }] 65 | } 66 | 67 | response = models.serializer.delete_relationship( 68 | self.session, payload, 'posts', blog_post.id, 'comments') 69 | 70 | expected = {'data': []} 71 | actual = response.data 72 | self.assertEqual(expected, actual) 73 | self.assertEqual(200, response.status_code) 74 | 75 | def test_delete_one_to_many_relationship_with_invalid_data(self): 76 | """Delete a relationship from a resource is successful returns 200.""" 77 | user = models.User( 78 | first='Sally', last='Smith', 79 | password='password', username='SallySmith1') 80 | self.session.add(user) 81 | blog_post = models.Post( 82 | title='This Is A Title', content='This is the content', 83 | author_id=user.id, author=user) 84 | self.session.add(blog_post) 85 | comment = models.Comment( 86 | content='This is a comment', author_id=user.id, 87 | post_id=blog_post.id, author=user, post=blog_post) 88 | self.session.add(comment) 89 | self.session.commit() 90 | payload = { 91 | 'data': { 92 | 'type': 'comments', 93 | 'id': comment.id 94 | } 95 | } 96 | 97 | with self.assertRaises(errors.ValidationError) as error: 98 | models.serializer.delete_relationship( 99 | self.session, payload, 'posts', blog_post.id, 'comments') 100 | 101 | self.assertEquals( 102 | error.exception.detail, 'Provided data must be an array.') 103 | self.assertEquals(error.exception.status_code, 409) 104 | 105 | def test_delete_many_to_one_relationship_response(self): 106 | """Delete a many-to-one relationship returns a 409. 107 | 108 | A ToManyExpectedError is returned. 109 | """ 110 | user = models.User( 111 | first='Sally', last='Smith', 112 | password='password', username='SallySmith1') 113 | self.session.add(user) 114 | blog_post = models.Post( 115 | title='This Is A Title', content='This is the content', 116 | author_id=user.id, author=user) 117 | self.session.add(blog_post) 118 | comment = models.Comment( 119 | content='This is a comment', author_id=user.id, 120 | post_id=blog_post.id, author=user, post=blog_post) 121 | self.session.add(comment) 122 | self.session.commit() 123 | payload = { 124 | 'data': [{ 125 | 'type': 'users', 126 | 'id': user.id 127 | }] 128 | } 129 | 130 | response = models.serializer.delete_relationship( 131 | self.session, payload, 'posts', blog_post.id, 'author') 132 | 133 | expected_detail = 'posts.1.author is not a to-many relationship' 134 | self.assertEqual(expected_detail, response.detail) 135 | self.assertEqual(409, response.status_code) 136 | 137 | def test_delete_one_to_many_relationship_model_not_found(self): 138 | """Delete a one-to-many relationship whose api-type does not exist returns 404. 139 | 140 | A ResourceTypeNotFoundError is raised. 141 | """ 142 | with self.assertRaises(errors.ResourceTypeNotFoundError) as error: 143 | models.serializer.delete_relationship( 144 | self.session, {}, 'not-existant', 1, 'author') 145 | 146 | self.assertEquals(error.exception.status_code, 404) 147 | 148 | def test_delete_one_to_many_relationship_of_nonexistant_resource(self): 149 | """Delete a one-to-many relationship of nonexistant resource returns 404. 150 | 151 | A ResourceNotFoundError is raised. 152 | """ 153 | with self.assertRaises(errors.ResourceNotFoundError) as error: 154 | models.serializer.delete_relationship( 155 | self.session, {}, 'posts', 1, 'author') 156 | 157 | self.assertEquals(error.exception.status_code, 404) 158 | 159 | def test_delete_one_to_many_relationship_with_unknown_relationship(self): 160 | """Delete a one-to-many relationship with unknown relationship returns 404. 161 | 162 | A RelationshipNotFoundError is raised. 163 | """ 164 | user = models.User( 165 | first='Sally', last='Smith', 166 | password='password', username='SallySmith1') 167 | self.session.add(user) 168 | blog_post = models.Post( 169 | title='This Is A Title', content='This is the content', 170 | author_id=user.id, author=user) 171 | self.session.add(blog_post) 172 | self.session.commit() 173 | 174 | with self.assertRaises(errors.RelationshipNotFoundError) as error: 175 | models.serializer.delete_relationship( 176 | self.session, {}, 'posts', 1, 'logs') 177 | 178 | self.assertEquals(error.exception.status_code, 404) 179 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/test_serializer_delete_resource.py: -------------------------------------------------------------------------------- 1 | """Test for serializer's delete_resource.""" 2 | 3 | from sqlalchemy_jsonapi import errors 4 | 5 | from sqlalchemy_jsonapi.unittests.utils import testcases 6 | from sqlalchemy_jsonapi.unittests import models 7 | from sqlalchemy_jsonapi import __version__ 8 | 9 | 10 | class DeleteResource(testcases.SqlalchemyJsonapiTestCase): 11 | """Tests for serializer.delete_resource.""" 12 | 13 | def test_delete_resource_successs_response(self): 14 | """Delete a resource successfully returns 204.""" 15 | user = models.User( 16 | first='Sally', last='Smith', 17 | username='SallySmith1', password='password') 18 | self.session.add(user) 19 | self.session.commit() 20 | 21 | response = models.serializer.delete_resource( 22 | self.session, {}, 'users', user.id) 23 | 24 | expected = { 25 | 'meta': { 26 | 'sqlalchemy_jsonapi_version': __version__ 27 | }, 28 | 'jsonapi': { 29 | 'version': '1.0' 30 | } 31 | } 32 | actual = response.data 33 | self.assertEqual(expected, actual) 34 | self.assertEqual(204, response.status_code) 35 | 36 | def test_delete_nonexistant_resource(self): 37 | """Delete notexistant resource returns 404. 38 | 39 | A ResourceTypeNotFoundError is raised. 40 | """ 41 | with self.assertRaises(errors.ResourceTypeNotFoundError) as error: 42 | models.serializer.delete_resource( 43 | self.session, {}, 'non-existant', 1) 44 | 45 | self.assertEqual(error.exception.status_code, 404) 46 | 47 | def test_delete_resource_cascade_with_one_many_relationship(self): 48 | """Delete a resource with a cascade and one-to-many relationship. 49 | 50 | Ensure all referencing models are removed. 51 | """ 52 | user = models.User( 53 | first='Sally', last='Smith', 54 | password='password', username='SallySmith1') 55 | self.session.add(user) 56 | blog_post = models.Post( 57 | title='This Is A Title', content='This is the content', 58 | author_id=user.id, author=user) 59 | self.session.add(blog_post) 60 | comment = models.Comment( 61 | content='This is comment 1', author_id=user.id, 62 | post_id=blog_post.id, author=user, post=blog_post) 63 | self.session.add(comment) 64 | self.session.commit() 65 | 66 | models.serializer.delete_resource(self.session, {}, 'users', 1) 67 | 68 | post = self.session.query(models.Post).get(1) 69 | comment = self.session.query(models.Comment).get(1) 70 | self.assertEqual(post, None) 71 | self.assertEqual(comment, None) 72 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/test_serializer_get_related.py: -------------------------------------------------------------------------------- 1 | """Test for serializer's get_related.""" 2 | 3 | from sqlalchemy_jsonapi import errors 4 | 5 | from sqlalchemy_jsonapi.unittests.utils import testcases 6 | from sqlalchemy_jsonapi.unittests import models 7 | from sqlalchemy_jsonapi import __version__ 8 | 9 | 10 | class GetRelated(testcases.SqlalchemyJsonapiTestCase): 11 | """Tests for serializer.get_related.""" 12 | 13 | @testcases.fragile 14 | def test_get_related_of_to_one(self): 15 | """Get a related single resource returns a 200. 16 | 17 | This test is fragile. 18 | """ 19 | user = models.User( 20 | first='Sally', last='Smith', 21 | password='password', username='SallySmith1') 22 | self.session.add(user) 23 | blog_post = models.Post( 24 | title='This Is A Title', content='This is the content', 25 | author_id=user.id, author=user) 26 | self.session.add(blog_post) 27 | comment = models.Comment( 28 | content='This is a comment', author_id=user.id, 29 | post_id=blog_post.id, author=user, post=blog_post) 30 | self.session.add(comment) 31 | self.session.commit() 32 | 33 | response = models.serializer.get_related( 34 | self.session, {}, 'posts', blog_post.id, 'author') 35 | 36 | expected = { 37 | 'data': { 38 | 'id': 1, 39 | 'type': 'users', 40 | 'included': {}, 41 | 'relationships': { 42 | 'comments': { 43 | 'links': { 44 | 'related': '/users/1/comments', 45 | 'self': '/users/1/relationships/comments' 46 | } 47 | }, 48 | 'logs': { 49 | 'links': { 50 | 'related': '/users/1/logs', 51 | 'self': '/users/1/relationships/logs' 52 | } 53 | }, 54 | 'posts': { 55 | 'links': { 56 | 'related': '/users/1/posts', 57 | 'self': '/users/1/relationships/posts' 58 | } 59 | } 60 | }, 61 | 'attributes': { 62 | 'first': u'Sally', 63 | 'last': u'Smith', 64 | 'username': u'SallySmith1' 65 | } 66 | }, 67 | 'jsonapi': { 68 | 'version': '1.0' 69 | }, 70 | 'meta': { 71 | 'sqlalchemy_jsonapi_version': __version__ 72 | } 73 | } 74 | actual = response.data 75 | self.assertEqual(expected, actual) 76 | self.assertEqual(200, response.status_code) 77 | 78 | def test_get_related_of_to_many(self): 79 | """Get many related resource returns a 200.""" 80 | user = models.User( 81 | first='Sally', last='Smith', 82 | password='password', username='SallySmith1') 83 | self.session.add(user) 84 | blog_post = models.Post( 85 | title='This Is A Title', content='This is the content', 86 | author_id=user.id, author=user) 87 | self.session.add(blog_post) 88 | for x in (range(2)): 89 | comment = models.Comment( 90 | content='This is comment {0}'.format(x+1), author_id=user.id, 91 | post_id=blog_post.id, author=user, post=blog_post) 92 | self.session.add(comment) 93 | self.session.commit() 94 | 95 | response = models.serializer.get_related( 96 | self.session, {}, 'posts', blog_post.id, 'comments') 97 | 98 | expected = { 99 | 'data': [{ 100 | 'id': 1, 101 | 'type': 'comments', 102 | 'included': {}, 103 | 'relationships': { 104 | 'post': { 105 | 'links': { 106 | 'self': '/comments/1/relationships/post', 107 | 'related': '/comments/1/post' 108 | } 109 | }, 110 | 'author': { 111 | 'links': { 112 | 'self': '/comments/1/relationships/author', 113 | 'related': '/comments/1/author' 114 | } 115 | } 116 | }, 117 | 'attributes': { 118 | 'content': u'This is comment 1' 119 | } 120 | }, { 121 | 'id': 2, 122 | 'type': 'comments', 123 | 'included': {}, 124 | 'relationships': { 125 | 'post': { 126 | 'links': { 127 | 'self': '/comments/2/relationships/post', 128 | 'related': '/comments/2/post' 129 | } 130 | }, 131 | 'author': { 132 | 'links': { 133 | 'self': '/comments/2/relationships/author', 134 | 'related': '/comments/2/author' 135 | } 136 | } 137 | }, 138 | 'attributes': { 139 | 'content': u'This is comment 2' 140 | } 141 | }], 142 | 'jsonapi': { 143 | 'version': '1.0' 144 | }, 145 | 'meta': { 146 | 'sqlalchemy_jsonapi_version': __version__ 147 | } 148 | } 149 | actual = response.data 150 | self.assertEqual(expected, actual) 151 | self.assertEqual(200, response.status_code) 152 | 153 | def test_get_related_with_unknown_relationship(self): 154 | """Get related resource with unknown relationship returns 404. 155 | 156 | A RelationshipNotFoundError is raised. 157 | """ 158 | user = models.User( 159 | first='Sally', last='Smith', 160 | password='password', username='SallySmith1') 161 | self.session.add(user) 162 | blog_post = models.Post( 163 | title='This Is A Title', content='This is the content', 164 | author_id=user.id, author=user) 165 | self.session.add(blog_post) 166 | comment = models.Comment( 167 | content='This is a comment', author_id=user.id, 168 | post_id=blog_post.id, author=user, post=blog_post) 169 | self.session.add(comment) 170 | self.session.commit() 171 | 172 | with self.assertRaises(errors.RelationshipNotFoundError) as error: 173 | models.serializer.get_related( 174 | self.session, {}, 'posts', 175 | blog_post.id, 'invalid-relationship') 176 | 177 | self.assertEqual(error.exception.status_code, 404) 178 | 179 | def test_get_related_when_related_object_is_null(self): 180 | """Get a related object that is null returns 200.""" 181 | user = models.User( 182 | first='Sally', last='Smith', 183 | password='password', username='SallySmith1') 184 | self.session.add(user) 185 | blog_post = models.Post( 186 | title='This Is A Title', content='This is the content') 187 | self.session.add(blog_post) 188 | self.session.commit() 189 | 190 | response = models.serializer.get_related( 191 | self.session, {}, 'posts', blog_post.id, 'author') 192 | 193 | expected = { 194 | 'data': None, 195 | 'jsonapi': { 196 | 'version': '1.0' 197 | }, 198 | 'meta': { 199 | 'sqlalchemy_jsonapi_version': __version__ 200 | } 201 | } 202 | actual = response.data 203 | self.assertEqual(expected, actual) 204 | self.assertEqual(200, response.status_code) 205 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/test_serializer_get_relationship.py: -------------------------------------------------------------------------------- 1 | """Test for serializer's get_relationship.""" 2 | 3 | from sqlalchemy_jsonapi import errors 4 | 5 | from sqlalchemy_jsonapi.unittests.utils import testcases 6 | from sqlalchemy_jsonapi.unittests import models 7 | from sqlalchemy_jsonapi import __version__ 8 | 9 | 10 | class GetRelationship(testcases.SqlalchemyJsonapiTestCase): 11 | """Tests for serializer.get_relationship.""" 12 | 13 | def test_get_relationship_on_to_many(self): 14 | """Get a relationship to many resources returns 200.""" 15 | user = models.User( 16 | first='Sally', last='Smith', 17 | password='password', username='SallySmith1') 18 | self.session.add(user) 19 | blog_post = models.Post( 20 | title='This Is A Title', content='This is the content', 21 | author_id=user.id, author=user) 22 | self.session.add(blog_post) 23 | for x in range(2): 24 | comment = models.Comment( 25 | content='This is comment {0}'.format(x+1), author_id=user.id, 26 | post_id=blog_post.id, author=user, post=blog_post) 27 | self.session.add(comment) 28 | self.session.commit() 29 | 30 | response = models.serializer.get_relationship( 31 | self.session, {}, 'posts', blog_post.id, 'comments') 32 | 33 | expected = { 34 | 'data': [{ 35 | 'id': 1, 36 | 'type': 'comments' 37 | }, { 38 | 'id': 2, 39 | 'type': 'comments' 40 | }], 41 | 'jsonapi': { 42 | 'version': '1.0' 43 | }, 44 | 'meta': { 45 | 'sqlalchemy_jsonapi_version': __version__ 46 | } 47 | } 48 | 49 | actual = response.data 50 | self.assertEqual(expected, actual) 51 | self.assertEqual(200, response.status_code) 52 | 53 | def test_get_relationship_on_to_one(self): 54 | """Get a relationship of on to one returns 200.""" 55 | user = models.User( 56 | first='Sally', last='Smith', 57 | password='password', username='SallySmith1') 58 | self.session.add(user) 59 | blog_post = models.Post( 60 | title='This Is A Title', content='This is the content', 61 | author_id=user.id, author=user) 62 | self.session.add(blog_post) 63 | self.session.commit() 64 | 65 | response = models.serializer.get_relationship( 66 | self.session, {}, 'posts', blog_post.id, 'author') 67 | 68 | expected = { 69 | 'data': { 70 | 'id': 1, 71 | 'type': 'users' 72 | }, 73 | 'jsonapi': { 74 | 'version': '1.0' 75 | }, 76 | 'meta': { 77 | 'sqlalchemy_jsonapi_version': __version__ 78 | } 79 | } 80 | actual = response.data 81 | self.assertEqual(expected, actual) 82 | self.assertEqual(200, response.status_code) 83 | 84 | def test_get_relationship_with_unknown_relationship(self): 85 | """Get a resources relationship with an unknown relationship returns 404. 86 | 87 | A RelationshipNotFoundError is raised. 88 | """ 89 | user = models.User( 90 | first='Sally', last='Smith', 91 | password='password', username='SallySmith1') 92 | self.session.add(user) 93 | blog_post = models.Post( 94 | title='This Is A Title', content='This is the content', 95 | author_id=user.id, author=user) 96 | self.session.add(blog_post) 97 | comment = models.Comment( 98 | content='This is a comment', author_id=user.id, 99 | post_id=blog_post.id, author=user, post=blog_post) 100 | self.session.add(comment) 101 | self.session.commit() 102 | 103 | with self.assertRaises(errors.RelationshipNotFoundError) as error: 104 | models.serializer.get_relationship( 105 | self.session, {}, 'posts', 106 | blog_post.id, 'invalid-relationship') 107 | 108 | self.assertEqual(error.exception.status_code, 404) 109 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/test_serializer_patch_relationship.py: -------------------------------------------------------------------------------- 1 | """Test for serializer's patch_relationship.""" 2 | 3 | from sqlalchemy_jsonapi import errors 4 | 5 | from sqlalchemy_jsonapi.unittests.utils import testcases 6 | from sqlalchemy_jsonapi.unittests import models 7 | from sqlalchemy_jsonapi import __version__ 8 | 9 | 10 | class GPatchRelationship(testcases.SqlalchemyJsonapiTestCase): 11 | """Tests for serializer.patch_relationship.""" 12 | 13 | def test_patch_relationship_on_to_one_set_to_resource_response(self): 14 | """Patch single relationship and set resource returns 200.""" 15 | user = models.User( 16 | first='Sally', last='Smith', 17 | password='password', username='SallySmith1') 18 | self.session.add(user) 19 | new_user = models.User( 20 | first='Bob', last='Joe', 21 | password='password', username='BobJoe2') 22 | self.session.add(new_user) 23 | blog_post = models.Post( 24 | title='This Is A Title', content='This is the content', 25 | author_id=user.id, author=user) 26 | self.session.add(blog_post) 27 | self.session.commit() 28 | payload = { 29 | 'data': { 30 | 'type': 'users', 31 | 'id': new_user.id 32 | } 33 | } 34 | 35 | response = models.serializer.patch_relationship( 36 | self.session, payload, 'posts', blog_post.id, 'author') 37 | 38 | expected = { 39 | 'data': { 40 | 'id': new_user.id, 41 | 'type': 'users' 42 | }, 43 | 'jsonapi': { 44 | 'version': '1.0' 45 | }, 46 | 'meta': { 47 | 'sqlalchemy_jsonapi_version': __version__ 48 | } 49 | } 50 | actual = response.data 51 | self.assertEqual(expected, actual) 52 | self.assertEqual(200, response.status_code) 53 | 54 | def test_patch_relationship_on_to_one_set_to_resource_successful(self): 55 | """Patch single relationship successfully updates resource.""" 56 | user = models.User( 57 | first='Sally', last='Smith', 58 | password='password', username='SallySmith1') 59 | self.session.add(user) 60 | new_user = models.User( 61 | first='Bob', last='Joe', 62 | password='password', username='BobJoe2') 63 | self.session.add(new_user) 64 | blog_post = models.Post( 65 | title='This Is A Title', content='This is the content', 66 | author_id=user.id, author=user) 67 | self.session.add(blog_post) 68 | self.session.commit() 69 | payload = { 70 | 'data': { 71 | 'type': 'users', 72 | 'id': new_user.id 73 | } 74 | } 75 | 76 | models.serializer.patch_relationship( 77 | self.session, payload, 'posts', blog_post.id, 'author') 78 | 79 | self.assertEqual(blog_post.author.id, new_user.id) 80 | self.assertEqual(blog_post.author, new_user) 81 | 82 | def test_patch_relationship_on_to_one_set_resource_to_null_response(self): 83 | """Patch relationship of a single resource and set to null returns 200.""" 84 | user = models.User( 85 | first='Sally', last='Smith', 86 | password='password', username='SallySmith1') 87 | self.session.add(user) 88 | blog_post = models.Post( 89 | title='This Is A Title', content='This is the content', 90 | author_id=user.id, author=user) 91 | self.session.add(blog_post) 92 | self.session.commit() 93 | payload = { 94 | 'data': None 95 | } 96 | 97 | response = models.serializer.patch_relationship( 98 | self.session, payload, 'posts', blog_post.id, 'author') 99 | 100 | expected = { 101 | 'data': None, 102 | 'jsonapi': { 103 | 'version': '1.0' 104 | }, 105 | 'meta': { 106 | 'sqlalchemy_jsonapi_version': __version__ 107 | } 108 | } 109 | actual = response.data 110 | self.assertEqual(expected, actual) 111 | self.assertEqual(200, response.status_code) 112 | 113 | def test_patch_relationship_on_to_one_set_resource_to_null_successful(self): 114 | """Patch relationship of single resource and set to null is successful.""" 115 | user = models.User( 116 | first='Sally', last='Smith', 117 | password='password', username='SallySmith1') 118 | self.session.add(user) 119 | blog_post = models.Post( 120 | title='This Is A Title', content='This is the content', 121 | author_id=user.id, author=user) 122 | self.session.add(blog_post) 123 | self.session.commit() 124 | payload = { 125 | 'data': None 126 | } 127 | 128 | models.serializer.patch_relationship( 129 | self.session, payload, 'posts', blog_post.id, 'author') 130 | 131 | self.assertEqual(blog_post.author, None) 132 | 133 | def test_patch_relationship_on_to_many_set_resources_response(self): 134 | """Patch relationships on many and set resources returns 200.""" 135 | user = models.User( 136 | first='Sally', last='Smith', 137 | password='password', username='SallySmith1') 138 | self.session.add(user) 139 | blog_post = models.Post( 140 | title='This Is A Title', content='This is the content', 141 | author_id=user.id, author=user) 142 | self.session.add(blog_post) 143 | comment = models.Comment( 144 | content='This is comment 1', author_id=user.id, 145 | post_id=blog_post.id, author=user, post=blog_post) 146 | self.session.add(comment) 147 | new_comment = models.Comment( 148 | content='This is a new comment 2', author_id=user.id, 149 | author=user) 150 | self.session.add(new_comment) 151 | self.session.commit() 152 | payload = { 153 | 'data': [{ 154 | 'type': 'comments', 155 | 'id': new_comment.id 156 | }] 157 | } 158 | 159 | response = models.serializer.patch_relationship( 160 | self.session, payload, 'posts', blog_post.id, 'comments') 161 | 162 | expected = { 163 | 'data': [{ 164 | 'type': 'comments', 165 | 'id': 2 166 | }], 167 | 'jsonapi': { 168 | 'version': '1.0' 169 | }, 170 | 'meta': { 171 | 'sqlalchemy_jsonapi_version': __version__ 172 | } 173 | } 174 | actual = response.data 175 | self.assertEqual(expected, actual) 176 | self.assertEqual(200, response.status_code) 177 | 178 | def test_patch_relationship_on_to_many_set_resources_successful(self): 179 | """Patch relationships on many and set resources is successful.""" 180 | user = models.User( 181 | first='Sally', last='Smith', 182 | password='password', username='SallySmith1') 183 | self.session.add(user) 184 | blog_post = models.Post( 185 | title='This Is A Title', content='This is the content', 186 | author_id=user.id, author=user) 187 | self.session.add(blog_post) 188 | comment = models.Comment( 189 | content='This is comment 1', author_id=user.id, 190 | post_id=blog_post.id, author=user, post=blog_post) 191 | self.session.add(comment) 192 | new_comment = models.Comment( 193 | content='This is a new comment 2', author_id=user.id, 194 | author=user) 195 | self.session.add(new_comment) 196 | self.session.commit() 197 | payload = { 198 | 'data': [{ 199 | 'type': 'comments', 200 | 'id': new_comment.id 201 | }] 202 | } 203 | 204 | models.serializer.patch_relationship( 205 | self.session, payload, 'posts', blog_post.id, 'comments') 206 | 207 | self.assertEqual(new_comment.post.id, blog_post.id) 208 | self.assertEqual(new_comment.post, blog_post) 209 | 210 | def test_patch_relationship_on_to_many_set_to_empty_response(self): 211 | """Patch relationships on many and set to empty returns 200.""" 212 | user = models.User( 213 | first='Sally', last='Smith', 214 | password='password', username='SallySmith1') 215 | self.session.add(user) 216 | blog_post = models.Post( 217 | title='This Is A Title', content='This is the content', 218 | author_id=user.id, author=user) 219 | self.session.add(blog_post) 220 | comment = models.Comment( 221 | content='This is comment 1', author_id=user.id, 222 | post_id=blog_post.id, author=user, post=blog_post) 223 | self.session.add(comment) 224 | self.session.commit() 225 | payload = { 226 | 'data': [] 227 | } 228 | 229 | response = models.serializer.patch_relationship( 230 | self.session, payload, 'posts', blog_post.id, 'comments') 231 | 232 | expected = { 233 | 'data': [], 234 | 'jsonapi': { 235 | 'version': '1.0' 236 | }, 237 | 'meta': { 238 | 'sqlalchemy_jsonapi_version': __version__ 239 | } 240 | } 241 | actual = response.data 242 | self.assertEqual(expected, actual) 243 | self.assertEqual(200, response.status_code) 244 | 245 | def test_patch_relationship_on_to_many_set_to_empty_successful(self): 246 | """Patch relationships on many and set to empty is successful.""" 247 | user = models.User( 248 | first='Sally', last='Smith', 249 | password='password', username='SallySmith1') 250 | self.session.add(user) 251 | blog_post = models.Post( 252 | title='This Is A Title', content='This is the content', 253 | author_id=user.id, author=user) 254 | self.session.add(blog_post) 255 | comment = models.Comment( 256 | content='This is comment 1', author_id=user.id, 257 | post_id=blog_post.id, author=user, post=blog_post) 258 | self.session.add(comment) 259 | self.session.commit() 260 | payload = { 261 | 'data': [] 262 | } 263 | 264 | models.serializer.patch_relationship( 265 | self.session, payload, 'posts', blog_post.id, 'comments') 266 | 267 | self.assertEqual(comment.post, None) 268 | 269 | def test_patch_relationship_on_to_one_with_empty_list(self): 270 | """Patch relationship on to one with empty list returns 409. 271 | 272 | A ValidationError is raised. 273 | """ 274 | user = models.User( 275 | first='Sally', last='Smith', 276 | password='password', username='SallySmith1') 277 | self.session.add(user) 278 | new_user = models.User( 279 | first='Bob', last='Joe', 280 | password='password', username='BobJoe2') 281 | self.session.add(new_user) 282 | blog_post = models.Post( 283 | title='This Is A Title', content='This is the content', 284 | author_id=user.id, author=user) 285 | self.session.add(blog_post) 286 | self.session.commit() 287 | payload = { 288 | 'data': [] 289 | } 290 | 291 | with self.assertRaises(errors.ValidationError) as error: 292 | models.serializer.patch_relationship( 293 | self.session, payload, 'posts', blog_post.id, 'author') 294 | 295 | expected_detail = 'Provided data must be a hash.' 296 | self.assertEqual(error.exception.detail, expected_detail) 297 | self.assertEqual(error.exception.status_code, 409) 298 | 299 | def test_patch_relationship_on_to_many_with_null(self): 300 | """Patch relationship on to many with null returns 409. 301 | 302 | A ValidationError is raised. 303 | """ 304 | user = models.User( 305 | first='Sally', last='Smith', 306 | password='password', username='SallySmith1') 307 | self.session.add(user) 308 | blog_post = models.Post( 309 | title='This Is A Title', content='This is the content', 310 | author_id=user.id, author=user) 311 | self.session.add(blog_post) 312 | comment = models.Comment( 313 | content='This is comment 1', author_id=user.id, 314 | post_id=blog_post.id, author=user, post=blog_post) 315 | self.session.add(comment) 316 | self.session.commit() 317 | payload = { 318 | 'data': None 319 | } 320 | 321 | with self.assertRaises(errors.ValidationError) as error: 322 | models.serializer.patch_relationship( 323 | self.session, payload, 'posts', blog_post.id, 'comments') 324 | 325 | expected_detail = 'Provided data must be an array.' 326 | self.assertEqual(error.exception.detail, expected_detail) 327 | self.assertEqual(error.exception.status_code, 409) 328 | 329 | def test_patch_relationship_with_unknown_relationship(self): 330 | """Patch relationship with unknown relationship returns 404. 331 | 332 | A RelationshipNotFoundError is raised. 333 | """ 334 | user = models.User( 335 | first='Sally', last='Smith', 336 | password='password', username='SallySmith1') 337 | self.session.add(user) 338 | blog_post = models.Post( 339 | title='This Is A Title', content='This is the content', 340 | author_id=user.id, author=user) 341 | self.session.add(blog_post) 342 | comment = models.Comment( 343 | content='This is comment 1', author_id=user.id, 344 | post_id=blog_post.id, author=user, post=blog_post) 345 | self.session.add(comment) 346 | self.session.commit() 347 | payload = { 348 | 'data': {} 349 | } 350 | 351 | with self.assertRaises(errors.RelationshipNotFoundError) as error: 352 | models.serializer.patch_relationship( 353 | self.session, payload, 'posts', 354 | blog_post.id, 'unknown-relationship') 355 | 356 | self.assertEqual(error.exception.status_code, 404) 357 | 358 | def test_patch_relationship_on_to_one_with_incompatible_model(self): 359 | """Patch relationship on to one with incompatible model returns 409. 360 | 361 | A ValidationError is raised. 362 | """ 363 | user = models.User( 364 | first='Sally', last='Smith', 365 | password='password', username='SallySmith1') 366 | self.session.add(user) 367 | blog_post = models.Post( 368 | title='This Is A Title', content='This is the content', 369 | author_id=user.id, author=user) 370 | self.session.add(blog_post) 371 | comment = models.Comment( 372 | content='This is comment 1', author_id=user.id, 373 | post_id=blog_post.id, author=user, post=blog_post) 374 | self.session.add(comment) 375 | self.session.commit() 376 | payload = { 377 | 'data': { 378 | 'type': 'comments', 379 | 'id': comment.id 380 | } 381 | } 382 | 383 | with self.assertRaises(errors.ValidationError) as error: 384 | models.serializer.patch_relationship( 385 | self.session, payload, 'posts', blog_post.id, 'author') 386 | 387 | expected_detail = 'Incompatible Type' 388 | self.assertEqual(error.exception.detail, expected_detail) 389 | self.assertEqual(error.exception.status_code, 409) 390 | 391 | def test_patch_relationship_on_to_many_with_incompatible_model(self): 392 | """Patch relationship on to many with incompatible model returns 409. 393 | 394 | A ValidationError is raised. 395 | """ 396 | user = models.User( 397 | first='Sally', last='Smith', 398 | password='password', username='SallySmith1') 399 | self.session.add(user) 400 | blog_post = models.Post( 401 | title='This Is A Title', content='This is the content', 402 | author_id=user.id, author=user) 403 | self.session.add(blog_post) 404 | comment = models.Comment( 405 | content='This is comment 1', author_id=user.id, 406 | post_id=blog_post.id, author=user, post=blog_post) 407 | self.session.add(comment) 408 | self.session.commit() 409 | payload = { 410 | 'data': [{ 411 | 'type': 'users', 412 | 'id': user.id 413 | }] 414 | } 415 | 416 | with self.assertRaises(errors.ValidationError) as error: 417 | models.serializer.patch_relationship( 418 | self.session, payload, 'posts', blog_post.id, 'comments') 419 | 420 | expected_detail = 'Incompatible Type' 421 | self.assertEqual(error.exception.detail, expected_detail) 422 | self.assertEqual(error.exception.status_code, 409) 423 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/test_serializer_patch_resource.py: -------------------------------------------------------------------------------- 1 | """Test for serializer's patch_resource.""" 2 | 3 | from sqlalchemy_jsonapi import errors 4 | 5 | from sqlalchemy_jsonapi.unittests.utils import testcases 6 | from sqlalchemy_jsonapi.unittests import models 7 | from sqlalchemy_jsonapi import __version__ 8 | 9 | 10 | class PatchResource(testcases.SqlalchemyJsonapiTestCase): 11 | """Tests for serializer.patch_resource.""" 12 | 13 | def test_patch_resource_successful(self): 14 | """Patch resource is successful""" 15 | user = models.User( 16 | first='Sally', last='Smith', 17 | password='password', username='SallySmith1') 18 | self.session.add(user) 19 | blog_post = models.Post( 20 | title='This Is A Title', content='This is the content') 21 | self.session.add(blog_post) 22 | self.session.commit() 23 | payload = { 24 | 'data': { 25 | 'type': 'posts', 26 | 'id': blog_post.id, 27 | 'attributes': { 28 | 'title': 'This is a new title' 29 | }, 30 | 'relationships': { 31 | 'author': { 32 | 'data': { 33 | 'type': 'users', 34 | 'id': user.id 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | models.serializer.patch_resource( 42 | self.session, payload, 'posts', blog_post.id) 43 | 44 | self.assertEqual(blog_post.author.id, user.id) 45 | self.assertEqual(blog_post.author, user) 46 | 47 | @testcases.fragile 48 | def test_patch_resource_response(self): 49 | """Patch resource response returns resource and 200.""" 50 | user = models.User( 51 | first='Sally', last='Smith', 52 | password='password', username='SallySmith1') 53 | self.session.add(user) 54 | blog_post = models.Post( 55 | title='This Is A Title', content='This is the content') 56 | self.session.add(blog_post) 57 | self.session.commit() 58 | payload = { 59 | 'data': { 60 | 'type': 'posts', 61 | 'id': blog_post.id, 62 | 'attributes': { 63 | 'title': 'This is a new title' 64 | }, 65 | 'relationships': { 66 | 'author': { 67 | 'data': { 68 | 'type': 'users', 69 | 'id': user.id 70 | } 71 | } 72 | } 73 | } 74 | } 75 | 76 | response = models.serializer.patch_resource( 77 | self.session, payload, 'posts', blog_post.id) 78 | 79 | expected = { 80 | 'data': { 81 | 'id': 1, 82 | 'attributes': { 83 | 'content': u'This is the content', 84 | 'title': u'This is a new title' 85 | }, 86 | 'type': 'posts', 87 | 'relationships': { 88 | 'comments': { 89 | 'links': { 90 | 'related': '/posts/1/comments', 91 | 'self': '/posts/1/relationships/comments' 92 | } 93 | }, 94 | 'author': { 95 | 'links': { 96 | 'related': '/posts/1/author', 97 | 'self': '/posts/1/relationships/author' 98 | } 99 | } 100 | } 101 | }, 102 | 'included': [], 103 | 'jsonapi': { 104 | 'version': '1.0' 105 | }, 106 | 'meta': { 107 | 'sqlalchemy_jsonapi_version': __version__ 108 | } 109 | } 110 | actual = response.data 111 | self.assertEqual(expected, actual) 112 | self.assertEqual(200, response.status_code) 113 | 114 | def test_patch_resource_with_missing_type(self): 115 | """Patch resource with missing type results in 400. 116 | 117 | A BadRequestError is raised. 118 | """ 119 | user = models.User( 120 | first='Sally', last='Smith', 121 | password='password', username='SallySmith1') 122 | self.session.add(user) 123 | blog_post = models.Post( 124 | title='This Is A Title', content='This is the content') 125 | self.session.add(blog_post) 126 | self.session.commit() 127 | payload = { 128 | 'data': { 129 | 'type': 'posts', 130 | 'attributes': { 131 | 'title': 'This is a new title' 132 | }, 133 | 'relationships': { 134 | 'author': { 135 | 'data': { 136 | 'type': 'users', 137 | 'id': user.id 138 | } 139 | } 140 | } 141 | } 142 | } 143 | 144 | with self.assertRaises(errors.BadRequestError) as error: 145 | models.serializer.patch_resource( 146 | self.session, payload, 'posts', blog_post.id) 147 | 148 | expected_detail = 'Missing type or id' 149 | self.assertEqual(error.exception.detail, expected_detail) 150 | self.assertEqual(error.exception.status_code, 400) 151 | 152 | def test_patch_resource_with_mismatched_id(self): 153 | """Patch resource with mismatched id results in 400. 154 | 155 | A BadRequestError is raised. 156 | """ 157 | user = models.User( 158 | first='Sally', last='Smith', 159 | password='password', username='SallySmith1') 160 | self.session.add(user) 161 | blog_post = models.Post( 162 | title='This Is A Title', content='This is the content') 163 | self.session.add(blog_post) 164 | self.session.commit() 165 | payload = { 166 | 'data': { 167 | 'type': 'posts', 168 | 'id': 2, 169 | 'attributes': { 170 | 'title': 'This is a new title' 171 | }, 172 | 'relationships': { 173 | 'author': { 174 | 'data': { 175 | 'type': 'users', 176 | 'id': user.id 177 | } 178 | } 179 | } 180 | } 181 | } 182 | 183 | with self.assertRaises(errors.BadRequestError) as error: 184 | models.serializer.patch_resource( 185 | self.session, payload, 'posts', blog_post.id) 186 | 187 | expected_detail = 'IDs do not match' 188 | self.assertEqual(error.exception.detail, expected_detail) 189 | self.assertEqual(error.exception.status_code, 400) 190 | 191 | def test_patch_resource_with_mismatched_type(self): 192 | """Patch resource with mismatched type results in 400. 193 | 194 | A BadRequestError is raised. 195 | """ 196 | user = models.User( 197 | first='Sally', last='Smith', 198 | password='password', username='SallySmith1') 199 | self.session.add(user) 200 | blog_post = models.Post( 201 | title='This Is A Title', content='This is the content') 202 | self.session.add(blog_post) 203 | self.session.commit() 204 | payload = { 205 | 'data': { 206 | 'type': 'comments', 207 | 'id': blog_post.id, 208 | 'attributes': { 209 | 'title': 'This is a new title' 210 | }, 211 | 'relationships': { 212 | 'author': { 213 | 'data': { 214 | 'type': 'users', 215 | 'id': user.id 216 | } 217 | } 218 | } 219 | } 220 | } 221 | 222 | with self.assertRaises(errors.BadRequestError) as error: 223 | models.serializer.patch_resource( 224 | self.session, payload, 'posts', blog_post.id) 225 | 226 | expected_detail = 'Type does not match' 227 | self.assertEqual(error.exception.detail, expected_detail) 228 | self.assertEqual(error.exception.status_code, 400) 229 | 230 | def test_patch_resource_relationship_field_not_found(self): 231 | """Patch resource with unknown relationship field returns 400. 232 | 233 | A BadRequestError is raised. 234 | """ 235 | user = models.User( 236 | first='Sally', last='Smith', 237 | password='password', username='SallySmith1') 238 | self.session.add(user) 239 | blog_post = models.Post( 240 | title='This Is A Title', content='This is the content', 241 | author_id=user.id, author=user) 242 | self.session.add(blog_post) 243 | comment = models.Comment( 244 | content='This is a comment', author_id=user.id, 245 | post_id=blog_post.id, author=user, post=blog_post) 246 | self.session.add(comment) 247 | self.session.commit() 248 | payload = { 249 | 'data': { 250 | 'type': 'posts', 251 | 'id': blog_post.id, 252 | 'relationships': { 253 | 'nonexistant': { 254 | 'data': { 255 | 'type': 'users', 256 | 'id': user.id 257 | } 258 | } 259 | } 260 | } 261 | } 262 | 263 | with self.assertRaises(errors.BadRequestError) as error: 264 | models.serializer.patch_resource( 265 | self.session, payload, 'posts', blog_post.id) 266 | 267 | expected_detail = 'nonexistant not relationships for posts.1' 268 | self.assertEqual(error.exception.detail, expected_detail) 269 | self.assertEqual(error.exception.status_code, 400) 270 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/test_serializer_post_collection.py: -------------------------------------------------------------------------------- 1 | """Test for serializer's post_collection.""" 2 | 3 | from sqlalchemy_jsonapi import errors 4 | 5 | from sqlalchemy_jsonapi.unittests.utils import testcases 6 | from sqlalchemy_jsonapi.unittests import models 7 | from sqlalchemy_jsonapi import __version__ 8 | 9 | 10 | class PostCollection(testcases.SqlalchemyJsonapiTestCase): 11 | """Tests for serializer.post_collection.""" 12 | 13 | def test_add_resource(self): 14 | """Create resource successfully.""" 15 | payload = { 16 | 'data': { 17 | 'type': 'users', 18 | 'attributes': { 19 | 'first': 'Sally', 20 | 'last': 'Smith', 21 | 'username': 'SallySmith1', 22 | 'password': 'password', 23 | } 24 | } 25 | } 26 | 27 | response = models.serializer.post_collection( 28 | self.session, payload, 'users') 29 | user = self.session.query(models.User).get( 30 | response.data['data']['id']) 31 | self.assertEqual(user.first, 'SET-ATTR:Sally') 32 | self.assertEqual(user.last, 'Smith') 33 | self.assertEqual(user.username, 'SallySmith1') 34 | self.assertEqual(user.password, 'password') 35 | 36 | @testcases.fragile 37 | def test_add_resource_response(self): 38 | """Create resource returns data response and 201. 39 | 40 | This test is very fragile. 41 | """ 42 | payload = { 43 | 'data': { 44 | 'type': 'users', 45 | 'attributes': { 46 | 'first': 'Sally', 47 | 'last': 'Smith', 48 | 'username': 'SallySmith1', 49 | 'password': 'password' 50 | } 51 | } 52 | } 53 | 54 | response = models.serializer.post_collection( 55 | self.session, payload, 'users') 56 | 57 | expected = { 58 | 'data': { 59 | 'attributes': { 60 | 'first': u'Sally', 61 | 'last': u'Smith', 62 | 'username': u'SallySmith1' 63 | }, 64 | 'id': 1, 65 | 'relationships': { 66 | 'posts': { 67 | 'links': { 68 | 'related': '/users/1/posts', 69 | 'self': '/users/1/relationships/posts' 70 | } 71 | }, 72 | 'logs': { 73 | 'links': { 74 | 'related': '/users/1/logs', 75 | 'self': '/users/1/relationships/logs' 76 | } 77 | }, 78 | 'comments': { 79 | 'links': { 80 | 'related': '/users/1/comments', 81 | 'self': '/users/1/relationships/comments' 82 | } 83 | } 84 | }, 85 | 'type': 'users' 86 | }, 87 | 'included': [], 88 | 'jsonapi': { 89 | 'version': '1.0' 90 | }, 91 | 'meta': { 92 | 'sqlalchemy_jsonapi_version': __version__ 93 | } 94 | } 95 | 96 | actual = response.data 97 | self.assertEqual(expected, actual) 98 | self.assertEqual(201, response.status_code) 99 | 100 | def test_add_resource_with_relationship(self): 101 | """Create resource succesfully with many-to-one relationship.""" 102 | user = models.User( 103 | first='Sally', last='Smith', 104 | password='password', username='SallySmith1') 105 | self.session.add(user) 106 | self.session.commit() 107 | 108 | payload = { 109 | 'data': { 110 | 'type': 'posts', 111 | 'attributes': { 112 | 'title': 'Some Title', 113 | 'content': 'Some Content Inside' 114 | }, 115 | 'relationships': { 116 | 'author': { 117 | 'data': { 118 | 'type': 'users', 119 | 'id': user.id 120 | } 121 | } 122 | } 123 | } 124 | } 125 | 126 | response = models.serializer.post_collection( 127 | self.session, payload, 'posts') 128 | 129 | blog_post = self.session.query(models.Post).get( 130 | response.data['data']['id']) 131 | self.assertEqual(blog_post.title, 'Some Title') 132 | self.assertEqual(blog_post.content, 'Some Content Inside') 133 | self.assertEqual(blog_post.author_id, user.id) 134 | self.assertEqual(blog_post.author, user) 135 | 136 | @testcases.fragile 137 | def test_add_resource_with_many_to_one_relationship_response(self): 138 | """Create resource succesfully with many-to-one relationship returns 201.""" 139 | user = models.User( 140 | first='Sally', last='Smith', 141 | password='password', username='SallySmith1') 142 | self.session.add(user) 143 | self.session.commit() 144 | 145 | payload = { 146 | 'data': { 147 | 'type': 'posts', 148 | 'attributes': { 149 | 'title': 'Some Title', 150 | 'content': 'Some Content Inside' 151 | }, 152 | 'relationships': { 153 | 'author': { 154 | 'data': { 155 | 'type': 'users', 156 | 'id': user.id 157 | } 158 | } 159 | } 160 | } 161 | } 162 | 163 | response = models.serializer.post_collection( 164 | self.session, payload, 'posts') 165 | 166 | expected = { 167 | 'data': { 168 | 'type': 'posts', 169 | 'attributes': { 170 | 'title': u'Some Title', 171 | 'content': u'Some Content Inside' 172 | }, 173 | 'id': 1, 174 | 'relationships': { 175 | 'author': { 176 | 'links': { 177 | 'related': '/posts/1/author', 178 | 'self': '/posts/1/relationships/author' 179 | } 180 | }, 181 | 'comments': { 182 | 'links': { 183 | 'related': '/posts/1/comments', 184 | 'self': '/posts/1/relationships/comments' 185 | } 186 | } 187 | } 188 | }, 189 | 'included': [], 190 | 'jsonapi': { 191 | 'version': '1.0' 192 | }, 193 | 'meta': { 194 | 'sqlalchemy_jsonapi_version': __version__ 195 | } 196 | } 197 | actual = response.data 198 | self.assertEqual(expected, actual) 199 | self.assertEqual(response.status_code, 201) 200 | 201 | def test_add_resource_twice(self): 202 | """Creating same resource twice results in 409 conflict.""" 203 | payload = { 204 | 'data': { 205 | 'type': 'users', 206 | 'attributes': { 207 | 'first': 'Sally', 208 | 'last': 'Smith', 209 | 'username': 'SallySmith1', 210 | 'password': 'password', 211 | } 212 | } 213 | } 214 | models.serializer.post_collection(self.session, payload, 'users') 215 | 216 | with self.assertRaises(errors.ValidationError) as error: 217 | models.serializer.post_collection( 218 | self.session, payload, 'users') 219 | 220 | self.assertEqual(error.exception.status_code, 409) 221 | 222 | def test_add_resource_mismatched_endpoint(self): 223 | """Create resource with mismatched returns 409. 224 | 225 | A InvalidTypeEndpointError is raised. 226 | """ 227 | payload = { 228 | 'data': { 229 | 'type': 'posts' 230 | } 231 | } 232 | 233 | with self.assertRaises(errors.InvalidTypeForEndpointError) as error: 234 | models.serializer.post_collection(self.session, payload, 'users') 235 | 236 | self.assertEqual( 237 | error.exception.detail, 'Expected users, got posts') 238 | self.assertEqual(error.exception.status_code, 409) 239 | 240 | def test_add_resource_with_missing_data(self): 241 | """Create resource with missing content data results in 400. 242 | 243 | A BadRequestError is raised. 244 | """ 245 | payload = {} 246 | 247 | with self.assertRaises(errors.BadRequestError) as error: 248 | models.serializer.post_collection(self.session, payload, 'users') 249 | 250 | self.assertEqual( 251 | error.exception.detail, 'Request should contain data key') 252 | self.assertEqual(error.exception.status_code, 400) 253 | 254 | def test_add_resource_with_missing_type(self): 255 | """Creat resource without type results in 409. 256 | 257 | A MissingTypeError is raised. 258 | """ 259 | payload = { 260 | 'data': { 261 | 'attributes': { 262 | 'first': 'Sally', 263 | 'last': 'Smith', 264 | 'username': 'SallySmith1', 265 | 'password': 'password', 266 | } 267 | } 268 | } 269 | 270 | with self.assertRaises(errors.MissingTypeError) as error: 271 | models.serializer.post_collection(self.session, payload, 'users') 272 | 273 | self.assertEqual( 274 | error.exception.detail, 'Missing /data/type key in request body') 275 | self.assertEqual(error.exception.status_code, 409) 276 | 277 | def test_add_resource_with_unknown_field_name(self): 278 | """Create resource with unknown field results in 409. 279 | 280 | A ValidationError is raised. 281 | """ 282 | payload = { 283 | 'data': { 284 | 'type': 'users', 285 | 'attributes': { 286 | 'first': 'Sally', 287 | 'last': 'Smith', 288 | 'username': 'SallySmith1', 289 | 'password': 'password', 290 | 'unknown-attribute': 'test' 291 | } 292 | } 293 | } 294 | 295 | with self.assertRaises(errors.ValidationError) as error: 296 | models.serializer.post_collection( 297 | self.session, payload, 'users') 298 | 299 | self.assertEqual(error.exception.detail, 'Incompatible data type') 300 | self.assertEqual(error.exception.status_code, 409) 301 | 302 | def test_add_resource_access_denied(self): 303 | """Add a resource with access denied results in 403.""" 304 | payload = { 305 | 'data': { 306 | 'type': 'logs' 307 | } 308 | } 309 | 310 | with self.assertRaises(errors.PermissionDeniedError) as error: 311 | models.serializer.post_collection( 312 | self.session, payload, 'logs') 313 | 314 | self.assertEqual(error.exception.detail, 'CREATE denied on logs.None') 315 | self.assertEqual(error.exception.status_code, 403) 316 | 317 | def test_add_resource_with_given_id(self): 318 | """Create resource successfully with specified id.""" 319 | payload = { 320 | 'data': { 321 | 'type': 'users', 322 | 'id': 3, 323 | 'attributes': { 324 | 'first': 'Sally', 325 | 'last': 'Smith', 326 | 'username': 'SallySmith1', 327 | 'password': 'password', 328 | } 329 | } 330 | } 331 | 332 | response = models.serializer.post_collection( 333 | self.session, payload, 'users') 334 | user = self.session.query(models.User).get( 335 | response.data['data']['id']) 336 | self.assertEqual(user.first, 'SET-ATTR:Sally') 337 | self.assertEqual(user.last, 'Smith') 338 | self.assertEqual(user.username, 'SallySmith1') 339 | self.assertEqual(user.password, 'password') 340 | 341 | def test_add_resource_with_invalid_one_to_many_relationships(self): 342 | """Create resource with invalid one-to-many relationship returns 400. 343 | 344 | In a one-to-many relationship, the data in the relationship must be 345 | of type array. 346 | A BadRequestError is raised. 347 | """ 348 | payload = { 349 | 'data': { 350 | 'attributes': { 351 | 'first': 'Sally', 352 | 'last': 'Smith', 353 | 'username': 'SallySmith1', 354 | 'password': 'password', 355 | }, 356 | 'type': 'users', 357 | 'relationships': { 358 | 'posts': { 359 | 'data': { 360 | 'type': 'posts', 361 | 'id': 1 362 | } 363 | } 364 | } 365 | } 366 | } 367 | 368 | with self.assertRaises(errors.BadRequestError) as error: 369 | models.serializer.post_collection( 370 | self.session, payload, 'users') 371 | 372 | self.assertEqual(error.exception.detail, 'posts must be an array') 373 | self.assertEqual(error.exception.status_code, 400) 374 | 375 | def test_add_resource_with_no_data_in_many_to_one_relationship(self): 376 | """Create resource without data in many-to-one relationships returns 400. 377 | 378 | A BadRequestError is raised. 379 | """ 380 | user = models.User( 381 | first='Sally', last='Smith', 382 | password='password', username='SallySmith1') 383 | self.session.add(user) 384 | self.session.commit() 385 | 386 | payload = { 387 | 'data': { 388 | 'type': 'posts', 389 | 'attributes': { 390 | 'title': 'Some Title', 391 | 'content': 'Some Content Inside' 392 | }, 393 | 'relationships': { 394 | 'author': { 395 | 'test': { 396 | 'type': 'users', 397 | 'id': user.id 398 | } 399 | } 400 | } 401 | } 402 | } 403 | 404 | with self.assertRaises(errors.BadRequestError) as error: 405 | models.serializer.post_collection( 406 | self.session, payload, 'posts') 407 | 408 | self.assertEqual( 409 | error.exception.detail, 'Missing data key in relationship author') 410 | self.assertEqual(error.exception.status_code, 400) 411 | 412 | def test_add_resource_when_data_in_many_to_one_relationship_not_dict(self): 413 | """Create resource with many-to-one relationship whose data is not a dict returns 400. 414 | 415 | A BadRequestError is raised. 416 | """ 417 | payload = { 418 | 'data': { 419 | 'type': 'posts', 420 | 'attributes': { 421 | 'title': 'Some Title', 422 | 'content': 'Some Content Inside' 423 | }, 424 | 'relationships': { 425 | 'author': { 426 | 'data': 'Test that not being a dictionary fails' 427 | } 428 | } 429 | } 430 | } 431 | with self.assertRaises(errors.BadRequestError) as error: 432 | models.serializer.post_collection( 433 | self.session, payload, 'posts') 434 | 435 | self.assertEqual(error.exception.detail, 'author must be a hash') 436 | self.assertEqual(error.exception.status_code, 400) 437 | 438 | def test_add_resource_with_invalid_many_to_one_relationship_data(self): 439 | """Create resource with invalid many-to-one relationship data returns 400. 440 | 441 | A BadRequestError is raised. 442 | """ 443 | user = models.User( 444 | first='Sally', last='Smith', 445 | password='password', username='SallySmith1') 446 | self.session.add(user) 447 | self.session.commit() 448 | 449 | payload = { 450 | 'data': { 451 | 'type': 'posts', 452 | 'attributes': { 453 | 'title': 'Some Title', 454 | 'content': 'Some Content Inside' 455 | }, 456 | 'relationships': { 457 | 'author': { 458 | 'data': { 459 | 'type': 'users', 460 | 'id': 1, 461 | 'name': 'Sally' 462 | } 463 | } 464 | } 465 | } 466 | } 467 | with self.assertRaises(errors.BadRequestError) as error: 468 | models.serializer.post_collection( 469 | self.session, payload, 'posts') 470 | 471 | self.assertEqual( 472 | error.exception.detail, 'author must have type and id keys') 473 | self.assertEqual(error.exception.status_code, 400) 474 | 475 | def test_add_resource_with_missing_one_to_many_relationship_type(self): 476 | """Create resource with missing one-to-many relationship type returns 400. 477 | 478 | The relationship data must contain 'id' and 'type'. 479 | A BadRequestError is raised. 480 | """ 481 | payload = { 482 | 'data': { 483 | 'attributes': { 484 | 'first': 'Sally', 485 | 'last': 'Smith', 486 | 'username': 'SallySmith1', 487 | 'password': 'password', 488 | }, 489 | 'type': 'users', 490 | 'relationships': { 491 | 'posts': { 492 | 'data': [{ 493 | 'type': 'posts', 494 | }] 495 | } 496 | } 497 | } 498 | } 499 | 500 | with self.assertRaises(errors.BadRequestError) as error: 501 | models.serializer.post_collection( 502 | self.session, payload, 'users') 503 | 504 | self.assertEqual( 505 | error.exception.detail, 'posts must have type and id keys') 506 | self.assertEqual(error.exception.status_code, 400) 507 | 508 | def test_add_resource_with_invalid_json_payload(self): 509 | """Create resource with invalid json payload returns 400. 510 | 511 | A BadRequestError is raised. 512 | """ 513 | payload = {'foo'} 514 | 515 | with self.assertRaises(errors.BadRequestError) as error: 516 | models.serializer.post_collection( 517 | self.session, payload, 'users') 518 | 519 | self.assertEqual( 520 | error.exception.detail, 'Request body should be a JSON hash') 521 | self.assertEqual(error.exception.status_code, 400) 522 | 523 | @testcases.fragile 524 | def test_add_resource_with_a_null_relationship(self): 525 | """Create resource with a null relationship returns 201.""" 526 | payload = { 527 | 'data': { 528 | 'type': 'posts', 529 | 'attributes': { 530 | 'title': 'Some Title', 531 | 'content': 'Some Content Inside' 532 | }, 533 | 'relationships': { 534 | 'author': { 535 | 'data': None 536 | } 537 | } 538 | } 539 | } 540 | 541 | response = models.serializer.post_collection( 542 | self.session, payload, 'posts') 543 | 544 | expected = { 545 | 'data': { 546 | 'type': 'posts', 547 | 'relationships': { 548 | 'author': { 549 | 'links': { 550 | 'self': '/posts/1/relationships/author', 551 | 'related': '/posts/1/author' 552 | } 553 | }, 554 | 'comments': { 555 | 'links': { 556 | 'self': '/posts/1/relationships/comments', 557 | 'related': '/posts/1/comments' 558 | } 559 | } 560 | }, 561 | 'id': 1, 562 | 'attributes': { 563 | 'title': u'Some Title', 564 | 'content': u'Some Content Inside' 565 | } 566 | }, 567 | 'jsonapi': { 568 | 'version': '1.0' 569 | }, 570 | 'meta': { 571 | 'sqlalchemy_jsonapi_version': __version__ 572 | }, 573 | 'included': [] 574 | } 575 | actual = response.data 576 | self.assertEqual(expected, actual) 577 | self.assertEqual(201, response.status_code) 578 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/test_serializer_post_relationship.py: -------------------------------------------------------------------------------- 1 | """Test for serializer's post_relationship.""" 2 | 3 | from sqlalchemy_jsonapi import errors 4 | 5 | from sqlalchemy_jsonapi.unittests.utils import testcases 6 | from sqlalchemy_jsonapi.unittests import models 7 | from sqlalchemy_jsonapi import __version__ 8 | 9 | 10 | class PostRelationship(testcases.SqlalchemyJsonapiTestCase): 11 | """Tests for serializer.post_relationship.""" 12 | 13 | def test_post_relationship_on_to_many_success(self): 14 | """Post relationship creates a relationship on many resources.""" 15 | user = models.User( 16 | first='Sally', last='Smith', 17 | password='password', username='SallySmith1') 18 | self.session.add(user) 19 | blog_post = models.Post( 20 | title='This Is A Title', content='This is the content', 21 | author_id=user.id, author=user) 22 | self.session.add(blog_post) 23 | comment_one = models.Comment( 24 | content='This is the first comment', 25 | author_id=user.id, author=user) 26 | self.session.add(comment_one) 27 | comment_two = models.Comment( 28 | content='This is the second comment', 29 | author_id=user.id, author=user) 30 | self.session.add(comment_two) 31 | self.session.commit() 32 | payload = { 33 | 'data': [{ 34 | 'type': 'comments', 35 | 'id': comment_one.id 36 | }, { 37 | 'type': 'comments', 38 | 'id': comment_two.id 39 | }] 40 | } 41 | 42 | models.serializer.post_relationship( 43 | self.session, payload, 'posts', blog_post.id, 'comments') 44 | 45 | self.assertEqual(comment_one.post.id, blog_post.id) 46 | self.assertEqual(comment_one.post, blog_post) 47 | self.assertEqual(comment_two.post.id, blog_post.id) 48 | self.assertEqual(comment_two.post.id, blog_post.id) 49 | 50 | def test_post_relationship_on_to_many_response(self): 51 | """Post relationship creates a relationship on many resources returns 200.""" 52 | user = models.User( 53 | first='Sally', last='Smith', 54 | password='password', username='SallySmith1') 55 | self.session.add(user) 56 | blog_post = models.Post( 57 | title='This Is A Title', content='This is the content', 58 | author_id=user.id, author=user) 59 | self.session.add(blog_post) 60 | comment_one = models.Comment( 61 | content='This is the first comment', 62 | author_id=user.id, author=user) 63 | self.session.add(comment_one) 64 | comment_two = models.Comment( 65 | content='This is the second comment', 66 | author_id=user.id, author=user) 67 | self.session.add(comment_two) 68 | self.session.commit() 69 | payload = { 70 | 'data': [{ 71 | 'type': 'comments', 72 | 'id': comment_one.id 73 | }, { 74 | 'type': 'comments', 75 | 'id': comment_two.id 76 | }] 77 | } 78 | 79 | response = models.serializer.post_relationship( 80 | self.session, payload, 'posts', blog_post.id, 'comments') 81 | 82 | expected = { 83 | 'data': [{ 84 | 'type': 'comments', 85 | 'id': comment_one.id 86 | }, { 87 | 'type': 'comments', 88 | 'id': comment_two.id 89 | }], 90 | 'jsonapi': { 91 | 'version': '1.0' 92 | }, 93 | 'meta': { 94 | 'sqlalchemy_jsonapi_version': __version__ 95 | } 96 | } 97 | actual = response.data 98 | self.assertEqual(expected, actual) 99 | self.assertEqual(200, response.status_code) 100 | 101 | def test_post_relationship_with_hash_instead_of_array(self): 102 | """Post relalationship with a hash instead of an array returns 409. 103 | 104 | A ValidationError is raised. 105 | """ 106 | user = models.User( 107 | first='Sally', last='Smith', 108 | password='password', username='SallySmith1') 109 | self.session.add(user) 110 | blog_post = models.Post( 111 | title='This Is A Title', content='This is the content', 112 | author_id=user.id, author=user) 113 | self.session.add(blog_post) 114 | comment = models.Comment( 115 | content='This is the first comment', 116 | author_id=user.id, author=user) 117 | self.session.add(comment) 118 | self.session.commit() 119 | payload = { 120 | 'data': { 121 | 'type': 'comments', 122 | 'id': comment.id 123 | } 124 | } 125 | 126 | with self.assertRaises(errors.ValidationError) as error: 127 | models.serializer.post_relationship( 128 | self.session, payload, 'posts', blog_post.id, 'comments') 129 | 130 | expected_detail = '/data must be an array' 131 | self.assertEqual(error.exception.detail, expected_detail) 132 | self.assertEqual(error.exception.status_code, 409) 133 | 134 | def test_post_relationship_with_incompatible_data_model(self): 135 | """Post relationship with incompatible data model returns 409. 136 | 137 | The model type in the payload must match the relationship type. 138 | A ValidationError is raised. 139 | """ 140 | user = models.User( 141 | first='Sally', last='Smith', 142 | password='password', username='SallySmith1') 143 | self.session.add(user) 144 | blog_post = models.Post( 145 | title='This Is A Title', content='This is the content', 146 | author_id=user.id, author=user) 147 | self.session.add(blog_post) 148 | comment = models.Comment( 149 | content='This is the first comment', 150 | author_id=user.id, author=user) 151 | self.session.add(comment) 152 | self.session.commit() 153 | payload = { 154 | 'data': [{ 155 | 'type': 'users', 156 | 'id': user.id 157 | }] 158 | } 159 | 160 | with self.assertRaises(errors.ValidationError) as error: 161 | models.serializer.post_relationship( 162 | self.session, payload, 'posts', blog_post.id, 'comments') 163 | 164 | expected_detail = 'Incompatible type provided' 165 | self.assertEqual(error.exception.detail, expected_detail) 166 | self.assertEqual(error.exception.status_code, 409) 167 | 168 | def test_post_relationship_with_to_one_relationship(self): 169 | """Post relationship with to one relationship returns 409. 170 | 171 | Cannot post to a to-one relationship. 172 | A ValidationError is raised. 173 | """ 174 | user = models.User( 175 | first='Sally', last='Smith', 176 | password='password', username='SallySmith1') 177 | self.session.add(user) 178 | blog_post = models.Post( 179 | title='This Is A Title', content='This is the content', 180 | author_id=user.id, author=user) 181 | self.session.add(blog_post) 182 | comment = models.Comment( 183 | content='This is the first comment', 184 | author_id=user.id, author=user) 185 | self.session.add(comment) 186 | self.session.commit() 187 | 188 | with self.assertRaises(errors.ValidationError) as error: 189 | models.serializer.post_relationship( 190 | self.session, {}, 'comments', comment.id, 'author') 191 | 192 | expected_detail = 'Cannot post to to-one relationship' 193 | self.assertEqual(error.exception.detail, expected_detail) 194 | self.assertEqual(error.exception.status_code, 409) 195 | 196 | def test_post_relationship_with_unknown_relationship(self): 197 | """Post relationship with unknown relationship results in a 404. 198 | 199 | A RelationshipNotFoundError is raised. 200 | """ 201 | user = models.User( 202 | first='Sally', last='Smith', 203 | password='password', username='SallySmith1') 204 | self.session.add(user) 205 | blog_post = models.Post( 206 | title='This Is A Title', content='This is the content', 207 | author_id=user.id, author=user) 208 | self.session.add(blog_post) 209 | comment = models.Comment( 210 | content='This is the first comment', 211 | author_id=user.id, author=user) 212 | self.session.add(comment) 213 | self.session.commit() 214 | 215 | with self.assertRaises(errors.RelationshipNotFoundError) as error: 216 | models.serializer.post_relationship( 217 | self.session, {}, 'posts', 218 | blog_post.id, 'unknown-relationship') 219 | 220 | self.assertEqual(error.exception.status_code, 404) 221 | 222 | def test_post_relationship_with_extra_data_keys(self): 223 | """Post relationship with data keys other than 'id' and 'type' results in 404. 224 | 225 | A BadRequestError is raised. 226 | """ 227 | user = models.User( 228 | first='Sally', last='Smith', 229 | password='password', username='SallySmith1') 230 | self.session.add(user) 231 | blog_post = models.Post( 232 | title='This Is A Title', content='This is the content', 233 | author_id=user.id, author=user) 234 | self.session.add(blog_post) 235 | comment = models.Comment( 236 | content='This is the first comment', 237 | author_id=user.id, author=user) 238 | self.session.add(comment) 239 | self.session.commit() 240 | payload = { 241 | 'data': [{ 242 | 'type': 'comments', 243 | 'id': comment.id, 244 | 'extra-key': 'foo' 245 | }] 246 | } 247 | with self.assertRaises(errors.BadRequestError) as error: 248 | models.serializer.post_relationship( 249 | self.session, payload, 'posts', blog_post.id, 'comments') 250 | 251 | expected_detail = 'comments must have type and id keys' 252 | self.assertEqual(error.exception.detail, expected_detail) 253 | self.assertEqual(error.exception.status_code, 400) 254 | -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ColtonProvias/sqlalchemy-jsonapi/40f8b5970d44935b27091c2bf3224482d23311bb/sqlalchemy_jsonapi/unittests/utils/__init__.py -------------------------------------------------------------------------------- /sqlalchemy_jsonapi/unittests/utils/testcases.py: -------------------------------------------------------------------------------- 1 | """Testcases for sqlalchemy_jsonapi unittests.""" 2 | 3 | import unittest 4 | import nose 5 | from functools import wraps 6 | 7 | from sqlalchemy.orm import sessionmaker 8 | from sqlalchemy import create_engine 9 | 10 | from sqlalchemy_jsonapi.unittests.models import Base 11 | 12 | 13 | def fragile(func): 14 | """The fragile decorator raises SkipTest if test fails. 15 | 16 | Use @fragile for tests that intermittenly fail. 17 | """ 18 | @wraps(func) 19 | def wrapper(self): 20 | try: 21 | return func(self) 22 | except AssertionError: 23 | raise nose.SkipTest() 24 | return wrapper 25 | 26 | 27 | class SqlalchemyJsonapiTestCase(unittest.TestCase): 28 | """Base testcase for SQLAclehmy-related tests.""" 29 | 30 | def setUp(self, *args, **kwargs): 31 | """Configure sqlalchemy and session.""" 32 | super(SqlalchemyJsonapiTestCase, self).setUp(*args, **kwargs) 33 | self.engine = create_engine('sqlite://') 34 | Session = sessionmaker(bind=self.engine) 35 | self.session = Session() 36 | Base.metadata.create_all(self.engine) 37 | 38 | def tearDown(self, *args, **kwargs): 39 | """Reset the sqlalchemy engine.""" 40 | super(SqlalchemyJsonapiTestCase, self).tearDown(*args, **kwargs) 41 | Base.metadata.drop_all(self.engine) 42 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import OrderedDict 3 | 4 | TESTS = { 5 | 'email == "cj@coltonprovias.com"': 'User.email == "cj@coltonprovias.com"', 6 | 'email.lower() == "cj@coltonprovias.com"': 'User.email.lower() == "cj@coltonprovias.com"', 7 | 'email ilike \'cj@%\'': 'User.email.ilike(\'cj@%\')', 8 | 'published_at!=null': 'User.published_at != None', 9 | '(5('([^'\\]*(?:\\.[^'\\]*)*)'" + r'|"([^"\\]*(?:\\.[^"\\]*)*)"))' 16 | 17 | REGEX = [STRING, 18 | r'(?P\s+)', 19 | r'(?P\b[a-zA-Z_][a-zA-Z0-9_]*\b)', 20 | r'(?P\()', 21 | r'(?P\))', 22 | r'(?P\[)', 23 | r'(?P\])', 24 | r'(?P\>)', 25 | r'(?P\<)', 26 | r'(?P\>\=)', 27 | r'(?P\<\=)', 28 | r'(?P(\|\||or))', 29 | r'(?P(\&\&|and))', 30 | r'(?P(\!|not))', 31 | r'(?Pin)', 32 | r'(?P\.)', 33 | r'(?P,)', 34 | r'(?P\=\=)', 35 | r'(?P\!\=)', 36 | r'(?P\+)', 37 | r'(?P\-)', 38 | r'(?P\*)', 39 | r'(?P\/)', 40 | r'(?P\/\/)', 41 | r'(?P\%)', 42 | r'(?P\*\*)', 43 | r'(?P\d+)', 44 | r'(?P\d+\.\d+)' 45 | ] 46 | 47 | ### 48 | # EQ 49 | # NE 50 | # LT 51 | # LE 52 | # GT 53 | # GE 54 | # NEG 55 | # GETITEM 56 | # CONCAT 57 | # LIKE 58 | # ILIKE 59 | # IN_ 60 | # NOTIN_ 61 | # NOTLIKE 62 | # NOTILIKE 63 | # IS_ 64 | # ISNOT 65 | # STARTSWITH 66 | # ENDSWITH 67 | # CONTAINS 68 | # MATCH 69 | # ADD 70 | # SUBTRACT 71 | # MULTIPLY 72 | # DIVIDE 73 | # MODULUS 74 | # FLOORDIV 75 | 76 | BINARY_OPERATORS = OrderedDict([ 77 | ['EQ', r'(?P\=\=)'], 78 | ['NE', r'(?P\!\=)'] 79 | ]) 80 | 81 | 82 | compiled = re.compile(r'|'.join(reversed(REGEX)), re.IGNORECASE) 83 | 84 | for test, final in TESTS.items(): 85 | print(test) 86 | print(final) 87 | finalized = [] 88 | matched = compiled.finditer(test) 89 | prev_key = None 90 | for item in matched: 91 | key = item.lastgroup 92 | value = item.group(0) 93 | print(' {:20}:{}'.format(key, value)) 94 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py3,dandruff 3 | 4 | [testenv] 5 | use_develop=True 6 | deps=-r{toxinidir}/requirements.txt 7 | commands= 8 | py.test {toxinidir}/sqlalchemy_jsonapi/tests/ 9 | nosetests --with-coverage --cover-package=sqlalchemy_jsonapi {toxinidir}/sqlalchemy_jsonapi/unittests/ 10 | 11 | [testenv:dandruff] 12 | deps=-r{toxinidir}/requirements.txt 13 | flake8 14 | commands=flake8 {toxinidir}/sqlalchemy_jsonapi --------------------------------------------------------------------------------