-
Notifications
You must be signed in to change notification settings - Fork 111
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #151 from AltSchool/feature/api-client-edge
Feature/api client edge
- Loading branch information
Showing
21 changed files
with
971 additions
and
101 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from .client import DRESTClient # noqa |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,190 @@ | ||
import json | ||
import requests | ||
from .exceptions import AuthenticationFailed, BadRequest, DoesNotExist | ||
from .resource import DRESTResource | ||
from dynamic_rest.conf import settings | ||
|
||
|
||
class DRESTClient(object): | ||
"""DREST Python client. | ||
Exposes a DREST API to Python using a Django-esque interface. | ||
Resources are available on the client through access-by-name. | ||
Arguments: | ||
host: hostname to a DREST API | ||
version: version (defaults to no version) | ||
client: HTTP client (defaults to requests.session) | ||
scheme: defaults to https | ||
authentication: if unset, authentication is disabled. | ||
If set, provides credentials: { | ||
usename: login username, | ||
password: login password, | ||
token: authorization token, | ||
cookie: session cookie | ||
} | ||
Either username/password, token, or cookie should be provided. | ||
Examples: | ||
Assume there is a DREST resource at "https://my.api.io/v0/users", | ||
and that we can access this resource with an auth token "secret". | ||
Getting a client: | ||
client = DRESTClient( | ||
'my.api.io', | ||
version='v0', | ||
authentication={'token': 'secret'} | ||
) | ||
Getting a single record of the Users resource | ||
client.Users.get('123') | ||
Getting all records (automatic pagination): | ||
client.Users.all() | ||
Filtering records: | ||
client.Users.filter(name__icontains='john') | ||
other_users = client.Users.exclude(name__icontains='john') | ||
Ordering records: | ||
users = client.Users.sort('-name') | ||
Including / excluding fields: | ||
users = client.Users.all() | ||
.excluding('birthday') | ||
.including('events.*') | ||
.get('123') | ||
Mapping by field: | ||
users_by_id = client.Users.map() | ||
users_by_name = client.Users.map('name') | ||
Updating records: | ||
user = client.Users.first() | ||
user.name = 'john' | ||
user.save() | ||
Creating records: | ||
user = client.Users.create(name='john') | ||
""" | ||
def __init__( | ||
self, | ||
host, | ||
version=None, | ||
client=None, | ||
scheme='https', | ||
authentication=None | ||
): | ||
self._host = host | ||
self._version = version | ||
self._client = client or requests.session() | ||
self._client.headers.update({ | ||
'Content-Type': 'application/json', | ||
'Accept': 'application/json' | ||
}) | ||
self._resources = {} | ||
self._scheme = scheme | ||
self._authenticated = True | ||
self._authentication = authentication | ||
|
||
if authentication: | ||
self._authenticated = False | ||
token = authentication.get('token') | ||
cookie = authentication.get('cookie') | ||
if token: | ||
self._use_token(token) | ||
if cookie: | ||
self._use_cookie(cookie) | ||
|
||
def __repr__(self): | ||
return '%s%s' % ( | ||
self._host, | ||
'/%s/' % self._version if self._version else '' | ||
) | ||
|
||
def _use_token(self, value): | ||
self._token = value | ||
self._authenticated = bool(value) | ||
self._client.headers.update({ | ||
'Authorization': '%s %s' % ( | ||
settings.AUTH_TYPE, self._token if value else '' | ||
) | ||
}) | ||
|
||
def _use_cookie(self, value): | ||
self._cookie = value | ||
self._authenticated = bool(value) | ||
self._client.headers.update({ | ||
'Cookie': '%s=%s' % (settings.AUTH_COOKIE_NAME, value) | ||
}) | ||
|
||
def __getattr__(self, key): | ||
key = key.lower() | ||
return self._resources.get(key, DRESTResource(self, key)) | ||
|
||
def _login(self, raise_exception=True): | ||
username = self._username | ||
password = self._password | ||
response = requests.post( | ||
self._build_url(settings.AUTH_LOGIN_ENDPOINT), | ||
data={ | ||
'login': username, | ||
'password': password | ||
}, | ||
allow_redirects=False | ||
) | ||
if raise_exception: | ||
response.raise_for_status() | ||
|
||
self._use_cookie(response.cookies.get(settings.AUTH_COOKIE_NAME)) | ||
|
||
def _authenticate(self, raise_exception=True): | ||
response = None | ||
if not self._authenticated: | ||
self._login(self._username, self._password, raise_exception) | ||
if raise_exception and not self._authenticated: | ||
raise AuthenticationFailed( | ||
response.text if response else 'Unknown error' | ||
) | ||
return self._authenticated | ||
|
||
def _build_url(self, url, prefix=None): | ||
if not url.startswith('/'): | ||
url = '/%s' % url | ||
|
||
if prefix: | ||
if not prefix.startswith('/'): | ||
prefix = '/%s' % prefix | ||
|
||
url = '%s%s' % (prefix, url) | ||
return '%s://%s%s' % (self._scheme, self._host, url) | ||
|
||
def request(self, method, url, params=None, data=None): | ||
self._authenticate() | ||
response = self._client.request( | ||
method, | ||
self._build_url(url, prefix=self._version), | ||
params=params, | ||
data=data | ||
) | ||
|
||
if response.status_code == 401: | ||
raise AuthenticationFailed() | ||
|
||
if response.status_code == 404: | ||
raise DoesNotExist() | ||
|
||
if response.status_code >= 400: | ||
raise BadRequest() | ||
|
||
return json.loads(response.content.decode('utf-8')) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
class DoesNotExist(Exception): | ||
pass | ||
|
||
|
||
class AuthenticationFailed(Exception): | ||
pass | ||
|
||
|
||
class BadRequest(Exception): | ||
pass |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,173 @@ | ||
from copy import copy | ||
from dynamic_rest.utils import unpack | ||
from six import string_types | ||
|
||
|
||
class DRESTQuery(object): | ||
|
||
def __init__( | ||
self, | ||
resource=None, | ||
filters=None, | ||
orders=None, | ||
includes=None, | ||
excludes=None, | ||
extras=None | ||
): | ||
self.resource = resource | ||
self.filters = filters or {} | ||
self.includes = includes or [] | ||
self.excludes = excludes or [] | ||
self.orders = orders or [] | ||
self.extras = extras or {} | ||
# disable sideloading for easy loading | ||
self.extras['sideloading'] = 'false' | ||
# enable debug for inline types | ||
self.extras['debug'] = 'true' | ||
self._reset() | ||
|
||
def __repr__(self): | ||
return 'Query: %s' % self.resource.name | ||
|
||
def all(self): | ||
return self._copy() | ||
|
||
def list(self): | ||
return list(self) | ||
|
||
def first(self): | ||
l = self.list() | ||
return l[0] if l else None | ||
|
||
def last(self): | ||
l = self.list() | ||
return l[-1] if l else None | ||
|
||
def map(self, field='id'): | ||
return dict(( | ||
(getattr(k, field), k) for k in self.list() | ||
)) | ||
|
||
def get(self, id): | ||
"""Returns a single record by ID. | ||
Arguments: | ||
id: a resource ID | ||
""" | ||
resource = self.resource | ||
response = resource.request('get', id=id, params=self._get_params()) | ||
return self._load(response) | ||
|
||
def filter(self, **kwargs): | ||
return self._copy(filters=kwargs) | ||
|
||
def exclude(self, **kwargs): | ||
filters = dict(('-' + k, v) for k, v in kwargs.items()) | ||
return self._copy(filters=filters) | ||
|
||
def including(self, *args): | ||
return self._copy(includes=args) | ||
|
||
def excluding(self, *args): | ||
return self._copy(excludes=args) | ||
|
||
def extra(self, **kwargs): | ||
return self._copy(extras=kwargs) | ||
|
||
def sort(self, *args): | ||
return self._copy(orders=args) | ||
|
||
def order_by(self, *args): | ||
return self.sort(*args) | ||
|
||
def _get_params(self): | ||
filters = self.filters | ||
includes = self.includes | ||
excludes = self.excludes | ||
orders = self.orders | ||
extras = self.extras | ||
|
||
params = {} | ||
for key, value in filters.items(): | ||
filter_key = 'filter{%s}' % key.replace('__', '.') | ||
params[filter_key] = value | ||
|
||
if includes: | ||
params['include[]'] = includes | ||
|
||
if excludes: | ||
params['exclude[]'] = excludes | ||
|
||
if orders: | ||
params['sort[]'] = orders | ||
|
||
for key, value in extras.items(): | ||
params[key] = value | ||
return params | ||
|
||
def _reset(self): | ||
# current page of data | ||
self._data = None | ||
# iteration index on current page | ||
self._index = None | ||
# page number | ||
self._page = None | ||
# total number of pages | ||
self._pages = None | ||
|
||
def _copy(self, **kwargs): | ||
data = self.__dict__ | ||
new_data = { | ||
k: copy(v) | ||
for k, v in data.items() | ||
if not k.startswith('_') | ||
} | ||
for key, value in kwargs.items(): | ||
new_value = data.get(key) | ||
if isinstance(new_value, dict): | ||
if value != new_value: | ||
new_value = copy(new_value) | ||
new_value.update(value) | ||
elif ( | ||
isinstance(new_value, (list, tuple)) and | ||
not isinstance(new_value, string_types) | ||
): | ||
if value != new_value: | ||
new_value = list(set(new_value + list(value))) | ||
new_data[key] = new_value | ||
return DRESTQuery(**new_data) | ||
|
||
def _get_page(self, params): | ||
if self._page is None: | ||
self._page = 1 | ||
else: | ||
self._page += 1 | ||
|
||
resource = self.resource | ||
params['page'] = self._page | ||
data = resource.request('get', params=params) | ||
meta = data.get('meta', {}) | ||
pages = meta.get('total_pages', 1) | ||
|
||
self._data = self._load(data) | ||
self._pages = pages | ||
self._index = 0 | ||
|
||
def _load(self, data): | ||
return self.resource.load(unpack(data)) | ||
|
||
def __iter__(self): | ||
# TODO: implement __getitem__ for random access | ||
params = self._get_params() | ||
self._get_page(params) | ||
while True: | ||
if self._index == len(self._data): | ||
# end of page | ||
if self._page == self._pages: | ||
# end of results | ||
self._reset() | ||
raise StopIteration() | ||
self._get_page(params) | ||
|
||
yield self._data[self._index] | ||
self._index += 1 |
Oops, something went wrong.