├── .bumpversion.cfg ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .travis.yml ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── examples └── item.py ├── pydantic_jsonapi ├── __init__.py ├── errors.py ├── factory.py ├── filter.py ├── relationships.py ├── request.py ├── resource_identifier.py ├── resource_linkage.py ├── resource_links.py └── response.py ├── scripts └── bumpversion.sh ├── setup.py ├── tests ├── helpers.py ├── test_errors.py ├── test_factory.py ├── test_relationships.py ├── test_request.py ├── test_resource_identifier.py ├── test_resource_linkage.py ├── test_resource_links.py └── test_response.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.11.0 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | message = Bump version from {current_version} to {new_version} 7 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)((-rc(?P\d+))?) 8 | serialize = 9 | {major}.{minor}.{patch}-rc{rc} 10 | {major}.{minor}.{patch} 11 | 12 | [bumpversion:file:setup.py] 13 | 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # editor config 107 | .vscode/ 108 | .idea/ 109 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | install: pip install tox-travis 3 | script: tox 4 | stages: 5 | - name: test 6 | - name: build 7 | if: type = push AND tag is present 8 | jobs: 9 | include: 10 | - stage: test 11 | python: 3.7 12 | - stage: test 13 | python: 3.8 14 | - stage: build 15 | name: 'PyPI upload' 16 | python: 3.7 17 | deploy: 18 | provider: pypi 19 | user: DeanWay 20 | password: 21 | secure: ZXy6gVUxJg9/qzxVSy20DkN/tm5EboHzlA2pjifzGojGDTt3+vNW/OtagiwTIFHd69/xSzbU3IkD17ruoH5aabtatAObUVIRrssQGWpWcHMc0to6jsnzGsZ6TXsyIo0G6KsOaubVT8jw1HNgk9ZT22AL2nUJsvyeXeQa7hLZleRoeeEt/5ascdFiFRGaEKPXQnFYe83GoPYepdV2QDVW7pEIDG/8X8UNvhJn2iwQp8LhMvi8hqwzNQlQUcdOSUODiXS2JdhOlAw6VkLNm0bvbQj+91QmlOJU7bfqZoN8t4e+KuweXyVT5Om26z4YlOvHIjviFn52mGYTBkgey4QdALeE+VJ5z1pAEtRPXaoP5WkHKUJ4W4v/7Cvgd1tULjXklMcrXygml3x61jxzUw2apaMnRb8eteuAxjqd91HGDBBUvWqzr3XUv8g7nJRfwUT0CjYjvxo6fP9jfqA9szaoJpmiidWt0AiqEnR+O65AjXnWeMzbQHHyHwqu2+hAhHyAyde7g6ZOVrG/59XJ2aKoOHsAFK0w65WFYuPMnEv52mon78rC/8GDniTmgatfYC63m0NvpVMcxcKXAT5ImaOUnSu6O2vuHStqOihpoC2VeZrAccmU3/yeZtTAPsVaFa50MMUtK2uwqAqghOiaLQjYY4HQQMrra4wDBtEeGFJ9P04= 22 | on: 23 | tags: true 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Dean Way 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | setuptools = "*" 8 | pytest = "*" 9 | pylint = "*" 10 | tox = "*" 11 | pydantic = ">=0.32.2" 12 | typing-extensions = ">=3.7.4" 13 | bumpversion = "*" 14 | 15 | [packages] 16 | pydantic-jsonapi = {editable = true,path = "."} 17 | 18 | [requires] 19 | python_version = "3.7" 20 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "c20e7a92238eaa39d7d50007826c790d1cf3172d440c694d190b800e56c5cd88" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "pydantic": { 20 | "hashes": [ 21 | "sha256:1c2df10aca600a23e7310df7ee62bc8024e4bfc6a444bc4d38c7b095b0cc8f79", 22 | "sha256:29669232b21a0fe45ada7c183198129af8447e12d0b6e727098ce57c3c8df320", 23 | "sha256:320578dc67bd6854675a34ddda7a2519cf7132f08c0a376d43f5eb64bd052ca7", 24 | "sha256:326ebef3ffed3ec20bd92a3d75c175333a3295e97b26f41cc2eb04ef76725aa2", 25 | "sha256:3adcf1cb80d7fe665d4a87e49b47285c9802762cce57fa85ce41a9d2a198f2b0", 26 | "sha256:4338e598ae11ae236aec596a975d9b88c9c40c9406193a53064c01682aa2a6d3", 27 | "sha256:45d2ea27997fff4cb5916a97705403edf82ecabe8d79ef31f6069b7f1391c3df", 28 | "sha256:539fe3a2d231bf7be7bb50c2e8dd1bc61eff2f66ed1a26307eef6a4e5902f33a", 29 | "sha256:56f138161da9bde0e6d0301e7921856e89e02eefde22c8001e9aaa2335c26444", 30 | "sha256:67a3128260b268fba0e0e0ce8e8353022a68b223062ae218e8f0b8f74324d797", 31 | "sha256:c67c2239fab51a65d09e8859423f6b8ce5b4c76f0be4bf61ed22621774ef146a", 32 | "sha256:da10b034750addbd95a328654d20364c479f4e2e26e0f72933204d61cbc8fa78", 33 | "sha256:de0624545e13a5eb09ab4fbd7076e4beced5aff2cd56097264c5f740f4b5fd39", 34 | "sha256:fb6a5ddf762c594e6038af30fd3dc843f1238d60b5d477734fbc4b24cc1b87b9" 35 | ], 36 | "version": "==1.2" 37 | }, 38 | "pydantic-jsonapi": { 39 | "editable": true, 40 | "path": "." 41 | }, 42 | "typing-extensions": { 43 | "hashes": [ 44 | "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2", 45 | "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d", 46 | "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575" 47 | ], 48 | "version": "==3.7.4.1" 49 | } 50 | }, 51 | "develop": { 52 | "astroid": { 53 | "hashes": [ 54 | "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a", 55 | "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42" 56 | ], 57 | "version": "==2.3.3" 58 | }, 59 | "attrs": { 60 | "hashes": [ 61 | "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", 62 | "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" 63 | ], 64 | "version": "==19.3.0" 65 | }, 66 | "bumpversion": { 67 | "hashes": [ 68 | "sha256:6744c873dd7aafc24453d8b6a1a0d6d109faf63cd0cd19cb78fd46e74932c77e", 69 | "sha256:6753d9ff3552013e2130f7bc03c1007e24473b4835952679653fb132367bdd57" 70 | ], 71 | "index": "pypi", 72 | "version": "==0.5.3" 73 | }, 74 | "filelock": { 75 | "hashes": [ 76 | "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", 77 | "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" 78 | ], 79 | "version": "==3.0.12" 80 | }, 81 | "importlib-metadata": { 82 | "hashes": [ 83 | "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", 84 | "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" 85 | ], 86 | "markers": "python_version < '3.8'", 87 | "version": "==0.23" 88 | }, 89 | "isort": { 90 | "hashes": [ 91 | "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", 92 | "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" 93 | ], 94 | "version": "==4.3.21" 95 | }, 96 | "lazy-object-proxy": { 97 | "hashes": [ 98 | "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", 99 | "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", 100 | "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", 101 | "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", 102 | "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", 103 | "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", 104 | "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", 105 | "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", 106 | "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", 107 | "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", 108 | "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", 109 | "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", 110 | "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", 111 | "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", 112 | "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", 113 | "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", 114 | "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", 115 | "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", 116 | "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", 117 | "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", 118 | "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" 119 | ], 120 | "version": "==1.4.3" 121 | }, 122 | "mccabe": { 123 | "hashes": [ 124 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 125 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 126 | ], 127 | "version": "==0.6.1" 128 | }, 129 | "more-itertools": { 130 | "hashes": [ 131 | "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", 132 | "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" 133 | ], 134 | "version": "==7.2.0" 135 | }, 136 | "packaging": { 137 | "hashes": [ 138 | "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", 139 | "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108" 140 | ], 141 | "version": "==19.2" 142 | }, 143 | "pluggy": { 144 | "hashes": [ 145 | "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", 146 | "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" 147 | ], 148 | "version": "==0.13.1" 149 | }, 150 | "py": { 151 | "hashes": [ 152 | "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", 153 | "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" 154 | ], 155 | "version": "==1.8.0" 156 | }, 157 | "pydantic": { 158 | "hashes": [ 159 | "sha256:1c2df10aca600a23e7310df7ee62bc8024e4bfc6a444bc4d38c7b095b0cc8f79", 160 | "sha256:29669232b21a0fe45ada7c183198129af8447e12d0b6e727098ce57c3c8df320", 161 | "sha256:320578dc67bd6854675a34ddda7a2519cf7132f08c0a376d43f5eb64bd052ca7", 162 | "sha256:326ebef3ffed3ec20bd92a3d75c175333a3295e97b26f41cc2eb04ef76725aa2", 163 | "sha256:3adcf1cb80d7fe665d4a87e49b47285c9802762cce57fa85ce41a9d2a198f2b0", 164 | "sha256:4338e598ae11ae236aec596a975d9b88c9c40c9406193a53064c01682aa2a6d3", 165 | "sha256:45d2ea27997fff4cb5916a97705403edf82ecabe8d79ef31f6069b7f1391c3df", 166 | "sha256:539fe3a2d231bf7be7bb50c2e8dd1bc61eff2f66ed1a26307eef6a4e5902f33a", 167 | "sha256:56f138161da9bde0e6d0301e7921856e89e02eefde22c8001e9aaa2335c26444", 168 | "sha256:67a3128260b268fba0e0e0ce8e8353022a68b223062ae218e8f0b8f74324d797", 169 | "sha256:c67c2239fab51a65d09e8859423f6b8ce5b4c76f0be4bf61ed22621774ef146a", 170 | "sha256:da10b034750addbd95a328654d20364c479f4e2e26e0f72933204d61cbc8fa78", 171 | "sha256:de0624545e13a5eb09ab4fbd7076e4beced5aff2cd56097264c5f740f4b5fd39", 172 | "sha256:fb6a5ddf762c594e6038af30fd3dc843f1238d60b5d477734fbc4b24cc1b87b9" 173 | ], 174 | "version": "==1.2" 175 | }, 176 | "pylint": { 177 | "hashes": [ 178 | "sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd", 179 | "sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4" 180 | ], 181 | "index": "pypi", 182 | "version": "==2.4.4" 183 | }, 184 | "pyparsing": { 185 | "hashes": [ 186 | "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f", 187 | "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a" 188 | ], 189 | "version": "==2.4.5" 190 | }, 191 | "pytest": { 192 | "hashes": [ 193 | "sha256:63344a2e3bce2e4d522fd62b4fdebb647c019f1f9e4ca075debbd13219db4418", 194 | "sha256:f67403f33b2b1d25a6756184077394167fe5e2f9d8bdaab30707d19ccec35427" 195 | ], 196 | "index": "pypi", 197 | "version": "==5.3.1" 198 | }, 199 | "six": { 200 | "hashes": [ 201 | "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", 202 | "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" 203 | ], 204 | "version": "==1.13.0" 205 | }, 206 | "toml": { 207 | "hashes": [ 208 | "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", 209 | "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" 210 | ], 211 | "version": "==0.10.0" 212 | }, 213 | "tox": { 214 | "hashes": [ 215 | "sha256:1d1368ac86e8332f79e2bcef9fefe2b077469f08449eadf0183759b34f3b2070", 216 | "sha256:bcfa3e40abc1e9b70607b56adfd976fe7dc8286ad56aab44e3151daca7d2d0d0" 217 | ], 218 | "index": "pypi", 219 | "version": "==3.14.1" 220 | }, 221 | "typed-ast": { 222 | "hashes": [ 223 | "sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161", 224 | "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", 225 | "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", 226 | "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", 227 | "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", 228 | "sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47", 229 | "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", 230 | "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", 231 | "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", 232 | "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", 233 | "sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2", 234 | "sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e", 235 | "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", 236 | "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", 237 | "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", 238 | "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", 239 | "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", 240 | "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", 241 | "sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", 242 | "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12" 243 | ], 244 | "markers": "implementation_name == 'cpython' and python_version < '3.8'", 245 | "version": "==1.4.0" 246 | }, 247 | "typing-extensions": { 248 | "hashes": [ 249 | "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2", 250 | "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d", 251 | "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575" 252 | ], 253 | "version": "==3.7.4.1" 254 | }, 255 | "virtualenv": { 256 | "hashes": [ 257 | "sha256:116655188441670978117d0ebb6451eb6a7526f9ae0796cc0dee6bd7356909b0", 258 | "sha256:b57776b44f91511866594e477dd10e76a6eb44439cdd7f06dcd30ba4c5bd854f" 259 | ], 260 | "version": "==16.7.8" 261 | }, 262 | "wcwidth": { 263 | "hashes": [ 264 | "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", 265 | "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" 266 | ], 267 | "version": "==0.1.7" 268 | }, 269 | "wrapt": { 270 | "hashes": [ 271 | "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" 272 | ], 273 | "version": "==1.11.2" 274 | }, 275 | "zipp": { 276 | "hashes": [ 277 | "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", 278 | "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335" 279 | ], 280 | "version": "==0.6.0" 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pydantic-jsonapi 2 | [![Build Status](https://travis-ci.org/DeanWay/pydantic-jsonapi.svg?branch=master)](https://travis-ci.org/DeanWay/pydantic-jsonapi) 3 | [![PyPi Link](https://img.shields.io/pypi/pyversions/pydantic-jsonapi.svg)](https://pypi.org/project/pydantic-jsonapi/) 4 | 5 | 6 | an implementation of JSON:api using pydantic for validation 7 | 8 | 9 | ```python 10 | from pydantic_jsonapi import JsonApiModel 11 | from pydantic import BaseModel 12 | 13 | class Item(BaseModel): 14 | name: str 15 | quantity: int 16 | price: float 17 | 18 | ItemRequest, ItemResponse = JsonApiModel('item', Item) 19 | 20 | # request validation 21 | request = { 22 | 'data': { 23 | 'type': 'item', 24 | 'attributes': { 25 | 'name': 'apple', 26 | 'quantity': 10, 27 | 'price': 1.20, 28 | }, 29 | } 30 | } 31 | ItemRequest(**request) 32 | 33 | #response validation 34 | response = { 35 | 'data': { 36 | 'id': 'abc123', 37 | 'type': 'item', 38 | 'attributes': { 39 | 'name': 'apple', 40 | 'quantity': 10, 41 | 'price': 1.20, 42 | }, 43 | 'relationships': { 44 | 'store': { 45 | 'links': { 46 | 'related': '/stores/123' 47 | } 48 | } 49 | } 50 | }, 51 | 'links': { 52 | 'self': '/item/abc123' 53 | } 54 | } 55 | ItemResponse(**response) 56 | ``` 57 | -------------------------------------------------------------------------------- /examples/item.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | from uuid import uuid4 4 | 5 | from pydantic import BaseModel 6 | from pydantic_jsonapi import JsonApiRequest, JsonApiResponse 7 | 8 | 9 | # A mock database object 10 | # This could be any form of persistence 11 | @dataclass 12 | class ItemData: 13 | name: str 14 | quantity: int 15 | price: float 16 | id: str = str(uuid4().hex) 17 | created_at: datetime = datetime.now() 18 | 19 | 20 | # Define our Item Request/Response schema 21 | class Item(BaseModel): 22 | name: str 23 | quantity: int 24 | price: float 25 | 26 | ITEM_TYPE_NAME = 'item' 27 | ItemRequest = JsonApiRequest(ITEM_TYPE_NAME, Item) 28 | ItemResponse = JsonApiResponse(ITEM_TYPE_NAME, Item) 29 | 30 | # Simple post method logic 31 | def item_post_method(item_request: ItemRequest) -> ItemResponse: 32 | attributes = item_request.data.attributes 33 | item_row = ItemData(**attributes.dict()) 34 | return ItemResponse( 35 | data=ItemResponse.resource_object( 36 | id=item_row.id, 37 | attributes=item_row 38 | ) 39 | ) 40 | 41 | # example request 42 | mock_request = { 43 | 'data': { 44 | 'type': 'item', 45 | 'attributes': { 46 | 'name': 'apple', 47 | 'quantity': 10, 48 | 'price': 1.20, 49 | } 50 | } 51 | } 52 | response = item_post_method(ItemRequest(**mock_request)) 53 | print(response.json(indent=2)) 54 | # prints: 55 | # { 56 | # "data": { 57 | # "id": "b56ba127bab6459db045d0038a0c06ba", 58 | # "type": "item", 59 | # "attributes": { 60 | # "name": "apple", 61 | # "quantity": 10, 62 | # "price": 1.2 63 | # }, 64 | # "relationships": null 65 | # }, 66 | # "included": null, 67 | # "meta": null, 68 | # "links": null, 69 | # "errors": null 70 | # } 71 | -------------------------------------------------------------------------------- /pydantic_jsonapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .request import JsonApiRequest 2 | from .response import JsonApiResponse 3 | from .factory import JsonApiModel 4 | from .errors import ErrorResponse, transform_to_json_api_errors 5 | 6 | __all__ = [ 7 | 'JsonApiModel', 8 | 'JsonApiRequest', 9 | 'JsonApiResponse', 10 | 'ErrorResponse', 11 | 'transform_to_json_api_errors', 12 | ] 13 | -------------------------------------------------------------------------------- /pydantic_jsonapi/errors.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | from pydantic import BaseModel, ValidationError 4 | from pydantic_jsonapi.filter import filter_none 5 | from pydantic_jsonapi.resource_links import ResourceLinks 6 | 7 | 8 | class ErrorSource(BaseModel): 9 | pointer: Optional[str] 10 | parameter: Optional[str] 11 | 12 | 13 | class Error(BaseModel): 14 | """https://jsonapi.org/format/#error-objects""" 15 | id: Optional[str] 16 | links: Optional[ResourceLinks] 17 | status: Optional[str] 18 | code: Optional[str] 19 | title: Optional[str] 20 | detail: Optional[str] 21 | source: Optional[ErrorSource] 22 | meta: Optional[dict] 23 | 24 | 25 | class ErrorResponse(BaseModel): 26 | errors: List[Error] 27 | 28 | def transform_to_json_api_errors(validation_error: ValidationError) -> dict: 29 | def transform_error(error): 30 | return { 31 | 'detail': error.get('msg'), 32 | 'title': error.get('type'), 33 | 'source': { 34 | 'pointer': '/' + '/'.join(error['loc']), 35 | }, 36 | } 37 | error_response = ErrorResponse( 38 | errors=[transform_error(error) for error in validation_error.errors()] 39 | ) 40 | return filter_none(error_response.dict()) 41 | -------------------------------------------------------------------------------- /pydantic_jsonapi/factory.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Tuple 2 | 3 | from .request import JsonApiRequest, RequestModel 4 | from .response import JsonApiResponse, ResponseModel 5 | 6 | 7 | def JsonApiModel( 8 | type_string: str, 9 | attributes_model: Any, 10 | *, 11 | list_response: bool = False 12 | ) -> Tuple[RequestModel, ResponseModel]: 13 | return ( 14 | JsonApiRequest(type_string, attributes_model), 15 | JsonApiResponse(type_string, attributes_model, use_list=list_response), 16 | ) 17 | -------------------------------------------------------------------------------- /pydantic_jsonapi/filter.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | from collections.abc import Mapping, Iterable 3 | 4 | T = TypeVar('T') 5 | def filter_none(thing_to_traverse: T) -> T: 6 | if isinstance(thing_to_traverse, dict): 7 | return { 8 | k: filter_none(v) 9 | for k, v in thing_to_traverse.items() 10 | if v is not None 11 | } 12 | elif isinstance(thing_to_traverse, list): 13 | return [ 14 | filter_none(item) 15 | for item in thing_to_traverse 16 | ] 17 | return thing_to_traverse 18 | -------------------------------------------------------------------------------- /pydantic_jsonapi/relationships.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Mapping, Optional, Union 3 | 4 | from pydantic import BaseModel 5 | from pydantic_jsonapi.resource_linkage import ResourceLinkage 6 | from pydantic_jsonapi.resource_links import ResourceLinks 7 | 8 | 9 | class RequestRelationshipModel(BaseModel): 10 | data: ResourceLinkage 11 | 12 | RequestRelationshipsType = Mapping[str, RequestRelationshipModel] 13 | RequestRelationshipsType.__doc__ = "https://jsonapi.org/format/#crud-creating" 14 | 15 | 16 | class ResponseRelationshipModel(BaseModel): 17 | links: Optional[ResourceLinks] 18 | data: ResourceLinkage 19 | meta: Optional[dict] 20 | 21 | ResponseRelationshipsType = Mapping[str, ResponseRelationshipModel] 22 | ResponseRelationshipsType.__doc__ = "https://jsonapi.org/format/#document-resource-object-relationships" 23 | -------------------------------------------------------------------------------- /pydantic_jsonapi/request.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar, Optional, Any, Type 2 | from typing_extensions import Literal 3 | 4 | from pydantic import UUID4 5 | from pydantic.generics import GenericModel 6 | 7 | from pydantic_jsonapi.relationships import RequestRelationshipsType 8 | 9 | 10 | TypeT = TypeVar('TypeT') 11 | AttributesT = TypeVar('AttributesT') 12 | class RequestDataModel(GenericModel, Generic[TypeT, AttributesT]): 13 | """ 14 | """ 15 | type: TypeT 16 | attributes: AttributesT 17 | id: Optional[str] 18 | relationships: Optional[RequestRelationshipsType] 19 | 20 | 21 | DataT = TypeVar('DataT', bound=RequestDataModel) 22 | class RequestModel(GenericModel, Generic[DataT]): 23 | """ 24 | """ 25 | data: DataT 26 | 27 | def attributes(self): 28 | return self.data.attributes 29 | 30 | def JsonApiRequest(type_string: str, attributes_model: Any) -> Type[RequestModel]: 31 | request_data_model = RequestDataModel[ 32 | Literal[type_string], 33 | attributes_model, 34 | ] 35 | request_data_model.__name__ = f'RequestData[{type_string}]' 36 | request_model = RequestModel[request_data_model] 37 | request_model.__name__ = f'Request[{type_string}]' 38 | return request_model 39 | -------------------------------------------------------------------------------- /pydantic_jsonapi/resource_identifier.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class ResourceIdentifier(BaseModel): 7 | """ 8 | https://jsonapi.org/format/#document-resource-identifier-objects 9 | """ 10 | id: str 11 | type: str 12 | meta: Optional[dict] 13 | -------------------------------------------------------------------------------- /pydantic_jsonapi/resource_linkage.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union, List 2 | 3 | from pydantic import BaseModel 4 | 5 | from pydantic_jsonapi.resource_identifier import ResourceIdentifier 6 | 7 | ResourceLinkage = Optional[Union[ResourceIdentifier, List[ResourceIdentifier]]] 8 | ResourceLinkage.__doc__ = "https://jsonapi.org/format/#document-resource-object-linkage" 9 | -------------------------------------------------------------------------------- /pydantic_jsonapi/resource_links.py: -------------------------------------------------------------------------------- 1 | from typing import Mapping, Union 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class LinkHref(BaseModel): 7 | href: str 8 | meta: dict 9 | 10 | Link = Union[str, LinkHref] 11 | ResourceLinks = Mapping[str, Link] 12 | ResourceLinks.__doc__ = "https://jsonapi.org/format/#document-links" 13 | -------------------------------------------------------------------------------- /pydantic_jsonapi/response.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar, Optional, List, Any, Type, get_type_hints 2 | from typing_extensions import Literal 3 | 4 | from pydantic.generics import GenericModel 5 | 6 | from pydantic_jsonapi.filter import filter_none 7 | from pydantic_jsonapi.relationships import ResponseRelationshipsType 8 | from pydantic_jsonapi.resource_links import ResourceLinks 9 | 10 | 11 | TypeT = TypeVar('TypeT', bound=str) 12 | AttributesT = TypeVar('AttributesT') 13 | class ResponseDataModel(GenericModel, Generic[TypeT, AttributesT]): 14 | """ 15 | """ 16 | id: str 17 | type: TypeT 18 | attributes: AttributesT = {} 19 | relationships: Optional[ResponseRelationshipsType] 20 | links: Optional[ResourceLinks] 21 | 22 | class Config: 23 | validate_all = True 24 | 25 | 26 | DataT = TypeVar('DataT', bound=ResponseDataModel) 27 | class ResponseModel(GenericModel, Generic[DataT]): 28 | """ 29 | """ 30 | data: DataT 31 | included: Optional[list] 32 | meta: Optional[dict] 33 | links: Optional[ResourceLinks] 34 | 35 | def dict( 36 | self, 37 | *, 38 | serlialize_none: bool = False, 39 | **kwargs 40 | ): 41 | response = super().dict(**kwargs) 42 | if serlialize_none: 43 | return response 44 | return filter_none(response) 45 | 46 | @classmethod 47 | def resource_object( 48 | cls, 49 | *, 50 | id: str, 51 | attributes: Optional[dict] = None, 52 | relationships: Optional[dict] = None, 53 | links: Optional[dict] = None, 54 | ) -> ResponseDataModel: 55 | data_type = get_type_hints(cls)['data'] 56 | if getattr(data_type, '__origin__', None) is list: 57 | data_type = data_type.__args__[0] 58 | typename = get_type_hints(data_type)['type'].__args__[0] 59 | return data_type( 60 | id=id, 61 | type=typename, 62 | attributes=attributes or {}, 63 | relationships=relationships, 64 | links=links, 65 | ) 66 | 67 | def JsonApiResponse( 68 | type_string: str, 69 | attributes_model: Any, 70 | *, 71 | use_list: bool = False 72 | ) -> Type[ResponseModel]: 73 | response_data_model = ResponseDataModel[ 74 | Literal[type_string], 75 | attributes_model, 76 | ] 77 | if use_list: 78 | response_data_model = List[response_data_model] 79 | response_data_model.__name__ = f'ListResponseData[{type_string}]' 80 | response_model = ResponseModel[response_data_model] 81 | response_model.__name__ = f'ListResponse[{type_string}]' 82 | else: 83 | response_data_model.__name__ = f'ResponseData[{type_string}]' 84 | response_model = ResponseModel[response_data_model] 85 | response_model.__name__ = f'Response[{type_string}]' 86 | return response_model 87 | -------------------------------------------------------------------------------- /scripts/bumpversion.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | git fetch origin 6 | git checkout -B master 7 | git reset --soft origin/master 8 | bumpversion "$@" 9 | git push 10 | git push --tags 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name="pydantic_jsonapi", 8 | version="0.11.0", 9 | author="Dean Way", 10 | description="an implementation of JSON:api using pydantic", 11 | long_description=long_description, 12 | long_description_content_type="text/markdown", 13 | url="https://github.com/DeanWay/pydantic-jsonapi", 14 | packages=['pydantic_jsonapi'], 15 | classifiers=[ 16 | "Programming Language :: Python :: 3.7", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | ], 20 | install_requires=[ 21 | 'pydantic>=0.32.2', 22 | 'typing-extensions>=3.7.4' 23 | ], 24 | python_requires='>=3.7', 25 | ) 26 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from pydantic_jsonapi import JsonApiRequest 3 | 4 | class ItemModel(BaseModel): 5 | name: str 6 | quantity: int 7 | price: float 8 | 9 | class ItemModelWithOrmMode(BaseModel): 10 | name: str 11 | quantity: int 12 | price: float 13 | class Config: 14 | orm_mode = True 15 | 16 | item_type_name = 'item' 17 | ItemRequest = JsonApiRequest(item_type_name, ItemModel) 18 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | 3 | import pytest 4 | from pytest import raises 5 | from pydantic import ValidationError 6 | from pydantic_jsonapi import JsonApiRequest, ErrorResponse, transform_to_json_api_errors 7 | from pydantic_jsonapi.filter import filter_none 8 | 9 | from tests.helpers import ItemRequest 10 | 11 | errors_wrapper = lambda d: { 'errors': [d] } 12 | 13 | valid_error_objects = [ 14 | { 'id': 'abc123' }, 15 | { 'status': '404' }, 16 | { 'code': '1005' }, 17 | { 'title': 'Something went wrong' }, 18 | { 'detail': "oh wow, there's a few things we messed up there" }, 19 | { 'meta': { 'num_errors_today': 10000 } }, 20 | { 'links': { 'about': '/my/error-info?code=1005'} }, 21 | { 22 | 'source': { 23 | 'pointer': '/data/attributes/price', 24 | }, 25 | }, 26 | ] 27 | 28 | valid_error_responses = map(errors_wrapper, valid_error_objects) 29 | 30 | @pytest.mark.parametrize('error_response', valid_error_responses) 31 | def test_valid_error_response_fields(error_response): 32 | validated = ErrorResponse(**error_response) 33 | assert filter_none(validated.dict()) == error_response 34 | 35 | error_with_all_fields = reduce( 36 | lambda acc, d: { **acc, **d }, valid_error_objects, {} 37 | ) 38 | def test_error_response_with_all_fields(): 39 | error_response = errors_wrapper(error_with_all_fields) 40 | validated = ErrorResponse(**error_response) 41 | assert filter_none(validated.dict()) == error_response 42 | 43 | 44 | def test_empty_error_response_valid(): 45 | error_response = { 'errors': [] } 46 | validated = ErrorResponse(**error_response) 47 | assert filter_none(validated.dict()) == error_response 48 | 49 | def test_transform_to_json_api_errors(): 50 | with raises(ValidationError) as e: 51 | ItemRequest(**{ 52 | 'data': { 53 | 'type': 'user' 54 | } 55 | }) 56 | assert transform_to_json_api_errors( 57 | e.value 58 | ) == { 59 | 'errors': [ 60 | { 61 | 'detail': "unexpected value; permitted: 'item'", 62 | 'source': { 63 | 'pointer': '/data/type' 64 | }, 65 | 'title': 'value_error.const' 66 | }, 67 | { 68 | 'detail': 'field required', 69 | 'source': { 70 | 'pointer': '/data/attributes' 71 | }, 72 | 'title': 'value_error.missing' 73 | }, 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /tests/test_factory.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from typing_extensions import Literal 3 | 4 | from pydantic_jsonapi import JsonApiModel 5 | from pydantic_jsonapi.request import RequestModel, RequestDataModel 6 | from pydantic_jsonapi.response import ResponseModel, ResponseDataModel 7 | from tests.helpers import ItemModel 8 | 9 | def test_json_api_model(): 10 | ItemRequest, ItemResponse = JsonApiModel('item', ItemModel) 11 | assert ItemRequest == RequestModel[RequestDataModel[Literal['item'], ItemModel]] 12 | assert ItemResponse == ResponseModel[ResponseDataModel[Literal['item'], ItemModel]] 13 | 14 | def test_json_api_model__list_response(): 15 | ItemRequest, ItemResponse = JsonApiModel('item', ItemModel, list_response=True) 16 | assert ItemRequest == RequestModel[RequestDataModel[Literal['item'], ItemModel]] 17 | assert ItemResponse == ResponseModel[List[ResponseDataModel[Literal['item'], ItemModel]]] 18 | -------------------------------------------------------------------------------- /tests/test_relationships.py: -------------------------------------------------------------------------------- 1 | from pytest import raises 2 | 3 | from pydantic_jsonapi.relationships import ResponseRelationshipsType 4 | from pydantic import BaseModel, ValidationError 5 | 6 | 7 | class Relatable(BaseModel): 8 | relationships: ResponseRelationshipsType 9 | 10 | 11 | class TestResponseRelationshipsType: 12 | def test_follows_strucutre(self): 13 | validated = Relatable(relationships={ 14 | 'walter': { 15 | 'data': None, 16 | 'links': { 17 | 'self': '/person/walter' 18 | } 19 | }, 20 | 'wendy': { 21 | 'data': { 22 | 'id': '1', 23 | 'type': 'wendy-type' 24 | }, 25 | 'links': { 26 | 'self': '/person/wendy' 27 | } 28 | }, 29 | 'wandas': { 30 | 'data': [], 31 | 'links': { 32 | 'self': '/person/wandas' 33 | } 34 | }, 35 | 'warners': { 36 | 'data': [{ 37 | 'id': '1', 38 | 'type': 'warner-type' 39 | }], 40 | 'links': { 41 | 'self': '/person/warners' 42 | } 43 | } 44 | }) 45 | assert validated.dict() == { 46 | 'relationships': { 47 | 'walter': { 48 | 'links': { 49 | 'self': '/person/walter' 50 | }, 51 | 'data': None, 52 | 'meta': None, 53 | }, 54 | 'wendy': { 55 | 'links': { 56 | 'self': '/person/wendy' 57 | }, 58 | 'data': { 59 | 'id': '1', 60 | 'type': 'wendy-type', 61 | 'meta': None, 62 | }, 63 | 'meta': None, 64 | }, 65 | 'wandas': { 66 | 'links': { 67 | 'self': '/person/wandas' 68 | }, 69 | 'data': [], 70 | 'meta': None, 71 | }, 72 | 'warners': { 73 | 'links': { 74 | 'self': '/person/warners' 75 | }, 76 | 'data': [{ 77 | 'id': '1', 78 | 'type': 'warner-type', 79 | 'meta': None, 80 | }], 81 | 'meta': None, 82 | }, 83 | } 84 | } 85 | 86 | def test_must_be_valid_map(self): 87 | with raises(ValidationError) as e: 88 | Relatable(relationships=['walter', 'wendy']) 89 | assert e.value.errors() == [ 90 | {'loc': ('relationships',), 'msg': 'value is not a valid dict', 'type': 'type_error.dict'} 91 | ] 92 | 93 | def test_values_must_be_a_relationship_object(self): 94 | with raises(ValidationError) as e: 95 | Relatable(relationships={'walter': '/person/walter'}) 96 | assert e.value.errors() == [ 97 | {'loc': ('relationships', 'walter'), 'msg': 'value is not a valid dict', 'type': 'type_error.dict'} 98 | ] 99 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | from pytest import raises 2 | 3 | from pydantic import ValidationError 4 | 5 | from pydantic_jsonapi import JsonApiRequest 6 | from tests.helpers import ItemModel 7 | 8 | 9 | class TestJsonApiRequest: 10 | def test_attributes_as_dict(self): 11 | DictRequest = JsonApiRequest('item', dict) 12 | obj_to_validate = { 13 | 'data': {'type': 'item', 'attributes': {}} 14 | } 15 | my_request_obj = DictRequest(**obj_to_validate) 16 | assert my_request_obj.dict() == { 17 | 'data': { 18 | 'type': 'item', 19 | 'attributes': {}, 20 | 'relationships': None, 21 | 'id': None, 22 | } 23 | } 24 | 25 | def test_attributes_as_item_model(self): 26 | ItemRequest = JsonApiRequest('item', ItemModel) 27 | obj_to_validate = { 28 | 'data': { 29 | 'type': 'item', 30 | 'attributes': { 31 | 'name': 'apple', 32 | 'quantity': 10, 33 | 'price': 1.20 34 | }, 35 | 'relationships': None, 36 | 'id': None, 37 | } 38 | } 39 | my_request_obj = ItemRequest(**obj_to_validate) 40 | assert my_request_obj.dict() == obj_to_validate 41 | 42 | def test_attributes_as_item_model__empty_dict(self): 43 | ItemRequest = JsonApiRequest('item', ItemModel) 44 | obj_to_validate = { 45 | 'data': { 46 | 'type': 'item', 47 | 'attributes': {} 48 | } 49 | } 50 | with raises(ValidationError) as e: 51 | ItemRequest(**obj_to_validate) 52 | 53 | assert e.value.errors() == [ 54 | {'loc': ('data', 'attributes', 'name'), 'msg': 'field required', 'type': 'value_error.missing'}, 55 | {'loc': ('data', 'attributes', 'quantity'), 'msg': 'field required', 'type': 'value_error.missing'}, 56 | {'loc': ('data', 'attributes', 'price'), 'msg': 'field required', 'type': 'value_error.missing'} 57 | ] 58 | 59 | def test_type_invalid_string(self): 60 | MyRequest = JsonApiRequest('item', dict) 61 | obj_to_validate = { 62 | 'data': {'type': 'not_an_item', 'attributes': {}} 63 | } 64 | with raises(ValidationError) as e: 65 | MyRequest(**obj_to_validate) 66 | 67 | assert e.value.errors() == [ 68 | { 69 | 'loc': ('data', 'type'), 70 | 'msg': "unexpected value; permitted: 'item'", 71 | 'type': 'value_error.const', 72 | 'ctx': {'given': 'not_an_item', 'permitted': ('item',)}, 73 | }, 74 | ] 75 | 76 | def test_attributes_required(self): 77 | MyRequest = JsonApiRequest('item', dict) 78 | obj_to_validate = { 79 | 'data': {'type': 'item', 'attributes': None} 80 | } 81 | with raises(ValidationError) as e: 82 | MyRequest(**obj_to_validate) 83 | 84 | assert e.value.errors() == [ 85 | {'loc': ('data', 'attributes'), 'msg': 'none is not an allowed value', 'type': 'type_error.none.not_allowed'}, 86 | ] 87 | 88 | def test_data_required(self): 89 | MyRequest = JsonApiRequest('item', dict) 90 | obj_to_validate = { 91 | 'data': None 92 | } 93 | with raises(ValidationError) as e: 94 | MyRequest(**obj_to_validate) 95 | 96 | assert e.value.errors() == [ 97 | {'loc': ('data',), 'msg': 'none is not an allowed value', 'type': 'type_error.none.not_allowed'}, 98 | ] 99 | 100 | def test_request_with_relationships(self): 101 | MyRequest = JsonApiRequest('item', dict) 102 | obj_to_validate = { 103 | 'data': { 104 | 'type': 'item', 105 | 'attributes': {}, 106 | 'relationships': { 107 | 'sold_at': { 108 | 'data': { 109 | 'type': 'store', 110 | 'id': 'abc123', 111 | 'meta': None 112 | }, 113 | } 114 | } 115 | }, 116 | } 117 | my_request_obj = MyRequest(**obj_to_validate) 118 | assert my_request_obj.dict() == { 119 | 'data': { 120 | 'type': 'item', 121 | 'attributes': {}, 122 | 'relationships': { 123 | 'sold_at': { 124 | 'data': { 125 | 'type': 'store', 126 | 'id': 'abc123', 127 | 'meta': None 128 | }, 129 | } 130 | }, 131 | 'id': None 132 | }, 133 | } 134 | 135 | def test_request_with_id(self): 136 | MyRequest = JsonApiRequest('item', dict) 137 | obj_to_validate = { 138 | 'data': { 139 | 'type': 'item', 140 | 'attributes': {}, 141 | 'id': 'abc123' 142 | }, 143 | } 144 | my_request_obj = MyRequest(**obj_to_validate) 145 | assert my_request_obj.dict() == { 146 | 'data': { 147 | 'type': 'item', 148 | 'attributes': {}, 149 | 'relationships': None, 150 | 'id': 'abc123', 151 | }, 152 | } 153 | 154 | -------------------------------------------------------------------------------- /tests/test_resource_identifier.py: -------------------------------------------------------------------------------- 1 | from pytest import raises 2 | from pydantic import ValidationError 3 | 4 | from pydantic_jsonapi.resource_identifier import ResourceIdentifier 5 | 6 | class TestResourceIdentifier: 7 | def test_follows_strucutre(self): 8 | structure_to_validate = { 9 | 'id': 'abc123', 10 | 'type': 'item', 11 | 'meta': { 12 | 'set': 'of', 13 | 'extra': 'data', 14 | }, 15 | } 16 | validated = ResourceIdentifier(**structure_to_validate) 17 | assert validated.dict() == structure_to_validate 18 | 19 | def test_required_fields(self): 20 | with raises(ValidationError) as e: 21 | ResourceIdentifier() 22 | assert e.value.errors() == [ 23 | {'loc': ('id',), 'msg': 'field required', 'type': 'value_error.missing'}, 24 | {'loc': ('type',), 'msg': 'field required', 'type': 'value_error.missing'}, 25 | ] 26 | 27 | def test_meta_must_be_dict(self): 28 | with raises(ValidationError) as e: 29 | ResourceIdentifier( 30 | id='abc123', 31 | type='item', 32 | meta=['123'] 33 | ) 34 | assert e.value.errors() == [ 35 | {'loc': ('meta',), 'msg': 'value is not a valid dict', 'type': 'type_error.dict'}, 36 | ] 37 | -------------------------------------------------------------------------------- /tests/test_resource_linkage.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest import raises 3 | 4 | from pydantic_jsonapi.resource_linkage import ResourceLinkage 5 | from pydantic import BaseModel, ValidationError 6 | 7 | 8 | class ThingWithLinkageData(BaseModel): 9 | data: ResourceLinkage 10 | 11 | 12 | class TestResourceLinks: 13 | 14 | @pytest.mark.parametrize( 15 | 'linkage, message', 16 | [ 17 | ( 18 | None, 19 | 'null is valid for empty to-one relationships', 20 | ), 21 | ( 22 | [], 23 | 'empty list valid for empty to-many relationships.', 24 | ), 25 | ( 26 | {'id': 'abc123', 'type': 'item', 'meta': None}, 27 | 'single resource identifier valid for non-empty to-one relationships.', 28 | ), 29 | ( 30 | [ 31 | {'id': 'abc123', 'type': 'item', 'meta': None}, 32 | {'id': 'def456', 'type': 'item', 'meta': None}, 33 | ], 34 | 'array of resource identifiers valid for non-empty to-many relationships.', 35 | ), 36 | ], 37 | ) 38 | def test_valid_possibilities(self, linkage, message): 39 | structure_to_validate = { 40 | 'data': linkage 41 | } 42 | validated = ThingWithLinkageData(**structure_to_validate) 43 | assert validated.dict() == structure_to_validate, message 44 | 45 | def test_invalid_resource_identifier(self): 46 | structure_to_validate = { 47 | 'data': {} 48 | } 49 | with raises(ValidationError) as e: 50 | ThingWithLinkageData(**structure_to_validate) 51 | assert e.value.errors() == [ 52 | {'loc': ('data', 'id'), 'msg': 'field required', 'type': 'value_error.missing'}, 53 | {'loc': ('data', 'type'), 'msg': 'field required', 'type': 'value_error.missing'}, 54 | {'loc': ('data',), 'msg': 'value is not a valid list', 'type': 'type_error.list'}, 55 | ] 56 | 57 | def test_invalid_resource_identifier_array(self): 58 | structure_to_validate = { 59 | 'data': [ 60 | {} 61 | ], 62 | } 63 | with raises(ValidationError) as e: 64 | ThingWithLinkageData(**structure_to_validate) 65 | assert e.value.errors() == [ 66 | {'loc': ('data',), 'msg': 'value is not a valid dict', 'type': 'type_error.dict'}, 67 | {'loc': ('data', 0, 'id'), 'msg': 'field required', 'type': 'value_error.missing'}, 68 | {'loc': ('data', 0, 'type'), 'msg': 'field required', 'type': 'value_error.missing'}, 69 | ] 70 | -------------------------------------------------------------------------------- /tests/test_resource_links.py: -------------------------------------------------------------------------------- 1 | from pytest import raises 2 | 3 | from pydantic_jsonapi.resource_links import ResourceLinks 4 | from pydantic import BaseModel, ValidationError 5 | 6 | 7 | class ThingWithLinks(BaseModel): 8 | links: ResourceLinks 9 | 10 | 11 | class TestResourceLinks: 12 | def test_follows_strucutre(self): 13 | structure_to_validate = { 14 | 'links': { 15 | 'self': '/person/walter', 16 | 'related': { 17 | 'href': '/person/wendy', 18 | 'meta': { 19 | 'relationship': 'friend', 20 | }, 21 | } 22 | } 23 | } 24 | validated = ThingWithLinks(**structure_to_validate) 25 | assert validated.dict() == structure_to_validate 26 | 27 | def test_must_be_valid_map(self): 28 | with raises(ValidationError) as e: 29 | ThingWithLinks(links=['walter', 'wendy']) 30 | assert e.value.errors() == [ 31 | {'loc': ('links',), 'msg': 'value is not a valid dict', 'type': 'type_error.dict'} 32 | ] 33 | 34 | def test_values_must_be_a_str_or_link_href_object(self): 35 | ThingWithLinks(links={'walter': ''}) 36 | ThingWithLinks(links={ 37 | 'wendy': { 38 | 'href': '/person/wendy', 39 | 'meta': { 40 | 'relationship': 'friend', 41 | }, 42 | } 43 | }) 44 | invalid_string_error = {'loc': ('links', 'walter'), 'msg': 'str type expected', 'type': 'type_error.str'} 45 | 46 | with raises(ValidationError) as e: 47 | ThingWithLinks(links={'walter': object()}) 48 | assert e.value.errors() == [ 49 | invalid_string_error, 50 | {'loc': ('links', 'walter'), 'msg': 'value is not a valid dict', 'type': 'type_error.dict'} 51 | ] 52 | 53 | with raises(ValidationError) as e: 54 | ThingWithLinks(links={'walter': {'href': '/people/123'}}) 55 | assert e.value.errors() == [ 56 | invalid_string_error, 57 | {'loc': ('links', 'walter', 'meta'), 'msg': 'field required', 'type': 'value_error.missing'}, 58 | ] 59 | -------------------------------------------------------------------------------- /tests/test_response.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | 4 | from pytest import raises 5 | from pydantic import BaseModel, ValidationError 6 | 7 | from pydantic_jsonapi import JsonApiResponse 8 | from tests.helpers import ItemModel, ItemModelWithOrmMode 9 | 10 | 11 | class TestJsonApiResponse: 12 | def test_attributes_as_dict(self): 13 | MyResponse = JsonApiResponse('item', dict) 14 | obj_to_validate = { 15 | 'data': {'id': '123', 'type': 'item', 'attributes': {}}, 16 | 'included': [{'id': '456', 'type': 'not-an-item', 'attributes': {}}] 17 | } 18 | my_response_object = MyResponse(**obj_to_validate) 19 | assert my_response_object.dict() == { 20 | 'data': { 21 | 'id': '123', 22 | 'type': 'item', 23 | 'attributes': {}, 24 | }, 25 | 'included': [{ 26 | 'id': '456', 27 | 'type': 'not-an-item', 28 | 'attributes': {} 29 | }] 30 | } 31 | 32 | def test_missing_attributes_dict(self): 33 | MyResponse = JsonApiResponse('item', dict) 34 | obj_to_validate = { 35 | 'data': {'id': '123', 'type': 'item'} 36 | } 37 | my_response_object = MyResponse(**obj_to_validate) 38 | assert my_response_object.dict() == { 39 | 'data': { 40 | 'id': '123', 41 | 'type': 'item', 42 | 'attributes': {}, 43 | } 44 | } 45 | 46 | def test_missing_attributes_empty_model(self): 47 | class EmptyModel(BaseModel): 48 | pass 49 | 50 | MyResponse = JsonApiResponse('item', EmptyModel) 51 | obj_to_validate = { 52 | 'data': {'id': '123', 'type': 'item'} 53 | } 54 | my_response_object = MyResponse(**obj_to_validate) 55 | assert my_response_object.dict() == { 56 | 'data': { 57 | 'id': '123', 58 | 'type': 'item', 59 | 'attributes': {}, 60 | } 61 | } 62 | assert isinstance(my_response_object.data.attributes, EmptyModel) 63 | 64 | def test_attributes_as_item_model(self): 65 | ItemResponse = JsonApiResponse('item', ItemModel) 66 | obj_to_validate = { 67 | 'data': { 68 | 'id': '123', 69 | 'type': 'item', 70 | 'attributes': { 71 | 'name': 'apple', 72 | 'quantity': 10, 73 | 'price': 1.20 74 | }, 75 | 'relationships': { 76 | 'store': { 77 | 'links': { 78 | 'related': '/stores/123', 79 | }, 80 | }, 81 | }, 82 | 'links': { 83 | 'self': '/items/123', 84 | }, 85 | } 86 | } 87 | my_response_obj = ItemResponse(**obj_to_validate) 88 | assert my_response_obj.dict() == { 89 | 'data': { 90 | 'id': '123', 91 | 'type': 'item', 92 | 'attributes': { 93 | 'name': 'apple', 94 | 'quantity': 10, 95 | 'price': 1.20, 96 | }, 97 | 'relationships': { 98 | 'store': { 99 | 'links': { 100 | 'related': '/stores/123', 101 | }, 102 | }, 103 | }, 104 | 'links': { 105 | 'self': '/items/123', 106 | }, 107 | }, 108 | } 109 | 110 | def test_list_item_model(self): 111 | ItemResponse = JsonApiResponse('item', ItemModel, use_list=True) 112 | obj_to_validate = { 113 | 'data': [ 114 | { 115 | 'id': '123', 116 | 'type': 'item', 117 | 'attributes': { 118 | 'name': 'apple', 119 | 'quantity': 10, 120 | 'price': 1.20 121 | }, 122 | }, 123 | { 124 | 'id': '321', 125 | 'type': 'item', 126 | 'attributes': { 127 | 'name': 'banana', 128 | 'quantity': 20, 129 | 'price': 2.34 130 | }, 131 | }, 132 | ], 133 | } 134 | my_response_obj = ItemResponse(**obj_to_validate) 135 | assert my_response_obj.dict() == { 136 | 'data': [ 137 | { 138 | 'id': '123', 139 | 'type': 'item', 140 | 'attributes': { 141 | 'name': 'apple', 142 | 'quantity': 10, 143 | 'price': 1.20, 144 | }, 145 | }, 146 | { 147 | 'id': '321', 148 | 'type': 'item', 149 | 'attributes': { 150 | 'name': 'banana', 151 | 'quantity': 20, 152 | 'price': 2.34, 153 | }, 154 | }, 155 | ], 156 | } 157 | 158 | def test_type_invalid_string(self): 159 | MyResponse = JsonApiResponse('item', dict) 160 | obj_to_validate = { 161 | 'data': {'id': '123', 'type': 'not_an_item', 'attributes': {}} 162 | } 163 | with raises(ValidationError) as e: 164 | MyResponse(**obj_to_validate) 165 | 166 | assert e.value.errors() == [ 167 | { 168 | 'loc': ('data', 'type'), 169 | 'msg': "unexpected value; permitted: 'item'", 170 | 'type': 'value_error.const', 171 | 'ctx': {'given': 'not_an_item', 'permitted': ('item',)}, 172 | }, 173 | ] 174 | 175 | def test_attributes_required(self): 176 | ItemResponse = JsonApiResponse('item', ItemModel) 177 | obj_to_validate = { 178 | 'data': {'id': '123', 'type': 'item', 'attributes': None} 179 | } 180 | with raises(ValidationError) as e: 181 | ItemResponse(**obj_to_validate) 182 | 183 | assert e.value.errors() == [ 184 | { 185 | 'loc': ('data', 'attributes'), 186 | 'msg': 'none is not an allowed value', 187 | 'type': 'type_error.none.not_allowed', 188 | }, 189 | ] 190 | 191 | def test_attributes_as_item_model__empty_dict(self): 192 | ItemResponse = JsonApiResponse('item', ItemModel) 193 | obj_to_validate = { 194 | 'data': { 195 | 'id': '123', 196 | 'type': 'item', 197 | 'attributes': {} 198 | } 199 | } 200 | with raises(ValidationError) as e: 201 | ItemResponse(**obj_to_validate) 202 | 203 | assert e.value.errors() == [ 204 | {'loc': ('data', 'attributes', 'name'), 'msg': 'field required', 'type': 'value_error.missing'}, 205 | {'loc': ('data', 'attributes', 'quantity'), 'msg': 'field required', 'type': 'value_error.missing'}, 206 | {'loc': ('data', 'attributes', 'price'), 'msg': 'field required', 'type': 'value_error.missing'}, 207 | ] 208 | 209 | def test_resource_object_constructor(self): 210 | ItemResponse = JsonApiResponse('item', ItemModel) 211 | item = ItemModel(name='pear', price=1.2, quantity=10) 212 | document = ItemResponse.resource_object(id='abc123', attributes=item).dict() 213 | assert document == { 214 | 'id': 'abc123', 215 | 'type': 'item', 216 | 'attributes': { 217 | 'name': 'pear', 218 | 'price': 1.2, 219 | 'quantity': 10, 220 | }, 221 | 'relationships': None, 222 | 'links': None, 223 | } 224 | 225 | def test_resource_object_constructor__no_attributes(self): 226 | IdentifierResponse = JsonApiResponse('item', dict) 227 | document = IdentifierResponse.resource_object(id='abc123').dict() 228 | assert document == { 229 | 'id': 'abc123', 230 | 'type': 'item', 231 | 'attributes': {}, 232 | 'relationships': None, 233 | 'links': None, 234 | } 235 | 236 | 237 | def test_resource_object_constructor__with_relationships(self): 238 | ItemResponse = JsonApiResponse('item', ItemModel) 239 | item = ItemModel(name='pear', price=1.2, quantity=10) 240 | document = ItemResponse.resource_object( 241 | id='abc123', 242 | attributes=item, 243 | relationships={ 244 | 'sold_at': { 245 | 'data': {'type': 'store', 'id': 'def456'} 246 | } 247 | } 248 | ).dict() 249 | assert document == { 250 | 'id': 'abc123', 251 | 'type': 'item', 252 | 'attributes': { 253 | 'name': 'pear', 254 | 'price': 1.2, 255 | 'quantity': 10, 256 | }, 257 | 'relationships': { 258 | 'sold_at': { 259 | 'data': { 260 | 'id': 'def456', 261 | 'type': 'store', 262 | 'meta': None, 263 | }, 264 | 'links': None, 265 | 'meta': None, 266 | } 267 | }, 268 | 'links': None, 269 | } 270 | 271 | def test_resource_object_constructor__with_invalid_relationship(self): 272 | ItemResponse = JsonApiResponse('item', ItemModel) 273 | item = ItemModel(name='pear', price=1.2, quantity=10) 274 | with raises(ValidationError) as e: 275 | ItemResponse.resource_object( 276 | id='abc123', 277 | attributes=item, 278 | relationships={ 279 | 'sold_at': { 280 | 'meta': 'rofl' 281 | } 282 | } 283 | ) 284 | assert e.value.errors() == [ 285 | { 286 | 'loc': ('relationships', 'sold_at', 'meta'), 287 | 'msg': 'value is not a valid dict', 288 | 'type': 'type_error.dict' 289 | }, 290 | ] 291 | 292 | def test_resource_object_constructor__with_links(self): 293 | ItemResponse = JsonApiResponse('item', ItemModel) 294 | item = ItemModel(name='pear', price=1.2, quantity=10) 295 | document = ItemResponse.resource_object( 296 | id='abc123', 297 | attributes=item, 298 | links={'self': '/items/abc123'} 299 | ).dict() 300 | assert document == { 301 | 'id': 'abc123', 302 | 'type': 'item', 303 | 'attributes': { 304 | 'name': 'pear', 305 | 'price': 1.2, 306 | 'quantity': 10, 307 | }, 308 | 'relationships': None, 309 | 'links': { 310 | 'self': '/items/abc123', 311 | }, 312 | } 313 | 314 | def test_resource_object_constructor__with_invalid_links(self): 315 | ItemResponse = JsonApiResponse('item', ItemModel) 316 | item = ItemModel(name='pear', price=1.2, quantity=10) 317 | with raises(ValidationError) as e: 318 | ItemResponse.resource_object( 319 | id='abc123', 320 | attributes=item, 321 | links='/items/abc123', 322 | ) 323 | assert e.value.errors() == [ 324 | { 325 | 'loc': ('links',), 326 | 'msg': 'value is not a valid dict', 327 | 'type': 'type_error.dict' 328 | }, 329 | ] 330 | 331 | def test_resource_object_constructor__with_list_response(self): 332 | ItemResponse = JsonApiResponse('item', ItemModel, use_list=True) 333 | item = ItemModel(name='pear', price=1.2, quantity=10) 334 | document = ItemResponse.resource_object(id='abc123', attributes=item).dict() 335 | assert document == { 336 | 'id': 'abc123', 337 | 'type': 'item', 338 | 'attributes': { 339 | 'name': 'pear', 340 | 'price': 1.2, 341 | 'quantity': 10, 342 | }, 343 | 'relationships': None, 344 | 'links': None, 345 | } 346 | 347 | def test_response_constructed_with_resource_object(self): 348 | ItemResponse = JsonApiResponse('item', ItemModel) 349 | item = ItemModel(name='pear', price=1.2, quantity=10) 350 | data = ItemResponse.resource_object(id='abc123', attributes=item).dict() 351 | assert ItemResponse(data=data).dict() == { 352 | 'data': { 353 | 'id': 'abc123', 354 | 'type': 'item', 355 | 'attributes': { 356 | 'name': 'pear', 357 | 'price': 1.2, 358 | 'quantity': 10, 359 | }, 360 | } 361 | } 362 | 363 | def test_response_constructed_with_resource_object__list(self): 364 | @dataclass 365 | class FakeDBItem: 366 | item_id: int 367 | name: str 368 | price: float 369 | quantity: int 370 | created_at: datetime = datetime.utcnow() 371 | 372 | ItemResponse = JsonApiResponse('item', ItemModelWithOrmMode, use_list=True) 373 | items = [ 374 | FakeDBItem(item_id=1, name='apple', price=1.5, quantity=3), 375 | FakeDBItem(item_id=2, name='pear', price=1.2, quantity=10), 376 | FakeDBItem(item_id=3, name='orange', price=2.2, quantity=5) 377 | ] 378 | response = ItemResponse( 379 | data=[ 380 | ItemResponse.resource_object(id=item.item_id, attributes=item) 381 | for item in items 382 | ] 383 | ) 384 | assert response.dict() == { 385 | 'data': [ 386 | { 387 | 'id': '1', 388 | 'type': 'item', 389 | 'attributes': { 390 | 'name': 'apple', 391 | 'price': 1.5, 392 | 'quantity': 3, 393 | }, 394 | }, 395 | { 396 | 'id': '2', 397 | 'type': 'item', 398 | 'attributes': { 399 | 'name': 'pear', 400 | 'price': 1.2, 401 | 'quantity': 10, 402 | }, 403 | }, 404 | { 405 | 'id': '3', 406 | 'type': 'item', 407 | 'attributes': { 408 | 'name': 'orange', 409 | 'price': 2.2, 410 | 'quantity': 5, 411 | }, 412 | }, 413 | ] 414 | } 415 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37, py38 3 | 4 | [testenv] 5 | deps = pipenv 6 | commands= 7 | pipenv install --dev 8 | pipenv run pytest tests/ 9 | --------------------------------------------------------------------------------