├── docs ├── readme.rst ├── contributing.rst ├── index.rst ├── Makefile ├── make.bat ├── usage.rst ├── installation.rst └── conf.py ├── Pipfile ├── .editorconfig ├── .github └── ISSUE_TEMPLATE.md ├── json_api_doc ├── __init__.py ├── __main__.py ├── deserialization.py └── serialization.py ├── tox.ini ├── setup.cfg ├── LICENSE ├── .travis.yml ├── setup.py ├── .gitignore ├── tests ├── test_simple.py ├── test_serialize.py ├── test_relationships.py └── test_deserialize.py ├── Makefile ├── CONTRIBUTING.rst └── README.rst /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | JSON API Doc's documentation 2 | ============================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | readme 9 | installation 10 | usage 11 | contributing 12 | 13 | Indices and tables 14 | ================== 15 | * :ref:`genindex` 16 | * :ref:`search` 17 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [dev-packages] 7 | bumpversion = "*" 8 | watchdog = "*" 9 | "flake8" = "*" 10 | tox = "*" 11 | coverage = "*" 12 | twine = "*" 13 | pytest = "*" 14 | pytest-runner = "*" 15 | Sphinx = "*" 16 | "e1839a8" = {path = ".", editable = true} 17 | 18 | [requires] 19 | python_version = "3.6" 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * JSON API doc version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /json_api_doc/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = """Julien Duponchelle""" 4 | __email__ = 'julien@duponchelle.info' 5 | __version__ = '0.15.0' 6 | 7 | 8 | from .serialization import serialize 9 | from .deserialization import deserialize, _flat, _resolve, _parse_included 10 | 11 | # keeps backwards compatibility 12 | parse = deserialize 13 | 14 | __all__ = ['serialize', 'deserialize', '_flat', '_resolve', '_parse_included'] 15 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py34, py35, py36, flake8 3 | 4 | [flake8] 5 | ignore=E402,E741 6 | 7 | [travis] 8 | python = 9 | 3.6: py36 10 | 3.5: py35 11 | 12 | [testenv:flake8] 13 | basepython = python 14 | deps = flake8 15 | commands = flake8 json_api_doc 16 | 17 | [testenv] 18 | setenv = 19 | PYTHONPATH = {toxinidir} 20 | commands = 21 | pip install -U pipenv 22 | pipenv install -d --system 23 | py.test --basetemp={envtmpdir} 24 | 25 | 26 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.15.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:json_api_doc/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | 20 | [aliases] 21 | test = pytest 22 | 23 | [tool:pytest] 24 | collect_ignore = ['setup.py'] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache Software License 2.0 2 | 3 | Copyright (c) 2018, Julien Duponchelle 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = json-api-doc 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=json-api-doc 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /json_api_doc/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | from . import parse 4 | 5 | 6 | def main(): 7 | if len(sys.argv) > 1: 8 | for path in sys.argv[1:]: 9 | try: 10 | with open(path) as f: 11 | _read_file(f) 12 | except OSError as e: 13 | print(e, file=sys.stderr) 14 | sys.exit(1) 15 | else: 16 | _read_file(sys.stdin) 17 | 18 | 19 | def _read_file(content): 20 | try: 21 | content = json.load(content) 22 | except json.decoder.JSONDecodeError: 23 | print("Invalid JSON file", file=sys.stderr) 24 | sys.exit(1) 25 | except KeyboardInterrupt: 26 | sys.exit(1) 27 | try: 28 | doc = parse(content) 29 | except AttributeError as e: 30 | print(e, file=sys.stderr) 31 | sys.exit(1) 32 | print(json.dumps(doc, indent=4)) 33 | 34 | 35 | if __name__ == "__main__": 36 | main() 37 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use JSON API doc in a project:: 6 | 7 | import json_api_doc 8 | 9 | The module provides 2 functions, serialize and deserialize. 10 | 11 | To transform the JSON API document into a simple denormalized Python dict use 12 | `deserialize`: 13 | 14 | .. code-block:: python 15 | 16 | document = { 17 | 'data': { 18 | 'type': 'article, 19 | 'id': '1', 20 | 'attributes': { 21 | 'name': 'Article 1' 22 | } 23 | } 24 | } 25 | json_api_doc.deserialize(document) 26 | 27 | To transform the a Python dict object into a normalized JSON API document use 28 | `serialize`: 29 | 30 | .. code-block:: python 31 | 32 | obj = { 33 | '$type': 'article, 34 | 'id': '1', 35 | 'name': 'Article 1' 36 | } 37 | json_api_doc.serialize(document) 38 | 39 | 40 | .. automodule:: json_api_doc 41 | :members: 42 | :undoc-members: 43 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.6 4 | - 3.5 5 | install: pip install -U tox-travis 6 | script: tox 7 | deploy: 8 | provider: pypi 9 | distributions: sdist bdist_wheel 10 | user: noplay 11 | password: 12 | secure: JfFFQHbFc16M3SEjLLoD1ASw2h9z6EHFdz0Bl6GrXgtzMOz5o6vDC3xIFleIkVwhS6RqZpS98ujoNxuqf4m666Th42aYh3fGvS9JNS8baD8z1CPoGEJXYCRhUjPItI5zkylyKGO+abqGE/1HET8BWWNpRx8W9l7m2tDzqayvetrtQFDVsN0DOW+5iopbxoB4TRmVB7VNTVWdaDvNn3KjzJROFhOwsrsOzPyVrMQhlNSDz1SVE8l0ae5bo1Rt2A9MrrTmD0NnrupOGGznEvaPXEQaBrGaK9nqQc7/egSkmTLusBLe6aBCw/oUeWx+6e4906zc8mDTG5yBEnGQQOE8yXBVs2315Hq8xcMkFCRIqWwZ6oUnwLqRfcwivLiMTpLyerniBsS1mEm0PMB7mOpohSMfoMRLFXHgS7b1AU1BWZDRdREfDDeEJQJeXFPhUR8SAyDdCKRj9e7ukXEMnK9TAOv71DvaMHQ+LpT/MaTnJf0O5Kvayjvf0JQOiNbYKo83tGxAHZbxZnmUH5O8sDWJWhLFpyuCDnDIT8qhykDNtaaIE2bzxDDSZFIROKVpUMHc6j8JB2Rkwn8FXICylajsH7uaFicDkBEH6eIXxYY8+0KKL5tsnaQFMj/XJFGcWGgZrxdngKhQsE6c2vvAKTOL3Ww16Coy5Ix+l/tTVpoKO8E= 13 | on: 14 | tags: true 15 | repo: noplay/json-api-doc 16 | python: 3.6 17 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install JSON API doc, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install json-api-doc 16 | 17 | This is the preferred method to install JSON API doc, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From sources 27 | ------------ 28 | 29 | The sources for JSON API doc can be downloaded from the `Github repo`_. 30 | 31 | You can either clone the public repository: 32 | 33 | .. code-block:: console 34 | 35 | $ git clone git://github.com/noplay/json-api-doc 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OL https://github.com/noplay/json-api-doc/tarball/master 42 | 43 | Once you have a copy of the source, you can install it with: 44 | 45 | .. code-block:: console 46 | 47 | $ python setup.py install 48 | 49 | 50 | .. _Github repo: https://github.com/noplay/json-api-doc 51 | .. _tarball: https://github.com/noplay/json-api-doc/tarball/master 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """The setup script.""" 5 | 6 | from setuptools import setup, find_packages 7 | 8 | with open('README.rst') as readme_file: 9 | readme = readme_file.read() 10 | 11 | requirements = [] 12 | 13 | setup_requirements = ['pytest-runner', ] 14 | 15 | test_requirements = ['pytest', ] 16 | 17 | setup( 18 | author="Julien Duponchelle", 19 | author_email='julien@duponchelle.info', 20 | classifiers=[ 21 | 'Development Status :: 2 - Pre-Alpha', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: Apache Software License', 24 | 'Natural Language :: English', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3.5', 27 | 'Programming Language :: Python :: 3.6', 28 | ], 29 | description="JSON API to document parser", 30 | install_requires=requirements, 31 | license="Apache Software License 2.0", 32 | long_description=readme, 33 | include_package_data=True, 34 | keywords='json api', 35 | name='json-api-doc', 36 | packages=find_packages(include=['json_api_doc']), 37 | setup_requires=setup_requirements, 38 | test_suite='tests', 39 | tests_require=test_requirements, 40 | url='https://github.com/noplay/json-api-doc', 41 | version='0.15.0', 42 | entry_points={ 43 | 'console_scripts': ['jsonapidoc = json_api_doc.__main__:main'], 44 | } 45 | ) 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | .vscode 104 | -------------------------------------------------------------------------------- /tests/test_simple.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for `json_api_doc` package.""" 5 | 6 | import pytest 7 | 8 | 9 | import json_api_doc 10 | 11 | 12 | def test_simple_null_object(): 13 | response = { 14 | "data": None 15 | } 16 | doc = json_api_doc.parse(response) 17 | assert doc is None 18 | 19 | 20 | def test_simple_object(): 21 | response = { 22 | "data": { 23 | "type": "article", 24 | "id": "1", 25 | "attributes": { 26 | "title": "Article 1" 27 | }, 28 | } 29 | } 30 | doc = json_api_doc.parse(response) 31 | assert doc == { 32 | "type": "article", 33 | "id": "1", 34 | "title": "Article 1" 35 | } 36 | 37 | 38 | def test_simple_object_without_attributes(): 39 | response = { 40 | "data": { 41 | "type": "article", 42 | "id": "1" 43 | } 44 | } 45 | doc = json_api_doc.parse(response) 46 | assert doc == { 47 | "type": "article", 48 | "id": "1" 49 | } 50 | 51 | 52 | def test_simple_list(): 53 | response = { 54 | "data": [ 55 | { 56 | "type": "article", 57 | "id": "1", 58 | "attributes": { 59 | "title": "Article 1" 60 | }, 61 | }, 62 | { 63 | "type": "article", 64 | "id": "2", 65 | "attributes": { 66 | "title": "Article 2" 67 | }, 68 | } 69 | ] 70 | } 71 | doc = json_api_doc.parse(response) 72 | assert len(doc) == 2 73 | assert doc[0] == { 74 | "type": "article", 75 | "id": "1", 76 | "title": "Article 1" 77 | } 78 | assert doc[1] == { 79 | "type": "article", 80 | "id": "2", 81 | "title": "Article 2" 82 | } 83 | 84 | 85 | def test_invalid(): 86 | with pytest.raises(AttributeError): 87 | json_api_doc.parse({"a": 1}) 88 | 89 | 90 | def test_error(): 91 | response = { 92 | "errors": [{ 93 | "status": "404", 94 | "title": "not found", 95 | "detail": "Resource not found" 96 | }] 97 | } 98 | doc = json_api_doc.parse(response) 99 | assert doc == response 100 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | try: 8 | from urllib import pathname2url 9 | except: 10 | from urllib.request import pathname2url 11 | 12 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 13 | endef 14 | export BROWSER_PYSCRIPT 15 | 16 | define PRINT_HELP_PYSCRIPT 17 | import re, sys 18 | 19 | for line in sys.stdin: 20 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 21 | if match: 22 | target, help = match.groups() 23 | print("%-20s %s" % (target, help)) 24 | endef 25 | export PRINT_HELP_PYSCRIPT 26 | 27 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 28 | 29 | help: 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | 32 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 33 | 34 | clean-build: ## remove build artifacts 35 | rm -fr build/ 36 | rm -fr dist/ 37 | rm -fr .eggs/ 38 | find . -name '*.egg-info' -exec rm -fr {} + 39 | find . -name '*.egg' -exec rm -f {} + 40 | 41 | clean-pyc: ## remove Python file artifacts 42 | find . -name '*.pyc' -exec rm -f {} + 43 | find . -name '*.pyo' -exec rm -f {} + 44 | find . -name '*~' -exec rm -f {} + 45 | find . -name '__pycache__' -exec rm -fr {} + 46 | 47 | clean-test: ## remove test and coverage artifacts 48 | rm -fr .tox/ 49 | rm -f .coverage 50 | rm -fr htmlcov/ 51 | rm -fr .pytest_cache 52 | 53 | lint: ## check style with flake8 54 | flake8 json_api_doc tests 55 | 56 | test: ## run tests quickly with the default Python 57 | py.test 58 | 59 | test-all: ## run tests on every Python version with tox 60 | tox 61 | 62 | coverage: ## check code coverage quickly with the default Python 63 | coverage run --source json_api_doc -m pytest 64 | coverage report -m 65 | coverage html 66 | $(BROWSER) htmlcov/index.html 67 | 68 | docs: ## generate Sphinx HTML documentation, including API docs 69 | rm -f docs/json_api_doc.rst 70 | rm -f docs/modules.rst 71 | sphinx-apidoc -o docs/ json_api_doc 72 | $(MAKE) -C docs clean 73 | $(MAKE) -C docs html 74 | $(BROWSER) docs/_build/html/index.html 75 | 76 | servedocs: docs ## compile the docs watching for changes 77 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 78 | 79 | release: dist ## package and upload a release 80 | twine upload dist/* 81 | 82 | dist: clean ## builds source and wheel package 83 | python setup.py sdist 84 | python setup.py bdist_wheel 85 | ls -l dist 86 | 87 | install: clean ## install the package to the active Python's site-packages 88 | python setup.py install 89 | -------------------------------------------------------------------------------- /json_api_doc/deserialization.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | 4 | def deserialize(content): 5 | """ 6 | :param content: A JSON API document already 7 | :returns: The JSON API document parsed 8 | """ 9 | if "errors" in content: 10 | return content 11 | 12 | if "data" not in content: 13 | raise AttributeError("This is not a JSON API document") 14 | 15 | # be nondestructive with provided content 16 | content = copy.deepcopy(content) 17 | 18 | if "included" in content: 19 | included = _parse_included(content["included"]) 20 | else: 21 | included = {} 22 | if isinstance(content["data"], dict): 23 | return _resolve(_flat(content["data"]), included, set()) 24 | elif isinstance(content["data"], list): 25 | result = [] 26 | for obj in content["data"]: 27 | result.append(_resolve(_flat(obj), included, set())) 28 | return result 29 | else: 30 | return None 31 | 32 | 33 | def _resolve(data, included, resolved, deep=True): 34 | if not isinstance(data, dict): 35 | return data 36 | keys = data.keys() 37 | if keys == {"type", "id"} or keys == {"type", "id", "meta"}: 38 | type_id = data["type"], data["id"] 39 | meta = data.get("meta") 40 | resolved_item = included.get(type_id, data) 41 | resolved_item = resolved_item.copy() 42 | if type_id not in resolved: 43 | data = _resolve( 44 | resolved_item, 45 | included, 46 | resolved | {type_id} 47 | ) 48 | if meta is not None: 49 | data = data.copy() 50 | data.update(meta=meta) 51 | return data 52 | for key, value in data.items(): 53 | if isinstance(value, dict): 54 | data[key] = _resolve(value, included, resolved) 55 | elif isinstance(value, list): 56 | if deep: 57 | data[key] = [ 58 | _resolve(item, included, resolved, False) 59 | for item in value 60 | ] 61 | else: 62 | data[key] = value 63 | return data 64 | 65 | 66 | def _parse_included(included): 67 | result = {} 68 | for include in included: 69 | result[(include["type"], include["id"])] = _flat(include) 70 | return result 71 | 72 | 73 | def _flat(obj): 74 | obj.pop("links", None) 75 | obj.update(obj.pop("attributes", {})) 76 | if "relationships" in obj: 77 | for relationship, item in obj.pop("relationships").items(): 78 | data = item.get("data") 79 | links = item.get("links") 80 | if data is not None: 81 | obj[relationship] = data 82 | elif links: 83 | obj[relationship] = item 84 | else: 85 | obj[relationship] = None 86 | return obj 87 | -------------------------------------------------------------------------------- /json_api_doc/serialization.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from collections import OrderedDict 4 | 5 | 6 | def serialize(data={}, errors={}, meta={}, links={}): 7 | """ 8 | :param data: Dict with data to serialize 9 | :param errors: Dict with error data to serialize 10 | :param meta: Dict with meta data to serialize 11 | :returns: Dict normalized as a valid JSON API document 12 | """ 13 | 14 | if data and errors: 15 | raise AttributeError("""Only 'data' or 'errors' can be present in a 16 | valid JSON API document""") 17 | 18 | included = OrderedDict() 19 | res = {} 20 | if data: 21 | if isinstance(data, list): 22 | res["data"] = list( 23 | map(lambda item: _serialize(item, included), data)) 24 | else: 25 | res["data"] = _serialize(data, included) 26 | elif isinstance(data, list): 27 | res["data"] = [] 28 | 29 | if included: 30 | res["included"] = list(included.values()) 31 | 32 | if meta: 33 | res["meta"] = meta 34 | 35 | if errors: 36 | res["errors"] = errors 37 | 38 | if links: 39 | res["links"] = links 40 | 41 | return res or {"data": None} 42 | 43 | 44 | def _serialize(data, included): 45 | obj_type = data.get("$type", None) 46 | if obj_type is None: 47 | raise AttributeError("Missing object $type") 48 | 49 | res = _expand(data, included) 50 | 51 | res["type"] = obj_type 52 | obj_id = data.get("id", None) 53 | if obj_id is not None: 54 | res["id"] = obj_id 55 | 56 | return res 57 | 58 | 59 | def _expand(data, included): 60 | res = {} 61 | attrs = {} 62 | rels = {} 63 | for k, v in data.items(): 64 | if k in ["$type", "id"]: 65 | continue 66 | 67 | if isinstance(v, dict): 68 | embedded, is_res = _expand_included(v, included) 69 | if is_res: 70 | rels[k] = { 71 | "data": embedded 72 | } 73 | else: 74 | attrs[k] = embedded 75 | elif isinstance(v, list): 76 | embedded = list(map(lambda l: _expand_included(l, included), v)) 77 | if all(map(lambda i: i[1], embedded)): 78 | rels[k] = { 79 | "data": list(map(lambda i: i[0], embedded)) 80 | } 81 | else: 82 | attrs[k] = list(map(lambda i: i[0], embedded)) 83 | else: 84 | attrs[k] = v 85 | 86 | if len(attrs): 87 | res["attributes"] = attrs 88 | 89 | if len(rels): 90 | res["relationships"] = rels 91 | 92 | return res 93 | 94 | 95 | def _expand_included(data, included): 96 | if not isinstance(data, dict): 97 | return data, False 98 | 99 | typ = data.get("$type", None) 100 | id = data.get("id", None) 101 | 102 | if typ is None or id is None: 103 | # not a sub-resource, return as is 104 | return data, False 105 | 106 | if typ is not None and id is not None and (typ, id) not in included: 107 | serialized = _expand(data, included) 108 | serialized["type"] = typ 109 | serialized["id"] = id 110 | included[(typ, id)] = serialized 111 | 112 | return {"type": typ, "id": id}, True 113 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/noplay/json-api-doc/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | JSON API doc could always use more documentation, whether as part of the 42 | official JSON API doc docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/noplay/json-api-doc/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `json-api-doc` for local development. 61 | 62 | 1. Fork the `json-api-doc` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/json-api-doc.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have pipenv installed, 68 | this is how you set up your fork for local development:: 69 | 70 | $ cd json-api-doc/ 71 | $ pipenv shell 72 | $ pipenv install --dev 73 | 74 | 4. Create a branch for local development:: 75 | 76 | $ git checkout -b name-of-your-bugfix-or-feature 77 | 78 | Now you can make your changes locally. 79 | 80 | 5. When you're done making changes, check that your changes pass flake8 and the 81 | tests, including testing other Python versions with tox:: 82 | 83 | $ flake8 json_api_doc tests 84 | $ python setup.py test or py.test 85 | $ tox 86 | 87 | To get flake8 and tox, just pip install them into your virtualenv. 88 | 89 | 6. Commit your changes and push your branch to GitHub:: 90 | 91 | $ git add . 92 | $ git commit -m "Your detailed description of your changes." 93 | $ git push origin name-of-your-bugfix-or-feature 94 | 95 | 7. Submit a pull request through the GitHub website. 96 | 97 | Pull Request Guidelines 98 | ----------------------- 99 | 100 | Before you submit a pull request, check that it meets these guidelines: 101 | 102 | 1. The pull request should include tests. 103 | 2. If the pull request adds functionality, the docs should be updated. Put 104 | your new functionality into a function with a docstring, and add the 105 | feature to the list in README.rst. 106 | 3. The pull request should work for Python 3.5 and 3.6, and for PyPy. Check 107 | https://travis-ci.org/noplay/json-api-doc/pull_requests 108 | and make sure that the tests pass for all supported Python versions. 109 | 110 | Tips 111 | ---- 112 | 113 | To run a subset of tests:: 114 | 115 | $ py.test tests.test_simple 116 | 117 | 118 | Deploying 119 | --------- 120 | 121 | A reminder for the maintainers on how to deploy. 122 | Make sure all your changes are committed 123 | Then run:: 124 | 125 | $ bumpversion patch # possible: major / minor / patch 126 | $ git push 127 | $ git push --tags 128 | 129 | Travis will then deploy to PyPI if tests pass. 130 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # json-api-doc documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another 17 | # directory, add these directories to sys.path here. If the directory is 18 | # relative to the documentation root, use os.path.abspath to make it 19 | # absolute, like shown here. 20 | # 21 | import os 22 | import sys 23 | sys.path.insert(0, os.path.abspath('..')) 24 | 25 | import json_api_doc 26 | 27 | # -- General configuration --------------------------------------------- 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | # 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 35 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix(es) of source filenames. 41 | # You can specify multiple suffix as a list of string: 42 | # 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = '.rst' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = u'JSON API doc' 51 | copyright = u"2018, Julien Duponchelle" 52 | author = u"Julien Duponchelle" 53 | 54 | # The version info for the project you're documenting, acts as replacement 55 | # for |version| and |release|, also used in various other places throughout 56 | # the built documents. 57 | # 58 | # The short X.Y version. 59 | version = json_api_doc.__version__ 60 | # The full version, including alpha/beta/rc tags. 61 | release = json_api_doc.__version__ 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = None 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | # This patterns also effect to html_static_path and html_extra_path 73 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | pygments_style = 'sphinx' 77 | 78 | # If true, `todo` and `todoList` produce output, else they produce nothing. 79 | todo_include_todos = False 80 | 81 | 82 | # -- Options for HTML output ------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = 'alabaster' 88 | 89 | # Theme options are theme-specific and customize the look and feel of a 90 | # theme further. For a list of options available for each theme, see the 91 | # documentation. 92 | # 93 | # html_theme_options = {} 94 | 95 | # Add any paths that contain custom static files (such as style sheets) here, 96 | # relative to this directory. They are copied after the builtin static files, 97 | # so a file named "default.css" will overwrite the builtin "default.css". 98 | # html_static_path = ['_static'] 99 | 100 | 101 | # -- Options for HTMLHelp output --------------------------------------- 102 | 103 | # Output file base name for HTML help builder. 104 | htmlhelp_basename = 'json_api_docdoc' 105 | 106 | 107 | # -- Options for LaTeX output ------------------------------------------ 108 | 109 | latex_elements = { 110 | # The paper size ('letterpaper' or 'a4paper'). 111 | # 112 | # 'papersize': 'letterpaper', 113 | 114 | # The font size ('10pt', '11pt' or '12pt'). 115 | # 116 | # 'pointsize': '10pt', 117 | 118 | # Additional stuff for the LaTeX preamble. 119 | # 120 | # 'preamble': '', 121 | 122 | # Latex figure (float) alignment 123 | # 124 | # 'figure_align': 'htbp', 125 | } 126 | 127 | # Grouping the document tree into LaTeX files. List of tuples 128 | # (source start file, target name, title, author, documentclass 129 | # [howto, manual, or own class]). 130 | latex_documents = [ 131 | (master_doc, 'json_api_doc.tex', 132 | u'JSON API doc Documentation', 133 | u'Julien Duponchelle', 'manual'), 134 | ] 135 | 136 | 137 | # -- Options for manual page output ------------------------------------ 138 | 139 | # One entry per manual page. List of tuples 140 | # (source start file, name, description, authors, manual section). 141 | man_pages = [ 142 | (master_doc, 'json_api_doc', 143 | u'JSON API doc Documentation', 144 | [author], 1) 145 | ] 146 | 147 | 148 | # -- Options for Texinfo output ---------------------------------------- 149 | 150 | # Grouping the document tree into Texinfo files. List of tuples 151 | # (source start file, target name, title, author, 152 | # dir menu entry, description, category) 153 | texinfo_documents = [ 154 | (master_doc, 'json_api_doc', 155 | u'JSON API Doc Documentation', 156 | author, 157 | 'json_api_doc', 158 | 'JSON API to document parser', 159 | 'Miscellaneous'), 160 | ] 161 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | JSON API Doc 3 | ============ 4 | 5 | 6 | .. image:: https://img.shields.io/pypi/v/json-api-doc.svg 7 | :target: https://pypi.python.org/pypi/json-api-doc 8 | 9 | .. image:: https://img.shields.io/travis/noplay/json-api-doc.svg 10 | :target: https://travis-ci.org/noplay/json-api-doc 11 | 12 | .. image:: https://readthedocs.org/projects/json-api-doc/badge/?version=latest 13 | :target: https://json-api-doc.readthedocs.io/en/latest/?badge=latest 14 | :alt: Documentation Status 15 | 16 | 17 | .. image:: https://pyup.io/repos/github/noplay/json-api-doc/shield.svg 18 | :target: https://pyup.io/repos/github/noplay/json-api-doc/ 19 | :alt: Updates 20 | 21 | 22 | 23 | This library provides ability to transform between normalized JSON API 24 | (http://jsonapi.org/) documents and denormalized Python dictionary object for 25 | easier manipulation in code. 26 | Also available as a command line utility and Python 3 module. 27 | 28 | Deserialization 29 | ~~~~~~~~~~~~~~~ 30 | 31 | For this JSON API document: 32 | 33 | .. code-block:: json 34 | 35 | { 36 | "data": [{ 37 | "type": "articles", 38 | "id": "1", 39 | "attributes": { 40 | "title": "JSON API paints my bikeshed!", 41 | "body": "The shortest article. Ever.", 42 | "created": "2015-05-22T14:56:29.000Z", 43 | "updated": "2015-05-22T14:56:28.000Z" 44 | }, 45 | "relationships": { 46 | "author": { 47 | "data": {"id": "42", "type": "people"} 48 | } 49 | } 50 | }], 51 | "included": [ 52 | { 53 | "type": "people", 54 | "id": "42", 55 | "attributes": { 56 | "name": "John", 57 | "age": 80, 58 | "gender": "male" 59 | } 60 | } 61 | ] 62 | } 63 | 64 | The simplified version will be: 65 | 66 | .. code-block:: json 67 | 68 | [ 69 | { 70 | "type": "articles", 71 | "id": "1", 72 | "title": "JSON API paints my bikeshed!", 73 | "body": "The shortest article. Ever.", 74 | "created": "2015-05-22T14:56:29.000Z", 75 | "updated": "2015-05-22T14:56:28.000Z", 76 | "author": { 77 | "type": "people", 78 | "id": "42", 79 | "name": "John", 80 | "age": 80, 81 | "gender": "male" 82 | } 83 | } 84 | ] 85 | 86 | Serialization 87 | ~~~~~~~~~~~~~ 88 | 89 | To turn an dict into JSON API specification document the root of your object 90 | must contain a `$type` key with a value corresponding to the name of 91 | the object's resource type. Any sub-dict or sub-array of dicts that also 92 | contain a `$type` key will be considered an included documents and serialized 93 | accordingly. 94 | 95 | .. code-block:: json 96 | 97 | [ 98 | { 99 | "$type": "articles", 100 | "id": "1", 101 | "title": "JSON API paints my bikeshed!", 102 | "body": "The shortest article. Ever.", 103 | "created": "2015-05-22T14:56:29.000Z", 104 | "updated": "2015-05-22T14:56:28.000Z", 105 | "author": { 106 | "$type": "people", 107 | "id": "42", 108 | "name": "John", 109 | "age": 80, 110 | "gender": "male" 111 | } 112 | } 113 | ] 114 | 115 | .. code-block:: json 116 | 117 | { 118 | "data": [{ 119 | "type": "articles", 120 | "id": "1", 121 | "attributes": { 122 | "title": "JSON API paints my bikeshed!", 123 | "body": "The shortest article. Ever.", 124 | "created": "2015-05-22T14:56:29.000Z", 125 | "updated": "2015-05-22T14:56:28.000Z" 126 | }, 127 | "relationships": { 128 | "author": { 129 | "data": {"id": "42", "type": "people"} 130 | } 131 | } 132 | }], 133 | "included": [ 134 | { 135 | "type": "people", 136 | "id": "42", 137 | "attributes": { 138 | "name": "John", 139 | "age": 80, 140 | "gender": "male" 141 | } 142 | } 143 | ] 144 | } 145 | 146 | Usage as python module 147 | ---------------------- 148 | 149 | .. code-block:: python 150 | 151 | import json_api_doc 152 | 153 | document = { 154 | 'data': { 155 | 'type': 'article', 156 | 'id': '1', 157 | 'attributes': { 158 | 'name': 'Article 1' 159 | } 160 | } 161 | } 162 | json_api_doc.deserialize(document) 163 | 164 | .. code-block:: python 165 | 166 | import json_api_doc 167 | 168 | document = { 169 | '$type': 'article', 170 | 'id': '1', 171 | 'name': 'Article 1' 172 | } 173 | json_api_doc.serialize(document) 174 | 175 | Usage as cli 176 | ------------ 177 | 178 | .. code-block:: bash 179 | 180 | $ jsonapidoc document.json 181 | 182 | 183 | Contributors 184 | ------------- 185 | * Julien Duponchelle (https://github.com/noplay) 186 | * Antonio Martinović (https://github.com/TopHatCroat) 187 | * Jeff Zellman (https://github.com/jzellman) 188 | * Brenda Deely (https://github.com/brendadeely) 189 | * Taylor Hobbs (https://github.com/TayHobbs) 190 | 191 | Licence 192 | -------- 193 | Free software: Apache Software License 2.0 194 | 195 | Documentation 196 | -------------- 197 | Full Documentation is available: https://json-api-doc.readthedocs.io. 198 | 199 | -------------------------------------------------------------------------------- /tests/test_serialize.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for `json_api_doc` package.""" 5 | 6 | import pytest 7 | 8 | import json_api_doc 9 | 10 | 11 | def test_serialize_object(): 12 | data = { 13 | "$type": "article", 14 | "id": "1", 15 | "title": "Article 1" 16 | } 17 | doc = json_api_doc.serialize(data) 18 | assert doc == { 19 | "data": { 20 | "type": "article", 21 | "id": "1", 22 | "attributes": { 23 | "title": "Article 1" 24 | } 25 | } 26 | } 27 | 28 | 29 | def test_serialize_object_list(): 30 | data = [{ 31 | "$type": "article", 32 | "id": "1", 33 | "title": "Article 1" 34 | }, { 35 | "$type": "article", 36 | "id": "2", 37 | "title": "Article 2" 38 | }] 39 | 40 | doc = json_api_doc.serialize(data) 41 | assert doc == { 42 | "data": [{ 43 | "type": "article", 44 | "id": "1", 45 | "attributes": { 46 | "title": "Article 1" 47 | } 48 | }, { 49 | "type": "article", 50 | "id": "2", 51 | "attributes": { 52 | "title": "Article 2" 53 | } 54 | }] 55 | } 56 | 57 | def test_serialize_empty_list(): 58 | doc = json_api_doc.serialize([]) 59 | assert doc == { 60 | "data": [] 61 | } 62 | 63 | 64 | def test_serialize_object_without_attributes(): 65 | data = { 66 | "$type": "article", 67 | "id": "1" 68 | } 69 | doc = json_api_doc.serialize(data) 70 | assert doc == { 71 | "data": { 72 | "type": "article", 73 | "id": "1" 74 | } 75 | } 76 | 77 | 78 | def test_invalid(): 79 | with pytest.raises(AttributeError): 80 | json_api_doc.serialize({"a": 1}) 81 | 82 | 83 | def test_serialize_object_embedded(): 84 | data = { 85 | "$type": "article", 86 | "id": "1", 87 | "title": "Article 1", 88 | "author": { 89 | "$type": "people", 90 | "id": "9", 91 | "name": "Bob" 92 | } 93 | } 94 | doc = json_api_doc.serialize(data) 95 | assert doc == { 96 | "data": { 97 | "type": "article", 98 | "id": "1", 99 | "attributes": { 100 | "title": "Article 1" 101 | }, 102 | "relationships": { 103 | "author": { 104 | "data": {"type": "people", "id": "9"} 105 | } 106 | } 107 | }, 108 | "included": [{ 109 | "type": "people", 110 | "id": "9", 111 | "attributes": { 112 | "name": "Bob", 113 | } 114 | }] 115 | } 116 | 117 | 118 | def test_serialize_object_embedded_list(): 119 | data = { 120 | "$type": "article", 121 | "id": "1", 122 | "title": "Article 1", 123 | "comments": [{ 124 | "$type": "comment", 125 | "id": "100", 126 | "content": "First" 127 | }, { 128 | "$type": "comment", 129 | "id": "101", 130 | "content": "Second" 131 | }] 132 | } 133 | doc = json_api_doc.serialize(data) 134 | assert doc == { 135 | "data": { 136 | "type": "article", 137 | "id": "1", 138 | "attributes": { 139 | "title": "Article 1" 140 | }, 141 | "relationships": { 142 | "comments": { 143 | "data": [ 144 | {"type": "comment", "id": "100"}, 145 | {"type": "comment", "id": "101"} 146 | ] 147 | } 148 | } 149 | }, 150 | "included": [{ 151 | "type": "comment", 152 | "id": "100", 153 | "attributes": { 154 | "content": "First", 155 | } 156 | }, { 157 | "type": "comment", 158 | "id": "101", 159 | "attributes": { 160 | "content": "Second", 161 | } 162 | }] 163 | } 164 | 165 | 166 | def test_serialize_object_embedded_json(): 167 | data = { 168 | "$type": "article", 169 | "id": "1", 170 | "title": "Article 1", 171 | "author": { 172 | "$type": "people", 173 | "id": "100" 174 | }, 175 | "inner": { 176 | "value": "embedded regular JSON" 177 | }, 178 | "innerArray": [ 179 | "embedded", "regular", "JSON", "array" 180 | ], 181 | "innerObjectArray": [ 182 | { 183 | "value": "something" 184 | }, { 185 | "value": "something_else" 186 | } 187 | ] 188 | } 189 | doc = json_api_doc.serialize(data) 190 | assert doc == { 191 | "data": { 192 | "type": "article", 193 | "id": "1", 194 | "attributes": { 195 | "title": "Article 1", 196 | "inner": { 197 | "value": "embedded regular JSON" 198 | }, 199 | "innerArray": [ 200 | "embedded", "regular", "JSON", "array" 201 | ], 202 | "innerObjectArray": [ 203 | { 204 | "value": "something" 205 | }, { 206 | "value": "something_else" 207 | } 208 | ] 209 | }, 210 | "relationships": { 211 | "author": { 212 | "data": { 213 | "type": "people", 214 | "id": "100" 215 | } 216 | } 217 | } 218 | }, 219 | "included": [{ 220 | "type": "people", 221 | "id": "100" 222 | }] 223 | } 224 | 225 | 226 | def test_serialize_meta(): 227 | meta = { 228 | "some": "random", 229 | "silly": "data" 230 | } 231 | doc = json_api_doc.serialize(meta=meta) 232 | assert doc == { 233 | "meta": { 234 | "some": "random", 235 | "silly": "data" 236 | } 237 | } 238 | 239 | 240 | def test_serialize_errors(): 241 | errors = { 242 | "some": "random", 243 | "silly": "data" 244 | } 245 | doc = json_api_doc.serialize(errors=errors) 246 | assert doc == { 247 | "errors": { 248 | "some": "random", 249 | "silly": "data" 250 | } 251 | } 252 | 253 | 254 | def test_serialize_links(): 255 | links = { 256 | "some": "random", 257 | "silly": { 258 | "href": "random", 259 | "meta": { 260 | "silly": "data" 261 | } 262 | } 263 | } 264 | doc = json_api_doc.serialize(links=links) 265 | assert doc == { 266 | "links": { 267 | "some": "random", 268 | "silly": { 269 | "href": "random", 270 | "meta": { 271 | "silly": "data" 272 | } 273 | } 274 | } 275 | } 276 | 277 | 278 | def test_serialize_object_deep(): 279 | data = { 280 | "$type": "article", 281 | "id": "1", 282 | "title": "Article 1", 283 | "author": { 284 | "$type": "people", 285 | "id": "10", 286 | "name": "Bob", 287 | "role": { 288 | "$type": "role", 289 | "id": "100", 290 | "name": "Writer" 291 | } 292 | } 293 | } 294 | doc = json_api_doc.serialize(data) 295 | assert doc == { 296 | "data": { 297 | "type": "article", 298 | "id": "1", 299 | "attributes": { 300 | "title": "Article 1" 301 | }, 302 | "relationships": { 303 | "author": { 304 | "data": { 305 | "type": "people", 306 | "id": "10" 307 | } 308 | } 309 | } 310 | }, 311 | "included": [ 312 | { 313 | "type": "role", 314 | "id": "100", 315 | "attributes": { 316 | "name": "Writer", 317 | } 318 | }, { 319 | "type": "people", 320 | "id": "10", 321 | "attributes": { 322 | "name": "Bob", 323 | }, 324 | "relationships": { 325 | "role": { 326 | "data": { 327 | "type": "role", 328 | "id": "100" 329 | } 330 | } 331 | } 332 | } 333 | ] 334 | } 335 | 336 | 337 | def test_error_and_data(): 338 | with pytest.raises(AttributeError): 339 | doc = { 340 | "data": { 341 | "$type": "article" 342 | }, 343 | "errors": { 344 | "status": 200 345 | } 346 | } 347 | json_api_doc.serialize(**doc) 348 | -------------------------------------------------------------------------------- /tests/test_relationships.py: -------------------------------------------------------------------------------- 1 | import json_api_doc 2 | 3 | 4 | def test_flat(): 5 | data = { 6 | "type": "article", 7 | "id": "1", 8 | "attributes": { 9 | "title": "Article 1" 10 | }, 11 | "relationships": { 12 | "author": { 13 | "data": {"type": "people", "id": "9"} 14 | } 15 | } 16 | } 17 | assert json_api_doc._flat(data)["author"] == {"type": "people", "id": "9"} 18 | 19 | 20 | def test_flat_list(): 21 | data = { 22 | "type": "article", 23 | "id": "1", 24 | "attributes": { 25 | "title": "Article 1" 26 | }, 27 | "relationships": { 28 | "authors": { 29 | "data": [ 30 | {"type": "people", "id": "9"}, 31 | {"type": "people", "id": "10"} 32 | ] 33 | } 34 | } 35 | } 36 | assert json_api_doc._flat(data)["authors"] == [ 37 | {"type": "people", "id": "9"}, 38 | {"type": "people", "id": "10"} 39 | ] 40 | 41 | 42 | def test_flat_none(): 43 | data = { 44 | "type": "article", 45 | "id": "1", 46 | "attributes": { 47 | "title": "Article 1" 48 | }, 49 | "relationships": { 50 | "authors": { 51 | "data": None 52 | } 53 | } 54 | } 55 | assert json_api_doc._flat(data)["authors"] is None 56 | 57 | 58 | def test_parse_included(): 59 | data = [{ 60 | "type": "people", 61 | "id": "9", 62 | "attributes": { 63 | "first-name": "Bob", 64 | "last-name": "Doe", 65 | } 66 | }] 67 | assert json_api_doc._parse_included(data) == { 68 | ("people", "9"): { 69 | "type": "people", 70 | "id": "9", 71 | "first-name": "Bob", 72 | "last-name": "Doe", 73 | } 74 | } 75 | 76 | 77 | def test_resolve(): 78 | included = { 79 | ("people", "9"): { 80 | "name": "Jean" 81 | } 82 | } 83 | data = { 84 | "title": "Article 1", 85 | "author": {"type": "people", "id": "9"} 86 | } 87 | doc = json_api_doc._resolve(data, included, set()) 88 | assert doc == { 89 | "title": "Article 1", 90 | "author": {"name": "Jean"} 91 | } 92 | 93 | 94 | def test_resolve_with_meta(): 95 | included = { 96 | ("people", "9"): { 97 | "name": "Jean" 98 | } 99 | } 100 | data = { 101 | "title": "Article 1", 102 | "author": {"type": "people", "id": "9", "meta": {"index": 3}} 103 | } 104 | doc = json_api_doc._resolve(data, included, set()) 105 | assert doc == { 106 | "title": "Article 1", 107 | "author": {"name": "Jean", "meta": {"index": 3}} 108 | } 109 | 110 | 111 | def test_resolve_missing(): 112 | included = { 113 | } 114 | data = { 115 | "title": "Article 1", 116 | "author": {"type": "people", "id": "9"} 117 | } 118 | doc = json_api_doc._resolve(data, included, set()) 119 | assert doc == { 120 | "title": "Article 1", 121 | "author": {"type": "people", "id": "9"} 122 | } 123 | 124 | 125 | def test_resolve_list(): 126 | included = { 127 | ("people", "9"): { 128 | "name": "Jean" 129 | }, 130 | ("people", "10"): { 131 | "name": "Luc" 132 | } 133 | } 134 | data = { 135 | "title": "Article 1", 136 | "authors": [ 137 | {"type": "people", "id": "9"}, 138 | {"type": "people", "id": "10"}, 139 | ] 140 | } 141 | doc = json_api_doc._resolve(data, included, set()) 142 | assert doc == { 143 | "title": "Article 1", 144 | "authors": [ 145 | {"name": "Jean"}, 146 | {"name": "Luc"}, 147 | ] 148 | } 149 | 150 | 151 | def test_resolve_list_with_meta(): 152 | included = { 153 | ("people", "9"): { 154 | "name": "Jean" 155 | }, 156 | ("people", "10"): { 157 | "name": "Luc" 158 | } 159 | } 160 | data = { 161 | "title": "Article 1", 162 | "authors": [ 163 | {"type": "people", "id": "9", "meta": {"index": 3}}, 164 | {"type": "people", "id": "10", "meta": {"index": 18}}, 165 | ] 166 | } 167 | doc = json_api_doc._resolve(data, included, set()) 168 | assert doc == { 169 | "title": "Article 1", 170 | "authors": [ 171 | {"name": "Jean", "meta": {"index": 3}}, 172 | {"name": "Luc", "meta": {"index": 18}}, 173 | ] 174 | } 175 | 176 | 177 | def test_resolve_list_missing_items(): 178 | included = { 179 | } 180 | data = { 181 | "title": "Article 1", 182 | "authors": [ 183 | {"type": "people", "id": "9"}, 184 | {"type": "people", "id": "10"}, 185 | ] 186 | } 187 | doc = json_api_doc._resolve(data, included, set()) 188 | assert doc == { 189 | "title": "Article 1", 190 | "authors": [ 191 | {"id": "9", "type": "people"}, 192 | {"id": "10", "type": "people"} 193 | ] 194 | } 195 | 196 | 197 | def test_resolve_nested(): 198 | included = { 199 | ("people", "9"): { 200 | "name": "Jean", 201 | "address": {"type": "location", "id": "2"}, 202 | }, 203 | ("location", "2"): { 204 | "street": "boulevard magenta", 205 | "city": {"type": "city", "id": "3"} 206 | }, 207 | ("city", "3"): { 208 | "name": "Paris" 209 | } 210 | } 211 | data = { 212 | "title": "Article 1", 213 | "author": {"type": "people", "id": "9"} 214 | } 215 | doc = json_api_doc._resolve(data, included, set()) 216 | assert doc == { 217 | "title": "Article 1", 218 | "author": { 219 | "name": "Jean", 220 | "address": { 221 | "street": "boulevard magenta", 222 | "city": {"name": "Paris"} 223 | } 224 | } 225 | } 226 | 227 | 228 | def test_resolve_loop(): 229 | included = { 230 | ("people", "1"): { 231 | "name": "Jean", 232 | "father": {"type": "people", "id": "2"}, 233 | }, 234 | ("people", "2"): { 235 | "name": "Luc", 236 | "son": {"type": "people", "id": "1"}, 237 | }, 238 | } 239 | data = { 240 | "title": "Article 1", 241 | "author": {"type": "people", "id": "1"} 242 | } 243 | doc = json_api_doc._resolve(data, included, set()) 244 | assert doc == { 245 | "title": "Article 1", 246 | "author": { 247 | "name": "Jean", 248 | "father": { 249 | "name": "Luc", 250 | "son": {"type": "people", "id": "1"} 251 | } 252 | } 253 | } 254 | 255 | 256 | def test_simple_relationships(): 257 | response = { 258 | "data": { 259 | "type": "article", 260 | "id": "1", 261 | "attributes": { 262 | "title": "Article 1" 263 | }, 264 | "relationships": { 265 | "author": { 266 | "data": {"type": "people", "id": "9"} 267 | } 268 | } 269 | }, 270 | "included": [{ 271 | "type": "people", 272 | "id": "9", 273 | "attributes": { 274 | "first-name": "Bob", 275 | "last-name": "Doe", 276 | } 277 | }] 278 | } 279 | doc = json_api_doc.parse(response) 280 | assert doc == { 281 | "type": "article", 282 | "id": "1", 283 | "title": "Article 1", 284 | "author": { 285 | "type": "people", 286 | "id": "9", 287 | "first-name": "Bob", 288 | "last-name": "Doe" 289 | } 290 | } 291 | 292 | 293 | def test_simple_relationships_with_meta(): 294 | response = { 295 | "data": { 296 | "type": "article", 297 | "id": "1", 298 | "attributes": { 299 | "title": "Article 1" 300 | }, 301 | "relationships": { 302 | "author": { 303 | "data": {"type": "people", "id": "9", "meta": {"index": 3}} 304 | } 305 | } 306 | }, 307 | "included": [{ 308 | "type": "people", 309 | "id": "9", 310 | "attributes": { 311 | "first-name": "Bob", 312 | "last-name": "Doe", 313 | } 314 | }] 315 | } 316 | doc = json_api_doc.parse(response) 317 | assert doc == { 318 | "type": "article", 319 | "id": "1", 320 | "title": "Article 1", 321 | "author": { 322 | "type": "people", 323 | "id": "9", 324 | "first-name": "Bob", 325 | "last-name": "Doe", 326 | "meta": {"index": 3} 327 | } 328 | } 329 | 330 | 331 | def test_linked_relationship(): 332 | response = { 333 | "data": { 334 | "type": "article", 335 | "id": "1", 336 | "attributes": { 337 | "title": "Article 1" 338 | }, 339 | "relationships": { 340 | "author": { 341 | "links": { 342 | "related": "/authors/9" 343 | } 344 | } 345 | } 346 | } 347 | } 348 | doc = json_api_doc.parse(response) 349 | assert doc == { 350 | "type": "article", 351 | "id": "1", 352 | "title": "Article 1", 353 | "author": { 354 | "links": { 355 | "related": "/authors/9" 356 | } 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /tests/test_deserialize.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Tests for `json_api_doc` package.""" 4 | 5 | import json 6 | 7 | import json_api_doc 8 | 9 | 10 | def test_deserialize_is_non_destructive(): 11 | raw_response = """ 12 | { 13 | "jsonapi": { 14 | "version": "1.0" 15 | }, 16 | "meta": { 17 | "foo": "bar" 18 | }, 19 | "data": { 20 | "type": "article", 21 | "id": "1", 22 | "attributes": { 23 | "title": "Article 1" 24 | }, 25 | "relationships": { 26 | "author": { 27 | "links": { 28 | "related": "/authors/9" 29 | } 30 | } 31 | } 32 | } 33 | } 34 | """ 35 | response = json.loads(raw_response) 36 | json_api_doc.deserialize(response) 37 | assert response == json.loads(raw_response) 38 | 39 | 40 | def test_first_level_data_in_included(): 41 | response = { 42 | "data": { 43 | "type": "article", 44 | "id": "1" 45 | }, 46 | "included": [ 47 | { 48 | "type": "article", 49 | "id": "1", 50 | "attributes": { 51 | "title": "Article 1" 52 | }, 53 | "relationships": { 54 | "author": { 55 | "links": { 56 | "related": "/authors/9" 57 | } 58 | } 59 | } 60 | } 61 | ] 62 | } 63 | doc = json_api_doc.deserialize(response) 64 | assert doc == { 65 | "type": "article", 66 | "id": "1", 67 | "title": "Article 1", 68 | "author": { 69 | "links": { 70 | "related": "/authors/9" 71 | } 72 | } 73 | } 74 | 75 | 76 | def test_can_handle_deep_recursive_relationships(): 77 | response = { 78 | "data": { 79 | "type": "Version", 80 | "id": "1", 81 | "attributes": {}, 82 | "relationships": { 83 | "drivers": { 84 | "meta": {"count": 1}, 85 | "data": [{"type": "Driver", "id": "1"}] 86 | }, 87 | "named_insureds": { 88 | "meta": {"count": 1}, 89 | "data": [{"type": "NamedInsured", "id": "1"}] 90 | }, 91 | "losses": { 92 | "meta": {"count": 1}, 93 | "data": [{"type": "Loss", "id": "1"}] 94 | }, 95 | } 96 | }, 97 | "included": [ 98 | { 99 | "type": "Address", 100 | "id": "1", 101 | "attributes": {}, 102 | "relationships": 103 | { 104 | "entities": { 105 | "meta": {"count": 1}, 106 | "data": [{"type": "Entity", "id": "1"}] 107 | }, 108 | "info": { 109 | "meta": {"count": 1}, 110 | "data": [{"type": "AddressInfo", "id": "1"}] 111 | } 112 | } 113 | }, { 114 | "type": "AddressInfo", 115 | "id": "1", 116 | "attributes": {}, 117 | "relationships": { 118 | "address": { 119 | "data": {"type": "Address", "id": "1"} 120 | } 121 | } 122 | }, { 123 | "type": "Driver", 124 | "id": "1", 125 | "attributes": {}, 126 | "relationships": { 127 | "entity": { 128 | "data": {"type": "Entity", "id": "1"} 129 | } 130 | } 131 | }, { 132 | "type": "Entity", 133 | "id": "1", 134 | "attributes": {}, 135 | "relationships": { 136 | "addresses": { 137 | "meta": {"count": 1}, 138 | "data": [{"type": "Address", "id": "1"}] 139 | } 140 | } 141 | }, { 142 | "type": "Loss", 143 | "id": "1", 144 | "attributes": {}, 145 | "relationships": { 146 | "address": { 147 | "data": {"type": "Address", "id": "1"} 148 | }, 149 | } 150 | }, { 151 | "type": "NamedInsured", 152 | "id": "1", 153 | "attributes": {}, 154 | "relationships": { 155 | "entity": { 156 | "data": {"type": "Entity", "id": "1"} 157 | } 158 | } 159 | }, 160 | ] 161 | } 162 | doc = json_api_doc.deserialize(response) 163 | assert bool(doc["drivers"][0]["entity"]["addresses"][0]["info"]) is True 164 | assert bool(doc["losses"][0]["address"]) is True 165 | 166 | 167 | def test_resolves_deeply_without_infinite_recursion(): 168 | response = { 169 | "data": [ 170 | { 171 | "id": "O-546755D4", 172 | "relationships": { 173 | "route": { 174 | "data": { 175 | "id": "Orange", 176 | "type": "route" 177 | } 178 | }, 179 | "trip": { 180 | "data": { 181 | "id": "45616458", 182 | "type": "trip" 183 | } 184 | } 185 | }, 186 | "type": "vehicle" 187 | }, 188 | { 189 | "id": "O-546751D5", 190 | "relationships": { 191 | "route": { 192 | "data": { 193 | "id": "Orange", 194 | "type": "route" 195 | } 196 | }, 197 | "trip": { 198 | "data": { 199 | "id": "45616586", 200 | "type": "trip" 201 | } 202 | } 203 | }, 204 | "type": "vehicle" 205 | }, 206 | { 207 | "id": "O-54675162", 208 | "relationships": { 209 | "route": { 210 | "data": { 211 | "id": "Orange", 212 | "type": "route" 213 | } 214 | }, 215 | "trip": { 216 | "data": { 217 | "id": "45616587", 218 | "type": "trip" 219 | } 220 | } 221 | }, 222 | "type": "vehicle" 223 | } 224 | ], 225 | "included": [ 226 | { 227 | "id": "45616586", 228 | "relationships": { 229 | "route": { 230 | "data": { 231 | "id": "Orange", 232 | "type": "route" 233 | } 234 | }, 235 | "route_pattern": { 236 | "data": { 237 | "id": "Orange-3-1", 238 | "type": "route_pattern" 239 | } 240 | } 241 | }, 242 | "type": "trip" 243 | }, 244 | { 245 | "id": "Orange-3-1", 246 | "relationships": { 247 | "token_trip": { 248 | "data": { 249 | "id": "45616458", 250 | "type": "trip" 251 | } 252 | }, 253 | "route": { 254 | "data": { 255 | "id": "Orange", 256 | "type": "route" 257 | } 258 | } 259 | }, 260 | "type": "route_pattern" 261 | }, 262 | { 263 | "id": "45616458", 264 | "relationships": { 265 | "route": { 266 | "data": { 267 | "id": "Orange", 268 | "type": "route" 269 | } 270 | }, 271 | "route_pattern": { 272 | "data": { 273 | "id": "Orange-3-1", 274 | "type": "route_pattern" 275 | } 276 | } 277 | }, 278 | "type": "trip" 279 | }, 280 | { 281 | "id": "45616587", 282 | "relationships": { 283 | "route": { 284 | "data": { 285 | "id": "Orange", 286 | "type": "route" 287 | } 288 | }, 289 | "route_pattern": { 290 | "data": { 291 | "id": "Orange-3-1", 292 | "type": "route_pattern" 293 | } 294 | } 295 | }, 296 | "type": "trip" 297 | } 298 | ] 299 | } 300 | 301 | doc = json_api_doc.parse(response) 302 | trip_id = {'id': '45616458', 'type': 'trip'} 303 | route_id = {'id': 'Orange', 304 | 'type': 'route'} 305 | route_pattern_id = {'id': 'Orange-3-1', 306 | 'type': 'route_pattern'} 307 | 308 | trip0 = doc[0]['trip'] 309 | trip1 = doc[1]['trip'] 310 | assert bool(trip0 != trip_id) 311 | assert bool(trip0['route_pattern']['route'] == route_id) 312 | assert bool(trip0['route_pattern']['token_trip'] == trip_id) 313 | 314 | assert bool(trip1['route_pattern'] != route_pattern_id) 315 | assert bool(trip1['route_pattern']['route'] == route_id) 316 | assert bool(trip1['route_pattern']['token_trip']['route'] == route_id) 317 | assert bool(trip1['route_pattern']['token_trip']['route_pattern'] == route_pattern_id) 318 | --------------------------------------------------------------------------------