├── .gitignore ├── .pre-commit-config.yaml ├── .readthdocs.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── azure-pipelines.yml ├── docs ├── Makefile ├── api_reference.rst ├── authors.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── index.rst ├── license.rst ├── make.bat ├── quickstart.rst └── requirements.txt ├── examples └── flask_example.py ├── marshmallow_jsonapi ├── __init__.py ├── exceptions.py ├── fields.py ├── flask.py ├── schema.py └── utils.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── base.py ├── conftest.py ├── test_fields.py ├── test_flask.py ├── test_options.py ├── test_schema.py └── test_utils.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | .cache 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | # Complexity 39 | output/*.html 40 | output/*/index.html 41 | 42 | # Sphinx 43 | docs/_build 44 | README.html 45 | 46 | _sandbox 47 | .konchrc 48 | 49 | # Virtual Environment 50 | env 51 | venv 52 | .python-version 53 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v2.31.0 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py36-plus] 7 | - repo: https://github.com/python/black 8 | rev: 22.1.0 9 | hooks: 10 | - id: black 11 | language_version: python3 12 | - repo: https://gitlab.com/pycqa/flake8 13 | rev: 3.9.2 14 | hooks: 15 | - id: flake8 16 | additional_dependencies: [flake8-bugbear==22.1.11] 17 | - repo: https://github.com/asottile/blacken-docs 18 | rev: v1.12.1 19 | hooks: 20 | - id: blacken-docs 21 | additional_dependencies: [black==22.1.0] 22 | -------------------------------------------------------------------------------- /.readthdocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | sphinx: 3 | configuration: docs/conf.py 4 | formats: all 5 | python: 6 | version: 3.7 7 | install: 8 | - requirements: docs/requirements.txt 9 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | Authors 3 | ******* 4 | 5 | Lead 6 | ==== 7 | 8 | - Steven Loria `@sloria `_ 9 | 10 | Contributors (chronological) 11 | ============================ 12 | 13 | - Jotham Apaloo `@jo-tham `_ 14 | - Anders Steinlein `@asteinlein `_ 15 | - `@floqqi `_ 16 | - Colton Allen `@cmanallen `_ 17 | - Dominik Steinberger `@ZeeD26 `_ 18 | - Tim Mundt `@Tim-Erwin `_ 19 | - Brandon Wood `@woodb `_ 20 | - Frazer McLean `@RazerM `_ 21 | - J Rob Gant `@rgant `_ 22 | - Dan Poland `@danpoland `_ 23 | - Pierre CHAISY `@akira-dev `_ 24 | - `@mrhanky17 `_ 25 | - Mark Hall `@scmmmh `_ 26 | - Scott Werner `@scottwernervt `_ 27 | - Michael Dodsworth `@mdodsworth `_ 28 | - Mathieu Alorent `@kumy `_ 29 | - Grant Harris `@grantHarris `_ 30 | - Robert Sawicki `@ww3pl `_ 31 | - `@aberres `_ 32 | - George Alton `@georgealton `_ 33 | - Areeb Jamal `@iamareebjamal `_ 34 | - Suren Khorenyan `@mahenzon `_ 35 | - Karthikeyan Singaravelan `@tirkarthi `_ 36 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ********* 2 | Changelog 3 | ********* 4 | 5 | 0.24.0 (2020-12-27) 6 | =================== 7 | 8 | Deprecations/Removals: 9 | 10 | * Drop support for marshmallow 2, which is now EOL (:pr:`332`). 11 | 12 | Bug fixes: 13 | 14 | * Fix behavior when serializing ``None`` (:pr:`302`). Thanks :user:`mahenzon`. 15 | 16 | Other changes: 17 | 18 | * Test against Python 3.8 and 3.9 (:pr:`332`). 19 | 20 | 0.23.2 (2020-07-20) 21 | =================== 22 | 23 | Bug fixes: 24 | 25 | * Import from `collections.abc` for forward-compatibility with Python 3.10 (:issue:`318`). 26 | Thanks :user:`tirkarthi`. 27 | 28 | 0.23.1 (2020-03-22) 29 | =================== 30 | 31 | Bug fixes: 32 | 33 | * Fix nested fields validation error formatting (:issue:`120`). 34 | Thanks :user:`mahenzon` and :user:`debonzi` for the PRs. 35 | 36 | 0.23.0 (2020-02-02) 37 | =================== 38 | 39 | * Improve performance of link generation from `Relationship` (:issue:`277`). 40 | Thanks :user:`iamareebjamal` for reporting and fixing. 41 | 42 | 0.22.0 (2019-09-15) 43 | =================== 44 | 45 | Deprecation/Removals: 46 | 47 | * Drop support for Python 2.7 and 3.5. 48 | Only Python>=3.6 is supported (:issue:`251`). 49 | * Drop support for marshmallow 3 pre-releases. Only stable versions >=2.15.2 are supported. 50 | * Remove ``fields.Meta``. 51 | 52 | Bug fixes: 53 | 54 | * Address ``DeprecationWarning`` raised by ``Field.fail`` on marshmallow 3. 55 | 56 | 0.21.2 (2019-07-01) 57 | =================== 58 | 59 | Bug fixes: 60 | 61 | * marshmallow 3.0.0rc7 compatibility (:pr:`233`). 62 | 63 | Other changes: 64 | 65 | * Format with pyupgrade and black (:pr:`235`). 66 | * Switch to Azure Pipelines for CI (:pr:`234`). 67 | 68 | 0.21.1 (2019-05-05) 69 | =================== 70 | 71 | Bug fixes: 72 | 73 | * marshmallow 3.0.0rc6 cmpatibility (:pr:`221`). 74 | 75 | 0.21.0 (2018-12-16) 76 | =================== 77 | 78 | Bug fixes: 79 | 80 | * *Backwards-incompatible*: Revert URL quoting introduced in 0.20.2 81 | (:issue:`184`). If you need quoting, override `Schema.generate_url`. 82 | 83 | .. code-block:: python 84 | 85 | from marshmallow_jsonapi import Schema 86 | from werkzeug.urls import url_fix 87 | 88 | 89 | class MySchema(Schema): 90 | def generate_url(self, link, **kwargs): 91 | url = super().generate_url(link, **kwargs) 92 | return url_fix(url) 93 | 94 | Thanks :user:`kgutwin` for reporting the issue. 95 | 96 | * Fix `Relationship` deserialization behavior when ``required=False`` (:issue:`177`). 97 | Thanks :user:`aberres` for reporting and :user:`scottwernervt` for the 98 | fix. 99 | 100 | Other changes: 101 | 102 | * Test against Python 3.7. 103 | 104 | 0.20.5 (2018-10-27) 105 | =================== 106 | 107 | Bug fixes: 108 | 109 | * Fix deserializing ``id`` field to non-string types (:pr:`179`). 110 | Thanks :user:`aberres` for the catch and patch. 111 | 112 | 0.20.4 (2018-10-04) 113 | =================== 114 | 115 | Bug fixes: 116 | 117 | * Fix bug where multi-level nested relationships would not be properly 118 | deserialized (:issue:`127`). Thanks :user:`ww3pl` for the catch and 119 | patch. 120 | 121 | 0.20.3 (2018-09-13) 122 | =================== 123 | 124 | Bug fixes: 125 | 126 | * Fix missing load validation when data is not a collection 127 | but many=True (:pr:`161`). Thanks :user:`grantHarris`. 128 | 129 | 0.20.2 (2018-08-15) 130 | =================== 131 | 132 | Bug fixes: 133 | 134 | * Fix issues where generated URLs are unquoted (:pr:`147`). Thanks 135 | :user:`grantHarris`. 136 | 137 | Other changes: 138 | 139 | * Fix tests against marshmallow 3.0.0b13. 140 | 141 | 0.20.1 (2018-07-15) 142 | =================== 143 | 144 | Bug fixes: 145 | 146 | * Fix deserializing ``missing`` with a `Relationship` field (:issue:`130`). 147 | Thanks :user:`kumy` for the catch and patch. 148 | 149 | 0.20.0 (2018-06-10) 150 | =================== 151 | 152 | Bug fixes: 153 | 154 | * Fix serialization of ``id`` for ``Relationship`` fields when 155 | ``attribute`` is set (:issue:`69`). Thanks :user:`jordal` for 156 | reporting and thanks :user:`scottwernervt` for the fix. 157 | 158 | Note: The above fix could break some code that set 159 | ``Relationship.id_field`` before instantiating it. 160 | Set ``Relationship.default_id_field`` instead. 161 | 162 | .. code-block:: python 163 | 164 | 165 | # before 166 | fields.Relationship.id_field = "item_id" 167 | 168 | # after 169 | fields.Relationship.default_id_field = "item_id" 170 | 171 | 172 | Support: 173 | 174 | * Test refactoring and various doc improvements (:issue:`63`, :issue:`86`, 175 | :issue:`121,` and :issue:`122`). Thanks :user:`scottwernervt`. 176 | 177 | 0.19.0 (2018-05-27) 178 | =================== 179 | 180 | Features: 181 | 182 | * Schemas passed to ``fields.Relationship`` will inherit context from 183 | the parent schema (:issue:`84`). Thanks :user:`asteinlein` and 184 | :user:`scottwernervt` for the PRs. 185 | 186 | 0.18.0 (2018-05-19) 187 | =================== 188 | 189 | Features: 190 | 191 | * Add ``fields.ResourceMeta`` for serializing a resource-level meta 192 | object (:issue:`107`). Thanks :user:`scottwernervt`. 193 | 194 | Other changes: 195 | 196 | * *Backwards-incompatible*: Drop official support for Python 3.4. 197 | 198 | 0.17.0 (2018-04-29) 199 | =================== 200 | 201 | Features: 202 | 203 | * Add support for marshmallow 3 (:issue:`97`). Thanks :user:`rockmnew`. 204 | * Thanks :user:`mdodsworth` for helping with :issue:`101`. 205 | * Move meta information object to document top level (:issue:`95`). Thanks :user:`scottwernervt`. 206 | 207 | 0.16.0 (2017-11-08) 208 | =================== 209 | 210 | Features: 211 | 212 | * Add support for exluding or including nested fields on relationships 213 | (:issue:`94`). Thanks :user:`scottwernervt` for the PR. 214 | 215 | Other changes: 216 | 217 | * *Backwards-incompatible*: Drop support for marshmallow<2.8.0 218 | 219 | 0.15.1 (2017-08-23) 220 | =================== 221 | 222 | Bug fixes: 223 | 224 | * Fix pointer for ``id`` in error objects (:issue:`90`). Thanks 225 | :user:`rgant` for the catch and patch. 226 | 227 | 0.15.0 (2017-06-27) 228 | =================== 229 | 230 | Features: 231 | 232 | * ``Relationship`` field supports deserializing included data 233 | (:issue:`83`). Thanks :user:`anuragagarwal561994` for the suggestion 234 | and thanks :user:`asteinlein` for the PR. 235 | 236 | 0.14.0 (2017-04-30) 237 | =================== 238 | 239 | Features: 240 | 241 | * ``Relationship`` respects its passed ``Schema's`` ``get_attribute`` method when getting the ``id`` field for resource linkages (:issue:`80`). Thanks :user:`scmmmh` for the PR. 242 | 243 | 0.13.0 (2017-04-18) 244 | =================== 245 | 246 | Features: 247 | 248 | * Add support for including deeply nested relationships in compount documents (:issue:`61`). Thanks :user:`mrhanky17` for the PR. 249 | 250 | 0.12.0 (2017-04-16) 251 | =================== 252 | 253 | Features: 254 | 255 | * Use default attribute value instead of raising exception if relationship is ``None`` on ``Relationship`` field (:issue:`75`). Thanks :user:`akira-dev`. 256 | 257 | 0.11.1 (2017-04-06) 258 | =================== 259 | 260 | Bug fixes: 261 | 262 | - Fix formatting JSON pointer when serializing an invalid object at index 0 (:issue:`77`). Thanks :user:`danpoland` for the catch and patch. 263 | 264 | 0.11.0 (2017-03-12) 265 | =================== 266 | 267 | Bug fixes: 268 | 269 | * Fix compatibility with marshmallow 3.x. 270 | 271 | 272 | Other changes: 273 | 274 | * *Backwards-incompatible*: Remove unused `utils.get_value_or_raise` function. 275 | 276 | 0.10.2 (2017-03-08) 277 | =================== 278 | 279 | Bug fixes: 280 | 281 | * Fix format of error object returned when ``data`` key is not included in input (:issue:`66`). Thanks :user:`RazerM`. 282 | * Fix serializing compound documents when ``Relationship`` is passed a schema class and ``many=True`` (:issue:`67`). Thanks :user:`danpoland` for the catch and patch. 283 | 284 | 0.10.1 (2017-02-05) 285 | =================== 286 | 287 | Bug fixes: 288 | 289 | * Serialize ``None`` and empty lists (``[]``) to valid JSON-API objects (:issue:`58`). Thanks :user:`rgant` for reporting and sending a PR. 290 | 291 | 0.10.0 (2017-01-05) 292 | =================== 293 | 294 | Features: 295 | 296 | * Add ``fields.Meta`` for (de)serializing ``meta`` data on resource objects (:issue:`28`). Thanks :user:`rubdos` for the suggestion and initial work. Thanks :user:`RazerM` for the PR. 297 | 298 | Other changes: 299 | 300 | * Test against Python 3.6. 301 | 302 | 0.9.0 (2016-10-08) 303 | ================== 304 | 305 | Features: 306 | 307 | * Add Flask-specific schema with class Meta options for self link generation: ``self_view``, ``self_view_kwargs``, and ``self_view_many`` (:issue:`51`). Thanks :user:`asteinlein`. 308 | 309 | Bug fixes: 310 | 311 | * Fix formatting of validation error messages on newer versions of marshmallow. 312 | 313 | Other changes: 314 | 315 | * Drop official support for Python 3.3. 316 | 317 | 0.8.0 (2016-06-20) 318 | ================== 319 | 320 | Features: 321 | 322 | * Add support for compound documents (:issue:`11`). Thanks :user:`Tim-Erwin` and :user:`woodb` for implementing this. 323 | * *Backwards-incompatible*: Remove ``include_data`` parameter from ``Relationship``. Use ``include_resource_linkage`` instead. 324 | 325 | 0.7.1 (2016-05-08) 326 | ================== 327 | 328 | Bug fixes: 329 | 330 | * Format correction for error objects (:issue:`47`). Thanks :user:`ZeeD26` for the PR. 331 | 332 | 0.7.0 (2016-04-03) 333 | ================== 334 | 335 | Features: 336 | 337 | * Correctly format ``messages`` attribute of ``ValidationError`` raised when ``type`` key is missing in input (:issue:`43`). Thanks :user:`ZeeD26` for the catch and patch. 338 | * JSON pointers for error objects for relationships will point to the ``data`` key (:issue:`41`). Thanks :user:`cmanallen` for the PR. 339 | 340 | 0.6.0 (2016-03-24) 341 | ================== 342 | 343 | Features: 344 | 345 | * ``Relationship`` deserialization improvements: properly validate to-one and to-many relatinoships and validate the presense of the ``data`` key (:issue:`37`). Thanks :user:`cmanallen` for the PR. 346 | * ``attributes`` is no longer a required key in the ``data`` object (:issue:`#39`, :issue:`42`). Thanks :user:`ZeeD26` for reporting and :user:`cmanallen` for the PR. 347 | * Added ``id`` serialization (:issue:`39`). Thanks again :user:`cmanallen`. 348 | 349 | 0.5.0 (2016-02-08) 350 | ================== 351 | 352 | Features: 353 | 354 | * Add relationship deserialization (:issue:`15`). 355 | * Allow serialization of foreign key attributes (:issue:`32`). 356 | * Relationship IDs serialize to strings, as is required by JSON-API (:issue:`31`). 357 | * ``Relationship`` field respects ``dump_to`` parameter (:issue:`33`). 358 | 359 | Thanks :user:`cmanallen` for all of these changes. 360 | 361 | Other changes: 362 | 363 | * The minimum supported marshmallow version is 2.3.0. 364 | 365 | 0.4.2 (2015-12-21) 366 | ================== 367 | 368 | Bug fixes: 369 | 370 | * Relationship names are inflected when appropriate (:issue:`22`). Thanks :user:`angelosarto` for reporting. 371 | 372 | 0.4.1 (2015-12-19) 373 | ================== 374 | 375 | Bug fixes: 376 | 377 | * Fix serializing null and empty relationships with ``flask.Relationship`` (:issue:`24`). Thanks :user:`floqqi` for the catch and patch. 378 | 379 | 0.4.0 (2015-12-06) 380 | ================== 381 | 382 | * Correctly serialize null and empty relationships (:issue:`10`). Thanks :user:`jo-tham` for the PR. 383 | * Add ``self_url``, ``self_url_kwargs``, and ``self_url_many`` class Meta options for adding ``self`` links. Thanks :user:`asteinlein` for the PR. 384 | 385 | 0.3.0 (2015-10-18) 386 | ================== 387 | 388 | * *Backwards-incompatible*: Replace ``HyperlinkRelated`` with ``Relationship`` field. Supports related links (``related``), relationship links (``self``), and resource linkages. 389 | * *Backwards-incompatible*: Validate and deserialize JSON API-formatted request payloads. 390 | * Fix error formatting when ``many=True``. 391 | * Fix error formatting in strict mode. 392 | 393 | 0.2.2 (2015-09-26) 394 | ================== 395 | 396 | * Fix for marshmallow 2.0.0 compat. 397 | 398 | 0.2.1 (2015-09-16) 399 | ================== 400 | 401 | * Compatibility with marshmallow>=2.0.0rc2. 402 | 403 | 0.2.0 (2015-09-13) 404 | ================== 405 | 406 | Features: 407 | 408 | * Add framework-independent ``HyperlinkRelated`` field. 409 | * Support inflection of attribute names via the ``inflect`` class Meta option. 410 | 411 | Bug fixes: 412 | 413 | * Fix for making ``HyperlinkRelated`` read-only by defualt. 414 | 415 | Support: 416 | 417 | * Docs updates. 418 | * Tested on Python 3.5. 419 | 420 | 0.1.0 (2015-09-12) 421 | ================== 422 | 423 | * First PyPI release. 424 | * Include Schema that serializes objects to resource objects. 425 | * Flask-compatible HyperlinkRelate field for serializing relationships. 426 | * Errors are formatted as JSON API errror objects. 427 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing Guidelines 2 | ======================= 3 | 4 | Questions, Feature Requests, Bug Reports, and Feedback… 5 | ------------------------------------------------------- 6 | 7 | …should all be reported on the `Github Issue Tracker`_ . 8 | 9 | .. _`Github Issue Tracker`: https://github.com/marshmallow-code/marshmallow-jsonapi/issues?state=open 10 | 11 | Setting Up for Local Development 12 | -------------------------------- 13 | 14 | 1. Fork marshmallow-jsonapi_ on Github. 15 | 16 | :: 17 | 18 | $ git clone https://github.com/marshmallow-code/marshmallow-jsonapi.git 19 | $ cd marshmallow-jsonapi 20 | 21 | 2. Install development requirements. **It is highly recommended that you use a virtualenv.** 22 | Use the following command to install an editable version of 23 | marshmallow-jsonapi along with its development requirements. 24 | 25 | :: 26 | 27 | # After activating your virtualenv 28 | $ pip install -e '.[dev]' 29 | 30 | 3. Install the pre-commit hooks, which will format and lint your git staged files. 31 | 32 | :: 33 | 34 | # The pre-commit CLI was installed above 35 | $ pre-commit install 36 | 37 | Git Branch Structure 38 | -------------------- 39 | 40 | Marshmallow abides by the following branching model: 41 | 42 | 43 | ``dev`` 44 | Current development branch. **New features should branch off here**. 45 | 46 | ``X.Y-line`` 47 | Maintenance branch for release ``X.Y``. **Bug fixes should be sent to the most recent release branch.** The maintainer will forward-port the fix to ``dev``. Note: exceptions may be made for bug fixes that introduce large code changes. 48 | 49 | **Always make a new branch for your work**, no matter how small. Also, **do not put unrelated changes in the same branch or pull request**. This makes it more difficult to merge your changes. 50 | 51 | Pull Requests 52 | -------------- 53 | 54 | 1. Create a new local branch. 55 | :: 56 | 57 | $ git checkout -b name-of-feature dev 58 | 59 | 2. Commit your changes. Write `good commit messages `_. 60 | :: 61 | 62 | $ git commit -m "Detailed commit message" 63 | $ git push origin name-of-feature 64 | 65 | 3. Before submitting a pull request, check the following: 66 | 67 | - If the pull request adds functionality, it is tested and the docs are updated. 68 | - You've added yourself to ``AUTHORS.rst``. 69 | 70 | 4. Submit a pull request to ``marshmallow-code:dev`` or the appropriate maintenance branch. The `CI `_ build must be passing before your pull request is merged. 71 | 72 | Running tests 73 | ------------- 74 | 75 | To run all To run all tests: :: 76 | 77 | $ pytest 78 | 79 | To run syntax checks: :: 80 | 81 | $ tox -e lint 82 | 83 | (Optional) To run tests in all supported Python versions in their own virtual environments (must have each interpreter installed): :: 84 | 85 | $ tox 86 | 87 | Documentation 88 | ------------- 89 | 90 | Contributions to the documentation are welcome. Documentation is written in `reStructuredText`_ (rST). A quick rST reference can be found `here `_. Builds are powered by Sphinx_. 91 | 92 | To build the docs in "watch" mode: :: 93 | 94 | $ tox -e watch-docs 95 | 96 | Changes in the `docs/` directory will automatically trigger a rebuild. 97 | 98 | .. _Sphinx: http://sphinx.pocoo.org/ 99 | .. _`reStructuredText`: https://docutils.sourceforge.io/rst.html 100 | .. _marshmallow-jsonapi: https://github.com/marshmallow-code/marshmallow-jsonapi 101 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015-2020 Steven Loria and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst LICENSE 2 | recursive-include tests * 3 | recursive-include docs * 4 | recursive-include examples * 5 | recursive-exclude docs *.pyc 6 | recursive-exclude docs *.pyo 7 | recursive-exclude tests *.pyc 8 | recursive-exclude tests *.pyo 9 | recursive-exclude examples *.pyc 10 | recursive-exclude examples *.pyo 11 | prune docs/_build 12 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ******************* 2 | marshmallow-jsonapi 3 | ******************* 4 | 5 | .. image:: https://badgen.net/pypi/v/marshmallow-jsonapi 6 | :target: https://pypi.org/project/marshmallow-jsonapi/ 7 | :alt: PyPI version 8 | 9 | .. image:: https://dev.azure.com/sloria/sloria/_apis/build/status/marshmallow-code.marshmallow-jsonapi?branchName=dev 10 | :target: https://dev.azure.com/sloria/sloria/_build/latest?definitionId=7&branchName=dev 11 | :alt: Build status 12 | 13 | .. image:: https://readthedocs.org/projects/marshmallow-jsonapi/badge/ 14 | :target: https://marshmallow-jsonapi.readthedocs.io/ 15 | :alt: Documentation 16 | 17 | .. image:: https://badgen.net/badge/marshmallow/3 18 | :target: https://marshmallow.readthedocs.io/en/latest/upgrading.html 19 | :alt: marshmallow 3 compatible 20 | 21 | .. image:: https://badgen.net/badge/code%20style/black/000 22 | :target: https://github.com/ambv/black 23 | :alt: code style: black 24 | 25 | Homepage: http://marshmallow-jsonapi.readthedocs.io/ 26 | 27 | JSON API 1.0 (`https://jsonapi.org `_) formatting with `marshmallow `_. 28 | 29 | marshmallow-jsonapi provides a simple way to produce JSON API-compliant data in any Python web framework. 30 | 31 | .. code-block:: python 32 | 33 | from marshmallow_jsonapi import Schema, fields 34 | 35 | 36 | class PostSchema(Schema): 37 | id = fields.Str(dump_only=True) 38 | title = fields.Str() 39 | 40 | author = fields.Relationship( 41 | "/authors/{author_id}", related_url_kwargs={"author_id": ""} 42 | ) 43 | 44 | comments = fields.Relationship( 45 | "/posts/{post_id}/comments", 46 | related_url_kwargs={"post_id": ""}, 47 | # Include resource linkage 48 | many=True, 49 | include_resource_linkage=True, 50 | type_="comments", 51 | ) 52 | 53 | class Meta: 54 | type_ = "posts" 55 | 56 | 57 | post_schema = PostSchema() 58 | post_schema.dump(post) 59 | # { 60 | # "data": { 61 | # "id": "1", 62 | # "type": "posts" 63 | # "attributes": { 64 | # "title": "JSON API paints my bikeshed!" 65 | # }, 66 | # "relationships": { 67 | # "author": { 68 | # "links": { 69 | # "related": "/authors/9" 70 | # } 71 | # }, 72 | # "comments": { 73 | # "links": { 74 | # "related": "/posts/1/comments/" 75 | # } 76 | # "data": [ 77 | # {"id": 5, "type": "comments"}, 78 | # {"id": 12, "type": "comments"} 79 | # ], 80 | # } 81 | # }, 82 | # } 83 | # } 84 | 85 | Installation 86 | ============ 87 | :: 88 | 89 | pip install marshmallow-jsonapi 90 | 91 | 92 | Documentation 93 | ============= 94 | 95 | Full documentation is available at https://marshmallow-jsonapi.readthedocs.io/. 96 | 97 | Requirements 98 | ============ 99 | 100 | - Python >= 3.6 101 | 102 | Project Links 103 | ============= 104 | 105 | - Docs: http://marshmallow-jsonapi.readthedocs.io/ 106 | - Changelog: http://marshmallow-jsonapi.readthedocs.io/en/latest/changelog.html 107 | - Contributing Guidelines: https://marshmallow-jsonapi.readthedocs.io/en/latest/contributing.html 108 | - PyPI: https://pypi.python.org/pypi/marshmallow-jsonapi 109 | - Issues: https://github.com/marshmallow-code/marshmallow-jsonapi/issues 110 | 111 | License 112 | ======= 113 | 114 | MIT licensed. See the bundled `LICENSE `_ file for more details. 115 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | branches: 3 | include: [dev, test-me-*] 4 | tags: 5 | include: ["*"] 6 | 7 | # Run builds nightly to catch incompatibilities with new marshmallow releases 8 | schedules: 9 | - cron: "0 0 * * *" 10 | displayName: Daily midnight build 11 | branches: 12 | include: 13 | - dev 14 | always: "true" 15 | 16 | resources: 17 | repositories: 18 | - repository: sloria 19 | type: github 20 | endpoint: github 21 | name: sloria/azure-pipeline-templates 22 | ref: refs/heads/sloria 23 | 24 | jobs: 25 | - template: job--python-tox.yml@sloria 26 | parameters: 27 | toxenvs: 28 | - lint 29 | 30 | - py36-marshmallow3 31 | - py37-marshmallow3 32 | - py38-marshmallow3 33 | - py39-marshmallow3 34 | - py39-marshmallowdev 35 | 36 | - docs 37 | 38 | os: linux 39 | - template: job--pypi-release.yml@sloria 40 | parameters: 41 | dependsOn: 42 | - tox_linux 43 | -------------------------------------------------------------------------------- /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 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 " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/complexity.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/complexity.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/complexity" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/complexity" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/api_reference.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | ************* 4 | API Reference 5 | ************* 6 | 7 | Core 8 | ==== 9 | 10 | .. automodule:: marshmallow_jsonapi 11 | :members: 12 | 13 | Fields 14 | ====== 15 | 16 | .. automodule:: marshmallow_jsonapi.fields 17 | :members: 18 | 19 | Flask 20 | ===== 21 | 22 | .. automodule:: marshmallow_jsonapi.flask 23 | :members: 24 | 25 | Exceptions 26 | ========== 27 | 28 | .. automodule:: marshmallow_jsonapi.exceptions 29 | :members: 30 | 31 | Utilities 32 | ========= 33 | 34 | .. automodule:: marshmallow_jsonapi.utils 35 | :members: 36 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. _changelog: 2 | 3 | .. include:: ../CHANGELOG.rst 4 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import os 3 | import sys 4 | 5 | sys.path.insert(0, os.path.abspath("..")) 6 | import marshmallow_jsonapi # noqa: E402 7 | 8 | extensions = [ 9 | "sphinx.ext.autodoc", 10 | "sphinx.ext.intersphinx", 11 | "sphinx.ext.viewcode", 12 | "sphinx_issues", 13 | ] 14 | 15 | primary_domain = "py" 16 | default_role = "py:obj" 17 | 18 | intersphinx_mapping = { 19 | "python": ("http://python.readthedocs.io/en/latest/", None), 20 | "marshmallow": ("http://marshmallow.readthedocs.io/en/latest/", None), 21 | } 22 | 23 | issues_github_path = "marshmallow-code/marshmallow-jsonapi" 24 | 25 | source_suffix = ".rst" 26 | master_doc = "index" 27 | project = "marshmallow-jsonapi" 28 | copyright = f"Steven Loria {dt.datetime.utcnow():%Y}" 29 | 30 | version = release = marshmallow_jsonapi.__version__ 31 | 32 | exclude_patterns = ["_build"] 33 | 34 | # THEME 35 | 36 | # on_rtd is whether we are on readthedocs.org 37 | on_rtd = os.environ.get("READTHEDOCS", None) == "True" 38 | 39 | if not on_rtd: # only import and set the theme if we're building docs locally 40 | import sphinx_rtd_theme 41 | 42 | html_theme = "sphinx_rtd_theme" 43 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 44 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ******************* 2 | marshmallow-jsonapi 3 | ******************* 4 | 5 | Release v\ |version|. (:ref:`Changelog `) 6 | 7 | JSON API 1.0 (`https://jsonapi.org `_) formatting with `marshmallow `_. 8 | 9 | marshmallow-jsonapi provides a simple way to produce JSON API-compliant data in any Python web framework. 10 | 11 | .. code-block:: python 12 | 13 | from marshmallow_jsonapi import Schema, fields 14 | 15 | 16 | class PostSchema(Schema): 17 | id = fields.Str(dump_only=True) 18 | title = fields.Str() 19 | 20 | author = fields.Relationship( 21 | related_url="/authors/{author_id}", 22 | related_url_kwargs={"author_id": ""}, 23 | ) 24 | 25 | comments = fields.Relationship( 26 | related_url="/posts/{post_id}/comments", 27 | related_url_kwargs={"post_id": ""}, 28 | # Include resource linkage 29 | many=True, 30 | include_resource_linkage=True, 31 | type_="comments", 32 | ) 33 | 34 | class Meta: 35 | type_ = "posts" 36 | strict = True 37 | 38 | 39 | post_schema = PostSchema() 40 | post_schema.dump(post) 41 | # { 42 | # "data": { 43 | # "id": "1", 44 | # "type": "posts" 45 | # "attributes": { 46 | # "title": "JSON API paints my bikeshed!" 47 | # }, 48 | # "relationships": { 49 | # "author": { 50 | # "links": { 51 | # "related": "/authors/9" 52 | # } 53 | # }, 54 | # "comments": { 55 | # "data": [ 56 | # {"id": 5, "type": "comments"}, 57 | # {"id": 12, "type": "comments"} 58 | # ], 59 | # "links": { 60 | # "related": "/posts/1/comments/" 61 | # } 62 | # } 63 | # }, 64 | # } 65 | # } 66 | 67 | Installation 68 | ============ 69 | :: 70 | 71 | pip install marshmallow-jsonapi 72 | 73 | Guide 74 | ===== 75 | 76 | .. toctree:: 77 | :maxdepth: 2 78 | 79 | quickstart 80 | 81 | API Reference 82 | ============= 83 | 84 | .. toctree:: 85 | :maxdepth: 2 86 | 87 | api_reference 88 | 89 | Project info 90 | ============ 91 | 92 | .. toctree:: 93 | :maxdepth: 1 94 | 95 | changelog 96 | authors 97 | contributing 98 | license 99 | 100 | Links 101 | ===== 102 | 103 | - `marshmallow-jsonapi @ GitHub `_ 104 | - `marshmallow-jsonapi @ PyPI `_ 105 | - `Issue Tracker `_ 106 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | License 3 | ******* 4 | 5 | .. literalinclude:: ../LICENSE 6 | -------------------------------------------------------------------------------- /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 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\complexity.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\complexity.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | ********** 2 | Quickstart 3 | ********** 4 | 5 | .. note:: The following guide assumes some familiarity with the marshmallow API. To learn more about marshmallow, see its official documentation at `https://marshmallow.readthedocs.io `_. 6 | 7 | Declaring schemas 8 | ================= 9 | 10 | Let’s start with a basic post “model”. 11 | 12 | .. code-block:: python 13 | 14 | class Post: 15 | def __init__(self, id, title): 16 | self.id = id 17 | self.title = title 18 | 19 | Declare your schemas as you would with marshmallow. 20 | 21 | A :class:`.Schema` **MUST** define: 22 | 23 | - An ``id`` field 24 | - The ``type_`` class Meta option 25 | 26 | It is **RECOMMENDED** to set strict mode to `True`. 27 | 28 | Automatic self-linking is supported through these Meta options: 29 | 30 | - ``self_url`` specifies the URL to the resource itself 31 | - ``self_url_kwargs`` specifies replacement fields for `self_url` 32 | - ``self_url_many`` specifies the URL the resource when a collection (many) are 33 | serialized 34 | 35 | .. code-block:: python 36 | 37 | from marshmallow_jsonapi import Schema, fields 38 | 39 | 40 | class PostSchema(Schema): 41 | id = fields.Str(dump_only=True) 42 | title = fields.Str() 43 | 44 | class Meta: 45 | type_ = "posts" 46 | self_url = "/posts/{id}" 47 | self_url_kwargs = {"id": ""} 48 | self_url_many = "/posts/" 49 | 50 | These URLs can be auto-generated by specifying ``self_view``, ``self_view_kwargs`` 51 | and ``self_view_many`` instead when using the :ref:`flask-integration`. 52 | 53 | Serialization 54 | ============= 55 | 56 | Objects will be serialized to `JSON API documents `_ with primary data. 57 | 58 | .. code-block:: python 59 | 60 | post = Post(id="1", title="Django is Omakase") 61 | PostSchema().dump(post) 62 | # { 63 | # 'data': { 64 | # 'id': '1', 65 | # 'type': 'posts', 66 | # 'attributes': {'title': 'Django is Omakase'}, 67 | # 'links': {'self': '/posts/1'} 68 | # }, 69 | # 'links': {'self': '/posts/1'} 70 | # } 71 | 72 | Relationships 73 | ============= 74 | 75 | The `Relationship ` field is used to serialize `relationship objects `_. For example, a Post may have an author and comments associated with it. 76 | 77 | .. code-block:: python 78 | 79 | class User: 80 | def __init__(self, id, name): 81 | self.id = id 82 | self.name = name 83 | 84 | 85 | class Comment: 86 | def __init__(self, id, body, author): 87 | self.id = id 88 | self.body = body 89 | self.author = author 90 | 91 | 92 | class Post: 93 | def __init__(self, id, title, author, comments=None): 94 | self.id = id 95 | self.title = title 96 | self.author = author # User object 97 | self.comments = [] if comments is None else comments # Comment objects 98 | 99 | To serialize links, pass a URL format string and a dictionary of keyword arguments. String arguments enclosed in `< >` will be interpreted as attributes to pull from the object being serialized. The relationship links can automatically be generated from Flask view names when using the :ref:`flask-integration`. 100 | 101 | .. code-block:: python 102 | :emphasize-lines: 5-10 103 | 104 | class PostSchema(Schema): 105 | id = fields.Str(dump_only=True) 106 | title = fields.Str() 107 | 108 | author = fields.Relationship( 109 | self_url="/posts/{post_id}/relationships/author", 110 | self_url_kwargs={"post_id": ""}, 111 | related_url="/authors/{author_id}", 112 | related_url_kwargs={"author_id": ""}, 113 | ) 114 | 115 | class Meta: 116 | type_ = "posts" 117 | 118 | 119 | user = User(id="94", name="Laura") 120 | post = Post(id="1", title="Django is Omakase", author=user) 121 | PostSchema().dump(post) 122 | # { 123 | # 'data': { 124 | # 'id': '1', 125 | # 'type': 'posts', 126 | # 'attributes': {'title': 'Django is Omakase'}, 127 | # 'relationships': { 128 | # 'author': { 129 | # 'links': { 130 | # 'self': '/posts/1/relationships/author', 131 | # 'related': '/authors/94' 132 | # } 133 | # } 134 | # } 135 | # } 136 | # } 137 | 138 | Resource linkages 139 | ----------------- 140 | 141 | You can serialize `resource linkages `_ by passing ``include_resource_linkage=True`` and the resource ``type_`` argument. 142 | 143 | .. code-block:: python 144 | :emphasize-lines: 10-12 145 | 146 | class PostSchema(Schema): 147 | id = fields.Str(dump_only=True) 148 | title = fields.Str() 149 | 150 | author = fields.Relationship( 151 | self_url="/posts/{post_id}/relationships/author", 152 | self_url_kwargs={"post_id": ""}, 153 | related_url="/authors/{author_id}", 154 | related_url_kwargs={"author_id": ""}, 155 | # Include resource linkage 156 | include_resource_linkage=True, 157 | type_="users", 158 | ) 159 | 160 | class Meta: 161 | type_ = "posts" 162 | 163 | 164 | PostSchema().dump(post) 165 | # { 166 | # 'data': { 167 | # 'id': '1', 168 | # 'type': 'posts', 169 | # 'attributes': {'title': 'Django is Omakase'}, 170 | # 'relationships': { 171 | # 'author': { 172 | # 'data': {'type': 'users', 'id': '94'}, 173 | # 'links': { 174 | # 'self': '/posts/1/relationships/author', 175 | # 'related': '/authors/94' 176 | # } 177 | # } 178 | # } 179 | # } 180 | # } 181 | 182 | Compound documents 183 | ------------------ 184 | 185 | `Compound documents `_ allow to include related resources into the request with the primary resource. In order to include objects, you have to define a :class:`.Schema` for the respective relationship, which will be used to render those objects. 186 | 187 | .. code-block:: python 188 | :emphasize-lines: 10-11 189 | 190 | class PostSchema(Schema): 191 | id = fields.Str(dump_only=True) 192 | title = fields.Str() 193 | 194 | comments = fields.Relationship( 195 | related_url="/posts/{post_id}/comments", 196 | related_url_kwargs={"post_id": ""}, 197 | many=True, 198 | include_resource_linkage=True, 199 | type_="comments", 200 | # define a schema for rendering included data 201 | schema="CommentSchema", 202 | ) 203 | 204 | author = fields.Relationship( 205 | self_url="/posts/{post_id}/relationships/author", 206 | self_url_kwargs={"post_id": ""}, 207 | related_url="/authors/{author_id}", 208 | related_url_kwargs={"author_id": ""}, 209 | include_resource_linkage=True, 210 | type_="users", 211 | ) 212 | 213 | class Meta: 214 | type_ = "posts" 215 | 216 | 217 | class CommentSchema(Schema): 218 | id = fields.Str(dump_only=True) 219 | body = fields.Str() 220 | 221 | author = fields.Relationship( 222 | self_url="/comments/{comment_id}/relationships/author", 223 | self_url_kwargs={"comment_id": ""}, 224 | related_url="/comments/{author_id}", 225 | related_url_kwargs={"author_id": ""}, 226 | type_="users", 227 | # define a schema for rendering included data 228 | schema="UserSchema", 229 | ) 230 | 231 | class Meta: 232 | type_ = "comments" 233 | 234 | 235 | class UserSchema(Schema): 236 | id = fields.Str(dump_only=True) 237 | name = fields.Str() 238 | 239 | class Meta: 240 | type_ = "users" 241 | 242 | Just as with nested fields the ``schema`` can be a class or a string with a simple or fully qualified class name. Make sure to import the schema beforehand. 243 | 244 | Now you can include some data in a dump by specifying the ``include_data`` argument (also supports nested relations via the dot syntax). 245 | 246 | .. code-block:: python 247 | :emphasize-lines: 8 248 | 249 | armin = User(id="101", name="Armin") 250 | laura = User(id="94", name="Laura") 251 | steven = User(id="23", name="Steven") 252 | comments = [ 253 | Comment(id="5", body="Marshmallow is sweet like sugar!", author=steven), 254 | Comment(id="12", body="Flask is Fun!", author=armin), 255 | ] 256 | post = Post(id="1", title="Django is Omakase", author=laura, comments=comments) 257 | 258 | PostSchema(include_data=("comments", "comments.author")).dump(post) 259 | # { 260 | # 'data': { 261 | # 'id': '1', 262 | # 'type': 'posts', 263 | # 'attributes': {'title': 'Django is Omakase'}, 264 | # 'relationships': { 265 | # 'author': { 266 | # 'data': {'type': 'users', 'id': '94'}, 267 | # 'links': { 268 | # 'self': '/posts/1/relationships/author', 269 | # 'related': '/authors/94' 270 | # } 271 | # }, 272 | # 'comments': { 273 | # 'data': [ 274 | # {'type': 'comments', 'id': '5'}, 275 | # {'type': 'comments', 'id': '12'} 276 | # ], 277 | # 'links': { 278 | # 'related': '/posts/1/comments' 279 | # } 280 | # } 281 | # } 282 | # }, 283 | # 'included': [ 284 | # { 285 | # 'id': '5', 286 | # 'type': 'comments', 287 | # 'attributes': {'body': 'Marshmallow is sweet like sugar!'}, 288 | # 'relationships': { 289 | # 'author': { 290 | # 'data': {'type': 'users', 'id': '23'}, 291 | # 'links': { 292 | # 'self': '/comments/5/relationships/author', 293 | # 'related': '/comments/23' 294 | # } 295 | # } 296 | # } 297 | # }, 298 | # { 299 | # 'id': '12', 300 | # 'type': 'comments', 301 | # 'attributes': {'body': 'Flask is Fun!'}, 302 | # 'relationships': { 303 | # 'author': { 304 | # 'data': {'type': 'users', 'id': '101'}, 305 | # 'links': { 306 | # 'self': '/comments/12/relationships/author', 307 | # 'related': '/comments/101' 308 | # } 309 | # } 310 | # }, 311 | # 312 | # }, 313 | # { 314 | # 'id': '23', 315 | # 'type': 'users', 316 | # 'attributes': {'name': 'Steven'} 317 | # }, 318 | # { 319 | # 'id': '101', 320 | # 'type': 'users', 321 | # 'attributes': {'name': 'Armin'} 322 | # } 323 | # ] 324 | # } 325 | 326 | Meta Information 327 | ================ 328 | 329 | The :class:`.DocumentMeta` field is used to serialize 330 | the meta object within a `document’s "top level" `_. 331 | 332 | .. code-block:: python 333 | :emphasize-lines: 6 334 | 335 | from marshmallow_jsonapi import Schema, fields 336 | 337 | 338 | class UserSchema(Schema): 339 | id = fields.Str(dump_only=True) 340 | name = fields.Str() 341 | document_meta = fields.DocumentMeta() 342 | 343 | class Meta: 344 | type_ = "users" 345 | 346 | 347 | user = {"name": "Alice", "document_meta": {"page": {"offset": 10}}} 348 | UserSchema().dump(user) 349 | # { 350 | # "meta": { 351 | # "page": { 352 | # "offset": 10 353 | # } 354 | # }, 355 | # "data": { 356 | # "id": "1", 357 | # "type": "users" 358 | # "attributes": {"name": "Alice"}, 359 | # } 360 | # } 361 | 362 | The :class:`.ResourceMeta` field is used to serialize the meta object within a `resource object `_. 363 | 364 | .. code-block:: python 365 | :emphasize-lines: 6 366 | 367 | from marshmallow_jsonapi import Schema, fields 368 | 369 | 370 | class UserSchema(Schema): 371 | id = fields.Str(dump_only=True) 372 | name = fields.Str() 373 | resource_meta = fields.ResourceMeta() 374 | 375 | class Meta: 376 | type_ = "users" 377 | 378 | 379 | user = {"name": "Alice", "resource_meta": {"active": True}} 380 | UserSchema().dump(user) 381 | # { 382 | # "data": { 383 | # "type": "users", 384 | # "attributes": {"name": "Alice"}, 385 | # "meta": { 386 | # "active": true 387 | # } 388 | # } 389 | # } 390 | 391 | Errors 392 | ====== 393 | 394 | :func:`.Schema.load` and :func:`.Schema.validate` will return JSON API-formatted `Error objects `_. 395 | 396 | .. code-block:: python 397 | 398 | from marshmallow_jsonapi import Schema, fields 399 | from marshmallow import validate, ValidationError 400 | 401 | 402 | class AuthorSchema(Schema): 403 | id = fields.Str(dump_only=True) 404 | first_name = fields.Str(required=True) 405 | last_name = fields.Str(required=True) 406 | password = fields.Str(load_only=True, validate=validate.Length(6)) 407 | twitter = fields.Str() 408 | 409 | class Meta: 410 | type_ = "authors" 411 | 412 | 413 | author_data = { 414 | "data": {"type": "users", "attributes": {"first_name": "Dan", "password": "short"}} 415 | } 416 | AuthorSchema().validate(author_data) 417 | # { 418 | # 'errors': [ 419 | # { 420 | # 'detail': 'Missing data for required field.', 421 | # 'source': { 422 | # 'pointer': '/data/attributes/last_name' 423 | # } 424 | # }, 425 | # { 426 | # 'detail': 'Shorter than minimum length 6.', 427 | # 'source': { 428 | # 'pointer': '/data/attributes/password' 429 | # } 430 | # } 431 | # ] 432 | # } 433 | 434 | If an invalid "type" is passed in the input data, an :class:`.IncorrectTypeError` is raised. 435 | 436 | .. code-block:: python 437 | 438 | from marshmallow_jsonapi.exceptions import IncorrectTypeError 439 | 440 | author_data = { 441 | "data": { 442 | "type": "invalid-type", 443 | "attributes": { 444 | "first_name": "Dan", 445 | "last_name": "Gebhardt", 446 | "password": "verysecure", 447 | }, 448 | } 449 | } 450 | 451 | try: 452 | AuthorSchema().validate(author_data) 453 | except IncorrectTypeError as err: 454 | pprint(err.messages) 455 | # { 456 | # 'errors': [ 457 | # { 458 | # 'detail': 'Invalid type. Expected "users".', 459 | # 'source': { 460 | # 'pointer': '/data/type' 461 | # } 462 | # } 463 | # ] 464 | # } 465 | 466 | Inflection 467 | ========== 468 | 469 | You can optionally specify a function to transform attribute names. For example, you may decide to follow JSON API's `recommendation `_ to use "dasherized" names. 470 | 471 | .. code-block:: python 472 | 473 | from marshmallow_jsonapi import Schema, fields 474 | 475 | 476 | def dasherize(text): 477 | return text.replace("_", "-") 478 | 479 | 480 | class UserSchema(Schema): 481 | id = fields.Str(dump_only=True) 482 | first_name = fields.Str(required=True) 483 | last_name = fields.Str(required=True) 484 | 485 | class Meta: 486 | type_ = "users" 487 | inflect = dasherize 488 | 489 | 490 | UserSchema().dump(user) 491 | # { 492 | # 'data': { 493 | # 'id': '9', 494 | # 'type': 'users', 495 | # 'attributes': { 496 | # 'first-name': 'Dan', 497 | # 'last-name': 'Gebhardt' 498 | # } 499 | # } 500 | # } 501 | 502 | .. _flask-integration: 503 | 504 | Flask integration 505 | ================= 506 | 507 | marshmallow-jsonapi includes optional utilities to integrate with Flask. 508 | 509 | A Flask-specific schema in `marshmallow_jsonapi.flask` can be used to 510 | auto-generate self-links based on view names instead of hard-coding URLs. 511 | 512 | Additionally, the ``Relationship`` field in the `marshmallow_jsonapi.flask` 513 | module allows you to pass view names instead of path templates to generate 514 | relationship links. 515 | 516 | .. code-block:: python 517 | 518 | from marshmallow_jsonapi import fields 519 | from marshmallow_jsonapi.flask import Relationship, Schema 520 | 521 | 522 | class PostSchema(Schema): 523 | id = fields.Str(dump_only=True) 524 | title = fields.Str() 525 | 526 | author = fields.Relationship( 527 | self_view="post_author", 528 | self_url_kwargs={"post_id": ""}, 529 | related_view="author_detail", 530 | related_view_kwargs={"author_id": ""}, 531 | ) 532 | 533 | comments = Relationship( 534 | related_view="post_comments", 535 | related_view_kwargs={"post_id": ""}, 536 | many=True, 537 | include_resource_linkage=True, 538 | type_="comments", 539 | ) 540 | 541 | class Meta: 542 | type_ = "posts" 543 | self_view = "post_detail" 544 | self_view_kwargs = {"post_detail": ""} 545 | self_view_many = "posts_list" 546 | 547 | See `here `_ for a full example. 548 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | marshmallow>=2.0.0rc1 2 | Flask==1.1.2 3 | sphinx==3.5.3 4 | sphinx-rtd-theme==0.5.0 5 | sphinx-issues>=0.2.0 6 | -------------------------------------------------------------------------------- /examples/flask_example.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, jsonify 2 | 3 | ### MODELS ### 4 | 5 | 6 | class Model: 7 | def __init__(self, **kwargs): 8 | for key, val in kwargs.items(): 9 | setattr(self, key, val) 10 | 11 | 12 | class Comment(Model): 13 | pass 14 | 15 | 16 | class Author(Model): 17 | pass 18 | 19 | 20 | class Post(Model): 21 | pass 22 | 23 | 24 | ### MOCK DATABASE ### 25 | 26 | 27 | comment1 = Comment(id=1, body="First!") 28 | comment2 = Comment(id=2, body="I like XML better!") 29 | 30 | author1 = Author(id=1, first_name="Dan", last_name="Gebhardt", twitter="dgeb") 31 | 32 | post1 = Post( 33 | id=1, 34 | title="JSON API paints my bikeshed!", 35 | author=author1, 36 | comments=[comment1, comment2], 37 | ) 38 | 39 | db = {"comments": [comment1, comment2], "authors": [author1], "posts": [post1]} 40 | 41 | 42 | ### SCHEMAS ### 43 | 44 | from marshmallow import validate, ValidationError # noqa: E402 45 | from marshmallow_jsonapi import fields # noqa: E402 46 | from marshmallow_jsonapi.flask import Relationship, Schema # noqa: E402 47 | 48 | 49 | class CommentSchema(Schema): 50 | id = fields.Str(dump_only=True) 51 | body = fields.Str() 52 | 53 | class Meta: 54 | type_ = "comments" 55 | self_view = "comment_detail" 56 | self_view_kwargs = {"comment_id": "", "_external": True} 57 | self_view_many = "comments_list" 58 | 59 | 60 | class AuthorSchema(Schema): 61 | id = fields.Str(dump_only=True) 62 | first_name = fields.Str(required=True) 63 | last_name = fields.Str(required=True) 64 | password = fields.Str(load_only=True, validate=validate.Length(6)) 65 | twitter = fields.Str() 66 | 67 | class Meta: 68 | type_ = "people" 69 | self_view = "author_detail" 70 | self_view_kwargs = {"author_id": ""} 71 | self_view_many = "authors_list" 72 | 73 | 74 | class PostSchema(Schema): 75 | id = fields.Str(dump_only=True) 76 | title = fields.Str() 77 | 78 | author = Relationship( 79 | related_view="author_detail", 80 | related_view_kwargs={"author_id": "", "_external": True}, 81 | include_data=True, 82 | type_="people", 83 | ) 84 | 85 | comments = Relationship( 86 | related_view="posts_comments", 87 | related_view_kwargs={"post_id": "", "_external": True}, 88 | many=True, 89 | include_data=True, 90 | type_="comments", 91 | ) 92 | 93 | class Meta: 94 | type_ = "posts" 95 | self_view = "posts_detail" 96 | self_view_kwargs = {"post_id": ""} 97 | self_view_many = "posts_list" 98 | 99 | 100 | ### VIEWS ### 101 | 102 | app = Flask(__name__) 103 | app.config["DEBUG"] = True 104 | 105 | 106 | def J(*args, **kwargs): 107 | """Wrapper around jsonify that sets the Content-Type of the response to 108 | application/vnd.api+json. 109 | """ 110 | response = jsonify(*args, **kwargs) 111 | response.mimetype = "application/vnd.api+json" 112 | return response 113 | 114 | 115 | @app.route("/posts/", methods=["GET"]) 116 | def posts_list(): 117 | posts = db["posts"] 118 | data = PostSchema(many=True).dump(posts) 119 | return J(data) 120 | 121 | 122 | @app.route("/posts/") 123 | def posts_detail(post_id): 124 | post = db["posts"][post_id - 1] 125 | data = PostSchema().dump(post) 126 | return J(data) 127 | 128 | 129 | @app.route("/posts//comments/") 130 | def posts_comments(post_id): 131 | post = db["posts"][post_id - 1] 132 | comments = post.comments 133 | data = CommentSchema(many=True).dump(comments) 134 | return J(data) 135 | 136 | 137 | @app.route("/authors/") 138 | def authors_list(): 139 | author = db["authors"] 140 | data = AuthorSchema(many=True).dump(author) 141 | return J(data) 142 | 143 | 144 | @app.route("/authors/") 145 | def author_detail(author_id): 146 | author = db["authors"][author_id - 1] 147 | data = AuthorSchema().dump(author) 148 | return J(data) 149 | 150 | 151 | @app.route("/authors/", methods=["POST"]) 152 | def author_create(): 153 | schema = AuthorSchema() 154 | input_data = request.get_json() or {} 155 | try: 156 | data = schema.load(input_data) 157 | except ValidationError as err: 158 | return J(err.messages), 422 159 | id_ = len(db["authors"]) 160 | author = Author(id=id_, **data) 161 | db["authors"].append(author) 162 | data = schema.dump(author) 163 | return J(data) 164 | 165 | 166 | @app.route("/comments/") 167 | def comments_list(): 168 | comment = db["comments"] 169 | data = CommentSchema(many=True).dump(comment) 170 | return J(data) 171 | 172 | 173 | @app.route("/comments/") 174 | def comment_detail(comment_id): 175 | comment = db["comments"][comment_id - 1] 176 | data = CommentSchema().dump(comment) 177 | return J(data) 178 | 179 | 180 | if __name__ == "__main__": 181 | app.run() 182 | -------------------------------------------------------------------------------- /marshmallow_jsonapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .schema import Schema, SchemaOpts 2 | 3 | __version__ = "0.24.0" 4 | __all__ = ("Schema", "SchemaOpts") 5 | -------------------------------------------------------------------------------- /marshmallow_jsonapi/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exception classes.""" 2 | 3 | 4 | class JSONAPIError(Exception): 5 | """Base class for all exceptions in this package.""" 6 | 7 | pass 8 | 9 | 10 | class IncorrectTypeError(JSONAPIError, ValueError): 11 | """Raised when client provides an invalid `type` in a request.""" 12 | 13 | pointer = "/data/type" 14 | default_message = 'Invalid type. Expected "{expected}".' 15 | 16 | def __init__(self, message=None, actual=None, expected=None): 17 | message = message or self.default_message 18 | format_kwargs = {} 19 | if actual: 20 | format_kwargs["actual"] = actual 21 | if expected: 22 | format_kwargs["expected"] = expected 23 | self.detail = message.format(**format_kwargs) 24 | super().__init__(self.detail) 25 | 26 | @property 27 | def messages(self): 28 | """JSON API-formatted error representation.""" 29 | return { 30 | "errors": [{"detail": self.detail, "source": {"pointer": self.pointer}}] 31 | } 32 | -------------------------------------------------------------------------------- /marshmallow_jsonapi/fields.py: -------------------------------------------------------------------------------- 1 | """Includes all the fields classes from `marshmallow.fields` as well as 2 | fields for serializing JSON API-formatted hyperlinks. 3 | """ 4 | import collections.abc 5 | 6 | from marshmallow import ValidationError, class_registry 7 | from marshmallow.fields import Field 8 | 9 | # Make core fields importable from marshmallow_jsonapi 10 | from marshmallow.fields import * # noqa 11 | from marshmallow.base import SchemaABC 12 | from marshmallow.utils import is_collection, missing as missing_, get_value 13 | 14 | from .utils import resolve_params 15 | 16 | 17 | _RECURSIVE_NESTED = "self" 18 | # JSON API disallows U+005F LOW LINE at the start of a member name, so we can 19 | # use it to load the Meta type from since it can't clash with an attribute 20 | # named meta (which isn't disallowed by the spec). 21 | _DOCUMENT_META_LOAD_FROM = "_document_meta" 22 | _RESOURCE_META_LOAD_FROM = "_resource_meta" 23 | 24 | 25 | class BaseRelationship(Field): 26 | """Base relationship field. 27 | 28 | This is used by `marshmallow_jsonapi.Schema` to determine which 29 | fields should be formatted as relationship objects. 30 | 31 | See: http://jsonapi.org/format/#document-resource-object-relationships 32 | """ 33 | 34 | pass 35 | 36 | 37 | def _stringify(value): 38 | if value is not None: 39 | return str(value) 40 | return value 41 | 42 | 43 | class Relationship(BaseRelationship): 44 | """Framework-independent field which serializes to a "relationship object". 45 | 46 | See: http://jsonapi.org/format/#document-resource-object-relationships 47 | 48 | Examples: :: 49 | 50 | author = Relationship( 51 | related_url='/authors/{author_id}', 52 | related_url_kwargs={'author_id': ''}, 53 | ) 54 | 55 | comments = Relationship( 56 | related_url='/posts/{post_id}/comments/', 57 | related_url_kwargs={'post_id': ''}, 58 | many=True, include_resource_linkage=True, 59 | type_='comments' 60 | ) 61 | 62 | This field is read-only by default. 63 | 64 | :param str related_url: Format string for related resource links. 65 | :param dict related_url_kwargs: Replacement fields for `related_url`. String arguments 66 | enclosed in `< >` will be interpreted as attributes to pull from the target object. 67 | :param str self_url: Format string for self relationship links. 68 | :param dict self_url_kwargs: Replacement fields for `self_url`. String arguments 69 | enclosed in `< >` will be interpreted as attributes to pull from the target object. 70 | :param bool include_resource_linkage: Whether to include a resource linkage 71 | (http://jsonapi.org/format/#document-resource-object-linkage) in the serialized result. 72 | :param marshmallow_jsonapi.Schema schema: The schema to render the included data with. 73 | :param bool many: Whether the relationship represents a many-to-one or many-to-many 74 | relationship. Only affects serialization of the resource linkage. 75 | :param str type_: The type of resource. 76 | :param str id_field: Attribute name to pull ids from if a resource linkage is included. 77 | """ 78 | 79 | default_id_field = "id" 80 | 81 | def __init__( 82 | self, 83 | related_url="", 84 | related_url_kwargs=None, 85 | *, 86 | self_url="", 87 | self_url_kwargs=None, 88 | include_resource_linkage=False, 89 | schema=None, 90 | many=False, 91 | type_=None, 92 | id_field=None, 93 | **kwargs 94 | ): 95 | self.related_url = related_url 96 | self.related_url_kwargs = related_url_kwargs or {} 97 | self.self_url = self_url 98 | self.self_url_kwargs = self_url_kwargs or {} 99 | if include_resource_linkage and not type_: 100 | raise ValueError( 101 | "include_resource_linkage=True requires the type_ argument." 102 | ) 103 | self.many = many 104 | self.include_resource_linkage = include_resource_linkage 105 | self.include_data = False 106 | self.type_ = type_ 107 | self.__id_field = id_field 108 | self.__schema = schema 109 | super().__init__(**kwargs) 110 | 111 | @property 112 | def id_field(self): 113 | if self.__id_field: 114 | return self.__id_field 115 | if self.__schema: 116 | field = self.schema.fields["id"] 117 | return field.attribute or self.default_id_field 118 | else: 119 | return self.default_id_field 120 | 121 | @property 122 | def schema(self): 123 | only = getattr(self, "only", None) 124 | exclude = getattr(self, "exclude", ()) 125 | context = getattr(self, "context", {}) 126 | 127 | if isinstance(self.__schema, SchemaABC): 128 | return self.__schema 129 | if isinstance(self.__schema, type) and issubclass(self.__schema, SchemaABC): 130 | self.__schema = self.__schema(only=only, exclude=exclude, context=context) 131 | return self.__schema 132 | if isinstance(self.__schema, (str, bytes)): 133 | if self.__schema == _RECURSIVE_NESTED: 134 | parent_class = self.parent.__class__ 135 | self.__schema = parent_class( 136 | only=only, 137 | exclude=exclude, 138 | context=context, 139 | include_data=self.parent.include_data, 140 | ) 141 | else: 142 | schema_class = class_registry.get_class(self.__schema) 143 | self.__schema = schema_class( 144 | only=only, exclude=exclude, context=context 145 | ) 146 | return self.__schema 147 | else: 148 | raise ValueError( 149 | "A Schema is required to serialize a nested " 150 | "relationship with include_data" 151 | ) 152 | 153 | def get_related_url(self, obj): 154 | if self.related_url: 155 | params = resolve_params(obj, self.related_url_kwargs, default=self.default) 156 | non_null_params = { 157 | key: value for key, value in params.items() if value is not None 158 | } 159 | if non_null_params: 160 | return self.related_url.format(**non_null_params) 161 | return None 162 | 163 | def get_self_url(self, obj): 164 | if self.self_url: 165 | params = resolve_params(obj, self.self_url_kwargs, default=self.default) 166 | non_null_params = { 167 | key: value for key, value in params.items() if value is not None 168 | } 169 | if non_null_params: 170 | return self.self_url.format(**non_null_params) 171 | return None 172 | 173 | def get_resource_linkage(self, value): 174 | if self.many: 175 | resource_object = [ 176 | {"type": self.type_, "id": _stringify(self._get_id(each))} 177 | for each in value 178 | ] 179 | else: 180 | resource_object = { 181 | "type": self.type_, 182 | "id": _stringify(self._get_id(value)), 183 | } 184 | return resource_object 185 | 186 | def extract_value(self, data): 187 | """Extract the id key and validate the request structure.""" 188 | errors = [] 189 | if "id" not in data: 190 | errors.append("Must have an `id` field") 191 | if "type" not in data: 192 | errors.append("Must have a `type` field") 193 | elif data["type"] != self.type_: 194 | errors.append("Invalid `type` specified") 195 | 196 | if errors: 197 | raise ValidationError(errors) 198 | 199 | # If ``attributes`` is set, we've folded included data into this 200 | # relationship. Unserialize it if we have a schema set; otherwise we 201 | # fall back below to old behaviour of only IDs. 202 | if "attributes" in data and self.__schema: 203 | result = self.schema.load( 204 | {"data": data, "included": self.root.included_data} 205 | ) 206 | return result 207 | 208 | id_value = data.get("id") 209 | 210 | if self.__schema: 211 | id_value = self.schema.fields["id"].deserialize(id_value) 212 | 213 | return id_value 214 | 215 | def deserialize(self, value, attr=None, data=None, **kwargs): 216 | """Deserialize ``value``. 217 | 218 | :raise ValidationError: If the value is not type `dict`, if the 219 | value does not contain a `data` key, and if the value is 220 | required but unspecified. 221 | """ 222 | if value is missing_: 223 | return super().deserialize(value, attr, data) 224 | if not isinstance(value, dict) or "data" not in value: 225 | # a relationships object does not need 'data' if 'links' is present 226 | if value and "links" in value: 227 | return missing_ 228 | else: 229 | raise ValidationError("Must include a `data` key") 230 | return super().deserialize(value["data"], attr, data, **kwargs) 231 | 232 | def _deserialize(self, value, attr, obj, **kwargs): 233 | if self.many: 234 | if not is_collection(value): 235 | raise ValidationError("Relationship is list-like") 236 | return [self.extract_value(item) for item in value] 237 | 238 | if is_collection(value): 239 | raise ValidationError("Relationship is not list-like") 240 | return self.extract_value(value) 241 | 242 | # We have to override serialize because we don't want those fields 243 | # to be serialized which are related to the resource but not included 244 | # in the request. And we don't have enough control in _serialize 245 | # to prevent their serialization 246 | def serialize(self, attr, obj, accessor=None): 247 | if obj is None or self.include_resource_linkage or self.include_data: 248 | return super().serialize(attr, obj, accessor) 249 | return self._serialize(None, attr, obj) 250 | 251 | def _serialize(self, value, attr, obj): 252 | dict_class = self.parent.dict_class if self.parent else dict 253 | 254 | ret = dict_class() 255 | self_url = self.get_self_url(obj) 256 | related_url = self.get_related_url(obj) 257 | if self_url or related_url: 258 | ret["links"] = dict_class() 259 | if self_url: 260 | ret["links"]["self"] = self_url 261 | if related_url: 262 | ret["links"]["related"] = related_url 263 | 264 | # resource linkage is required when including the data 265 | if self.include_resource_linkage or self.include_data: 266 | if value is None: 267 | ret["data"] = [] if self.many else None 268 | else: 269 | ret["data"] = self.get_resource_linkage(value) 270 | 271 | if self.include_data and value is not None: 272 | if self.many: 273 | for item in value: 274 | self._serialize_included(item) 275 | else: 276 | self._serialize_included(value) 277 | return ret 278 | 279 | def _serialize_included(self, value): 280 | result = self.schema.dump(value) 281 | item = result["data"] 282 | self.root.included_data[(item["type"], item["id"])] = item 283 | for key, value in self.schema.included_data.items(): 284 | self.root.included_data[key] = value 285 | 286 | def _get_id(self, value): 287 | if self.__schema: 288 | return self.schema.get_attribute(value, self.id_field, value) 289 | else: 290 | return get_value(value, self.id_field, value) 291 | 292 | 293 | class DocumentMeta(Field): 294 | """Field which serializes to a "meta object" within a document’s “top level”. 295 | 296 | Examples: :: 297 | 298 | from marshmallow_jsonapi import Schema, fields 299 | 300 | class UserSchema(Schema): 301 | id = fields.String() 302 | metadata = fields.DocumentMeta() 303 | 304 | class Meta: 305 | type_ = 'product' 306 | 307 | See: http://jsonapi.org/format/#document-meta 308 | """ 309 | 310 | default_error_messages = {"invalid": "Not a valid mapping type."} 311 | 312 | def __init__(self, **kwargs): 313 | super().__init__(**kwargs) 314 | self.data_key = _DOCUMENT_META_LOAD_FROM 315 | 316 | def _deserialize(self, value, attr, data, **kwargs): 317 | if isinstance(value, collections.abc.Mapping): 318 | return value 319 | else: 320 | raise self.make_error("invalid") 321 | 322 | def _serialize(self, value, *args, **kwargs): 323 | if isinstance(value, collections.abc.Mapping): 324 | return super()._serialize(value, *args, **kwargs) 325 | else: 326 | raise self.make_error("invalid") 327 | 328 | 329 | class ResourceMeta(Field): 330 | """Field which serializes to a "meta object" within a "resource object". 331 | 332 | Examples: :: 333 | 334 | from marshmallow_jsonapi import Schema, fields 335 | 336 | class UserSchema(Schema): 337 | id = fields.String() 338 | meta_resource = fields.ResourceMeta() 339 | 340 | class Meta: 341 | type_ = 'product' 342 | 343 | See: http://jsonapi.org/format/#document-resource-objects 344 | """ 345 | 346 | default_error_messages = {"invalid": "Not a valid mapping type."} 347 | 348 | def __init__(self, **kwargs): 349 | super().__init__(**kwargs) 350 | self.data_key = _RESOURCE_META_LOAD_FROM 351 | 352 | def _deserialize(self, value, attr, data, **kwargs): 353 | if isinstance(value, collections.abc.Mapping): 354 | return value 355 | else: 356 | raise self.make_error("invalid") 357 | 358 | def _serialize(self, value, *args, **kwargs): 359 | if isinstance(value, collections.abc.Mapping): 360 | return super()._serialize(value, *args, **kwargs) 361 | else: 362 | raise self.make_error("invalid") 363 | -------------------------------------------------------------------------------- /marshmallow_jsonapi/flask.py: -------------------------------------------------------------------------------- 1 | """Flask integration that avoids the need to hard-code URLs for links. 2 | 3 | This includes a Flask-specific schema with custom Meta options and a 4 | relationship field for linking to related resources. 5 | """ 6 | import flask 7 | from werkzeug.routing import BuildError 8 | 9 | from .fields import Relationship as GenericRelationship 10 | from .schema import Schema as DefaultSchema, SchemaOpts as DefaultOpts 11 | from .utils import resolve_params 12 | 13 | 14 | class SchemaOpts(DefaultOpts): 15 | """Options to use Flask view names instead of hard coding URLs.""" 16 | 17 | def __init__(self, meta, *args, **kwargs): 18 | if getattr(meta, "self_url", None): 19 | raise ValueError( 20 | "Use `self_view` instead of `self_url` " "using the Flask extension." 21 | ) 22 | if getattr(meta, "self_url_kwargs", None): 23 | raise ValueError( 24 | "Use `self_view_kwargs` instead of `self_url_kwargs` " 25 | "when using the Flask extension." 26 | ) 27 | if getattr(meta, "self_url_many", None): 28 | raise ValueError( 29 | "Use `self_view_many` instead of `self_url_many` " 30 | "when using the Flask extension." 31 | ) 32 | 33 | if getattr(meta, "self_view_kwargs", None) and not getattr( 34 | meta, "self_view", None 35 | ): 36 | raise ValueError( 37 | "Must specify `self_view` Meta option when " 38 | "`self_view_kwargs` is specified." 39 | ) 40 | 41 | # Transfer Flask options to URL options, to piggy-back on its handling 42 | meta.self_url = getattr(meta, "self_view", None) 43 | meta.self_url_kwargs = getattr(meta, "self_view_kwargs", None) 44 | meta.self_url_many = getattr(meta, "self_view_many", None) 45 | 46 | super().__init__(meta, *args, **kwargs) 47 | 48 | 49 | class Schema(DefaultSchema): 50 | """A Flask specific schema that resolves self URLs from view names.""" 51 | 52 | OPTIONS_CLASS = SchemaOpts 53 | 54 | class Meta: 55 | """Options object that takes the same options as `marshmallow-jsonapi.Schema`, 56 | but instead of ``self_url``, ``self_url_kwargs`` and ``self_url_many`` 57 | has the following options to resolve the URLs from Flask views: 58 | 59 | * ``self_view`` - View name to resolve the self URL link from. 60 | * ``self_view_kwargs`` - Replacement fields for ``self_view``. String 61 | attributes enclosed in ``< >`` will be interpreted as attributes to 62 | pull from the schema data. 63 | * ``self_view_many`` - View name to resolve the self URL link when a 64 | collection of resources is returned. 65 | """ 66 | 67 | pass 68 | 69 | def generate_url(self, view_name, **kwargs): 70 | """Generate URL with any kwargs interpolated.""" 71 | return flask.url_for(view_name, **kwargs) if view_name else None 72 | 73 | 74 | class Relationship(GenericRelationship): 75 | r"""Field which serializes to a "relationship object" 76 | with a "related resource link". 77 | 78 | See: http://jsonapi.org/format/#document-resource-object-relationships 79 | 80 | Examples: :: 81 | 82 | author = Relationship( 83 | related_view='author_detail', 84 | related_view_kwargs={'author_id': ''}, 85 | ) 86 | 87 | comments = Relationship( 88 | related_view='posts_comments', 89 | related_view_kwargs={'post_id': ''}, 90 | many=True, include_resource_linkage=True, 91 | type_='comments' 92 | ) 93 | 94 | This field is read-only by default. 95 | 96 | :param str related_view: View name for related resource link. 97 | :param dict related_view_kwargs: Path kwargs fields for `related_view`. String arguments 98 | enclosed in `< >` will be interpreted as attributes to pull from the target object. 99 | :param str self_view: View name for self relationship link. 100 | :param dict self_view_kwargs: Path kwargs for `self_view`. String arguments 101 | enclosed in `< >` will be interpreted as attributes to pull from the target object. 102 | :param \*\*kwargs: Same keyword arguments as `marshmallow_jsonapi.fields.Relationship`. 103 | """ 104 | 105 | def __init__( 106 | self, 107 | related_view=None, 108 | related_view_kwargs=None, 109 | *, 110 | self_view=None, 111 | self_view_kwargs=None, 112 | **kwargs 113 | ): 114 | self.related_view = related_view 115 | self.related_view_kwargs = related_view_kwargs or {} 116 | self.self_view = self_view 117 | self.self_view_kwargs = self_view_kwargs or {} 118 | super().__init__(**kwargs) 119 | 120 | def get_url(self, obj, view_name, view_kwargs): 121 | if view_name: 122 | kwargs = resolve_params(obj, view_kwargs, default=self.default) 123 | kwargs["endpoint"] = view_name 124 | try: 125 | return flask.url_for(**kwargs) 126 | except BuildError: 127 | if ( 128 | None in kwargs.values() 129 | ): # most likely to be caused by empty relationship 130 | return None 131 | raise 132 | return None 133 | 134 | def get_related_url(self, obj): 135 | return self.get_url(obj, self.related_view, self.related_view_kwargs) 136 | 137 | def get_self_url(self, obj): 138 | return self.get_url(obj, self.self_view, self.self_view_kwargs) 139 | -------------------------------------------------------------------------------- /marshmallow_jsonapi/schema.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | import marshmallow as ma 4 | from marshmallow.exceptions import ValidationError 5 | from marshmallow.utils import is_collection 6 | 7 | from .fields import BaseRelationship, DocumentMeta, ResourceMeta 8 | from .fields import _RESOURCE_META_LOAD_FROM, _DOCUMENT_META_LOAD_FROM 9 | from .exceptions import IncorrectTypeError 10 | from .utils import resolve_params 11 | 12 | TYPE = "type" 13 | ID = "id" 14 | 15 | 16 | class SchemaOpts(ma.SchemaOpts): 17 | def __init__(self, meta, *args, **kwargs): 18 | super().__init__(meta, *args, **kwargs) 19 | self.type_ = getattr(meta, "type_", None) 20 | self.inflect = getattr(meta, "inflect", None) 21 | self.self_url = getattr(meta, "self_url", None) 22 | self.self_url_kwargs = getattr(meta, "self_url_kwargs", None) 23 | self.self_url_many = getattr(meta, "self_url_many", None) 24 | 25 | 26 | class Schema(ma.Schema): 27 | """Schema class that formats data according to JSON API 1.0. 28 | Must define the ``type_`` `class Meta` option. 29 | 30 | Example: :: 31 | 32 | from marshmallow_jsonapi import Schema, fields 33 | 34 | def dasherize(text): 35 | return text.replace('_', '-') 36 | 37 | class PostSchema(Schema): 38 | id = fields.Str(dump_only=True) # Required 39 | title = fields.Str() 40 | 41 | author = fields.HyperlinkRelated( 42 | '/authors/{author_id}', 43 | url_kwargs={'author_id': ''}, 44 | ) 45 | 46 | comments = fields.HyperlinkRelated( 47 | '/posts/{post_id}/comments', 48 | url_kwargs={'post_id': ''}, 49 | # Include resource linkage 50 | many=True, include_resource_linkage=True, 51 | type_='comments' 52 | ) 53 | 54 | class Meta: 55 | type_ = 'posts' # Required 56 | inflect = dasherize 57 | 58 | """ 59 | 60 | class Meta: 61 | """Options object for `Schema`. Takes the same options as `marshmallow.Schema.Meta` with 62 | the addition of: 63 | 64 | * ``type_`` - required, the JSON API resource type as a string. 65 | * ``inflect`` - optional, an inflection function to modify attribute names. 66 | * ``self_url`` - optional, URL to use to `self` in links 67 | * ``self_url_kwargs`` - optional, replacement fields for `self_url`. 68 | String arguments enclosed in ``< >`` will be interpreted as attributes 69 | to pull from the schema data. 70 | * ``self_url_many`` - optional, URL to use to `self` in top-level ``links`` 71 | when a collection of resources is returned. 72 | """ 73 | 74 | pass 75 | 76 | def __init__(self, *args, **kwargs): 77 | self.include_data = kwargs.pop("include_data", ()) 78 | super().__init__(*args, **kwargs) 79 | if self.include_data: 80 | self.check_relations(self.include_data) 81 | 82 | if not self.opts.type_: 83 | raise ValueError("Must specify type_ class Meta option") 84 | 85 | if "id" not in self.fields: 86 | raise ValueError("Must have an `id` field") 87 | 88 | if self.opts.self_url_kwargs and not self.opts.self_url: 89 | raise ValueError( 90 | "Must specify `self_url` Meta option when " 91 | "`self_url_kwargs` is specified" 92 | ) 93 | self.included_data = {} 94 | self.document_meta = {} 95 | 96 | OPTIONS_CLASS = SchemaOpts 97 | 98 | def check_relations(self, relations): 99 | """Recursive function which checks if a relation is valid.""" 100 | for rel in relations: 101 | if not rel: 102 | continue 103 | fields = rel.split(".", 1) 104 | 105 | local_field = fields[0] 106 | if local_field not in self.fields: 107 | raise ValueError(f'Unknown field "{local_field}"') 108 | 109 | field = self.fields[local_field] 110 | if not isinstance(field, BaseRelationship): 111 | raise ValueError( 112 | 'Can only include relationships. "{}" is a "{}"'.format( 113 | field.name, field.__class__.__name__ 114 | ) 115 | ) 116 | 117 | field.include_data = True 118 | if len(fields) > 1: 119 | field.schema.check_relations(fields[1:]) 120 | 121 | @ma.post_dump(pass_many=True) 122 | def format_json_api_response(self, data, many, **kwargs): 123 | """Post-dump hook that formats serialized data as a top-level JSON API object. 124 | 125 | See: http://jsonapi.org/format/#document-top-level 126 | """ 127 | ret = self.format_items(data, many) 128 | ret = self.wrap_response(ret, many) 129 | ret = self.render_included_data(ret) 130 | ret = self.render_meta_document(ret) 131 | return ret 132 | 133 | def render_included_data(self, data): 134 | if not self.included_data: 135 | return data 136 | data["included"] = list(self.included_data.values()) 137 | return data 138 | 139 | def render_meta_document(self, data): 140 | if not self.document_meta: 141 | return data 142 | data["meta"] = self.document_meta 143 | return data 144 | 145 | def unwrap_item(self, item): 146 | if "type" not in item: 147 | raise ma.ValidationError( 148 | [ 149 | { 150 | "detail": "`data` object must include `type` key.", 151 | "source": {"pointer": "/data"}, 152 | } 153 | ] 154 | ) 155 | if item["type"] != self.opts.type_: 156 | raise IncorrectTypeError(actual=item["type"], expected=self.opts.type_) 157 | 158 | payload = self.dict_class() 159 | if "id" in item: 160 | payload["id"] = item["id"] 161 | if "meta" in item: 162 | payload[_RESOURCE_META_LOAD_FROM] = item["meta"] 163 | if self.document_meta: 164 | payload[_DOCUMENT_META_LOAD_FROM] = self.document_meta 165 | for key, value in item.get("attributes", {}).items(): 166 | payload[key] = value 167 | for key, value in item.get("relationships", {}).items(): 168 | # Fold included data related to this relationship into the item, so 169 | # that we can deserialize the whole objects instead of just IDs. 170 | if self.included_data: 171 | included_data = [] 172 | inner_data = value.get("data", []) 173 | 174 | # Data may be ``None`` (for empty relationships), but we only 175 | # need to process it when it's present. 176 | if inner_data: 177 | if not is_collection(inner_data): 178 | included_data = next( 179 | self._extract_from_included(inner_data), None 180 | ) 181 | else: 182 | for data in inner_data: 183 | included_data.extend(self._extract_from_included(data)) 184 | 185 | if included_data: 186 | value["data"] = included_data 187 | 188 | payload[key] = value 189 | return payload 190 | 191 | @ma.pre_load(pass_many=True) 192 | def unwrap_request(self, data, many, **kwargs): 193 | if "data" not in data: 194 | raise ma.ValidationError( 195 | [ 196 | { 197 | "detail": "Object must include `data` key.", 198 | "source": {"pointer": "/"}, 199 | } 200 | ] 201 | ) 202 | 203 | data = data["data"] 204 | if many: 205 | if not is_collection(data): 206 | raise ma.ValidationError( 207 | [ 208 | { 209 | "detail": "`data` expected to be a collection.", 210 | "source": {"pointer": "/data"}, 211 | } 212 | ] 213 | ) 214 | return [self.unwrap_item(each) for each in data] 215 | return self.unwrap_item(data) 216 | 217 | def on_bind_field(self, field_name, field_obj): 218 | """Schema hook override. When binding fields, set ``data_key`` to the inflected form of field_name.""" 219 | if not field_obj.data_key: 220 | field_obj.data_key = self.inflect(field_name) 221 | return None 222 | 223 | def _do_load(self, data, many=None, **kwargs): 224 | """Override `marshmallow.Schema._do_load` for custom JSON API handling. 225 | 226 | Specifically, we do this to format errors as JSON API Error objects, 227 | and to support loading of included data. 228 | """ 229 | many = self.many if many is None else bool(many) 230 | 231 | # Store this on the instance so we have access to the included data 232 | # when processing relationships (``included`` is outside of the 233 | # ``data``). 234 | self.included_data = data.get("included", {}) 235 | self.document_meta = data.get("meta", {}) 236 | 237 | try: 238 | result = super()._do_load(data, many=many, **kwargs) 239 | except ValidationError as err: # strict mode 240 | error_messages = err.messages 241 | if "_schema" in error_messages: 242 | error_messages = error_messages["_schema"] 243 | formatted_messages = self.format_errors(error_messages, many=many) 244 | err.messages = formatted_messages 245 | raise err 246 | return result 247 | 248 | def _extract_from_included(self, data): 249 | """Extract included data matching the items in ``data``. 250 | 251 | For each item in ``data``, extract the full data from the included 252 | data. 253 | """ 254 | return ( 255 | item 256 | for item in self.included_data 257 | if item["type"] == data["type"] and str(item["id"]) == str(data["id"]) 258 | ) 259 | 260 | def inflect(self, text): 261 | """Inflect ``text`` if the ``inflect`` class Meta option is defined, otherwise 262 | do nothing. 263 | """ 264 | return self.opts.inflect(text) if self.opts.inflect else text 265 | 266 | ### Overridable hooks ### 267 | 268 | def format_errors(self, errors, many): 269 | """Format validation errors as JSON Error objects.""" 270 | if not errors: 271 | return {} 272 | if isinstance(errors, (list, tuple)): 273 | return {"errors": errors} 274 | 275 | formatted_errors = [] 276 | if many: 277 | for index, i_errors in errors.items(): 278 | formatted_errors.extend(self._get_formatted_errors(i_errors, index)) 279 | else: 280 | formatted_errors.extend(self._get_formatted_errors(errors)) 281 | 282 | return {"errors": formatted_errors} 283 | 284 | def _get_formatted_errors(self, errors, index=None): 285 | return itertools.chain( 286 | *( 287 | [ 288 | self.format_error(field_name, message, index=index) 289 | for message in field_errors 290 | ] 291 | for field_name, field_errors in itertools.chain( 292 | *(self._process_nested_errors(k, v) for k, v in errors.items()) 293 | ) 294 | ) 295 | ) 296 | 297 | def _process_nested_errors(self, name, data): 298 | if not isinstance(data, dict): 299 | return [(name, data)] 300 | 301 | return itertools.chain( 302 | *(self._process_nested_errors(f"{name}/{k}", v) for k, v in data.items()) 303 | ) 304 | 305 | def format_error(self, field_name, message, index=None): 306 | """Override-able hook to format a single error message as an Error object. 307 | 308 | See: http://jsonapi.org/format/#error-objects 309 | """ 310 | pointer = ["/data"] 311 | 312 | if index is not None: 313 | pointer.append(str(index)) 314 | 315 | relationship = isinstance( 316 | self.declared_fields.get(field_name), BaseRelationship 317 | ) 318 | if relationship: 319 | pointer.append("relationships") 320 | elif field_name != "id": 321 | # JSONAPI identifier is a special field that exists above the attribute object. 322 | pointer.append("attributes") 323 | 324 | pointer.append(self.inflect(field_name)) 325 | 326 | if relationship: 327 | pointer.append("data") 328 | 329 | return {"detail": message, "source": {"pointer": "/".join(pointer)}} 330 | 331 | def format_item(self, item): 332 | """Format a single datum as a Resource object. 333 | 334 | See: http://jsonapi.org/format/#document-resource-objects 335 | """ 336 | # http://jsonapi.org/format/#document-top-level 337 | # Primary data MUST be either... a single resource object, a single resource 338 | # identifier object, or null, for requests that target single resources 339 | if not item: 340 | return None 341 | 342 | ret = self.dict_class() 343 | ret[TYPE] = self.opts.type_ 344 | 345 | # Get the schema attributes so we can confirm `dump-to` values exist 346 | attributes = { 347 | (self.fields[field].data_key or field): field for field in self.fields 348 | } 349 | 350 | for field_name, value in item.items(): 351 | attribute = attributes[field_name] 352 | if attribute == ID: 353 | ret[ID] = value 354 | elif isinstance(self.fields[attribute], DocumentMeta): 355 | if not self.document_meta: 356 | self.document_meta = self.dict_class() 357 | self.document_meta.update(value) 358 | elif isinstance(self.fields[attribute], ResourceMeta): 359 | if "meta" not in ret: 360 | ret["meta"] = self.dict_class() 361 | ret["meta"].update(value) 362 | elif isinstance(self.fields[attribute], BaseRelationship): 363 | if value: 364 | if "relationships" not in ret: 365 | ret["relationships"] = self.dict_class() 366 | ret["relationships"][self.inflect(field_name)] = value 367 | else: 368 | if "attributes" not in ret: 369 | ret["attributes"] = self.dict_class() 370 | ret["attributes"][self.inflect(field_name)] = value 371 | 372 | links = self.get_resource_links(item) 373 | if links: 374 | ret["links"] = links 375 | return ret 376 | 377 | def format_items(self, data, many): 378 | """Format data as a Resource object or list of Resource objects. 379 | 380 | See: http://jsonapi.org/format/#document-resource-objects 381 | """ 382 | if many: 383 | return [self.format_item(item) for item in data] 384 | else: 385 | return self.format_item(data) 386 | 387 | def get_top_level_links(self, data, many): 388 | """Hook for adding links to the root of the response data.""" 389 | self_link = None 390 | 391 | if many: 392 | if self.opts.self_url_many: 393 | self_link = self.generate_url(self.opts.self_url_many) 394 | else: 395 | if self.opts.self_url: 396 | self_link = data.get("links", {}).get("self", None) 397 | 398 | return {"self": self_link} 399 | 400 | def get_resource_links(self, item): 401 | """Hook for adding links to a resource object.""" 402 | if self.opts.self_url: 403 | ret = self.dict_class() 404 | kwargs = resolve_params(item, self.opts.self_url_kwargs or {}) 405 | ret["self"] = self.generate_url(self.opts.self_url, **kwargs) 406 | return ret 407 | return None 408 | 409 | def wrap_response(self, data, many): 410 | """Wrap data and links according to the JSON API""" 411 | ret = {"data": data} 412 | # self_url_many is still valid when there isn't any data, but self_url 413 | # may only be included if there is data in the ret 414 | if many or data: 415 | top_level_links = self.get_top_level_links(data, many) 416 | if top_level_links["self"]: 417 | ret["links"] = top_level_links 418 | return ret 419 | 420 | def generate_url(self, link, **kwargs): 421 | """Generate URL with any kwargs interpolated.""" 422 | return link.format_map(kwargs) if link else None 423 | -------------------------------------------------------------------------------- /marshmallow_jsonapi/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions. 2 | 3 | This module should be considered private API. 4 | """ 5 | import re 6 | 7 | from marshmallow.utils import get_value, missing 8 | 9 | 10 | _tpl_pattern = re.compile(r"\s*<\s*(\S*)\s*>\s*") 11 | 12 | 13 | def tpl(val): 14 | """Return value within ``< >`` if possible, else return ``None``.""" 15 | match = _tpl_pattern.match(val) 16 | if match: 17 | return match.groups()[0] 18 | return None 19 | 20 | 21 | def resolve_params(obj, params, default=missing): 22 | """Given a dictionary of keyword arguments, return the same dictionary except with 23 | values enclosed in `< >` resolved to attributes on `obj`. 24 | """ 25 | param_values = {} 26 | for name, attr_tpl in params.items(): 27 | attr_name = tpl(str(attr_tpl)) 28 | if attr_name: 29 | attribute_value = get_value(obj, attr_name, default=default) 30 | if attribute_value is not missing: 31 | param_values[name] = attribute_value 32 | else: 33 | raise AttributeError( 34 | "{attr_name!r} is not a valid " 35 | "attribute of {obj!r}".format(attr_name=attr_name, obj=obj) 36 | ) 37 | else: 38 | param_values[name] = attr_tpl 39 | return param_values 40 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_files = LICENSE 3 | 4 | [bdist_wheel] 5 | # This flag says that the code is written to work on both Python 2 and Python 6 | # 3. If at all possible, it is good practice to do this. If you cannot, you 7 | # will need to generate wheels for each Python version that you support. 8 | universal=1 9 | 10 | [flake8] 11 | ignore = E203, E266, E501, W503 12 | max-line-length = 110 13 | max-complexity = 18 14 | select = B,C,E,F,W,T4,B9 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from setuptools import setup, find_packages 3 | 4 | INSTALL_REQUIRES = ("marshmallow>=2.15.2",) 5 | EXTRAS_REQUIRE = { 6 | "tests": ["pytest", "mock", "faker==4.18.0", "Flask==1.1.2"], 7 | "lint": ["flake8==3.9.0", "flake8-bugbear==20.11.1", "pre-commit~=2.0"], 8 | } 9 | EXTRAS_REQUIRE["dev"] = EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["lint"] + ["tox"] 10 | 11 | 12 | def find_version(fname): 13 | """Attempts to find the version number in the file names fname. 14 | Raises RuntimeError if not found. 15 | """ 16 | version = "" 17 | with open(fname) as fp: 18 | reg = re.compile(r'__version__ = [\'"]([^\'"]*)[\'"]') 19 | for line in fp: 20 | m = reg.match(line) 21 | if m: 22 | version = m.group(1) 23 | break 24 | if not version: 25 | raise RuntimeError("Cannot find version information") 26 | return version 27 | 28 | 29 | def read(fname): 30 | with open(fname) as fp: 31 | content = fp.read() 32 | return content 33 | 34 | 35 | setup( 36 | name="marshmallow-jsonapi", 37 | version=find_version("marshmallow_jsonapi/__init__.py"), 38 | description="JSON API 1.0 (https://jsonapi.org) formatting with marshmallow", 39 | long_description=read("README.rst"), 40 | author="Steven Loria", 41 | author_email="sloria1@gmail.com", 42 | url="https://github.com/marshmallow-code/marshmallow-jsonapi", 43 | packages=find_packages(exclude=("test*",)), 44 | package_dir={"marshmallow-jsonapi": "marshmallow-jsonapi"}, 45 | include_package_data=True, 46 | install_requires=INSTALL_REQUIRES, 47 | extras_require=EXTRAS_REQUIRE, 48 | python_requires=">=3.6", 49 | license="MIT", 50 | zip_safe=False, 51 | keywords=( 52 | "marshmallow-jsonapi marshmallow marshalling serialization " 53 | "jsonapi deserialization validation" 54 | ), 55 | classifiers=[ 56 | "Intended Audience :: Developers", 57 | "License :: OSI Approved :: MIT License", 58 | "Natural Language :: English", 59 | "Programming Language :: Python :: 3", 60 | "Programming Language :: Python :: 3.6", 61 | "Programming Language :: Python :: 3.7", 62 | "Programming Language :: Python :: 3.8", 63 | "Programming Language :: Python :: 3.9", 64 | ], 65 | test_suite="tests", 66 | project_urls={ 67 | "Bug Reports": "https://github.com/marshmallow-code/marshmallow-jsonapi/issues", 68 | "Funding": "https://opencollective.com/marshmallow", 69 | }, 70 | ) 71 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marshmallow-code/marshmallow-jsonapi/e7273b4c3e6b7bad6279302caef2a87c96fbb785/tests/__init__.py -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | from hashlib import md5 2 | 3 | from faker import Factory 4 | from marshmallow import validate 5 | 6 | from marshmallow_jsonapi import Schema, fields 7 | 8 | fake = Factory.create() 9 | 10 | 11 | class Bunch: 12 | def __init__(self, **kwargs): 13 | for key, val in kwargs.items(): 14 | setattr(self, key, val) 15 | 16 | 17 | class Post(Bunch): 18 | pass 19 | 20 | 21 | class Author(Bunch): 22 | pass 23 | 24 | 25 | class Comment(Bunch): 26 | pass 27 | 28 | 29 | class Keyword(Bunch): 30 | pass 31 | 32 | 33 | class AuthorSchema(Schema): 34 | id = fields.Str() 35 | first_name = fields.Str(required=True) 36 | last_name = fields.Str(required=True) 37 | password = fields.Str(load_only=True, validate=validate.Length(6)) 38 | twitter = fields.Str() 39 | 40 | def get_top_level_links(self, data, many): 41 | if many: 42 | self_link = "/authors/" 43 | else: 44 | self_link = "/authors/{}".format(data["id"]) 45 | return {"self": self_link} 46 | 47 | class Meta: 48 | type_ = "people" 49 | 50 | 51 | class KeywordSchema(Schema): 52 | id = fields.Str() 53 | keyword = fields.Str(required=True) 54 | 55 | def get_attribute(self, attr, obj, default): 56 | if obj == "id": 57 | return md5( 58 | super(Schema, self) 59 | .get_attribute(attr, "keyword", default) 60 | .encode("utf-8") 61 | ).hexdigest() 62 | else: 63 | return super(Schema, self).get_attribute(attr, obj, default) 64 | 65 | class Meta: 66 | type_ = "keywords" 67 | strict = True 68 | 69 | 70 | class CommentSchema(Schema): 71 | id = fields.Str() 72 | body = fields.Str(required=True) 73 | 74 | author = fields.Relationship( 75 | "http://test.test/comments/{id}/author/", 76 | related_url_kwargs={"id": ""}, 77 | schema=AuthorSchema, 78 | many=False, 79 | ) 80 | 81 | class Meta: 82 | type_ = "comments" 83 | strict = True 84 | 85 | 86 | class ArticleSchema(Schema): 87 | id = fields.Integer() 88 | body = fields.String() 89 | author = fields.Relationship( 90 | dump_only=False, include_resource_linkage=True, many=False, type_="people" 91 | ) 92 | comments = fields.Relationship( 93 | dump_only=False, include_resource_linkage=True, many=True, type_="comments" 94 | ) 95 | 96 | class Meta: 97 | type_ = "articles" 98 | strict = True 99 | 100 | 101 | class PostSchema(Schema): 102 | id = fields.Str() 103 | post_title = fields.Str(attribute="title", dump_to="title", data_key="title") 104 | 105 | author = fields.Relationship( 106 | "http://test.test/posts/{id}/author/", 107 | related_url_kwargs={"id": ""}, 108 | schema=AuthorSchema, 109 | many=False, 110 | type_="people", 111 | ) 112 | 113 | post_comments = fields.Relationship( 114 | "http://test.test/posts/{id}/comments/", 115 | related_url_kwargs={"id": ""}, 116 | attribute="comments", 117 | load_from="post-comments", 118 | dump_to="post-comments", 119 | data_key="post-comments", 120 | schema="CommentSchema", 121 | many=True, 122 | type_="comments", 123 | ) 124 | 125 | post_keywords = fields.Relationship( 126 | "http://test.test/posts/{id}/keywords/", 127 | related_url_kwargs={"id": ""}, 128 | attribute="keywords", 129 | dump_to="post-keywords", 130 | data_key="post-keywords", 131 | schema="KeywordSchema", 132 | many=True, 133 | type_="keywords", 134 | ) 135 | 136 | class Meta: 137 | type_ = "posts" 138 | strict = True 139 | 140 | 141 | class PolygonSchema(Schema): 142 | id = fields.Integer(as_string=True) 143 | sides = fields.Integer() 144 | # This is an attribute that uses the 'meta' key: /data/attributes/meta 145 | meta = fields.String() 146 | # This is the document's top level meta object: /meta 147 | document_meta = fields.DocumentMeta() 148 | # This is the resource object's meta object: /data/meta 149 | resource_meta = fields.ResourceMeta() 150 | 151 | class Meta: 152 | type_ = "shapes" 153 | strict = True 154 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.base import Author, Post, Comment, Keyword, fake 4 | 5 | 6 | def make_author(): 7 | return Author( 8 | id=fake.random_int(), 9 | first_name=fake.first_name(), 10 | last_name=fake.last_name(), 11 | twitter=fake.domain_word(), 12 | ) 13 | 14 | 15 | def make_post(with_comments=True, with_author=True, with_keywords=True): 16 | comments = [make_comment() for _ in range(2)] if with_comments else [] 17 | keywords = [make_keyword() for _ in range(3)] if with_keywords else [] 18 | author = make_author() if with_author else None 19 | return Post( 20 | id=fake.random_int(), 21 | title=fake.catch_phrase(), 22 | author=author, 23 | author_id=author.id if with_author else None, 24 | comments=comments, 25 | keywords=keywords, 26 | ) 27 | 28 | 29 | def make_comment(with_author=True): 30 | author = make_author() if with_author else None 31 | return Comment(id=fake.random_int(), body=fake.bs(), author=author) 32 | 33 | 34 | def make_keyword(): 35 | return Keyword(keyword=fake.domain_word()) 36 | 37 | 38 | @pytest.fixture() 39 | def author(): 40 | return make_author() 41 | 42 | 43 | @pytest.fixture() 44 | def authors(): 45 | return [make_author() for _ in range(3)] 46 | 47 | 48 | @pytest.fixture() 49 | def comments(): 50 | return [make_comment() for _ in range(3)] 51 | 52 | 53 | @pytest.fixture() 54 | def post(): 55 | return make_post() 56 | 57 | 58 | @pytest.fixture() 59 | def post_with_null_comment(): 60 | return make_post(with_comments=False) 61 | 62 | 63 | @pytest.fixture() 64 | def post_with_null_author(): 65 | return make_post(with_author=False) 66 | 67 | 68 | @pytest.fixture() 69 | def posts(): 70 | return [make_post() for _ in range(3)] 71 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from hashlib import md5 4 | from marshmallow import ValidationError, missing as missing_ 5 | from marshmallow.fields import Int 6 | 7 | from marshmallow_jsonapi import Schema 8 | from marshmallow_jsonapi.fields import Str, DocumentMeta, ResourceMeta, Relationship 9 | 10 | 11 | class TestGenericRelationshipField: 12 | def test_serialize_relationship_link(self, post): 13 | field = Relationship( 14 | "http://example.com/posts/{id}/comments", related_url_kwargs={"id": ""} 15 | ) 16 | result = field.serialize("comments", post) 17 | assert field.serialize("comments", post) 18 | related = result["links"]["related"] 19 | assert related == f"http://example.com/posts/{post.id}/comments" 20 | 21 | def test_serialize_self_link(self, post): 22 | field = Relationship( 23 | self_url="http://example.com/posts/{id}/relationships/comments", 24 | self_url_kwargs={"id": ""}, 25 | ) 26 | result = field.serialize("comments", post) 27 | related = result["links"]["self"] 28 | assert "related" not in result["links"] 29 | assert related == "http://example.com/posts/{id}/relationships/comments".format( 30 | id=post.id 31 | ) 32 | 33 | def test_include_resource_linkage_requires_type(self): 34 | with pytest.raises(ValueError) as excinfo: 35 | Relationship( 36 | related_url="/posts/{post_id}", 37 | related_url_kwargs={"post_id": ""}, 38 | include_resource_linkage=True, 39 | ) 40 | assert ( 41 | excinfo.value.args[0] 42 | == "include_resource_linkage=True requires the type_ argument." 43 | ) 44 | 45 | def test_include_resource_linkage_single(self, post): 46 | field = Relationship( 47 | related_url="/posts/{post_id}/author/", 48 | related_url_kwargs={"post_id": ""}, 49 | include_resource_linkage=True, 50 | type_="people", 51 | ) 52 | result = field.serialize("author", post) 53 | assert "data" in result 54 | assert result["data"] 55 | assert result["data"]["id"] == str(post.author.id) 56 | 57 | def test_include_resource_linkage_single_with_schema(self, post): 58 | field = Relationship( 59 | related_url="/posts/{post_id}/author/", 60 | related_url_kwargs={"post_id": ""}, 61 | include_resource_linkage=True, 62 | type_="people", 63 | schema="PostSchema", 64 | ) 65 | result = field.serialize("author", post) 66 | assert "data" in result 67 | assert result["data"] 68 | assert result["data"]["id"] == str(post.author.id) 69 | 70 | def test_include_resource_linkage_single_foreign_key(self, post): 71 | field = Relationship( 72 | related_url="/posts/{post_id}/author/", 73 | related_url_kwargs={"post_id": ""}, 74 | include_resource_linkage=True, 75 | type_="people", 76 | ) 77 | result = field.serialize("author_id", post) 78 | assert result["data"]["id"] == str(post.author_id) 79 | 80 | def test_include_resource_linkage_single_foreign_key_with_schema(self, post): 81 | field = Relationship( 82 | related_url="/posts/{post_id}/author/", 83 | related_url_kwargs={"post_id": ""}, 84 | include_resource_linkage=True, 85 | type_="people", 86 | schema="PostSchema", 87 | ) 88 | result = field.serialize("author_id", post) 89 | assert result["data"]["id"] == str(post.author_id) 90 | 91 | def test_include_resource_linkage_id_field_from_string(self): 92 | field = Relationship( 93 | include_resource_linkage=True, type_="authors", id_field="name" 94 | ) 95 | result = field.serialize("author", {"author": {"name": "Ray Bradbury"}}) 96 | assert "data" in result 97 | assert result["data"] 98 | assert result["data"]["id"] == "Ray Bradbury" 99 | 100 | def test_include_resource_linkage_id_field_from_schema(self): 101 | class AuthorSchema(Schema): 102 | id = Str(attribute="name") 103 | 104 | class Meta: 105 | type_ = "authors" 106 | strict = True 107 | 108 | field = Relationship( 109 | include_resource_linkage=True, type_="authors", schema=AuthorSchema 110 | ) 111 | result = field.serialize("author", {"author": {"name": "Ray Bradbury"}}) 112 | assert "data" in result 113 | assert result["data"] 114 | assert result["data"]["id"] == "Ray Bradbury" 115 | 116 | def test_include_resource_linkage_many(self, post): 117 | field = Relationship( 118 | related_url="/posts/{post_id}/comments", 119 | related_url_kwargs={"post_id": ""}, 120 | many=True, 121 | include_resource_linkage=True, 122 | type_="comments", 123 | ) 124 | result = field.serialize("comments", post) 125 | assert "data" in result 126 | ids = [each["id"] for each in result["data"]] 127 | assert ids == [str(each.id) for each in post.comments] 128 | 129 | def test_include_resource_linkage_many_with_schema(self, post): 130 | field = Relationship( 131 | related_url="/posts/{post_id}/comments", 132 | related_url_kwargs={"post_id": ""}, 133 | many=True, 134 | include_resource_linkage=True, 135 | type_="comments", 136 | schema="CommentSchema", 137 | ) 138 | result = field.serialize("comments", post) 139 | assert "data" in result 140 | ids = [each["id"] for each in result["data"]] 141 | assert ids == [str(each.id) for each in post.comments] 142 | 143 | def test_include_resource_linkage_many_with_schema_overriding_get_attribute( 144 | self, post 145 | ): 146 | field = Relationship( 147 | related_url="/posts/{post_id}/keywords", 148 | related_url_kwargs={"post_id": ""}, 149 | many=True, 150 | include_resource_linkage=True, 151 | type_="keywords", 152 | schema="KeywordSchema", 153 | ) 154 | result = field.serialize("keywords", post) 155 | assert "data" in result 156 | ids = [each["id"] for each in result["data"]] 157 | assert ids == [ 158 | md5(each.keyword.encode("utf-8")).hexdigest() for each in post.keywords 159 | ] 160 | 161 | def test_deserialize_data_single(self): 162 | field = Relationship( 163 | related_url="/posts/{post_id}/comments", 164 | related_url_kwargs={"post_id": ""}, 165 | many=False, 166 | include_resource_linkage=True, 167 | type_="comments", 168 | ) 169 | value = {"data": {"type": "comments", "id": "1"}} 170 | result = field.deserialize(value) 171 | assert result == "1" 172 | 173 | def test_deserialize_data_many(self): 174 | field = Relationship( 175 | related_url="/posts/{post_id}/comments", 176 | related_url_kwargs={"post_id": ""}, 177 | many=True, 178 | include_resource_linkage=True, 179 | type_="comments", 180 | ) 181 | value = {"data": [{"type": "comments", "id": "1"}]} 182 | result = field.deserialize(value) 183 | assert result == ["1"] 184 | 185 | def test_deserialize_data_missing_id(self): 186 | field = Relationship( 187 | related_url="/posts/{post_id}/comments", 188 | related_url_kwargs={"post_id": ""}, 189 | many=False, 190 | include_resource_linkage=True, 191 | type_="comments", 192 | ) 193 | with pytest.raises(ValidationError) as excinfo: 194 | value = {"data": {"type": "comments"}} 195 | field.deserialize(value) 196 | assert excinfo.value.args[0] == ["Must have an `id` field"] 197 | 198 | def test_deserialize_data_missing_type(self): 199 | field = Relationship( 200 | related_url="/posts/{post_id}/comments", 201 | related_url_kwargs={"post_id": ""}, 202 | many=False, 203 | include_resource_linkage=True, 204 | type_="comments", 205 | ) 206 | with pytest.raises(ValidationError) as excinfo: 207 | value = {"data": {"id": "1"}} 208 | field.deserialize(value) 209 | assert excinfo.value.args[0] == ["Must have a `type` field"] 210 | 211 | def test_deserialize_data_incorrect_type(self): 212 | field = Relationship( 213 | related_url="/posts/{post_id}/comments", 214 | related_url_kwargs={"post_id": ""}, 215 | many=False, 216 | include_resource_linkage=True, 217 | type_="comments", 218 | ) 219 | with pytest.raises(ValidationError) as excinfo: 220 | value = {"data": {"type": "posts", "id": "1"}} 221 | field.deserialize(value) 222 | assert excinfo.value.args[0] == ["Invalid `type` specified"] 223 | 224 | def test_deserialize_null_data_value(self): 225 | field = Relationship( 226 | related_url="/posts/{post_id}/comments", 227 | related_url_kwargs={"post_id": ""}, 228 | allow_none=True, 229 | many=False, 230 | include_resource_linkage=False, 231 | type_="comments", 232 | ) 233 | result = field.deserialize({"data": None}) 234 | assert result is None 235 | 236 | def test_deserialize_null_value_disallow_none(self): 237 | field = Relationship( 238 | related_url="/posts/{post_id}/comments", 239 | related_url_kwargs={"post_id": ""}, 240 | allow_none=False, 241 | many=False, 242 | include_resource_linkage=False, 243 | type_="comments", 244 | ) 245 | with pytest.raises(ValidationError) as excinfo: 246 | field.deserialize({"data": None}) 247 | assert excinfo.value.args[0] == "Field may not be null." 248 | 249 | def test_deserialize_empty_data_list(self): 250 | field = Relationship( 251 | related_url="/posts/{post_id}/comments", 252 | related_url_kwargs={"post_id": ""}, 253 | many=True, 254 | include_resource_linkage=False, 255 | type_="comments", 256 | ) 257 | result = field.deserialize({"data": []}) 258 | assert result == [] 259 | 260 | def test_deserialize_empty_data(self): 261 | field = Relationship( 262 | related_url="/posts/{post_id}/comments", 263 | related_url_kwargs={"post_id": ""}, 264 | many=False, 265 | include_resource_linkage=False, 266 | type_="comments", 267 | ) 268 | with pytest.raises(ValidationError) as excinfo: 269 | field.deserialize({"data": {}}) 270 | assert excinfo.value.args[0] == [ 271 | "Must have an `id` field", 272 | "Must have a `type` field", 273 | ] 274 | 275 | def test_deserialize_required_missing(self): 276 | field = Relationship( 277 | related_url="/posts/{post_id}/comments", 278 | related_url_kwargs={"post_id": ""}, 279 | required=True, 280 | many=False, 281 | include_resource_linkage=True, 282 | type_="comments", 283 | ) 284 | with pytest.raises(ValidationError) as excinfo: 285 | field.deserialize(missing_) 286 | assert excinfo.value.args[0] == "Missing data for required field." 287 | 288 | def test_deserialize_required_empty(self): 289 | field = Relationship( 290 | related_url="/posts/{post_id}/comments", 291 | related_url_kwargs={"post_id": ""}, 292 | required=True, 293 | many=False, 294 | include_resource_linkage=False, 295 | type_="comments", 296 | ) 297 | with pytest.raises(ValidationError) as excinfo: 298 | field.deserialize({}) 299 | assert excinfo.value.args[0] == "Must include a `data` key" 300 | 301 | def test_deserialize_many_non_list_relationship(self): 302 | field = Relationship(many=True, include_resource_linkage=True, type_="comments") 303 | with pytest.raises(ValidationError) as excinfo: 304 | field.deserialize({"data": "1"}) 305 | assert excinfo.value.args[0] == "Relationship is list-like" 306 | 307 | def test_deserialize_non_many_list_relationship(self): 308 | field = Relationship( 309 | many=False, include_resource_linkage=True, type_="comments" 310 | ) 311 | with pytest.raises(ValidationError) as excinfo: 312 | field.deserialize({"data": ["1"]}) 313 | assert excinfo.value.args[0] == "Relationship is not list-like" 314 | 315 | def test_include_null_data_single(self, post_with_null_author): 316 | field = Relationship( 317 | related_url="posts/{post_id}/author", 318 | related_url_kwargs={"post_id": ""}, 319 | include_resource_linkage=True, 320 | type_="people", 321 | ) 322 | result = field.serialize("author", post_with_null_author) 323 | assert result and result["links"]["related"] 324 | assert result["data"] is None 325 | 326 | def test_include_null_data_many(self, post_with_null_comment): 327 | field = Relationship( 328 | related_url="/posts/{post_id}/comments", 329 | related_url_kwargs={"post_id": ""}, 330 | many=True, 331 | include_resource_linkage=True, 332 | type_="comments", 333 | ) 334 | result = field.serialize("comments", post_with_null_comment) 335 | assert result and result["links"]["related"] 336 | assert result["data"] == [] 337 | 338 | def test_exclude_data(self, post_with_null_comment): 339 | field = Relationship( 340 | related_url="/posts/{post_id}/comments", 341 | related_url_kwargs={"post_id": ""}, 342 | many=True, 343 | include_resource_linkage=False, 344 | type_="comments", 345 | ) 346 | result = field.serialize("comments", post_with_null_comment) 347 | assert result and result["links"]["related"] 348 | assert "data" not in result 349 | 350 | def test_empty_relationship_with_alternative_identifier_field( 351 | self, post_with_null_author 352 | ): 353 | field = Relationship( 354 | related_url="/authors/{author_id}", 355 | related_url_kwargs={"author_id": ""}, 356 | default=None, 357 | ) 358 | result = field.serialize("author", post_with_null_author) 359 | 360 | assert not result 361 | 362 | def test_resource_linkage_id_type_from_schema(self): 363 | class AuthorSchema(Schema): 364 | id = Int(attribute="author_id", as_string=True) 365 | 366 | class Meta: 367 | type_ = "authors" 368 | strict = True 369 | 370 | field = Relationship( 371 | include_resource_linkage=True, type_="authors", schema=AuthorSchema 372 | ) 373 | 374 | result = field.deserialize({"data": {"type": "authors", "id": "1"}}) 375 | 376 | assert result == 1 377 | 378 | def test_resource_linkage_id_of_invalid_type(self): 379 | class AuthorSchema(Schema): 380 | id = Int(attribute="author_id", as_string=True) 381 | 382 | class Meta: 383 | type_ = "authors" 384 | strict = True 385 | 386 | field = Relationship( 387 | include_resource_linkage=True, type_="authors", schema=AuthorSchema 388 | ) 389 | 390 | with pytest.raises(ValidationError) as excinfo: 391 | field.deserialize({"data": {"type": "authors", "id": "not_a_number"}}) 392 | assert excinfo.value.args[0] == "Not a valid integer." 393 | 394 | 395 | class TestDocumentMetaField: 396 | def test_serialize(self): 397 | field = DocumentMeta() 398 | result = field.serialize( 399 | "document_meta", {"document_meta": {"page": {"offset": 1}}} 400 | ) 401 | assert result == {"page": {"offset": 1}} 402 | 403 | def test_serialize_incorrect_type(self): 404 | field = DocumentMeta() 405 | with pytest.raises(ValidationError) as excinfo: 406 | field.serialize("document_meta", {"document_meta": 1}) 407 | assert excinfo.value.args[0] == "Not a valid mapping type." 408 | 409 | def test_deserialize(self): 410 | field = DocumentMeta() 411 | value = {"page": {"offset": 1}} 412 | result = field.deserialize(value) 413 | assert result == value 414 | 415 | def test_deserialize_incorrect_type(self): 416 | field = DocumentMeta() 417 | value = 1 418 | with pytest.raises(ValidationError) as excinfo: 419 | field.deserialize(value) 420 | assert excinfo.value.args[0] == "Not a valid mapping type." 421 | 422 | 423 | class TestResourceMetaField: 424 | def test_serialize(self): 425 | field = ResourceMeta() 426 | result = field.serialize("resource_meta", {"resource_meta": {"active": True}}) 427 | assert result == {"active": True} 428 | 429 | def test_serialize_incorrect_type(self): 430 | field = ResourceMeta() 431 | with pytest.raises(ValidationError) as excinfo: 432 | field.serialize("resource_meta", {"resource_meta": True}) 433 | assert excinfo.value.args[0] == "Not a valid mapping type." 434 | 435 | def test_deserialize(self): 436 | field = ResourceMeta() 437 | value = {"active": True} 438 | result = field.deserialize(value) 439 | assert result == value 440 | 441 | def test_deserialize_incorrect_type(self): 442 | field = ResourceMeta() 443 | value = True 444 | with pytest.raises(ValidationError) as excinfo: 445 | field.deserialize(value) 446 | assert excinfo.value.args[0] == "Not a valid mapping type." 447 | -------------------------------------------------------------------------------- /tests/test_flask.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, url_for 2 | import pytest 3 | from werkzeug.routing import BuildError 4 | 5 | from marshmallow_jsonapi import fields 6 | from marshmallow_jsonapi.flask import Relationship, Schema 7 | 8 | 9 | @pytest.fixture() 10 | def app(): 11 | app_ = Flask("testapp") 12 | app_.config["DEBUG"] = True 13 | app_.config["TESTING"] = True 14 | 15 | @app_.route("/posts/") 16 | def posts(): 17 | return "All posts" 18 | 19 | @app_.route("/posts//") 20 | def post_detail(post_id): 21 | return f"Detail for post {post_id}" 22 | 23 | @app_.route("/posts//comments/") 24 | def posts_comments(post_id): 25 | return f"Comments for post {post_id}" 26 | 27 | @app_.route("/authors/") 28 | def author_detail(author_id): 29 | return f"Detail for author {author_id}" 30 | 31 | ctx = app_.test_request_context() 32 | ctx.push() 33 | yield app_ 34 | ctx.pop() 35 | 36 | 37 | class TestSchema: 38 | class PostFlaskSchema(Schema): 39 | id = fields.Int() 40 | title = fields.Str() 41 | 42 | class Meta: 43 | type_ = "posts" 44 | self_view = "post_detail" 45 | self_view_kwargs = {"post_id": ""} 46 | self_view_many = "posts" 47 | 48 | class PostAuthorFlaskSchema(Schema): 49 | id = fields.Int() 50 | title = fields.Str() 51 | 52 | field = Relationship( 53 | related_view="author_detail", 54 | related_view_kwargs={"author_id": ""}, 55 | default=None, 56 | ) 57 | 58 | class Meta: 59 | type_ = "posts" 60 | self_view = "post_detail" 61 | self_view_kwargs = {"post_id": ""} 62 | self_view_many = "posts" 63 | 64 | def test_schema_requires_view_options(self): 65 | with pytest.raises(ValueError): 66 | 67 | class InvalidFlaskMetaSchema(Schema): 68 | id = fields.Int() 69 | 70 | class Meta: 71 | type_ = "posts" 72 | self_url = "/posts/{id}" 73 | self_url_kwargs = {"post_id": ""} 74 | 75 | def test_non_existing_view(self, app, post): 76 | class InvalidFlaskMetaSchema(Schema): 77 | id = fields.Int() 78 | 79 | class Meta: 80 | type_ = "posts" 81 | self_view = "wrong_view" 82 | self_view_kwargs = {"post_id": ""} 83 | 84 | with pytest.raises(BuildError): 85 | InvalidFlaskMetaSchema().dump(post) 86 | 87 | def test_self_link_single(self, app, post): 88 | data = self.PostFlaskSchema().dump(post) 89 | assert "links" in data 90 | assert data["links"]["self"] == f"/posts/{post.id}/" 91 | 92 | def test_self_link_many(self, app, posts): 93 | data = self.PostFlaskSchema(many=True).dump(posts) 94 | assert "links" in data 95 | assert data["links"]["self"] == "/posts/" 96 | 97 | assert "links" in data["data"][0] 98 | assert data["data"][0]["links"]["self"] == f"/posts/{posts[0].id}/" 99 | 100 | def test_schema_with_empty_relationship(self, app, post_with_null_author): 101 | data = self.PostAuthorFlaskSchema().dump(post_with_null_author) 102 | assert "relationships" not in data 103 | 104 | 105 | class TestRelationshipField: 106 | def test_serialize_basic(self, app, post): 107 | field = Relationship( 108 | related_view="posts_comments", related_view_kwargs={"post_id": ""} 109 | ) 110 | result = field.serialize("comments", post) 111 | assert "links" in result 112 | assert "related" in result["links"] 113 | related = result["links"]["related"] 114 | assert related == url_for("posts_comments", post_id=post.id) 115 | 116 | def test_serialize_external(self, app, post): 117 | field = Relationship( 118 | related_view="posts_comments", 119 | related_view_kwargs={"post_id": "", "_external": True}, 120 | ) 121 | result = field.serialize("comments", post) 122 | related = result["links"]["related"] 123 | assert related == url_for("posts_comments", post_id=post.id, _external=True) 124 | 125 | def test_include_resource_linkage_requires_type(self, app, post): 126 | with pytest.raises(ValueError) as excinfo: 127 | Relationship( 128 | related_view="posts_comments", 129 | related_view_kwargs={"post_id": ""}, 130 | include_resource_linkage=True, 131 | ) 132 | assert ( 133 | excinfo.value.args[0] 134 | == "include_resource_linkage=True requires the type_ argument." 135 | ) 136 | 137 | def test_serialize_self_link(self, app, post): 138 | field = Relationship( 139 | self_view="posts_comments", self_view_kwargs={"post_id": ""} 140 | ) 141 | result = field.serialize("comments", post) 142 | assert "links" in result 143 | assert "self" in result["links"] 144 | related = result["links"]["self"] 145 | assert related == url_for("posts_comments", post_id=post.id) 146 | 147 | def test_empty_relationship(self, app, post_with_null_author): 148 | field = Relationship( 149 | related_view="author_detail", related_view_kwargs={"author_id": ""} 150 | ) 151 | result = field.serialize("author", post_with_null_author) 152 | 153 | assert not result 154 | 155 | def test_non_existing_view(self, app, post): 156 | field = Relationship( 157 | related_view="non_existing_view", 158 | related_view_kwargs={"author_id": ""}, 159 | ) 160 | with pytest.raises(BuildError): 161 | field.serialize("author", post) 162 | 163 | def test_empty_relationship_with_alternative_identifier_field( 164 | self, app, post_with_null_author 165 | ): 166 | field = Relationship( 167 | related_view="author_detail", 168 | related_view_kwargs={"author_id": ""}, 169 | default=None, 170 | ) 171 | result = field.serialize("author", post_with_null_author) 172 | 173 | assert not result 174 | -------------------------------------------------------------------------------- /tests/test_options.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from marshmallow import validate, ValidationError 3 | 4 | from marshmallow_jsonapi import Schema, fields 5 | from tests.base import AuthorSchema, CommentSchema 6 | from tests.test_schema import make_serialized_author, get_error_by_field 7 | 8 | 9 | def dasherize(text): 10 | return text.replace("_", "-") 11 | 12 | 13 | class AuthorSchemaWithInflection(Schema): 14 | id = fields.Int(dump_only=True) 15 | first_name = fields.Str(required=True, validate=validate.Length(min=2)) 16 | last_name = fields.Str(required=True) 17 | 18 | class Meta: 19 | type_ = "people" 20 | inflect = dasherize 21 | strict = True 22 | 23 | 24 | class AuthorSchemaWithOverrideInflection(Schema): 25 | id = fields.Str(dump_only=True) 26 | # data_key and load_from takes precedence over inflected attribute 27 | first_name = fields.Str(data_key="firstName", load_from="firstName") 28 | last_name = fields.Str() 29 | 30 | class Meta: 31 | type_ = "people" 32 | inflect = dasherize 33 | strict = True 34 | 35 | 36 | class TestInflection: 37 | @pytest.fixture() 38 | def schema(self): 39 | return AuthorSchemaWithInflection() 40 | 41 | def test_dump(self, schema, author): 42 | data = schema.dump(author) 43 | 44 | assert data["data"]["id"] == author.id 45 | assert data["data"]["type"] == "people" 46 | attribs = data["data"]["attributes"] 47 | 48 | assert "first-name" in attribs 49 | assert "last-name" in attribs 50 | 51 | assert attribs["first-name"] == author.first_name 52 | assert attribs["last-name"] == author.last_name 53 | 54 | def test_validate_with_inflection(self, schema): 55 | errors = schema.validate(make_serialized_author({"first-name": "d"})) 56 | lname_err = get_error_by_field(errors, "last-name") 57 | assert lname_err 58 | assert lname_err["detail"] == "Missing data for required field." 59 | 60 | fname_err = get_error_by_field(errors, "first-name") 61 | assert fname_err 62 | assert fname_err["detail"] == "Shorter than minimum length 2." 63 | 64 | def test_load_with_inflection(self, schema): 65 | # invalid 66 | with pytest.raises(ValidationError) as excinfo: 67 | schema.load(make_serialized_author({"first-name": "d"})) 68 | errors = excinfo.value.messages 69 | fname_err = get_error_by_field(errors, "first-name") 70 | assert fname_err 71 | assert fname_err["detail"] == "Shorter than minimum length 2." 72 | 73 | # valid 74 | data = schema.load( 75 | make_serialized_author({"first-name": "Nevets", "last-name": "Longoria"}) 76 | ) 77 | 78 | assert data["first_name"] == "Nevets" 79 | 80 | def test_load_with_inflection_and_load_from_override(self): 81 | schema = AuthorSchemaWithOverrideInflection() 82 | data = schema.load( 83 | make_serialized_author({"firstName": "Steve", "last-name": "Loria"}) 84 | ) 85 | 86 | assert data["first_name"] == "Steve" 87 | assert data["last_name"] == "Loria" 88 | 89 | def test_load_bulk_id_fields(self): 90 | request = {"data": [{"id": "1", "type": "people"}]} 91 | 92 | result = AuthorSchema(only=("id",), many=True).load(request) 93 | assert type(result) is list 94 | 95 | response = result[0] 96 | assert response["id"] == request["data"][0]["id"] 97 | 98 | def test_relationship_keys_get_inflected(self, post): 99 | class PostSchema(Schema): 100 | id = fields.Int() 101 | post_title = fields.Str(attribute="title") 102 | 103 | post_comments = fields.Relationship( 104 | "http://test.test/posts/{id}/comments/", 105 | related_url_kwargs={"id": ""}, 106 | attribute="comments", 107 | ) 108 | 109 | class Meta: 110 | type_ = "posts" 111 | inflect = dasherize 112 | strict = True 113 | 114 | data = PostSchema().dump(post) 115 | assert "post-title" in data["data"]["attributes"] 116 | assert "post-comments" in data["data"]["relationships"] 117 | related_href = data["data"]["relationships"]["post-comments"]["links"][ 118 | "related" 119 | ] 120 | assert related_href == f"http://test.test/posts/{post.id}/comments/" 121 | 122 | 123 | class AuthorAutoSelfLinkSchema(Schema): 124 | id = fields.Int(dump_only=True) 125 | first_name = fields.Str(required=True) 126 | last_name = fields.Str(required=True) 127 | password = fields.Str(load_only=True, validate=validate.Length(6)) 128 | twitter = fields.Str() 129 | 130 | class Meta: 131 | type_ = "people" 132 | self_url = "/authors/{id}" 133 | self_url_kwargs = {"id": ""} 134 | self_url_many = "/authors/" 135 | 136 | 137 | class AuthorAutoSelfLinkFirstLastSchema(AuthorAutoSelfLinkSchema): 138 | class Meta: 139 | type_ = "people" 140 | self_url = "http://example.com/authors/{first_name} {last_name}" 141 | self_url_kwargs = {"first_name": "", "last_name": ""} 142 | self_url_many = "http://example.com/authors/" 143 | 144 | 145 | class TestAutoSelfUrls: 146 | def test_self_url_kwargs_requires_self_url(self, author): 147 | class InvalidSelfLinkSchema(Schema): 148 | id = fields.Int() 149 | 150 | class Meta: 151 | type_ = "people" 152 | self_url_kwargs = {"id": ""} 153 | 154 | with pytest.raises(ValueError): 155 | InvalidSelfLinkSchema().dump(author) 156 | 157 | def test_self_link_single(self, author): 158 | data = AuthorAutoSelfLinkSchema().dump(author) 159 | assert "links" in data 160 | assert data["links"]["self"] == f"/authors/{author.id}" 161 | 162 | def test_self_link_many(self, authors): 163 | data = AuthorAutoSelfLinkSchema(many=True).dump(authors) 164 | assert "links" in data 165 | assert data["links"]["self"] == "/authors/" 166 | 167 | assert "links" in data["data"][0] 168 | assert data["data"][0]["links"]["self"] == f"/authors/{authors[0].id}" 169 | 170 | def test_without_self_link(self, comments): 171 | data = CommentSchema(many=True).dump(comments) 172 | 173 | assert "data" in data 174 | assert type(data["data"]) is list 175 | 176 | first = data["data"][0] 177 | assert first["id"] == str(comments[0].id) 178 | assert first["type"] == "comments" 179 | 180 | assert "links" not in data 181 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import marshmallow as ma 3 | from marshmallow import ValidationError, INCLUDE 4 | 5 | from marshmallow_jsonapi import Schema, fields 6 | from marshmallow_jsonapi.exceptions import IncorrectTypeError 7 | from tests.base import ( 8 | AuthorSchema, 9 | CommentSchema, 10 | PostSchema, 11 | PolygonSchema, 12 | ArticleSchema, 13 | ) 14 | 15 | 16 | def make_serialized_author(attributes): 17 | return {"data": {"type": "people", "attributes": attributes}} 18 | 19 | 20 | def make_serialized_authors(items): 21 | return {"data": [{"type": "people", "attributes": each} for each in items]} 22 | 23 | 24 | def test_type_is_required(): 25 | class BadSchema(Schema): 26 | id = fields.Str() 27 | 28 | class Meta: 29 | pass 30 | 31 | with pytest.raises(ValueError) as excinfo: 32 | BadSchema() 33 | assert excinfo.value.args[0] == "Must specify type_ class Meta option" 34 | 35 | 36 | def test_id_field_is_required(): 37 | class BadSchema(Schema): 38 | class Meta: 39 | type_ = "users" 40 | 41 | with pytest.raises(ValueError) as excinfo: 42 | BadSchema() 43 | assert excinfo.value.args[0] == "Must have an `id` field" 44 | 45 | 46 | class TestResponseFormatting: 47 | def test_dump_single(self, author): 48 | data = AuthorSchema().dump(author) 49 | 50 | assert "data" in data 51 | assert type(data["data"]) is dict 52 | 53 | assert data["data"]["id"] == str(author.id) 54 | assert data["data"]["type"] == "people" 55 | attribs = data["data"]["attributes"] 56 | 57 | assert attribs["first_name"] == author.first_name 58 | assert attribs["last_name"] == author.last_name 59 | 60 | def test_dump_many(self, authors): 61 | data = AuthorSchema(many=True).dump(authors) 62 | assert "data" in data 63 | assert type(data["data"]) is list 64 | 65 | first = data["data"][0] 66 | assert first["id"] == str(authors[0].id) 67 | assert first["type"] == "people" 68 | 69 | attribs = first["attributes"] 70 | 71 | assert attribs["first_name"] == authors[0].first_name 72 | assert attribs["last_name"] == authors[0].last_name 73 | 74 | def test_self_link_single(self, author): 75 | data = AuthorSchema().dump(author) 76 | assert "links" in data 77 | assert data["links"]["self"] == f"/authors/{author.id}" 78 | 79 | def test_self_link_many(self, authors): 80 | data = AuthorSchema(many=True).dump(authors) 81 | assert "links" in data 82 | assert data["links"]["self"] == "/authors/" 83 | 84 | def test_dump_to(self, post): 85 | data = PostSchema().dump(post) 86 | assert "data" in data 87 | assert "attributes" in data["data"] 88 | assert "title" in data["data"]["attributes"] 89 | assert "relationships" in data["data"] 90 | assert "post-comments" in data["data"]["relationships"] 91 | 92 | def test_dump_none(self): 93 | data = AuthorSchema().dump(None) 94 | 95 | assert "data" in data 96 | assert data["data"] is None 97 | assert "links" not in data 98 | 99 | def test_schema_with_relationship_processes_none(self): 100 | data = CommentSchema().dump(None) 101 | assert data == {"data": None} 102 | 103 | def test_dump_empty_list(self): 104 | data = AuthorSchema(many=True).dump([]) 105 | 106 | assert "data" in data 107 | assert type(data["data"]) is list 108 | assert len(data["data"]) == 0 109 | assert "links" in data 110 | assert data["links"]["self"] == "/authors/" 111 | 112 | 113 | class TestCompoundDocuments: 114 | def test_include_data_with_many(self, post): 115 | data = PostSchema(include_data=("post_comments", "post_comments.author")).dump( 116 | post 117 | ) 118 | 119 | assert "included" in data 120 | assert len(data["included"]) == 4 121 | first_comment = [i for i in data["included"] if i["type"] == "comments"][0] 122 | assert "attributes" in first_comment 123 | assert "body" in first_comment["attributes"] 124 | 125 | def test_include_data_with_single(self, post): 126 | data = PostSchema(include_data=("author",)).dump(post) 127 | assert "included" in data 128 | assert len(data["included"]) == 1 129 | author = data["included"][0] 130 | assert "attributes" in author 131 | assert "first_name" in author["attributes"] 132 | 133 | def test_include_data_with_all_relations(self, post): 134 | data = PostSchema( 135 | include_data=("author", "post_comments", "post_comments.author") 136 | ).dump(post) 137 | 138 | assert "included" in data 139 | assert len(data["included"]) == 5 140 | for included in data["included"]: 141 | assert included["id"] 142 | assert included["type"] in ("people", "comments") 143 | expected_comments_author_ids = { 144 | str(comment.author.id) for comment in post.comments 145 | } 146 | included_comments_author_ids = { 147 | i["id"] 148 | for i in data["included"] 149 | if i["type"] == "people" and i["id"] != str(post.author.id) 150 | } 151 | assert included_comments_author_ids == expected_comments_author_ids 152 | 153 | def test_include_no_data(self, post): 154 | data = PostSchema(include_data=()).dump(post) 155 | assert "included" not in data 156 | 157 | def test_include_self_referential_relationship(self): 158 | class RefSchema(Schema): 159 | id = fields.Int() 160 | data = fields.Str() 161 | parent = fields.Relationship(schema="self", many=False) 162 | 163 | class Meta: 164 | type_ = "refs" 165 | 166 | obj = {"id": 1, "data": "data1", "parent": {"id": 2, "data": "data2"}} 167 | data = RefSchema(include_data=("parent",)).dump(obj) 168 | assert "included" in data 169 | assert data["included"][0]["attributes"]["data"] == "data2" 170 | 171 | def test_include_self_referential_relationship_many(self): 172 | class RefSchema(Schema): 173 | id = fields.Str() 174 | data = fields.Str() 175 | children = fields.Relationship(schema="self", many=True) 176 | 177 | class Meta: 178 | type_ = "refs" 179 | 180 | obj = { 181 | "id": "1", 182 | "data": "data1", 183 | "children": [{"id": "2", "data": "data2"}, {"id": "3", "data": "data3"}], 184 | } 185 | data = RefSchema(include_data=("children",)).dump(obj) 186 | assert "included" in data 187 | assert len(data["included"]) == 2 188 | for child in data["included"]: 189 | assert child["attributes"]["data"] == "data%s" % child["id"] 190 | 191 | def test_include_self_referential_relationship_many_deep(self): 192 | class RefSchema(Schema): 193 | id = fields.Str() 194 | data = fields.Str() 195 | children = fields.Relationship(schema="self", type_="refs", many=True) 196 | 197 | class Meta: 198 | type_ = "refs" 199 | 200 | obj = { 201 | "id": "1", 202 | "data": "data1", 203 | "children": [ 204 | {"id": "2", "data": "data2", "children": []}, 205 | { 206 | "id": "3", 207 | "data": "data3", 208 | "children": [ 209 | {"id": "4", "data": "data4", "children": []}, 210 | {"id": "5", "data": "data5", "children": []}, 211 | ], 212 | }, 213 | ], 214 | } 215 | data = RefSchema(include_data=("children",)).dump(obj) 216 | assert "included" in data 217 | assert len(data["included"]) == 4 218 | for child in data["included"]: 219 | assert child["attributes"]["data"] == "data%s" % child["id"] 220 | 221 | def test_include_data_with_many_and_schema_as_class(self, post): 222 | class PostClassSchema(PostSchema): 223 | post_comments = fields.Relationship( 224 | "http://test.test/posts/{id}/comments/", 225 | related_url_kwargs={"id": ""}, 226 | attribute="comments", 227 | dump_to="post-comments", 228 | schema=CommentSchema, 229 | many=True, 230 | ) 231 | 232 | class Meta(PostSchema.Meta): 233 | pass 234 | 235 | data = PostClassSchema(include_data=("post_comments",)).dump(post) 236 | assert "included" in data 237 | assert len(data["included"]) == 2 238 | first_comment = data["included"][0] 239 | assert "attributes" in first_comment 240 | assert "body" in first_comment["attributes"] 241 | 242 | def test_include_data_with_nested_only_arg(self, post): 243 | data = PostSchema( 244 | only=( 245 | "id", 246 | "post_comments.id", 247 | "post_comments.author.id", 248 | "post_comments.author.twitter", 249 | ), 250 | include_data=("post_comments", "post_comments.author"), 251 | ).dump(post) 252 | 253 | assert "included" in data 254 | assert len(data["included"]) == 4 255 | 256 | first_author = [i for i in data["included"] if i["type"] == "people"][0] 257 | assert "twitter" in first_author["attributes"] 258 | for attribute in ("first_name", "last_name"): 259 | assert attribute not in first_author["attributes"] 260 | 261 | def test_include_data_with_nested_exclude_arg(self, post): 262 | data = PostSchema( 263 | exclude=("post_comments.author.twitter",), 264 | include_data=("post_comments", "post_comments.author"), 265 | ).dump(post) 266 | 267 | assert "included" in data 268 | assert len(data["included"]) == 4 269 | 270 | first_author = [i for i in data["included"] if i["type"] == "people"][0] 271 | assert "twitter" not in first_author["attributes"] 272 | for attribute in ("first_name", "last_name"): 273 | assert attribute in first_author["attributes"] 274 | 275 | def test_include_data_load(self, post): 276 | serialized = PostSchema( 277 | include_data=("author", "post_comments", "post_comments.author") 278 | ).dump(post) 279 | 280 | loaded = PostSchema().load(serialized) 281 | 282 | assert "author" in loaded 283 | assert loaded["author"]["id"] == str(post.author.id) 284 | assert loaded["author"]["first_name"] == post.author.first_name 285 | 286 | assert "comments" in loaded 287 | assert len(loaded["comments"]) == len(post.comments) 288 | for comment in loaded["comments"]: 289 | assert "body" in comment 290 | assert comment["id"] in [str(c.id) for c in post.comments] 291 | 292 | def test_include_data_load_null(self, post_with_null_author): 293 | serialized = PostSchema(include_data=("author", "post_comments")).dump( 294 | post_with_null_author 295 | ) 296 | 297 | with pytest.raises(ValidationError) as excinfo: 298 | PostSchema().load(serialized) 299 | err = excinfo.value 300 | assert "author" in err.args[0] 301 | 302 | def test_include_data_load_without_schema_loads_only_ids(self, post): 303 | class PostInnerSchemalessSchema(Schema): 304 | id = fields.Str() 305 | comments = fields.Relationship( 306 | "http://test.test/posts/{id}/comments/", 307 | related_url_kwargs={"id": ""}, 308 | data_key="post-comments", 309 | load_from="post-comments", 310 | many=True, 311 | type_="comments", 312 | ) 313 | 314 | class Meta: 315 | type_ = "posts" 316 | strict = True 317 | 318 | serialized = PostSchema(include_data=("author", "post_comments")).dump(post) 319 | loaded = PostInnerSchemalessSchema(unknown=INCLUDE).load(serialized) 320 | 321 | assert "comments" in loaded 322 | assert len(loaded["comments"]) == len(post.comments) 323 | for comment_id in loaded["comments"]: 324 | assert int(comment_id) in [c.id for c in post.comments] 325 | 326 | def test_include_data_with_schema_context(self, post): 327 | class ContextTestSchema(Schema): 328 | id = fields.Str() 329 | from_context = fields.Method("get_from_context") 330 | 331 | def get_from_context(self, obj): 332 | return self.context["some_value"] 333 | 334 | class Meta: 335 | type_ = "people" 336 | 337 | class PostContextTestSchema(PostSchema): 338 | author = fields.Relationship( 339 | "http://test.test/posts/{id}/author/", 340 | related_url_kwargs={"id": ""}, 341 | schema=ContextTestSchema, 342 | many=False, 343 | ) 344 | 345 | class Meta(PostSchema.Meta): 346 | pass 347 | 348 | serialized = PostContextTestSchema( 349 | include_data=("author",), context={"some_value": "Hello World"} 350 | ).dump(post) 351 | 352 | for included in serialized["included"]: 353 | if included["type"] == "people": 354 | assert "from_context" in included["attributes"] 355 | assert included["attributes"]["from_context"] == "Hello World" 356 | 357 | 358 | def get_error_by_field(errors, field): 359 | for err in errors["errors"]: 360 | # Relationship error pointers won't match with this. 361 | if err["source"]["pointer"].endswith("/" + field): 362 | return err 363 | return None 364 | 365 | 366 | class TestErrorFormatting: 367 | def test_validate(self): 368 | author = make_serialized_author({"first_name": "Dan", "password": "short"}) 369 | errors = AuthorSchema().validate(author) 370 | assert "errors" in errors 371 | assert len(errors["errors"]) == 2 372 | 373 | password_err = get_error_by_field(errors, "password") 374 | assert password_err 375 | assert password_err["detail"] == "Shorter than minimum length 6." 376 | 377 | lname_err = get_error_by_field(errors, "last_name") 378 | assert lname_err 379 | assert lname_err["detail"] == "Missing data for required field." 380 | 381 | def test_errors_in_strict_mode(self): 382 | author = make_serialized_author({"first_name": "Dan", "password": "short"}) 383 | with pytest.raises(ValidationError) as excinfo: 384 | AuthorSchema().load(author) 385 | errors = excinfo.value.messages 386 | assert "errors" in errors 387 | assert len(errors["errors"]) == 2 388 | password_err = get_error_by_field(errors, "password") 389 | assert password_err 390 | assert password_err["detail"] == "Shorter than minimum length 6." 391 | 392 | lname_err = get_error_by_field(errors, "last_name") 393 | assert lname_err 394 | assert lname_err["detail"] == "Missing data for required field." 395 | 396 | def test_no_type_raises_error(self): 397 | author = { 398 | "data": {"attributes": {"first_name": "Dan", "password": "supersecure"}} 399 | } 400 | with pytest.raises(ValidationError) as excinfo: 401 | AuthorSchema().load(author) 402 | 403 | expected = { 404 | "errors": [ 405 | { 406 | "detail": "`data` object must include `type` key.", 407 | "source": {"pointer": "/data"}, 408 | } 409 | ] 410 | } 411 | assert excinfo.value.messages == expected 412 | 413 | errors = AuthorSchema().validate(author) 414 | assert errors == expected 415 | 416 | def test_validate_no_data_raises_error(self): 417 | author = {"meta": {"this": "that"}} 418 | 419 | with pytest.raises(ValidationError) as excinfo: 420 | AuthorSchema().load(author) 421 | 422 | errors = excinfo.value.messages 423 | 424 | expected = { 425 | "errors": [ 426 | { 427 | "detail": "Object must include `data` key.", 428 | "source": {"pointer": "/"}, 429 | } 430 | ] 431 | } 432 | 433 | assert errors == expected 434 | 435 | def test_validate_type(self): 436 | author = { 437 | "data": { 438 | "type": "invalid", 439 | "attributes": {"first_name": "Dan", "password": "supersecure"}, 440 | } 441 | } 442 | with pytest.raises(IncorrectTypeError) as excinfo: 443 | AuthorSchema().validate(author) 444 | assert excinfo.value.args[0] == 'Invalid type. Expected "people".' 445 | assert excinfo.value.messages == { 446 | "errors": [ 447 | { 448 | "detail": 'Invalid type. Expected "people".', 449 | "source": {"pointer": "/data/type"}, 450 | } 451 | ] 452 | } 453 | 454 | def test_validate_id(self): 455 | """the pointer for id should be at the data object, not attributes""" 456 | author = { 457 | "data": { 458 | "type": "people", 459 | "id": 123, 460 | "attributes": {"first_name": "Rob", "password": "correcthorses"}, 461 | } 462 | } 463 | try: 464 | errors = AuthorSchema().validate(author) 465 | except ValidationError as err: 466 | errors = err.messages 467 | assert "errors" in errors 468 | assert len(errors["errors"]) == 2 469 | 470 | lname_err = get_error_by_field(errors, "last_name") 471 | assert lname_err 472 | assert lname_err["source"]["pointer"] == "/data/attributes/last_name" 473 | assert lname_err["detail"] == "Missing data for required field." 474 | 475 | id_err = get_error_by_field(errors, "id") 476 | assert id_err 477 | assert id_err["source"]["pointer"] == "/data/id" 478 | assert id_err["detail"] == "Not a valid string." 479 | 480 | def test_load(self): 481 | with pytest.raises(ValidationError) as excinfo: 482 | AuthorSchema().load( 483 | make_serialized_author({"first_name": "Dan", "password": "short"}) 484 | ) 485 | errors = excinfo.value.messages 486 | assert "errors" in errors 487 | assert len(errors["errors"]) == 2 488 | 489 | password_err = get_error_by_field(errors, "password") 490 | assert password_err 491 | assert password_err["detail"] == "Shorter than minimum length 6." 492 | 493 | lname_err = get_error_by_field(errors, "last_name") 494 | assert lname_err 495 | assert lname_err["detail"] == "Missing data for required field." 496 | 497 | def test_errors_is_empty_if_valid(self): 498 | errors = AuthorSchema().validate( 499 | make_serialized_author( 500 | { 501 | "first_name": "Dan", 502 | "last_name": "Gebhardt", 503 | "password": "supersecret", 504 | } 505 | ) 506 | ) 507 | assert errors == {} 508 | 509 | def test_errors_many(self): 510 | authors = make_serialized_authors( 511 | [ 512 | {"first_name": "Dan", "last_name": "Gebhardt", "password": "bad"}, 513 | { 514 | "first_name": "Dan", 515 | "last_name": "Gebhardt", 516 | "password": "supersecret", 517 | }, 518 | ] 519 | ) 520 | try: 521 | errors = AuthorSchema(many=True).validate(authors)["errors"] 522 | except ValidationError as err: 523 | errors = err.messages["errors"] 524 | 525 | assert len(errors) == 1 526 | 527 | err = errors[0] 528 | assert "source" in err 529 | assert err["source"]["pointer"] == "/data/0/attributes/password" 530 | 531 | def test_errors_many_not_list(self): 532 | authors = make_serialized_author( 533 | {"first_name": "Dan", "last_name": "Gebhardt", "password": "bad"} 534 | ) 535 | try: 536 | errors = AuthorSchema(many=True).validate(authors)["errors"] 537 | except ValidationError as err: 538 | errors = err.messages["errors"] 539 | 540 | assert len(errors) == 1 541 | 542 | err = errors[0] 543 | assert "source" in err 544 | assert err["source"]["pointer"] == "/data" 545 | assert err["detail"] == "`data` expected to be a collection." 546 | 547 | def test_many_id_errors(self): 548 | """the pointer for id should be at the data object, not attributes""" 549 | author = { 550 | "data": [ 551 | { 552 | "type": "people", 553 | "id": "invalid", 554 | "attributes": {"first_name": "Rob", "password": "correcthorses"}, 555 | }, 556 | { 557 | "type": "people", 558 | "id": 37, 559 | "attributes": { 560 | "first_name": "Dan", 561 | "last_name": "Gebhardt", 562 | "password": "supersecret", 563 | }, 564 | }, 565 | ] 566 | } 567 | errors = AuthorSchema(many=True).validate(author) 568 | assert "errors" in errors 569 | assert len(errors["errors"]) == 2 570 | 571 | lname_err = get_error_by_field(errors, "last_name") 572 | assert lname_err 573 | assert lname_err["source"]["pointer"] == "/data/0/attributes/last_name" 574 | assert lname_err["detail"] == "Missing data for required field." 575 | 576 | id_err = get_error_by_field(errors, "id") 577 | assert id_err 578 | assert id_err["source"]["pointer"] == "/data/1/id" 579 | assert id_err["detail"] == "Not a valid string." 580 | 581 | def test_nested_fields_error(self): 582 | min_size = 10 583 | 584 | class ThirdLevel(ma.Schema): 585 | number = fields.Int(required=True, validate=ma.validate.Range(min=min_size)) 586 | 587 | class SecondLevel(ma.Schema): 588 | foo = fields.Str(required=True) 589 | third = fields.Nested(ThirdLevel) 590 | 591 | class FirstLevel(Schema): 592 | class Meta: 593 | type_ = "first" 594 | 595 | id = fields.Int() 596 | second = fields.Nested(SecondLevel) 597 | 598 | schema = FirstLevel() 599 | result = schema.validate( 600 | { 601 | "data": { 602 | "type": "first", 603 | "attributes": {"second": {"third": {"number": 5}}}, 604 | } 605 | } 606 | ) 607 | 608 | def sort_func(d): 609 | return d["source"]["pointer"] 610 | 611 | expected_errors = sorted( 612 | [ 613 | { 614 | "source": {"pointer": "/data/attributes/second/third/number"}, 615 | "detail": f"Must be greater than or equal to {min_size}.", 616 | }, 617 | { 618 | "source": {"pointer": "/data/attributes/second/foo"}, 619 | "detail": ma.fields.Field.default_error_messages["required"], 620 | }, 621 | ], 622 | key=sort_func, 623 | ) 624 | 625 | errors = sorted(result["errors"], key=sort_func) 626 | 627 | assert errors == expected_errors 628 | 629 | 630 | class TestMeta: 631 | shape = { 632 | "id": 1, 633 | "sides": 3, 634 | "meta": "triangle", 635 | "resource_meta": {"concave": False}, 636 | "document_meta": {"page": 1}, 637 | } 638 | 639 | shapes = [ 640 | { 641 | "id": 1, 642 | "sides": 3, 643 | "meta": "triangle", 644 | "resource_meta": {"concave": False}, 645 | "document_meta": {"page": 1}, 646 | }, 647 | { 648 | "id": 2, 649 | "sides": 4, 650 | "meta": "quadrilateral", 651 | "resource_meta": {"concave": True}, 652 | "document_meta": {"page": 1}, 653 | }, 654 | ] 655 | 656 | def test_dump_single(self): 657 | serialized = PolygonSchema().dump(self.shape) 658 | assert "meta" in serialized 659 | assert serialized["meta"] == self.shape["document_meta"] 660 | assert serialized["data"]["attributes"]["meta"] == self.shape["meta"] 661 | assert serialized["data"]["meta"] == self.shape["resource_meta"] 662 | 663 | def test_dump_many(self): 664 | serialized = PolygonSchema(many=True).dump(self.shapes) 665 | assert "meta" in serialized 666 | assert serialized["meta"] == self.shapes[0]["document_meta"] 667 | 668 | first = serialized["data"][0] 669 | assert first["attributes"]["meta"] == self.shapes[0]["meta"] 670 | assert first["meta"] == self.shapes[0]["resource_meta"] 671 | 672 | second = serialized["data"][1] 673 | assert second["attributes"]["meta"] == self.shapes[1]["meta"] 674 | assert second["meta"] == self.shapes[1]["resource_meta"] 675 | 676 | def test_load_single(self): 677 | serialized = PolygonSchema().dump(self.shape) 678 | loaded = PolygonSchema().load(serialized) 679 | 680 | assert loaded["meta"] == self.shape["meta"] 681 | assert loaded["resource_meta"] == self.shape["resource_meta"] 682 | assert loaded["document_meta"] == self.shape["document_meta"] 683 | 684 | def test_load_many(self): 685 | serialized = PolygonSchema(many=True).dump(self.shapes) 686 | loaded = PolygonSchema(many=True).load(serialized) 687 | 688 | first = loaded[0] 689 | assert first["meta"] == self.shapes[0]["meta"] 690 | assert first["resource_meta"] == self.shapes[0]["resource_meta"] 691 | assert first["document_meta"] == self.shapes[0]["document_meta"] 692 | 693 | second = loaded[1] 694 | assert second["meta"] == self.shapes[1]["meta"] 695 | assert second["resource_meta"] == self.shapes[1]["resource_meta"] 696 | assert second["document_meta"] == self.shapes[1]["document_meta"] 697 | 698 | 699 | def assert_relationship_error(pointer, errors): 700 | """Walk through the dictionary and determine if a specific 701 | relationship pointer exists 702 | """ 703 | pointer = f"/data/relationships/{pointer}/data" 704 | for error in errors: 705 | if pointer == error["source"]["pointer"]: 706 | return True 707 | return False 708 | 709 | 710 | class TestRelationshipLoading: 711 | article = { 712 | "data": { 713 | "id": "1", 714 | "type": "articles", 715 | "attributes": {"body": "Test"}, 716 | "relationships": { 717 | "author": {"data": {"type": "people", "id": "1"}}, 718 | "comments": {"data": [{"type": "comments", "id": "1"}]}, 719 | }, 720 | } 721 | } 722 | 723 | def test_deserializing_relationship_fields(self): 724 | data = ArticleSchema().load(self.article) 725 | assert data["body"] == "Test" 726 | assert data["author"] == "1" 727 | assert data["comments"] == ["1"] 728 | 729 | def test_deserializing_nested_relationship_fields(self): 730 | class RelationshipWithSchemaCommentSchema(Schema): 731 | id = fields.Str() 732 | body = fields.Str(required=True) 733 | author = fields.Relationship( 734 | schema=AuthorSchema, many=False, type_="people" 735 | ) 736 | 737 | class Meta: 738 | type_ = "comments" 739 | strict = True 740 | 741 | class RelationshipWithSchemaArticleSchema(Schema): 742 | id = fields.Integer() 743 | body = fields.String() 744 | comments = fields.Relationship( 745 | schema=RelationshipWithSchemaCommentSchema, many=True, type_="comments" 746 | ) 747 | author = fields.Relationship( 748 | dump_only=False, 749 | include_resource_linkage=True, 750 | many=False, 751 | type_="people", 752 | ) 753 | 754 | class Meta: 755 | type_ = "articles" 756 | strict = True 757 | 758 | article = self.article.copy() 759 | article["included"] = [ 760 | { 761 | "id": "1", 762 | "type": "comments", 763 | "attributes": {"body": "Test comment"}, 764 | "relationships": {"author": {"data": {"type": "people", "id": "2"}}}, 765 | }, 766 | { 767 | "id": "2", 768 | "type": "people", 769 | "attributes": {"first_name": "Marshmallow Jr", "last_name": "JsonAPI"}, 770 | }, 771 | ] 772 | 773 | included_author = filter( 774 | lambda item: item["type"] == "people", article["included"] 775 | ) 776 | included_author = list(included_author)[0] 777 | 778 | data = RelationshipWithSchemaArticleSchema().load(article) 779 | author = data["comments"][0]["author"] 780 | 781 | assert isinstance(author, dict) 782 | assert author["first_name"] == included_author["attributes"]["first_name"] 783 | 784 | def test_deserializing_relationship_errors(self): 785 | data = self.article 786 | data["data"]["relationships"]["author"]["data"] = {} 787 | data["data"]["relationships"]["comments"]["data"] = [{}] 788 | with pytest.raises(ValidationError) as excinfo: 789 | ArticleSchema().load(data) 790 | errors = excinfo.value.messages 791 | 792 | assert assert_relationship_error("author", errors["errors"]) 793 | assert assert_relationship_error("comments", errors["errors"]) 794 | 795 | def test_deserializing_missing_required_relationship(self): 796 | class ArticleSchemaRequiredRelationships(Schema): 797 | id = fields.Integer() 798 | body = fields.String() 799 | author = fields.Relationship( 800 | dump_only=False, 801 | include_resource_linkage=True, 802 | many=False, 803 | type_="people", 804 | required=True, 805 | ) 806 | comments = fields.Relationship( 807 | dump_only=False, 808 | include_resource_linkage=True, 809 | many=True, 810 | type_="comments", 811 | required=True, 812 | ) 813 | 814 | class Meta: 815 | type_ = "articles" 816 | strict = True 817 | 818 | article = self.article.copy() 819 | article["data"]["relationships"] = {} 820 | 821 | with pytest.raises(ValidationError) as excinfo: 822 | ArticleSchemaRequiredRelationships().load(article) 823 | errors = excinfo.value.messages 824 | 825 | assert assert_relationship_error("author", errors["errors"]) 826 | assert assert_relationship_error("comments", errors["errors"]) 827 | 828 | def test_deserializing_relationship_with_missing_param(self): 829 | class ArticleMissingParamSchema(Schema): 830 | id = fields.Integer() 831 | body = fields.String() 832 | author = fields.Relationship( 833 | dump_only=False, 834 | include_resource_linkage=True, 835 | many=False, 836 | type_="people", 837 | missing="1", 838 | ) 839 | comments = fields.Relationship( 840 | dump_only=False, 841 | include_resource_linkage=True, 842 | many=True, 843 | type_="comments", 844 | missing=["2", "3"], 845 | ) 846 | 847 | class Meta: 848 | type_ = "articles" 849 | strict = True 850 | 851 | article = self.article.copy() 852 | article["data"]["relationships"] = {} 853 | 854 | data = ArticleMissingParamSchema().load(article) 855 | 856 | assert "author" in data 857 | assert data["author"] == "1" 858 | assert "comments" in data 859 | assert data["comments"] == ["2", "3"] 860 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from marshmallow_jsonapi import utils 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "tag,val", 8 | [ 9 | ("", "id"), 10 | ("", "author.last_name"), 11 | ("", "comment.author.first_name"), 12 | ("True", None), 13 | ("", None), 14 | ], 15 | ) 16 | def test_tpl(tag, val): 17 | assert utils.tpl(tag) == val 18 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist= 3 | lint 4 | py{36,37,38,39}-marshmallow3 5 | py39-marshmallowdev 6 | docs 7 | 8 | [testenv] 9 | extras = tests 10 | deps = 11 | marshmallow3: marshmallow>=3.0.0<4.0.0 12 | marshmallowdev: https://github.com/marshmallow-code/marshmallow/archive/dev.tar.gz 13 | commands = pytest {posargs} 14 | 15 | [testenv:lint] 16 | deps = pre-commit~=2.0 17 | skip_install = true 18 | commands = pre-commit run --all-files 19 | 20 | [testenv:docs] 21 | deps = -rdocs/requirements.txt 22 | extras = 23 | commands = sphinx-build docs/ docs/_build {posargs} 24 | 25 | 26 | ; Below tasks are for development only (not run in CI) 27 | 28 | [testenv:watch-docs] 29 | deps = 30 | -rdocs/requirements.txt 31 | sphinx-autobuild 32 | extras = 33 | commands = sphinx-autobuild --open-browser docs/ docs/_build {posargs} --watch marshmallow_jsonapi --delay 2 34 | 35 | [testenv:watch-readme] 36 | deps = restview 37 | skip_install = true 38 | commands = restview README.rst 39 | --------------------------------------------------------------------------------