├── src ├── __init__.py └── jsonapi_client │ ├── __init__.py │ ├── exceptions.py │ ├── filter.py │ ├── common.py │ ├── objects.py │ ├── document.py │ ├── relationships.py │ ├── session.py │ └── resourceobject.py ├── docs ├── .gitignore ├── source │ ├── usage.rst │ ├── index.rst │ ├── classes.rst │ └── conf.py └── Makefile ├── .gitignore ├── requirements.txt ├── tests ├── json │ ├── api │ │ ├── error.json │ │ ├── user-accounts │ │ │ └── qvantel-useraccount1.json │ │ ├── leases │ │ │ └── qvantel-lease1 │ │ │ │ ├── external-references.json │ │ │ │ └── parent-lease.json │ │ └── leases.json │ ├── people │ │ └── 2.json │ ├── external__filter[title]=Hep.json │ ├── external_3.json │ ├── external.json │ ├── test_leases__filter[title]=Dippadai.json │ ├── invitations.json │ ├── test_leases.json │ ├── test_leases_5.json │ ├── test_leases_3.json │ └── articles.json ├── test_modifiers.py └── test_client.py ├── MANIFEST.in ├── .travis.yml ├── Dockerfile ├── setup.py ├── LICENSE.txt ├── CHANGES.rst └── README.rst /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | -------------------------------------------------------------------------------- /docs/source/usage.rst: -------------------------------------------------------------------------------- 1 | ../../README.txt -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.mo 2 | *~ 3 | *.pyc 4 | *.pyo 5 | *.egg-info 6 | .idea/ 7 | .DS_Store 8 | .coverage* 9 | __pycache__ 10 | dist 11 | /.cache/ 12 | *.swp 13 | /build/ 14 | .venv 15 | .vscode 16 | .pytest_cache -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | pytest-asyncio 3 | pytest-mock 4 | pytest 5 | pytest-aiohttp 6 | asynctest 7 | jsonschema 8 | aiohttp>=3.0 9 | aiodns 10 | sphinx 11 | sphinx-autodoc-annotation 12 | pytest-cov 13 | coveralls 14 | -------------------------------------------------------------------------------- /tests/json/api/error.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors": [{ 3 | "id": "hostname", 4 | "status": "404", 5 | "title": "The requested resource could not be found but may be available again in the future.", 6 | "detail": "" 7 | }] 8 | } -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include CHANGES.rst 3 | include LICENSE.txt 4 | 5 | recursive-include tests * 6 | recursive-exclude * __pycache__ 7 | recursive-exclude * *.py[co] 8 | 9 | recursive-include docs *.rst conf.py Makefile make.bat 10 | -------------------------------------------------------------------------------- /tests/json/people/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "people", 4 | "id": "2", 5 | "attributes": { 6 | "first-name": "Dan 2", 7 | "last-name": "Gebhardt 2", 8 | "twitter": "dgeb 2" 9 | }, 10 | "links": { 11 | "self": "http://example.com/people/2" 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | - "3.7" 5 | - "3.8" 6 | - "3.8-dev" 7 | # command to install dependencies 8 | install: 9 | - "pip install --upgrade pip" 10 | - "pip install -r requirements.txt" 11 | - "pip install --upgrade pytest" 12 | - "pip install -e ." 13 | # command to run tests 14 | script: py.test --cov src/jsonapi_client/ tests/ 15 | after_success: coveralls 16 | -------------------------------------------------------------------------------- /tests/json/external__filter[title]=Hep.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "first": "http://example.com/external", 4 | "self": "http://example.com/external", 5 | "next": "http://example.com/external_3", 6 | "last": "http://example.com/external_3" 7 | }, 8 | "data": [ 9 | { 10 | "type": "external", 11 | "id": "2", 12 | "attributes": { 13 | "title": "Hep" 14 | }, 15 | "links": { 16 | "self": "http://example.com/external/2" 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6.1-alpine 2 | MAINTAINER Tuomas Airaksinen 3 | ENV PYTHONUNBUFFERED 1 4 | RUN apk update && apk upgrade && apk add --no-cache gcc python3-dev musl-dev make libffi-dev 5 | RUN pip install -U pip setuptools 6 | RUN adduser -D web 7 | RUN mkdir /jsonapi-client 8 | WORKDIR /jsonapi-client 9 | ADD requirements.txt /jsonapi-client 10 | RUN pip install -r requirements.txt 11 | ADD . /jsonapi-client 12 | RUN pip install -e . 13 | RUN apk del gcc musl-dev make 14 | CMD pytest tests/ -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. JSON API Client documentation master file, created by 2 | sphinx-quickstart on Thu Mar 2 15:35:36 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to JSON API Client's documentation! 7 | =========================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | usage 14 | classes 15 | 16 | Indices and tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | 23 | 24 | -------------------------------------------------------------------------------- /tests/json/external_3.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "first": "http://example.com/external", 4 | "self": "http://example.com/external_3", 5 | "last": "http://example.com/external_3" 6 | }, 7 | "data": [ 8 | { 9 | "type": "external", 10 | "id": "3", 11 | "attributes": { 12 | "title": "JSON API paints my bikeshed!" 13 | }, 14 | "links": { 15 | "self": "http://example.com/external/1" 16 | } 17 | }, 18 | { 19 | "type": "external", 20 | "id": "4", 21 | "attributes": { 22 | "title": "JSON API paints my bikeshed!" 23 | }, 24 | "links": { 25 | "self": "http://example.com/external/2" 26 | } 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = JSONAPIClient 8 | SOURCEDIR = source 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) -------------------------------------------------------------------------------- /tests/json/external.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "first": "http://example.com/external", 4 | "self": "http://example.com/external", 5 | "next": "http://example.com/external_3", 6 | "last": "http://example.com/external_3" 7 | }, 8 | "data": [ 9 | { 10 | "type": "external", 11 | "id": "1", 12 | "attributes": { 13 | "title": "JSON API paints my bikeshed!" 14 | }, 15 | "links": { 16 | "self": "http://example.com/external/1" 17 | } 18 | }, 19 | { 20 | "type": "external", 21 | "id": "2", 22 | "attributes": { 23 | "title": "Hep" 24 | }, 25 | "links": { 26 | "self": "http://example.com/external/2" 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /tests/json/test_leases__filter[title]=Dippadai.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "first": "http://example.com/test_leases", 4 | "self": "http://example.com/test_leases", 5 | "next": "http://example.com/test_leases_3", 6 | "last": "http://example.com/test_leases_5" 7 | }, 8 | "data": [ 9 | { 10 | "type": "test_leases", 11 | "id": "1", 12 | "attributes": { 13 | "title": "Dippadai" 14 | }, 15 | "relationships": { 16 | "external-references": { 17 | "links": { 18 | "self": "http://example.com/test_leases_links/1/external_references/links", 19 | "related": "http://example.com/external" 20 | } 21 | } 22 | }, 23 | "links": { 24 | "self": "http://example.com/test_leases/1" 25 | } 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /docs/source/classes.rst: -------------------------------------------------------------------------------- 1 | Class reference 2 | =============== 3 | 4 | Waiting for https://github.com/sphinx-doc/sphinx/issues/3494 to be resolved... 5 | 6 | Session 7 | ------- 8 | .. autoclass:: jsonapi_client.session.Session 9 | :members: 10 | 11 | .. automodule:: jsonapi_client.filter 12 | :members: 13 | 14 | 15 | Document 16 | -------- 17 | 18 | .. automodule:: jsonapi_client.document 19 | :members: 20 | 21 | ResourceObject 22 | -------------- 23 | 24 | .. automodule:: jsonapi_client.resourceobject 25 | :members: 26 | 27 | 28 | Relationships 29 | ------------- 30 | 31 | .. automodule:: jsonapi_client.relationships 32 | :members: 33 | 34 | Other objects 35 | ------------- 36 | 37 | .. automodule:: jsonapi_client.objects 38 | :members: 39 | 40 | Other 41 | ----- 42 | 43 | .. automodule:: jsonapi_client.common 44 | :members: 45 | 46 | Exceptions 47 | ---------- 48 | 49 | .. automodule:: jsonapi_client.exceptions 50 | :members: 51 | -------------------------------------------------------------------------------- /tests/test_modifiers.py: -------------------------------------------------------------------------------- 1 | from jsonapi_client.filter import Inclusion, Modifier 2 | 3 | 4 | def test_modifier(): 5 | url = 'http://localhost:8080' 6 | query = 'example_attr=1' 7 | m = Modifier(query) 8 | assert m.url_with_modifiers(url) == f'{url}?{query}' 9 | 10 | 11 | def test_inclusion(): 12 | url = 'http://localhost:8080' 13 | f = Inclusion('something', 'something_else') 14 | assert f.url_with_modifiers(url) == f'{url}?include=something,something_else' 15 | 16 | 17 | def test_modifier_sum(): 18 | url = 'http://localhost:8080' 19 | q1 = 'item1=1' 20 | q2 = 'item2=2' 21 | q3 = 'item3=3' 22 | m1 = Modifier(q1) 23 | m2 = Modifier(q2) 24 | m3 = Modifier(q3) 25 | 26 | assert ((m1 + m2) + m3).url_with_modifiers(url) == f'{url}?{q1}&{q2}&{q3}' 27 | assert (m1 + (m2 + m3)).url_with_modifiers(url) == f'{url}?{q1}&{q2}&{q3}' 28 | assert (m1 + m2 + m3).url_with_modifiers(url) == f'{url}?{q1}&{q2}&{q3}' 29 | -------------------------------------------------------------------------------- /tests/json/api/user-accounts/qvantel-useraccount1.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "attributes": { 4 | "name": "", 5 | "user-type": "soho", 6 | "account-id": "123457", 7 | "active-status": "active", 8 | "characteristics": { 9 | 10 | }, 11 | "valid-for": { 12 | "start-datetime": "2000-01-01T00:00:00.000Z", 13 | "end-datetime": "3000-01-01T00:00:00.000Z", 14 | "meta": { 15 | "type": "valid-for-datetime" 16 | } 17 | } 18 | }, 19 | "relationships": { 20 | "partner-accounts": { 21 | "links": { 22 | "related": "/api/user-accounts/qvantel-useraccount1/partner-accounts" 23 | } 24 | } 25 | }, 26 | "links": { 27 | "self": "/api/user-accounts/qvantel-useraccount1" 28 | }, 29 | "id": "qvantel-useraccount1", 30 | "type": "user-accounts" 31 | }, 32 | "links": { 33 | "self": "/api/user-accounts/qvantel-useraccount1" 34 | } 35 | } -------------------------------------------------------------------------------- /tests/json/invitations.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "self": "http://example.com/invitations", 4 | "next": "http://example.com/invitations?page[offset]=2", 5 | "last": "http://example.com/invitations?page[offset]=2" 6 | }, 7 | "data": [ 8 | { 9 | "type": "invitations", 10 | "id": "1", 11 | "relationships": { 12 | "host": { 13 | "links": { 14 | "self": "http://example.com/invitations/1/relationships/host", 15 | "related": "http://example.com/invitations/1/host" 16 | }, 17 | "data": { 18 | "type": "people", 19 | "id": "2" 20 | } 21 | }, 22 | "guest": { 23 | "links": { 24 | "self": "http://example.com/invitations/1/relationships/guest", 25 | "related": "http://example.com/invitations/1/guest" 26 | }, 27 | "data": { 28 | "type": "people", 29 | "id": "9" 30 | } 31 | } 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="jsonapi_client", 5 | version='0.9.9', 6 | description="Comprehensive, yet easy-to-use, pythonic, ORM-like access to JSON API services", 7 | long_description=(open("README.rst").read() + "\n" + 8 | open("CHANGES.rst").read()), 9 | classifiers=[ 10 | "Programming Language :: Python", 11 | "Programming Language :: Python :: 3.6", 12 | "Topic :: Software Development :: Libraries", 13 | "License :: OSI Approved :: BSD License", 14 | ], 15 | author="Tuomas Airaksinen", 16 | author_email="tuomas.airaksinen@qvantel.com", 17 | url="https://github.com/qvantel/jsonapi-client", 18 | keywords="JSONAPI JSON API client", 19 | license="BSD-3", 20 | package_dir={"": "src"}, 21 | packages=find_packages("src"), 22 | include_package_data=True, 23 | zip_safe=False, 24 | install_requires=[ 25 | "requests", 26 | "jsonschema", 27 | "aiohttp", 28 | ], 29 | ) 30 | -------------------------------------------------------------------------------- /tests/json/api/leases/qvantel-lease1/external-references.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [{ 3 | "attributes": { 4 | "reference-id": "0123015150", 5 | "reference-type": "legacy-bss-contract-id", 6 | "valid-for": { 7 | "start-datetime": "2000-01-01T00:00:00.000Z", 8 | "end-datetime": "2017-01-01T00:00:00.000Z", 9 | "meta": { 10 | "type": "valid-for-datetime" 11 | } 12 | }, 13 | "null-field": null 14 | }, 15 | "relationships": { 16 | "target": { 17 | "links": { 18 | "related": "/api/external-references/qvantel-lease1-extref/target" 19 | }, 20 | "data": { 21 | "id": "qvantel-lease1", 22 | "type": "leases" 23 | } 24 | } 25 | }, 26 | "links": { 27 | "self": "/api/external-references/qvantel-lease1-extref" 28 | }, 29 | "id": "qvantel-lease1-extref", 30 | "type": "external-references" 31 | }], 32 | "links": { 33 | "self": "/api/leases/qvantel-lease1/external-references" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/json/api/leases/qvantel-lease1/parent-lease.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "attributes": { 4 | "lease-id": null, 5 | "active-status": "active", 6 | "reference-number": "5373K5", 7 | "valid-for": { 8 | "start-datetime": "2015-07-06T12:23:26.000Z", 9 | "end-datetime": null, 10 | "meta": { 11 | "type": "valid-for-datetime" 12 | } 13 | } 14 | }, 15 | "relationships": { 16 | "external-references": { 17 | "links": { 18 | "related": "/api/leases/qvantel-lease1/external-references" 19 | } 20 | }, 21 | "user-account": { 22 | "links": { 23 | "related": "/api/leases/qvantel-lease1/user-account" 24 | }, 25 | "data": { 26 | "id": "qvantel-useraccount1", 27 | "type": "user-accounts" 28 | } 29 | }, 30 | "parent-lease": { 31 | "links": { 32 | "related": "/api/leases/qvantel-lease1/parent-lease" 33 | } 34 | } 35 | }, 36 | "links": { 37 | "self": "/api/leases/qvantel-lease1" 38 | }, 39 | "id": "qvantel-lease1", 40 | "type": "leases" 41 | }, 42 | "links": { 43 | "self": "/api/leases/qvantel-lease1" 44 | } 45 | } -------------------------------------------------------------------------------- /tests/json/test_leases.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "first": "http://example.com/test_leases", 4 | "self": "http://example.com/test_leases", 5 | "next": "http://example.com/test_leases_3", 6 | "last": "http://example.com/test_leases_5" 7 | }, 8 | "data": [ 9 | 10 | { 11 | "type": "test_leases", 12 | "id": "1", 13 | "attributes": { 14 | "title": "Dippadai" 15 | }, 16 | "relationships": { 17 | "external-references": { 18 | "links": { 19 | "self": "http://example.com/test_leases_links/1/external_references/links", 20 | "related": "http://example.com/external" 21 | } 22 | } 23 | }, 24 | "links": { 25 | "self": "http://example.com/test_leases/1" 26 | } 27 | }, 28 | 29 | { 30 | "type": "test_leases", 31 | "id": "2", 32 | "attributes": { 33 | "title": "JSON API paints my bikeshed!" 34 | }, 35 | "relationships": { 36 | "external-references": { 37 | "links": { 38 | "self": "http://example.com/test_leases/2/external_references/links", 39 | "related": "http://example.com/test_leases/2/external_references" 40 | } 41 | } 42 | }, 43 | "links": { 44 | "self": "http://example.com/test_leases/2" 45 | } 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /tests/json/test_leases_5.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "first": "http://example.com/test_leases", 4 | "self": "http://example.com/test_leases_5", 5 | "prev": "http://example.com/test_leases_3", 6 | "last": "http://example.com/test_leases_5" 7 | }, 8 | "data": [ 9 | 10 | { 11 | "type": "test_leases", 12 | "id": "5", 13 | "attributes": { 14 | "title": "JSON API paints my bikeshed!" 15 | }, 16 | "relationships": { 17 | "external-references": { 18 | "links": { 19 | "self": "http://example.com/test_leases/1/external_references/links", 20 | "related": "http://example.com/test_leases/1/external_references" 21 | } 22 | } 23 | }, 24 | "links": { 25 | "self": "http://example.com/test_leases/1" 26 | } 27 | }, 28 | 29 | { 30 | "type": "test_leases", 31 | "id": "6", 32 | "attributes": { 33 | "title": "JSON API paints my bikeshed!" 34 | }, 35 | "relationships": { 36 | "external-references": { 37 | "links": { 38 | "self": "http://example.com/test_leases/2/external_references/links", 39 | "related": "http://example.com/test_leases/2/external_references" 40 | } 41 | } 42 | }, 43 | "links": { 44 | "self": "http://example.com/test_leases/2" 45 | } 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /tests/json/test_leases_3.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "first": "http://example.com/test_leases", 4 | "self": "http://example.com/test_leases_3", 5 | "next": "http://example.com/test_leases_5", 6 | "prev": "http://example.com/test_leases", 7 | "last": "http://example.com/test_leases_5" 8 | }, 9 | "data": [ 10 | 11 | { 12 | "type": "test_leases", 13 | "id": "3", 14 | "attributes": { 15 | "title": "JSON API paints my bikeshed!" 16 | }, 17 | "relationships": { 18 | "external-references": { 19 | "links": { 20 | "self": "http://example.com/test_leases/1/external_references/links", 21 | "related": "http://example.com/test_leases/1/external_references" 22 | } 23 | } 24 | }, 25 | "links": { 26 | "self": "http://example.com/test_leases/1" 27 | } 28 | }, 29 | 30 | { 31 | "type": "test_leases", 32 | "id": "4", 33 | "attributes": { 34 | "title": "JSON API paints my bikeshed!" 35 | }, 36 | "relationships": { 37 | "external-references": { 38 | "links": { 39 | "self": "http://example.com/test_leases/2/external_references/links", 40 | "related": "http://example.com/test_leases/2/external_references" 41 | } 42 | } 43 | }, 44 | "links": { 45 | "self": "http://example.com/test_leases/2" 46 | } 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Qvantel 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the Qvantel nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL QVANTEL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /src/jsonapi_client/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON API Python client 3 | https://github.com/qvantel/jsonapi-client 4 | 5 | (see JSON API specification in http://jsonapi.org/) 6 | 7 | Copyright (c) 2017, Qvantel 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | * Redistributions in binary form must reproduce the above copyright 15 | notice, this list of conditions and the following disclaimer in the 16 | documentation and/or other materials provided with the distribution. 17 | * Neither the name of the Qvantel nor the 18 | names of its contributors may be used to endorse or promote products 19 | derived from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL QVANTEL BE LIABLE FOR ANY 25 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 28 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | try: 33 | from importlib.metadata import version 34 | __version__ = version("jsonapi-client") 35 | except ImportError: 36 | from pkg_resources import get_distribution 37 | __version__ = get_distribution("jsonapi-client").version 38 | 39 | from .session import Session 40 | from .filter import Filter, Inclusion, Modifier 41 | from .common import ResourceTuple 42 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 5 | 0.9.9 (2020-03-12) 6 | ------------------ 7 | 8 | - Adapt to aiohttp>3.0 9 | - Workaround a weird bug 10 | - Fix deprecation warnings 11 | - Prevent AttributeDict() from modifying its input 12 | - #24: Fix error handling of server response 13 | 14 | 15 | 0.9.8 (2020-02-14) 16 | ------------------ 17 | 18 | - #25: Fix for fetching resources without attributes 19 | - Stop following next when there are no more items 20 | - Fix build 21 | - Use custom_url logic for all request methods 22 | - #27: Await close on async sessions 23 | - Add apk libffi-dev dependency 24 | - Fix pytest.raise exception validation e.value 25 | - Added .venv, .vscode, .pytest_cache to .gitignore 26 | - Add support for extra headers as request_kwargs 27 | 28 | 29 | 0.9.7 (2019-02-01) 30 | ------------------ 31 | 32 | - Support __getitem__ in Meta 33 | - Handle empty relationship data list 34 | - Allow returning link to relationship as an iterator 35 | - Fix handling null one-to-one-relationship 36 | - Don't explicitly quote filter values 37 | - Include support 38 | 39 | 0.9.6 (2017-06-26) 40 | ------------------ 41 | 42 | - When creating new resources, use default value specified in 43 | jsonschema, when available. 44 | 45 | 46 | 0.9.5 (2017-06-16) 47 | ------------------ 48 | 49 | - Change Session.create_and_commit signature similarly as Session.create 50 | 51 | 0.9.4 (2017-06-16) 52 | ------------------ 53 | 54 | - Remove ? from filenames (illegal in Windows) 55 | - Pass event loop aiohttp's ClientSession 56 | - Return resource from .commit if return status is 202 57 | - Support underscores in field names in Session.create() through fields keyword argument. 58 | - Add support for extra arguments such as authentication object 59 | - AsyncIO support for context manager usage of Session 60 | 61 | 62 | 0.9.3 (2017-04-03) 63 | ------------------ 64 | 65 | - Added aiohttp to install requirements 66 | 67 | 68 | 0.9.2 (2017-04-03) 69 | ------------------ 70 | 71 | - Github release. 72 | 73 | 74 | 0.9.1 (2017-03-23) 75 | ------------------ 76 | 77 | - Fix async content_type checking 78 | - Use Python 3's new typing.NamedTuple instead of collections.NamedTuple 79 | - Make included resources available from Document 80 | - ResourceObject.json property 81 | -------------------------------------------------------------------------------- /src/jsonapi_client/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON API Python client 3 | https://github.com/qvantel/jsonapi-client 4 | 5 | (see JSON API specification in http://jsonapi.org/) 6 | 7 | Copyright (c) 2017, Qvantel 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | * Redistributions in binary form must reproduce the above copyright 15 | notice, this list of conditions and the following disclaimer in the 16 | documentation and/or other materials provided with the distribution. 17 | * Neither the name of the Qvantel nor the 18 | names of its contributors may be used to endorse or promote products 19 | derived from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL QVANTEL BE LIABLE FOR ANY 25 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 28 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | 33 | 34 | class JsonApiClientError(Exception): 35 | """ 36 | Generic exception class for jsonapi-client 37 | """ 38 | pass 39 | 40 | 41 | class ValidationError(JsonApiClientError): 42 | pass 43 | 44 | 45 | class DocumentError(JsonApiClientError): 46 | """ 47 | Raised when 404 or other error takes place. 48 | Status code is stored in errors['status_code']. 49 | """ 50 | def __init__(self, *args, errors, **kwargs): 51 | super().__init__(*args) 52 | self.errors = errors 53 | for key, value in kwargs.items(): 54 | setattr(self, key, value) 55 | 56 | 57 | class DocumentInvalid(JsonApiClientError): 58 | pass 59 | 60 | 61 | class AsyncError(JsonApiClientError): 62 | pass 63 | -------------------------------------------------------------------------------- /tests/json/api/leases.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [{ 3 | "attributes": { 4 | "lease-id": null, 5 | "active-status": "active", 6 | "reference-number": "5373K5", 7 | "valid-for": { 8 | "start-datetime": "2015-07-06T12:23:26.000Z", 9 | "end-datetime": null, 10 | "meta": { 11 | "type": "valid-for-datetime", 12 | "with_underscore": "underscore", 13 | "with-dash": "dash" 14 | } 15 | } 16 | }, 17 | "relationships": { 18 | "external-references": { 19 | "links": { 20 | "related": "/api/leases/qvantel-lease1/external-references" 21 | } 22 | }, 23 | "user-account": { 24 | "links": { 25 | "related": "/api/leases/qvantel-lease1/user-account" 26 | }, 27 | "data": { 28 | "id": "qvantel-useraccount1", 29 | "type": "user-accounts" 30 | } 31 | }, 32 | 33 | "parent-lease": { 34 | "links": { 35 | "related": "/api/leases/qvantel-lease1/parent-lease" 36 | } 37 | } 38 | }, 39 | "links": { 40 | "self": "/api/leases/qvantel-lease1" 41 | }, 42 | "id": "qvantel-lease1", 43 | "type": "leases" 44 | }, { 45 | "attributes": { 46 | "lease-id": null, 47 | "active-status": "active", 48 | "reference-number": "5373K5", 49 | "valid-for": { 50 | "start-datetime": "2016-11-03T00:00:00.000Z", 51 | "end-datetime": null, 52 | "meta": { 53 | "type": "valid-for-datetime" 54 | } 55 | } 56 | }, 57 | "relationships": { 58 | "external-references": { 59 | "links": { 60 | "related": "/api/leases/juanita-lease1/external-references" 61 | } 62 | }, 63 | "user-account": { 64 | "links": { 65 | "related": "/api/leases/juanita-lease1/user-account" 66 | }, 67 | "data": { 68 | "id": "juanita-useraccount", 69 | "type": "user-accounts" 70 | } 71 | }, 72 | "parent-lease": { 73 | "links": { 74 | "related": "/api/leases/juanita-lease1/parent-lease" 75 | } 76 | } 77 | }, 78 | "links": { 79 | "self": "/api/leases/juanita-lease1" 80 | }, 81 | "id": "juanita-lease1", 82 | "type": "leases" 83 | }, { 84 | "attributes": { 85 | "lease-id": null, 86 | "active-status": "active", 87 | "reference-number": "5333B5", 88 | "valid-for": { 89 | "start-datetime": "2016-06-21T16:00:07.000Z", 90 | "end-datetime": null, 91 | "meta": { 92 | "type": "valid-for-datetime" 93 | } 94 | } 95 | }, 96 | "relationships": { 97 | "external-references": { 98 | "links": { 99 | "related": "/api/leases/timo-lease1/external-references" 100 | } 101 | }, 102 | "user-account": { 103 | "links": { 104 | "related": "/api/leases/timo-lease1/user-account" 105 | }, 106 | "data": { 107 | "id": "timo-useraccount", 108 | "type": "user-accounts" 109 | } 110 | }, 111 | "parent-lease": { 112 | "links": { 113 | "related": "/api/leases/timo-lease1/parent-lease" 114 | } 115 | } 116 | }, 117 | "links": { 118 | "self": "/api/leases/timo-lease1" 119 | }, 120 | "id": "timo-lease1", 121 | "type": "leases" 122 | }, { 123 | "attributes": { 124 | "lease-id": "ext-doc-store-id-1", 125 | "active-status": "active", 126 | "reference-number": "M33B3F", 127 | "valid-for": { 128 | "start-datetime": "2015-10-06T16:21:07.000Z", 129 | "end-datetime": null, 130 | "meta": { 131 | "type": "valid-for-datetime" 132 | } 133 | } 134 | }, 135 | "relationships": { 136 | "external-references": { 137 | "links": { 138 | "related": "/api/leases/qvantel-lease2/external-references" 139 | } 140 | }, 141 | "user-account": { 142 | "links": { 143 | "related": "/api/leases/qvantel-lease2/user-account" 144 | }, 145 | "data": { 146 | "id": "qvantel-useraccount1", 147 | "type": "user-accounts" 148 | } 149 | }, 150 | "parent-lease": { 151 | "links": { 152 | "related": "/api/leases/qvantel-lease2/parent-lease" 153 | }, 154 | "data": { 155 | "id": "qvantel-saleslease3", 156 | "type": "sales-leases" 157 | } 158 | } 159 | }, 160 | "links": { 161 | "self": "/api/leases/qvantel-lease2" 162 | }, 163 | "id": "qvantel-lease2", 164 | "type": "leases" 165 | }], 166 | "links": { 167 | "self": "/api/leases" 168 | } 169 | } -------------------------------------------------------------------------------- /tests/json/articles.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "self": "http://example.com/articles", 4 | "next": "http://example.com/articles?page[offset]=2", 5 | "last": "http://example.com/articles?page[offset]=10" 6 | }, 7 | "data": [{ 8 | "type": "articles", 9 | "id": "1", 10 | "attributes": { 11 | "title": "JSON API paints my bikeshed!", 12 | "nested1": {"nested": {"name": "test"}} 13 | }, 14 | "relationships": { 15 | "author": { 16 | "links": { 17 | "self": "http://example.com/articles/1/relationships/author", 18 | "related": "http://example.com/articles/1/author" 19 | }, 20 | "data": { 21 | "type": "people", 22 | "id": "9" 23 | } 24 | }, 25 | "comments": { 26 | "links": { 27 | "self": "http://example.com/articles/1/relationships/comments", 28 | "related": "http://example.com/articles/1/comments" 29 | }, 30 | "data": [ 31 | { 32 | "type": "comments", 33 | "id": "5" 34 | }, 35 | { 36 | "type": "comments", 37 | "id": "12" 38 | } 39 | ] 40 | }, 41 | "comment-or-author": { 42 | "data": { 43 | "type": "comments", 44 | "id": "12" 45 | } 46 | }, 47 | "comments-or-authors": { 48 | "data": [ 49 | { 50 | "type": "people", 51 | "id": "9" 52 | }, 53 | { 54 | "type": "comments", 55 | "id": "12" 56 | } 57 | ] 58 | } 59 | }, 60 | "links": { 61 | "self": "http://example.com/articles/1" 62 | } 63 | }, 64 | { 65 | "type": "articles", 66 | "id": "2", 67 | "attributes": { 68 | "title": "2 JSON API paints my bikeshed!", 69 | "nested1": {"nested": {"name": "test"}} 70 | }, 71 | "relationships": { 72 | "author": { 73 | "links": { 74 | "self": "http://example.com/articles/2/relationships/author", 75 | "related": "http://example.com/articles/2/author" 76 | }, 77 | "data": { 78 | "type": "people", 79 | "id": "9" 80 | } 81 | }, 82 | "comments": { 83 | "links": { 84 | "self": "http://example.com/articles/1/relationships/comments", 85 | "related": "http://example.com/articles/1/comments" 86 | }, 87 | "data": [ 88 | { 89 | "type": "comments", 90 | "id": "5" 91 | }, 92 | { 93 | "type": "comments", 94 | "id": "12" 95 | } 96 | ] 97 | }, 98 | "comment-or-author": { 99 | "data": { 100 | "type": "people", 101 | "id": "9" 102 | } 103 | }, 104 | "comments-or-authors": { 105 | "data": [ 106 | { 107 | "type": "people", 108 | "id": "9" 109 | }, 110 | { 111 | "type": "comments", 112 | "id": "12" 113 | } 114 | ] 115 | } 116 | }, 117 | "links": { 118 | "self": "http://example.com/articles/2" 119 | } 120 | }, 121 | { 122 | "type": "articles", 123 | "id": "3", 124 | "attributes": { 125 | "title": "An authorless book!", 126 | "nested1": {"nested": {"name": "test"}} 127 | }, 128 | "relationships": { 129 | "author": { 130 | "data": null 131 | }, 132 | "comments": { 133 | "links": { 134 | "self": "http://example.com/articles/3/relationships/comments", 135 | "related": "http://example.com/articles/3/comments" 136 | }, 137 | "data": [] 138 | }, 139 | "comment-or-author": { 140 | "data": null 141 | }, 142 | "comments-or-authors": { 143 | "data": [] 144 | } 145 | }, 146 | "links": { 147 | "self": "http://example.com/articles/2" 148 | } 149 | } 150 | ], 151 | "included": [{ 152 | "type": "people", 153 | "id": "9", 154 | "attributes": { 155 | "first-name": "Dan", 156 | "last-name": "Gebhardt", 157 | "twitter": "dgeb" 158 | }, 159 | "links": { 160 | "self": "http://example.com/people/9" 161 | } 162 | }, { 163 | "type": "comments", 164 | "id": "5", 165 | "attributes": { 166 | "body": "First!" 167 | }, 168 | "relationships": { 169 | "author": { 170 | "data": { "type": "people", "id": "2" } 171 | } 172 | }, 173 | "links": { 174 | "self": "http://example.com/comments/5" 175 | } 176 | }, { 177 | "type": "comments", 178 | "id": "12", 179 | "attributes": { 180 | "body": "I like XML better" 181 | }, 182 | "relationships": { 183 | "author": { 184 | "data": { "type": "people", "id": "9" } 185 | } 186 | }, 187 | "links": { 188 | "self": "http://example.com/comments/12" 189 | } 190 | }] 191 | } 192 | -------------------------------------------------------------------------------- /src/jsonapi_client/filter.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON API Python client 3 | https://github.com/qvantel/jsonapi-client 4 | 5 | (see JSON API specification in http://jsonapi.org/) 6 | 7 | Copyright (c) 2017, Qvantel 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | * Redistributions in binary form must reproduce the above copyright 15 | notice, this list of conditions and the following disclaimer in the 16 | documentation and/or other materials provided with the distribution. 17 | * Neither the name of the Qvantel nor the 18 | names of its contributors may be used to endorse or promote products 19 | derived from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL QVANTEL BE LIABLE FOR ANY 25 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 28 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | 33 | from typing import TYPE_CHECKING, Union, Dict, Sequence 34 | 35 | if TYPE_CHECKING: 36 | FilterKeywords = Dict[str, Union[str, Sequence[Union[str, int, float]]]] 37 | IncludeKeywords = Sequence[str] 38 | 39 | 40 | class Modifier: 41 | """ 42 | Base class for query modifiers. 43 | You can derive your own class and use it if you have custom syntax. 44 | """ 45 | def __init__(self, query_str: str='') -> None: 46 | self._query_str = query_str 47 | 48 | def url_with_modifiers(self, base_url: str) -> str: 49 | """ 50 | Returns url with modifiers appended. 51 | 52 | Example: 53 | Modifier('filter[attr1]=1,2&filter[attr2]=2').filtered_url('doc') 54 | -> 'GET doc?filter[attr1]=1,2&filter[attr2]=2' 55 | """ 56 | filter_query = self.appended_query() 57 | fetch_url = f'{base_url}?{filter_query}' 58 | return fetch_url 59 | 60 | def appended_query(self) -> str: 61 | return self._query_str 62 | 63 | def __add__(self, other: 'Modifier') -> 'Modifier': 64 | mods = [] 65 | for m in [self, other]: 66 | if isinstance(m, ModifierSum): 67 | mods += m.modifiers 68 | else: 69 | mods.append(m) 70 | return ModifierSum(mods) 71 | 72 | 73 | class ModifierSum(Modifier): 74 | def __init__(self, modifiers): 75 | self.modifiers = modifiers 76 | 77 | def appended_query(self) -> str: 78 | return '&'.join(m.appended_query() for m in self.modifiers) 79 | 80 | 81 | class Filter(Modifier): 82 | """ 83 | Implements query filtering for Session.get etc. 84 | You can derive your own filter class and use it if you have a 85 | custom filter query syntax. 86 | """ 87 | def __init__(self, query_str: str='', **filter_kwargs: 'FilterKeywords') -> None: 88 | """ 89 | :param query_str: Specify query string manually. 90 | :param filter_kwargs: Specify required conditions on result. 91 | Example: Filter(attribute='1', relation__attribute='2') 92 | """ 93 | super().__init__(query_str) 94 | self._filter_kwargs = filter_kwargs 95 | 96 | # This and next method prevent any existing subclasses from breaking 97 | def url_with_modifiers(self, base_url: str) -> str: 98 | return self.filtered_url(base_url) 99 | 100 | def filtered_url(self, base_url: str) -> str: 101 | return super().url_with_modifiers(base_url) 102 | 103 | def appended_query(self) -> str: 104 | return super().appended_query() or self.format_filter_query(**self._filter_kwargs) 105 | 106 | def format_filter_query(self, **kwargs: 'FilterKeywords') -> str: 107 | """ 108 | Filter class that implements url filtering scheme according to JSONAPI 109 | recommendations (http://jsonapi.org/recommendations/) 110 | """ 111 | def jsonify_key(key): 112 | return key.replace('__', '.').replace('_', '-') 113 | return '&'.join(f'filter[{jsonify_key(key)}]={value}' 114 | for key, value in kwargs.items()) 115 | 116 | 117 | class Inclusion(Modifier): 118 | """ 119 | Implements query inclusion for Session.get etc. 120 | """ 121 | def __init__(self, *include_args: 'IncludeKeywords') -> None: 122 | super().__init__() 123 | self._include_args = include_args 124 | 125 | def appended_query(self) -> str: 126 | includes = ','.join(self._include_args) 127 | return f'include={includes}' 128 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # JSON API Client documentation build configuration file, created by 5 | # sphinx-quickstart on Thu Mar 2 15:35:36 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 directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | 35 | extensions = [ 36 | 37 | 'sphinx.ext.autodoc', 38 | #'sphinx_autodoc_annotation', 39 | 'sphinx.ext.viewcode', 40 | #'sphinx.ext.inheritance_diagram', 41 | ] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ['_templates'] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # 49 | # source_suffix = ['.rst', '.md'] 50 | source_suffix = '.rst' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # General information about the project. 56 | project = 'JSONAPI Client' 57 | copyright = '2017, Qvantel Inc' 58 | author = 'Tuomas Airaksinen' 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = '0.9' 66 | # The full version, including alpha/beta/rc tags. 67 | release = '0.9' 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = None 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | # This patterns also effect to html_static_path and html_extra_path 79 | exclude_patterns = [] 80 | 81 | # The name of the Pygments (syntax highlighting) style to use. 82 | pygments_style = 'sphinx' 83 | 84 | # If true, `todo` and `todoList` produce output, else they produce nothing. 85 | todo_include_todos = False 86 | 87 | 88 | # -- Options for HTML output ---------------------------------------------- 89 | 90 | # The theme to use for HTML and HTML Help pages. See the documentation for 91 | # a list of builtin themes. 92 | # 93 | html_theme = 'alabaster' 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | # 99 | # html_theme_options = {} 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | html_static_path = ['_static'] 105 | 106 | 107 | # -- Options for HTMLHelp output ------------------------------------------ 108 | 109 | # Output file base name for HTML help builder. 110 | htmlhelp_basename = 'JSONAPIClientdoc' 111 | 112 | autodoc_member_order = 'bysource' 113 | 114 | # -- Options for LaTeX output --------------------------------------------- 115 | 116 | latex_elements = { 117 | # The paper size ('letterpaper' or 'a4paper'). 118 | # 119 | # 'papersize': 'letterpaper', 120 | 121 | # The font size ('10pt', '11pt' or '12pt'). 122 | # 123 | # 'pointsize': '10pt', 124 | 125 | # Additional stuff for the LaTeX preamble. 126 | # 127 | # 'preamble': '', 128 | 129 | # Latex figure (float) alignment 130 | # 131 | # 'figure_align': 'htbp', 132 | } 133 | 134 | # Grouping the document tree into LaTeX files. List of tuples 135 | # (source start file, target name, title, 136 | # author, documentclass [howto, manual, or own class]). 137 | latex_documents = [ 138 | (master_doc, 'JSONAPIClient.tex', 'JSONAPI Client Documentation', 139 | 'Tuomas Airaksinen', 'manual'), 140 | ] 141 | 142 | 143 | # -- Options for manual page output --------------------------------------- 144 | 145 | # One entry per manual page. List of tuples 146 | # (source start file, name, description, authors, manual section). 147 | man_pages = [ 148 | (master_doc, 'jsonapiclient', 'JSONAPI Client Documentation', 149 | [author], 1) 150 | ] 151 | 152 | 153 | # -- Options for Texinfo output ------------------------------------------- 154 | 155 | # Grouping the document tree into Texinfo files. List of tuples 156 | # (source start file, target name, title, author, 157 | # dir menu entry, description, category) 158 | texinfo_documents = [ 159 | (master_doc, 'JSONAPIClient', 'JSONAPI Client Documentation', 160 | author, 'JSONAPIClient', 'One line description of project.', 161 | 'Miscellaneous'), 162 | ] 163 | 164 | 165 | 166 | -------------------------------------------------------------------------------- /src/jsonapi_client/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON API Python client 3 | https://github.com/qvantel/jsonapi-client 4 | 5 | (see JSON API specification in http://jsonapi.org/) 6 | 7 | Copyright (c) 2017, Qvantel 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | * Redistributions in binary form must reproduce the above copyright 15 | notice, this list of conditions and the following disclaimer in the 16 | documentation and/or other materials provided with the distribution. 17 | * Neither the name of the Qvantel nor the 18 | names of its contributors may be used to endorse or promote products 19 | derived from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL QVANTEL BE LIABLE FOR ANY 25 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 28 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | 33 | import asyncio 34 | import logging 35 | from typing import Union, TYPE_CHECKING, NamedTuple 36 | 37 | if TYPE_CHECKING: 38 | from .session import Session 39 | 40 | logger = logging.getLogger(__name__) 41 | 42 | 43 | class HttpStatus: 44 | OK_200 = 200 45 | CREATED_201 = 201 46 | ACCEPTED_202 = 202 47 | NO_CONTENT_204 = 204 48 | FORBIDDEN_403 = 403 49 | NOT_FOUND_404 = 404 50 | CONFLICT_409 = 409 51 | 52 | HAS_RESOURCES = (OK_200, CREATED_201) 53 | ALL_OK = (OK_200, CREATED_201, ACCEPTED_202, NO_CONTENT_204) 54 | 55 | 56 | class HttpMethod: 57 | POST = 'post' 58 | PATCH = 'patch' 59 | DELETE = 'delete' 60 | 61 | 62 | class RelationType: 63 | TO_ONE = 'to-one' 64 | TO_MANY = 'to-many' 65 | 66 | 67 | class AbstractJsonObject: 68 | """ 69 | Base for all JSON API specific objects 70 | """ 71 | def __init__(self, session: 'Session', data: Union[dict, list]) -> None: 72 | self._invalid = False 73 | self._session = session 74 | self._handle_data(data) 75 | 76 | @property 77 | def session(self): 78 | return self._session 79 | 80 | def _handle_data(self, data: Union[dict, list]) -> None: 81 | """ 82 | Store data 83 | """ 84 | raise NotImplementedError 85 | 86 | def __repr__(self): 87 | return f'<{self.__class__.__name__}: {str(self)} ({id(self)})>' 88 | 89 | def __str__(self): 90 | raise NotImplementedError 91 | 92 | @property 93 | def url(self) -> str: 94 | raise NotImplementedError 95 | 96 | def mark_invalid(self): 97 | self._invalid = True 98 | 99 | 100 | def error_from_response(response_content): 101 | try: 102 | error_str = response_content['errors'][0]['title'] 103 | except Exception: 104 | error_str = '?' 105 | return error_str 106 | 107 | 108 | def jsonify_attribute_name(name): 109 | return name.replace('__', '.').replace('_', '-') 110 | 111 | 112 | def dejsonify_attribute_name(name): 113 | return name.replace('.', '__').replace('-', '_') 114 | 115 | 116 | def jsonify_attribute_names(iterable): 117 | for i in iterable: 118 | yield jsonify_attribute_name(i) 119 | 120 | 121 | def dejsonify_attribute_names(iterable): 122 | for i in iterable: 123 | yield dejsonify_attribute_name(i) 124 | 125 | 126 | async def execute_async(func, *args): 127 | """Shortcut to asynchronize normal blocking function""" 128 | loop = asyncio.get_event_loop() 129 | return await loop.run_in_executor(None, func, *args) 130 | 131 | 132 | class cached_property(object): 133 | """ 134 | From Django code 135 | 136 | Decorator that converts a method with a single self argument into a 137 | property cached on the instance. 138 | 139 | Optional ``name`` argument allows you to make cached properties of other 140 | methods. (e.g. url = cached_property(get_absolute_url, name='url') ) 141 | """ 142 | def __init__(self, func, name=None): 143 | self.func = func 144 | self.__doc__ = getattr(func, '__doc__') 145 | self.name = name or func.__name__ 146 | 147 | def __get__(self, instance, type=None): 148 | if instance is None: 149 | return self 150 | res = instance.__dict__[self.name] = self.func(instance) 151 | return res 152 | 153 | 154 | class AttributeProxy: 155 | """ 156 | Attribute proxy used in ResourceObject.fields etc. 157 | """ 158 | def __init__(self, target_object=None): 159 | self._target_object = target_object 160 | 161 | def __getitem__(self, item): 162 | return self._target_object[item] 163 | 164 | def __setitem__(self, key, value): 165 | self._target_object[key] = value 166 | 167 | def __getattr__(self, item): 168 | try: 169 | return self[jsonify_attribute_name(item)] 170 | except KeyError: 171 | raise AttributeError 172 | 173 | def __setattr__(self, key, value): 174 | if key == '_target_object': 175 | return super().__setattr__(key, value) 176 | try: 177 | self[jsonify_attribute_name(key)] = value 178 | except KeyError: 179 | raise AttributeError 180 | 181 | 182 | class ResourceTuple(NamedTuple): 183 | id: str 184 | type: str 185 | 186 | -------------------------------------------------------------------------------- /src/jsonapi_client/objects.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON API Python client 3 | https://github.com/qvantel/jsonapi-client 4 | 5 | (see JSON API specification in http://jsonapi.org/) 6 | 7 | Copyright (c) 2017, Qvantel 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | * Redistributions in binary form must reproduce the above copyright 15 | notice, this list of conditions and the following disclaimer in the 16 | documentation and/or other materials provided with the distribution. 17 | * Neither the name of the Qvantel nor the 18 | names of its contributors may be used to endorse or promote products 19 | derived from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL QVANTEL BE LIABLE FOR ANY 25 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 28 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | 33 | import logging 34 | from itertools import chain 35 | from typing import Optional, Union, Awaitable, TYPE_CHECKING 36 | from urllib.parse import urlparse 37 | 38 | from .common import AbstractJsonObject, jsonify_attribute_name, ResourceTuple 39 | from .resourceobject import ResourceObject 40 | 41 | if TYPE_CHECKING: 42 | from .document import Document 43 | 44 | logger = logging.getLogger(__name__) 45 | 46 | 47 | class Meta(AbstractJsonObject): 48 | """ 49 | Object type for meta data 50 | 51 | http://jsonapi.org/format/#document-meta 52 | """ 53 | def _handle_data(self, data): 54 | self.meta = data 55 | 56 | def __getattr__(self, name): 57 | return self.meta.get(jsonify_attribute_name(name)) 58 | 59 | def __getitem__(self, name): 60 | return self.meta.get(name) 61 | 62 | def __str__(self): 63 | return str(self.meta) 64 | 65 | 66 | class Link(AbstractJsonObject): 67 | """ 68 | Object type for a single link 69 | 70 | http://jsonapi.org/format/#document-links 71 | """ 72 | def _handle_data(self, data): 73 | if data: 74 | if isinstance(data, str): 75 | self.href = data 76 | else: 77 | self.href = data['href'] 78 | self.meta = Meta(self.session, data.get('meta', {})) 79 | else: 80 | self.href = '' 81 | 82 | def __eq__(self, other): 83 | return self.href == other.href 84 | 85 | def __bool__(self): 86 | return bool(self.href) 87 | 88 | @property 89 | def url(self) -> str: 90 | if urlparse(self.href).scheme: # if href contains only relative link 91 | return self.href 92 | else: 93 | return f'{self.session.server_url}{self.href}' 94 | 95 | def __str__(self): 96 | return self.url if self.href else '' 97 | 98 | def fetch_sync(self) -> 'Optional[Document]': 99 | self.session.assert_sync() 100 | if self: 101 | return self.session.fetch_document_by_url(self.url) 102 | 103 | def fetch(self): 104 | if self.session.enable_async: 105 | return self.fetch_async() 106 | else: 107 | return self.fetch_sync() 108 | 109 | async def fetch_async(self) -> 'Optional[Document]': 110 | self.session.assert_async() 111 | if self: 112 | return await self.session.fetch_document_by_url_async(self.url) 113 | 114 | 115 | class Links(AbstractJsonObject): 116 | """ 117 | Object type for container of links 118 | 119 | http://jsonapi.org/format/#document-links 120 | """ 121 | def _handle_data(self, data): 122 | self._links = {key: Link(self.session, value) for key, value in data.items()} 123 | 124 | def __getattr__(self, item): 125 | attr = self._links.get(item) 126 | if not attr: 127 | return Link(self.session, '') 128 | return attr 129 | 130 | def __bool__(self): 131 | return bool(self._links) 132 | 133 | def __dir__(self): 134 | return chain(super().__dir__(), self._links.keys()) 135 | 136 | def __str__(self): 137 | return str(self._links) 138 | 139 | 140 | class ResourceIdentifier(AbstractJsonObject): 141 | """ 142 | Object type for resource identifier 143 | 144 | http://jsonapi.org/format/#document-resource-identifier-objects 145 | """ 146 | def _handle_data(self, data): 147 | self.id:str = data.get('id') 148 | self.type:str = data.get('type') 149 | 150 | @property 151 | def url(self): 152 | return f'{self.session.url_prefix}/{self.type}/{self.id}' 153 | 154 | def __str__(self): 155 | return f'{self.type}: {self.id}' 156 | 157 | def fetch_sync(self, cache_only=True) -> 'ResourceObject': 158 | return self.session.fetch_resource_by_resource_identifier(self, cache_only) 159 | 160 | async def fetch_async(self, cache_only=True) -> 'ResourceObject': 161 | return await self.session.fetch_resource_by_resource_identifier_async(self, 162 | cache_only) 163 | 164 | def fetch(self, cache_only=True) \ 165 | -> 'Union[Awaitable[ResourceObject], ResourceObject]': 166 | if self.session.enable_async: 167 | return self.fetch_async(cache_only) 168 | else: 169 | return self.fetch_sync(cache_only) 170 | 171 | def as_resource_identifier_dict(self) -> dict: 172 | return {'id': self.id, 'type': self.type} if self.id else None 173 | 174 | def __bool__(self): 175 | return self.id is not None 176 | 177 | RESOURCE_TYPES = (ResourceObject, ResourceIdentifier, ResourceTuple) 178 | ResourceTypes = Union[ResourceObject, ResourceIdentifier, ResourceTuple] 179 | -------------------------------------------------------------------------------- /src/jsonapi_client/document.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON API Python client 3 | https://github.com/qvantel/jsonapi-client 4 | 5 | (see JSON API specification in http://jsonapi.org/) 6 | 7 | Copyright (c) 2017, Qvantel 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | * Redistributions in binary form must reproduce the above copyright 15 | notice, this list of conditions and the following disclaimer in the 16 | documentation and/or other materials provided with the distribution. 17 | * Neither the name of the Qvantel nor the 18 | names of its contributors may be used to endorse or promote products 19 | derived from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL QVANTEL BE LIABLE FOR ANY 25 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 28 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | 33 | import logging 34 | from typing import TYPE_CHECKING, Iterator, AsyncIterator, List 35 | 36 | from .common import AbstractJsonObject 37 | from .exceptions import ValidationError, DocumentError 38 | from .objects import Meta, Links 39 | from .resourceobject import ResourceObject 40 | 41 | if TYPE_CHECKING: 42 | from .session import Session 43 | 44 | 45 | logger = logging.getLogger(__name__) 46 | 47 | 48 | class Document(AbstractJsonObject): 49 | """ 50 | Top level of JSON API document. 51 | Contains one or more ResourceObjects. 52 | 53 | http://jsonapi.org/format/#document-top-level 54 | """ 55 | 56 | #: List of ResourceObjects contained in this Document 57 | resources: List['ResourceObject'] 58 | 59 | def __init__(self, session: 'Session', 60 | json_data: dict, 61 | url: str, 62 | no_cache: bool=False) -> None: 63 | self._no_cache = no_cache # if true, do not store resources to session cache 64 | self._url = url 65 | super().__init__(session, json_data) 66 | 67 | @property 68 | def url(self) -> str: 69 | return self._url 70 | 71 | @property 72 | def resource(self) -> 'ResourceObject': 73 | """ 74 | If there is only 1 ResourceObject contained in this Document, return it. 75 | """ 76 | if len(self.resources) > 1: 77 | logger.warning('There are more than 1 item in document %s, please use ' 78 | '.resources!', self) 79 | return self.resources[0] 80 | 81 | def _handle_data(self, json_data): 82 | data = json_data.get('data') 83 | 84 | self.resources = [] 85 | 86 | if data: 87 | if isinstance(data, list): 88 | self.resources.extend([ResourceObject(self.session, i) for i in data]) 89 | elif isinstance(data, dict): 90 | self.resources.append(ResourceObject(self.session, data)) 91 | 92 | self.errors = json_data.get('errors') 93 | if [data, self.errors] == [None]*2: 94 | raise ValidationError('Data or errors is needed') 95 | if data and self.errors: 96 | logger.error('Data and errors can not both exist in the same document') 97 | 98 | self.meta = Meta(self.session, json_data.get('meta', {})) 99 | 100 | self.jsonapi = json_data.get('jsonapi', {}) 101 | self.links = Links(self.session, json_data.get('links', {})) 102 | if self.errors: 103 | raise DocumentError(f'Error document was fetched. Details: {self.errors}', 104 | errors=self.errors) 105 | self.included = [ResourceObject(self.session, i) 106 | for i in json_data.get('included', [])] 107 | if not self._no_cache: 108 | self.session.add_resources(*self.resources, *self.included) 109 | 110 | def __str__(self): 111 | return f'{self.resources}' if self.resources else f'{self.errors}' 112 | 113 | def _iterator_sync(self) -> 'Iterator[ResourceObject]': 114 | # if we currently have no items on the page, then there's no need to yield items 115 | # and check the next page 116 | # we do this because there are APIs that always have a 'next' link, even when 117 | # there are no items on the page 118 | if len(self.resources) == 0: 119 | return 120 | 121 | yield from self.resources 122 | 123 | if self.links.next: 124 | next_doc = self.links.next.fetch() 125 | yield from next_doc.iterator() 126 | 127 | async def _iterator_async(self) -> 'AsyncIterator[ResourceObject]': 128 | # if we currently have no items on the page, then there's no need to yield items 129 | # and check the next page 130 | # we do this because there are APIs that always have a 'next' link, even when 131 | # there are no items on the page 132 | if len(self.resources) == 0: 133 | return 134 | 135 | for res in self.resources: 136 | yield res 137 | 138 | if self.links.next: 139 | next_doc = await self.links.next.fetch() 140 | async for res in next_doc.iterator(): 141 | yield res 142 | 143 | def iterator(self): 144 | """ 145 | Iterate through all resources of this Document and follow pagination until 146 | there's no more resources. 147 | 148 | If Session is in async mode, this needs to be used with async for. 149 | """ 150 | if self.session.enable_async: 151 | return self._iterator_async() 152 | else: 153 | return self._iterator_sync() 154 | 155 | def mark_invalid(self): 156 | """ 157 | Mark this Document and it's resources invalid. 158 | """ 159 | super().mark_invalid() 160 | for r in self.resources: 161 | r.mark_invalid() -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/qvantel/jsonapi-client.svg?branch=master 2 | :target: https://travis-ci.org/qvantel/jsonapi-client 3 | 4 | .. image:: https://coveralls.io/repos/github/qvantel/jsonapi-client/badge.svg 5 | :target: https://coveralls.io/github/qvantel/jsonapi-client 6 | 7 | .. image:: https://img.shields.io/pypi/v/jsonapi-client.svg 8 | :target: https://pypi.python.org/pypi/jsonapi-client 9 | 10 | .. image:: https://img.shields.io/pypi/pyversions/jsonapi-client.svg 11 | :target: https://pypi.python.org/pypi/jsonapi-client 12 | 13 | .. image:: https://img.shields.io/badge/licence-BSD%203--clause-blue.svg 14 | :target: https://github.com/qvantel/jsonapi-client/blob/master/LICENSE.txt 15 | 16 | ========================== 17 | JSON API client for Python 18 | ========================== 19 | 20 | Introduction 21 | ============ 22 | 23 | Package repository: https://github.com/qvantel/jsonapi-client 24 | 25 | This Python (3.6+) library provides easy-to-use, pythonic, ORM-like access to 26 | JSON API ( http://jsonapi.org ) 27 | 28 | - Optional asyncio implementation 29 | - Optional model schema definition and validation (=> easy reads even without schema) 30 | - Resource caching within session 31 | 32 | 33 | Installation 34 | ============ 35 | 36 | From Pypi:: 37 | 38 | pip install jsonapi-client 39 | 40 | Or from sources:: 41 | 42 | ./setup.py install 43 | 44 | 45 | Usage 46 | ===== 47 | 48 | Client session 49 | -------------- 50 | 51 | .. code-block:: python 52 | 53 | from jsonapi_client import Session, Filter, ResourceTuple 54 | 55 | s = Session('http://localhost:8080/') 56 | # To start session in async mode 57 | s = Session('http://localhost:8080/', enable_async=True) 58 | 59 | # You can also pass extra arguments that are passed directly to requests or aiohttp methods, 60 | # such as authentication object 61 | s = Session('http://localhost:8080/', 62 | request_kwargs=dict(auth=HTTPBasicAuth('user', 'password')) 63 | 64 | 65 | # You can also use Session as a context manager. Changes are committed in the end 66 | # and session is closed. 67 | with Session(...) as s: 68 | your code 69 | 70 | # Or with enable_async=True 71 | async with Session(..., enable_async=True): 72 | your code 73 | 74 | # If you are not using context manager, you need to close session manually 75 | s.close() 76 | 77 | # Again, don't forget to await in the AsyncIO mode 78 | await s.close() 79 | 80 | # Fetching documents 81 | documents = s.get('resource_type') 82 | # Or if you want only 1, then 83 | documents = s.get('resource_type', 'id_of_document') 84 | 85 | # AsyncIO the same but remember to await: 86 | documents = await s.get('resource_type') 87 | 88 | Filtering and including 89 | ----------------------- 90 | 91 | .. code-block:: python 92 | 93 | # You need first to specify your filter instance. 94 | # - filtering with two criteria (and) 95 | filter = Filter(attribute='something', attribute2='something_else') 96 | # - filtering some-dict.some-attr == 'something' 97 | filter = Filter(some_dict__some_attr='something')) 98 | 99 | # Same thing goes for including. 100 | # - including two fields 101 | include = Inclusion('related_field', 'other_related_field') 102 | 103 | # Custom syntax for request parameters. 104 | # If you have different URL schema for filtering or other GET parameters, 105 | # you can implement your own Modifier class (derive it from Modifier and 106 | # reimplement appended_query). 107 | modifier = Modifier('filter[post]=1&filter[author]=2') 108 | 109 | # All above classes subclass Modifier and can be added to concatenate 110 | # parameters 111 | modifier_sum = filter + include + modifier 112 | 113 | # Now fetch your document 114 | filtered = s.get('resource_type', modifier_sum) # AsyncIO with await 115 | 116 | # To access resources included in document: 117 | r1 = document.resources[0] # first ResourceObject of document. 118 | r2 = document.resource # if there is only 1 resource we can use this 119 | 120 | Pagination 121 | ---------- 122 | 123 | .. code-block:: python 124 | 125 | # Pagination links can be accessed via Document object. 126 | next_doc = document.links.next.fetch() 127 | # AsyncIO 128 | next_doc = await document.links.next.fetch() 129 | 130 | # Iteration through results (uses pagination): 131 | for r in s.iterate('resource_type'): 132 | print(r) 133 | 134 | # AsyncIO: 135 | async for r in s.iterate('resource_type'): 136 | print(r) 137 | 138 | Resource attribute and relationship access 139 | ------------------------------------------ 140 | 141 | .. code-block:: python 142 | 143 | # - attribute access 144 | attr1 = r1.some_attr 145 | nested_attr = r1.some_dict.some_attr 146 | # Attributes can always also be accessed via __getitem__: 147 | nested_attr = r1['some-dict']['some-attr'] 148 | 149 | # If there is namespace collision, you can also access attributes via .fields proxy 150 | # (both attributes and relationships) 151 | attr2 = r1.fields.some_attr 152 | 153 | # - relationship access. 154 | # * Sync, this gives directly ResourceObject 155 | rel = r1.some_relation 156 | attr3 = r1.some_relation.some_attr # Relationship attribute can be accessed directly 157 | 158 | # * AsyncIO, this gives Relationship object instead because we anyway need to 159 | # call asynchronous fetch function. 160 | rel = r1.some_relation 161 | # To access ResourceObject you need to first fetch content 162 | await r1.some_relation.fetch() 163 | # and then you can access associated resourceobject 164 | res = r1.some_relation.resource 165 | attr3 = res.some_attr # Attribute access through ResourceObject 166 | 167 | # If you need to access relatinoship object itself (with sync API), you can do it via 168 | # .relationships proxy. For example, if you are interested in links or metadata 169 | # provided within relationship, or intend to manipulate relationship. 170 | rel_obj = r1.relationships.relation_name 171 | 172 | Resource updating 173 | ----------------- 174 | 175 | .. code-block:: python 176 | 177 | # Updating / patching existing resources 178 | r1.some_attr = 'something else' 179 | # Patching element in nested json 180 | r1.some_dict.some_dict.some_attr = 'something else' 181 | 182 | # change relationships, to-many. Accepts also iterable of ResourceObjects/ 183 | # ResourceIdentifiers/ResourceTuples 184 | r1.comments = ['1', '2'] 185 | # or if resource type is not known or can have multiple types of resources 186 | r1.comments_or_people = [ResourceTuple('1', 'comments'), ResourceTuple('2', 'people')] 187 | # or if you want to add some resources you can 188 | r1.comments_or_people += [ResourceTuple('1', 'people')] 189 | r1.commit() 190 | 191 | # change to-one relationships 192 | r1.author = '3' # accepts also ResourceObjects/ResourceIdentifiers/ResourceTuple 193 | # or resource type is not known (via schema etc.) 194 | r1.author = ResourceTuple('3', 'people') 195 | 196 | # Committing changes (PATCH request) 197 | r1.commit(meta={'some_meta': 'data'}) # Resource committing supports optional meta data 198 | # AsyncIO 199 | await r1.commit(meta={'some_meta': 'data'}) 200 | 201 | 202 | Creating new resources 203 | ---------------------- 204 | 205 | 206 | .. code-block:: python 207 | 208 | # Creating new resources. Schema must be given. Accepts dictionary of schema models 209 | # (key is model name and value is schema as json-schema.org). 210 | 211 | models_as_jsonschema = { 212 | 'articles': {'properties': { 213 | 'title': {'type': 'string'}, 214 | 'author': {'relation': 'to-one', 'resource': ['people']}, 215 | 'comments': {'relation': 'to-many', 'resource': ['comments']}, 216 | }}, 217 | 'people': {'properties': { 218 | 'first-name': {'type': 'string'}, 219 | 'last-name': {'type': 'string'}, 220 | 'twitter': {'type': ['null', 'string']}, 221 | }}, 222 | 'comments': {'properties': { 223 | 'body': {'type': 'string'}, 224 | 'author': {'relation': 'to-one', 'resource': ['people']} 225 | }} 226 | } 227 | # If you type schema by hand, it could be more convenient to type it as yml in a file 228 | # instead 229 | 230 | s = Session('http://localhost:8080/', schema=models_as_jsonschema) 231 | a = s.create('articles') # Creates empty ResourceObject of 'articles' type 232 | a.title = 'Test title' 233 | 234 | # Validates and performs POST request, and finally updates resource based on server response 235 | a.commit(meta={'some_meta': 'data'}) 236 | # Or with AsyncIO, remember to await 237 | await a.commit(meta={'some_meta': 'data'}) 238 | 239 | # Commit metadata could be also saved in advance: 240 | a.commit_metadata = {'some_meta': 'data'} 241 | # You can also commit all changed resources in session by 242 | s.commit() 243 | # or with AsyncIO 244 | await s.commit() 245 | 246 | # Another example of resource creation, setting attributes and relationships & committing: 247 | # If you have underscores in your field names, you can pass them in fields keyword argument as 248 | # a dictionary: 249 | cust1 = s.create_and_commit('articles', 250 | attribute='1', 251 | dict_object__attribute='2', 252 | to_one_relationship='3', 253 | to_many_relationship=['1', '2'], 254 | fields={'some_field_with_underscore': '1'} 255 | ) 256 | 257 | # Async: 258 | cust1 = await s.create_and_commit('articles', 259 | attribute='1', 260 | dict_object__attribute='2', 261 | to_one_relationship='3', 262 | to_many_relationship=['1', '2'], 263 | fields={'some_field_with_underscore': '1'} 264 | ) 265 | 266 | Deleting resources 267 | ------------------ 268 | 269 | .. code-block:: python 270 | 271 | # Delete resource 272 | cust1.delete() # Mark to be deleted 273 | cust1.commit() # Actually delete 274 | 275 | 276 | Credits 277 | ======= 278 | 279 | - Work was supported by Qvantel (http://qvantel.com). 280 | - Author and package maintainer: Tuomas Airaksinen (https://github.com/tuomas2/). 281 | 282 | 283 | License 284 | ======= 285 | 286 | Copyright (c) 2017, Qvantel 287 | 288 | All rights reserved. 289 | 290 | Redistribution and use in source and binary forms, with or without 291 | modification, are permitted provided that the following conditions are met: 292 | 293 | - Redistributions of source code must retain the above copyright 294 | notice, this list of conditions and the following disclaimer. 295 | - Redistributions in binary form must reproduce the above copyright 296 | notice, this list of conditions and the following disclaimer in the 297 | documentation and/or other materials provided with the distribution. 298 | - Neither the name of the Qvantel nor the 299 | names of its contributors may be used to endorse or promote products 300 | derived from this software without specific prior written permission. 301 | 302 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 303 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 304 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 305 | DISCLAIMED. IN NO EVENT SHALL QVANTEL BE LIABLE FOR ANY 306 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 307 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 308 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 309 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 310 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 311 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 312 | 313 | -------------------------------------------------------------------------------- /src/jsonapi_client/relationships.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON API Python client 3 | https://github.com/qvantel/jsonapi-client 4 | 5 | (see JSON API specification in http://jsonapi.org/) 6 | 7 | Copyright (c) 2017, Qvantel 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | * Redistributions in binary form must reproduce the above copyright 15 | notice, this list of conditions and the following disclaimer in the 16 | documentation and/or other materials provided with the distribution. 17 | * Neither the name of the Qvantel nor the 18 | names of its contributors may be used to endorse or promote products 19 | derived from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL QVANTEL BE LIABLE FOR ANY 25 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 28 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | 33 | import collections 34 | import logging 35 | from typing import List, Union, Iterable, Dict, Tuple, Awaitable, TYPE_CHECKING 36 | 37 | from .common import AbstractJsonObject, RelationType, ResourceTuple 38 | from .objects import (Meta, Links, ResourceIdentifier, RESOURCE_TYPES) 39 | from .resourceobject import ResourceObject 40 | 41 | logger = logging.getLogger(__name__) 42 | 43 | R_IDENT_TYPES = Union[str, ResourceObject, ResourceIdentifier, ResourceTuple] 44 | 45 | if TYPE_CHECKING: 46 | from .filter import Modifier 47 | from .document import Document 48 | from .session import Session 49 | 50 | 51 | class AbstractRelationship(AbstractJsonObject): 52 | """ 53 | Relationships are containers for ResourceObjects related to relationships. 54 | ResourceObjects are automatically fetched if not in async mode. 55 | If in async mode, .fetch() needs to be awaited first. 56 | 57 | http://jsonapi.org/format/#document-resource-object-relationships 58 | """ 59 | 60 | def __init__(self, 61 | session: 'Session', 62 | data: dict, 63 | resource_types: List[str]=None, 64 | relation_type: str='') -> None: 65 | """ 66 | :param resource_types: List of allowed resource types 67 | :param relation_type: Relation type, either 'to-one' or 'to-many', 68 | or not specified (empty string). 69 | """ 70 | self._resources: Dict[Tuple[str, str], ResourceObject] = None 71 | self._invalid = False 72 | self._is_dirty: bool = False 73 | self._resource_types = resource_types or [] 74 | self._relation_type = relation_type 75 | 76 | super().__init__(session, data) 77 | 78 | @property 79 | def is_single(self) -> bool: 80 | raise NotImplementedError 81 | 82 | def _modify_sync(self, modifier: 'Modifier') -> 'Document': 83 | url = modifier.url_with_modifiers(self.url) 84 | return self.session.fetch_document_by_url(url) 85 | 86 | async def _modify_async(self, modifier: 'Modifier'): 87 | url = modifier.url_with_modifiers(self.url) 88 | return self.session.fetch_document_by_url_async(url) 89 | 90 | def filter(self, filter: 'Modifier') -> 'Union[Awaitable[Document], Document]': 91 | """ 92 | Receive filtered list of resources. Use Modifier instance. 93 | 94 | If in async mode, this needs to be awaited. 95 | """ 96 | if self.session.enable_async: 97 | return self._modify_async(filter) 98 | else: 99 | return self._modify_sync(filter) 100 | 101 | @property 102 | def is_dirty(self) -> bool: 103 | return self._is_dirty 104 | 105 | def mark_clean(self): 106 | """ 107 | Mark this relationship as clean (not modified/dirty). 108 | """ 109 | self._is_dirty = False 110 | 111 | def mark_dirty(self): 112 | """ 113 | Mark this relationship as modified/dirty. 114 | """ 115 | self._is_dirty = True 116 | 117 | async def _fetch_async(self) -> 'List[ResourceObject]': 118 | raise NotImplementedError 119 | 120 | def _fetch_sync(self) -> 'List[ResourceObject]': 121 | raise NotImplementedError 122 | 123 | def fetch(self) -> 'Union[Awaitable[List[ResourceObject]], List[ResourceObject]]': 124 | """ 125 | Fetch ResourceObjects. In practice this needs to be used only if in async mode 126 | and then this needs to be awaited. 127 | 128 | In blocking (sync) mode this is called automatically when .resource or 129 | .resources is accessed. 130 | """ 131 | if self.session.enable_async: 132 | return self._fetch_async() 133 | else: 134 | return self._fetch_sync() 135 | 136 | def _handle_data(self, data): 137 | self.links = Links(self.session, data.get('links', {})) 138 | self.meta = Meta(self.session, data.get('meta', {})) 139 | self._resource_data = data.get('data', {}) 140 | 141 | @property 142 | def resources(self) -> 'List[Union[ResourceIdentifier, ResourceObject]]': 143 | """ 144 | Return related ResourceObjects. If this relationship has been 145 | modified (waiting to be committed (PATCH)), this also returns 146 | ResourceIdentifier objects of those new linked resources. 147 | 148 | In async mode, you need to first await .fetch() 149 | """ 150 | return ((self._resources is not None and list(self._resources.values())) 151 | or self._fetch_sync()) 152 | 153 | @property 154 | def resource(self) -> 'ResourceObject': 155 | """ 156 | If there is only 1 resource, return it. 157 | 158 | In async mode, you need to first await .fetch() 159 | """ 160 | if len(self.resources) > 1: 161 | logger.warning('More than 1 resource, use .resources instead!') 162 | return self.resources[0] 163 | 164 | @property 165 | def as_json_resource_identifiers(self) -> dict: 166 | """ 167 | Return resource identifier -style linkage (used in posting/patching) 168 | 169 | i.e. 170 | 171 | {'type': 'model_name', 'id': '1'} 172 | 173 | or list of these. 174 | """ 175 | raise NotImplementedError 176 | 177 | @property 178 | def is_fetched(self) -> bool: 179 | return bool(self._resources) 180 | 181 | def set(self, new_value, type_=None) -> None: 182 | """ 183 | This function is used when new values is set as targets of this relationship. 184 | 185 | Implement in subclasses 186 | """ 187 | raise NotImplementedError 188 | 189 | def __str__(self): 190 | raise NotImplementedError 191 | 192 | @property 193 | def url(self) -> str: 194 | raise NotImplementedError 195 | 196 | @property 197 | def type(self) -> str: 198 | """ 199 | Return the type of this relationship, if there is only 1 allowed type. 200 | """ 201 | if len(self._resource_types) != 1: 202 | raise TypeError('Type needs to be specified manually, use .set or .add') 203 | return self._resource_types[0] 204 | 205 | def __bool__(self): 206 | raise NotImplementedError 207 | 208 | def _value_to_identifier(self, value: R_IDENT_TYPES, type_: str='') \ 209 | -> 'Union[ResourceIdentifier, ResourceObject]': 210 | if isinstance(value, RESOURCE_TYPES): 211 | r_ident = ResourceIdentifier(self.session, {'id': value.id, 'type': value.type}) 212 | else: 213 | r_ident = ResourceIdentifier(self.session, {'id': value, 214 | 'type': type_ or self.type}) 215 | res = self._resources and self._resources.get((r_ident.type, r_ident.id)) 216 | return res or r_ident 217 | 218 | 219 | class SingleRelationship(AbstractRelationship): 220 | """ 221 | Relationship class for to-one type relationships, that are received from 222 | server as ResourceIdentifiers. 223 | """ 224 | def _handle_data(self, data): 225 | super()._handle_data(data) 226 | if self._resource_data is None: 227 | self._resource_identifier = None 228 | else: 229 | self._resource_identifier = ResourceIdentifier(self.session, self._resource_data) 230 | del self._resource_data # This is not intended to be used after this 231 | 232 | async def _fetch_async(self) -> 'List[ResourceObject]': 233 | self.session.assert_async() 234 | res_id = self._resource_identifier 235 | if res_id is None: 236 | self._resources = {None: None} 237 | else: 238 | res = await self.session.fetch_resource_by_resource_identifier_async(res_id) 239 | self._resources = {(res.type, res.id): res} 240 | return list(self._resources.values()) 241 | 242 | def _fetch_sync(self) -> 'List[ResourceObject]': 243 | self.session.assert_sync() 244 | res_id = self._resource_identifier 245 | if res_id is None: 246 | self._resources = {None: None} 247 | else: 248 | res = self.session.fetch_resource_by_resource_identifier(res_id) 249 | self._resources = {(res.type, res.id): res} 250 | return list(self._resources.values()) 251 | 252 | def __bool__(self): 253 | return bool(self._resource_identifier) 254 | 255 | def __str__(self): 256 | return str(self._resource_identifier) 257 | 258 | @property 259 | def is_single(self) -> bool: 260 | return True 261 | 262 | @property 263 | def url(self) -> str: 264 | if self._resource_identifier is None: 265 | return self.links.related 266 | return self._resource_identifier.url 267 | 268 | @property 269 | def as_json_resource_identifiers(self) -> dict: 270 | if self._resource_identifier is None: 271 | return None 272 | return self._resource_identifier.as_resource_identifier_dict() 273 | 274 | def _value_to_identifier(self, value: R_IDENT_TYPES, type_: str='') \ 275 | -> 'Union[ResourceIdentifier, ResourceObject]': 276 | if value is None: 277 | return None 278 | return super()._value_to_identifier(value, type_) 279 | 280 | def set(self, new_value: R_IDENT_TYPES, type_: str='') -> None: 281 | 282 | self._resource_identifier = self._value_to_identifier(new_value, type_) 283 | self.mark_dirty() 284 | 285 | 286 | class MultiRelationship(AbstractRelationship): 287 | """ 288 | Relationship class for to-many type relationships, that are received from 289 | server as ResourceIdentifiers. 290 | """ 291 | def _handle_data(self, data): 292 | super()._handle_data(data) 293 | self._resource_identifiers = [ResourceIdentifier(self.session, d) 294 | for d in self._resource_data] 295 | del self._resource_data 296 | 297 | @property 298 | def is_single(self) -> bool: 299 | return False 300 | 301 | async def _fetch_async(self) -> 'List[ResourceObject]': 302 | self.session.assert_async() 303 | self._resources = {} 304 | for res_id in self._resource_identifiers: 305 | res = await self.session.fetch_resource_by_resource_identifier_async(res_id) 306 | self._resources[(res.type, res.id)] = res 307 | return list(self._resources.values()) 308 | 309 | def _fetch_sync(self) -> 'List[ResourceObject]': 310 | self.session.assert_sync() 311 | self._resources = {} 312 | for res_id in self._resource_identifiers: 313 | res = self.session.fetch_resource_by_resource_identifier(res_id) 314 | self._resources[(res.type, res.id)] = res 315 | return list(self._resources.values()) 316 | 317 | def __str__(self): 318 | return str(self._resource_identifiers) 319 | 320 | @property 321 | def url(self) -> str: 322 | return self.links.related 323 | 324 | @property 325 | def as_json_resource_identifiers(self) -> List[dict]: 326 | return [res.as_resource_identifier_dict() for res in self._resource_identifiers] 327 | 328 | def set(self, new_values: Iterable[R_IDENT_TYPES], type_: str=None) -> None: 329 | self._resource_identifiers = [self._value_to_identifier(value, type_) 330 | for value in new_values] 331 | self.mark_dirty() 332 | 333 | def clear(self): 334 | """ 335 | Remove all target resources (commit will remove them on server side). 336 | """ 337 | self._resource_identifiers.clear() 338 | self.mark_dirty() 339 | 340 | def add(self, new_value: Union[R_IDENT_TYPES, Iterable[R_IDENT_TYPES]], type_=None) -> None: 341 | """ 342 | Add new resources 343 | """ 344 | if type_ is None: 345 | type_ = self.type 346 | if isinstance(new_value, collections.abc.Iterable): 347 | self._resource_identifiers.extend( 348 | [self._value_to_identifier(val, type_) for val in new_value]) 349 | else: 350 | self._resource_identifiers.append(self._value_to_identifier(new_value, type_)) 351 | 352 | self.mark_dirty() 353 | 354 | def __add__(self, other): 355 | return self.add(other) 356 | 357 | def __bool__(self): 358 | return bool(self._resource_identifiers) 359 | 360 | 361 | class LinkRelationship(AbstractRelationship): 362 | """ 363 | Relationship class for to-one or to-many type relationships, that are received from 364 | server with only link information (no ResourceIdentifiers). 365 | """ 366 | def __init__(self, *args, **kwargs): 367 | self._resource_identifiers = None 368 | self._document: 'Document' = None 369 | super().__init__(*args, **kwargs) 370 | 371 | def __bool__(self): 372 | return bool(self._resource_identifiers) 373 | 374 | @property 375 | def document(self) -> 'Document': 376 | doc = getattr(self, '_document', None) 377 | if doc is None: 378 | self._fetch_sync() 379 | return self._document 380 | 381 | @property 382 | def is_single(self) -> bool: 383 | if self._relation_type: 384 | return self._relation_type == RelationType.TO_ONE 385 | else: 386 | return False 387 | 388 | async def _fetch_async(self) -> 'List[ResourceObject]': 389 | self.session.assert_async() 390 | self._document = \ 391 | await self.session.fetch_document_by_url_async(self.links.related.url) 392 | if self.session.use_relationship_iterator: 393 | return self._document.iterator() 394 | self._resources = {(r.type, r.id): r for r in self._document.resources} 395 | return list(self._resources.values()) 396 | 397 | def _fetch_sync(self) -> 'List[ResourceObject]': 398 | self.session.assert_sync() 399 | self._document = self.session.fetch_document_by_url(self.links.related.url) 400 | if self.session.use_relationship_iterator: 401 | return self._document.iterator() 402 | self._resources = {(r.type, r.id): r for r in self._document.resources} 403 | return list(self._resources.values()) 404 | 405 | def mark_clean(self): 406 | self._is_dirty = False 407 | if self._document: 408 | self._document.mark_invalid() 409 | 410 | def __str__(self): 411 | return (f'{self.url} ({len(self.resources)}) dirty: {self.is_dirty}' 412 | if self.is_fetched else self.url) 413 | 414 | @property 415 | def as_json_resource_identifiers(self) -> Union[list, dict]: 416 | if self.is_single: 417 | return self.resource.as_resource_identifier_dict() 418 | else: 419 | return [res.as_resource_identifier_dict() for res in self.resources] 420 | 421 | @property 422 | def url(self) -> str: 423 | return str(self.links.related) 424 | 425 | def set(self, new_value: Union[Iterable[R_IDENT_TYPES], R_IDENT_TYPES], 426 | type_: str='') -> None: 427 | if isinstance(new_value, collections.abc.Iterable): 428 | if self.is_single: 429 | logger.warning('This should contain list of resources, ' 430 | 'but only one is given') 431 | resources = [self._value_to_identifier(val, type_) for val in new_value] 432 | self._resources = {(r.type, r.id):r for r in resources} 433 | else: 434 | if not self.is_single: 435 | logger.warning('This should contain only 1 resource, ' 436 | 'but a list of values is given') 437 | res = self._value_to_identifier(new_value, type_) 438 | self._resources = {(res.type, res.id): res} 439 | self.mark_dirty() 440 | 441 | 442 | class MetaRelationship(AbstractRelationship): 443 | """ 444 | Handle relationship manually through meta object. We don't know what to do 445 | about them as they are custom data. 446 | """ 447 | 448 | -------------------------------------------------------------------------------- /src/jsonapi_client/session.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON API Python client 3 | https://github.com/qvantel/jsonapi-client 4 | 5 | (see JSON API specification in http://jsonapi.org/) 6 | 7 | Copyright (c) 2017, Qvantel 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | * Redistributions in binary form must reproduce the above copyright 15 | notice, this list of conditions and the following disclaimer in the 16 | documentation and/or other materials provided with the distribution. 17 | * Neither the name of the Qvantel nor the 18 | names of its contributors may be used to endorse or promote products 19 | derived from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL QVANTEL BE LIABLE FOR ANY 25 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 28 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | 33 | import collections 34 | import json 35 | import logging 36 | from itertools import chain 37 | from typing import (TYPE_CHECKING, Set, Optional, Tuple, Dict, Union, Iterable, 38 | AsyncIterable, Awaitable, AsyncIterator, Iterator, List) 39 | from urllib.parse import ParseResult, urlparse 40 | 41 | import jsonschema 42 | 43 | from .common import jsonify_attribute_name, error_from_response, \ 44 | HttpStatus, HttpMethod 45 | from .exceptions import DocumentError, AsyncError 46 | 47 | if TYPE_CHECKING: 48 | from asyncio import AbstractEventLoop 49 | from .objects import ResourceIdentifier 50 | from .document import Document 51 | from .resourceobject import ResourceObject 52 | from .relationships import ResourceTuple 53 | from .filter import Modifier 54 | 55 | logger = logging.getLogger(__name__) 56 | NOT_FOUND = object() 57 | 58 | 59 | class Schema: 60 | """ 61 | Container for model schemas with associated methods. 62 | Session contains Schema. 63 | """ 64 | 65 | def __init__(self, schema_data: dict=None) -> None: 66 | self._schema_data = schema_data 67 | 68 | def find_spec(self, model_name: str, attribute_name: str) -> dict: 69 | """ 70 | Find specification from model_name for attribute_name which can 71 | be nested format, i.e. 'attribute-group1.attribute-group2.attribute-item' 72 | """ 73 | 74 | # We need to support meta, which can contain whatever schemaless metadata 75 | if attribute_name == 'meta' or attribute_name.endswith('.meta'): 76 | return {} 77 | 78 | model = self.schema_for_model(model_name) 79 | if not model: 80 | return {} 81 | if not attribute_name: 82 | return model 83 | attr_struct = attribute_name.split('.') 84 | for a in attr_struct: 85 | model = model['properties'].get(a, NOT_FOUND) 86 | if model is NOT_FOUND: 87 | return {} 88 | return model 89 | 90 | def add_model_schema(self, data: dict) -> None: 91 | self._schema_data.update(data) 92 | 93 | @property 94 | def is_enabled(self): 95 | return bool(self._schema_data) 96 | 97 | def schema_for_model(self, model_type: str) -> dict: 98 | return self._schema_data.get(model_type) if self.is_enabled else {} 99 | 100 | def validate(self, model_type: str, data: dict) -> None: 101 | """ 102 | Validate model data against schema. 103 | """ 104 | schema = self.schema_for_model(model_type) 105 | if not schema: 106 | return 107 | jsonschema.validate(data, schema) 108 | 109 | 110 | class Session: 111 | """ 112 | Resources are fetched and cached in a session. 113 | 114 | :param server_url: Server base url 115 | :param enable_async: Toggle AsyncIO mode for session 116 | :param schema: Schema in jsonschema format. See example from :ref:`usage-schema`. 117 | :param request_kwargs: Additional keyword arguments that are passed to requests.request or 118 | aiohttp.request functions (such as authentication object) 119 | 120 | """ 121 | def __init__(self, server_url: str=None, 122 | enable_async: bool=False, 123 | schema: dict=None, 124 | request_kwargs: dict=None, 125 | loop: 'AbstractEventLoop'=None, 126 | use_relationship_iterator: bool=False,) -> None: 127 | self._server: ParseResult 128 | self.enable_async = enable_async 129 | 130 | self._request_kwargs: dict = request_kwargs or {} 131 | 132 | if server_url: 133 | self._server = urlparse(server_url) 134 | else: 135 | self._server = None 136 | 137 | self.resources_by_resource_identifier: \ 138 | 'Dict[Tuple[str, str], ResourceObject]' = {} 139 | self.resources_by_link: 'Dict[str, ResourceObject]' = {} 140 | self.documents_by_link: 'Dict[str, Document]' = {} 141 | self.schema: Schema = Schema(schema) 142 | if enable_async: 143 | import aiohttp 144 | self._aiohttp_session = aiohttp.ClientSession(loop=loop) 145 | self.use_relationship_iterator = use_relationship_iterator 146 | 147 | def add_resources(self, *resources: 'ResourceObject') -> None: 148 | """ 149 | Add resources to session cache. 150 | """ 151 | for res in resources: 152 | self.resources_by_resource_identifier[(res.type, res.id)] = res 153 | lnk = res.links.self.url if res.links.self else res.url 154 | if lnk: 155 | self.resources_by_link[lnk] = res 156 | 157 | def remove_resource(self, res: 'ResourceObject') -> None: 158 | """ 159 | Remove resource from session cache. 160 | 161 | :param res: Resource to be removed 162 | """ 163 | del self.resources_by_resource_identifier[(res.type, res.id)] 164 | del self.resources_by_link[res.url] 165 | 166 | @staticmethod 167 | def _value_to_dict(value: 'Union[ResourceObject, ResourceIdentifier, ResourceTuple]', 168 | res_types: 'List[str]') -> dict: 169 | from .objects import RESOURCE_TYPES 170 | 171 | res_type = res_types[0] if len(res_types) == 1 else None 172 | 173 | if isinstance(value, RESOURCE_TYPES): 174 | if res_type and value.type != res_type: 175 | raise TypeError(f'Invalid resource type {value.type}. ' 176 | f'Should be {res_type}') 177 | elif res_types and value.type not in res_types: 178 | raise TypeError(f'Invalid resource type {value.type}. ' 179 | f'Should be one of {res_types}') 180 | return {'id': value.id, 'type': value.type} 181 | else: 182 | if not res_type: 183 | raise ValueError('Use ResourceTuple to identify types ' 184 | 'if there are more than 1 type') 185 | return {'id': value, 'type': res_types[0]} 186 | 187 | def create(self, _type: str, fields: dict=None, **more_fields) -> 'ResourceObject': 188 | """ 189 | Create a new ResourceObject of model _type. This requires that schema is defined 190 | for model. 191 | 192 | If you have field names that have underscores, you can pass those fields 193 | in fields dictionary. 194 | 195 | """ 196 | from .objects import RESOURCE_TYPES 197 | from .resourceobject import ResourceObject 198 | 199 | if fields is None: 200 | fields = {} 201 | 202 | attrs: dict = {} 203 | rels: dict = {} 204 | schema = self.schema.schema_for_model(_type) 205 | more_fields.update(fields) 206 | 207 | for key, value in more_fields.items(): 208 | if key not in fields: 209 | key = jsonify_attribute_name(key) 210 | props = schema['properties'].get(key, {}) 211 | if 'relation' in props: 212 | res_types = props['resource'] 213 | if isinstance(value, RESOURCE_TYPES + (str,)): 214 | value = self._value_to_dict(value, res_types) 215 | elif isinstance(value, collections.abc.Iterable): 216 | value = [self._value_to_dict(id_, res_types) for id_ in value] 217 | rels[key] = {'data': value} 218 | else: 219 | key = key.split('.') 220 | a = attrs 221 | for k in key[:-1]: 222 | a_ = a[k] = a.get(k, {}) 223 | a = a_ 224 | 225 | a[key[-1]] = value 226 | 227 | data = {'type': _type, 228 | 'id': None, 229 | 'attributes': attrs, 230 | 'relationships': rels, 231 | } 232 | 233 | res = ResourceObject(self, data) 234 | return res 235 | 236 | def _create_and_commit_sync(self, type_: str, fields: dict=None, **more_fields) -> 'ResourceObject': 237 | res = self.create(type_, fields, **more_fields) 238 | res.commit() 239 | return res 240 | 241 | async def _create_and_commit_async(self, type_: str, fields: dict=None, **more_fields) -> 'ResourceObject': 242 | res = self.create(type_, fields, **more_fields) 243 | await res.commit() 244 | return res 245 | 246 | def create_and_commit(self, type_: str, fields: dict=None, **more_fields) \ 247 | -> 'Union[Awaitable[ResourceObject], ResourceObject]': 248 | """ 249 | Create resource and commit (PUSH) it into server. 250 | If session is used with enable_async=True, this needs 251 | to be awaited. 252 | """ 253 | 254 | if self.enable_async: 255 | return self._create_and_commit_async(type_, fields, **more_fields) 256 | else: 257 | return self._create_and_commit_sync(type_, fields, **more_fields) 258 | 259 | def __enter__(self): 260 | self.assert_sync() 261 | logger.info('Entering session') 262 | return self 263 | 264 | async def __aenter__(self): 265 | self.assert_async() 266 | logger.info('Entering session') 267 | return self 268 | 269 | def __exit__(self, exc_type, exc_val, exc_tb): 270 | self.assert_sync() 271 | logger.info('Exiting session') 272 | if not exc_type: 273 | self.commit() 274 | self.close() 275 | 276 | async def __aexit__(self, exc_type, exc_val, exc_tb): 277 | self.assert_async() 278 | logger.info('Exiting session') 279 | if not exc_type: 280 | await self.commit() 281 | await self.close() 282 | 283 | def close(self): 284 | """ 285 | Close session and invalidate resources. 286 | """ 287 | self.invalidate() 288 | if self.enable_async: 289 | return self._aiohttp_session.close() 290 | 291 | def invalidate(self): 292 | """ 293 | Invalidate resources and documents associated with this Session. 294 | """ 295 | for resource in chain(self.documents_by_link.values(), 296 | self.resources_by_link.values(), 297 | self.resources_by_resource_identifier.values()): 298 | resource.mark_invalid() 299 | 300 | self.documents_by_link.clear() 301 | self.resources_by_link.clear() 302 | self.resources_by_resource_identifier.clear() 303 | 304 | @property 305 | def server_url(self) -> str: 306 | return f'{self._server.scheme}://{self._server.netloc}' 307 | 308 | @property 309 | def url_prefix(self) -> str: 310 | return self._server.geturl().rstrip('/') 311 | 312 | def _url_for_resource(self, resource_type: str, 313 | resource_id: str=None, 314 | filter: 'Modifier'=None) -> str: 315 | url = f'{self.url_prefix}/{resource_type}' 316 | if resource_id is not None: 317 | url = f'{url}/{resource_id}' 318 | if filter: 319 | url = filter.url_with_modifiers(url) 320 | return url 321 | 322 | @staticmethod 323 | def _resource_type_and_filter( 324 | resource_id_or_filter: 'Union[Modifier, str]'=None)\ 325 | -> 'Tuple[Optional[str], Optional[Modifier]]': 326 | from .filter import Modifier 327 | if isinstance(resource_id_or_filter, Modifier): 328 | resource_id = None 329 | filter = resource_id_or_filter 330 | else: 331 | resource_id = resource_id_or_filter 332 | filter = None 333 | return resource_id, filter 334 | 335 | def _get_sync(self, resource_type: str, 336 | resource_id_or_filter: 'Union[Modifier, str]'=None) -> 'Document': 337 | resource_id, filter_ = self._resource_type_and_filter( 338 | resource_id_or_filter) 339 | url = self._url_for_resource(resource_type, resource_id, filter_) 340 | return self.fetch_document_by_url(url) 341 | 342 | async def _get_async(self, resource_type: str, 343 | resource_id_or_filter: 'Union[Modifier, str]'=None) -> 'Document': 344 | resource_id, filter_ = self._resource_type_and_filter( 345 | resource_id_or_filter) 346 | url = self._url_for_resource(resource_type, resource_id, filter_) 347 | return await self.fetch_document_by_url_async(url) 348 | 349 | def get(self, resource_type: str, 350 | resource_id_or_filter: 'Union[Modifier, str]'=None) \ 351 | -> 'Union[Awaitable[Document], Document]': 352 | """ 353 | Request (GET) Document from server. 354 | 355 | :param resource_id_or_filter: Resource id or Modifier instance to filter 356 | resulting resources. 357 | 358 | If session is used with enable_async=True, this needs 359 | to be awaited. 360 | """ 361 | if self.enable_async: 362 | return self._get_async(resource_type, resource_id_or_filter) 363 | else: 364 | return self._get_sync(resource_type, resource_id_or_filter) 365 | 366 | def _iterate_sync(self, resource_type: str, filter: 'Modifier'=None) \ 367 | -> 'Iterator[ResourceObject]': 368 | doc = self.get(resource_type, filter) 369 | yield from doc._iterator_sync() 370 | 371 | async def _iterate_async(self, resource_type: str, filter: 'Modifier'=None) \ 372 | -> 'AsyncIterator[ResourceObject]': 373 | doc = await self._get_async(resource_type, filter) 374 | async for res in doc._iterator_async(): 375 | yield res 376 | 377 | def iterate(self, resource_type: str, filter: 'Modifier'=None) \ 378 | -> 'Union[AsyncIterator[ResourceObject], Iterator[ResourceObject]]': 379 | """ 380 | Request (GET) Document from server and iterate through resources. 381 | If Document uses pagination, fetch results as long as there are new 382 | results. 383 | 384 | If session is used with enable_async=True, this needs to iterated with 385 | async for. 386 | 387 | :param filter: Modifier instance to filter resulting resources. 388 | """ 389 | if self.enable_async: 390 | return self._iterate_async(resource_type, filter) 391 | else: 392 | return self._iterate_sync(resource_type, filter) 393 | 394 | def read(self, json_data: dict, url='', no_cache=False)-> 'Document': 395 | """ 396 | Read document from json_data dictionary instead of fetching it from the server. 397 | 398 | :param json_data: JSON API document as dictionary. 399 | :param url: Set source url to resulting document. 400 | :param no_cache: do not store results into Session's cache. 401 | """ 402 | from .document import Document 403 | doc = self.documents_by_link[url] = Document(self, json_data, url, 404 | no_cache=no_cache) 405 | return doc 406 | 407 | def fetch_resource_by_resource_identifier( 408 | self, 409 | resource: 'Union[ResourceIdentifier, ResourceObject, ResourceTuple]', 410 | cache_only=False, 411 | force=False) -> 'Optional[ResourceObject]': 412 | """ 413 | Internal use. 414 | 415 | Fetch resource from server by resource identifier. 416 | """ 417 | type_, id_ = resource.type, resource.id 418 | new_res = not force and self.resources_by_resource_identifier.get((type_, id_)) 419 | if new_res: 420 | return new_res 421 | elif cache_only: 422 | return None 423 | else: 424 | # Note: Document creation will add its resources to cache via .add_resources, 425 | # no need to do it manually here 426 | return self._ext_fetch_by_url(resource.url).resource 427 | 428 | async def fetch_resource_by_resource_identifier_async( 429 | self, 430 | resource: 'Union[ResourceIdentifier, ResourceObject, ResourceTuple]', 431 | cache_only=False, 432 | force=False) -> 'Optional[ResourceObject]': 433 | """ 434 | Internal use. Async version. 435 | 436 | Fetch resource from server by resource identifier. 437 | """ 438 | type_, id_ = resource.type, resource.id 439 | new_res = not force and self.resources_by_resource_identifier.get((type_, id_)) 440 | if new_res: 441 | return new_res 442 | elif cache_only: 443 | return None 444 | else: 445 | # Note: Document creation will add its resources to cache via .add_resources, 446 | # no need to do it manually here 447 | return (await self._ext_fetch_by_url_async(resource.url)).resource 448 | 449 | def fetch_document_by_url(self, url: str) -> 'Document': 450 | """ 451 | Internal use. 452 | 453 | Fetch Document from server by url. 454 | """ 455 | 456 | # TODO: should we try to guess type, id from url? 457 | return self.documents_by_link.get(url) or self._ext_fetch_by_url(url) 458 | 459 | async def fetch_document_by_url_async(self, url: str) -> 'Document': 460 | """ 461 | Internal use. Async version. 462 | 463 | Fetch Document from server by url. 464 | """ 465 | 466 | # TODO: should we try to guess type, id from url? 467 | return (self.documents_by_link.get(url) or 468 | await self._ext_fetch_by_url_async(url)) 469 | 470 | def _ext_fetch_by_url(self, url: str) -> 'Document': 471 | json_data = self._fetch_json(url) 472 | return self.read(json_data, url) 473 | 474 | async def _ext_fetch_by_url_async(self, url: str) -> 'Document': 475 | json_data = await self._fetch_json_async(url) 476 | return self.read(json_data, url) 477 | 478 | def _fetch_json(self, url: str) -> dict: 479 | """ 480 | Internal use. 481 | 482 | Fetch document raw json from server using requests library. 483 | """ 484 | self.assert_sync() 485 | import requests 486 | parsed_url = urlparse(url) 487 | logger.info('Fetching document from url %s', parsed_url) 488 | response = requests.get(parsed_url.geturl(), **self._request_kwargs) 489 | response_content = response.json() 490 | if response.status_code == HttpStatus.OK_200: 491 | return response_content 492 | else: 493 | 494 | raise DocumentError(f'Error {response.status_code}: ' 495 | f'{error_from_response(response_content)}', 496 | errors={'status_code': response.status_code}, 497 | response=response) 498 | 499 | async def _fetch_json_async(self, url: str) -> dict: 500 | """ 501 | Internal use. Async version. 502 | 503 | Fetch document raw json from server using aiohttp library. 504 | """ 505 | self.assert_async() 506 | parsed_url = urlparse(url) 507 | logger.info('Fetching document from url %s', parsed_url) 508 | async with self._aiohttp_session.get(parsed_url.geturl(), 509 | **self._request_kwargs) as response: 510 | response_content = await response.json(content_type='application/vnd.api+json') 511 | if response.status == HttpStatus.OK_200: 512 | return response_content 513 | else: 514 | raise DocumentError(f'Error {response.status}: ' 515 | f'{error_from_response(response_content)}', 516 | errors={'status_code': response.status}, 517 | response=response) 518 | 519 | def http_request(self, http_method: str, url: str, send_json: dict, 520 | expected_statuses: List[str]=None) -> Tuple[int, dict, str]: 521 | """ 522 | Internal use. 523 | 524 | Method to make PATCH/POST requests to server using requests library. 525 | """ 526 | self.assert_sync() 527 | import requests 528 | logger.debug('%s request: %s', http_method.upper(), send_json) 529 | expected_statuses = expected_statuses or HttpStatus.ALL_OK 530 | kwargs = {**self._request_kwargs} 531 | headers = {'Content-Type':'application/vnd.api+json'} 532 | headers.update(kwargs.pop('headers', {})) 533 | 534 | response = requests.request(http_method, url, json=send_json, 535 | headers=headers, 536 | **kwargs) 537 | 538 | response_json = response.json() 539 | if response.status_code not in expected_statuses: 540 | raise DocumentError(f'Could not {http_method.upper()} ' 541 | f'({response.status_code}): ' 542 | f'{error_from_response(response_json)}', 543 | errors={'status_code': response.status_code}, 544 | response=response, 545 | json_data=send_json) 546 | 547 | return response.status_code, response_json \ 548 | if response.content \ 549 | else {}, response.headers.get('Location') 550 | 551 | async def http_request_async( 552 | self, 553 | http_method: str, 554 | url: str, 555 | send_json: dict, 556 | expected_statuses: List[str]=None) \ 557 | -> Tuple[int, dict, str]: 558 | """ 559 | Internal use. Async version. 560 | 561 | Method to make PATCH/POST requests to server using aiohttp library. 562 | """ 563 | 564 | self.assert_async() 565 | logger.debug('%s request: %s', http_method.upper(), send_json) 566 | expected_statuses = expected_statuses or HttpStatus.ALL_OK 567 | content_type = '' if http_method == HttpMethod.DELETE else 'application/vnd.api+json' 568 | kwargs = {**self._request_kwargs} 569 | headers = {'Content-Type':'application/vnd.api+json'} 570 | headers.update(kwargs.pop('headers', {})) 571 | async with self._aiohttp_session.request( 572 | http_method, url, data=json.dumps(send_json), 573 | headers=headers, 574 | **kwargs) as response: 575 | 576 | response_json = await response.json(content_type=content_type) 577 | if response.status not in expected_statuses: 578 | raise DocumentError(f'Could not {http_method.upper()} ' 579 | f'({response.status}): ' 580 | f'{error_from_response(response_json)}', 581 | errors={'status_code': response.status}, 582 | response=response, 583 | json_data=send_json) 584 | 585 | return response.status, response_json or {}, response.headers.get('Location') 586 | 587 | @property 588 | def dirty_resources(self) -> 'Set[ResourceObject]': 589 | """ 590 | Set of all resources in Session cache that are marked as dirty, 591 | i.e. waiting for commit. 592 | """ 593 | return {i for i in self.resources_by_resource_identifier.values() if i.is_dirty} 594 | 595 | @property 596 | def is_dirty(self) -> bool: 597 | return bool(self.dirty_resources) 598 | 599 | def _commit_sync(self) -> None: 600 | self.assert_sync() 601 | logger.info('Committing dirty resources') 602 | for res in self.dirty_resources: 603 | res.commit() 604 | 605 | async def _commit_async(self) -> None: 606 | self.assert_async() 607 | logger.info('Committing dirty resources') 608 | for res in self.dirty_resources: 609 | await res._commit_async() 610 | 611 | def commit(self) -> Optional[Awaitable]: 612 | """ 613 | Commit (PATCH) all dirty resources to server. 614 | 615 | If session is used with enable_async=True, this needs to be awaited. 616 | """ 617 | if self.enable_async: 618 | return self._commit_async() 619 | else: 620 | return self._commit_sync() 621 | 622 | def assert_sync(self, msg=''): 623 | """ 624 | Internal method to assert that async is not enabled. 625 | """ 626 | msg = msg or 'Async requires manual fetching of resources' 627 | if self.enable_async: 628 | logger.error(msg) 629 | raise AsyncError(msg) 630 | 631 | def assert_async(self, msg=''): 632 | """ 633 | Internal method to assert that async is enabled. 634 | """ 635 | msg = msg or 'Calling this method is needed only when async is enabled' 636 | if not self.enable_async: 637 | logger.error(msg) 638 | raise AsyncError(msg) 639 | -------------------------------------------------------------------------------- /src/jsonapi_client/resourceobject.py: -------------------------------------------------------------------------------- 1 | """ 2 | JSON API Python client 3 | https://github.com/qvantel/jsonapi-client 4 | 5 | (see JSON API specification in http://jsonapi.org/) 6 | 7 | Copyright (c) 2017, Qvantel 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | * Redistributions in binary form must reproduce the above copyright 15 | notice, this list of conditions and the following disclaimer in the 16 | documentation and/or other materials provided with the distribution. 17 | * Neither the name of the Qvantel nor the 18 | names of its contributors may be used to endorse or promote products 19 | derived from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL QVANTEL BE LIABLE FOR ANY 25 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 28 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | """ 32 | 33 | import logging 34 | from itertools import chain 35 | from typing import Set, Optional, Awaitable, Union, Iterable, TYPE_CHECKING 36 | 37 | from .common import (jsonify_attribute_name, AbstractJsonObject, 38 | dejsonify_attribute_names, HttpMethod, HttpStatus, AttributeProxy, 39 | cached_property, RelationType) 40 | from .exceptions import ValidationError, DocumentInvalid 41 | 42 | NOT_FOUND = object() 43 | 44 | logger = logging.getLogger(__name__) 45 | 46 | if TYPE_CHECKING: 47 | from .session import Schema, Session 48 | 49 | 50 | class AttributeDict(dict): 51 | """ 52 | Container for JSON API attributes in ResourceObjects. 53 | In addition to standard dictionary this offers: 54 | - access to attributes via getattr (attribute names jsonified, i.e. 55 | my_attr -> my-attr) 56 | - dirty-flagging attributes upon change (keep track of changed attributes) 57 | and ability to generate diff structure containing only changed data 58 | (for PATCHing) 59 | - etc. 60 | """ 61 | def __init__(self, data: dict, 62 | resource: 'ResourceObject', 63 | name: str ='', 64 | parent: 'AttributeDict'=None) -> None: 65 | """ 66 | :param data: Input data (dictionary) that is stored here. 67 | :param resource: root ResourceObject 68 | :param name: name of this attribute, if this is contained within another 69 | AttributeDict. Otherwise None. 70 | :param parent: Parent AttributeDict if this is contained within another 71 | AttributeDict. Otherwise None 72 | """ 73 | super().__init__() 74 | self._parent = parent 75 | self._name = name 76 | self._resource = resource 77 | self._schema: 'Schema' = resource.session.schema 78 | self._full_name: str = name 79 | self._invalid = False 80 | self._dirty_attributes: Set[str] = set() 81 | 82 | if self._parent is not None and self._parent._full_name: 83 | self._full_name = f'{parent._full_name}.{name}' 84 | 85 | specification = self._schema.find_spec(self._resource.type, self._full_name) 86 | 87 | # Using .pop() below modifies the data, so we make a shallow copy of it first 88 | data = data.copy() 89 | # If there's schema for this object, we will use it to construct object. 90 | if specification: 91 | for field_name, field_spec in specification['properties'].items(): 92 | if field_spec.get('type') == 'object': 93 | _data = data.pop(field_name, {}) 94 | # Workaround a strange bug where _data is None instead of 95 | # default value {} 96 | if _data is None: 97 | _data = {} 98 | self[field_name] = AttributeDict(data=_data, 99 | name=field_name, 100 | parent=self, 101 | resource=resource) 102 | elif 'relation' in field_spec: 103 | pass # Special handling for relationships 104 | else: 105 | self[field_name] = data.pop(field_name, field_spec.get('default')) 106 | 107 | if data: 108 | logger.warning('There was extra data (not specified in schema): %s', 109 | data) 110 | # If not, we will use the source data as it is. 111 | if data: 112 | self.update(data) 113 | for key, value in data.items(): 114 | if isinstance(value, dict): 115 | self[key] = AttributeDict(data=value, name=key, parent=self, resource=resource) 116 | self._dirty_attributes.clear() 117 | 118 | def create_map(self, attr_name): 119 | """ 120 | Create a new map of values (i.e. child AttributeDict) within this AttributeDict 121 | 122 | :param attr_name: Name of this map object. 123 | """ 124 | self._check_invalid() 125 | name = jsonify_attribute_name(attr_name) 126 | self[name] = AttributeDict(data={}, name=name, parent=self, resource=self._resource) 127 | 128 | def _check_invalid(self): 129 | if self._invalid: 130 | raise DocumentInvalid('Resource has been invalidated.') 131 | 132 | def __getattr__(self, name): 133 | name = jsonify_attribute_name(name) 134 | if name not in self: 135 | raise AttributeError(f'No such attribute ' 136 | f'{self._resource.type}.{self._full_name}.{name}') 137 | return self[name] 138 | 139 | def __setitem__(self, key, value): 140 | if self.get(key) != value: 141 | self.mark_dirty(key) 142 | super().__setitem__(key, value) 143 | 144 | def __setattr__(self, name, value): 145 | if name.startswith('_'): 146 | return super().__setattr__(name, value) 147 | name = jsonify_attribute_name(name) 148 | self[name] = value 149 | 150 | def mark_dirty(self, name: str): 151 | """ 152 | Mark one attribute within this dictionary as dirty. 153 | 154 | :param name: Name of the attribute that is to be marked as dirty. 155 | """ 156 | self._dirty_attributes.add(name) 157 | if self._parent: 158 | self._parent.mark_dirty(self._name) 159 | 160 | def mark_clean(self): 161 | """ 162 | Mark all attributes recursively as clean.. 163 | """ 164 | for attr in self._dirty_attributes: 165 | value = self[attr] 166 | if isinstance(value, AttributeDict): 167 | value.mark_clean() 168 | self._dirty_attributes.clear() 169 | 170 | @property 171 | def diff(self) -> dict: 172 | """ 173 | Produce JSON containing only changed elements based on dirty fields. 174 | """ 175 | self._check_invalid() 176 | diff = {} 177 | for name in self._dirty_attributes: 178 | value = self[name] 179 | if isinstance(value, AttributeDict) and value.is_dirty: 180 | diff[name] = value.diff 181 | else: 182 | diff[name] = value 183 | return diff 184 | 185 | @property 186 | def post_data(self) -> dict: 187 | """ 188 | Produce JSON which does not contain values which are null. 189 | """ 190 | self._check_invalid() 191 | result = self.copy() 192 | for key, value in self.items(): 193 | if isinstance(value, AttributeDict): 194 | result[key] = new_value = value.post_data 195 | if len(new_value) == 0: 196 | del result[key] 197 | 198 | if value is None: 199 | del result[key] 200 | return result 201 | 202 | @property 203 | def is_dirty(self) -> bool: 204 | return bool(self._dirty_attributes) 205 | 206 | def mark_invalid(self): 207 | """ 208 | Recursively mark this and contained objects as invalid. 209 | """ 210 | self._invalid = True 211 | for value in self.values(): 212 | if isinstance(value, AttributeDict): 213 | value.mark_invalid() 214 | 215 | def change_resource(self, new_resource: 'ResourceObject') -> None: 216 | """ 217 | Change parent ResourceObject recursively 218 | :param new_resource: new resource that is used as a new root ResourceObject 219 | """ 220 | self._resource = new_resource 221 | for value in self.values(): 222 | if isinstance(value, AttributeDict): 223 | value.change_resource(new_resource) 224 | 225 | def keys_python(self) -> Iterable[str]: 226 | """ 227 | Pythonized version of contained keys (attribute names). 228 | """ 229 | yield from dejsonify_attribute_names(self.keys()) 230 | 231 | 232 | class RelationshipDict(dict): 233 | """ 234 | Container for relationships that is stored in ResourceObject 235 | """ 236 | 237 | def __init__(self, data: dict, resource: 'ResourceObject'): 238 | """ 239 | :param data: Raw input data where Relationship objects are built from. 240 | :param resource: Parent ResourceObject 241 | """ 242 | super().__init__() 243 | self._invalid = False 244 | self._resource = resource 245 | self.session = resource.session 246 | self._schema = schema = resource.session.schema 247 | model_schema = schema.schema_for_model(resource.type) 248 | if model_schema: 249 | for rel_name, rel_value in model_schema['properties'].items(): 250 | rel_type = rel_value.get('relation') 251 | if not rel_type: 252 | continue 253 | 254 | resource_types = rel_value['resource'] 255 | self[rel_name] = self._make_relationship(data.pop(rel_name, {}), rel_type, 256 | resource_types) 257 | else: 258 | relationships = {key: self._make_relationship(value) 259 | for key, value in data.items()} 260 | self.update(relationships) 261 | 262 | def mark_invalid(self): 263 | """ 264 | Mark invalid this dictionary and contained Relationships. 265 | """ 266 | self._invalid = True 267 | for value in self.values(): 268 | value.mark_invalid() 269 | 270 | def change_resource(self, new_resource: 'ResourceObject') -> None: 271 | """ 272 | :param new_resource: Change parent ResourceObject to new_resource. 273 | """ 274 | self._resource = new_resource 275 | 276 | def _determine_class(self, data: dict, relation_type: str=None): 277 | """ 278 | From data and/or provided relation_type, determine Relationship class 279 | to be used. 280 | 281 | :param data: Source data dictionary 282 | :param relation_type: either 'to-one' or 'to-many' 283 | """ 284 | from . import relationships as rel 285 | if 'data' in data: 286 | relationship_data = data['data'] 287 | if isinstance(relationship_data, list): 288 | if not (not relation_type or relation_type == RelationType.TO_MANY): 289 | logger.error('Conflicting information about relationship') 290 | return rel.MultiRelationship 291 | elif relationship_data is None or isinstance(relationship_data, dict): 292 | if not(not relation_type or relation_type == RelationType.TO_ONE): 293 | logger.error('Conflicting information about relationship') 294 | return rel.SingleRelationship 295 | else: 296 | raise ValidationError('Relationship data key is invalid') 297 | elif 'links' in data: 298 | return rel.LinkRelationship 299 | elif 'meta' in data: 300 | return rel.MetaRelationship 301 | elif relation_type == RelationType.TO_MANY: 302 | return rel.MultiRelationship 303 | elif relation_type == RelationType.TO_ONE: 304 | return rel.SingleRelationship 305 | else: 306 | raise ValidationError('Must have either links, data or meta in relationship') 307 | 308 | def _make_relationship(self, data, relation_type=None, resource_types=None): 309 | cls = self._determine_class(data, relation_type) 310 | return cls(self.session, data, resource_types=resource_types, 311 | relation_type=relation_type) 312 | 313 | def mark_clean(self): 314 | """ 315 | Mark all relationships as clean (not dirty). 316 | """ 317 | for attr in self.values(): 318 | attr.mark_clean() 319 | 320 | def keys_python(self) -> Iterable[str]: 321 | """ 322 | Pythonized version of contained keys (relationship names) 323 | """ 324 | yield from dejsonify_attribute_names(self.keys()) 325 | 326 | @property 327 | def is_dirty(self) -> bool: 328 | return any(r.is_dirty for r in self.values()) 329 | 330 | 331 | class ResourceObject(AbstractJsonObject): 332 | """ 333 | Basic JSON API resourceobject type. Field (attribute and relationship) access directly 334 | via instance attributes (__getattr__). In case of namespace collisions, there is also 335 | .fields attribute proxy. 336 | 337 | http://jsonapi.org/format/#document-resource-objects 338 | """ 339 | 340 | #: Attributes (that are not starting with _) that we want to ignore in __setattr__ 341 | __attributes = ['id', 'type', 'links', 'meta', 'commit_meta'] 342 | 343 | def __init__(self, session: 'Session', data: Union[dict, list]) -> None: 344 | self._delete = False 345 | self._commit_metadata = {} 346 | super().__init__(session, data) 347 | 348 | @cached_property 349 | def fields(self): 350 | """ 351 | Proxy to all fields (both attributes and relationship target resources) 352 | """ 353 | class Proxy(AttributeProxy): 354 | def __getitem__(proxy, item): 355 | rv = self._attributes.get(item, NOT_FOUND) 356 | if rv is NOT_FOUND: 357 | return self.relationship_resource[item] 358 | else: 359 | return rv 360 | 361 | def __setitem__(proxy, item, value): 362 | if item in self._relationships: 363 | return self._relationships[item].set(value) 364 | else: 365 | self._attributes[item] = value 366 | 367 | def __dir__(proxy): 368 | return chain(super().__dir__(), self._attributes.keys_python(), 369 | self._relationships.keys_python()) 370 | 371 | return Proxy() 372 | 373 | @cached_property 374 | def attributes(self): 375 | """ 376 | Proxy to all attributes (not relationships) 377 | """ 378 | return AttributeProxy(self._attributes) 379 | 380 | @cached_property 381 | def relationships(self): 382 | """ 383 | Proxy to relationship objects 384 | """ 385 | class Proxy(AttributeProxy): 386 | def __setitem__(proxy, key, value): 387 | rel = self._relationships[key] 388 | rel.set(value) 389 | 390 | return Proxy(self._relationships) 391 | 392 | @cached_property 393 | def relationship_resource(self): 394 | """ 395 | If async enabled, proxy to relationship objects. 396 | If async disabled, proxy to resources behind relationships. 397 | """ 398 | class Proxy(AttributeProxy): 399 | def __getitem__(proxy, item): 400 | rel = self.relationships[item] 401 | if self.session.enable_async: 402 | # With async it's more convenient to access Relationship object 403 | return self.relationships[item] 404 | 405 | if rel.is_single: 406 | return rel.resource 407 | else: 408 | return rel.resources 409 | 410 | return Proxy() 411 | 412 | def _handle_data(self, data): 413 | from .objects import Links, Meta 414 | self.id = data['id'] 415 | self.type = data['type'] 416 | self.links = Links(self.session, data.get('links', {})) 417 | self.meta = Meta(self.session, data.get('meta', {})) 418 | 419 | self._relationships = RelationshipDict( 420 | data=data.get('relationships', {}), 421 | resource=self) 422 | self._attributes = AttributeDict(data=data.get('attributes', {}), resource=self) 423 | 424 | if self.id: 425 | self.validate() 426 | 427 | def create_map(self, name): 428 | """ 429 | Create a map of values (AttributeDict) with name in attribute container. 430 | """ 431 | return self._attributes.create_map(name) 432 | 433 | def __dir__(self): 434 | return chain(super().__dir__(), self._attributes.keys_python(), 435 | self._relationships.keys_python()) 436 | 437 | def __str__(self): 438 | return f'{self.type}: {self.id} ({id(self)})' 439 | 440 | @property 441 | def json(self) -> dict: 442 | """ 443 | Return full JSON API resource object as json-serializable dictionary. 444 | """ 445 | return self._commit_data(full=True)['data'] 446 | 447 | @property 448 | def is_dirty(self) -> bool: 449 | return (self.id is None 450 | or self._delete 451 | or self._attributes.is_dirty 452 | or self._relationships.is_dirty) 453 | 454 | def __getitem__(self, item): 455 | return self.fields[item] 456 | 457 | def __setitem__(self, item, value): 458 | self.fields[item] = value 459 | 460 | def __getattr__(self, attr_name): 461 | return getattr(self.fields, attr_name) 462 | 463 | def __setattr__(self, attr_name, value): 464 | if attr_name.startswith('_') or attr_name in self.__attributes: 465 | return super().__setattr__(attr_name, value) 466 | 467 | return setattr(self.fields, attr_name, value) 468 | 469 | @property 470 | def dirty_fields(self): 471 | return (self._attributes._dirty_attributes | 472 | {name for name, rel in self._relationships.items() if rel.is_dirty}) 473 | 474 | @property 475 | def url(self) -> str: 476 | url = str(self.links.self) 477 | return url or self.id and f'{self.session.url_prefix}/{self.type}/{self.id}' 478 | 479 | @property 480 | def post_url(self) -> str: 481 | return f'{self.session.url_prefix}/{self.type}' 482 | 483 | def validate(self): 484 | """ 485 | Validate our attributes against schema. 486 | """ 487 | # TODO: what about relationships? Shouldn't we somehow validate those too? 488 | self.session.schema.validate(self.type, self._attributes) 489 | 490 | def _commit_data(self, meta: dict = None, full: bool=False) -> dict: 491 | """ 492 | Give JSON data for PATCH/POST request, requested by commit 493 | """ 494 | meta = meta or self._commit_metadata 495 | 496 | res_json = {'type': self.type} 497 | if self.id: 498 | res_json['id'] = self.id 499 | 500 | if self._http_method == 'post' or full: 501 | # When creating new resources, we need to specify explicitly all 502 | # relationships, as SingleRelationships, or MultiRelationships. 503 | 504 | relationships = {key: {'data': value.as_json_resource_identifiers} 505 | for key, value in self._relationships.items() if bool(value)} 506 | res_json.update({ 507 | 'attributes': self._attributes.post_data, 508 | 'relationships': relationships, 509 | }) 510 | else: 511 | changed_relationships = {key: {'data': value.as_json_resource_identifiers} 512 | for key, value in self._relationships.items() 513 | if value.is_dirty} 514 | res_json.update({ 515 | 'attributes': self._attributes.diff, 516 | 'relationships': changed_relationships, 517 | }) 518 | if meta: 519 | res_json['meta'] = meta 520 | return {'data': res_json} 521 | 522 | @property 523 | def _http_method(self): 524 | return HttpMethod.PATCH if self.id else HttpMethod.POST 525 | 526 | def _pre_commit(self, custom_url): 527 | url = custom_url or (self.post_url if self._http_method == HttpMethod.POST else self.url) 528 | logger.info('Committing %s to %s', self, url) 529 | self.validate() 530 | return url 531 | 532 | def _post_commit(self, status, result, location): 533 | if status in HttpStatus.HAS_RESOURCES: 534 | self._update_resource(result, location) 535 | 536 | # If no resources are returned (which is the case when 202 (Accepted) 537 | # is received for PATCH, for example). 538 | self.mark_clean() 539 | 540 | if status == HttpStatus.ACCEPTED_202: 541 | return self.session.read(result, location, no_cache=True).resource 542 | 543 | async def _commit_async(self, url: str= '', meta=None) -> None: 544 | self.session.assert_async() 545 | if self._delete: 546 | return await self._perform_delete_async(url) 547 | 548 | url = self._pre_commit(url) 549 | status, result, location = await self.session.http_request_async( 550 | self._http_method, url, 551 | self._commit_data(meta)) 552 | return self._post_commit(status, result, location) 553 | 554 | def _commit_sync(self, url: str= '', meta: dict=None) -> 'None': 555 | self.session.assert_sync() 556 | if self._delete: 557 | return self._perform_delete(url) 558 | 559 | url = self._pre_commit(url) 560 | status, result, location = self.session.http_request(self._http_method, url, 561 | self._commit_data(meta)) 562 | return self._post_commit(status, result, location) 563 | 564 | def commit(self, custom_url: str = '', meta: dict = None) \ 565 | -> 'Union[None, ResourceObject, Awaitable[Optional[ResourceObject]]': 566 | """ 567 | Commit (PATCH/POST) this resource to server. 568 | 569 | :param custom_url: Use this url instead of automatically determined one. 570 | :param meta: Optional metadata that is passed to server in POST/PATCH request 571 | 572 | If in async mode, this needs to be awaited. 573 | """ 574 | if self.session.enable_async: 575 | return self._commit_async(custom_url, meta) 576 | else: 577 | return self._commit_sync(custom_url, meta) 578 | 579 | def _update_resource(self, 580 | resource_dict: 'Union[dict, ResourceObject]', 581 | location: str=None) -> None: 582 | if isinstance(resource_dict, dict): 583 | new_res = self.session.read(resource_dict, location, no_cache=True).resource 584 | else: 585 | new_res = resource_dict 586 | self.id = new_res.id 587 | self._attributes.mark_invalid() 588 | self._relationships.mark_invalid() 589 | 590 | self._attributes: AttributeDict = new_res._attributes 591 | self._attributes.change_resource(self) 592 | self._relationships: RelationshipDict = new_res._relationships 593 | self._relationships.change_resource(self) 594 | self.meta = new_res.meta 595 | self.links = new_res.links 596 | self.session.add_resources(self) 597 | 598 | def _refresh_sync(self): 599 | self.session.assert_sync() 600 | new_res = self.session.fetch_resource_by_resource_identifier(self, force=True) 601 | self._update_resource(new_res) 602 | 603 | async def _refresh_async(self): 604 | self.session.assert_async() 605 | new_res = await self.session.fetch_resource_by_resource_identifier_async( 606 | self, 607 | force=True) 608 | self._update_resource(new_res) 609 | 610 | def refresh(self): 611 | """ 612 | Manual way to refresh the data contained in this ResourceObject from server. 613 | 614 | If in async mode, this needs to be awaited. 615 | """ 616 | if self.session.enable_async: 617 | return self._refresh_async() 618 | else: 619 | return self._refresh_sync() 620 | 621 | def delete(self): 622 | """ 623 | Mark resource to be deleted. Resource will be deleted upon commit. 624 | """ 625 | self._delete = True 626 | 627 | def _perform_delete(self, url=''): 628 | url = url or self.url 629 | self.session.http_request(HttpMethod.DELETE, url, {}) 630 | self.session.remove_resource(self) 631 | 632 | async def _perform_delete_async(self, url=''): 633 | url = url or self.url 634 | await self.session.http_request_async(HttpMethod.DELETE, url, {}) 635 | self.session.remove_resource(self) 636 | 637 | def mark_clean(self): 638 | """ 639 | Mark this resource and attributes / relationships as clean (not dirty). 640 | """ 641 | self._attributes.mark_clean() 642 | self._relationships.mark_clean() 643 | 644 | def mark_invalid(self): 645 | """ 646 | Mark this resource and it's related objects as invalid. 647 | """ 648 | super().mark_invalid() 649 | self._attributes.mark_invalid() 650 | self._relationships.mark_invalid() 651 | self.meta.mark_invalid() 652 | self.links.mark_invalid() 653 | 654 | def as_resource_identifier_dict(self) -> dict: 655 | return {'id': self.id, 'type': self.type} 656 | 657 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | from urllib.parse import urlparse 3 | from yarl import URL 4 | 5 | from aiohttp import ClientResponse 6 | from aiohttp.helpers import TimerNoop 7 | import jsonschema 8 | import pytest 9 | from requests import Response 10 | import json 11 | import os 12 | from jsonschema import ValidationError 13 | from jsonapi_client import ResourceTuple 14 | import jsonapi_client.objects 15 | import jsonapi_client.relationships 16 | import jsonapi_client.resourceobject 17 | from jsonapi_client.exceptions import DocumentError, AsyncError 18 | from jsonapi_client.filter import Filter 19 | from jsonapi_client.session import Session 20 | from unittest import mock 21 | 22 | 23 | external_references = \ 24 | {'$schema': 'http://json-schema.org/draft-04/schema#', 25 | 'properties': {'reference-id': {'type': ['string', 'null']}, 26 | 'reference-type': {'type': ['string', 'null']}, 27 | 'target': {'relation': 'to-one', 28 | 'resource': ['individuals', 29 | 'products']}, 30 | 'valid-for': {'properties': {'end-datetime': {'format': 'date-time', 31 | 'type': ['string', 32 | 'null']}, 33 | 'start-datetime': { 34 | 'format': 'date-time', 35 | 'type': ['string', 'null']}}, 36 | 'required': ['start-datetime'], 37 | 'type': 'object'}, 38 | 'null-field': {'properties': {'useless-field': {'type': ['string', 39 | 'null']}}, 40 | 'type': 'object'}}, 41 | 'type': 'object'} 42 | 43 | leases = \ 44 | {'$schema': 'http://json-schema.org/draft-04/schema#', 45 | 'properties': {'lease-items': {'relation': 'to-many', 46 | 'resource': ['lease-items']}, 47 | 'user-account': {'relation': 'to-one', 48 | 'resource': ['user-accounts']}, 49 | 'lease-id': {'type': ['string', 'null']}, 50 | 'external-references': {'relation': 'to-many', 51 | 'resource': ['external-references']}, 52 | 'active-status': {'enum': ['pending', 'active', 'terminated']}, 53 | 'parent-lease': {'relation': 'to-one', 54 | 'resource': ['sales-leases']}, 55 | 'reference-number': {'type': ['string', 'null']}, 56 | 'related-parties': {'relation': 'to-many', 57 | 'resource': ['party-relationships']}, 58 | 'valid-for': {'properties': {'end-datetime': {'format': 'date-time', 59 | 'type': ['string', 60 | 'null']}, 61 | 'start-datetime': { 62 | 'format': 'date-time', 63 | 'type': ['string', 'null']}}, 64 | 'required': ['start-datetime'], 65 | 'type': 'object'}}, 66 | 'type': 'object'} 67 | 68 | user_accounts = \ 69 | {'$schema': 'http://json-schema.org/draft-04/schema#', 70 | 'properties': {'account-id': {'type': ['string', 'null']}, 71 | 'user-type': {'type': ['string', 'null']}, 72 | 'leases': {'relation': 'to-many', 'resource': ['leases']}, 73 | 'associated-partner-accounts': {'relation': 'to-many', 74 | 'resource': ['partner-accounts']}, 75 | 'partner-accounts': {'relation': 'to-many', 76 | 'resource': ['partner-accounts']}, 77 | 'external-references': {'relation': 'to-many', 78 | 'resource': ['external-references']}, 79 | 'active-status': { 80 | 'enum': ['pending', 'active', 'inactive', 'suspended']}, 81 | 'name': {'type': ['string', 'null']}, 82 | 'valid-for': {'properties': {'end-datetime': {'format': 'date-time', 83 | 'type': ['string', 84 | 'null']}, 85 | 'start-datetime': { 86 | 'format': 'date-time', 87 | 'type': ['string', 'null']}}, 88 | 'required': ['start-datetime'], 89 | 'type': 'object'}}, 90 | 'type': 'object'} 91 | 92 | 93 | # TODO: figure out why this is not correctly in resources-schema 94 | leases['properties']['valid-for']['properties']['meta'] = \ 95 | {'type': 'object', 'properties': {'type': {'type': 'string'}}} 96 | 97 | api_schema_simple = \ 98 | {'leases': leases} 99 | 100 | api_schema_all = \ 101 | {'leases': leases, 102 | 'external-references': external_references, 103 | 'user-accounts': user_accounts, 104 | } 105 | 106 | 107 | # jsonapi.org example 108 | 109 | articles = { 110 | 'properties': { 111 | 'title': {'type': 'string'}, 112 | 'author': {'relation': 'to-one', 'resource': ['people']}, 113 | 'comments': {'relation': 'to-many', 'resource': ['comments']}, 114 | 'comment-or-author': {'relation': 'to-one', 'resource': ['comments', 'people']}, 115 | 'comments-or-authors': {'relation': 'to-many', 'resource': ['comments', 'people']}, 116 | } 117 | } 118 | 119 | people = {'properties': { 120 | 'first-name': {'type': 'string'}, 121 | 'last-name': {'type': 'string'}, 122 | 'twitter': {'type': ['null', 'string']}, 123 | }} 124 | 125 | comments = {'properties': { 126 | 'body': {'type': 'string'}, 127 | 'author': {'relation': 'to-one', 'resource': ['people']} 128 | }} 129 | 130 | article_schema_all = \ 131 | { 132 | 'articles': articles, 133 | 'people': people, 134 | 'comments': comments 135 | } 136 | 137 | article_schema_simple = \ 138 | { 139 | 'articles': articles, 140 | } 141 | 142 | # Invitation is an examaple of a resource without any attributes 143 | invitations = {'properties': { 144 | 'host': {'relation': 'to-one', 'resource': ['people']}, 145 | 'guest': {'relation': 'to-one', 'resource': ['people']} 146 | } 147 | } 148 | 149 | invitation_schema = { 150 | 'invitations': invitations 151 | } 152 | 153 | @pytest.fixture(scope='function', params=[None, article_schema_simple, 154 | article_schema_all]) 155 | def article_schema(request): 156 | return request.param 157 | 158 | 159 | @pytest.fixture(scope='function', params=[None, api_schema_simple, api_schema_all]) 160 | def api_schema(request): 161 | return request.param 162 | 163 | 164 | def load(filename): 165 | filename = filename.replace('?', '__').replace('"', '__') 166 | fname = os.path.join(os.path.dirname(__file__), 'json', f'{filename}.json') 167 | try: 168 | with open(fname, 'r') as f: 169 | return json.load(f) 170 | except FileNotFoundError: 171 | raise DocumentError(f'File not found: {fname}', errors=dict(status_code=404)) 172 | 173 | 174 | 175 | #mock_fetch_cm = async_mock.patch('jsonapi_client.session.fetch_json', new_callable=MockedFetch) 176 | 177 | @pytest.fixture 178 | def mock_req(mocker): 179 | m1 = mocker.patch('jsonapi_client.session.Session.http_request') 180 | m1.return_value = (201, {}, 'location') 181 | return m1 182 | 183 | 184 | @pytest.fixture 185 | def mock_req_async(mocker): 186 | rv = (201, {}, 'location') 187 | 188 | class MockedReqAsync(Mock): 189 | async def __call__(self, *args): 190 | super().__call__(*args) 191 | return rv 192 | 193 | m2 = mocker.patch('jsonapi_client.session.Session.http_request_async', new_callable=MockedReqAsync) 194 | return m2 195 | 196 | 197 | @pytest.fixture 198 | def mocked_fetch(mocker): 199 | def mock_fetch(url): 200 | parsed_url = urlparse(url) 201 | file_path = parsed_url.path[1:] 202 | query = parsed_url.query 203 | return load(f'{file_path}?{query}' if query else file_path) 204 | 205 | class MockedFetch: 206 | def __call__(self, url): 207 | return mock_fetch(url) 208 | 209 | class MockedFetchAsync: 210 | async def __call__(self, url): 211 | return mock_fetch(url) 212 | 213 | m1 = mocker.patch('jsonapi_client.session.Session._fetch_json', new_callable=MockedFetch) 214 | m2 = mocker.patch('jsonapi_client.session.Session._fetch_json_async', new_callable=MockedFetchAsync) 215 | return 216 | 217 | 218 | @pytest.fixture 219 | def mock_update_resource(mocker): 220 | m = mocker.patch('jsonapi_client.resourceobject.ResourceObject._update_resource') 221 | return m 222 | 223 | 224 | @pytest.fixture 225 | def session(): 226 | return mock.Mock() 227 | 228 | 229 | def test_initialization(mocked_fetch, article_schema): 230 | s = Session('http://localhost:8080', schema=article_schema) 231 | article = s.get('articles') 232 | assert s.resources_by_link['http://example.com/articles/1'] is \ 233 | s.resources_by_resource_identifier[('articles', '1')] 234 | assert s.resources_by_link['http://example.com/comments/12'] is \ 235 | s.resources_by_resource_identifier[('comments', '12')] 236 | assert s.resources_by_link['http://example.com/comments/5'] is \ 237 | s.resources_by_resource_identifier[('comments', '5')] 238 | assert s.resources_by_link['http://example.com/people/9'] is \ 239 | s.resources_by_resource_identifier[('people', '9')] 240 | 241 | 242 | @pytest.mark.asyncio 243 | async def test_initialization_async(mocked_fetch, article_schema): 244 | s = Session('http://localhost:8080', enable_async=True, schema=article_schema) 245 | article = await s.get('articles') 246 | assert s.resources_by_link['http://example.com/articles/1'] is \ 247 | s.resources_by_resource_identifier[('articles', '1')] 248 | assert s.resources_by_link['http://example.com/comments/12'] is \ 249 | s.resources_by_resource_identifier[('comments', '12')] 250 | assert s.resources_by_link['http://example.com/comments/5'] is \ 251 | s.resources_by_resource_identifier[('comments', '5')] 252 | assert s.resources_by_link['http://example.com/people/9'] is \ 253 | s.resources_by_resource_identifier[('people', '9')] 254 | await s.close() 255 | 256 | 257 | def test_basic_attributes(mocked_fetch, article_schema): 258 | s = Session('http://localhost:8080', schema=article_schema) 259 | doc = s.get('articles') 260 | assert len(doc.resources) == 3 261 | article = doc.resources[0] 262 | assert article.id == "1" 263 | assert article.type == "articles" 264 | assert article.title.startswith('JSON API paints') 265 | 266 | assert doc.links.self.href == 'http://example.com/articles' 267 | attr_set = {'title', 'author', 'comments', 'nested1', 'comment_or_author', 'comments_or_authors'} 268 | 269 | my_attrs = {i for i in dir(article.fields) if not i.startswith('_')} 270 | 271 | assert my_attrs == attr_set 272 | 273 | 274 | def test_resourceobject_without_attributes(mocked_fetch): 275 | s = Session('http://localhost:8080', schema=invitation_schema) 276 | doc = s.get('invitations') 277 | assert len(doc.resources) == 1 278 | invitation = doc.resources[0] 279 | assert invitation.id == "1" 280 | assert invitation.type == "invitations" 281 | assert doc.links.self.href == 'http://example.com/invitations' 282 | attr_set = {'host', 'guest'} 283 | 284 | my_attrs = {i for i in dir(invitation.fields) if not i.startswith('_')} 285 | 286 | assert my_attrs == attr_set 287 | 288 | 289 | 290 | @pytest.mark.asyncio 291 | async def test_basic_attributes_async(mocked_fetch, article_schema): 292 | s = Session('http://localhost:8080', enable_async=True, schema=article_schema) 293 | doc = await s.get('articles') 294 | assert len(doc.resources) == 3 295 | article = doc.resources[0] 296 | assert article.id == "1" 297 | assert article.type == "articles" 298 | assert article.title.startswith('JSON API paints') 299 | assert article['title'].startswith('JSON API paints') 300 | 301 | assert doc.links.self.href == 'http://example.com/articles' 302 | 303 | attr_set = {'title', 'author', 'comments', 'nested1', 'comment_or_author', 'comments_or_authors'} 304 | 305 | my_attrs = {i for i in dir(article.fields) if not i.startswith('_')} 306 | 307 | assert my_attrs == attr_set 308 | await s.close() 309 | 310 | 311 | def test_relationships_single(mocked_fetch, article_schema): 312 | s = Session('http://localhost:8080', schema=article_schema) 313 | article, article2, article3 = s.get('articles').resources 314 | author = article.author 315 | assert {i for i in dir(author.fields) if not i.startswith('_')} \ 316 | == {'first_name', 'last_name', 'twitter'} 317 | assert author.type == 'people' 318 | assert author.id == '9' 319 | 320 | assert author.first_name == 'Dan' 321 | assert author['first-name'] == 'Dan' 322 | assert author.last_name == 'Gebhardt' 323 | assert article.relationships.author.links.self.href == "http://example.com/articles/1/relationships/author" 324 | 325 | author = article.author 326 | assert author.first_name == 'Dan' 327 | assert author.last_name == 'Gebhardt' 328 | assert author.links.self.href == "http://example.com/people/9" 329 | 330 | assert article.comment_or_author.id == '12' 331 | assert article.comment_or_author.type == 'comments' 332 | assert article.comment_or_author.body == 'I like XML better' 333 | 334 | assert article2.comment_or_author.id == '9' 335 | assert article2.comment_or_author.type == 'people' 336 | assert article2.comment_or_author.first_name == 'Dan' 337 | 338 | assert article3.author is None 339 | assert article3.comment_or_author is None 340 | 341 | 342 | @pytest.mark.asyncio 343 | async def test_relationships_iterator_async(mocked_fetch, article_schema): 344 | s = Session('http://localhost:8080', enable_async=True, schema=article_schema, use_relationship_iterator=True) 345 | doc = await s.get('articles') 346 | article, article2, article3 = doc.resources 347 | comments = article.comments 348 | assert isinstance(comments, jsonapi_client.relationships.MultiRelationship) 349 | assert len(comments._resource_identifiers) == 2 350 | 351 | 352 | @pytest.mark.asyncio 353 | async def test_relationships_single_async(mocked_fetch, article_schema): 354 | s = Session('http://localhost:8080', enable_async=True, schema=article_schema) 355 | doc = await s.get('articles') 356 | article, article2, article3 = doc.resources 357 | 358 | author = article.author 359 | assert isinstance(author, jsonapi_client.relationships.SingleRelationship) 360 | with pytest.raises(AsyncError): 361 | _ = author.resource 362 | 363 | await author.fetch() 364 | author_res = author.resource 365 | assert {i for i in dir(author_res.fields) if not i.startswith('_')} \ 366 | == {'first_name', 'last_name', 'twitter'} 367 | assert author_res.type == 'people' 368 | assert author_res.id == '9' 369 | 370 | assert author_res.first_name == 'Dan' 371 | assert author_res.last_name == 'Gebhardt' 372 | assert author.links.self.href == "http://example.com/articles/1/relationships/author" 373 | 374 | author = article.author.resource 375 | assert isinstance(author, jsonapi_client.resourceobject.ResourceObject) 376 | assert author.first_name == 'Dan' 377 | assert author.last_name == 'Gebhardt' 378 | assert author.links.self.href == "http://example.com/people/9" 379 | 380 | await article.comment_or_author.fetch() 381 | assert article.comment_or_author.resource.id == '12' 382 | assert article.comment_or_author.resource.type == 'comments' 383 | assert article.comment_or_author.resource.body == 'I like XML better' 384 | 385 | await article2.comment_or_author.fetch() 386 | assert article2.comment_or_author.resource.id == '9' 387 | assert article2.comment_or_author.resource.type == 'people' 388 | assert article2.comment_or_author.resource.first_name == 'Dan' 389 | 390 | await article3.author.fetch() 391 | await article3.comment_or_author.fetch() 392 | assert article3.author.resource is None 393 | assert article3.comment_or_author.resource is None 394 | await s.close() 395 | 396 | def test_relationships_multi(mocked_fetch, article_schema): 397 | s = Session('http://localhost:8080', schema=article_schema) 398 | article, article2, article3 = s.get('articles').resources 399 | comments = article.comments 400 | assert len(comments) == 2 401 | c1, c2 = comments 402 | assert c1 == comments[0] 403 | assert c2 == comments[1] 404 | 405 | assert isinstance(c1, jsonapi_client.resourceobject.ResourceObject) 406 | assert 'body' in dir(c1) 407 | assert c1.body == "First!" 408 | 409 | assert c2.body == 'I like XML better' 410 | assert c2.author.id == '9' 411 | assert c2.author.first_name == 'Dan' 412 | assert c2.author.last_name == 'Gebhardt' 413 | 414 | res1, res2 = article.comments_or_authors 415 | assert res1.id == '9' 416 | assert res1.type == 'people' 417 | assert res1.first_name == 'Dan' 418 | 419 | assert res2.id == '12' 420 | assert res2.type == 'comments' 421 | assert res2.body == 'I like XML better' 422 | 423 | 424 | @pytest.mark.asyncio 425 | async def test_relationships_multi_async(mocked_fetch, article_schema): 426 | s = Session('http://localhost:8080', enable_async=True, schema=article_schema) 427 | doc = await s.get('articles') 428 | article = doc.resource 429 | comments = article.comments 430 | assert isinstance(comments, jsonapi_client.relationships.MultiRelationship) 431 | assert len(comments._resource_identifiers) == 2 432 | 433 | c1, c2 = await comments.fetch() 434 | 435 | assert isinstance(c1, jsonapi_client.resourceobject.ResourceObject) 436 | assert 'body' in dir(c1) 437 | assert c1.body == "First!" 438 | 439 | assert isinstance(c1.author, jsonapi_client.relationships.SingleRelationship) 440 | 441 | assert c2.body == 'I like XML better' 442 | with pytest.raises(AsyncError): 443 | assert c2.author.resource.id == '9' 444 | await c2.author.fetch() 445 | author_res = c2.author.resource 446 | assert author_res.id == '9' 447 | assert author_res.first_name == 'Dan' 448 | assert author_res.last_name == 'Gebhardt' 449 | 450 | rel = article.comments_or_authors 451 | assert isinstance(rel, jsonapi_client.relationships.MultiRelationship) 452 | await rel.fetch() 453 | res1, res2 = rel.resources 454 | 455 | assert res1.id == '9' 456 | assert res1.type == 'people' 457 | assert res1.first_name == 'Dan' 458 | 459 | assert res2.id == '12' 460 | assert res2.type == 'comments' 461 | assert res2.body == 'I like XML better' 462 | 463 | await s.close() 464 | 465 | 466 | def test_fetch_external_resources(mocked_fetch, article_schema): 467 | s = Session('http://localhost:8080', schema=article_schema) 468 | article = s.get('articles').resource 469 | comments = article.comments 470 | session = article.session 471 | c1, c2 = comments 472 | assert c1.body == "First!" 473 | assert len(session.resources_by_resource_identifier) == 6 474 | assert len(session.resources_by_link) == 5 475 | assert len(session.documents_by_link) == 1 476 | assert c1.author.id == "2" 477 | assert len(session.resources_by_resource_identifier) == 7 478 | assert len(session.resources_by_link) == 6 479 | assert len(session.documents_by_link) == 2 480 | 481 | assert c1.author.type == "people" 482 | 483 | # fetch external content 484 | assert c1.author.first_name == 'Dan 2' 485 | assert c1.author.last_name == 'Gebhardt 2' 486 | 487 | 488 | @pytest.mark.asyncio 489 | async def test_fetch_external_resources_async(mocked_fetch, article_schema): 490 | s = Session('http://localhost:8080', enable_async=True, schema=article_schema) 491 | doc = await s.get('articles') 492 | article = doc.resource 493 | comments = article.comments 494 | assert isinstance(comments, jsonapi_client.relationships.MultiRelationship) 495 | session = article.session 496 | c1, c2 = await comments.fetch() 497 | assert c1.body == "First!" 498 | assert len(session.resources_by_resource_identifier) == 6 499 | assert len(session.resources_by_link) == 5 500 | assert len(session.documents_by_link) == 1 501 | 502 | with pytest.raises(AsyncError): 503 | _ = c1.author.resource.id 504 | await c1.author.fetch() 505 | # fetch external content 506 | c1_author = c1.author.resource 507 | assert c1_author.id == "2" 508 | assert len(session.resources_by_resource_identifier) == 7 509 | assert len(session.resources_by_link) == 6 510 | assert len(session.documents_by_link) == 2 511 | 512 | assert c1_author.type == "people" 513 | assert c1_author.first_name == 'Dan 2' 514 | assert c1_author.last_name == 'Gebhardt 2' 515 | await s.close() 516 | 517 | def test_error_404(mocked_fetch, api_schema): 518 | s = Session('http://localhost:8080/api', schema=api_schema) 519 | documents = s.get('leases') 520 | d1 = documents.resources[1] 521 | 522 | parent_lease = d1.relationships.parent_lease 523 | assert isinstance(parent_lease, jsonapi_client.relationships.LinkRelationship) 524 | with pytest.raises(DocumentError) as e: 525 | assert parent_lease.resource.active_status == 'active' 526 | assert e.value.errors['status_code'] == 404 527 | 528 | with pytest.raises(DocumentError) as e: 529 | s.get('error') 530 | 531 | assert 'Error document was fetched' in str(e.value) 532 | 533 | 534 | @pytest.mark.asyncio 535 | async def test_error_404_async(mocked_fetch, api_schema): 536 | s = Session('http://localhost:8080/api', enable_async=True, schema=api_schema) 537 | documents = await s.get('leases') 538 | d1 = documents.resources[1] 539 | 540 | parent_lease = d1.parent_lease 541 | assert isinstance(parent_lease, jsonapi_client.relationships.LinkRelationship) 542 | with pytest.raises(AsyncError): 543 | _ = parent_lease.resource.active_status 544 | 545 | with pytest.raises(DocumentError) as e: 546 | res = await parent_lease.fetch() 547 | 548 | assert e.value.errors['status_code'] == 404 549 | with pytest.raises(DocumentError) as e: 550 | await s.get('error') 551 | assert 'Error document was fetched' in str(e.value) 552 | await s.close() 553 | 554 | 555 | def test_relationships_with_context_manager(mocked_fetch, api_schema): 556 | with Session('http://localhost:8080/api', schema=api_schema) as s: 557 | documents = s.get('leases') 558 | d1 = documents.resources[0] 559 | 560 | assert d1.lease_id is None 561 | assert d1.id == 'qvantel-lease1' 562 | assert d1.type == 'leases' 563 | assert d1.active_status == d1.fields.active_status == 'active' 564 | assert d1.valid_for.start_datetime == "2015-07-06T12:23:26.000Z" 565 | assert d1['valid-for']['start-datetime'] == "2015-07-06T12:23:26.000Z" 566 | assert d1.valid_for.meta.type == 'valid-for-datetime' 567 | with pytest.raises(AttributeError): 568 | d1.valid_for.meta.with_underscore 569 | 570 | assert d1.valid_for.meta['with_underscore'] == 'underscore' 571 | assert d1.valid_for.meta.with_dash == 'dash' 572 | 573 | # == 'valid-for-datetime' 574 | dird = dir(d1) 575 | assert 'external_references' in dird 576 | 577 | # Relationship collection (using link rather than ResourceObject) 578 | # fetches http://localhost:8080/api/leases/qvantel-lease1/external-references 579 | assert len(d1.external_references) == 1 580 | 581 | ext_ref = d1.external_references[0] 582 | assert ext_ref.reference_id == ext_ref.fields.reference_id == '0123015150' 583 | assert ext_ref.id == 'qvantel-lease1-extref' 584 | assert ext_ref.type == 'external-references' 585 | 586 | ext_ref = d1.external_references[0] 587 | assert isinstance(ext_ref, jsonapi_client.resourceobject.ResourceObject) 588 | 589 | assert ext_ref.reference_id == '0123015150' 590 | assert ext_ref.id == 'qvantel-lease1-extref' 591 | assert ext_ref.type == 'external-references' 592 | 593 | assert 'user_account' in dird 594 | assert d1.user_account.id == 'qvantel-useraccount1' 595 | #assert isinstance(d1.user_account, 596 | assert d1.user_account.type == 'user-accounts' 597 | assert d1.links.self.href == '/api/leases/qvantel-lease1' 598 | 599 | # Single relationship (using link rather than ResourceObject) 600 | # Fetches http://localhost:8080/api/leases/qvantel-lease1/parent-lease 601 | parent_lease = d1.parent_lease 602 | #assert isinstance(parent_lease, jsonapi_client.relationships.LinkRelationship) 603 | # ^ Anything is not fetched yet 604 | if api_schema: 605 | assert parent_lease.active_status == 'active' 606 | else: 607 | assert parent_lease[0].active_status == 'active' 608 | # ^ now parent lease is fetched, but attribute access goes through Relationship 609 | assert not s.resources_by_link 610 | assert not s.resources_by_resource_identifier 611 | assert not s.documents_by_link 612 | 613 | 614 | @pytest.mark.asyncio 615 | async def test_relationships_with_context_manager_async_async(mocked_fetch, api_schema): 616 | async with Session('http://localhost:8080/api', schema=api_schema, enable_async=True) as s: 617 | documents = await s.get('leases') 618 | d1 = documents.resources[0] 619 | 620 | assert d1.lease_id is None 621 | assert d1.id == 'qvantel-lease1' 622 | assert d1.type == 'leases' 623 | assert d1.active_status == d1.fields.active_status == 'active' 624 | assert d1.valid_for.start_datetime == "2015-07-06T12:23:26.000Z" 625 | assert d1['valid-for']['start-datetime'] == "2015-07-06T12:23:26.000Z" 626 | assert d1.valid_for.meta.type == 'valid-for-datetime' 627 | dird = dir(d1) 628 | assert 'external_references' in dird 629 | 630 | ext_refs = d1.external_references 631 | ext_ref_res = (await ext_refs.fetch())[0] 632 | 633 | assert ext_ref_res.reference_id == ext_ref_res.fields.reference_id == '0123015150' 634 | assert ext_ref_res.id == 'qvantel-lease1-extref' 635 | assert ext_ref_res.type == 'external-references' 636 | 637 | assert isinstance(ext_ref_res, jsonapi_client.resourceobject.ResourceObject) 638 | 639 | assert ext_ref_res.reference_id == '0123015150' 640 | assert ext_ref_res.id == 'qvantel-lease1-extref' 641 | assert ext_ref_res.type == 'external-references' 642 | 643 | assert 'user_account' in dird 644 | await d1.user_account.fetch() 645 | assert d1.user_account.resource.id == 'qvantel-useraccount1' 646 | assert d1.user_account.resource.type == 'user-accounts' 647 | assert d1.links.self.href == '/api/leases/qvantel-lease1' 648 | 649 | await d1.parent_lease.fetch() 650 | parent_lease = d1.parent_lease.resource 651 | assert parent_lease.active_status == 'active' 652 | 653 | assert not s.resources_by_link 654 | assert not s.resources_by_resource_identifier 655 | assert not s.documents_by_link 656 | 657 | 658 | def test_more_relationships(mocked_fetch, api_schema): 659 | s = Session('http://localhost:8080/api', schema=api_schema) 660 | documents = s.get('leases') 661 | d1 = documents.resources[0] 662 | 663 | assert d1.lease_id is None 664 | assert d1.id == 'qvantel-lease1' 665 | assert d1.type == 'leases' 666 | assert d1.active_status == d1.fields.active_status == 'active' 667 | assert d1.valid_for.start_datetime == "2015-07-06T12:23:26.000Z" 668 | assert d1.valid_for.meta.type == 'valid-for-datetime' 669 | dird = dir(d1) 670 | assert 'external_references' in dird 671 | 672 | # Relationship collection (using link rather than ResourceObject) 673 | # fetches http://localhost:8080/api/leases/qvantel-lease1/external-references 674 | assert len(d1.external_references) == 1 675 | 676 | ext_ref = d1.external_references[0] 677 | assert ext_ref.reference_id == ext_ref.fields.reference_id == '0123015150' 678 | assert ext_ref.id == 'qvantel-lease1-extref' 679 | assert ext_ref.type == 'external-references' 680 | 681 | ext_ref = d1.external_references[0] 682 | assert isinstance(ext_ref, jsonapi_client.resourceobject.ResourceObject) 683 | 684 | assert ext_ref.reference_id == '0123015150' 685 | assert ext_ref.id == 'qvantel-lease1-extref' 686 | assert ext_ref.type == 'external-references' 687 | 688 | assert 'user_account' in dird 689 | assert d1.user_account.id == 'qvantel-useraccount1' 690 | assert d1.user_account.type == 'user-accounts' 691 | assert d1.links.self.href == '/api/leases/qvantel-lease1' 692 | 693 | # Single relationship (using link rather than ResourceObject) 694 | # Fetches http://localhost:8080/api/leases/qvantel-lease1/parent-lease 695 | parent_lease = d1.parent_lease 696 | # ^ Anything is not fetched yet 697 | if api_schema: 698 | assert parent_lease.active_status == 'active' 699 | else: 700 | assert parent_lease[0].active_status == 'active' 701 | # ^ now parent lease is fetched, but attribute access goes through Relationship 702 | 703 | 704 | @pytest.mark.asyncio 705 | async def test_more_relationships_async_fetch(mocked_fetch, api_schema): 706 | s = Session('http://localhost:8080/api', enable_async=True, schema=api_schema) 707 | documents = await s.get('leases') 708 | d1 = documents.resources[0] 709 | dird = dir(d1) 710 | 711 | assert 'external_references' in dird 712 | 713 | # Relationship collection (using link rather than ResourceObject) 714 | # fetches http://localhost:8080/api/leases/qvantel-lease1/external-references 715 | 716 | ext_ref = d1.external_references 717 | assert isinstance(ext_ref, jsonapi_client.relationships.LinkRelationship) 718 | 719 | with pytest.raises(AsyncError): 720 | len(ext_ref.resources) == 1 721 | 722 | with pytest.raises(AsyncError): 723 | _ = ext_ref.resources.reference_id 724 | 725 | ext_ref_res = (await ext_ref.fetch())[0] 726 | 727 | assert ext_ref_res.reference_id == '0123015150' 728 | 729 | assert ext_ref_res.id == 'qvantel-lease1-extref' 730 | assert ext_ref_res.type == 'external-references' 731 | 732 | ext_ref = d1.external_references.resources[0] 733 | assert isinstance(ext_ref, jsonapi_client.resourceobject.ResourceObject) 734 | 735 | assert ext_ref.reference_id == '0123015150' 736 | assert ext_ref.id == 'qvantel-lease1-extref' 737 | assert ext_ref.type == 'external-references' 738 | 739 | assert 'user_account' in dird 740 | await d1.user_account.fetch() 741 | assert d1.user_account.resource.id == 'qvantel-useraccount1' 742 | assert d1.user_account.resource.type == 'user-accounts' 743 | assert d1.links.self.href == '/api/leases/qvantel-lease1' 744 | 745 | # Single relationship (using link rather than ResourceObject) 746 | # Fetches http://localhost:8080/api/leases/qvantel-lease1/parent-lease 747 | parent_lease = d1.parent_lease 748 | assert isinstance(parent_lease, jsonapi_client.relationships.LinkRelationship) 749 | # ^ Anything is not fetched yet 750 | await parent_lease.fetch() 751 | assert parent_lease.resource.active_status == 'active' 752 | # ^ now parent lease is fetched, but attribute access goes through Relationship 753 | await s.close() 754 | 755 | 756 | class SuccessfullResponse: 757 | status_code = 200 758 | headers = {} 759 | content = '' 760 | @classmethod 761 | def json(cls): 762 | return {} 763 | 764 | 765 | def test_patching(mocker, mocked_fetch, api_schema, mock_update_resource): 766 | mock_patch = mocker.patch('requests.request') 767 | mock_patch.return_value = SuccessfullResponse 768 | 769 | s = Session('http://localhost:80801/api', schema=api_schema) 770 | documents = s.get('leases').resources 771 | 772 | # if single document (not collection) we must also be able to 773 | # set attributes of main resourceobject directly 774 | # TODO test this^ 775 | 776 | assert len(documents) == 4 777 | with pytest.raises(AttributeError): 778 | documents.someattribute = 'something' 779 | 780 | d1 = documents[0] 781 | 782 | # Let's change fields in resourceobject 783 | assert d1.active_status == 'active' 784 | d1.active_status = 'terminated' 785 | assert d1.is_dirty 786 | assert s.is_dirty 787 | assert 'active-status' in d1.dirty_fields 788 | d1.commit() # alternatively s.commit() which does commit for all dirty objects 789 | assert len(d1.dirty_fields) == 0 790 | assert not d1.is_dirty 791 | assert not s.is_dirty 792 | 793 | assert d1.valid_for.start_datetime == "2015-07-06T12:23:26.000Z" 794 | 795 | d1.valid_for.start_datetime = 'something-else' 796 | d1.valid_for.new_field = 'something-new' 797 | assert d1.valid_for.is_dirty 798 | assert 'start-datetime' in d1.valid_for._dirty_attributes 799 | assert d1.is_dirty 800 | assert 'valid-for' in d1.dirty_fields 801 | 802 | assert d1._attributes.diff == {'valid-for': {'start-datetime': 'something-else', 803 | 'new-field': 'something-new'}} 804 | 805 | assert d1.external_references[0].id == 'qvantel-lease1-extref' 806 | 807 | assert len(d1.external_references) == 1 808 | 809 | add_resources = [ResourceTuple(str(i), 'external-references') for i in [1,2]] 810 | d1.external_references += add_resources 811 | 812 | assert len(d1.relationships.external_references.document.resources) == 1 # Document itself should not change 813 | assert len(d1.external_references) == 3 814 | 815 | d1.external_references += [ResourceTuple('3', 'external-references')] 816 | assert len(d1.relationships.external_references.document.resources) == 1 # Document itself should not change 817 | assert len(d1.external_references) == 4 818 | assert d1.relationships.external_references.is_dirty 819 | assert len(mock_patch.mock_calls) == 1 820 | d1.commit() 821 | assert len(mock_patch.mock_calls) == 2 822 | actual_data = mock_patch.mock_calls[1][2]['json']['data'] 823 | expected_data = { 824 | 'id': 'qvantel-lease1', 825 | 'type': 'leases', 826 | 'attributes': { 827 | 'valid-for': {'new-field': 'something-new', 828 | 'start-datetime': 'something-else'}}, 829 | 'relationships': { 830 | 'external-references': { 831 | 'data': [ 832 | {'id': 'qvantel-lease1-extref', 833 | 'type': 'external-references'}, 834 | {'id': '1', 835 | 'type': 'external-references'}, 836 | {'id': '2', 837 | 'type': 'external-references'}, 838 | {'id': '3', 839 | 'type': 'external-references'} 840 | ]}}} 841 | assert actual_data == expected_data 842 | assert not d1.is_dirty 843 | assert not d1.valid_for.is_dirty 844 | assert not d1.relationships.external_references.is_dirty 845 | # After commit we receive new data from the server, and everything should be as expected again 846 | 847 | 848 | def test_result_pagination(mocked_fetch, api_schema): 849 | s = Session('http://localhost:8080/', schema=api_schema) 850 | 851 | agr_pages = [] 852 | doc = s.get('test_leases') 853 | agr1 = doc.resources[0] 854 | 855 | agr_pages.append(agr1) 856 | 857 | # Pagination of collection 858 | assert len(doc.resources) == 2 # length of received collection 859 | 860 | agr_next = doc.links.next.fetch() 861 | while agr_next: 862 | agr_pages.append(agr_next) 863 | assert len(agr_next.resources) == 2 864 | agr_prev = agr_next 865 | agr_cur = agr_next 866 | agr_next = agr_next.links.next.fetch() 867 | if agr_next: 868 | assert agr_next.links.prev == agr_prev.links.self 869 | 870 | assert agr_cur.links.self == doc.links.last 871 | assert agr_cur.links.first == doc.links.self == doc.links.first 872 | 873 | d1 = doc.resources[0] 874 | ext_refs = d1.external_references 875 | 876 | assert len(ext_refs) == 2 877 | 878 | ext_refs2 = d1.relationships.external_references.document.links.next.fetch() 879 | assert len(ext_refs2.resources) == 2 880 | assert d1.relationships.external_references.document.links.last == ext_refs2.links.self 881 | 882 | 883 | def test_result_pagination_iteration(mocked_fetch, api_schema): 884 | s = Session('http://localhost:8080/', schema=api_schema) 885 | 886 | leases = list(s.iterate('test_leases')) 887 | assert len(leases) == 6 888 | for l in range(len(leases)): 889 | assert leases[l].id == str(l+1) 890 | 891 | 892 | @pytest.mark.asyncio 893 | async def test_result_pagination_iteration_async(mocked_fetch, api_schema): 894 | s = Session('http://localhost:8080/', schema=api_schema, enable_async=True) 895 | 896 | leases = [r async for r in s.iterate('test_leases')] 897 | assert len(leases) == 6 898 | for l in range(len(leases)): 899 | assert leases[l].id == str(l+1) 900 | await s.close() 901 | 902 | 903 | def test_result_filtering(mocked_fetch, api_schema): 904 | s = Session('http://localhost:8080/', schema=api_schema) 905 | 906 | result = s.get('test_leases', Filter(title='Dippadai')) 907 | result2 = s.get('test_leases', Filter(f'filter[title]=Dippadai')) 908 | 909 | assert result == result2 910 | 911 | d1 = result.resources[0] 912 | ext_refs = d1.relationships.external_references 913 | 914 | result = ext_refs.filter(Filter(title='Hep')) 915 | 916 | assert len(result.resources) == 1 917 | 918 | 919 | article_test_schema = \ 920 | { 921 | 'articles': { 922 | 'properties': { 923 | 'title': {'type': 'string'}, 924 | 'extra-attribute': {'type': ['string', 'null']}, 925 | 'nested1': {'type': 'object', 'properties': 926 | { 927 | 'other': {'type': ['string', 'null']}, 928 | 'nested': 929 | {'type': 'object', 'properties': 930 | {'name': {'type': 'string'}, 931 | 'other': {'type': ['string', 'null']}, 932 | }}}}, 933 | 'nested2': {'type': 'object', 'properties': 934 | { 935 | 'other': {'type': ['null', 'string']}, 936 | 'nested': 937 | {'type': 'object', 'properties': 938 | {'name': {'type': ['null', 'string'], 939 | 'other': {'type': ['string', 'null']}, 940 | }}}}} 941 | }, 942 | } 943 | } 944 | 945 | 946 | def test_attribute_checking_from_schema(mocked_fetch): 947 | 948 | s = Session('http://localhost:8080/', schema=article_test_schema) 949 | article = s.get('articles').resource 950 | assert article.title.startswith('JSON API paints') 951 | 952 | # Extra attribute that is in schema but not in data 953 | assert article.extra_attribute is None 954 | with pytest.raises(AttributeError): 955 | attr = article.extra_attribute_2 956 | 957 | # nested1 is in the test data 958 | with pytest.raises(AttributeError): 959 | attr = article.nested1.nested.a 960 | with pytest.raises(AttributeError): 961 | attr = article.nested1.a 962 | with pytest.raises(AttributeError): 963 | attr = article.a 964 | assert article.nested1.nested.name == 'test' 965 | assert article.nested1.nested.other is None 966 | assert article.nested1.other is None 967 | 968 | # nested2 is not in the test data 969 | with pytest.raises(AttributeError): 970 | attr = article.nested2.nested.a 971 | with pytest.raises(AttributeError): 972 | attr = article.nested2.a 973 | 974 | assert len(article.nested2) == 2 # There are still the items that were specified in schema 975 | 976 | assert article.nested2.nested.name is None 977 | assert len(article.nested2.nested) == 1 978 | 979 | 980 | def test_schema_validation(mocked_fetch): 981 | schema2 = article_test_schema.copy() 982 | schema2['articles']['properties']['title']['type'] = 'number' 983 | s = Session('http://localhost:8080/', schema=schema2) 984 | 985 | with pytest.raises(ValidationError) as e: 986 | article = s.get('articles') 987 | #article.title.startswith('JSON API paints') 988 | assert 'is not of type \'number\'' in str(e.value) 989 | 990 | 991 | def make_patch_json(ids, type_, field_name=None): 992 | if isinstance(ids, list): 993 | if isinstance(ids[0], tuple): 994 | content = {'data': [{'id': str(i), 'type': str(j)} for i, j in ids]} 995 | else: 996 | content = {'data': [{'id': str(i), 'type': type_} for i in ids]} 997 | elif ids is None: 998 | content = {'data': None} 999 | else: 1000 | content = {'data': {'id': str(ids), 'type': type_}} 1001 | 1002 | data = {'data': {'type': 'articles', 1003 | 'id': '1', 1004 | 'attributes': {}, 1005 | 'relationships': 1006 | { 1007 | field_name or type_: content 1008 | }}} 1009 | return data 1010 | 1011 | def test_posting_successfull(mock_req): 1012 | s = Session('http://localhost:80801/api', schema=api_schema_all) 1013 | a = s.create('leases') 1014 | assert a.is_dirty 1015 | a.lease_id = '1' 1016 | a.active_status = 'pending' 1017 | a.reference_number = 'test' 1018 | a.valid_for.start_datetime = 'asdf' 1019 | 1020 | with mock.patch('jsonapi_client.session.Session.read'): 1021 | a.commit() 1022 | 1023 | agr_data = \ 1024 | {'data': {'type': 'leases', 1025 | 'attributes': {'lease-id': '1', 1026 | 'active-status': 'pending', 1027 | 'reference-number': 'test', 1028 | 'valid-for': {'start-datetime': 'asdf'}, 1029 | }, 1030 | 'relationships': {}}} 1031 | 1032 | mock_req.assert_called_once_with('post', 'http://localhost:80801/api/leases', 1033 | agr_data) 1034 | 1035 | 1036 | @pytest.mark.asyncio 1037 | async def test_posting_successfull_async(mock_req_async, mock_update_resource): 1038 | s = Session('http://localhost:80801/api', schema=api_schema_all, enable_async=True) 1039 | a = s.create('leases') 1040 | assert a.is_dirty 1041 | a.lease_id = '1' 1042 | a.active_status = 'pending' 1043 | a.reference_number = 'test' 1044 | a.valid_for.start_datetime = 'asdf' 1045 | await a.commit() 1046 | 1047 | agr_data = \ 1048 | {'data': {'type': 'leases', 1049 | 'attributes': {'lease-id': '1', 'active-status': 'pending', 1050 | 'reference-number': 'test', 1051 | 'valid-for': {'start-datetime': 'asdf'}, 1052 | }, 1053 | 'relationships': {}}} 1054 | 1055 | 1056 | mock_req_async.assert_called_once_with('post', 'http://localhost:80801/api/leases', 1057 | agr_data) 1058 | await s.close() 1059 | 1060 | @pytest.mark.parametrize('commit', [0, 1]) 1061 | @pytest.mark.parametrize('kw_format', [0, 1]) 1062 | def test_posting_successfull_with_predefined_fields(kw_format, commit, mock_req, mocker): 1063 | mocker.patch('jsonapi_client.session.Session.read') 1064 | s = Session('http://localhost:80801/api', schema=api_schema_all) 1065 | 1066 | kwargs1 = dict(valid_for__start_datetime='asdf') 1067 | kwargs2 = dict(valid_for={'start-datetime':'asdf'}) 1068 | 1069 | a = s.create('leases', 1070 | lease_id='1', 1071 | active_status='pending', 1072 | reference_number='test', 1073 | lease_items=['1'], 1074 | **kwargs1 if kw_format else kwargs2 1075 | ) 1076 | if commit: 1077 | a.commit() 1078 | assert a.is_dirty != commit 1079 | 1080 | if not commit: 1081 | with mock.patch('jsonapi_client.session.Session.read'): 1082 | a.commit() 1083 | 1084 | agr_data = \ 1085 | {'data': {'type': 'leases', 1086 | 'attributes': {'lease-id': '1', 'active-status': 'pending', 1087 | 'reference-number': 'test', 1088 | 'valid-for': {'start-datetime': 'asdf'}, 1089 | }, 1090 | 'relationships': {'lease-items': {'data': [{'id': '1', 1091 | 'type': 'lease-items'}]}, 1092 | }}} 1093 | 1094 | mock_req.assert_called_once_with('post', 'http://localhost:80801/api/leases', 1095 | agr_data) 1096 | 1097 | 1098 | def test_create_with_default(mock_req): 1099 | test_schema = \ 1100 | { 1101 | 'articles': { 1102 | 'properties': { 1103 | 'testfield1': {'type': 'string', 'default': 'default'}, 1104 | 'testfield2': {'type': 'string', 'default': 'default'}, 1105 | } 1106 | } 1107 | } 1108 | 1109 | s = Session('http://localhost:8080/', schema=test_schema) 1110 | a = s.create('articles', fields={'testfield1': 'test', 'testfield2': 'test'}) 1111 | assert a.testfield1 == 'test' 1112 | assert a.testfield2 == 'test' 1113 | 1114 | with mock.patch('jsonapi_client.session.Session.read'): 1115 | a.commit() 1116 | 1117 | a2 = s.create('articles', fields={'testfield1': 'test'}) 1118 | assert a2.testfield1 == 'test' 1119 | assert a2.testfield2 == 'default' 1120 | 1121 | with mock.patch('jsonapi_client.session.Session.read'): 1122 | a2.commit() 1123 | 1124 | underscore_schema = \ 1125 | { 1126 | 'articles': { 1127 | 'properties': { 1128 | 'with_underscore': {'type': 'string'}, 1129 | 'with-dash': {'type': 'string'}, 1130 | } 1131 | } 1132 | } 1133 | 1134 | 1135 | def test_create_with_underscore(mock_req): 1136 | s = Session('http://localhost:8080/', schema=underscore_schema) 1137 | a = s.create('articles', 1138 | fields={'with-dash': 'test', 'with_underscore': 'test2'} 1139 | ) 1140 | assert 'with_underscore' in a._attributes 1141 | assert 'with-dash' in a._attributes 1142 | 1143 | with mock.patch('jsonapi_client.session.Session.read'): 1144 | a.commit() 1145 | 1146 | 1147 | def test_create_with_underscore2(mock_req): 1148 | s = Session('http://localhost:8080/', schema=underscore_schema) 1149 | a = s.create('articles', with_dash='test', 1150 | fields={'with_underscore': 'test2'} 1151 | ) 1152 | assert 'with_underscore' in a._attributes 1153 | assert 'with-dash' in a._attributes 1154 | 1155 | with mock.patch('jsonapi_client.session.Session.read'): 1156 | a.commit() 1157 | 1158 | 1159 | def test_posting_relationships(mock_req, article_schema): 1160 | if not article_schema: 1161 | return 1162 | 1163 | s = Session('http://localhost:8080/', schema=article_schema) 1164 | a = s.create('articles', 1165 | title='Test article', 1166 | comments=[ResourceTuple(i, 'comments') for i in ('5', '12')], 1167 | author=ResourceTuple('9', 'people'), 1168 | comments_or_authors=[ResourceTuple('9', 'people'), ResourceTuple('12', 'comments')] 1169 | ) 1170 | with mock.patch('jsonapi_client.session.Session.read'): 1171 | a.commit() 1172 | 1173 | 1174 | def test_posting_with_null_to_one_relationship(mock_req, article_schema): 1175 | if not article_schema: 1176 | return 1177 | 1178 | s = Session('http://localhost:8080/', schema=article_schema) 1179 | a = s.create('articles', 1180 | title='Test article', 1181 | comments=[], 1182 | author=None, 1183 | comments_or_authors=[] 1184 | ) 1185 | with mock.patch('jsonapi_client.session.Session.read'): 1186 | a.commit() 1187 | 1188 | 1189 | def test_posting_successfull_without_schema(mock_req): 1190 | s = Session('http://localhost:80801/api') 1191 | a = s.create('leases') 1192 | 1193 | a.lease_id = '1' 1194 | a.active_status = 'pending' 1195 | a.reference_number = 'test' 1196 | 1197 | a.create_map('valid_for') # Without schema we need to do this manually 1198 | 1199 | a.valid_for.start_datetime = 'asdf' 1200 | a.commit() 1201 | 1202 | agr_data = \ 1203 | {'data': {'type': 'leases', 1204 | 'attributes': {'lease-id': '1', 'active-status': 'pending', 1205 | 'reference-number': 'test', 1206 | 'valid-for': {'start-datetime': 'asdf'}}, 1207 | 'relationships': {}}} 1208 | 1209 | mock_req.assert_called_once_with('post', 'http://localhost:80801/api/leases', 1210 | agr_data) 1211 | 1212 | @pytest.mark.asyncio 1213 | async def test_posting_successfull_without_schema(mock_req_async, mock_update_resource): 1214 | s = Session('http://localhost:80801/api', enable_async=True) 1215 | a = s.create('leases') 1216 | 1217 | a.lease_id = '1' 1218 | a.active_status = 'pending' 1219 | a.reference_number = 'test' 1220 | 1221 | a.create_map('valid_for') # Without schema we need to do this manually 1222 | 1223 | a.valid_for.start_datetime = 'asdf' 1224 | await a.commit() 1225 | 1226 | agr_data = \ 1227 | {'data': {'type': 'leases', 1228 | 'attributes': {'lease-id': '1', 'active-status': 'pending', 1229 | 'reference-number': 'test', 1230 | 'valid-for': {'start-datetime': 'asdf'}}, 1231 | 'relationships': {}}} 1232 | 1233 | mock_req_async.assert_called_once_with('post', 'http://localhost:80801/api/leases', 1234 | agr_data) 1235 | await s.close() 1236 | 1237 | def test_posting_post_validation_error(): 1238 | s = Session('http://localhost:80801/api', schema=api_schema_all) 1239 | a = s.create('leases') 1240 | a.lease_id = '1' 1241 | a.active_status = 'blah' 1242 | a.reference_number = 'test' 1243 | a.valid_for.start_datetime='asdf' 1244 | with pytest.raises(jsonschema.ValidationError): 1245 | a.commit() 1246 | 1247 | 1248 | def test_relationship_manipulation(mock_req, article_schema, mocked_fetch, mock_update_resource): 1249 | s = Session('http://localhost:80801/', schema=article_schema) 1250 | article, article2, article3 = s.get('articles').resources 1251 | assert article.relationships.author.resource.id == '9' 1252 | if article_schema: 1253 | assert article.relationships.author.type == 'people' 1254 | # article.author = '10' # assigning could be done directly. 1255 | # This would go through ResourceObject.__setattr__ and 1256 | # through RelationshipDict.__setattr__, where it goes to 1257 | # Relationship.set() method 1258 | # But does pycharm get confused with this style? 1259 | 1260 | if article_schema: 1261 | article.relationships.author = '10' # to one. 1262 | else: 1263 | article.relationships.author.set('10', 'people') 1264 | assert article.relationships.author.is_dirty 1265 | assert 'author' in article.dirty_fields 1266 | 1267 | article.commit() 1268 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1269 | make_patch_json('10', 'people', 'author')) 1270 | mock_req.reset_mock() 1271 | assert not article.dirty_fields 1272 | assert not article.relationships.author.is_dirty 1273 | 1274 | assert article.relationships.author._resource_identifier.id == '10' 1275 | if article_schema: 1276 | assert article.relationships.comments.type == 'comments' 1277 | article.relationships.comments = ['5', '6'] # to many 1278 | else: 1279 | with pytest.raises(TypeError): 1280 | article.relationships.comments = ['5', '6'] 1281 | 1282 | article.relationships.comments.set(['5', '6'], 'comments') # to many 1283 | 1284 | article.commit() 1285 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1286 | make_patch_json([5, 6], 'comments')) 1287 | mock_req.reset_mock() 1288 | 1289 | assert [i.id for i in article.relationships.comments._resource_identifiers] == ['5', '6'] 1290 | 1291 | # Test .fields attribute proxy 1292 | article.relationships.comments.set(['6', '7'], 'comments') 1293 | article.commit() 1294 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1295 | make_patch_json([6, 7], 'comments')) 1296 | mock_req.reset_mock() 1297 | 1298 | assert [i.id for i in article.relationships.comments._resource_identifiers] == ['6', '7'] 1299 | 1300 | if article_schema: 1301 | article.relationships.comments.add('8') # id is sufficient as we know the type from schema 1302 | else: 1303 | with pytest.raises(TypeError): 1304 | article.relationships.comments.add('8') # id is sufficient as we know the type from schema 1305 | article.relationships.comments.add('8', 'comments') 1306 | article.relationships.comments.add('9', 'comments') # But we can supply also type just in case we don't have schema available 1307 | article.commit() 1308 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1309 | make_patch_json([6, 7, 8, 9], 'comments')) 1310 | mock_req.reset_mock() 1311 | 1312 | #assert article.relationships.comments == ['6', '7', '8', '9'] 1313 | if article_schema: 1314 | article.relationships.comments.add(['10','11']) 1315 | else: 1316 | with pytest.raises(TypeError): 1317 | article.relationships.comments.add(['10','11']) 1318 | article.relationships.comments.add(['10', '11'], 'comments') 1319 | #assert article.relationships.comments == ['6', '7', '8', '9', '10', '11'] 1320 | article.commit() 1321 | 1322 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1323 | make_patch_json([6, 7, 8, 9, 10, 11], 1324 | 'comments')) 1325 | mock_req.reset_mock() 1326 | comment = article.comment_or_author 1327 | assert comment.type == 'comments' 1328 | article.relationships.comment_or_author.set('12', 'comments') 1329 | article.commit() 1330 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1331 | make_patch_json('12', 'comments', 'comment-or-author')) 1332 | 1333 | 1334 | mock_req.reset_mock() 1335 | author, comment = article.comments_or_authors 1336 | assert comment.type == 'comments' 1337 | assert author.type == 'people' 1338 | 1339 | rel = article.relationships.comments_or_authors 1340 | rel.clear() 1341 | rel.add('5', 'comments') 1342 | rel.add('2', 'people') 1343 | 1344 | article.commit() 1345 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1346 | make_patch_json([('5', 'comments'), ('2', 'people')], None, 'comments-or-authors')) 1347 | 1348 | 1349 | mock_req.reset_mock() 1350 | article.relationships.comments_or_authors = [ 1351 | ResourceTuple('5', 'comments'), ResourceTuple('2', 'people')] 1352 | 1353 | article.commit() 1354 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1355 | make_patch_json([('5', 'comments'), ('2', 'people')], None, 'comments-or-authors')) 1356 | 1357 | mock_req.reset_mock() 1358 | article.relationships.author.set(None) 1359 | 1360 | article.commit() 1361 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1362 | make_patch_json(None, None, 'author')) 1363 | 1364 | 1365 | @pytest.mark.asyncio 1366 | async def test_relationship_manipulation_async(mock_req_async, mocked_fetch, article_schema, mock_update_resource): 1367 | s = Session('http://localhost:80801/', schema=article_schema, enable_async=True) 1368 | 1369 | doc = await s.get('articles') 1370 | article = doc.resource 1371 | 1372 | assert article.author._resource_identifier.id == '9' 1373 | if article_schema: 1374 | assert article.author.type == 'people' 1375 | # article.author = '10' # assigning could be done directly. 1376 | # This would go through ResourceObject.__setattr__ and 1377 | # through RelationshipDict.__setattr__, where it goes to 1378 | # Relationship.set() method 1379 | # But does pycharm get confused with this style? 1380 | 1381 | if article_schema: 1382 | article.author = '10' # to one. 1383 | else: 1384 | article.author.set('10', 'people') 1385 | assert article.author.is_dirty == True 1386 | assert 'author' in article.dirty_fields 1387 | 1388 | await article.commit() 1389 | mock_req_async.assert_called_once_with('patch', 'http://example.com/articles/1', 1390 | make_patch_json('10', 'people', field_name='author')) 1391 | mock_req_async.reset_mock() 1392 | assert not article.dirty_fields 1393 | assert not article.author.is_dirty 1394 | 1395 | #assert article.author.value == '10' 1396 | if article_schema: 1397 | assert article.comments.type == 'comments' 1398 | article.comments = ['5', '6'] # to many 1399 | else: 1400 | with pytest.raises(TypeError): 1401 | article.comments = ['5', '6'] 1402 | 1403 | article.comments.set(['5', '6'], 'comments') # to many 1404 | 1405 | assert article.comments.is_dirty 1406 | await article.commit() 1407 | mock_req_async.assert_called_once_with('patch', 'http://example.com/articles/1', 1408 | make_patch_json([5, 6], 'comments')) 1409 | mock_req_async.reset_mock() 1410 | 1411 | #assert article.comments.value == ['5', '6'] 1412 | 1413 | # Test .fields attribute proxy 1414 | article.fields.comments.set(['6', '7'], 'comments') 1415 | await article.commit() 1416 | mock_req_async.assert_called_once_with('patch', 'http://example.com/articles/1', 1417 | make_patch_json([6, 7], 'comments')) 1418 | mock_req_async.reset_mock() 1419 | 1420 | #assert article.comments.value == ['6', '7'] 1421 | 1422 | if article_schema: 1423 | article.comments.add('8') # id is sufficient as we know the type from schema 1424 | else: 1425 | with pytest.raises(TypeError): 1426 | article.comments.add('8') # id is sufficient as we know the type from schema 1427 | article.comments.add('8', 'comments') 1428 | article.comments.add('9', 'comments') # But we can supply also type just in case we don't have schema available 1429 | await article.commit() 1430 | mock_req_async.assert_called_once_with('patch', 'http://example.com/articles/1', 1431 | make_patch_json([6, 7, 8, 9], 'comments')) 1432 | mock_req_async.reset_mock() 1433 | 1434 | #assert article.comments.value == ['6', '7', '8', '9'] 1435 | if article_schema: 1436 | article.comments.add(['10','11']) 1437 | else: 1438 | with pytest.raises(TypeError): 1439 | article.comments.add(['10','11']) 1440 | article.comments.add(['10', '11'], 'comments') 1441 | #assert article.comments.value == ['6', '7', '8', '9', '10', '11'] 1442 | await article.commit() 1443 | 1444 | mock_req_async.assert_called_once_with('patch', 'http://example.com/articles/1', 1445 | make_patch_json([6, 7, 8, 9, 10, 11], 1446 | 'comments')) 1447 | mock_req_async.reset_mock() 1448 | await s.close() 1449 | 1450 | def test_relationship_manipulation_alternative_api(mock_req, mocked_fetch, article_schema, mock_update_resource): 1451 | s = Session('http://localhost:80801/', schema=article_schema) 1452 | article = s.get('articles').resource 1453 | 1454 | # Test alternative direct setting attribute via RelationshipDict's __setattr__ 1455 | # This does not look very nice with 'clever' IDE that gets totally confused about 1456 | # this. 1457 | 1458 | oc1, oc2 = article.comments 1459 | 1460 | if article_schema: 1461 | assert article.relationships.comments.type == 'comments' 1462 | article.comments = ['5', '6'] # to many 1463 | else: 1464 | with pytest.raises(TypeError): 1465 | article.relationships.comments = ['5', '6'] 1466 | with pytest.raises(TypeError): 1467 | article.comments = ['5', '6'] 1468 | article.comments = [ResourceTuple(i, 'comments') for i in ['5', '6']] 1469 | 1470 | 1471 | 1472 | article.commit() 1473 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1474 | make_patch_json([5, 6], 'comments')) 1475 | mock_req.reset_mock() 1476 | 1477 | #assert article.relationships.comments.value == ['5', '6'] 1478 | 1479 | # ***** # 1480 | if article_schema: 1481 | assert article.relationships.comments.type == 'comments' 1482 | article.comments = ['6', '7'] # to many 1483 | else: 1484 | with pytest.raises(TypeError): 1485 | article.relationships.comments = ['6', '7'] 1486 | with pytest.raises(TypeError): 1487 | article.comments = ['5', '6'] 1488 | #article.relationships.comments.set(['6', '7'], 'comments') 1489 | article.comments = [ResourceTuple(i, 'comments') for i in ['6', '7']] 1490 | 1491 | 1492 | article.commit() 1493 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1494 | make_patch_json([6, 7], 'comments')) 1495 | mock_req.reset_mock() 1496 | 1497 | #assert article.relationships.comments.value == ['6', '7'] 1498 | 1499 | # Set resourceobject 1500 | 1501 | if article_schema: 1502 | assert article.relationships.comments.type == 'comments' 1503 | article.comments = oc1, oc2 1504 | else: 1505 | article.relationships.comments = oc1, oc2 1506 | article.comments = oc1, oc2 1507 | 1508 | 1509 | article.commit() 1510 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1511 | make_patch_json([oc1.id, oc2.id], 'comments')) 1512 | mock_req.reset_mock() 1513 | 1514 | #assert article.relationships.comments.value == [str(i) for i in [oc1.id, oc2.id]] 1515 | 1516 | 1517 | # Let's test also .fields AttributeProxy 1518 | if article_schema: 1519 | assert article.relationships.comments.type == 'comments' 1520 | article.fields.comments = ['7', '6'] # to many 1521 | else: 1522 | with pytest.raises(TypeError): 1523 | article.fields.comments = ['7', '6'] 1524 | article.relationships.comments.set(['7', '6'], 'comments') 1525 | 1526 | article.commit() 1527 | mock_req.assert_called_once_with('patch', 'http://example.com/articles/1', 1528 | make_patch_json([7, 6], 'comments')) 1529 | mock_req.reset_mock() 1530 | 1531 | #assert article.relationships.comments.value == ['7', '6'] 1532 | 1533 | 1534 | class SuccessfullLeaseResponse: 1535 | status_code = 200 1536 | headers = {} 1537 | content = '' 1538 | 1539 | @classmethod 1540 | def json(cls): 1541 | return { 1542 | 'data': { 1543 | 'id': 'qvantel-lease1', 1544 | 'type': 'leases', 1545 | 'attributes': { 1546 | 'valid-for': { 1547 | 'new-field': 'something-new', 1548 | 'start-datetime': 'something-else' 1549 | } 1550 | }, 1551 | 'relationships': { 1552 | 'external-references': { 1553 | 'data': [ 1554 | { 1555 | 'id': 'qvantel-lease1-extref', 1556 | 'type': 'external-references'}, 1557 | { 1558 | 'id': '1', 1559 | 'type': 'external-references'}, 1560 | { 1561 | 'id': '2', 1562 | 'type': 'external-references'}, 1563 | { 1564 | 'id': '3', 1565 | 'type': 'external-references'} 1566 | ] 1567 | } 1568 | } 1569 | } 1570 | } 1571 | 1572 | 1573 | @pytest.mark.asyncio 1574 | async def test_set_custom_request_header_async_get_session(): 1575 | patcher = mock.patch('aiohttp.ClientSession') 1576 | client_mock = patcher.start() 1577 | request_kwargs = {'headers': {'Foo': 'Bar', 'X-Test': 'test'}} 1578 | s = Session( 1579 | 'http://localhost', 1580 | schema=leases, 1581 | enable_async=True, 1582 | request_kwargs=request_kwargs 1583 | ) 1584 | client_mock().get.return_value = SuccessfullLeaseResponse 1585 | with pytest.raises(AttributeError): 1586 | await s.get('leases', 1) 1587 | 1588 | s.close() 1589 | assert client_mock().get.called 1590 | args = client_mock().get.call_args 1591 | assert args[1]['headers']['Foo'] == 'Bar' 1592 | assert args[1]['headers']['X-Test'] == 'test' 1593 | patcher.stop() 1594 | 1595 | 1596 | def test_set_custom_request_header_get_session(): 1597 | patcher = mock.patch('requests.get') 1598 | get_mock = patcher.start() 1599 | request_kwargs = {'headers': {'Foo': 'Bar', 'X-Test': 'test'}} 1600 | s = Session('http://localhost', schema=leases, request_kwargs=request_kwargs) 1601 | get_mock.return_value = SuccessfullLeaseResponse 1602 | s.get('leases', 1) 1603 | 1604 | s.close() 1605 | assert get_mock.called 1606 | args = get_mock.call_args 1607 | assert args[1]['headers']['Foo'] == 'Bar' 1608 | assert args[1]['headers']['X-Test'] == 'test' 1609 | patcher.stop() 1610 | 1611 | 1612 | def test_set_custom_request_header_patch_session(): 1613 | patcher = mock.patch('requests.get') 1614 | get_mock = patcher.start() 1615 | request_kwargs = {'headers': {'Foo': 'Bar', 'X-Test': 'test'}} 1616 | s = Session('http://localhost', schema=leases, request_kwargs=request_kwargs) 1617 | get_mock.return_value = SuccessfullLeaseResponse 1618 | lease = s.get('leases', 1) 1619 | patcher.stop() 1620 | 1621 | patcher = mock.patch('requests.request') 1622 | request_mock = patcher.start() 1623 | lease.resource.valid_for.new_field = "updated" 1624 | with pytest.raises(DocumentError): 1625 | s.commit() 1626 | s.close() 1627 | assert request_mock.called 1628 | args = request_mock.call_args 1629 | assert args[1]['headers']['Content-Type'] == 'application/vnd.api+json' 1630 | assert args[1]['headers']['Foo'] == 'Bar' 1631 | assert args[1]['headers']['X-Test'] == 'test' 1632 | patcher.stop() 1633 | 1634 | 1635 | @pytest.mark.asyncio 1636 | async def test_posting_async_with_custom_header(loop, session): 1637 | response = ClientResponse('post', URL('http://localhost/api/leases'), 1638 | request_info=mock.Mock(), 1639 | writer=mock.Mock(), 1640 | continue100=None, 1641 | timer=TimerNoop(), 1642 | traces=[], 1643 | loop=loop, 1644 | session=session, 1645 | ) 1646 | 1647 | response._headers = {'Content-Type': 'application/vnd.api+json'} 1648 | response._body = json.dumps({'errors': [{'title': 'Internal server error'}]}).encode('UTF-8') 1649 | response.status = 500 1650 | 1651 | patcher = mock.patch('aiohttp.ClientSession.request') 1652 | request_mock = patcher.start() 1653 | request_mock.return_value = response 1654 | request_kwargs = {'headers': {'Foo': 'Bar', 'X-Test': 'test'}, 'something': 'else'} 1655 | s = Session( 1656 | 'http://localhost/api', 1657 | schema=api_schema_all, 1658 | enable_async=True, 1659 | request_kwargs=request_kwargs 1660 | ) 1661 | a = s.create('leases') 1662 | assert a.is_dirty 1663 | a.lease_id = '1' 1664 | a.active_status = 'pending' 1665 | a.reference_number = 'test' 1666 | a.valid_for.start_datetime = 'asdf' 1667 | with pytest.raises(DocumentError): 1668 | await a.commit() 1669 | 1670 | await s.close() 1671 | assert request_mock.called 1672 | args = request_mock.call_args 1673 | assert args[1]['headers']['Content-Type'] == 'application/vnd.api+json' 1674 | assert args[1]['headers']['Foo'] == 'Bar' 1675 | assert args[1]['headers']['X-Test'] == 'test' 1676 | assert args[1]['something'] == 'else' 1677 | patcher.stop() 1678 | 1679 | 1680 | def test_error_handling_get(): 1681 | response = Response() 1682 | response.url = URL('http://localhost:8080/invalid') 1683 | response.request = mock.Mock() 1684 | response.headers = {'Content-Type': 'application/vnd.api+json'} 1685 | response._content = json.dumps({'errors': [{'title': 'Resource not found'}]}).encode('UTF-8') 1686 | response.status_code = 404 1687 | 1688 | patcher = mock.patch('requests.get') 1689 | client_mock = patcher.start() 1690 | s = Session('http://localhost', schema=leases) 1691 | client_mock.return_value = response 1692 | with pytest.raises(DocumentError) as exp: 1693 | s.get('invalid') 1694 | 1695 | assert str(exp.value) == 'Error 404: Resource not found' 1696 | patcher.stop() 1697 | 1698 | 1699 | def test_error_handling_post(): 1700 | response = Response() 1701 | response.url = URL('http://localhost:8080/invalid') 1702 | response.request = mock.Mock() 1703 | response.headers = {'Content-Type': 'application/vnd.api+json'} 1704 | response._content = json.dumps({'errors': [{'title': 'Internal server error'}]}).encode('UTF-8') 1705 | response.status_code = 500 1706 | 1707 | patcher = mock.patch('requests.request') 1708 | client_mock = patcher.start() 1709 | s = Session('http://localhost', schema=leases) 1710 | client_mock.return_value = response 1711 | a = s.create('leases') 1712 | assert a.is_dirty 1713 | a.lease_id = '1' 1714 | a.active_status = 'pending' 1715 | a.reference_number = 'test' 1716 | with pytest.raises(DocumentError) as exp: 1717 | a.commit() 1718 | 1719 | assert str(exp.value) == 'Could not POST (500): Internal server error' 1720 | patcher.stop() 1721 | 1722 | 1723 | @pytest.mark.asyncio 1724 | async def test_error_handling_async_get(loop, session): 1725 | response = ClientResponse('get', URL('http://localhost:8080/invalid'), 1726 | request_info=mock.Mock(), 1727 | writer=mock.Mock(), 1728 | continue100=None, 1729 | timer=TimerNoop(), 1730 | traces=[], 1731 | loop=loop, 1732 | session=session, 1733 | ) 1734 | response._headers = {'Content-Type': 'application/vnd.api+json'} 1735 | response._body = json.dumps({'errors': [{'title': 'Resource not found'}]}).encode('UTF-8') 1736 | response.status = 404 1737 | 1738 | patcher = mock.patch('aiohttp.ClientSession') 1739 | client_mock = patcher.start() 1740 | s = Session('http://localhost', schema=leases, enable_async=True) 1741 | client_mock().get.return_value = response 1742 | with pytest.raises(DocumentError) as exp: 1743 | await s.get('invalid') 1744 | 1745 | assert str(exp.value) == 'Error 404: Resource not found' 1746 | patcher.stop() 1747 | 1748 | 1749 | @pytest.mark.asyncio 1750 | async def test_error_handling_posting_async(loop, session): 1751 | response = ClientResponse('post', URL('http://localhost:8080/leases'), 1752 | request_info=mock.Mock(), 1753 | writer=mock.Mock(), 1754 | continue100=None, 1755 | timer=TimerNoop(), 1756 | traces=[], 1757 | loop=loop, 1758 | session=session, 1759 | ) 1760 | response._headers = {'Content-Type': 'application/vnd.api+json'} 1761 | response._body = json.dumps({'errors': [{'title': 'Internal server error'}]}).encode('UTF-8') 1762 | response.status = 500 1763 | 1764 | patcher = mock.patch('aiohttp.ClientSession.request') 1765 | request_mock = patcher.start() 1766 | s = Session( 1767 | 'http://localhost:8080', 1768 | schema=api_schema_all, 1769 | enable_async=True 1770 | ) 1771 | request_mock.return_value = response 1772 | 1773 | a = s.create('leases') 1774 | assert a.is_dirty 1775 | a.lease_id = '1' 1776 | a.active_status = 'pending' 1777 | a.reference_number = 'test' 1778 | with pytest.raises(DocumentError) as exp: 1779 | await a.commit() 1780 | 1781 | assert str(exp.value) == 'Could not POST (500): Internal server error' 1782 | patcher.stop() 1783 | --------------------------------------------------------------------------------