diff --git a/dynamic_rest/client/__init__.py b/dynamic_rest/client/__init__.py new file mode 100644 index 00000000..4b26e969 --- /dev/null +++ b/dynamic_rest/client/__init__.py @@ -0,0 +1 @@ +from .client import DRESTClient # noqa diff --git a/dynamic_rest/client/client.py b/dynamic_rest/client/client.py new file mode 100644 index 00000000..38d6c9b9 --- /dev/null +++ b/dynamic_rest/client/client.py @@ -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')) diff --git a/dynamic_rest/client/exceptions.py b/dynamic_rest/client/exceptions.py new file mode 100644 index 00000000..76bfe499 --- /dev/null +++ b/dynamic_rest/client/exceptions.py @@ -0,0 +1,10 @@ +class DoesNotExist(Exception): + pass + + +class AuthenticationFailed(Exception): + pass + + +class BadRequest(Exception): + pass diff --git a/dynamic_rest/client/query.py b/dynamic_rest/client/query.py new file mode 100644 index 00000000..ddb8abf4 --- /dev/null +++ b/dynamic_rest/client/query.py @@ -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 diff --git a/dynamic_rest/client/record.py b/dynamic_rest/client/record.py new file mode 100644 index 00000000..1dc036ef --- /dev/null +++ b/dynamic_rest/client/record.py @@ -0,0 +1,77 @@ +from dynamic_rest.utils import unpack +from .exceptions import DoesNotExist + + +class DRESTRecord(object): + + def __init__(self, resource, **data): + self._resource = resource + self._load(data) + + def save(self): + id = self.id + new = not id + data = ( + self._get_data() if new + else self._serialize(self._get_diff()) + ) + if data: + response = self._resource.request( + 'post' if new else 'patch', + id=id, + data=data + ) + self._load(unpack(response)) + + def reload(self): + id = self.id + if id: + response = self._resource.request('get', id=id) + self._load(unpack(response)) + else: + raise DoesNotExist() + + def __eq__(self, other): + if hasattr(other, '_get_data'): + other = other._get_data() + return self._get_data() == other + + def __repr__(self): + return '%s.%s' % (self._resource.name, self.id if self.id else '') + + def _get_data(self, fn=None): + if fn is None: + flt = lambda k, v: not k.startswith('_') + else: + flt = lambda k, v: fn(k, v) + + return { + k: v for k, v in self.__dict__.items() + if flt(k, v) + } + + def _get_diff(self): + return self._get_data( + lambda k, v: ( + not k.startswith('_') and + (k not in self._clean or v != self._clean[k]) + ) + ) + + def _load(self, data): + for key, value in data.items(): + setattr(self, key, value) + + self._clean = self._get_data() + self.id = data.get('_meta', {}).get('id', data.get('id', None)) + + def _serialize(self, data): + for key, values in data.items(): + if isinstance(values, list): + if len(values) > 0: + for i, value in enumerate(values): + if isinstance(value, DRESTRecord): + values[i] = value.id + elif isinstance(values, DRESTRecord): + data[key] = values.id + return data diff --git a/dynamic_rest/client/resource.py b/dynamic_rest/client/resource.py new file mode 100644 index 00000000..8d527c56 --- /dev/null +++ b/dynamic_rest/client/resource.py @@ -0,0 +1,82 @@ +from .query import DRESTQuery +from .record import DRESTRecord + + +class DRESTResource(object): + + """Represents a single resource in a DREST API. + + Arguments: + client: a DRESTClient + name: a resource's name + """ + def __init__(self, client, name): + self.name = name.lower() + self._client = client + + def __repr__(self): + return self.name + + def request(self, method, id=None, params=None, data=None): + """Perform a request against this resource. + + Arguments: + method: HTTP method (get, put, post, delete, options) + id: resource ID. by default, assume no ID + params: HTTP params + data: HTTP data + """ + return self._client.request( + method, + self._get_url(id), + params=params, + data=data + ) + + def load(self, data): + """Loads data to an internal representation. + + Arguments: + data: array or object + Returns: + Array of DRESTRecord or single DRESTRecord. + """ + if isinstance(data, dict): + meta = data.get('_meta', {}) + name = meta.get('type') + pk = meta.get('id') + if name and pk: + # load from dict + data['id'] = pk + if name == self.name: + for key, value in data.items(): + loaded = self.load(value) + if value != loaded: + data[key] = loaded + return DRESTRecord(resource=self, **data) + else: + resource = getattr(self._client, name) + return resource.load(data) + else: + # plain dict + return data + elif isinstance(data, list) and not isinstance(data, str): + for i, value in enumerate(data): + # load from list + _value = self.load(value) + if value != _value: + data[i] = _value + return data + else: + return data + + def create(self, **kwargs): + record = DRESTRecord(resource=self, **kwargs) + record.save() + return record + + def __getattr__(self, value): + return getattr(DRESTQuery(self), value) + + def _get_url(self, id=None): + return '%s%s' % (self.name, '/%s' % id if id else '') diff --git a/dynamic_rest/conf.py b/dynamic_rest/conf.py index 7cf0fbb0..e07c2814 100644 --- a/dynamic_rest/conf.py +++ b/dynamic_rest/conf.py @@ -5,6 +5,15 @@ # DEBUG: enable/disable internal debugging 'DEBUG': False, + # AUTH_ENDPOINT: authentication endpoint (used by DREST client) + 'AUTH_LOGIN_ENDPOINT': '/accounts/login/', + + # AUTH_COOKIE_NAME: sessionid cookie + 'AUTH_COOKIE_NAME': 'sessionid', + + # AUTH_TYPE: authentication type + 'AUTH_TYPE': 'JWT', + # ENABLE_BROWSABLE_API: enable/disable the browsable API. # It can be useful to disable it in production. 'ENABLE_BROWSABLE_API': True, diff --git a/dynamic_rest/constants.py b/dynamic_rest/constants.py index 99ff89fd..91f53bdc 100644 --- a/dynamic_rest/constants.py +++ b/dynamic_rest/constants.py @@ -5,4 +5,4 @@ REPO_NAME = "dynamic-rest" PROJECT_NAME = "Dynamic REST" ORG_NAME = "AltSchool" -VERSION = "1.5.7" +VERSION = "1.6.0" diff --git a/dynamic_rest/fields/fields.py b/dynamic_rest/fields/fields.py index 01213a47..edd8066f 100644 --- a/dynamic_rest/fields/fields.py +++ b/dynamic_rest/fields/fields.py @@ -82,6 +82,8 @@ def __init__( many=False, queryset=None, embed=False, + sideloading=None, + debug=False, **kwargs ): """ @@ -91,13 +93,18 @@ def __init__( many: Boolean, if relation is to-many. queryset: Default queryset to apply when filtering for related objects. + sideloading: if True, force sideloading all the way down. + if False, force embedding all the way down. + This overrides the "embed" option if set. embed: If True, always embed related object(s). Will not sideload, and will include the full object unless specifically excluded. """ self._serializer_class = serializer_class self.bound = False self.queryset = queryset - self.embed = embed + self.sideloading = sideloading + self.debug = debug + self.embed = embed if sideloading is None else not sideloading if '.' in kwargs.get('source', ''): raise Exception('Nested relationships are not supported') if 'link' in kwargs: @@ -219,8 +226,11 @@ def _inherit_parent_kwargs(self, kwargs): # If 'embed' then make sure we fetch the full object. kwargs['request_fields'] = {} - if hasattr(self.parent, 'sideload'): - kwargs['sideload'] = self.parent.sideload + if hasattr(self.parent, 'sideloading'): + kwargs['sideloading'] = self.parent.sideloading + + if hasattr(self.parent, 'debug'): + kwargs['debug'] = self.parent.debug return kwargs @@ -286,7 +296,7 @@ def to_representation(self, instance): return serializer.to_representation(related) except Exception as e: # Provide more context to help debug these cases - if settings.DEBUG: + if serializer.debug: import traceback traceback.print_exc() raise Exception( diff --git a/dynamic_rest/filters.py b/dynamic_rest/filters.py index f5c26c77..439f8b2d 100644 --- a/dynamic_rest/filters.py +++ b/dynamic_rest/filters.py @@ -9,6 +9,7 @@ from rest_framework.fields import BooleanField, NullBooleanField from rest_framework.filters import BaseFilterBackend, OrderingFilter +from dynamic_rest.utils import is_truthy from dynamic_rest.conf import settings from dynamic_rest.datastructures import TreeMap from dynamic_rest.fields import DynamicRelationField @@ -147,8 +148,6 @@ class DynamicFilterBackend(BaseFilterBackend): Attributes: VALID_FILTER_OPERATORS: A list of filter operators. - FALSEY_STRINGS: A list of strings that are interpretted as - False by the isnull operator. """ VALID_FILTER_OPERATORS = ( @@ -176,12 +175,6 @@ class DynamicFilterBackend(BaseFilterBackend): None, ) - FALSEY_STRINGS = ( - '0', - 'false', - '', - ) - def filter_queryset(self, request, queryset, view): """Filter the queryset. @@ -194,6 +187,13 @@ def filter_queryset(self, request, queryset, view): self.DEBUG = settings.DEBUG return self._build_queryset(queryset=queryset) + """ + This function was renamed and broke downstream dependencies that haven't + been updated to use the new naming convention. + """ + def _extract_filters(self, **kwargs): + return self._get_requested_filters(**kwargs) + def _get_requested_filters(self, **kwargs): """ Convert 'filters' query params into a dict that can be passed @@ -248,7 +248,7 @@ def _get_requested_filters(self, **kwargs): operator == 'isnull' and isinstance(value, six.string_types) ): - value = value.lower() not in self.FALSEY_STRINGS + value = is_truthy(value) elif operator == 'eq': operator = None @@ -287,7 +287,7 @@ def rewrite_filters(filters, serializer): for k, node in six.iteritems(filters): filter_key, field = node.generate_query_key(serializer) if isinstance(field, (BooleanField, NullBooleanField)): - node.value = node.value.lower() not in self.FALSEY_STRINGS + node.value = is_truthy(node.value) out[filter_key] = node.value return out diff --git a/dynamic_rest/processors.py b/dynamic_rest/processors.py index e37209c8..5acf6262 100644 --- a/dynamic_rest/processors.py +++ b/dynamic_rest/processors.py @@ -17,8 +17,6 @@ class SideloadingProcessor(object): typically smaller than their nested equivalent. """ - prefix = settings.ADDITIONAL_PRIMARY_RESOURCE_PREFIX - def __init__(self, serializer, data): """Initializes and runs the processor. @@ -80,8 +78,11 @@ def process(self, obj, parent=None, parent_key=None, depth=0): if not dynamic or getattr(obj, 'embed', False): return - name = obj.serializer.get_plural_name() - pk = obj.pk_value or obj.instance.pk + serializer = obj.serializer + name = serializer.get_plural_name() + instance = getattr(obj, 'instance', serializer.instance) + instance_pk = instance.pk if instance else None + pk = getattr(obj, 'pk_value', instance_pk) or instance_pk # For polymorphic relations, `pk` can be a dict, so use the # string representation (dict isn't hashable). @@ -104,7 +105,7 @@ def process(self, obj, parent=None, parent_key=None, depth=0): # if the primary resource is embedded, add it to a prefixed key if name == self.plural_name: name = '%s%s' % ( - self.prefix, + settings.ADDITIONAL_PRIMARY_RESOURCE_PREFIX, name ) diff --git a/dynamic_rest/serializers.py b/dynamic_rest/serializers.py index 1df9647c..089de2d8 100644 --- a/dynamic_rest/serializers.py +++ b/dynamic_rest/serializers.py @@ -66,19 +66,16 @@ def id_only(self): @property def data(self): """Get the data, after performing post-processing if necessary.""" - if not hasattr(self, '_sideloaded_data'): + if not hasattr(self, '_processed_data'): data = super(DynamicListSerializer, self).data - if self.child.sideload: - self._sideloaded_data = ReturnDict( - SideloadingProcessor( - self, - data - ).data, - serializer=self - ) - else: - self._sideloaded_data = ReturnList(data, serializer=self) - return self._sideloaded_data + self._processed_data = ReturnDict( + SideloadingProcessor(self, data).data, + serializer=self + ) if self.child.envelope else ReturnList( + data, + serializer=self + ) + return self._processed_data def update(self, queryset, validated_data): lookup_attr = getattr(self.child.Meta, 'update_lookup_field', 'id') @@ -91,7 +88,7 @@ def update(self, queryset, validated_data): lookup_keys = lookup_objects.keys() if not all((bool(_) and not inspect.isclass(_) for _ in lookup_keys)): - raise exceptions.ValidationError('Invalid lookup key value') + raise exceptions.ValidationError('Invalid lookup key value.') # Since this method is given a queryset which can have many # model instances, first find all objects to update @@ -102,7 +99,7 @@ def update(self, queryset, validated_data): if len(lookup_keys) != objects_to_update.count(): raise exceptions.ValidationError( - 'Could not find all objects to update: {} != {}' + 'Could not find all objects to update: {} != {}.' .format(len(lookup_keys), objects_to_update.count()) ) @@ -160,25 +157,32 @@ def __init__( include_fields=None, exclude_fields=None, request_fields=None, - sideload=False, + sideloading=None, + debug=False, dynamic=True, embed=False, + envelope=False, **kwargs ): """ Custom initializer that builds `request_fields`. Arguments: - instance: Instance to be managed by the serializer. + instance: Initial instance, used by updates. + data: Initial data, used by updates / creates. only_fields: List of field names to render. include_fields: List of field names to include. exclude_fields: List of field names to exclude. - request_fields: map of field names that supports - inclusions, exclusions, and nested sideloads. - sideload: If False, do not perform any sideloading at this level. - embed: If True, force the current representation to be embedded. - dynamic: If False, ignore deferred rules and - revert to standard DRF `.fields` behavior. + request_fields: Map of field names that supports + nested inclusions / exclusions. + embed: If True, embed the current representation. + If False, sideload the current representation. + sideloading: If True, force sideloading for all descendents. + If False, force embedding for all descendents. + If None (default), respect descendents' embed parameters. + dynamic: If False, disable inclusion / exclusion features. + envelope: If True, wrap `.data` in an envelope. + If False, do not use an envelope. """ name = self.get_name() if data is not fields.empty and name in data and len(data) == 1: @@ -199,11 +203,22 @@ def __init__( kwargs['instance'] = instance kwargs['data'] = data + + # "sideload" argument is pending deprecation as of 1.6 + if kwargs.pop('sideload', False): + # if "sideload=True" is passed, turn on the envelope + envelope = True + super(WithDynamicSerializerMixin, self).__init__(**kwargs) - self.sideload = sideload + self.envelope = envelope + self.sideloading = sideloading + self.debug = debug self.dynamic = dynamic self.request_fields = request_fields or {} + + # `embed` is overriden by `sideloading` + embed = embed if sideloading is None else not sideloading self.embed = embed self._dynamic_init(only_fields, include_fields, exclude_fields) @@ -389,8 +404,9 @@ def get_fields(self): for name, include in six.iteritems(request_fields): if name not in serializer_fields: raise exceptions.ParseError( - "'%s' is not a valid field name for '%s'" % - (name, self.get_name())) + '"%s" is not a valid field name for "%s".' % + (name, self.get_name()) + ) if include is not False and name in deferred: deferred.remove(name) elif include is False: @@ -526,6 +542,12 @@ def to_representation(self, instance): self, representation, instance ) + if self.debug: + representation['_meta'] = { + 'id': instance.pk, + 'type': self.get_plural_name() + } + # tag the representation with the serializer and instance return tag_dict( representation, @@ -577,16 +599,16 @@ def id_only(self): @property def data(self): - if not hasattr(self, '_sideloaded_data'): + if not hasattr(self, '_processed_data'): data = super(WithDynamicSerializerMixin, self).data - self._sideloaded_data = ReturnDict( - SideloadingProcessor( - self, - data - ).data if self.sideload else data, + data = SideloadingProcessor( + self, data + ).data if self.envelope else data + self._processed_data = ReturnDict( + data, serializer=self ) - return self._sideloaded_data + return self._processed_data class WithDynamicModelSerializerMixin(WithDynamicSerializerMixin): @@ -639,7 +661,7 @@ class EphemeralObject(object): def __init__(self, values_dict): if 'pk' not in values_dict: - raise Exception("'pk' key is required") + raise Exception('"pk" key is required') self.__dict__.update(values_dict) diff --git a/dynamic_rest/utils.py b/dynamic_rest/utils.py new file mode 100644 index 00000000..674e17d6 --- /dev/null +++ b/dynamic_rest/utils.py @@ -0,0 +1,23 @@ +from six import string_types + +FALSEY_STRINGS = ( + '0', + 'false', + '', +) + + +def is_truthy(x): + if isinstance(x, string_types): + return x.lower() not in FALSEY_STRINGS + return bool(x) + + +def unpack(content): + if not content: + # empty values pass through + return content + + keys = [k for k in content.keys() if k != 'meta'] + unpacked = content[keys[0]] + return unpacked diff --git a/dynamic_rest/viewsets.py b/dynamic_rest/viewsets.py index f5ed06ea..3a85f097 100644 --- a/dynamic_rest/viewsets.py +++ b/dynamic_rest/viewsets.py @@ -13,6 +13,7 @@ from dynamic_rest.pagination import DynamicPageNumberPagination from dynamic_rest.processors import SideloadingProcessor from dynamic_rest.renderers import DynamicBrowsableAPIRenderer +from dynamic_rest.utils import is_truthy UPDATE_REQUEST_METHODS = ('PUT', 'PATCH', 'POST') DELETE_REQUEST_METHOD = 'DELETE' @@ -59,10 +60,11 @@ class WithDynamicViewSetMixin(object): Attributes: features: A list of features supported by the viewset. - sideload: Whether or not to enable sideloading in the DynamicRenderer. meta: Extra data that is added to the response by the DynamicRenderer. """ + DEBUG = 'debug' + SIDELOADING = 'sideloading' INCLUDE = 'include[]' EXCLUDE = 'exclude[]' FILTER = 'filter{}' @@ -74,8 +76,7 @@ class WithDynamicViewSetMixin(object): pagination_class = DynamicPageNumberPagination metadata_class = DynamicMetadata renderer_classes = (JSONRenderer, DynamicBrowsableAPIRenderer) - features = (INCLUDE, EXCLUDE, FILTER, PAGE, PER_PAGE, SORT) - sideload = True + features = (INCLUDE, EXCLUDE, FILTER, PAGE, PER_PAGE, SORT, SIDELOADING) meta = None filter_backends = (DynamicFilterBackend, DynamicSortingFilter) @@ -171,7 +172,7 @@ def _extract_object_params(self, name): # malformed argument like: # filter{foo=bar raise exceptions.ParseError( - "'%s' is not a well-formed filter key" % name + '"%s" is not a well-formed filter key.' % name ) else: continue @@ -190,7 +191,7 @@ def get_queryset(self, queryset=None): return getattr(self, 'queryset', serializer.Meta.model.objects.all()) def get_request_fields(self): - """Parses the `include[]` and `exclude[]` features. + """Parses the INCLUDE and EXCLUDE features. Extracts the dynamic field features from the request parameters into a field map that can be passed to a serializer. @@ -202,8 +203,8 @@ def get_request_fields(self): if hasattr(self, '_request_fields'): return self._request_fields - include_fields = self.get_request_feature('include[]') - exclude_fields = self.get_request_feature('exclude[]') + include_fields = self.get_request_feature(self.INCLUDE) + exclude_fields = self.get_request_feature(self.EXCLUDE) request_fields = {} for fields, include in( (include_fields, True), @@ -226,12 +227,21 @@ def get_request_fields(self): elif not last: # empty segment must be the last segment raise exceptions.ParseError( - "'%s' is not a valid field" % - field) + '"%s" is not a valid field.' % + field + ) self._request_fields = request_fields return request_fields + def get_request_debug(self): + debug = self.get_request_feature(self.DEBUG) + return is_truthy(debug) if debug is not None else None + + def get_request_sideloading(self): + sideloading = self.get_request_feature(self.SIDELOADING) + return is_truthy(sideloading) if sideloading is not None else None + def is_update(self): if ( self.request and @@ -253,24 +263,34 @@ def is_delete(self): def get_serializer(self, *args, **kwargs): if 'request_fields' not in kwargs: kwargs['request_fields'] = self.get_request_fields() - if 'sideload' not in kwargs: - kwargs['sideload'] = self.sideload + if 'sideloading' not in kwargs: + kwargs['sideloading'] = self.get_request_sideloading() + if 'debug' not in kwargs: + kwargs['debug'] = self.get_request_debug() + if 'envelope' not in kwargs: + kwargs['envelope'] = True if self.is_update(): kwargs['include_fields'] = '*' return super( - WithDynamicViewSetMixin, self).get_serializer( - *args, **kwargs) + WithDynamicViewSetMixin, self + ).get_serializer( + *args, **kwargs + ) def paginate_queryset(self, *args, **kwargs): if self.PAGE in self.features: # make sure pagination is enabled - if self.PER_PAGE not in self.features and \ - self.PER_PAGE in self.request.query_params: + if ( + self.PER_PAGE not in self.features and + self.PER_PAGE in self.request.query_params + ): # remove per_page if it is disabled self.request.query_params[self.PER_PAGE] = None return super( - WithDynamicViewSetMixin, self).paginate_queryset( - *args, **kwargs) + WithDynamicViewSetMixin, self + ).paginate_queryset( + *args, **kwargs + ) return None def _prefix_inex_params(self, request, feature, prefix): @@ -301,7 +321,7 @@ def list_related(self, request, pk=None, field_name=None): # can have unintended consequences when applied asynchronously. if self.get_request_feature(self.FILTER): raise ValidationError( - "Filtering is not enabled on relation endpoints." + 'Filtering is not enabled on relation endpoints.' ) # Prefix include/exclude filters with field_name so it's scoped to @@ -312,13 +332,13 @@ def list_related(self, request, pk=None, field_name=None): # Filter for parent object, include related field. self.request.query_params.add('filter{pk}', pk) - self.request.query_params.add('include[]', field_prefix) + self.request.query_params.add(self.INCLUDE, field_prefix) # Get serializer and field. serializer = self.get_serializer() field = serializer.fields.get(field_name) if field is None: - raise ValidationError("Unknown field: %s" % field_name) + raise ValidationError('Unknown field: "%s".' % field_name) # Query for root object, with related field prefetched queryset = self.get_queryset() @@ -330,7 +350,7 @@ def list_related(self, request, pk=None, field_name=None): # Serialize the related data. Use the field's serializer to ensure # it's configured identically to the sideload case. - serializer = field.serializer + serializer = field.get_serializer(envelope=True) try: # TODO(ryo): Probably should use field.get_attribute() but that # seems to break a bunch of things. Investigate later. @@ -524,4 +544,5 @@ def destroy(self, request, *args, **kwargs): # assume that it is a poorly formatted bulk request return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) return super(DynamicModelViewSet, self).destroy( - request, *args, **kwargs) + request, *args, **kwargs + ) diff --git a/install_requires.txt b/install_requires.txt index 20e14baa..06889931 100644 --- a/install_requires.txt +++ b/install_requires.txt @@ -1,3 +1,4 @@ Django>=1.7,<=1.10 djangorestframework>=3.1.0,<=3.4.0 inflection==0.3.1 +requests diff --git a/tests/serializers.py b/tests/serializers.py index 3e46e424..e4fc2fac 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -190,7 +190,8 @@ class Meta: favorite_pet = DynamicGenericRelationField(required=False) def get_number_of_cats(self, user): - return len(user.location.cat_set.all()) + location = user.location + return len(location.cat_set.all()) if location else 0 class ProfileSerializer(DynamicModelSerializer): diff --git a/tests/test_api.py b/tests/test_api.py index c7e99153..976bcb09 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -130,6 +130,44 @@ def test_get_with_exclude(self): }] }, json.loads(response.content.decode('utf-8'))) + def test_get_with_nested_has_one_sideloading_disabled(self): + with self.assertNumQueries(2): + response = self.client.get( + '/users/?include[]=location.&sideloading=false' + ) + self.assertEquals(200, response.status_code) + self.assertEquals({ + 'users': [{ + 'id': 1, + 'location': { + 'id': 1, + 'name': '0' + }, + 'name': '0' + }, { + 'id': 2, + 'location': { + 'id': 1, + 'name': '0' + }, + 'name': '1' + }, { + 'id': 3, + 'location': { + 'id': 2, + 'name': '1' + }, + 'name': '2' + }, { + 'id': 4, + 'location': { + 'id': 3, + 'name': '2' + }, + 'name': '3' + }] + }, json.loads(response.content.decode('utf-8'))) + def test_get_with_nested_has_one(self): with self.assertNumQueries(2): response = self.client.get('/users/?include[]=location.') @@ -279,7 +317,7 @@ def test_get_one_with_include(self): response = self.client.get('/users/1/?include[]=groups.') self.assertEquals(200, response.status_code) data = json.loads(response.content.decode('utf-8')) - self.assertEquals(len(data['groups']), 2) + self.assertEquals(len(data.get('groups', [])), 2) def test_get_with_filter(self): with self.assertNumQueries(1): @@ -838,7 +876,6 @@ def test_options(self): actual = json.loads(response.content.decode('utf-8')) expected = { 'description': '', - 'features': ['include[]', 'exclude[]', 'filter{}', 'sort[]'], 'name': 'Location List', 'parses': [ 'application/json', @@ -931,6 +968,7 @@ def test_options(self): for field in ['cats', 'friendly_cats', 'bad_cats', 'users']: del actual['properties'][field]['nullable'] del expected['properties'][field]['nullable'] + actual.pop('features') self.assertEquals( json.loads(json.dumps(expected)), json.loads(json.dumps(actual)) @@ -1031,6 +1069,19 @@ def test_get_embedded(self): self.assertTrue(isinstance(groups[0], dict)) self.assertTrue(isinstance(location, dict)) + def test_get_embedded_force_sideloading(self): + with self.assertNumQueries(3): + url = '/v1/user_locations/1/?sideloading=true' + response = self.client.get(url) + + self.assertEqual(200, response.status_code) + content = json.loads(response.content.decode('utf-8')) + groups = content['user_location']['groups'] + location = content['user_location']['location'] + self.assertEqual(content['locations'][0]['name'], '0') + self.assertFalse(isinstance(groups[0], dict)) + self.assertFalse(isinstance(location, dict)) + class TestLinks(APITestCase): diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 00000000..0d537373 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,158 @@ +from rest_framework.test import APITestCase, APIClient +from dynamic_rest.client import DRESTClient +from six import string_types +from dynamic_rest.client.exceptions import ( + BadRequest, DoesNotExist +) +from tests.setup import create_fixture +import urllib +try: + urlencode = urllib.urlencode +except: + # Py3 + urlencode = urllib.parse.urlencode + + +class MockSession(object): + + """requests.session compatiability adapter for DRESTClient.""" + + def __init__(self, client): + self._client = client or APIClient() + self.headers = {} + + def request(self, method, url, params=None, data=None): + def make_params(params): + list_params = [] + for key, value in params.items(): + if isinstance( + value, string_types + ) or not isinstance(value, list): + value = [value] + for v in value: + list_params.append((key, v)) + return urlencode(list_params) + + url = '%s%s' % ( + url, + ('?%s' % make_params(params)) if params else '' + ) + response = getattr(self._client, method)(url, data=data) + content = response.content.decode('utf-8') + response.content = content + return response + + +class ClientTestCase(APITestCase): + + def setUp(self): + self.fixture = create_fixture() + self.drest = DRESTClient('test', client=MockSession(self.client)) + + def test_get_all(self): + users = self.drest.Users.all().list() + self.assertEquals(len(users), len(self.fixture.users)) + self.assertEquals( + {user.name for user in users}, + {user.name for user in self.fixture.users} + ) + + def test_get_filter(self): + users = self.drest.Users.filter(id=self.fixture.users[0].id).list() + self.assertEquals(1, len(users)) + self.assertEquals(users[0].name, self.fixture.users[0].name) + + def test_get_one(self): + fixed_user = self.fixture.users[0] + pk = fixed_user.pk + user = self.drest.Users.get(pk) + self.assertEquals(user.name, fixed_user.name) + + def test_get_include(self): + users = self.drest.Users.including('location.*').list() + self.assertEquals(users[0].location.name, '0') + + def test_get_map(self): + users = self.drest.Users.map() + id = self.fixture.users[0].pk + self.assertEquals(users[id].id, id) + + def test_get_exclude(self): + user = self.fixture.users[0] + users = self.drest.Users.exclude(name=user.name).map() + self.assertTrue(user.pk not in users) + + def test_get_including_related_save(self): + users = self.drest.Users.including('location.*').list() + user = users[0] + location = user.location + _location = self.drest.Locations.map()[location.id] + self.assertTrue( + location.name in {l.name for l in self.fixture.locations} + ) + _location.name = 'foo' + _location.save() + location.reload() + self.assertTrue(_location.name, location.name) + self.assertTrue(location.name, 'foo') + + def test_get_excluding(self): + users = self.drest.Users.excluding('*').map() + pk = self.fixture.users[0].pk + self.assertEquals(users[pk].id, pk) + + def test_update(self): + user = self.drest.Users.first() + user.name = 'foo' + user.save() + user = self.drest.Users.first() + self.assertTrue(user.name, 'foo') + + def test_create(self): + user = self.drest.Users.create( + name='foo', + last_name='bar' + ) + self.assertIsNotNone(user.id) + + def test_invalid_resource(self): + with self.assertRaises(DoesNotExist): + self.drest.Foo.create(name='foo') + with self.assertRaises(DoesNotExist): + self.drest.Foo.filter(name='foo').list() + + def test_save_invalid_data(self): + user = self.drest.Users.first() + user.date_of_birth = 'foo' + with self.assertRaises(BadRequest): + user.save() + + def test_get_invalid_data(self): + with self.assertRaises(DoesNotExist): + self.drest.Users.get('does-not-exist') + + def test_extra_pagination(self): + users = list(self.drest.Users.all().extra(per_page=1)) + users2 = list(self.drest.Users.all()) + self.assertEquals(users, users2) + + name = users[0].name + users_named = list(self.drest.Users.extra(name=name)) + self.assertTrue(len(users_named), 1) + self.assertTrue(users_named[0].name, name) + + def test_save_deferred(self): + user = self.drest.Users.excluding('*').first() + user.name = 'foo' + user.save() + + user2 = self.drest.Users.filter(name='foo').first() + self.assertIsNotNone(user2) + self.assertEquals(user2.id, user.id) + + def test_sort(self): + users = self.drest.Users.sort('name').list() + self.assertEquals( + users, + list(sorted(users, key=lambda x: x.name)) + ) diff --git a/tests/test_generic.py b/tests/test_generic.py index 4155a740..b77d2d7d 100644 --- a/tests/test_generic.py +++ b/tests/test_generic.py @@ -171,7 +171,7 @@ class Meta: 'favorite_pet' ).first() - data = FooUserSerializer(user).data + data = FooUserSerializer(user, envelope=True).data['user'] self.assertIsNotNone(data) self.assertTrue('favorite_pet' in data) self.assertTrue(isinstance(data['favorite_pet'], dict)) diff --git a/tests/test_serializers.py b/tests/test_serializers.py index ea7d35b1..90cdbcf1 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -36,11 +36,28 @@ def setUp(self): self.fixture = create_fixture() self.maxDiff = None - def test_data(self): + def test_data_without_envelope(self): + serializer = UserSerializer( + self.fixture.users, + many=True, + ) + self.assertEqual(serializer.data, [ + OrderedDict( + [('id', 1), ('name', '0'), ('location', 1)]), + OrderedDict( + [('id', 2), ('name', '1'), ('location', 1)]), + OrderedDict( + [('id', 3), ('name', '2'), ('location', 2)]), + OrderedDict( + [('id', 4), ('name', '3'), ('location', 3)]) + ]) + + def test_data_with_envelope(self): serializer = UserSerializer( self.fixture.users, many=True, - sideload=True) + envelope=True + ) self.assertEqual(serializer.data, { 'users': [ OrderedDict( @@ -61,8 +78,9 @@ def test_data_with_included_field(self): serializer = UserSerializer( self.fixture.users, many=True, + sideload=True, # pending deprecation 1.6 request_fields=request_fields, - sideload=True) + ) self.assertEqual(serializer.data, { 'users': [ OrderedDict( @@ -87,8 +105,9 @@ def test_data_with_excluded_field(self): serializer = UserSerializer( self.fixture.users, many=True, + envelope=True, request_fields=request_fields, - sideload=True) + ) self.assertEqual(serializer.data, { 'users': [ OrderedDict( @@ -109,8 +128,9 @@ def test_data_with_included_has_one(self): serializer = UserSerializer( self.fixture.users, many=True, + envelope=True, request_fields=request_fields, - sideload=True) + ) self.assertEqual(serializer.data, { 'locations': [{ 'id': 1, @@ -143,8 +163,9 @@ def test_data_with_included_has_one(self): serializer = UserSerializer( self.fixture.users[0], + envelope=True, request_fields=request_fields, - sideload=True) + ) self.assertEqual(serializer.data, { 'locations': [{ 'id': 1, @@ -214,8 +235,9 @@ def test_data_with_included_has_many(self): serializer = UserSerializer( self.fixture.users, many=True, + envelope=True, request_fields=request_fields, - sideload=True) + ) self.assertEqual(serializer.data, expected) request_fields = { @@ -271,8 +293,9 @@ def test_data_with_included_has_many(self): serializer = GroupSerializer( self.fixture.groups, many=True, + envelope=True, request_fields=request_fields, - sideload=True) + ) self.assertEqual(serializer.data, expected) def test_data_with_nested_include(self): @@ -285,8 +308,9 @@ def test_data_with_nested_include(self): serializer = UserSerializer( self.fixture.users, many=True, + envelope=True, request_fields=request_fields, - sideload=True) + ) expected = { 'users': [ { @@ -354,8 +378,9 @@ def test_data_with_nested_exclude(self): serializer = UserSerializer( self.fixture.users, many=True, + envelope=True, request_fields=request_fields, - sideload=True) + ) self.assertEqual(serializer.data, { 'groups': [{ 'id': 1 @@ -506,21 +531,23 @@ def test_data(self): data['location'] = location data['groups'] = self.fixture.groups instance = EphemeralObject(data) - data = LocationGroupSerializer(instance).data + data = LocationGroupSerializer( + instance, envelope=True + ).data['locationgroup'] self.assertEqual( data, {'id': 1, 'groups': [1, 2], 'location': 1} ) def test_data_count_field(self): eo = EphemeralObject({'pk': 1, 'values': [1, 1, 2]}) - data = CountsSerializer(eo).data + data = CountsSerializer(eo, envelope=True).data['counts'] self.assertEqual(data['count'], 3) self.assertEqual(data['unique_count'], 2) def test_data_count_field_returns_none_if_null_values(self): eo = EphemeralObject({'pk': 1, 'values': None}) - data = CountsSerializer(eo).data + data = CountsSerializer(eo, envelope=True).data['counts'] self.assertEqual(data['count'], None) self.assertEqual(data['unique_count'], None) @@ -528,7 +555,7 @@ def test_data_count_field_returns_none_if_null_values(self): def test_data_count_raises_exception_if_wrong_type(self): eo = EphemeralObject({'pk': 1, 'values': {}}) with self.assertRaises(TypeError): - CountsSerializer(eo).data + CountsSerializer(eo, envelope=True).data def test_to_representation_if_id_only(self): ''' Test EphemeralSerializer.to_representation() in id_only mode ''' @@ -557,7 +584,9 @@ def setUp(self): def test_data_with_embed(self): data = UserLocationSerializer( - self.fixture.users[0], sideload=True).data + self.fixture.users[0], + envelope=True + ).data self.assertEqual(data['user_location']['location']['name'], '0') self.assertEqual( ['0', '1'], @@ -572,10 +601,13 @@ class Meta: model = User name = 'user_deferred_location' location = DynamicRelationField( - LocationSerializer, embed=True, deferred=True) + LocationSerializer, embed=True, deferred=True + ) data = UserDeferredLocationSerializer( - self.fixture.users[0]).data + self.fixture.users[0], + envelope=True + ).data self.assertFalse('location' in data) # Now include deferred embedded field @@ -585,7 +617,9 @@ class Meta: 'id': True, 'name': True, 'location': True - }).data + }, + envelope=True + ).data['user_deferred_location'] self.assertTrue('location' in data) self.assertEqual(data['location']['name'], '0') @@ -614,7 +648,9 @@ class Meta: 'id': True, 'name': True, 'groups': True - }).data + }, + envelope=True + ).data['user_deferred_location'] self.assertTrue('groups' in data) @override_settings( @@ -632,7 +668,9 @@ class Meta: groups = DynamicRelationField('GroupSerializer', many=True) data = UserDeferredLocationSerializer( - self.fixture.users[0]).data + self.fixture.users[0], + envelope=True + ).data['user_deferred_location'] self.assertTrue('groups' in data) diff --git a/tests/viewsets.py b/tests/viewsets.py index b921b391..03f2cc1a 100644 --- a/tests/viewsets.py +++ b/tests/viewsets.py @@ -29,7 +29,8 @@ class UserViewSet(DynamicModelViewSet): features = ( DynamicModelViewSet.INCLUDE, DynamicModelViewSet.EXCLUDE, - DynamicModelViewSet.FILTER, DynamicModelViewSet.SORT + DynamicModelViewSet.FILTER, DynamicModelViewSet.SORT, + DynamicModelViewSet.SIDELOADING, DynamicModelViewSet.DEBUG ) model = User serializer_class = UserSerializer @@ -92,7 +93,8 @@ class GroupViewSet(DynamicModelViewSet): class LocationViewSet(DynamicModelViewSet): features = ( DynamicModelViewSet.INCLUDE, DynamicModelViewSet.EXCLUDE, - DynamicModelViewSet.FILTER, DynamicModelViewSet.SORT + DynamicModelViewSet.FILTER, DynamicModelViewSet.SORT, + DynamicModelViewSet.DEBUG, DynamicModelViewSet.SIDELOADING ) model = Location serializer_class = LocationSerializer