Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 184 additions & 123 deletions README.md

Large diffs are not rendered by default.

143 changes: 127 additions & 16 deletions src/jsonapi/apis.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,119 @@
from __future__ import absolute_import, unicode_literals

import requests
import six

from .auth import BearerAuthentication
from .compat import JSONDecodeError
from .exceptions import JsonApiException
from .resources import Resource


class JsonApi(object):
""" Inteface for a new {json:api} API.
type_ = type # alias to avoid naming conflicts


class _JsonApiMetaclass(type_):
""" Simple metaclass that overwrites the `registry` class variable on
`JsonApi` subclasses. This is so that if multiple API connection types
are defined on the same application, one won't "contaminate" the other.

eg

>>> class API_A(jsonapi.JsonApi):
... HOST = "..."

>>> @API_A.register
... class ResourceA(jsonapi.Resource):
... TYPE = "resource_a"

>>> class API_AA(API_A):
... HOST = "..."

>>> @API_AA.register
... class ResourceAA(jsonapi.Resource):
... TYPE = "resource_aa"

>>> class API_B(jsonapi.JsonApi):
... HOST = "..."

>>> @API_B.register
... class ResourceB(jsonapi.Resource):
... TYPE = "resource_b"

>>> API_A.registry
<<< [ResourceA]

>>> API_AA.registry
<<< [ResourceA, ResourceAA]

>>> API_B.registry
<<< [ResourceB]
"""

def __new__(cls, *args, **kwargs):
result = super(_JsonApiMetaclass, cls).__new__(cls, *args, **kwargs)

# Use a copy, not reference to parent's registry
result.registry = list(getattr(result, 'registry', []))

return result


class JsonApi(six.with_metaclass(_JsonApiMetaclass, object)):
""" Inteface for a new {json:api} API connection. Initialization
parameters:

- host: The URL of the API
- headers: A dict of HTTP headers that will be included in every
request to the server
- auth: The authentication method. Can either be:

1. A callable, whose return value should be a dictionary which will
be merged with the headers of all HTTP request sent to the API
2. A string, in which case the 'Authorization' header will be
`Bearer <auth>`

>>> _api = jsonapi.JsonApi(host=..., auth=...)
>>> class API(jsonapi.JsonApi):
... HOST = "..."
>>> api = API(host=..., auth=...)

The arguments are optional and can be edited later with `.setup()`

>>> _api = jsonapi.JsonApi()
>>> _api.setup(host=..., auth=...)
>>> api = API()
>>> api.setup(host=..., auth=...)

All Resource classes that use this API should be registered to this API
instance:
class:

>>> @_api.register
>>> @API.register
... class Foo(jsonapi.Resource):
... TYPE = "foos"
"""

HOST = None

def __init__(self, **kwargs):
self.registry = {}
""" Create a new API connection instance. It will use the class's
registry to build the instance's registries in order to be able to
lookup API resource classes from their class names or API types.

Delegates configuration to `setup` method.
"""

self.type_registry = {}
self.class_registry = {}

for base_class in self.__class__.registry:
# Dynamically create a subclass adding 'self' (the API connection
# instance) as a class variable to it
child_class = type_(base_class.__name__,
(base_class, ),
{'API': self})
# Lookup the new class by it's name or its TYPE class attribute
self.type_registry[base_class.TYPE] = child_class
self.class_registry[base_class.__name__] = child_class

self.host = self.HOST
self.headers = {}
self.setup(**kwargs)

Expand All @@ -52,12 +130,44 @@ def setup(self, host=None, auth=None, headers=None):
if headers is not None:
self.headers = headers

def register(self, klass):
if klass.TYPE is not None:
self.registry[klass.TYPE] = klass
klass.API = self
@classmethod
def register(cls, klass):
""" Register a API resource type with this API connection *type* (since
this is a classmethod). When a new API connection *instance* is
created (see `__init__`), it will use this to build its own
registry in order to identify class names or API types with the
relevant API resource classes.
"""

cls.registry.append(klass)
return klass

def __getattr__(self, attr):
""" Access a registered API resource class. A class name or API
resource type can be used.

