├── requirements.txt ├── .coveragerc ├── pluct ├── tests │ ├── __init__.py │ ├── mocks.py │ ├── test_session.py │ ├── test_resource_rel.py │ ├── test_resource.py │ └── test_schema.py ├── exceptions.py ├── datastructures.py ├── __init__.py ├── session.py ├── schema.py └── resource.py ├── MANIFEST.in ├── requirements_test.txt ├── .gitignore ├── .bumpversion.cfg ├── .travis.yml ├── Makefile ├── setup.py ├── LICENSE.txt └── README.rst /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | 3 | bumpversion 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | pluct/datastructures.py 4 | -------------------------------------------------------------------------------- /pluct/tests/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | recursive-include pluct *.py 3 | prune pluct/tests 4 | -------------------------------------------------------------------------------- /pluct/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from requests import HTTPError # noqa 4 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | flake8==3.3.0 4 | nose==1.3.7 5 | mock==2.0.0 6 | coverage==4.3.4 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.pyc 3 | build/ 4 | pluct.egg-info/ 5 | *.sw[a-z] 6 | .coverage 7 | dist/ 8 | htmlcov/ 9 | __pycache__ 10 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | commit = True 3 | tag = True 4 | tag_name = {new_version} 5 | current_version = 1.3.0 6 | 7 | [bumpversion:file:setup.py] 8 | 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.6" 5 | install: make deps 6 | script: make test 7 | # blacklist 8 | branches: 9 | except: 10 | - rodsenra 11 | -------------------------------------------------------------------------------- /pluct/datastructures.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | try: 4 | from UserDict import IterableUserDict 5 | except ImportError: 6 | from collections import UserDict as IterableUserDict # noqa 7 | 8 | try: 9 | from UserList import UserList 10 | except ImportError: 11 | from collections import UserList # noqa 12 | -------------------------------------------------------------------------------- /pluct/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Used to mock validate method on tests 3 | from pluct import resource 4 | resources = resource 5 | 6 | # Shortcuts 7 | from pluct.resource import Resource # noqa 8 | from pluct.schema import LazySchema, Schema # noqa 9 | from pluct.session import Session as Pluct # noqa 10 | 11 | _pluct = Pluct() 12 | 13 | resource = _pluct.resource 14 | schema = _pluct.schema 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BUMP := 'patch' 2 | 3 | help: 4 | @grep '^[^#[:space:]].*:' Makefile | awk -F ":" '{print $$1}' 5 | 6 | clean: 7 | @find . -name "*.pyc" -delete 8 | 9 | deps: 10 | @pip install -r requirements_test.txt 11 | 12 | setup: deps 13 | 14 | patch: 15 | @$(eval BUMP := 'patch') 16 | 17 | minor: 18 | @$(eval BUMP := 'minor') 19 | 20 | major: 21 | @$(eval BUMP := 'major') 22 | 23 | bump: 24 | @bumpversion ${BUMP} 25 | 26 | release: 27 | @echo 'PyPI server: '; read PYPI_SERVER; \ 28 | python setup.py -q sdist upload -r $$PYPI_SERVER 29 | @git push 30 | @git push --tags 31 | 32 | coverage_html: 33 | @coverage html --include='pluct/**' 34 | @echo 'Check "htmlcov/index.html" for coverage report.' 35 | 36 | test: clean 37 | @nosetests -s -v --with-coverage --cover-package=pluct --cover-branches --cover-erase 38 | @flake8 pluct/ 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from setuptools import setup, find_packages 4 | 5 | 6 | setup( 7 | name='pluct', 8 | version='1.3.0', 9 | description='JSON Hyper Schema client', 10 | long_description=open('README.rst').read(), 11 | author='Marcos Daniel Petry', 12 | author_email='marcospetry@gmail.com', 13 | url='https://github.com/globocom/pluct', 14 | classifiers=[ 15 | 'Development Status :: 4 - Beta', 16 | 'Environment :: No Input/Output (Daemon)', 17 | 'Intended Audience :: Developers', 18 | 'License :: OSI Approved :: MIT License', 19 | 'Operating System :: Unix', 20 | 'Programming Language :: Python :: 2.7', 21 | 'Programming Language :: Python :: 3.6', 22 | ], 23 | test_suite='pluct.tests', 24 | packages=find_packages(exclude=('pluct.tests.*', 'pluct.tests')), 25 | include_package_data=True, 26 | install_requires=[ 27 | 'requests', 28 | 'uritemplate>=0.6,<1.0', 29 | 'jsonschema', 30 | 'jsonpointer', 31 | ], 32 | ) 33 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Marcos Daniel Petry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /pluct/session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from requests import Session as RequestsSession 4 | 5 | from pluct.resource import Resource 6 | from pluct.schema import Schema, LazySchema, get_profile_from_header 7 | 8 | 9 | class Session(object): 10 | 11 | def __init__(self, client=None, timeout=None): 12 | self.timeout = timeout 13 | self.store = {} 14 | 15 | if client is None: 16 | self.client = RequestsSession() 17 | else: 18 | self.client = client 19 | 20 | def resource(self, url, **kwargs): 21 | response = self.request(url, **kwargs) 22 | schema = None 23 | 24 | schema_url = get_profile_from_header(response.headers) 25 | if schema_url is not None: 26 | schema = LazySchema(href=schema_url, session=self) 27 | 28 | return Resource.from_response( 29 | response=response, session=self, schema=schema) 30 | 31 | def schema(self, url, **kwargs): 32 | data = self.request(url, **kwargs).json() 33 | return Schema(url, raw_schema=data, session=self) 34 | 35 | def request(self, url, **kwargs): 36 | if self.timeout is not None: 37 | kwargs.setdefault('timeout', self.timeout) 38 | 39 | kwargs.setdefault('headers', {}) 40 | kwargs['headers'].setdefault('content-type', 'application/json') 41 | 42 | kwargs.setdefault('method', 'get') 43 | 44 | response = self.client.request(url=url, **kwargs) 45 | response.raise_for_status() 46 | 47 | return response 48 | -------------------------------------------------------------------------------- /pluct/tests/mocks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import ujson 4 | import mock 5 | 6 | 7 | class ServiceSchemaMock(mock.MagicMock): 8 | headers = { 9 | 'content-type': 'application/json' 10 | } 11 | status_code = 200 12 | content = ujson.dumps( 13 | { 14 | 'items': [ 15 | { 16 | 'collection_name': "airports", 17 | # 'resource_id': "airport", 18 | }, 19 | { 20 | 'collection_name': "cities", 21 | # 'resource_id': "city", 22 | } 23 | ], 24 | 25 | 'item_count': 2 26 | } 27 | ) 28 | 29 | 30 | class ResourceMock(mock.MagicMock): 31 | headers = { 32 | 'content-type': 'application/json; profile=http://my-api.com/v1/schema' 33 | } 34 | status_code = 200 35 | 36 | 37 | class ResourceSchemaMock(mock.MagicMock): 38 | headers = { 39 | 'content-type': 'application/json' 40 | } 41 | status_code = 200 42 | json = { 43 | 'links': [ 44 | { 45 | 'href': 'http://my-awesome-api.com/g1/' + 46 | 'airports/{resource_id}', 47 | 'rel': 'item' 48 | }, 49 | { 50 | 'href': 'http://my-awesome-api.com/g1/' + 51 | 'airports/{resource_id}', 52 | 'method': 'PATCH', 53 | 'rel': 'edit' 54 | }, 55 | { 56 | 'href': 'http://my-awesome-api.com/g1/' + 57 | 'airports/{resource_id}', 58 | 'method': 'PUT', 59 | 'rel': 'replace' 60 | }, 61 | { 62 | 'href': 'http://my-awesome-api.com/g1/' + 63 | 'airports/{resource_id}', 64 | 'method': 'DELETE', 65 | 'rel': 'delete' 66 | }, 67 | { 68 | 'href': 'http://my-awesome-api.com/g1/airports', 69 | 'rel': 'self' 70 | }, 71 | { 72 | 'href': 'http://my-awesome-api.com/g1/airports', 73 | 'method': 'POST', 74 | 'rel': 'create' 75 | } 76 | ], 77 | 'item_count': 2 78 | } 79 | 80 | @property 81 | def content(self): 82 | return ujson.dumps(self.json) 83 | 84 | 85 | class ResourceItemsMock(mock.MagicMock): 86 | headers = { 87 | 'content-type': 'application/json' 88 | } 89 | status_code = 200 90 | content = ujson.dumps( 91 | { 92 | 'items': [ 93 | { 94 | 'name': "Rio de Janeiro", 95 | 'resource_id': "rio-de-janeiro" 96 | }, 97 | { 98 | 'name': "São Paulo", 99 | 'resource_id': "sao-paulo" 100 | }, 101 | { 102 | 'name': "Recife", 103 | 'resource_id': "recife" 104 | }, 105 | ], 106 | } 107 | ) 108 | -------------------------------------------------------------------------------- /pluct/tests/test_session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from unittest import TestCase 4 | 5 | from mock import ANY, Mock, patch 6 | 7 | from pluct.session import Session 8 | 9 | 10 | class SessionInitializationTestCase(TestCase): 11 | 12 | def test_keeps_timeout(self): 13 | session = Session(timeout=999) 14 | self.assertEqual(session.timeout, 999) 15 | 16 | def test_uses_requests_session_as_default_client(self): 17 | with patch('pluct.session.RequestsSession') as client: 18 | Session() 19 | client.assert_called_with() 20 | 21 | def test_allows_custom_client(self): 22 | custom_client = Mock() 23 | session = Session(client=custom_client) 24 | self.assertEqual(session.client, custom_client) 25 | 26 | 27 | class SessionRequestsTestCase(TestCase): 28 | 29 | def setUp(self): 30 | self.response = Mock() 31 | self.client = Mock() 32 | self.client.request.return_value = self.response 33 | 34 | self.session = Session() 35 | self.session.client = self.client 36 | 37 | def test_delegates_request_to_client(self): 38 | self.session.request('/') 39 | self.client.request.assert_called_with( 40 | url='/', method='get', headers=ANY) 41 | 42 | def test_uses_default_timeout(self): 43 | self.session.timeout = 333 44 | self.session.request('/') 45 | self.client.request.assert_called_with( 46 | url='/', method='get', timeout=333, headers=ANY) 47 | 48 | def test_allows_custom_timeout_per_request(self): 49 | self.session.request('/', timeout=999) 50 | self.client.request.assert_called_with( 51 | url='/', method='get', timeout=999, headers=ANY) 52 | 53 | def test_applies_json_content_type_header(self): 54 | self.session.request('/') 55 | self.client.request.assert_called_with( 56 | url='/', method='get', 57 | headers={'content-type': 'application/json'}) 58 | 59 | def test_allows_custom_content_type_header(self): 60 | custom_headers = {'content-type': 'application/yaml'} 61 | self.session.request('/', headers=custom_headers) 62 | self.client.request.assert_called_with( 63 | url='/', method='get', headers=custom_headers) 64 | 65 | def test_returns_response(self): 66 | response = self.session.request('/') 67 | self.assertIs(response, self.response) 68 | 69 | def test_checks_for_bad_response(self): 70 | self.session.request('/') 71 | self.response.raise_for_status.assert_called_once_with() 72 | 73 | 74 | class SessionResourceTestCase(TestCase): 75 | 76 | def setUp(self): 77 | self.schema_url = '/schema' 78 | 79 | self.response = Mock() 80 | self.response.headers = { 81 | 'content-type': 'application/json; profile=%s' % self.schema_url 82 | } 83 | 84 | self.session = Session() 85 | 86 | patch.object(self.session, 'request').start() 87 | self.session.request.return_value = self.response 88 | 89 | def tearDown(self): 90 | patch.stopall() 91 | 92 | @patch('pluct.session.Resource.from_response') 93 | @patch('pluct.session.LazySchema') 94 | def test_creates_resource_from_response(self, LazySchema, from_response): 95 | LazySchema.return_value = 'fake schema' 96 | 97 | self.session.resource('/') 98 | 99 | LazySchema.assert_called_with( 100 | href=self.schema_url, session=self.session) 101 | 102 | from_response.assert_called_with( 103 | response=self.response, session=self.session, schema='fake schema') 104 | 105 | @patch('pluct.session.Schema') 106 | def test_creates_schema_from_response(self, Schema): 107 | self.session.schema('/') 108 | Schema.assert_called_with( 109 | '/', raw_schema=self.response.json(), session=self.session) 110 | -------------------------------------------------------------------------------- /pluct/schema.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from cgi import parse_header 4 | 5 | from jsonpointer import resolve_pointer 6 | 7 | from pluct.datastructures import IterableUserDict 8 | 9 | 10 | class Schema(IterableUserDict, object): 11 | 12 | @staticmethod 13 | def __new__(cls, href, *args, **kwargs): 14 | (href, url, pointer) = cls._split_href(href) 15 | 16 | session = kwargs['session'] 17 | 18 | if href in session.store: 19 | return session.store[href] 20 | 21 | instance = super(Schema, cls).__new__(cls) 22 | session.store[href] = instance 23 | 24 | if pointer: 25 | # Reuse the constructor to make it register the root schema 26 | # without a pointer 27 | cls(url, *args, **kwargs) 28 | 29 | return instance 30 | 31 | def __init__(self, href, raw_schema=None, session=None): 32 | self._init_href(href) 33 | self._data = None 34 | self._raw_schema = raw_schema 35 | self.session = session 36 | 37 | @property 38 | def __class__(self): 39 | return dict 40 | 41 | def _is_simple_dict(self, obj): 42 | return isinstance(obj, dict) and (not isinstance(obj, Schema)) 43 | 44 | def expand_refs(self, item): 45 | if self._is_simple_dict(item): 46 | iterator = iter(item.items()) 47 | elif isinstance(item, list): 48 | iterator = enumerate(item) 49 | else: 50 | return 51 | 52 | for key, value in iterator: 53 | key_ref_in_dict = ( 54 | self._is_simple_dict(value) and ('$ref' in value) 55 | ) 56 | 57 | if key_ref_in_dict: 58 | item[key] = self.from_href( 59 | value['$ref'], raw_schema=self._raw_schema, 60 | session=self.session) 61 | continue 62 | self.expand_refs(value) 63 | 64 | @property 65 | def data(self): 66 | if self._data is None: 67 | self._data = self.resolve() 68 | return self._data 69 | 70 | @property 71 | def raw_schema(self): 72 | return self._raw_schema 73 | 74 | @classmethod 75 | def from_href(cls, href, raw_schema, session): 76 | href, url, pointer = cls._split_href(href) 77 | is_external = url != '' 78 | 79 | if is_external: 80 | return LazySchema(href, session=session) 81 | 82 | return Schema(href, raw_schema=raw_schema, session=session) 83 | 84 | def resolve(self): 85 | data = resolve_pointer(self.raw_schema, self.pointer) 86 | self.expand_refs(data) 87 | return data 88 | 89 | def get_link(self, name): 90 | links = self.get('links') or [] 91 | for link in links: 92 | if link.get('rel') == name: 93 | return link 94 | return None 95 | 96 | def _init_href(self, href): 97 | (self.href, self.url, self.pointer) = self._split_href(href) 98 | 99 | @classmethod 100 | def _split_href(cls, href): 101 | parts = href.split('#', 1) 102 | url = parts[0] 103 | 104 | pointer = '' 105 | if len(parts) > 1: 106 | pointer = parts[1] or pointer 107 | 108 | href = '#'.join((url, pointer)) 109 | 110 | return href, url, pointer 111 | 112 | 113 | class LazySchema(Schema): 114 | 115 | def __init__(self, href, session=None): 116 | self._init_href(href) 117 | self.session = session 118 | self._data = None 119 | self._raw_schema = None 120 | 121 | @property 122 | def raw_schema(self): 123 | if self._raw_schema is None: 124 | response = self.session.request(self.url) 125 | self._raw_schema = response.json() 126 | return self._raw_schema 127 | 128 | def __repr__(self): 129 | return repr({'$ref': self.href}) 130 | 131 | 132 | def get_profile_from_header(headers): 133 | if 'content-type' not in headers: 134 | return None 135 | 136 | full_content_type = 'content-type: {0}'.format(headers['content-type']) 137 | header, parameters = parse_header(full_content_type) 138 | 139 | if 'profile' not in parameters: 140 | return None 141 | 142 | schema_url = parameters['profile'] 143 | return schema_url 144 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pluct 2 | ===== 3 | 4 | .. image:: https://travis-ci.org/globocom/pluct.svg 5 | :target: https://travis-ci.org/globocom/pluct 6 | :alt: Travis CI Build Status 7 | 8 | A JSON Hyper Schema client that allows hypermedia navigation and 9 | resource validation. 10 | 11 | Basic Usage 12 | ----------- 13 | 14 | .. code:: python 15 | 16 | import pluct 17 | 18 | # Load a resource 19 | item = pluct.resource('http://myapi.com/api/item', timeout=2) # Works with connect timeout 20 | 21 | # Verifying if the resource is valid for the current schema 22 | item.is_valid() 23 | 24 | # Use the resource as a dictionary 25 | first_title = item['subitems'][0]['title'] 26 | 27 | # Accessing the item schema 28 | item.schema['properties']['title'] 29 | 30 | # Loading a related resource 31 | category = item.rel('category') 32 | 33 | # With additional parameters 34 | category = item.rel('category', timeout=(1, 2)) # You can choose from request parameters: http://docs.python-requests.org/en/latest/api/#requests.Session.request 35 | 36 | Authentication / Custom HTTP Client 37 | ----------------------------------- 38 | 39 | ``Pluct`` uses the `Session `_ 40 | object from the `requests `_ package as a HTTP client. 41 | 42 | Any other client with the same interface can be used. 43 | 44 | Here is an example using `alf `_, an OAuth 2 client: 45 | 46 | .. code:: python 47 | 48 | from pluct import Pluct 49 | from alf.client import Client 50 | 51 | alf = Client( 52 | token_endpoint='http://myapi.com/token', 53 | client_id='client-id', 54 | client_secret='secret') 55 | 56 | # Create a pluct session using the client 57 | pluct = Pluct(client=alf) 58 | item = pluct.resource('http://myapi.com/api/item') 59 | 60 | All subsequent requests for schemas or resources in this session will 61 | use the same client. 62 | 63 | Parameters and URI expansion 64 | ---------------------------- 65 | 66 | `URI Templates `_ are supported when following resource links. 67 | 68 | The context for URL expansion will be a merge of the resource ``data`` 69 | attribute and the ``params`` parameter passed to the resource’s ``rel`` 70 | method. 71 | 72 | Any variable not consumed by the URL Template will be used on the query 73 | string for the request. 74 | 75 | Better explained in an example. Consider the following resource and 76 | schema snippets: 77 | 78 | .. code:: json 79 | 80 | { 81 | "type": "article" 82 | } 83 | 84 | .. code:: json 85 | 86 | { 87 | "...": "...", 88 | "links": [ 89 | { 90 | "rel": "search", 91 | "href": "/api/search/{type}" 92 | } 93 | ] 94 | } 95 | 96 | The next example will resolve the ``href`` from the ``search`` link to 97 | ``/api/search/article?q=foo`` and will load articles containing the text 98 | “foo”: 99 | 100 | .. code:: python 101 | 102 | import pluct 103 | 104 | # Load a resource 105 | item = pluct.resource('http://myapi.com/api/item') 106 | 107 | articles = item.rel('search', params={'q': 'foo'}) 108 | 109 | To search for galleries is just a matter of passing a different ``type`` 110 | in the ``params`` argument, as follows: 111 | 112 | .. code:: python 113 | 114 | galleries = item.rel('search', params={'type': 'gallery', 'q': 'foo'}) 115 | 116 | To send your own body data you can send the object as data. This will follow 117 | your method (PUT, POST, GET or DELETE) with all data from object: 118 | 119 | .. code:: python 120 | 121 | galleries = item.rel('create', data=item) 122 | 123 | 124 | Schema loading 125 | -------------- 126 | 127 | When a resource is loaded, a lazy-schema schema will be created and its 128 | data will only be loaded when accessed. 129 | 130 | ``Pluct`` looks for a schema URL on the ``profile`` parameter of the 131 | ``Content-type`` header: 132 | 133 | .. code:: python 134 | 135 | Content-Type: application/json; profile="http://myapi.com/api/schema" 136 | 137 | References ($ref) 138 | ----------------- 139 | 140 | `JSON Pointers `_ on schemas are 141 | also supported. 142 | 143 | Pointers are identified by a dictionary with a ``$ref`` key pointing to an 144 | external URL or a local pointer. 145 | 146 | Considering the following definitions on the ``/api/definitions`` url: 147 | 148 | .. code:: json 149 | 150 | { 151 | "address": { 152 | "type": "object", 153 | "properties": { 154 | "line1": {"type": "string"}, 155 | "line2": {"type": "string"}, 156 | "zipcode": {"type": "integer"}, 157 | } 158 | } 159 | } 160 | 161 | And this schema on ``/api/schema`` that uses the above definitions: 162 | 163 | .. code:: json 164 | 165 | { 166 | "properties": { 167 | "shippingAddress": {"$ref": "http://myapi.com/api/definitions#/address"}, 168 | "billingAddress": {"$ref": "http://myapi.com/api/definitions#/address"}, 169 | } 170 | } 171 | 172 | The ``billingAddress`` can be accessed as follows: 173 | 174 | .. code:: python 175 | 176 | import pluct 177 | schema = pluct.schema('http://myapi.com/api/schema') 178 | 179 | schema['properties']['billingAddress']['zipcode'] == {"type": "integer"} 180 | 181 | Contributing 182 | ------------ 183 | 184 | Fork the repository on Github: 185 | https://github.com/globocom/pluct 186 | 187 | Create a virtualenv and install the dependencies: 188 | 189 | .. code:: bash 190 | 191 | make setup 192 | 193 | Tests are on the `pluct/tests` directory, run the test suite with: 194 | 195 | .. code:: bash 196 | 197 | make test 198 | 199 | -------------------------------------------------------------------------------- /pluct/tests/test_resource_rel.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | 5 | from unittest import TestCase 6 | from mock import patch, Mock 7 | from copy import deepcopy 8 | 9 | from pluct.resource import Resource 10 | from pluct.schema import Schema 11 | from pluct.session import Session 12 | 13 | 14 | class ResourceRelTestCase(TestCase): 15 | 16 | def setUp(self): 17 | raw_schema = { 18 | 'links': [ 19 | { 20 | 'rel': 'item', 21 | 'href': '/root/{id}', 22 | }, 23 | { 24 | 'rel': 'related', 25 | 'href': '/root/{slug}/{related}', 26 | }, 27 | { 28 | 'rel': 'create', 29 | 'href': '/root', 30 | 'method': 'POST', 31 | }, 32 | { 33 | 'rel': 'list', 34 | 'href': '/root', 35 | } 36 | ] 37 | } 38 | self.data = {'id': '123', 39 | 'slug': 'slug', 40 | 'items': [{"ide": 1}, 41 | {"ida": 2}]} 42 | 43 | self.session = Session() 44 | self.schema = Schema('/schema', raw_schema, session=self.session) 45 | 46 | self.response = Mock() 47 | self.response.url = 'http://example.com' 48 | 49 | self.profile_url = 'http://example.com/schema' 50 | 51 | content_type = 'application/json; profile=%s' % (self.profile_url) 52 | self.response.headers = { 53 | 'content-type': content_type 54 | } 55 | 56 | self.resource = Resource.from_data( 57 | 'http://much.url.com/', 58 | data=deepcopy(self.data), schema=self.schema, session=self.session 59 | ) 60 | 61 | self.resource2 = Resource.from_data( 62 | 'http://much.url.com/', 63 | data=deepcopy(self.data), schema=self.schema, 64 | session=self.session, response=self.response 65 | ) 66 | 67 | self.request_patcher = patch.object(self.session, 'request') 68 | self.request = self.request_patcher.start() 69 | 70 | def tearDown(self): 71 | self.request_patcher.stop() 72 | 73 | def test_rel_follows_content_type_profile(self): 74 | self.resource2.rel('create', data=self.resource2) 75 | self.request.assert_called_with( 76 | 'http://much.url.com/root', 77 | method='post', 78 | data=json.dumps(self.data), 79 | headers=self.response.headers 80 | ) 81 | 82 | def test_get_content_type_for_resource_default(self): 83 | content_type = self.resource._get_content_type_for_resource( 84 | self.resource) 85 | self.assertEqual(content_type, 'application/json; profile=/schema') 86 | 87 | def test_get_content_type_for_resource_with_response(self): 88 | content_type = self.resource2._get_content_type_for_resource( 89 | self.resource2) 90 | self.assertEqual(content_type, self.response.headers['content-type']) 91 | 92 | def test_expand_uri_returns_simple_link(self): 93 | uri = self.resource.expand_uri('create') 94 | self.assertEqual(uri, '/root') 95 | 96 | def test_expand_uri_returns_interpolated_link(self): 97 | uri = self.resource.expand_uri('related', related='foo') 98 | self.assertEqual(uri, '/root/slug/foo') 99 | 100 | def test_has_rel_finds_existent_link(self): 101 | self.assertTrue(self.resource.has_rel('create')) 102 | 103 | def test_has_rel_detects_unexistent_link(self): 104 | self.assertFalse(self.resource.has_rel('foo_bar')) 105 | 106 | def test_delegates_request_to_session(self): 107 | self.resource.rel('create', data=self.resource) 108 | self.request.assert_called_with( 109 | 'http://much.url.com/root', 110 | method='post', 111 | data=json.dumps(self.data), 112 | headers={'content-type': 'application/json; profile=/schema'} 113 | ) 114 | 115 | def test_accepts_extra_parameters(self): 116 | self.resource.rel('create', data=self.resource, timeout=333) 117 | self.request.assert_called_with( 118 | 'http://much.url.com/root', 119 | method='post', 120 | data=json.dumps(self.data), 121 | headers={'content-type': 'application/json; profile=/schema'}, 122 | timeout=333 123 | ) 124 | 125 | def test_accepts_dict(self): 126 | resource = {'name': 'Testing'} 127 | self.resource.rel('create', data=resource) 128 | self.request.assert_called_with( 129 | 'http://much.url.com/root', 130 | method='post', 131 | data=json.dumps(resource), 132 | headers={'content-type': 'application/json'} 133 | ) 134 | 135 | def test_uses_get_as_default_verb(self): 136 | self.resource.rel('list') 137 | self.request.assert_called_with( 138 | 'http://much.url.com/root', method='get' 139 | ) 140 | 141 | def test_expands_uri_using_resource_data(self): 142 | self.resource.rel('item') 143 | self.request.assert_called_with( 144 | 'http://much.url.com/root/123', method='get' 145 | ) 146 | 147 | def test_expands_uri_using_params(self): 148 | self.resource.rel('item', params={'id': 345}) 149 | self.request.assert_called_with( 150 | 'http://much.url.com/root/345', method='get', params={} 151 | ) 152 | 153 | def test_expands_uri_using_resource_data_and_params(self): 154 | self.resource.rel('related', params={'related': 'something'}) 155 | self.request.assert_called_with( 156 | 'http://much.url.com/root/slug/something', method='get', params={} 157 | ) 158 | 159 | def test_extracts_expanded_params_from_the_uri(self): 160 | self.resource.rel('item', params={'id': 345, 'fields': 'slug'}) 161 | self.request.assert_called_with( 162 | 'http://much.url.com/root/345', 163 | method='get', params={'fields': 'slug'} 164 | ) 165 | -------------------------------------------------------------------------------- /pluct/resource.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import uritemplate 4 | import jsonpointer 5 | import json 6 | 7 | try: 8 | from urllib.parse import urlparse, urljoin 9 | except ImportError: 10 | from urlparse import urlparse, urljoin 11 | 12 | from jsonschema import SchemaError, validate, ValidationError, RefResolver 13 | 14 | from pluct import datastructures 15 | from pluct.schema import Schema 16 | 17 | 18 | class Resource(object): 19 | 20 | def __init__(self, *args, **kwargs): 21 | raise NotImplementedError( 22 | 'Use subclasses or Resource.from_data to initialize resources') 23 | 24 | def init(self, url, data=None, schema=None, session=None, response=None, 25 | headers=None): 26 | self.url = url 27 | self.data = data or self.default_data() 28 | self.schema = schema 29 | self.session = session 30 | self.response = response 31 | self.headers = headers 32 | 33 | def session_request_json(self, url): 34 | return self.session.request(url).json() 35 | 36 | def is_valid(self): 37 | handlers = {'https': self.session_request_json, 38 | 'http': self.session_request_json} 39 | resolver = RefResolver.from_schema(self.schema.raw_schema, 40 | handlers=handlers) 41 | try: 42 | validate(self.data, self.schema.raw_schema, resolver=resolver) 43 | except (SchemaError, ValidationError): 44 | return False 45 | return True 46 | 47 | def rel(self, name, **kwargs): 48 | link = self.schema.get_link(name) 49 | method = link.get('method', 'get').lower() 50 | href = link.get('href', '') 51 | 52 | params = kwargs.get('params', {}) 53 | 54 | variables = uritemplate.variables(href) 55 | 56 | uri = self.expand_uri(name, **params) 57 | 58 | if not urlparse(uri).netloc: 59 | uri = urljoin(self.url, uri) 60 | if 'params' in kwargs: 61 | unused_params = { 62 | k: v for k, v in list(params.items()) if k not in variables} 63 | kwargs['params'] = unused_params 64 | 65 | if "data" in kwargs: 66 | resource = kwargs.get("data") 67 | headers = kwargs.get('headers', {}) 68 | 69 | if isinstance(resource, Resource): 70 | kwargs["data"] = json.dumps(resource.data) 71 | headers.setdefault( 72 | 'content-type', 73 | self._get_content_type_for_resource(resource)) 74 | 75 | elif isinstance(resource, dict): 76 | kwargs["data"] = json.dumps(resource) 77 | headers.setdefault('content-type', 'application/json') 78 | 79 | kwargs['headers'] = headers 80 | 81 | return self.session.resource(uri, method=method, **kwargs) 82 | 83 | def _get_content_type_for_resource(self, resource): 84 | response = resource.response 85 | if (response and response.headers and 86 | response.headers.get('content-type')): 87 | return resource.response.headers['content-type'] 88 | else: 89 | return 'application/json; profile=' + resource.schema.url 90 | 91 | def has_rel(self, name): 92 | link = self.schema.get_link(name) 93 | return bool(link) 94 | 95 | def expand_uri(self, name, **kwargs): 96 | link = self.schema.get_link(name) 97 | href = link.get('href', '') 98 | 99 | context = dict(self.data, **kwargs) 100 | 101 | return uritemplate.expand(href, context) 102 | 103 | @classmethod 104 | def from_data(cls, url, data=None, schema=None, session=None, 105 | response=None, headers=None): 106 | if isinstance(data, (list, tuple)): 107 | klass = ArrayResource 108 | elif isinstance(data, dict): 109 | klass = ObjectResource 110 | else: 111 | return data 112 | 113 | return klass( 114 | url, data=data, schema=schema, session=session, response=response, 115 | headers=headers) 116 | 117 | @classmethod 118 | def from_response(cls, response, session, schema): 119 | try: 120 | data = response.json() 121 | except ValueError: 122 | data = {} 123 | return cls.from_data( 124 | url=response.url, 125 | data=data, 126 | session=session, 127 | schema=schema, 128 | response=response, 129 | headers=response.headers 130 | ) 131 | 132 | def resolve_pointer(self, *args, **kwargs): 133 | return jsonpointer.resolve_pointer(self.data, *args, **kwargs) 134 | 135 | def __getitem__(self, item): 136 | schema = self.item_schema(item) 137 | return self.from_data(self.url, 138 | data=self.data[item], 139 | schema=schema, 140 | session=self.session) 141 | 142 | 143 | class ObjectResource(datastructures.IterableUserDict, Resource, dict): 144 | 145 | SCHEMA_PREFIX = 'properties' 146 | 147 | def __init__(self, *args, **kwargs): 148 | self.init(*args, **kwargs) 149 | 150 | def default_data(self): 151 | return {} 152 | 153 | def iterate_items(self): 154 | return iter(self.data.items()) 155 | 156 | def item_schema(self, key): 157 | href = '#/{0}/{1}'.format(self.SCHEMA_PREFIX, key) 158 | return Schema(href, raw_schema=self.schema, session=self.session) 159 | 160 | def __ne__(self, other): 161 | return self.data != other 162 | 163 | def __eq__(self, other): 164 | return self.data == other 165 | 166 | def __getitem__(self, item): 167 | return Resource.__getitem__(self, item) 168 | 169 | def __repr__(self): 170 | return "" % self.data 171 | 172 | 173 | class ArrayResource(datastructures.UserList, Resource, list): 174 | 175 | SCHEMA_PREFIX = 'items' 176 | 177 | def __init__(self, *args, **kwargs): 178 | self.init(*args, **kwargs) 179 | 180 | def default_data(self): 181 | return [] 182 | 183 | def iterate_items(self): 184 | return enumerate(self.data) 185 | 186 | def item_schema(self, key): 187 | href = '#/{0}'.format(self.SCHEMA_PREFIX) 188 | return Schema(href, raw_schema=self.schema, session=self.session) 189 | 190 | def __getitem__(self, item): 191 | return Resource.__getitem__(self, item) 192 | 193 | def __repr__(self): 194 | return "" % self.data 195 | -------------------------------------------------------------------------------- /pluct/tests/test_resource.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from unittest import TestCase 4 | from jsonschema import RefResolver 5 | from mock import patch, Mock 6 | 7 | from pluct.resource import Resource, ObjectResource, ArrayResource 8 | from pluct.session import Session 9 | from pluct.schema import Schema 10 | 11 | 12 | class BaseTestCase(TestCase): 13 | 14 | def setUp(self): 15 | self.session = Session() 16 | 17 | def resource_from_data(self, url, data=None, schema=None, response=None): 18 | resource = Resource.from_data( 19 | url=url, data=data, schema=schema, session=self.session, 20 | response=response) 21 | return resource 22 | 23 | def resource_from_response(self, response, schema): 24 | resource = Resource.from_response( 25 | response, session=self.session, schema=schema) 26 | 27 | return resource 28 | 29 | 30 | class ResourceInitTestCase(BaseTestCase): 31 | 32 | def test_blocks_init_of_base_class(self): 33 | with self.assertRaises(NotImplementedError): 34 | Resource() 35 | 36 | 37 | class ResourceTestCase(BaseTestCase): 38 | 39 | def setUp(self): 40 | super(ResourceTestCase, self).setUp() 41 | 42 | self.data = { 43 | "name": "repos", 44 | "platform": "js", 45 | } 46 | self.raw_schema = { 47 | 'type': "object", 48 | 'required': ["platform"], 49 | 'title': "some title", 50 | 'properties': { 51 | 'name': {'type': 'string'}, 52 | 'platform': {'type': 'string'} 53 | }, 54 | 'links': [ 55 | { 56 | "href": "/apps/{name}/log", 57 | "method": "GET", 58 | "rel": "log" 59 | }, 60 | { 61 | "href": "/apps/{name}/env", 62 | "method": "GET", 63 | "rel": "env" 64 | } 65 | ]} 66 | self.schema = Schema( 67 | href="url.com", raw_schema=self.raw_schema, session=self.session) 68 | 69 | self.url = "http://app.com/content" 70 | 71 | self.result = self.resource_from_data( 72 | url=self.url, data=self.data, schema=self.schema) 73 | 74 | def test_get_should_returns_a_resource(self): 75 | self.assertIsInstance(self.result, Resource) 76 | 77 | def test_missing_attribute(self): 78 | with self.assertRaises(AttributeError): 79 | self.result.not_found 80 | 81 | def test_str(self): 82 | expected = "" % self.data 83 | self.assertEqual(expected, str(self.result)) 84 | 85 | def test_data(self): 86 | self.assertEqual(self.data, self.result.data) 87 | 88 | def test_response(self): 89 | self.assertEqual(self.result.response, None) 90 | 91 | def test_iter(self): 92 | iterated = [i for i in self.result] 93 | self.assertEqual(iterated, list(self.data.keys())) 94 | 95 | def test_schema(self): 96 | self.assertEqual(self.schema.url, self.result.schema.url) 97 | 98 | def test_is_valid_schema_error(self): 99 | old = self.result.schema['required'] 100 | try: 101 | self.result.schema['required'] = ["ble"] 102 | self.assertFalse(self.result.is_valid()) 103 | finally: 104 | self.result.schema.required = old 105 | 106 | def test_is_valid_invalid(self): 107 | data = { 108 | 'doestnotexists': 'repos', 109 | } 110 | result = self.resource_from_data('/url', data=data, schema=self.schema) 111 | self.assertFalse(result.is_valid()) 112 | 113 | def test_is_valid(self): 114 | self.assertTrue(self.result.is_valid()) 115 | 116 | def test_resolve_pointer(self): 117 | self.assertEqual(self.result.resolve_pointer("/name"), "repos") 118 | 119 | def test_resource_should_be_instance_of_dict(self): 120 | self.assertIsInstance(self.result, dict) 121 | 122 | def test_resource_should_be_instance_of_schema(self): 123 | self.assertIsInstance(self.result, Resource) 124 | 125 | def test_is_valid_call_validate_with_resolver_instance(self): 126 | with patch('pluct.resources.validate') as mock_validate: 127 | self.result.is_valid() 128 | self.assertTrue(mock_validate.called) 129 | 130 | resolver = mock_validate.call_args[-1]['resolver'] 131 | self.assertIsInstance(resolver, RefResolver) 132 | 133 | http_handler, https_handler = list(resolver.handlers.values()) 134 | self.assertEqual(http_handler, self.result.session_request_json) 135 | self.assertEqual(https_handler, self.result.session_request_json) 136 | 137 | def test_session_request_json(self): 138 | mock_request_return = Mock() 139 | with patch.object(self.result.session, 'request') as mock_request: 140 | mock_request.return_value = mock_request_return 141 | 142 | self.result.session_request_json(self.url) 143 | self.assertTrue(mock_request.called) 144 | self.assertTrue(mock_request_return.json.called) 145 | 146 | 147 | class ParseResourceTestCase(BaseTestCase): 148 | 149 | def setUp(self): 150 | super(ParseResourceTestCase, self).setUp() 151 | 152 | self.item_schema = { 153 | 'type': 'object', 154 | 'properties': { 155 | 'id': { 156 | 'type': 'integer' 157 | } 158 | }, 159 | 'links': [{ 160 | "href": "http://localhost/foos/{id}/", 161 | "method": "GET", 162 | "rel": "item", 163 | }] 164 | } 165 | 166 | self.raw_schema = { 167 | 'title': "title", 168 | 'type': "object", 169 | 170 | 'properties': { 171 | 'objects': { 172 | 'type': 'array', 173 | 'items': self.item_schema, 174 | }, 175 | 'values': { 176 | 'type': 'array' 177 | } 178 | } 179 | } 180 | self.schema = Schema( 181 | href="url.com", raw_schema=self.raw_schema, session=self.session) 182 | 183 | def test_wraps_array_objects_as_resources(self): 184 | data = { 185 | 'objects': [ 186 | {'id': 111} 187 | ] 188 | } 189 | app = self.resource_from_data( 190 | url="appurl.com", data=data, schema=self.schema) 191 | item = app['objects'][0] 192 | self.assertIsInstance(item, ObjectResource) 193 | self.assertEqual(item.data['id'], 111) 194 | self.assertEqual(item.schema, self.item_schema) 195 | 196 | def test_eq_operators(self): 197 | data = { 198 | 'objects': [ 199 | {'id': 111} 200 | ] 201 | } 202 | app = self.resource_from_data( 203 | url="appurl.com", data=data, schema=self.schema) 204 | 205 | self.assertDictEqual(data, app) 206 | 207 | def test_wraps_array_objects_as_resources_even_without_items_key(self): 208 | data = { 209 | 'values': [ 210 | {'id': 1} 211 | ] 212 | } 213 | resource = self.resource_from_data( 214 | url="appurl.com", data=data, schema=self.schema) 215 | 216 | item = resource['values'][0] 217 | self.assertIsInstance(item, Resource) 218 | self.assertEqual(item.data['id'], 1) 219 | 220 | @patch("requests.get") 221 | def test_doesnt_wrap_non_objects_as_resources(self, get): 222 | data = { 223 | 'values': [ 224 | 1, 225 | 'string', 226 | ['array'] 227 | ] 228 | } 229 | resource_list = self.resource_from_data( 230 | url="appurl.com", data=data, schema=self.schema) 231 | values = resource_list['values'] 232 | 233 | self.assertEqual(values, data['values']) 234 | 235 | 236 | class FromResponseTestCase(BaseTestCase): 237 | 238 | def setUp(self): 239 | super(FromResponseTestCase, self).setUp() 240 | 241 | self._response = Mock() 242 | self._response.url = 'http://example.com' 243 | 244 | content_type = 'application/json; profile=http://example.com/schema' 245 | self._response.headers = { 246 | 'content-type': content_type 247 | } 248 | self.schema = Schema('/', raw_schema={}, session=self.session) 249 | 250 | def test_should_return_resource_from_response(self): 251 | self._response.json.return_value = {} 252 | returned_resource = self.resource_from_response( 253 | self._response, schema=self.schema) 254 | self.assertEqual(returned_resource.url, 'http://example.com') 255 | self.assertEqual(returned_resource.data, {}) 256 | 257 | def test_should_return_resource_from_response_with_no_json_data(self): 258 | self._response.json = Mock(side_effect=ValueError()) 259 | returned_resource = self.resource_from_response( 260 | self._response, schema=self.schema) 261 | self.assertEqual(returned_resource.url, 'http://example.com') 262 | self.assertEqual(returned_resource.data, {}) 263 | 264 | def test_should_return_resource_from_response_with_response_data(self): 265 | self._response.json.return_value = {} 266 | returned_resource = self.resource_from_response( 267 | self._response, schema=self.schema) 268 | self.assertEqual(returned_resource.response, self._response) 269 | self.assertEqual(returned_resource.response.headers, 270 | self._response.headers) 271 | 272 | def test_resource_with_an_array_without_schema(self): 273 | data = { 274 | 'units': [ 275 | {'name': 'someunit'} 276 | ], 277 | 'name': 'registry', 278 | } 279 | s = Schema( 280 | href='url', 281 | raw_schema={ 282 | 'title': 'app schema', 283 | 'type': 'object', 284 | 'required': ['name'], 285 | 'properties': {'name': {'type': 'string'}} 286 | }, 287 | session=self.session) 288 | response = self.resource_from_data("url", data, s) 289 | self.assertDictEqual(data, response.data) 290 | 291 | 292 | class ResourceFromDataTestCase(BaseTestCase): 293 | 294 | def test_should_create_array_resource_from_list(self): 295 | data = [] 296 | resource = self.resource_from_data('/', data=data) 297 | self.assertIsInstance(resource, ArrayResource) 298 | self.assertEqual(resource.url, '/') 299 | self.assertEqual(resource.data, data) 300 | expected = "" % resource.data 301 | self.assertEqual(expected, str(resource)) 302 | 303 | def test_should_create_object_resource_from_dict(self): 304 | data = {} 305 | resource = self.resource_from_data('/', data=data) 306 | self.assertIsInstance(resource, ObjectResource) 307 | self.assertEqual(resource.url, '/') 308 | self.assertEqual(resource.data, data) 309 | -------------------------------------------------------------------------------- /pluct/tests/test_schema.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from copy import deepcopy 4 | from unittest import TestCase 5 | 6 | from mock import Mock, patch 7 | from pluct.schema import get_profile_from_header, LazySchema, Schema 8 | from pluct.session import Session 9 | 10 | 11 | SCHEMA = { 12 | 'title': 'app schema', 13 | 'properties': { 14 | 'name': {'type': 'string'}, 15 | 'platform': {'type': 'string'}, 16 | 'pointer': {'$ref': '#/pointer'}, 17 | 'pointers': { 18 | 'items': { 19 | 'oneOf': [ 20 | {'$ref': '#/pointer'}, 21 | {'$ref': '#/pointer2'}, 22 | ] 23 | } 24 | }, 25 | 'repointer': {'$ref': '#/repointer'}, 26 | 'external': {'$ref': 'http://example.com/schema#/pointer'}, 27 | 'external2': {'$ref': 'http://example.com/schema#/pointer'}, 28 | 'self': {'$ref': '#'}, 29 | }, 30 | 'links': [ 31 | { 32 | 'rel': 'create', 33 | 'href': '/api/content', 34 | } 35 | ], 36 | 'required': ['platform', 'name'], 37 | 'pointer': { 38 | 'type': 'string', 39 | 'description': 'local-pointer-str', 40 | }, 41 | 'pointer2': { 42 | 'type': 'integer', 43 | 'description': 'local-pointer-int', 44 | }, 45 | 'repointer': { 46 | '$ref': '#/pointer', 47 | }, 48 | } 49 | 50 | 51 | class BaseLazySchemaTestCase(TestCase): 52 | 53 | HREF = '/schema' 54 | RAW_SCHEMA = SCHEMA 55 | 56 | def run(self, *args, **kwargs): 57 | self.session = Session() 58 | self.schema = LazySchema(self.HREF, session=self.session) 59 | 60 | with patch.object(self.session, 'request') as self.request: 61 | self.response = Mock() 62 | self.response.json.return_value = deepcopy(self.RAW_SCHEMA) 63 | self.request.return_value = self.response 64 | 65 | return super(BaseLazySchemaTestCase, self).run(*args, **kwargs) 66 | 67 | 68 | class SchemaTestCase(TestCase): 69 | 70 | def setUp(self): 71 | self.session = Session() 72 | 73 | self.url = 'http://app.com/myschema' 74 | self.schema = Schema( 75 | self.url, raw_schema=deepcopy(SCHEMA), session=self.session) 76 | 77 | def test_schema_required(self): 78 | self.assertListEqual(SCHEMA['required'], self.schema['required']) 79 | 80 | def test_schema_title(self): 81 | self.assertEqual(SCHEMA['title'], self.schema['title']) 82 | 83 | def test_schema_properties(self): 84 | self.assertEqual( 85 | SCHEMA['properties']['name'], 86 | self.schema.data['properties']['name']) 87 | 88 | def test_schema_links(self): 89 | self.assertListEqual(SCHEMA['links'], self.schema['links']) 90 | 91 | def test_schema_url(self): 92 | self.assertEqual(self.url, self.schema.url) 93 | 94 | def test_session(self): 95 | self.assertIs(self.session, self.schema.session) 96 | 97 | 98 | class LazySchemaTestCase(BaseLazySchemaTestCase): 99 | 100 | def test_loads_schema_once_accessing_data(self): 101 | self.assertEqual(self.schema.data['title'], SCHEMA['title']) 102 | self.assertEqual(self.schema.data['title'], SCHEMA['title']) 103 | 104 | self.request.assert_called_once_with('/schema') 105 | 106 | def test_loads_schema_once_accessing_raw_schema(self): 107 | self.assertEqual(self.schema.raw_schema['title'], SCHEMA['title']) 108 | self.assertEqual(self.schema.raw_schema['title'], SCHEMA['title']) 109 | 110 | self.request.assert_called_once_with('/schema') 111 | 112 | def test_url(self): 113 | self.assertEqual(self.schema.url, '/schema') 114 | 115 | def test_session(self): 116 | self.assertIs(self.session, self.schema.session) 117 | 118 | 119 | class CircularSchemaTestCase(TestCase): 120 | 121 | def test_uses_original_ref_on_representation(self): 122 | raw_schema = { 123 | 'properties': { 124 | 'inner': {'$ref': '/schema'}, 125 | } 126 | } 127 | session = Session() 128 | schema = Schema('/schema', raw_schema=raw_schema, session=session) 129 | self.assertEqual(repr(schema), repr(raw_schema)) 130 | 131 | 132 | class LazyCircularSchemaTestCase(BaseLazySchemaTestCase): 133 | 134 | HREF = '/schema#' 135 | RAW_SCHEMA = { 136 | 'properties': { 137 | 'inner': {'$ref': HREF}, 138 | } 139 | } 140 | 141 | def test_uses_original_ref_on_representation(self): 142 | self.assertEqual(repr(self.schema), repr({'$ref': self.HREF})) 143 | 144 | 145 | class GetProfileFromHeaderTestCase(TestCase): 146 | 147 | SCHEMA_URL = 'http://a.com/schema' 148 | 149 | def test_return_none_for_missing_content_type(self): 150 | headers = {} 151 | url = get_profile_from_header(headers) 152 | self.assertIs(url, None) 153 | 154 | def test_return_none_for_missing_profile(self): 155 | headers = {'content-type': 'application/json'} 156 | url = get_profile_from_header(headers) 157 | self.assertIs(url, None) 158 | 159 | def test_should_read_schema_from_profile(self): 160 | headers = { 161 | 'content-type': ( 162 | 'application/json; charset=utf-8; profile=%s' 163 | % self.SCHEMA_URL) 164 | } 165 | url = get_profile_from_header(headers) 166 | self.assertEqual(url, self.SCHEMA_URL) 167 | 168 | def test_should_parse_schema_from_quoted_profile(self): 169 | headers = { 170 | 'content-type': ( 171 | 'application/json; charset=utf-8; profile="%s"' 172 | % self.SCHEMA_URL) 173 | } 174 | url = get_profile_from_header(headers) 175 | self.assertEqual(url, self.SCHEMA_URL) 176 | 177 | 178 | class GetLinkTestCase(TestCase): 179 | 180 | def setUp(self): 181 | self.url = '/' 182 | self.session = Session() 183 | self.schema = Schema( 184 | self.url, raw_schema=deepcopy(SCHEMA), session=self.session) 185 | 186 | def test_returns_link_by_rel(self): 187 | link = self.schema.get_link('create') 188 | self.assertEqual(link, SCHEMA['links'][0]) 189 | 190 | def test_returns_none_for_missing_link(self): 191 | link = self.schema.get_link('missing') 192 | self.assertIs(link, None) 193 | 194 | 195 | class SchemaPointerTestCase(TestCase): 196 | 197 | def setUp(self): 198 | self.session = Session() 199 | self.url = 'http://example.org/schema' 200 | self.pointer = '' 201 | self.href = '#'.join((self.url, self.pointer)) 202 | 203 | def create_schema(self, href): 204 | schema = deepcopy(SCHEMA) 205 | return Schema( 206 | href, raw_schema=schema, session=self.session) 207 | 208 | def assertValidRefs(self, schema, url=None, pointer=None): 209 | url = url or self.url 210 | pointer = pointer or self.pointer 211 | href = '#'.join((url, pointer)) 212 | 213 | self.assertEqual(schema.url, url) 214 | self.assertEqual(schema.pointer, pointer) 215 | self.assertEqual(schema.href, href) 216 | 217 | def test_splits_url_and_pointer_on_init(self): 218 | schema = self.create_schema(self.href) 219 | self.assertValidRefs(schema) 220 | 221 | def test_fixes_href_without_pointer(self): 222 | schema = self.create_schema(self.url) 223 | self.assertValidRefs(schema) 224 | 225 | def test_fixes_empty_pointer(self): 226 | schema = self.create_schema(self.url + '#') 227 | self.assertValidRefs(schema) 228 | 229 | def test_accepts_pointer(self): 230 | pointer = '/properties/name' 231 | schema = self.create_schema(self.href + pointer) 232 | self.assertValidRefs(schema, pointer=pointer) 233 | 234 | def test_returns_root_schema_data_from_empty_pointer(self): 235 | schema = self.create_schema(self.href) 236 | self.assertEqual(schema['title'], SCHEMA['title']) 237 | 238 | def test_returns_schema_data_from_pointer(self): 239 | pointer = '/properties/name' 240 | schema = self.create_schema(self.href + pointer) 241 | self.assertEqual(schema, SCHEMA['properties']['name']) 242 | 243 | def test_resolves_local_pointer_on_objects(self): 244 | schema = self.create_schema(self.href) 245 | self.assertEqual(schema['properties']['pointer'], SCHEMA['pointer']) 246 | 247 | def test_resolves_local_pointer_on_arrays(self): 248 | schema = self.create_schema(self.href) 249 | pointers = schema['properties']['pointers']['items']['oneOf'] 250 | expected = [SCHEMA['pointer'], SCHEMA['pointer2']] 251 | self.assertEqual(pointers, expected) 252 | 253 | def test_keeps_context_between_refs(self): 254 | schema = self.create_schema(self.href) 255 | self.assertEqual(schema['properties']['repointer'], SCHEMA['pointer']) 256 | 257 | def test_resolves_external_ref_with_lazy_schema(self): 258 | schema = self.create_schema(self.href) 259 | external = schema['properties']['external'] 260 | self.assertIsInstance(external, LazySchema) 261 | self.assertEqual( 262 | external.href, SCHEMA['properties']['external']['$ref']) 263 | 264 | def test_resolves_self_reference(self): 265 | schema = self.create_schema(self.href) 266 | self_ref = schema['properties']['self'] 267 | self.assertEqual( 268 | self_ref['title'], SCHEMA['title']) 269 | 270 | def test_is_instance_of_dict(self): 271 | schema = self.create_schema(self.href) 272 | self.assertIsInstance(schema, dict) 273 | 274 | def test_is_instance_of_schema(self): 275 | schema = self.create_schema(self.href) 276 | self.assertIsInstance(schema, Schema) 277 | 278 | 279 | class LazySchemaPointerTestCase(BaseLazySchemaTestCase): 280 | 281 | HREF = '/schema#/properties/name' 282 | 283 | def test_applies_pointer_to_loaded_schema(self): 284 | self.assertEqual(self.schema, SCHEMA['properties']['name']) 285 | 286 | 287 | class SchemaStoreMixinTestCase(object): 288 | 289 | SCHEMA_URL = 'http://a.com/schema' 290 | 291 | def setUp(self): 292 | self.session = Session() 293 | 294 | def test_reuses_schema_for_same_href(self): 295 | schema1 = self.create_schema(self.SCHEMA_URL) 296 | schema2 = self.create_schema(self.SCHEMA_URL) 297 | self.assertIs(schema1, schema2) 298 | 299 | def test_doesnt_reuse_schema_with_empty_url(self): 300 | schema1 = self.create_schema('') 301 | schema2 = self.create_schema('') 302 | self.assertIs(schema1, schema2) 303 | 304 | def test_reuses_schema_for_different_pointers(self): 305 | schema1 = self.create_schema(self.SCHEMA_URL) 306 | schema2 = self.create_schema( 307 | self.SCHEMA_URL + '#/pointer') 308 | self.assertIs(schema1, schema2) 309 | 310 | def test_does_not_reuse_schema_on_different_sessions(self): 311 | other_session = Session() 312 | 313 | schema1 = self.create_schema(self.SCHEMA_URL) 314 | schema2 = self.create_schema( 315 | self.SCHEMA_URL, session=other_session) 316 | 317 | self.assertIsNot(schema1, schema2) 318 | 319 | 320 | class SchemaStoreTestCase(SchemaStoreMixinTestCase, TestCase): 321 | 322 | def create_schema(self, url, session=None): 323 | if session is None: 324 | session = self.session 325 | return Schema(self.SCHEMA_URL, raw_schema={}, session=session) 326 | 327 | 328 | class LazySchemaStoreTestCase(SchemaStoreMixinTestCase, TestCase): 329 | 330 | def create_schema(self, url, session=None): 331 | if session is None: 332 | session = self.session 333 | return LazySchema(self.SCHEMA_URL, session=session) 334 | --------------------------------------------------------------------------------