>>> class FooApi(JsonApi):
... HOST = "https://api.foo.com"

>>> @FooApi.register
... class Foo(Resource):
... TYPE = "foos"

>>> foo_api = FooApi()

>>> foo_api.Foo.list()
>>> # or
>>> foo_api.foos.list()
"""

try:
return self.class_registry[attr]
except KeyError:
try:
return self.type_registry[attr]
except KeyError:
raise AttributeError(attr)

# Required args
def request(self, method, url,
# Not passed to requests, used to determine Content-Type
Expand Down Expand Up @@ -125,12 +235,13 @@ def new(self, data=None, type=None, **kwargs):
data = data['data']
return self.new(**data)
else:
if type in self.registry:
klass = self.registry[type]
if type in self.type_registry:
klass = self.type_registry[type]
else:
# Lets make a new class on the fly
class klass(Resource):
API = self
klass = type_(type.capitalize(),
(Resource, ),
{'API': self, 'TYPE': type})
return klass(**kwargs)

def as_resource(self, data):
Expand Down
5 changes: 3 additions & 2 deletions src/jsonapi/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def _overwrite(self,

# Relationships
self.relationships, self.related = {}, {}
for key, value in deepcopy(relationships).items():
for key, value in relationships.items():
self._set_relationship(key, value)
relationship = self.relationships[key]
if is_null(relationship) or has_data(relationship):
Expand Down Expand Up @@ -139,6 +139,7 @@ def _set_relationship(self, key, value):
elif is_resource(value):
self.relationships[key] = value.as_relationship()
else:
value = deepcopy(value)
if not is_null(value) and is_resource_identifier(value):
value = {'data': value}
if is_null(value) or has_data(value) or has_links(value):
Expand Down Expand Up @@ -443,7 +444,7 @@ def _post_save(self, response_body):

data = response_body['data']

related = deepcopy(self.related)
related = dict(self.related)
for relationship_name, related_instance in list(related.items()):
if is_collection(related_instance):
continue # Plural relationship
Expand Down
48 changes: 29 additions & 19 deletions src/tests/test_apis.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,53 @@

from .constants import host

_api = jsonapi.JsonApi()


def reset_setup():
_api.setup(host, "test_api_key")
assert _api.make_auth_headers() == {'Authorization': "Bearer test_api_key"}
assert _api.host == host
class ATestApi(jsonapi.JsonApi):
HOST = host


@_api.register
@ATestApi.register
class GlobalTest(jsonapi.Resource):
TYPE = "globaltests"


def test_class_registry():
assert _api.registry['globaltests'] is GlobalTest
test_api = ATestApi()


def reset_setup():
test_api.setup(host=host, auth="test_api_key")
assert (test_api.make_auth_headers() ==
{'Authorization': "Bearer test_api_key"})
assert test_api.host == host


def test_registries():
assert issubclass(test_api.type_registry['globaltests'], GlobalTest)
assert issubclass(test_api.class_registry['GlobalTest'], GlobalTest)


def test_setup_plaintext():
_api.setup("http://some.host", "another_key")
assert _api.make_auth_headers() == {'Authorization': "Bearer another_key"}
assert _api.host == "http://some.host"
test_api.setup(host="http://some.host", auth="another_key")
assert (test_api.make_auth_headers() ==
{'Authorization': "Bearer another_key"})
assert test_api.host == "http://some.host"
reset_setup()


def test_setup_ulf():
_api.setup(host, ULFAuthentication('public'))
assert _api.make_auth_headers() == {'Authorization': "ULF public"}
test_api.setup(auth=ULFAuthentication('public'))
assert test_api.make_auth_headers() == {'Authorization': "ULF public"}

_api.setup(host, ULFAuthentication('public', 'secret'))
assert _api.make_auth_headers() == {'Authorization': "ULF public:secret"}
test_api.setup(auth=ULFAuthentication('public', 'secret'))
assert (test_api.make_auth_headers() ==
{'Authorization': "ULF public:secret"})

reset_setup()


def test_setup_any_callable():
_api.setup("http://some.host2", lambda: {'Authorization': "Another key2"})
assert _api.make_auth_headers() == {'Authorization': "Another key2"}
assert _api.host == "http://some.host2"
test_api.setup(host="http://some.host2",
auth=lambda: {'Authorization': "Another key2"})
assert test_api.make_auth_headers() == {'Authorization': "Another key2"}
assert test_api.host == "http://some.host2"
reset_setup()
23 changes: 14 additions & 9 deletions src/tests/test_bulk.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,28 @@
from .constants import host
from .payloads import Payloads

_api = jsonapi.JsonApi(host=host, auth="test_api_key")

class ATestApi(jsonapi.JsonApi):
HOST = host

@_api.register

@ATestApi.register
class BulkItem(jsonapi.Resource):
TYPE = "bulk_items"


test_api = ATestApi(host=host, auth="test_api_key")


payloads = Payloads('bulk_items')


@responses.activate
def test_bulk_delete():
responses.add(responses.DELETE, "{}/bulk_items".format(host))

items = [BulkItem(payload) for payload in payloads[1:6]]
BulkItem.bulk_delete([items[0],
items = [test_api.BulkItem(payload) for payload in payloads[1:6]]
test_api.BulkItem.bulk_delete([items[0],
items[1].as_resource_identifier(),
items[2].as_relationship(),
items[3].id])
Expand All @@ -46,7 +51,7 @@ def test_bulk_create():
responses.add(responses.POST, "{}/bulk_items".format(host),
json={'data': response_payload})

result = BulkItem.bulk_create([
result = test_api.BulkItem.bulk_create([
BulkItem(attributes={'name': "bulk_item 1"}),
{'attributes': {'name': "bulk_item 2"}},
({'name': "bulk_item 3"}, None),
Expand All @@ -64,7 +69,7 @@ def test_bulk_create():

assert isinstance(result[0], BulkItem)
assert result[1].id == "2"
assert result[2] == BulkItem(id="3")
assert result[2] == test_api.BulkItem(id="3")

for i in range(4):
assert result[i].id == str(i + 1)
Expand All @@ -85,8 +90,8 @@ def test_bulk_update():
responses.add(responses.PATCH, "{}/bulk_items".format(host),
json={'data': response_payload})

result = BulkItem.bulk_update([
BulkItem(id="1", attributes={'name': "modified name 1"}),
result = test_api.BulkItem.bulk_update([
test_api.BulkItem(id="1", attributes={'name': "modified name 1"}),
{'id': "2", 'attributes': {'name': "modified name 2"}},
("3", {'name': "modified name 3"}, None),
("4", {'name': "modified name 4"}),
Expand All @@ -106,7 +111,7 @@ def test_bulk_update():

assert isinstance(result[0], BulkItem)
assert result[1].id == "2"
assert result[2] == BulkItem(id="3")
assert result[2] == test_api.BulkItem(id="3")
assert result[3].name == "modified name 4"

for i in range(5):
Expand Down
11 changes: 8 additions & 3 deletions src/tests/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@

from .constants import host

_api = jsonapi.JsonApi(host=host, auth="test_api_key")

class ATestApi(jsonapi.JsonApi):
HOST = host

@_api.register

@ATestApi.register
class Foo(jsonapi.Resource):
TYPE = "foos"


test_api = ATestApi(auth="test_api_key")


@responses.activate
def test_exception_during_create():
responses.add(
Expand All @@ -32,7 +37,7 @@ def test_exception_during_create():

exc = None
try:
Foo.create(attributes={'username': "Foo"})
test_api.Foo.create(attributes={'username': "Foo"})
except JsonApiException as e:
exc = e

Expand Down
Loading