diff --git a/.travis.yml b/.travis.yml index 99838c3..de84a6e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,14 @@ language: python python: - - '2.7' - '3.3' -# - 'pypy' # Install system dependencies before_install: - - 'travis_retry sudo apt-get install python-dev libevent-dev' + - 'travis_retry sudo apt-get install python-dev' # Install python dependencies install: - - 'travis_retry pip install -U -e ".[test]" --use-mirrors' + - 'travis_retry pip install -Ue . --use-mirrors' - 'travis_retry pip install coveralls --use-mirrors' # Blacklist legacy branches as they don't have operating tests diff --git a/AUTHORS.md b/AUTHORS.md index ba1b2e5..7bbfe0d 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -1,18 +1,18 @@ The python implementation of **Armet** is currently maintained by: - Ryan Leckey ([Concordus Applications][]) — [@mehcode][] - Erich Healy ([Concordus Applications][]) — [@CactusCommander][] -- Hardy Jones ([Concordus Applications][]) — [@joneshf][] [Concordus Applications]: http://www.concordusapps.com/ [@mehcode]: http://github.com/mehcode [@CactusCommander]: http://github.com/CactusCommander -[@joneshf]: http://github.com/joneshf Additional contributors (in no particular order): - James Miles — [@flyingbluejay][] -- Adam Voliva ([Concordus Applications][]) — [@avoliva][] +- Adam Voliva — [@avoliva][] +- Hardy Jones — [@joneshf][] - Taylor Stackpole ([Concordus Applications][]) — [@taystack][] [@flyingbluejay]: http://github.com/flyingbluejay [@avoliva]: http://github.com/avoliva [@taystack]: http://github.com/taystack +[@joneshf]: http://github.com/joneshf diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 055f328..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,26 +0,0 @@ -## 0.4.0 - - - Catch assertions and value errors during the attribute cleaning cycle and send the messages back as a serialized 400 response to the client. - - - Removed deprecated `include` resource option for declaring attributes. - - - Moved attributes out of `armet.resources`. `armet.resources.*Attribute` becomes `armet.attributes.*Attribute`. - - - Facilitate properties defined for attributes: - - `read` (default `True`) — attribute may be accessed directly (eg. `GET /resource/attribute` or `GET /resource/1/attribute`). Note that setting this to `False` will still show the attribute in the body of `GET /resource`. Set `include` to `False` to hide it from the body. - - - `write` (default `True`) ­— attribute may be modified through any operation (`POST`, `PUT`, or `PATCH` on the body or directly). This can be set to `True`, `False` - - - `include` (default `True`) — attribute is included in the response body. This can be set to `True`, `False` - - - `null` (default: `True`) — attribute can accept a `null` value. This is only checked (for obvious reasons) at modification operations and results in a validation error. This can be set to `True`, `False` - - - `required` (default: `False`) — attribute must be present in the body. This is only checked (for obvious reasons) at modification operations and results in a validation error. This can be set to `True`, `False` - - - `collection` (default: `False`) — attribute is to be treated as a collection. This means that the response body will always be at least an array of one and any operations (such as pagination) that are defined for collections are defined for it. - - - Added `name` parameter to attributes to override name in resource model (normally derived from the python name). - - - Added simple path traversal for attributes (eg. `GET /path/attribute` or `GET /path/slug/attribute`). - - - Changed `slug` resource option to a string that maps to an existing attribute by their name. diff --git a/LICENSE b/LICENSE index bd41ff6..dfef7c5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright © 2012-2013 by Concordus Applications, Inc. +Copyright © 2012-2014 by Concordus Applications, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 3416e80..49822c8 100644 --- a/README.md +++ b/README.md @@ -13,60 +13,16 @@ that takes more time to develop then its underlying functionality. **Armet** enables you, the developer, to focus on developing the application behind the interface. -## Connectors - -**Armet** exists as an abstraction layer above your -web server framework and therefore can run in most -frameworks and envrionments using connectors. - -There are two kinds of connectors: `http` and `model`. The `http` connector -is in charge of facilitating the request / response cycle. The `model` -connector is in charge of facilitating the database access layer for RESTful -resources that are bound to a declarative model. For the most part these -connectors can be mixed and changed at a per-resource level (eg. one resource -may use `django` for its http connector and `sqlalchemy` for its model -and another may still use `django` for its http connector but reuse it for -its model one as well). - -### Request / response (http) - -###### [Django](https://www.djangoproject.com/) `>= 1.4` -> The Web framework for perfectionists (with deadlines). -> Django makes it easier to build better Web apps more quickly and -> with less code. - -###### [Flask](http://flask.pocoo.org/) -> Flask is a microframework for Python based on Werkzeug, -> Jinja 2 and good intentions. - -###### [Bottle](http://bottlepy.org/docs/dev/) -> Bottle is a fast, simple and lightweight -> WSGI micro web-framework for Python. - -### Database access (model) - -###### [Django](https://www.djangoproject.com/) `>= 1.4` -> The Web framework for perfectionists (with deadlines). -Django makes it easier to build better Web apps more quickly and with less code. - -###### [SQLAlchemy](http://www.sqlalchemy.org/) `>= 0.7` -> SQLAlchemy is the Python SQL toolkit and Object Relational Mapper that -gives application developers the full power and flexibility of SQL. - ## Installation ### Automated -1. **Armet** is not yet listed on [PyPI](https://pypi.python.org/pypi/) - but can be installed by directly referencing its git url with `pip` - or `easy_install`. +1. **Armet** can be installed with `pip` or `easy_install`. ```sh - pip install git+git://github.com/armet/python-armet.git + pip install armet ``` - > Examples are not included when installing using `pip` or `easy_install`. - ### Manual 1. Clone the **Armet** repository to your local computer. @@ -75,23 +31,6 @@ gives application developers the full power and flexibility of SQL. git clone git://github.com/armet/python-armet.git ``` - To grab a specific version you can use the `-b` argument to specify - a branch, which can include a specific tag - as well (eg. `versions/0.4.x` or `0.4.1`). - - ```sh - git clone -b 0.3.1 git://github.com/armet/python-armet.git - ``` - - To speed up the clone if you're just cloning the project for use and - don't intend to contribute back upstream, you can add the `--depth ` - option to do a shallow clone, in which it will only fetch the - exact version instead of the entire history. - - ```sh - git clone -b 0.3.1 --depth 1 git://github.com/armet/python-armet.git - ``` - 2. Change into the **armet** root directory. ```sh @@ -104,57 +43,6 @@ gives application developers the full power and flexibility of SQL. pip install . ``` - Additional *extra* requirements may be specified in brackets following - the `.`. - - ```sh - # Install armet as well as the additional dependencies to use - # the unit test suite. - pip install ".[test]" - ``` - -## Contributing - -### Setting up your environment -1. Follow steps 1 and 2 of the [manual installation instructions][]. - -[manual installation instructions]: #manual - -2. Initialize a virtual environment to develop in. - This is done so as to ensure every contributor is working with - close-to-identicial versions of packages. - - ```sh - mkvirtualenv armet - ``` - - The `mkvirtualenv` command is available from `virtualenvwrapper` which - can be installed as follows: - - ```sh - sudo pip install virtualenvwrapper - ``` - -3. Install **armet** in development mode with testing enabled. - This will download all dependencies required for running the unit tests. - - ```sh - pip install -e ".[test]" - ``` - -### Running the test suite -1. [Set up your environment](#setting-up-your-environment). - -2. Run the unit tests. - - ```sh - py.test - ``` - -**Armet** is maintained with all tests passing at all times. If you find -a failure, please [report it](https://github.com/armet/python-armet/issues/new) -along with the version. - ## License Unless otherwise noted, all files contained within this project are liensed under the MIT opensource license. See the included file LICENSE or visit diff --git a/TASKS b/TASKS new file mode 100644 index 0000000..71c39ae --- /dev/null +++ b/TASKS @@ -0,0 +1,23 @@ + ☐ Router + ☐ WSGI + ☐ Flask + ☐ Tornado + ☐ Twisted + ☐ Spawning + ☐ Rocket + + ☐ Decoder + ☐ JSON + ☐ XML + ☐ Form + ☐ URL + ☐ YAML + ☐ pList + + ☐ Encoder + ☐ JSON + ☐ XML + ☐ Form + ☐ URL + ☐ YAML + ☐ pList diff --git a/armet/__init__.py b/armet/__init__.py index e2003db..6017231 100644 --- a/armet/__init__.py +++ b/armet/__init__.py @@ -1,14 +1,9 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from ._version import __version__, __version_info__ # noqa -from .decorators import route, resource, asynchronous -from .helpers import use -from .relationship import Relationship +from ._version import __version__ +from . import encoders, decoders, resources __all__ = [ - 'route', - 'resource', - 'asynchronous', - 'use', - 'Relationship' + __version__, + encoders, + decoders, + resources, ] diff --git a/armet/_version.py b/armet/_version.py index b9bb55d..1056fd6 100644 --- a/armet/_version.py +++ b/armet/_version.py @@ -1,5 +1,2 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import, division - -__version_info__ = (0, 4, 43) +__version_info__ = (0, 5, 0) __version__ = '.'.join(map(str, __version_info__)) diff --git a/armet/api.py b/armet/api.py new file mode 100644 index 0000000..ceb03db --- /dev/null +++ b/armet/api.py @@ -0,0 +1,398 @@ +from . import decoders, encoders, registry +from armet import utils +from armet.http import exceptions, Request, Response +import http +import traceback +import werkzeug + + +class Api: + + def __init__(self, name=None, *, trailing_slash=False, debug=False): + # Resource registry that will contain any resources that could + # be exposed in this API context. + self._registry = registry.Registry(index={"name"}) + + # The name that this may take if it is registered in another API. + self.name = name + + # Dispatching registry used to delegate execution to other Api objects. + self._dispatcher = werkzeug.wsgi.DispatcherMiddleware(self.wsgi) + + # TODO: I don't like this but I liked the other thing less + + # Pull out the mount array from the dispatcher so that we can + # manipulate it in this class when users add sub-apis. + # self._defers = self._dispatcher.mounts + + # Whether we're in debugging mode. + # This causes tracebacks to be sent with the response on + # a 5xx resposne. + self.debug = debug + + # Trailing slash handling. + # The value indicates which URI is the canonical URI and the + # alternative URI is then made to redirect (with a 301) to the + # canonical URI. + self.trailing_slash = trailing_slash + + def redirect(self, request): + # Format an absolute path to the URI (adjusted to be canonical). + location = "%s://%s%s%s%s" % ( + request.scheme, + request.host, + request.script_root, + (request.path + '/' if self.trailing_slash else request.path[:-1]), + ('?' + request.query_string if request.query_string else '')) + + # Select the appropriate status_code based on the request method. + # MOVED_PERMANENTLY (301) only works for a GET but is preferred + # as it is cacheable. + if request.method == "GET": + status_code = http.client.MOVED_PERMANENTLY + else: + status_code = http.client.TEMPORARY_REDIRECT + + # Build and return the redirection response. + return werkzeug.utils.redirect(location, status_code) + + def setup(self): + """Called on request setup in the context of this API. + """ + + def teardown(self): + """Called on request teardown in the context of this API. + """ + + def register(self, handler, *, expose=True, name=None): + # Discern the name of the handler in order to register it. + if name is None: + if getattr(handler, "name", None): + name = handler.name + else: + # Convert the name of the handler to dash-case. + if isinstance(handler, Api): + name = utils.dasherize(type(handler).__name__) + if name.endswith("-api"): + name = name[:-4] + + else: + name = utils.dasherize(handler.__name__) + if name.endswith("-resource"): + name = name[:-9] + + # Insert the handler into the registry. + if isinstance(handler, Api): + self._dispatcher.mounts["/" + name] = handler + + else: + self._registry.register(handler, name=name, expose=expose) + + def __call__(self, environ, start_response): + return self._dispatcher(environ, start_response) + + def wsgi(self, environ, start_response): + """Entry-point from the WSGI environment. + + When a request comes in from a "client" this is the first place + it goes after it is received by the "server" (uWSGI, nginx, etc.). + """ + + # Create the request wrapper around the environment. + request = Request(environ) + + try: + # TODO: We do not handle rendering an API index. We should. + if request.path == "/": + raise exceptions.NotFound() + + # Test and decide if we need to redirect the client to + # the canonical representation of the given request path. + if self.trailing_slash ^ request.path.endswith('/'): + response = self.redirect(request) + return response(environ, start_response) + + # Setup the request. + self.setup() + + # Dispatch the request with an empty response object. Return + # the response in the event a new one was generated during routing. + response = self.route(request, Response()) + + except exceptions.Base as ex: + # FIXME: Show the exception in the response not in stdout + # If the exception raised was an error-like exception (4xx or 5xx) + # then print the traceback as well. + if self.debug and ex.code // 100 >= 4: + traceback.print_exc() + + # Exceptions also double as response objects. Just use that. + response = ex + + except Exception as ex: + response = exceptions.InternalServerError() + if self.debug: + traceback.print_exc() + + # Teardown the request. + # FIXME: This should happen directly before closing the connection + # with the user, not here. + self.teardown() + + # Invoke the response wrapper to initiate the (possibly streaming) + # response. + return response(environ, start_response) + + def _find(self, path): + """Find and instantiate the right-most resource using path traversal. + + Collects and forwards the resource context during traversal. + """ + # Attempt to find the resource through the initial path. + context = {} + segments = list(filter(None, path.split("/"))) + count = 0 + last_resource_cls = None + while len(segments) > 2: + # Pop the (name, slug) pair from the segments list. + name = segments.pop(0) + slug = segments.pop(0) + + try: + # Attempt to lookup the resource from the passed name. + resource_cls, metadata = self._registry.find(name=name) + + except KeyError: + raise exceptions.NotFound() + + if count == 0: + # If we are at the initial resource .. + # Check if this resource is allowed to be exposed. + if not metadata.get("expose"): + # This resource is not exposed at "/" + # NOTE: This resource -can- still be traversed to from + # another resource. + raise exceptions.NotFound() + + elif last_resource_cls is not None: + # Check if the last resource is allowed to traverse to this + # resource. + rels = getattr(last_resource_cls, "relationships", ()) + if name not in rels: + # This resource does not exist in context of the previous + # resource. + raise exceptions.NotFound() + + # Instantiate the resource. + resource = resource_cls(slug=slug, context=context) + + # Invoke `.read` and store it in the context; we are not + # at the final segment in the url. + context[name] = resource.filter(resource.read(), None) + + # Update the `last_resource_cls` (keeps track of the + # immediate-left resource) + last_resource_cls = resource_cls + + # Increment resource counter; keep track of how many resources + # have been traversed. + count += 1 + + # Grab the final (name, slug?) pair from the list. + name = segments[0] + slug = segments[1] if len(segments) > 1 else None + + if last_resource_cls is not None: + # Check if the last resource is allowed to traverse to this + # resource. + rels = getattr(last_resource_cls, "relationships", ()) + if name not in rels: + # This resource does not exist in context of the previous + # resource. + raise exceptions.NotFound() + + try: + # Attempt to lookup the resource from the passed name. + resource_cls, _ = self._registry.find(name=name) + + except KeyError: + raise exceptions.NotFound() + + # Instantiate the resource. + return resource_cls(slug=slug, context=context) + + def decode(self, request): + # Read in the request data. + # TODO: Think of a way to expose this (just "content" or "data") + request_raw_data = request.data + if request_raw_data: + # Find an available decoder. + content_type = request.headers.get("Content-Type") + if not content_type: + # TODO: Handle content-type "detection" + raise exceptions.UnsupportedMediaType() + + try: + # TODO: The content-type header could state more than just + # the mime_type (charset, etc.); think of how to deal + # with it as media_range detection "works" with it but + # mime_type would be faster. + decode, _ = decoders.find(media_range=content_type) + + # Decode the incoming request data. + # TODO: We should likely be sending the proper charset + # to ".decode(..)" + return decode(request_raw_data.decode("utf-8")) + + except (KeyError, TypeError): + # Failed to find a matching decoder. + raise exceptions.UnsupportedMediaType() + + # No request data + return None + + def encode(self, request, response, data): + # Find an available encoder. + media_range = request.headers.get("Accept", "application/json") + if media_range == "*/*": + media_range = "application/json" + + try: + encoder, metadata = encoders.find(media_range=media_range) + + # Encode the data. + # TODO: We should be detecting the proper charset and using that + # instead. + response.response = encoder(data, 'utf-8') + response.headers['Content-Type'] = metadata["preferred_mime_type"] + + except (KeyError, TypeError) as ex: + # Failed to find a matching encoder. + raise exceptions.NotAcceptable() from ex + + def route(self, request, response): + # Store both the request and response on `self` + self.request = request + self.response = response + + # Find and instantiate the right-most resource using path traversal. + resource = self._find(request.path) + + # Get the request data + request_data = self.decode(request) + + # Instantiate the correct resource with + resource = self._find(request.path) + # We are at the final segment in the URL; we need to route this + # dependent on the HTTP/1.1 method. + try: + route = getattr(self, request.method.lower()) + + except AttributeError: + raise exceptions.MethodNotAllowed([request.method]) + + try: + # Dispatch the request. + response_data = route(resource, request_data) + status_code = 200 + if isinstance(response_data, tuple): + response_data, status_code = response_data + + elif response_data is None: + response.status_code = 204 + return response + + except exceptions.Base as ex: + # An HTTP/1.1 understood exception was raised from somewhere + # These exceptions can be invoked the same way that response + # objects can. + + # If these exceptions have a message associated with them, + # then continue as if that was the response data. Otherwise, + # just continue tossing it up the stack. + response_data = ex.message + if not response_data: + raise + + # Generate a new response based on the exception tossed. + # This response will still go through the encoding cycle to encode + # any error messages. + response = ex.get_response() + + else: + # Return a successful response. + response.status_code = status_code + + if response_data is not None: + # Write the response data into the response object + self.encode(request, response, response_data) + + return response + + def get(self, resource, data=None): + + # TODO: When armet queries are implemented, pass the query here. + # instead of None + items = resource.filter(resource.read(), None) + + if items is None: + return + + if resource.slug is not None: + try: + return resource.prepare_item(items[0]) + + except IndexError: + raise exceptions.NotFound() + + except TypeError: + return resource.prepare_item(items) + + return resource.prepare(items) + + def url_for(self, resource, slug=None): + name = self._registry.rfind(type(resource)) + slug = "/%s" % slug if slug else "" + return "{}/{}{slug}".format( + self.request.script_root, + name, + slug=slug) + + def post(self, resource, data): + try: + # Ask the resource to `create` (should return the new item) + item = resource.create(data) + slug = resource.slug_for(item) + + except AttributeError: + raise exceptions.NotImplemented() + + # Set the `Location` header to indicate where we created the item + self.response.headers["Location"] = self.url_for( + resource, slug=slug) + + # Return nothing and a 201 to indicate creation + return None, 201 + + def patch(self, resource, data): + if resource.slug is None: + # We don't support mass-updates (yet) + raise exceptions.NotImplemented() + + # Fetch the item to update + items = resource.filter(resource.read(), None) + try: + item = items[0] + + except IndexError: + raise exceptions.NotFound() + + try: + # Ask the resource to `update` + resource.update(item, data) + + except AttributeError: + raise exceptions.NotImplemented() + + # Return nothing to indicate success + return None diff --git a/armet/attributes/__init__.py b/armet/attributes/__init__.py deleted file mode 100644 index b52bea7..0000000 --- a/armet/attributes/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .attribute import Attribute -from .primitive import BooleanAttribute, TextAttribute, IntegerAttribute -from .temporal import TimeAttribute, DateAttribute, DateTimeAttribute -from .uuid import UUIDAttribute -from .timezone import TimezoneAttribute -from .decimal import DecimalAttribute - - -__all__ = [ - 'Attribute', - 'BooleanAttribute', - 'TextAttribute', - 'IntegerAttribute', - 'TimeAttribute', - 'DateAttribute', - 'DateTimeAttribute', - 'UUIDAttribute', - 'TimezoneAttribute', - 'DecimalAttribute' -] diff --git a/armet/attributes/attribute.py b/armet/attributes/attribute.py deleted file mode 100644 index 6d159f4..0000000 --- a/armet/attributes/attribute.py +++ /dev/null @@ -1,264 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet.utils import compose -from collections import defaultdict -from functools import partial - - -class Attribute(object): - """Generic attribute. - - Use this to indicate that the type of the data is unmanaged by armet. - This is also an extension point for further attributes. - """ - - #! Python type expected to be marshalled through this attribute. - type = None - - def __init__(self, path=None, **kwargs): - - #! Attribute may be accessed directly. - #! Note that setting this to false will still show the attribute in - #! the body. Set include to false to hide it from the body. - self.read = kwargs.get('read', True) - - #! Attribute may be modified through any operation. - self.write = kwargs.get('write', True) - - #! Attribute is included in the response body. - self.include = kwargs.get('include', True) - - #! Attribute can accept a null value. - self.null = kwargs.get('null', True) - - #! Attribute must be present in the body. - self.required = kwargs.get('required', False) - - #! Attribute is to be treated as a collection. - self.collection = kwargs.get('collection', False) - - #! Override python name of the attribute. - self.name = kwargs.get('name') - - #! Flag to turn the set operation into a no-op. - # HACK: This is in-place in order to get around a bug I've encountered - # until relationships are implemented. - self._set = kwargs.get('_set', True) - - #! The path reference of where to find this attribute on an - #! item (eg. 'name' references the name key if the read method returns - #! a dictionary.) - #! - #! The path may be dot-separated to indicate simple traversal. - #! Eg. user.name could be obj['user'].name - self.path = path - if self.path: - # Explode the path into segments to iterate over in operations. - self._segments = defaultdict(lambda: self.path.split('.')) - - # Attributes use a lazy optimization process for attribute lookup - # on the underlying data model. When an attribute is accessed - # it builds and caches the operation action. - self._getters = defaultdict(list) - - # Attribute cache that is keyed on the type of the target object. - this = self - - class GetResolver(dict): - - def __missing__(self, key): - return partial(this._resolve_get, self, key.__class__) - - self._get = GetResolver() - - def _resolve_get(self, resolver, key, target): - if self.path is None: - # There is no path defined on this resource. - # We can do no magic to get the value. - resolver[key] = lambda *a: None - return None - - # Iterate and resolve each constructed getter. - for func in self._getters[key]: - if target is None: - # No more getters can be resolved. - return None - - # Resolve this getter and continue iteration. - target = func(target) - - # Are their segments remaining to make a getter for? - while self._segments[key]: - if target is None: - # No more getters can be made. - return None - - # Fetch the next path segment. - segment = self._segments[key].pop(0) - - # Make and append the corresponding getter. - func = self._make_getter(segment, key) - self._getters[key].append(func) - - # Resolve the getter. - target = func(target) - - # Have we finished resolving the getters? - if not self._segments[key]: - # Compose a replacement get function that just iterates over - # the getters. - resolver[key] = compose(*self._getters[key]) - - # Return our resolved value. - return target - - def get(self, target): - """Retrieve the value of this attribute from the passed object. - """ - - # Attempt to resolve an accessor for the specific target. Creates - # the accessor if not available. - return self._get[target](target) - - def set(self, target, value): - """Set the value of this attribute for the passed object. - """ - - if not self._set: - return - - if self.path is None: - # There is no path defined on this resource. - # We can do no magic to set the value. - self.set = lambda *a: None - return None - - if self._segments[target.__class__]: - # Attempt to resolve access to this attribute. - self.get(target) - - if self._segments[target.__class__]: - # Attribute is not fully resolved; an interim segment is null. - return - - # Resolve access to the parent object. - # For a single-segment path this will effectively be a no-op. - parent_getter = compose(*self._getters[target.__class__][:-1]) - target = parent_getter(target) - - # Make the setter. - func = self._make_setter(self.path.split('.')[-1], target.__class__) - - # Apply the setter now. - func(target, value) - - # Replace this function with the constructed setter. - def setter(target, value): - func(parent_getter(target), value) - - self.set = setter - - def prepare(self, value): - """Prepare the value for serialization and presentation to the client. - """ - - # By default, do nothing to the value. - return value - - def clean(self, value): - """Cleans the value from deserialization into consumption by python. - """ - - # By default, do nothing to the value. - return value - - def try_clean(self, value): - try: - # TODO: Remove usage of this in query builder. - return self.clean(value) - - except (ValueError, AssertionError): - return None - - def clone(self): - """Construct an identical attribute. - - Used by the resource metaclass to ensure all attributes are unique - instances. This is done so that when getters and setters are resolved - the caches don't clobber base classes (for inherited attributes). - """ - - return self.__class__(**self.__dict__) - - def _make_getter(self, segment, class_): - - # Attempt to resolve properties and simple functions by - # accessing the class attribute directly. - obj = getattr(class_, segment, None) - if obj is not None: - if hasattr(obj, '__call__'): - return obj.__call__ - - if hasattr(obj, '__get__'): - return lambda target, x=obj.__get__: x(target, class_) - - # Check for much better hidden descriptor. - obj = class_.__dict__.get(segment) - if obj is not None and hasattr(obj, '__get__'): - return lambda target, x=obj.__get__: x(target, class_) - - # Check for item access (for a dictionary). - if hasattr(class_, '__getitem__'): - def getter(target): - try: - return target[segment] - - except KeyError: - return None - - return getter - - # Check for attribute access. - if hasattr(class_, '__dict__'): - return lambda target: target.__dict__.get(segment) - - raise RuntimeError( - 'unable to resolve attribute access for %r on %r' % ( - segment, class_)) - - def _make_setter(self, segment, class_): - - # Attempt to resolve a data descriptor. - obj = getattr(class_, segment, None) - if obj is not None: - if hasattr(obj, '__set__'): - def setter(target, value, x=obj.__set__): - x(target, value) - - return setter - - # Check for much better hidden descriptor. - obj = class_.__dict__.get(segment) - if obj is not None and hasattr(obj, '__set__'): - def setter(target, value, x=obj.__set__): - x(target, value) - - return setter - - # Check for item access (for a dictionary). - if hasattr(class_, '__getitem__'): - def setter(target, value): - target[segment] = value - - return setter - - # Check for attribute access. - if hasattr(class_, '__dict__'): - def setter(target, value): - target.__dict__[segment] = value - - return setter - - raise RuntimeError( - 'unable to resolve attribute setter for %r on %r' % ( - segment, class_)) diff --git a/armet/attributes/decimal.py b/armet/attributes/decimal.py deleted file mode 100644 index 2897dc0..0000000 --- a/armet/attributes/decimal.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .attribute import Attribute -import decimal - - -class DecimalAttribute(Attribute): - - type = decimal.Decimal - - def prepare(self, value): - if value is None: - return None - - return six.text_type(float(value)) - - def clean(self, value): - if isinstance(value, decimal.Decimal): - # Value is already an integer. - return value - - if isinstance(value, six.string_types): - # Strip the string of whitespace - value = value.strip() - - if not value: - # Value is nothing; return nothing. - return None - - try: - # Attempt to coerce whatever we have as an int. - return decimal.Decimal(value) - - except (ValueError, decimal.InvalidOperation): - # Failed to do so. - raise ValueError('Not a valid decimal value.') diff --git a/armet/attributes/primitive.py b/armet/attributes/primitive.py deleted file mode 100644 index 14a008c..0000000 --- a/armet/attributes/primitive.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .attribute import Attribute - - -class BooleanAttribute(Attribute): - - type = bool - - #! Textual values accepted for `True`. - TRUE = ( - 'true', - 't', - 'yes', - 'y', - 'on', - '1' - ) - - #! Textual values accepted for `False`. - FALSE = ( - 'false', - 'f', - 'no', - 'n', - 'off', - '0' - ) - - def clean(self, value): - if value is None: - # Value is nothing; return it. - return value - - if value is True or value is False: - # Value is a python boolean; just return it. - return value - - if value.strip().lower() in self.TRUE: - # Some sort of truthy value. - return True - - if value.strip().lower() in self.FALSE: - # Some sort of falsy value. - return False - - # Neither true or false matches; return a boolifyed version of - # whatever we have. - return bool(value) - - -class TextAttribute(Attribute): - - type = six.text_type - - def clean(self, value): - return six.text_type(value) if value is not None else None - - -class IntegerAttribute(Attribute): - - type = int - - def clean(self, value): - if isinstance(value, six.integer_types): - # Value is already an integer. - return value - - if isinstance(value, six.string_types): - # Strip the string of whitespace - value = value.strip() - - if not value: - # Value is nothing; return nothing. - return None - - try: - # Attempt to coerce whatever we have as an int. - return int(value) - - except ValueError: - # Failed to do so. - raise ValueError('Not a valid integral value.') diff --git a/armet/attributes/temporal.py b/armet/attributes/temporal.py deleted file mode 100644 index 23e5433..0000000 --- a/armet/attributes/temporal.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .attribute import Attribute -import datetime -from armet import exceptions - - -try: - # Attempt to import and make use of date/time libraries. - # TODO: Make note of this optional dep somewhere. - from dateutil.parser import parse as parse_datetime - from time import mktime - -except ImportError: - # No support for date/times. - parse_datetime = None - - -class _TemporalAttribute(Attribute): - - """Represents a temporal attribute, such as a date or time. - """ - - def __init__(self, *args, **kwargs): - # Ensure we have support. - if parse_datetime is None: - raise exceptions.ImproperlyConfigured( - 'Use of temporal attributes requires at ' - 'least python-dateutil (and optionally parsedatetime).') - - # Continue on. - super(_TemporalAttribute, self).__init__(*args, **kwargs) - - def prepare(self, value): - if not value: - return None - - # Serialize as ISO format. - return value.isoformat() - - def clean(self, value): - if not value: - # Value is nothing; return it. - return value - - try: - # Attempt to use the dateutil library to parse. - return parse_datetime(value, fuzzy=False) - - except (TypeError, ValueError, AttributeError): - # Not a strictly formatted date; return nothing. - pass - - try: - # Attempt to magic a date out of it. - # TODO: List this somewhere as an optional dep. - import parsedatetime as pdt - # from parsedatetime import parsedatetime as pdt - c = pdt.Constants() - c.BirthdayEpoch = 80 # TODO: Figure out what this is. - p = pdt.Calendar(c) - result = p.parse(value) - if result[1] != 0: - return datetime.datetime.fromtimestamp(mktime(result[0])) - - except (NameError, ImportError, TypeError): - # No magical date/time support. - pass - - # Couldn't figure out what we're dealing with. - raise ValueError('Invalid date/time or in an invalid format.') - - -class DateAttribute(_TemporalAttribute, Attribute): - - type = datetime.date - - def clean(self, value): - value = super(DateAttribute, self).clean(value) - if value: - # Constrain to just the date part. - return value.date() - - -class TimeAttribute(_TemporalAttribute, Attribute): - - type = datetime.time - - def clean(self, value): - value = super(TimeAttribute, self).clean(value) - if value: - # Constrain to just the date part. - return value.time() - - -class DateTimeAttribute(_TemporalAttribute, Attribute): - - type = datetime.datetime diff --git a/armet/attributes/timezone.py b/armet/attributes/timezone.py deleted file mode 100644 index 016ed39..0000000 --- a/armet/attributes/timezone.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet import exceptions -from .attribute import Attribute - - -try: - # TODO: List this somewhere as an optional dep. - import pytz - -except ImportError: - # No timezone support. - pytz = None - - -class TimezoneAttribute(Attribute): - - if pytz is not None: - type = pytz.tzfile.DstTzInfo - - def __init__(self, *args, **kwargs): - # Ensure we have support. - if pytz is None: - raise exceptions.ImproperlyConfigured( - 'Use of the timezone attribute requires pytz') - - # Continue on. - super(TimezoneAttribute, self).__init__(*args, **kwargs) - - def prepare(self, value): - return value.zone - - def clean(self, value): - if value is None: - # Value is nothing; return it. - return value - - try: - # Attempt to coerce the timezone. - return pytz.timezone(value) - - except pytz.UnknownTimeZoneError: - raise ValueError('Unknown time zone.') diff --git a/armet/attributes/uuid.py b/armet/attributes/uuid.py deleted file mode 100644 index 88b88ee..0000000 --- a/armet/attributes/uuid.py +++ /dev/null @@ -1,69 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .attribute import Attribute -from armet import exceptions -import uuid - - -try: - import shortuuid - -except ImportError: - shortuuid = None - - -class UUIDAttribute(Attribute): - - type = uuid.UUID - - def __init__(self, *args, **kwargs): - super(UUIDAttribute, self).__init__(*args, **kwargs) - - #! Encode and decode the UUID using 'shortuuid' (only if available). - self.short = kwargs.get('short') - if self.short is None or self.short: - if shortuuid is None: - if self.short is None: - self.short = False - return - - else: - raise exceptions.ImproperlyConfigured( - "Use of 'short' UUID attributes requires the " - "'shortuuid' package.") - - # Default to using short UUIDs if we have the package. - self.short = True - - def prepare(self, value): - if value is None: - return None - - if self.short: - # Serialize as a 22-digit hex representation. - return shortuuid.encode(value) - - # Serialize as the 32-digit hex representation. - return value.hex - - def clean(self, value): - if value is None: - # Value is nothing; return it. - return value - - try: - try: - if self.short: - # Attempt to coerce the short UUID. - return shortuuid.decode(value) - - except ValueError: - pass - - # Attempt to coerce the UUID. - return uuid.UUID(value) - - except ValueError: - raise ValueError( - 'UUID must be of the form: ' - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx') diff --git a/armet/authentication.py b/armet/authentication.py deleted file mode 100644 index 44847a4..0000000 --- a/armet/authentication.py +++ /dev/null @@ -1,94 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Describes the authentication protocols and generalizations used to -authenticate access to a resource endpoint. -""" -from __future__ import absolute_import, unicode_literals, division -from armet import http -import base64 - - -class Authentication(object): - - #! Whether returning `None` from `authenticate` indicates - #! unauthenticated; or, simply no authenticated user. - allow_anonymous = True - - def __init__(self, **kwargs): - """Initialize authentication protocol; establish parameters. - """ - - # Allow overriding class attributes. - self.__dict__.update(kwargs) - - def authenticate(self, request): - """Gets the a user if they are authenticated; else None. - - @retval False Unable to authenticate. - @retval None Able to authenticate but failed. - @retval User object representing the current user. - """ - return None - - def unauthenticated(self): - """ - Callback that is invoked when after a user is determined to - be unauthenticated. - """ - raise http.exceptions.Forbidden() - - -class HeaderAuthentication(Authentication): - - def can_authenticate(self, request, method): - # Determine if we can authenticate. - return False - - def get_credentials(self, text): - # Decode credentials. - return text, - - def get_user(self, request, method, *args): - """ - Callback that is invoked when a user is attempting to be - authenticated with a set of credentials. - """ - return None - - def authenticate(self, request): - # Retrieve the authorization header. - header = request.headers.get('Authorization') - - try: - # Split the authorization header into method and credentials. - method, text = (header or '').split(' ', 1) - - except ValueError: - # Strange format in the header. - return False - - if not self.can_authenticate(request, method): - # Not the right kind of authentication. - return False - - # Retreive and return the user object. - return self.get_user(request, method, *self.get_credentials(text)) - - -class BasicAuthentication(HeaderAuthentication): - - allow_anonymous = False - - def can_authenticate(self, request, method): - # Determine if we can authenticate. - return method.lower() == 'basic' - - def get_credentials(self, text): - try: - # Decode credentials. - text = base64.b64decode(text.encode('utf8')).decode('utf8') - return text.split(':', 1) - - except base64.binascii.Error: - # Decoding error. - return None, None diff --git a/armet/authorization.py b/armet/authorization.py deleted file mode 100644 index 2b8b9e9..0000000 --- a/armet/authorization.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Describes the authorization protocols and generalizations used to -authorize access to a resource endpoint. -""" -from __future__ import absolute_import, unicode_literals, division -from armet.exceptions import ImproperlyConfigured -from armet import http - - -class Authorization(object): - """ - Establishes the protocol for authorization; by default, all users are - authorized. - """ - - def is_accessible(self, user, method, resource): - """ - Determines the accessibility to a resource endpoint for a particular - method. An inaccessible resource is indistinguishable from a - non-existant resource. - - @param[in] user - The user in question that is being checked. - - @param[in] method - The method in question that is being performed (eg. 'GET'). - - @param[in] resource - The resource instance that is being authorized. - - @returns - Returns true if the user can access the resource for - the passed operation; otherwise, false. - """ - return True - - def inaccessible(self): - """Informs the client that the resource is inaccessible.""" - raise http.exceptions.Forbidden() - - def is_authorized(self, user, operation, resource, item): - """Determines authroization to a specific resource object. - - @param[in] user - The user in question that is being checked. - - @param[in] operation - The operation in question that is being performed (eg. 'read'). - - @param[in] resource - The resource instance that is being authorized. - - @param[in] item - The specific instance of an object returned by a `read` from - the `resource`. - - @returns - Returns true to indicate authorization or false to indicate - otherwise. - """ - return True - - def unauthorized(self): - """Informs the client that it is not authrozied for the resource.""" - raise http.exceptions.Forbidden() - - def filter(self, user, operation, resource, iterable): - """ - Filters an iterable to contain only the items for which the user - is authorized to perform the operation on. - - @param[in] user - The user in question that is being checked. - - @param[in] operation - The operation in question that is being performed (eg. 'read'). - - @param[in] resource - The resource instance that is being authorized. - - @param[in] iterable - The iterable of objects to be checked. This method is called - from the model connector so the actual value of this parameter - depends on the model connector (eg. it may be a django queryset). - - @returns - Returns an iterable containing the remaining objects. - """ - return iterable - - -class ManagedAuthorization(Authorization): - """Extends the authorization protocol to apply to managed resources. - - Managed resources only check accessibility and authorization based on - the operation (eg. 'read') and not the method (eg. 'GET'). - """ - - def inaccessible(self): - """Informs the client that the resource is inaccessible.""" - raise http.exceptions.NotFound() diff --git a/armet/codecs.py b/armet/codecs.py new file mode 100644 index 0000000..fc2f7da --- /dev/null +++ b/armet/codecs.py @@ -0,0 +1,65 @@ +import mimeparse +from .registry import Registry + + +class CodecRegistry(Registry): + """A registry used for registering and removing encoders and decoders. + """ + + def __init__(self, *args, **kwargs): + kwargs.setdefault("index", {"name", "mime_type"}) + super().__init__(*args, **kwargs) + + def find_media_range(self, media_range): + try: + # best_match returns empty string on failure to find a match. + # The correct KeyError will be thrown when attempting the lookup + # below. + found = mimeparse.best_match(self.map["mime_type"].keys(), + media_range) + + except ValueError: + raise KeyError('Malformed media range.') + + # Push the resolved mime_type into the `find` method. + return self.find(mime_type=found)[0] + + +class URLCodec: + + preferred_mime_type = 'application/x-www-form-urlencoded' + + mime_types = {preferred_mime_type} + + names = {'url'} + + +class JSONCodec: + + # Offical; as per RFC 4627. + preferred_mime_type = 'application/json' + + mime_types = { + preferred_mime_type, + + # Widely used (thanks .) + 'application/jsonrequest', + + # Miscellaneous mimetypes that are used frequently (incorrectly). + 'application/x-json', + 'text/json', + + # Widely used (incorrectly) thanks to ruby. + 'text/x-json', + } + + names = {'json'} + + +class FormCodec: + + preferred_mime_type = 'multipart/form-data' + + mime_types = {preferred_mime_type} + + names = {'form'} diff --git a/armet/connectors/__init__.py b/armet/connectors/__init__.py deleted file mode 100644 index 87f31b8..0000000 --- a/armet/connectors/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - -#! List of available HTTP/1.1 connectors. -http = ('bottle', 'flask', 'django',) - -#! List of available ORM connectors. -model = ('django', 'sqlalchemy') diff --git a/armet/connectors/bottle/http.py b/armet/connectors/bottle/http.py deleted file mode 100644 index 684f7c2..0000000 --- a/armet/connectors/bottle/http.py +++ /dev/null @@ -1,152 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet import http -import bottle - - -class RequestHeaders(http.request.Headers): - - def __getitem__(self, name): - return bottle.request.headers[name] - - def __iter__(self): - return iter(bottle.request.headers) - - def __len__(self): - return len(bottle.request.headers) - - def __contains__(self, name): - return name in bottle.request.headers - - -class Request(http.Request): - - def __init__(self, *args, **kwargs): - # Initialize the request headers. - self.headers = RequestHeaders() - - # Set the method of the request. - request = bottle.request - kwargs.update(method=request.method) - - # Elide the thread-safe request copy and the global bottle.request. - self._handle = request.copy() if kwargs['asynchronous'] else request - - # Continue the initialization. - super(Request, self).__init__(*args, **kwargs) - - def _read(self): - return self._handle.body.read() - - @property - def protocol(self): - return self._handle.urlparts.scheme.upper() - - @property - def mount_point(self): - path = self._handle.path - return path[:path.rfind(self.path)] if self.path else path - - @property - def query(self): - return self._handle.query_string - - @property - def uri(self): - return self._handle.url - - -class ResponseHeaders(http.response.Headers): - - def __init__(self, response, handle): - #! Reference to the response object. - self._response = response - - #! Reference to the underlying response handle. - self._handle = handle - - # Continue the initialization. - super(ResponseHeaders, self).__init__() - - def __setitem__(self, name, value): - self._response.require_open() - self._handle.headers[self.normalize(name)] = value - - def __getitem__(self, name): - return self._handle.headers[name] - - def __contains__(self, name): - return name in self._handle.headers - - def __delitem__(self, name): - self._response.require_open() - del self._handle.headers[name] - - def __len__(self): - return len(self._handle.headers) - - def __iter__(self): - return iter(self._handle.headers) - - -class Response(http.Response): - - def __init__(self, *args, **kwargs): - # Elide the thread-safe response copy and the global bottle.response. - self._handle = bottle.response - - # Complete the initialization. - super(Response, self).__init__(*args, **kwargs) - - # If we're dealing with an asynchronous response, we need - # to have a thread-safe response handle as well as an - # asynchronous queue to give to WSGI. - if self.asynchronous: - from gevent import queue - - self._handle = self._handle.copy() - self._queue = queue.Queue() - - # Initialize the response headers. - self.headers = ResponseHeaders(self, self._handle) - - def __iter__(self): - # Return the asynchronous queue. - return self._queue - - @property - def status(self): - return self._handle.status_code - - @status.setter - def status(self, value): - self.require_open() - self._handle.status = value - - @http.Response.body.setter - def body(self, value): - if value: - if not self.streaming: - # Point the response handle to our constructed response. - bottle.response.status = self.status - bottle.response.headers.update(self.headers) - - if self.asynchronous: - # Unset the underlying store. - super(Response, Response).body.__set__(self, None) - - # Write the chunk to the asynchronous queue. - self._queue.put(value) - return - - # Set the underlying store. - super(Response, Response).body.__set__(self, value) - - def close(self): - # Perform general clean-up and a final flush. - super(Response, self).close() - - if self.asynchronous: - # Close the asynchronous queue and terminate the connection - # to the client. - self._queue.put(StopIteration) diff --git a/armet/connectors/bottle/resources.py b/armet/connectors/bottle/resources.py deleted file mode 100644 index 6552d81..0000000 --- a/armet/connectors/bottle/resources.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import bottle -from . import http - - -class Resource(object): - - @classmethod - def view(cls, *args, **kwargs): - # Construct request and response wrappers. - async = cls.meta.asynchronous - request = http.Request(path=kwargs.get('path', ''), asynchronous=async) - response = http.Response(asynchronous=async) - - # Defer the execution thread if we're running asynchronously. - if async: - # Defer the view to pass of control. - import gevent - gevent.spawn(super(Resource, cls).view, request, response) - - # Construct and return a streamer. - return cls.stream(response, response) - - # Pass control off to the resource handler. - return super(Resource, cls).view(request, response) - - @classmethod - def mount(cls, url='/', application=None): - if application is None: - # If no explicit application is passed; use - # the current default application. - application = bottle.app[-1] - - # Generate a name to use to mount this resource. - name = '{}:{}:{}'.format('armet', cls.__module__, cls.meta.name) - - # Apply the routing rules and add the URL route. - rule = '{}{}'.format(url, cls.meta.name) - application.route(rule, 'ANY', cls.view, name) diff --git a/armet/connectors/django/http.py b/armet/connectors/django/http.py deleted file mode 100644 index be5bdb2..0000000 --- a/armet/connectors/django/http.py +++ /dev/null @@ -1,179 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from django.http import HttpResponse -from armet import http -from io import BytesIO -import re - - -# For almost all headers, django prefixes the header with `HTTP_`. This is a -# list of headers that are an exception to that rule. -SPECIAL_HEADERS = ('CONTENT_TYPE', 'CONTENT_LENGTH') - - -def _normalize(name): - name = re.sub(r'^HTTP-', '', name.replace('_', '-')) - name = http.request.Headers.normalize(name) - return name - - -def _denormalize(name): - name = name.replace('-', '_').upper() - return name if name in SPECIAL_HEADERS else 'HTTP_' + name - - -class RequestHeaders(http.request.Headers): - - def __init__(self, handle): - #! Reference to the underlying response handle. - self._handle = handle - - # Continue the initialization. - super(RequestHeaders, self).__init__() - - @staticmethod - def normalize(name): - # Proxy for internal usage of this. - return _normalize(name) - - def __getitem__(self, name): - return self._handle.META[_denormalize(name)] - - def __iter__(self): - for name in self._handle.META: - if name.startswith('HTTP_') or name in SPECIAL_HEADERS: - yield self.normalize(name) - - def __len__(self): - return sum(1 for x in self) - - def __contains__(self, name): - return _denormalize(name) in self._handle.META - - -class Request(http.Request): - - def __init__(self, request, *args, **kwargs): - # Store the request handle. - self._handle = request - - # Initialize the request headers. - self.headers = RequestHeaders(request) - - # Store a stream of the request. - self._stream = BytesIO(request.body) - - # Set the method of the request. - kwargs.update(method=self._handle.method) - - # Continue the initialization. - super(Request, self).__init__(*args, **kwargs) - - def _read(self): - return self._stream.read() - - @property - def protocol(self): - return self._handle.META['SERVER_PROTOCOL'].split('/')[0].upper() - - @property - def mount_point(self): - path = self._handle.path - return path[:path.rfind(self.path)] if self.path else path - - @property - def query(self): - return self._handle.META.get('QUERY_STRING', '') - - @property - def uri(self): - return self._handle.build_absolute_uri() - - -class ResponseHeaders(http.response.Headers): - - def __init__(self, response, handle): - #! Reference to the response object. - self._response = response - - #! Reference to the underlying response handle. - self._handle = handle - - # Continue the initialization. - super(ResponseHeaders, self).__init__() - - def __getitem__(self, name): - return self._handle[name] - - def __setitem__(self, name, value): - self._response.require_open() - self._handle[self.normalize(name)] = value - - def __delitem__(self, name): - self._response.require_open() - del self._handle[name] - - def __contains__(self, name): - return self._handle.has_header(name) - - def __iter__(self): - for name, _ in self._handle.items(): - yield name - - def __len__(self): - return len(self._handle._headers) - - -class Response(http.Response): - - def __init__(self, *args, **kwargs): - # Construct and store a new response object. - self._handle = HttpResponse() - - # Complete the initialization. - super(Response, self).__init__(*args, **kwargs) - - # If we're dealing with an asynchronous response, we need - # to have an asynchronous queue to give to WSGI. - if self.asynchronous: - from gevent import queue - self._queue = queue.Queue() - - # Initialize the response headers. - self.headers = ResponseHeaders(self, self._handle) - - def __iter__(self): - # Return the asynchronous queue. - return self._queue - - @property - def status(self): - return self._handle.status_code - - @status.setter - def status(self, value): - self.require_open() - self._handle.status_code = value - - @http.Response.body.setter - def body(self, value): - if value: - if self.asynchronous: - # Unset the underlying store. - super(Response, Response).body.__set__(self, None) - - # Write the chunk to the asynchronous queue. - self._queue.put(value) - return - - # Set the underlying store. - super(Response, Response).body.__set__(self, value) - - def close(self): - # Perform general clean-up and a final flush. - super(Response, self).close() - - if self.asynchronous: - # Close the asynchronous queue and terminate the connection - # to the client. - self._queue.put(StopIteration) diff --git a/armet/connectors/django/resources.py b/armet/connectors/django/resources.py deleted file mode 100644 index 2cebc1e..0000000 --- a/armet/connectors/django/resources.py +++ /dev/null @@ -1,255 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import operator -import six -from six.moves import map, reduce -from django.conf import urls -from django.db.models import Q -from django.views.decorators import csrf -from armet import utils -from . import http -from armet.query import parser, Query, QuerySegment, constants - - -class Resource(object): - - @classmethod - @csrf.csrf_exempt - def view(cls, django_request, *args, **kwargs): - # Construct request and response wrappers. - async = cls.meta.asynchronous - path = kwargs.get('path') or '' - request = http.Request(django_request, path=path, asynchronous=async) - response = http.Response(asynchronous=async) - - # Defer the execution thread if we're running asynchronously. - if async: - # Defer the view to pass of control. - import gevent - gevent.spawn(super(Resource, cls).view, request, response) - - # Construct and return the generator response. - response._handle.content = cls.stream(response, response) - return response._handle - - # Pass control off to the resource handler. - result = super(Resource, cls).view(request, response) - - # Configure the response and return it. - response._handle.content = result - return response._handle - - @utils.classproperty - def urls(cls): - """Builds the URL configuration for this resource.""" - return urls.patterns('', urls.url( - r'^{}(?:$|(?P[/:(.].*))'.format(cls.meta.name), - cls.view, - name='armet-api-{}'.format(cls.meta.name), - kwargs={'resource': cls.meta.name})) - - -# Build an operator map to use for django. -OPERATOR_MAP = { - constants.OPERATOR_EQUAL: '__exact', - constants.OPERATOR_IEQUAL: '__iexact', - constants.OPERATOR_LT: '__lt', - constants.OPERATOR_GT: '__gt', - constants.OPERATOR_LTE: '__lte', - constants.OPERATOR_GTE: '__gte', -} - -# import ipdb; ipdb.set_trace() -# Rewire the map. -OPERATOR_MAP = dict( - (constants.OPERATOR_MAP[k], v) for k, v in OPERATOR_MAP.items() -) - - -def segment_query(seg, attributes): - - # Get the attribute in question. - attribute = attributes[seg.path[0]] - - # Replace the initial path segment with the expanded - # attribute path. - seg.path[0:1] = attribute.path.split('.') - - # Boolean's should use `exact` rather than `iexact`. - if attribute.type is bool: - op = '__exact' - else: - op = OPERATOR_MAP[seg.operator] - - # Build the path from the segment. - path = '__'.join(seg.path) + op - - # Construct a Q-object from the segment. - return reduce(operator.or_, - map(lambda x: Q((path, x)), - map(attribute.try_clean, seg.values))) - - -def noop_query(*args): - return Q() - - -def unary_query(query, *args): - return query.operation(build_clause(query.operand, *args)) - - -def binary_query(query, *args): - return query.operation( - build_clause(query.left, *args), - build_clause(query.right, *args)) - - -CLAUSE_MAP = { - parser.NoopQuerySegment: noop_query, - parser.BinarySegmentCombinator: binary_query, - parser.UnarySegmentCombinator: unary_query, - parser.QuerySegment: segment_query, -} - - -def build_clause(query, attributes): - class_ = type(query) if not isinstance(query, type) else query - fn = CLAUSE_MAP.get(class_) - if fn is not None: - return fn(query, attributes) - elif issubclass(class_, Query): - return build_clause(query.parsed, attributes) - else: - raise ValueError('Unable to translate query node %s' % str(query)) - -# def build_clause(query, attributes): -# # Iterate through each query segment. -# clause = None -# last = None -# for seg in query.segments: -# # Get the attribute in question. -# attribute = attributes[seg.path[0]] - -# # Replace the initial path segment with the expanded -# # attribute path. -# seg.path[0:1] = attribute.path.split('.') - -# # Boolean's should use `exact` rather than `iexact`. -# if attribute.type is bool: -# op = '__exact' -# else: -# op = OPERATOR_MAP[seg.operator] - -# # Build the path from the segment. -# path = '__'.join(seg.path) + op - -# # Construct a Q-object from the segment. -# q = reduce(operator.or_, -# map(lambda x: Q((path, x)), -# map(attribute.try_clean, seg.values))) - -# # Combine the segment with the last. -# clause = last.combinator(clause, q) if last is not None else q -# last = seg - -# # Return the constructed clause. -# return clause - - -class ModelResource(object): - - def filter(self, clause, queryset): - # Filter the queryset by the passed clause. - return queryset.filter(clause).distinct() - - def count(self, queryset): - # Return the count of the queryset. - return len(queryset) - - def read(self): - # Initialize the queryset to the model manager. - queryset = self.meta.model.objects - - query = None - if self.slug is not None: - # This is an item-access (eg. GET //:slug); ignore the - # query string and generate a query-object based on the slug. - query = Query( - original=None, - parsed=QuerySegment( - path=self.meta.slug.path.split('.'), - operator=constants.OPERATOR_MAP[constants.OPERATOR_EQUAL], - values=[self.slug] - ) - ) - - elif self.request.query: - # This is a list-access; use the query string and construct - # a query object from it. - query = parser.parse(self.request.query) - - # Determine if we need to filter the queryset in some way; and if so, - # filter it. - if query is not None: - clause = build_clause(query, self.attributes) - queryset = self.filter(clause, queryset) - - # Filter the queryset by asserting authorization. - queryset = self.meta.authorization.filter( - self.request.user, 'read', self, queryset) - - if self.slug is not None: - # Attempt to return just the single result we should have. - result = queryset.all()[:1] - return result[0] if result else None - - # Return the entire queryset. - return queryset.all() - - def create(self, data): - # Instantiate a new target. - target = self.meta.model() - - # Iterate through all attributes and set each one. - for name, attribute in six.iteritems(self.attributes): - # Set each one on the target. - value = data.get(name) - if value is not None: - attribute.set(target, value) - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'create', self, target): - authz.unauthorized() - - # Save the target. - target.save() - - # Return the target. - return target - - def update(self, target, data): - # Iterate through all attributes and set each one. - for name, attribute in six.iteritems(self.attributes): - # Set each one on the target. - attribute.set(target, data.get(name)) - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'update', self, target): - authz.unauthorized() - - # Save the target. - target.save() - - def destroy(self): - # Grab the existing target. - target = self.read() - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'destroy', self, target): - authz.unauthorized() - - # Destroy the target. - target.delete() diff --git a/armet/connectors/flask/__init__.py b/armet/connectors/flask/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/armet/connectors/flask/http.py b/armet/connectors/flask/http.py deleted file mode 100644 index dda85fd..0000000 --- a/armet/connectors/flask/http.py +++ /dev/null @@ -1,158 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from armet import http -import flask -from flask.globals import current_app -from werkzeug.wsgi import get_current_url - - -class RequestHeaders(http.request.Headers): - - def __init__(self, handle): - #! Reference to the underlying request handle. - self._handle = handle - - # Continue the initialization. - super(RequestHeaders, self).__init__() - - def __getitem__(self, name): - return self._handle.headers[name] - - def __iter__(self): - return (key for key, _ in self._handle.headers) - - def __len__(self): - return len(self._handle.headers) - - def __contains__(self, name): - return name in self._handle.headers - - -class Request(http.Request): - - def __init__(self, *args, **kwargs): - # Elide the thread-safe request copy and the global bottle.request. - request = flask.request - async = kwargs['asynchronous'] - self._handle = request if not async else flask.Request(request.environ) - - # Initialize the request headers. - self.headers = RequestHeaders(self._handle) - - # Set the method of the request. - kwargs.update(method=request.method) - - # Continue the initialization. - super(Request, self).__init__(*args, **kwargs) - - def _read(self): - return self._handle.stream.read() - - @property - def protocol(self): - return self._handle.scheme.upper() - - @property - def mount_point(self): - path = self._handle.path - return path[:path.rfind(self.path)] if self.path else path - - @property - def query(self): - if isinstance(self._handle.query_string, six.binary_type): - return self._handle.query_string.decode('utf8') - - return self._handle.query_string - - @property - def uri(self): - return get_current_url(self._handle.environ) - - -class ResponseHeaders(http.response.Headers): - - def __init__(self, response, handle): - #! Reference to the response object. - self._response = response - - #! Reference to the underlying response handle. - self._handle = handle - - # Continue the initialization. - super(ResponseHeaders, self).__init__() - - def __getitem__(self, name): - return self._handle.headers[name] - - def __setitem__(self, name, value): - self._response.require_open() - self._handle.headers[self.normalize(name)] = value - - def __contains__(self, name): - return name in self._handle.headers - - def __delitem__(self, name): - self._response.require_open() - del self._handle.headers[name] - - def __iter__(self): - return (key for key, _ in self._handle.headers) - - def __len__(self): - return len(self._handle.headers) - - -class Response(http.Response): - - def __init__(self, *args, **kwargs): - # Construct and store a new response object. - self._handle = current_app.response_class() - - # Complete the initialization. - super(Response, self).__init__(*args, **kwargs) - - # If we're dealing with an asynchronous response, we need - # to have an asynchronous queue to give to WSGI. - if self.asynchronous: - from gevent import queue - self._queue = queue.Queue() - - # Initialize the response headers. - self.headers = ResponseHeaders(self, self._handle) - - def __iter__(self): - # Return the asynchronous queue. - return self._queue - - @property - def status(self): - return self._handle.status_code - - @status.setter - def status(self, value): - self.require_open() - self._handle.status_code = value - - @http.Response.body.setter - def body(self, value): - if value: - if self.asynchronous: - # Unset the underlying store. - super(Response, Response).body.__set__(self, None) - - # Write the chunk to the asynchronous queue. - self._queue.put(value) - return - - # Set the underlying store. - super(Response, Response).body.__set__(self, value) - - def close(self): - # Perform general clean-up and a final flush. - super(Response, self).close() - - if self.asynchronous: - # Close the asynchronous queue and terminate the connection - # to the client. - self._queue.put(StopIteration) diff --git a/armet/connectors/flask/resources.py b/armet/connectors/flask/resources.py deleted file mode 100644 index 8731cf7..0000000 --- a/armet/connectors/flask/resources.py +++ /dev/null @@ -1,121 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import collections -import flask -from werkzeug.routing import BaseConverter -from . import http - - -class RegexConverter(BaseConverter): - """Regular expression URL converter for werkzeug / flask. - """ - - def __init__(self, url_map, pattern=r'(.*)'): - super(RegexConverter, self).__init__(url_map) - self.regex = pattern - - -class Resource(object): - - @classmethod - def view(cls, *args, **kwargs): - # Initiate the base view request cycle. - path = kwargs.get('path', '') - if flask.request.path.endswith('/'): - # The trailing slash is stripped for fun by werkzeug. - path += '/' - - # Construct request and response wrappers. - async = cls.meta.asynchronous - request = http.Request(path=path, asynchronous=async) - response = http.Response(asynchronous=async) - - # Defer the execution thread if we're running asynchronously. - if async: - # Defer the view to pass of control. - import gevent - gevent.spawn(super(Resource, cls).view, request, response) - - # Construct and return the generator response. - response._handle.response = cls.stream(response, response) - return response._handle - - # Pass control off to the resource handler. - result = super(Resource, cls).view(request, response) - - if isinstance(result, collections.Iterator): - # Construct and return the generator response. - response._handle.response = result - return response._handle - - # Configure the response if we received any data. - if result is not None: - response._handle.data = result - - # Return the response. - return response._handle - - @classmethod - def mount(cls, url, app=None): - # Generate a name to use to mount this resource. - name = 'armet' + cls.__module__ + cls.meta.name + url - - # If application is not provided; make use of the app context. - if app is None: - app = flask.current_app - - # Prepare the flask application to accept armet resources. - # The strict slashes setting must be False for our routes. - strict_slashes = app.url_map.strict_slashes = False - - # Ensure that there is a compliant regex converter available. - converter = app.url_map.converters['default'] - app.url_map.converters['default'] = RegexConverter - - # Mount this resource. - pattern = '{}{}'.format(url, cls.meta.name) - rule = app.url_rule_class(pattern, endpoint=name) - app.url_map.add(rule) - app.view_functions[name] = cls.view - - # Restore the flask environment. - app.url_map.strict_slashes = strict_slashes - app.url_map.converters['default'] = converter - - def _request_read(self, path): - # Save the current request object. - _req = flask.request._get_current_object() - - # Build a new environ object. - env = dict(flask.request.environ) - env['PATH_INFO'] = path - env['REQUEST_METHOD'] = 'GET' - if 'HTTP_X_HTTP_METHOD_OVERRIDE' in env: - del env['HTTP_X_HTTP_METHOD_OVERRIDE'] - - # Build and insert a new request object. - req = flask.Request(env) - flask._request_ctx_stack.top.request = req - - # Bind the url-map and pull out the - urls = flask.current_app.url_map.bind_to_environ(env) - endpoint, args = urls.match() - - # Pull out the resource class. - cls = flask.current_app.view_functions[endpoint].__self__ - - # Construct a request wrapper. - request = http.Request(path=args['path'], asynchronous=False) - - # Construct a resource object. - resource = cls(request=request, response=None) - - # Perform the `read` request. - resource.require_authentication(resource.request) - result = resource.read() - - # Restore the request object. - flask._request_ctx_stack.top.request = _req - - # Return what we read - return result diff --git a/armet/connectors/sqlalchemy/__init__.py b/armet/connectors/sqlalchemy/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/armet/connectors/sqlalchemy/authorization.py b/armet/connectors/sqlalchemy/authorization.py deleted file mode 100644 index e278a0f..0000000 --- a/armet/connectors/sqlalchemy/authorization.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet.authorization import ManagedAuthorization -from armet.exceptions import ImproperlyConfigured - - -try: - import shield - -except ImportError: - shield = None - - -class ShieldAuthorization(ManagedAuthorization): - """Implements managed authorization using the shield permissions framework. - """ - - def __init__(self, permissions=None): - # Ensure we have access to the shield framework. - if shield is None: - raise ImproperlyConfigured( - "'shield' is required to use 'ShieldAuthorization'") - - #! Permissions to check for each operation. - self.permissions = permissions - if self.permissions is None: - self.permissions = { - 'read': ('read',), - 'create': ('create',), - 'update': ('update',), - 'destroy': ('destroy',) - } - - def is_authorized(self, user, operation, resource, item): - return shield.has( - *self.permissions[operation], - bearer=user, - target=item) - - def filter(self, user, operation, resource, iterable): - query = shield.filter( - *self.permissions[operation], - bearer=user, - target=resource.meta.model, - query=iterable) - - return query.distinct() diff --git a/armet/connectors/sqlalchemy/resources.py b/armet/connectors/sqlalchemy/resources.py deleted file mode 100644 index 6872cc0..0000000 --- a/armet/connectors/sqlalchemy/resources.py +++ /dev/null @@ -1,430 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import operator -from functools import partial -from collections import Iterable -from copy import deepcopy -from six.moves import map, reduce -from armet.exceptions import ImproperlyConfigured -from armet.query import parser, Query, QuerySegment, constants -from armet.http.exceptions import BadRequest -from sqlalchemy.exc import InvalidRequestError -import sqlalchemy as sa -import functools - - -class ModelResourceOptions(object): - - def __init__(self, meta, name, bases): - #! SQLAlchemy session used to perform operations on the models. - self.Session = meta.get('Session') - if not self.Session: - raise ImproperlyConfigured( - 'A session factory (via sessionmaker) is required by ' - 'the SQLAlchemy model connector.') - - -def ilike_helper(default): - """Helper function that performs an `ilike` query if a string value - is passed, otherwise the normal default operation.""" - @functools.wraps(default) - def wrapped(x, y): - # String values should use ILIKE queries. - if isinstance(y, six.string_types) and not isinstance(x.type, sa.Enum): - return x.ilike("%" + y + "%") - else: - return default(x, y) - return wrapped - - -# Build an operator map to use for sqlalchemy. -OPERATOR_MAP = { - constants.OPERATOR_EQUAL: operator.eq, - constants.OPERATOR_IEQUAL: ilike_helper(operator.eq), - constants.OPERATOR_LT: operator.lt, - constants.OPERATOR_GT: operator.gt, - constants.OPERATOR_LTE: operator.le, - constants.OPERATOR_GTE: operator.ge, - constants.OPERATOR_ICONTAINS: ilike_helper(operator.contains), -} - -# Rewire the map. -OPERATOR_MAP = dict( - (constants.OPERATOR_MAP[k], v) for k, v in OPERATOR_MAP.items() -) - - -def build_segment(model, segment, attr, clean): - # Get the associated column for the initial path. - path = segment.path.pop(0) - col = getattr(model, path) - - # Resolve the inner-most path segment. - if segment.path: - if col.impl.accepts_scalar_loader: - return col.has(build_segment( - col.property.mapper.class_, segment, attr, clean)) - - else: - try: - return col.any(build_segment( - col.property.mapper.class_, deepcopy(segment), - attr, clean)) - - except InvalidRequestError: - return col.has(build_segment( - col.property.mapper.class_, deepcopy(segment), - attr, clean)) - - # Determine the operator. - op = OPERATOR_MAP[segment.operator] - - # Apply the operator to the values and return the expression - qs = reduce(operator.or_, - map(partial(op, col), - map(lambda x: clean(attr.try_clean(x)), segment.values))) - - # Apply the negation. - if segment.negated: - qs = ~qs - - # Return our query object. - return qs - - -def segment_query(resource, segment, attributes, cleaners, model): - - attribute = attributes[segment.path[0]] - - # Modify the segment's to reflect what the attribute was declared for. - segment.path[0:1] = attribute.path.split('.') - - # Create a cleaner that can work here. - clean = partial(cleaners[attribute.name], resource) - - # Dispatch to the recursive segment building function - return build_segment(model, segment, attribute, clean) - - -def noop_query(*args): - return sa.sql.true() - - -def unary_query(resource, query, *args): - return query.operation(build_clause(resource, query.operand, *args)) - - -def binary_query(resource, query, *args): - return query.operation( - build_clause(resource, query.left, *args), - build_clause(resource, query.right, *args)) - - -CLAUSE_MAP = { - parser.NoopQuerySegment: noop_query, - parser.BinarySegmentCombinator: binary_query, - parser.UnarySegmentCombinator: unary_query, - parser.QuerySegment: segment_query, -} - - -def build_clause(resource, query, attributes, cleaners, model): - class_ = type(query) if not isinstance(query, type) else query - fn = CLAUSE_MAP.get(class_) - if fn is not None: - return fn(resource, query, attributes, cleaners, model) - elif issubclass(class_, Query): - return build_clause( - resource, - query.parsed, - attributes, - cleaners, - model) - else: - raise ValueError('Unable to translate query node %s' % str(query)) - - -class ModelResource(object): - """Specializes the RESTFul model resource protocol for SQLAlchemy. - - @note - This is not what you derive from to create resources. Import - ModelResource from `armet.resources` and derive from that. - """ - - def route(self, *args, **kwargs): - # Establish a session. - self.session = session = self.meta.Session() - - try: - # Continue on with the cycle. - result = super(ModelResource, self).route(*args, **kwargs) - - # Commit the session. - session.commit() - - # Return the result. - return result - - except: - # Something occurred; rollback the session. - session.rollback() - - # Re-raise the exception. - raise - - finally: - # Close the session. - session.close() - - def filter(self, clause, queryset): - # Filter the queryset by the passed clause. - return queryset.filter(clause).distinct() - - def count(self, queryset): - # Return the count of the queryset. - return queryset.count() - - def read(self): - # Initialize the query to the model. - queryset = self.session.query(self.meta.model) - - query = None - if self.slug is not None: - # This is an item-access (eg. GET //:slug); ignore the - # query string and generate a query-object based on the slug. - query = Query( - original=None, - parsed=QuerySegment( - path=self.meta.slug.path.split('.'), - operator=constants.OPERATOR_MAP[constants.OPERATOR_EQUAL], - values=[self.slug] - ) - ) - - elif self.request.query: - # This is a list-access; use the query string and construct - # a query object from it. - query = parser.parse(self.request.query) - - # Determine if we need to filter the queryset in some way; and if so, - # filter it. - clause = None - if query is not None: - clause = build_clause( - self, query, self.attributes, - self.cleaners, self.meta.model) - - queryset = self.filter(clause, queryset) - - if self.slug is None: - # Filter the queryset by asserting authorization. - queryset = self.meta.authorization.filter( - self.request.user, 'read', self, queryset) - - # Return the queryset. - return queryset - - else: - # Get the item in question. - item = queryset.first() - - # Sanity check to make sure some item was found. - if item is None: - return None - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'read', self, item): - authz.unauthorized() - - # We're good, return the item. - return item - - def create(self, data): - # Instantiate a new target. - target = self.meta.model() - - # Iterate through all attributes and set each one. - for name, attribute in six.iteritems(self.attributes): - # Set each one on the target. - value = data.get(name) - if value is not None: - attribute.set(target, value) - - # Iterate through all write-able relations and set each one. - for name, relation in six.iteritems(self.relationships): - if relation.write: - # Set each one on the target. - value = data.get(name) - if value is not None: - # FIXME: Use some deferred thing that is not `setattr`. - setattr(target, relation.key, value) - - # Add the target to the session. - self.session.add(target) - self.session.flush() - - # Refresh the target object to avoid inconsistencies with storage. - self.session.expire(target) - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'create', self, target): - authz.unauthorized() - - # Return the target. - return target - - def update(self, target, data): - # Iterate through all attributes and set each one. - for name, attribute in six.iteritems(self.attributes): - # Set each one on the target. - attribute.set(target, data.get(name)) - - # Iterate through all write-able relations and set each one. - for name, relation in six.iteritems(self.relationships): - if relation.write: - # Set each one on the target. - # FIXME: Use some deferred thing that is not `setattr`. - setattr(target, relation.key, data.get(name)) - - # Flush the target and expire attributes. - self.session.flush() - - # Refresh the target object to avoid inconsistencies with storage. - self.session.expire(target) - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'update', self, target): - authz.unauthorized() - - def destroy(self): - # Grab the existing target. - target = self.read() - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'destroy', self, target): - authz.unauthorized() - - # Remove the object from the session. - self.session.delete(target) - - def _resolve_relation(self, target): - # Resolve the relationship key. - key = None - related = type(self)._related_models - res_clss = type(target), - while res_clss: - res_cls_next = [] - for res_cls in res_clss: - if res_cls in related: - key = self.relationships[related[res_cls]].key - break - - if res_cls.__bases__: - res_cls_next.extend(res_cls.__bases__) - - else: - # Nope; continue - res_clss = res_cls_next - continue - - # Able to link - break - - else: - # Not able to link - return None - - # Able to link - return key - - def relate(self, target, other): - # Resolve the relationship key. - key = self._resolve_relation(other) - if not key: - raise BadRequest({ - '__all__': "Unable to link a '%s' to a '%s'." % ( - type(target).__name__, type(other).__name__)}) - - # Grab the set_ in question. - set_ = getattr(target, key) - - # Append the relationship. - set_.append(other) - - # Flush the target and expire attributes. - self.session.flush() - - # Refresh the target object to avoid inconsistencies with storage. - self.session.expire(target) - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'update', self, target): - authz.unauthorized() - - def unrelate(self, target, other): - # Resolve the relationship key. - key = self._resolve_relation(other) - if not key: - raise BadRequest({ - '__all__': "Unable to link a '%s' to a '%s'." % ( - type(target).__name__, type(other).__name__)}) - - # Grab the set_ in question. - set_ = getattr(target, key) - - # Append the relationship. - set_.remove(other) - - # Flush the target and expire attributes. - self.session.flush() - - # Refresh the target object to avoid inconsistencies with storage. - self.session.expire(target) - - # Ensure the user is authorized to perform this action. - authz = self.meta.authorization - if not authz.is_authorized(self.request.user, 'update', self, target): - authz.unauthorized() - - def read_related(self, target, resource, key): - # Grab the set_ in question. - set_ = getattr(target, key) - - # Build the related items. - qs = set_ - - if isinstance(qs, Iterable): - if self.request.user: - # Filter the queryset by asserting authorization. - qs = resource.meta.authorization.filter( - self.request.user, 'read', resource, qs) - - else: - if self.request.user: - # Ensure we can access this. - authz = self.meta.authorization - if not authz.is_authorized( - self.request.user, 'read', resource, qs): - authz.unauthorized() - - # Return the queryset. - return qs - - def clean_related(self, relation, value): - # Grab the model in question. - model = relation.resource.meta.model - - # Attempt to build a query to `get` the model. - target = self.session.query(model).get(value) - if target is not None: - # Found a target from the value. - return target - - # Found nothing. diff --git a/armet/connectors/bottle/__init__.py b/armet/contrib/__init__.py similarity index 100% rename from armet/connectors/bottle/__init__.py rename to armet/contrib/__init__.py diff --git a/armet/connectors/django/__init__.py b/armet/contrib/sqla/__init__.py similarity index 100% rename from armet/connectors/django/__init__.py rename to armet/contrib/sqla/__init__.py diff --git a/armet/contrib/sqla/resources.py b/armet/contrib/sqla/resources.py new file mode 100644 index 0000000..da93245 --- /dev/null +++ b/armet/contrib/sqla/resources.py @@ -0,0 +1,187 @@ +import sqlalchemy as sa +from armet.resources.base import Resource, ResourceMeta +from armet.utils import classproperty, memoize +from sqlalchemy.orm import ColumnProperty, RelationshipProperty +from sqlalchemy.orm.interfaces import MapperProperty +from sqlalchemy.ext.associationproxy import AssociationProxy + +# Mapping of 'Model' classes to canonical 'Resources' +_resources = {} + +# Mapping of 'Tables' to 'Models' +_models = {} + + +class SQLAlchemyResourceMeta(ResourceMeta): + + def __init__(self, name, bases, attrs): + super().__init__(name, bases, attrs) + + if self._meta.model is not None: + # Emplace ourself in the mapping for this model + # TODO: We should be able to make a non-canonical resource + _resources[self._meta.model] = self + _models[self._meta.model.__table__] = self._meta.model + + if hasattr(self._meta, "attributes"): + self.attributes = attrs = list(self._meta.attributes) + + +class SQLAlchemyResource(Resource, metaclass=SQLAlchemyResourceMeta): + + class Meta: + # The model class object that this sqlalchemy resource will use. + model = None + + # The session constructor used to build a session for this resource. + session = None + + # Relationships to facilitate. + relationships = [] + + @classproperty + @memoize + def _segment_attributes(cls): + columns = [] + rels = [] + calculated = [] + for name in cls.attributes: + attr = getattr(cls._meta.model, name) + if (hasattr(attr, "property") + and isinstance(attr.property, ColumnProperty)): + # This is a scalar column and can be easily fetched + columns.append(name) + elif (hasattr(attr, "property") + and isinstance(attr.property, MapperProperty)): + # This is /not/ a scalar column attribute + # but is stil sqlalchemy-y + rels.append(name) + else: + calculated.append(name) + + return columns, rels, calculated + + @classproperty + def _calculated(cls): + return cls._segment_attributes[2] + + @classproperty + def _relationships(cls): + return cls._segment_attributes[1] + + @classproperty + def _columns(cls): + return cls._segment_attributes[0] + + def read(self): + # Build the base queryset (over each scalar column) + queryset = self._meta.session().query(self._meta.model) + + # Iterate each `relationship` + entities = [] + for rel in self._relationships: + # Build the selectable for the end-result and join the + # neccessary table(s) to get there + attr = getattr(self._meta.model, rel) + direction = attr.property.direction.name + if direction == "MANYTOONE": + # Get the `target_table` and `target` and make that + # the selectable + target_table = attr.property.target + target = _models[target_table] + + # Join the table to us in our quest to get this attribute + queryset = queryset.outerjoin(target, attr.expression) + + else: + # TO-MANY attributes are added later + continue + + # Add the 'enttiy' to the queryset + queryset = queryset.add_entity(target) + + return queryset + + def filter(self, queryset, query): + # Apply the slug to the queryset + if self.slug is not None: + slug_column = getattr( + self._meta.model, self._meta.slug_attribute) + queryset = queryset.filter(slug_column == self.slug) + + return queryset + + @classmethod + def prepare_item(cls, item): + # If we didn't get a (tuple,) ... + if not isinstance(item, tuple): + item = (item,) + + # Check to see if we need to polymorphically + # apply a different resource + if type(item[0]) is not cls._meta.model: + resource = _resources.get(type(item[0])) + if resource is not None: + return resource.prepare_item(item) + + # Prepare the initial (scalar) dataset + data = super().prepare_item(item[0]) + + # Iterate through our relationships (and prepare and embed) + for idx, rel in enumerate(cls._relationships): + + # FIXME: This could be calculated in `read` + attr = getattr(cls._meta.model, rel) + direction = attr.property.direction.name + target_table = attr.property.target + target = _models[target_table] + + resource = _resources[target] + + if direction == "MANYTOONE": + # Get the value and prepare it appropriately + value = item[1 + idx] + + if value is not None: + cls._set_item(data, rel, resource.prepare_item(value)) + + else: + cls._set_item(data, rel, None) + + elif direction == "ONETOMANY": + # Get the values + values = getattr(item[0], rel) + + # Prepare each value and add to the data + cls._set_item(data, rel, resource.prepare(values)) + + # Apply calculated attributes + for name in cls._calculated: + cls._set_item(data, name, getattr(item[0], name)) + + return data + + def slug_for(self, item): + return getattr(item, self._meta.slug_attribute) + + def create(self, data): + target = self._meta.model(**data) + + # Add the new model to the session (and flush) + self._meta.session().add(target) + self._meta.session().flush() + + # Finalize the transaction + self._meta.session().commit() + + return target + + def update(self, item, data): + # Set the simple (scalar) attributes + for name in self._columns: + value = data.get(name) + if value: + setattr(item, name, value) + + # Finalize the transaction + self._meta.session().commit() diff --git a/armet/decoders/__init__.py b/armet/decoders/__init__.py new file mode 100644 index 0000000..2f3c965 --- /dev/null +++ b/armet/decoders/__init__.py @@ -0,0 +1,21 @@ +from ..codecs import CodecRegistry + + +__all__ = [ + "find", + "remove", + "register" +] + + +# Construct a module-scoped registry for the decoders. +_registry = CodecRegistry() + +# Take the general methods and attach to the module for easy access. +find = _registry.find +remove = _registry.remove +register = _registry.register + + +# Import the builtin decoders (which can be overriden by a user). +from . import json, url, form # noqa diff --git a/armet/decoders/form.py b/armet/decoders/form.py new file mode 100644 index 0000000..7699471 --- /dev/null +++ b/armet/decoders/form.py @@ -0,0 +1,28 @@ +import cgi +import operator +import io + +from ..codecs import FormCodec +from . import register + + +# FIXME: This expects a "boundary" property that it won't get, ever. +# FIXME: This is quite slow (guess is that is because of "cgi") +@register(name="form", mime_type=FormCodec.mime_types) +def decode(text, boundary): + fp = io.BytesIO(text) + result = cgi.parse_multipart(fp, {'boundary': boundary.encode('utf8')}) + + # We need to operate on the values to decode them and to unpack shallow + # ones. + keys, values = zip(*result.items()) + + # Decode the values + decode = operator.methodcaller('decode', 'utf8') + values = (list(map(decode, entry)) for entry in values) + + # Unpack shallow values (where there's only one) + values = (x if len(x) > 1 else x[0] for x in values) + + # Return the dictionary! + return dict(zip(keys, values)) diff --git a/armet/decoders/json.py b/armet/decoders/json.py new file mode 100644 index 0000000..a339ed9 --- /dev/null +++ b/armet/decoders/json.py @@ -0,0 +1,13 @@ +import ujson as json + +from ..codecs import JSONCodec +from . import register + + +@register(name="json", mime_type=JSONCodec.mime_types) +def decode(text): + try: + return json.loads(text) + + except ValueError as ex: + raise TypeError from ex diff --git a/armet/decoders/url.py b/armet/decoders/url.py new file mode 100644 index 0000000..6dfd2f2 --- /dev/null +++ b/armet/decoders/url.py @@ -0,0 +1,14 @@ +from urllib.parse import parse_qs + +from ..codecs import URLCodec +from . import register + + +@register(name="url", mime_type=URLCodec.mime_types) +def decode(text): + try: + data = parse_qs(text) + return {k: v[0] if len(v) == 1 else v for k, v in data.items()} + + except AttributeError as ex: + raise TypeError from ex diff --git a/armet/decorators.py b/armet/decorators.py deleted file mode 100644 index 610b625..0000000 --- a/armet/decorators.py +++ /dev/null @@ -1,117 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .exceptions import ImproperlyConfigured -import six -from . import utils - - -class route: - """ - Mounts a resource on the passed URL mount point by invoking the - `mount` classmethod. - - @example - @route('/') - class Resource(resources.Resource): - # ... - """ - - def __init__(self, *args, **kwargs): - # Just store the arguments. - self.args = args - self.kwargs = kwargs - - def __call__(self, resource): - # Is the resources a function? - if callable(resource) and not isinstance(resource, type): - # Yes; call the resource decorator. - import armet - resource = armet.resource(**self.kwargs)(resource) - - # Ensure the resource has a `mount` classmethod. - if not hasattr(resource, 'mount'): - raise ImproperlyConfigured( - 'The {} resource doesn\'t have a `mount` method.'.format( - resource.meta.name)) - - # Hook up the resource at the mount point. - resource.mount(*self.args) - - # Return the resource - return resource - - -def asynchronous(resource): - """Instructs a decorated resource that it is to be asynchronous. - - An asynchronous resource means that `response.close()` must be called - explicitly as returning from a method (eg. `get`) does not close - the connection. - - @note - This can also be configured by setting `asynchronous` to `True` - on `.Meta`. The benefit of the decorator is that this - can be applied to specific methods as well as the entire class - body. - """ - # TODO: Check for gevent support... - - # Flip the asynchronous switch. - resource.meta.asynchronous = True - - # Return the resource - return resource - - -#! Mapping of lightweight resources. -_resources = {} - -#! Mapping of functional handlers for the lightweight resources. -_handlers = {} - - -def resource(**kwargs): - """Wraps the decorated function in a lightweight resource.""" - def inner(function): - name = kwargs.pop('name', None) - if name is None: - name = utils.dasherize(function.__name__) - - methods = kwargs.pop('methods', None) - if isinstance(methods, six.string_types): - # Tuple-ify the method if we got just a string. - methods = methods, - - # Construct a handler. - handler = (function, methods) - - if name not in _resources: - # Initiate the handlers list. - _handlers[name] = [] - - # Construct a light-weight resource using the passed kwargs - # as the arguments for the meta. - from armet import resources - kwargs['name'] = name - - class LightweightResource(resources.Resource): - Meta = type(str('Meta'), (), kwargs) - - def route(self, request, response): - for handler, methods in _handlers[name]: - if methods is None or request.method in methods: - return handler(request, response) - - resources.Resource.route(self) - - # Construct and add this resource. - _resources[name] = LightweightResource - - # Add this to the handlers. - _handlers[name].append(handler) - - # Return the resource. - return _resources[name] - - # Return the inner method. - return inner diff --git a/armet/deserializers/__init__.py b/armet/deserializers/__init__.py deleted file mode 100644 index 752a272..0000000 --- a/armet/deserializers/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -from .base import Deserializer -from .json import JSONDeserializer -from .url import URLDeserializer - -__all__ = [ - 'Deserializer', - 'JSONDeserializer', - 'URLDeserializer' -] diff --git a/armet/deserializers/base.py b/armet/deserializers/base.py deleted file mode 100644 index 8c46f0a..0000000 --- a/armet/deserializers/base.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - - -class Deserializer(object): - - #! Applicable media types for this deserializer. - media_types = () - - def deserialize(self, request=None, text=None): - """Parses the request content into a format consumable by python. - - @throws ValueError - To indicate this deserializer cannot deserialize the - passed text. - """ - - if text is None: - # Read in the text from the request. - text = request.read() - - return text diff --git a/armet/deserializers/json.py b/armet/deserializers/json.py deleted file mode 100644 index e422811..0000000 --- a/armet/deserializers/json.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import ujson as json -from .base import Deserializer -from armet import media_types - - -class JSONDeserializer(Deserializer): - - media_types = media_types.JSON - - def deserialize(self, request=None, text=None, encoding='utf8'): - if text is None: - # Read in the text from the request. - text = request.read() - - # Ensure the text is decoded. - if isinstance(text, six.binary_type): - text = text.decode(encoding) - - try: - # Attempt to deserialize the text. - return json.loads(text) - - except TypeError: - # Failed; possibly null. - raise ValueError diff --git a/armet/deserializers/url.py b/armet/deserializers/url.py deleted file mode 100644 index a4c5fb0..0000000 --- a/armet/deserializers/url.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .base import Deserializer -from armet import media_types - - -if six.PY3: - from urllib.parse import parse_qsl - -else: - from urlparse import parse_qsl - - -class URLDeserializer(Deserializer): - - media_types = media_types.URL - - def deserialize(self, request=None, text=None, encoding='utf8'): - - if text is None: - # Read in the text from the request. - text = request.read() - - # Ensure we don't attempt to deserialize nothing. - if text is None: - raise ValueError - - try: - # Attempt to desserialize the URL using the - # URL decoder. - data = {} - for name, value in parse_qsl(text, keep_blank_values=True): - # Ensure values are properly decoded if neccessary. - if isinstance(value, six.binary_type): - value = value.decode(encoding) - - if isinstance(name, six.binary_type): - name = name.decode(encoding) - - # Initialize the array. - if name not in data: - data[name] = [] - - # Append the data value. - data[name].append(value) - - return data - - except AttributeError: - # Something went wront internally; bad input. - raise ValueError diff --git a/armet/encoders/__init__.py b/armet/encoders/__init__.py new file mode 100644 index 0000000..5b5e6f2 --- /dev/null +++ b/armet/encoders/__init__.py @@ -0,0 +1,21 @@ +from ..codecs import CodecRegistry + + +__all__ = [ + "find", + "remove", + "register" +] + + +# Construct a module-scoped registry for the encoders. +_registry = CodecRegistry() + +# Take the general methods and attach to the module for easy access. +find = _registry.find +remove = _registry.remove +register = _registry.register + + +# Import the builtin encoders (which can be overriden by a user). +from . import json, url, form # noqa diff --git a/armet/encoders/form.py b/armet/encoders/form.py new file mode 100644 index 0000000..5b15407 --- /dev/null +++ b/armet/encoders/form.py @@ -0,0 +1,97 @@ +import uuid +import collections +from io import BytesIO + +from ..codecs import FormCodec +from . import register + + +# FIXME: Should use the make_boundary function from the stdlib +# COUNTER: make_boundary is no longer part of the standard library. It was +# removed in python3 The implementation is still there, but it is private +# and therefore we should not be using it. +def generate_boundary(): + """http://xkcd.com/221/""" + return uuid.uuid4().hex + + +def generate_encoder(encoding): + def retfn(string): + return string.encode(encoding) + return retfn + + +# FIXME: I don't understand the point of this (why not just utils.chunk) +def segment_stream(cache, buf, segment_size=16*1024): + """Yields bytes in `segment_size` chunks from cache and then from buf. + Operates by writing buf to cache and cleaning it every once in a while. + Expects cache to be a BytesIO and buf to be a bytes. + """ + while True: + size = cache.tell() + add = buf[:segment_size-size] + buf = buf[segment_size-size:] + cache.write(add) + + value = cache.getvalue() + + # Reset the cache. + cache.truncate(0) + cache.seek(0) + + if not value: + break + + yield value + + +@register(name="form", mime_type=FormCodec.mime_types, + preferred_mime_type=FormCodec.preferred_mime_type) +def encode(data, encoding): + """Expects to recieve a data structure of the following form: + {name: value, name: [value1, value2]} + """ + buf = BytesIO() + encode = generate_encoder(encoding) + boundary = encode(generate_boundary()) + + # This function uses a lot of + to join strings, + # this is because python3 does not allow for b'%s' % bytes() + + # Sanity check. + if not isinstance(data, collections.Mapping): + raise TypeError + + for name, values in data.items(): + # Normalize the values. + if isinstance(values, str): + values = (values,) + elif not isinstance(values, collections.Sequence): + # Sanity check. + raise TypeError + + for entry in values: + # Write the boundary for the next entry. + buf.write(b'--' + boundary + b'\r\n') + + # Write the header for the next entry. + # Note the extra new line afterwards. This is because the next + # entry is the value. + bounds = ( + b'Content-Disposition: form-data; name=' + + encode(name) + + b'\r\n\r\n') + + buf.write(bounds) + + # Write the contents. + # TODO: Make this stream the contents of entry + yield from segment_stream(buf, encode(entry)) + + # Write the trailing newline + buf.write(b'\r\n') + + # All done. + buf.write(b'--' + boundary + b'--') + + yield from segment_stream(buf, b'') diff --git a/armet/encoders/json.py b/armet/encoders/json.py new file mode 100644 index 0000000..7d737f4 --- /dev/null +++ b/armet/encoders/json.py @@ -0,0 +1,22 @@ +import ujson as json +from collections import Iterable + +from ..codecs import JSONCodec +from ..utils import chunk +from . import register + + +@register(name="json", mime_type=JSONCodec.mime_types, + preferred_mime_type=JSONCodec.preferred_mime_type) +def encode(data, encoding): + # Ensure that the scalar data is wrapped in a list as + # a valid JSON document be an object or a list. + # See: http://tools.ietf.org/html/rfc4627 + if isinstance(data, str) or not isinstance(data, Iterable): + data = [data] + + # Dump the json data. + data = json.dumps(data) + + # TODO: Replace this with real json streaming + return chunk(data.encode(encoding)) diff --git a/armet/encoders/url.py b/armet/encoders/url.py new file mode 100644 index 0000000..dbc1526 --- /dev/null +++ b/armet/encoders/url.py @@ -0,0 +1,24 @@ +from itertools import chain, repeat +from urllib.parse import urlencode + +from ..codecs import URLCodec +from ..utils import chunk +from . import register + + +@register(name="url", mime_type=URLCodec.mime_types, + preferred_mime_type=URLCodec.preferred_mime_type) +def encode(data, encoding): + try: + # Normalize the encode so that users pay invoke using either + # {"foo": "bar"} or {"foo": ["bar", "baz"]}. + data = list(chain.from_iterable( + ((k, v),) if isinstance(v, str) else zip(repeat(k), v) + for k, v in data.items())) + + except AttributeError as ex: + raise TypeError from ex + + # URL encode it and return a streamable generator. + # TODO: Replace this with actual url encoded streaming. + return chunk(urlencode(data).encode(encoding)) diff --git a/armet/exceptions.py b/armet/exceptions.py deleted file mode 100644 index 90b2785..0000000 --- a/armet/exceptions.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - - -class ImproperlyConfigured(BaseException): - """Something has been set up or configured incorrectly. - """ - - -class InvalidOperation(BaseException): - """ - Something is being asked to operate outside of the defined specification - of it operating. - """ - - -class ValidationError(BaseException): - """ - Something has been found invalid during the attribute clean cycle; - normally resulting from form or some type of validation. - """ - - def __init__(self, *errors): - self.errors = errors diff --git a/armet/helpers.py b/armet/helpers.py deleted file mode 100644 index 0a904fd..0000000 --- a/armet/helpers.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - - -def use(**kwargs): - """ - Updates the active resource configuration to the passed - keyword arguments. - - Invoking this method without passing arguments will just return the - active resource configuration. - - @returns - The previous configuration. - """ - config = dict(use.config) - use.config.update(kwargs) - return config - -# Set the initial resource configuration. -use.config = {} diff --git a/armet/http/__init__.py b/armet/http/__init__.py index 14a3997..958d0e6 100644 --- a/armet/http/__init__.py +++ b/armet/http/__init__.py @@ -1,31 +1,9 @@ -# -*- coding: utf-8 -*- -"""Normalizes access to the HTTP libraries. -""" -from __future__ import absolute_import, unicode_literals, division -from six.moves import http_client as client -from .request import Request -from .response import Response -from . import exceptions +from werkzeug.wrappers import BaseRequest, BaseResponse, ResponseStreamMixin -__all__ = [ - 'client', - 'Request', - 'Response', - 'exceptions' -] -try: - # Attempt to get additional status codes (added in python 3.2) - getattr(client, 'PERMANENT_REDIRECT') - getattr(client, 'PRECONDITION_REQUIRED') - getattr(client, 'TOO_MANY_REQUESTS') - getattr(client, 'REQUEST_HEADER_FIELDS_TOO_LARGE') - getattr(client, 'NETWORK_AUTHENTICATION_REQUIRED') +class Request(BaseRequest): + pass -except AttributeError: - # Don't have em; add them. - client.PERMANENT_REDIRECT = 308 - client.PRECONDITION_REQUIRED = 428 - client.TOO_MANY_REQUESTS = 429 - client.REQUEST_HEADER_FIELDS_TOO_LARGE = 431 - client.NETWORK_AUTHENTICATION_REQUIRED = 511 + +class Response(BaseResponse, ResponseStreamMixin): + pass diff --git a/armet/http/exceptions.py b/armet/http/exceptions.py index 7d3ba30..7df11f6 100644 --- a/armet/http/exceptions.py +++ b/armet/http/exceptions.py @@ -1,138 +1,129 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet.http import client - - -class BaseHTTPException(BaseException): - status = None - - def __init__(self, content=None, headers=None): - #! Body of the exception message. - self.content = content - - #! Additional headers to place with the response. - self.headers = headers or {} - - -class BadRequest(BaseHTTPException): # 400 - status = client.BAD_REQUEST - - -class Unauthorized(BaseHTTPException): # 401 - status = client.UNAUTHORIZED - - -class PaymentRequired(BaseHTTPException): # 402 - status = client.PAYMENT_REQUIRED - - -class Forbidden(BaseHTTPException): # 403 - status = client.FORBIDDEN - - -class NotFound(BaseHTTPException): # 404 - status = client.NOT_FOUND - - -class MethodNotAllowed(BaseHTTPException): # 405 - status = client.METHOD_NOT_ALLOWED - - def __init__(self, allowed): - super(MethodNotAllowed, self).__init__( - headers={'Allow': ', '.join(allowed)}) - - -class NotAcceptable(BaseHTTPException): # 406 - status = client.NOT_ACCEPTABLE - - -class ProxyAuthenticationRequired(BaseHTTPException): # 407 - status = client.PROXY_AUTHENTICATION_REQUIRED - - -class RequestTimeout(BaseHTTPException): # 408 - status = client.REQUEST_TIMEOUT - - -class Conflict(BaseHTTPException): # 409 - status = client.CONFLICT - - -class Gone(BaseHTTPException): # 410 - status = client.GONE - - -class LengthRequired(BaseHTTPException): # 411 - status = client.LENGTH_REQUIRED - - -class PreconditionFailed(BaseHTTPException): # 412 - status = client.PRECONDITION_FAILED - - -class RequestEntityTooLarge(BaseHTTPException): # 413 - status = client.REQUEST_ENTITY_TOO_LARGE - - -class RequestUriTooLong(BaseHTTPException): # 414 - status = client.REQUEST_URI_TOO_LONG - - -class UnsupportedMediaType(BaseHTTPException): # 415 - status = client.UNSUPPORTED_MEDIA_TYPE - - -class RequestedRangeNotSatisfiable(BaseHTTPException): # 416 - status = client.REQUESTED_RANGE_NOT_SATISFIABLE - - -class ExpectationFailed(BaseHTTPException): # 417 - status = client.EXPECTATION_FAILED - - -class UnprocessableEntity(BaseHTTPException): # 422 - status = client.UNPROCESSABLE_ENTITY - - -class Locked(BaseHTTPException): # 423 - status = client.LOCKED - - -class FailedDependency(BaseHTTPException): # 424 - status = client.FAILED_DEPENDENCY - - -class UgradeRequired(BaseHTTPException): # 426 - status = client.UPGRADE_REQUIRED - - -class InternalServerError(BaseHTTPException): # 500 - status = client.INTERNAL_SERVER_ERROR - - -class NotImplemented(BaseHTTPException): # 501 - status = client.NOT_IMPLEMENTED - - -class BadGateway(BaseHTTPException): # 502 - status = client.BAD_GATEWAY - - -class ServiceUnavailable(BaseHTTPException): # 503 - status = client.SERVICE_UNAVAILABLE - - -class GatewayTimeout(BaseHTTPException): # 504 - status = client.GATEWAY_TIMEOUT - - -class HttpVersionNotSupported(BaseHTTPException): # 505 - status = client.HTTP_VERSION_NOT_SUPPORTED - - -class InsufficientStorage(BaseHTTPException): # 507 - status = client.INSUFFICIENT_STORAGE - - -class NotExtended(BaseHTTPException): # 510 - status = client.NOT_EXTENDED +from werkzeug import exceptions +from http import client +from armet import utils + +# Mirrors to werkzeug exceptions in the event we need to overload +# these ourselves. +Base = exceptions.HTTPException + + +class Base(exceptions.HTTPException): + + @property + def message(self): + """Convenient accessor for any message provided for this exception.""" + return self.description + + def get_body(self, environ=None): + # Api exceptions have an optional content parameter. + return '' + + def get_headers(self, environ=None): + # Because we're no longer returning html, our content type is no + # longer text/html + # return [('Content-Type', 'text/plain')] + headers = super().get_headers(environ) + content = [('Content-Type', 'text/plain')] + return utils.merge_headers(content, headers) + + +# The following exceptions are already implemented in werkzeug Wrap them +# in our exception class so they don't return html +BadRequest = Base.wrap(exceptions.BadRequest) +Unauthorized = Base.wrap(exceptions.Unauthorized) +Forbidden = Base.wrap(exceptions.Forbidden) +NotFound = Base.wrap(exceptions.NotFound) +MethodNotAllowed = Base.wrap(exceptions.MethodNotAllowed) +NotAcceptable = Base.wrap(exceptions.NotAcceptable) +RequestTimeout = Base.wrap(exceptions.RequestTimeout) +Conflict = Base.wrap(exceptions.Conflict) +Gone = Base.wrap(exceptions.Gone) +LengthRequired = Base.wrap(exceptions.LengthRequired) +PreconditionFailed = Base.wrap(exceptions.PreconditionFailed) +RequestEntityTooLarge = Base.wrap(exceptions.RequestEntityTooLarge) +UnsupportedMediaType = Base.wrap(exceptions.UnsupportedMediaType) +ExpectationFailed = Base.wrap(exceptions.ExpectationFailed) +UnprocessableEntity = Base.wrap(exceptions.UnprocessableEntity) +InternalServerError = Base.wrap(exceptions.InternalServerError) +NotImplemented = Base.wrap(exceptions.NotImplemented) +BadGateway = Base.wrap(exceptions.BadGateway) +ServiceUnavailable = Base.wrap(exceptions.ServiceUnavailable) +RequestedRangeNotSatisfiable = Base.wrap( + exceptions.RequestedRangeNotSatisfiable) + + +# The following exceptions are not already implemented in werkzeug and require +# that we create them ourselves. + + +class PaymentRequired(Base): # 402 + code = client.PAYMENT_REQUIRED + description = ( + 'The server requires additional payment before ' + 'this resource can be accessed.' + ) + + +class ProxyAuthenticationRequired(Base): # 407 + code = client.PROXY_AUTHENTICATION_REQUIRED + description = ( + 'The proxy requires authentication before ' + 'this resource can be accessed.' + ) + + +class RequestUriTooLong(Base): # 414 + code = client.REQUEST_URI_TOO_LONG + description = ('The uri used to access this resource is too long.') + + +class Locked(Base): # 423 + code = client.LOCKED + description = ( + 'This resource is currently locked. ' + 'Please try again later.' + ) + + +class FailedDependency(Base): # 424 + code = client.FAILED_DEPENDENCY + description = ( + 'A prior request is reqired as a dependency for this request.' + ) + + +class UgradeRequired(Base): # 426 + code = client.UPGRADE_REQUIRED + description = ( + 'Client should re-attempt request using a different protocol.' + ) + + +class GatewayTimeout(Base): # 504 + code = client.GATEWAY_TIMEOUT + description = ( + 'Gateway did not recieve a response from the upstream server.' + ) + + +class HttpVersionNotSupported(Base): # 505 + code = client.HTTP_VERSION_NOT_SUPPORTED + description = ( + 'Server does not support the http protocol version used to connect.' + ) + + +class InsufficientStorage(Base): # 507 + code = client.INSUFFICIENT_STORAGE + description = ( + 'Server has exceeded maximum storage capacity ' + 'required to complete this request' + ) + + +class NotExtended(Base): + code = client.NOT_EXTENDED + description = ( + 'Further extensions to the request are required' + 'for the server to fulfill it.' + ) diff --git a/armet/http/request.py b/armet/http/request.py deleted file mode 100644 index dd7435a..0000000 --- a/armet/http/request.py +++ /dev/null @@ -1,267 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import abc -import six -import string -import collections -import mimeparse -import weakref -from six.moves import http_cookies - - -class Headers(collections.Mapping): - """Describes a mapping abstraction over request headers. - """ - - @staticmethod - def _Header(sequence, name): - """Returns the passed header as a tuple. - - Implements a facade so that the response headers can override - this to provide a mutable sequence header. - """ - return tuple(sequence._headers.get(name, '').split(',')) - - class _Sequence(dict): - """ - Provides an implementation of a dictionary that retreives its - values as sequences. - """ - - def __init__(self, headers): - self._headers = headers - - def __missing__(self, name): - self[name] = value = self.headers._Header(self, name) - return value - - def __init__(self): - #! Internal store of the multi-valued headers as lists. - self._sequence = self._Sequence(self) - - @staticmethod - def normalize(name): - """Normalizes the case of the passed name to be Http-Header-Case.""" - return str(string.capwords(name, '-')) - - @abc.abstractmethod - def __getitem__(self, name): - """Retrieves a header with the passed name. - - @param[in] name - The case-insensitive name of the header to retrieve. - """ - - @abc.abstractmethod - def __len__(self): - """Retrieves the number of headers in the request.""" - - @abc.abstractmethod - def __iter__(self): - """Returns an iterable for all headers in the request.""" - - @abc.abstractmethod - def __contains__(self, name): - """Tests if the passed header exists in the request.""" - - def index(self, name, value): - """ - Return the index in the list of the first item whose value is x in - the values of the named header. - """ - return self._sequence[name].index(value) - - def count(self, name, value): - """ - Return the number of times a value appears in the list of the values - of the named header. - """ - return self._sequence[name].count(value) - - def getlist(self, name): - """Retrieves the passed header as a tuple of its values.""" - return self._sequence[name] - - -class Request(six.Iterator): - """Describes the RESTful request abstraction. - """ - - #! Dictionary-like interface to access headers; this should be - #! set by the dervied class to an instance of a derived Headers class. - headers = None - - def __init__(self, path, method, asynchronous, *args, **kwargs): - #! The captured path of the request, after the mount point. - #! Example: GET /api/poll/23 => '/23' - self.path = path - - #! True if we're asynchronous. - self.asynchronous = asynchronous - - # Determine the actual HTTP method; apply the override header. - override = self.headers.get('X-Http-Method-Override') - if override: - # Passed method was overriden; store override. - self.method = override.upper() - - else: - # Passed method is the actual method. - self.method = method.upper() - - #! Cookie jar full of python morsel objects that represent the - #! cookies that were sent with the request. - text = self.get('Cookie') - self.cookies = http_cookies.SimpleCookie() - if text: - self.cookies.load(str(text)) - - #! A reference to the bound resource; this is set in the resource - #! view method after traversal. - self._resource = None - - # HACK: Initialize some context sets. - self._embed_related = set() - - def bind(self, resource): - """Binds this to the passed resource object. - - This is used so that the request and response classes can access - metadata and configuration on the resource handling this request - so helper methods on the request and response like `serialize` work - in full knowledge of configuration supplied to the resource. - """ - self._resource = weakref.proxy(resource) - - @property - def resource(self): - return self._resource - - @property - def protocol(self): - """Retrieves the upper-cased version of the protocol (eg. HTTP).""" - raise NotImplementedError() - - @property - def host(self): - """Retrieves the hostname, normally from the `Host` header.""" - return self.headers.get('Host') or '127.0.0.1' - - @property - def mount_point(self): - """Retrieves the mount point portion of the path of this request.""" - raise NotImplementedError() - - @property - def query(self): - """Retrieves the text after the first ? in the path.""" - raise NotImplementedError() - - @property - def uri(self): - """Returns the complete URI of the request.""" - raise NotImplementedError() - - @property - def encoding(self): - """ - The name of the encoding used to decode the stream’s bytes - into strings, and to encode strings into bytes. - - Reads the charset value from the `Content-Type` header, if available; - else, returns nothing. - """ - # Get the `Content-Type` header, if available. - content_type = self.headers.get('Content-Type') - if content_type: - # Parse out the primary type and parameters from the media type. - ptype, _, params = mimeparse.parse_mime_type(content_type) - - # Return the specified charset or the default depending on the - # primary type. - default = 'utf-8' if ptype == 'application' else 'iso-8859-1' - return params.get('charset', default) - - def _read(self): - """Read and return the request data. - - @note Connectors should override this method. - """ - return None - - def read(self, deserialize=False, format=None): - """Read and return the request data. - - @param[in] deserialize - True to deserialize the resultant text using a determiend format - or the passed format. - - @param[in] format - A specific format to deserialize in; if provided, no detection is - done. If not provided, the content-type header is looked at to - determine an appropriate deserializer. - """ - - if deserialize: - data, _ = self.deserialize(format=format) - return data - - content = self._read() - - if not content: - return '' - - if type(content) is six.binary_type: - content = content.decode(self.encoding) - - return content - - def deserialize(self, format=None): - """Deserializes the request body using a determined deserializer. - - @param[in] format - A specific format to deserialize in; if provided, no detection is - done. If not provided, the content-type header is looked at to - determine an appropriate deserializer. - - @returns - A tuple of the deserialized data and an instance of the - deserializer used. - """ - return self._resource.deserialize(self, format=format) - - def __len__(self): - """Returns the length of the request body, if known.""" - length = self.headers.get('Content-Length') - return int(length) if length else 0 - - def __getitem__(self, name): - """Retrieves a header with the passed name.""" - return self.headers[name] - - def get(self, name, default=None): - """Retrieves a header with the passed name.""" - return self.headers.get(name, default) - - def getlist(self, name): - """ - Retrieves a the multi-valued list of the header with - the passed name. - """ - return self.headers.getlist(name) - - def __contains__(self, name): - """Tests if the passed header exists in the request.""" - return name in self.headers - - def keys(self): - """Return a new view of the header names.""" - return self.headers.keys() - - def values(self): - """Return a new view of the header values.""" - return self.headers.values() - - def items(self): - """Return a new view of the headers.""" - return self.headers.items() diff --git a/armet/http/response.py b/armet/http/response.py deleted file mode 100644 index 8973fd8..0000000 --- a/armet/http/response.py +++ /dev/null @@ -1,476 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import collections -import abc -import six -import mimeparse -import weakref -import io -from armet import exceptions -from . import request, client - - -class Headers(collections.MutableMapping, request.Headers): - """Describes a mutable mapping abstraction over response headers. - """ - - class _Header(collections.MutableSequence): - """ - Provides an implementation of a mutable sequence of multi-valued - headers that are synced with the normal list of headers. - """ - - def __init__(self, sequence, name): - #! Internal reference to the first-degree headers dictionary. - self._headers = sequence._headers - self._obj = self._headers._obj - self._name = name - - #! Internal store of the value at instantiation of the header. - self._value = self._headers.get(self._name, '') - - #! Internal sequence of the list of the values of the header. - self._sequence = self._value.split(',') - - def _pull(self): - # Pull the headers from the source. - if self._value != self._headers.get(self._name, ''): - # If the header value has changed; update our sequence. - self._value = self._headers.get(self._name, '') - self._sequence = self._value.split(',') - - def __getitem__(self, index): - # Retrieve the header value at the passed index. - self._pull() - return self._sequence[index] - - def __len__(self): - self._pull() - return len(self._sequence) - - def _push(self): - # Push the headers to the source. - self._value = ','.join(self._sequence) - self._headers[self._name] = self._value - - def __setitem__(self, index, value): - # Store the value at the passed index - self._obj._assert_open() - self._sequence[index] = value - self._push() - - def __delitem__(self, index): - # Remove the value at the passed index. - self._obj._assert_open() - del self._sequence[index] - self._push() - - def insert(self, index, value): - # Insert the header value at the passed index. - self._obj._assert_open() - self._sequence.insert(index, value) - self._push() - - @abc.abstractmethod - def __setitem__(self, name, value): - """Stores a header with the passed name. - - @param[in] name - The name to store the header as. This is passed through - `Headers.normalize` before storing on the response. - - @param[in] value - The value to store for the header; for multi-valued headers, - this can be a comma-separated list of values. - """ - - @abc.abstractmethod - def __delitem__(self, name): - """Removes a header with the passed name. - - @param[in] name - The case-insensitive name of the header to remove - from the response. - """ - - def append(self, name, value): - """Add a value to the end of the list for the named header.""" - return self._sequence[name].append(value) - - def extend(self, name, values): - """Extend the list for the named header by appending all values.""" - return self._sequence[name].extend(values) - - def insert(self, name, index, value): - """Insert a value at the passed index in the named header.""" - return self._sequence[name].insert(index, value) - - def remove(self, name, value): - """ - Remove the first item with the passed value from the - list for the named header. - """ - return self._sequence[name].remove(value) - - def popvalue(self, name, index=None): - """Remove the item at the given position in the named header list.""" - return self._sequence[name].pop(index) - - def sort(self, name): - """Sort the items of the list, in place.""" - return self._sequence[name].sort() - - def reverse(self, name): - """Reverse the elements of the list, in place.""" - return self._sequence[name].reverse() - - -class Response(object): - """Describes the RESTful response abstraction. - """ - - #! Dictionary-like interface to access headers; this should be - #! set by the dervied class to an instance of a derived Headers class. - headers = None - - def __init__(self, asynchronous, *args, **kwargs): - #! True if the response object is closed. - self._closed = False - - #! True if the response object is streaming. - self.streaming = False - - #! True if we're asynchronous. - self.asynchronous = asynchronous - - #! Default the status code to OK. - self.status = client.OK - - #! A reference to the bound resource; this is set in the resource - #! view method after traversal. - self._resource = None - - #! Explicit declaration of character encoding to use when - #! writing data to the response body. - #! Defaults to parsing content-type and determining encoding - #! via the standard rules. - self._encoding = None - - #! The underlying file stream to write incoming data to. - self._stream = io.BytesIO() - - #! The content chunk to return to the client. - self._body = None - - #! The length of the response. - self._length = 0 - - def require_not_closed(self): - """Raises an exception if the response is closed.""" - if self.closed: - raise exceptions.InvalidOperation('Response is closed.') - - def require_open(self): - """Raises an exception if the response is not open.""" - self.require_not_closed() - if self.streaming: - raise exceptions.InvalidOperation('Response is streaming.') - - @property - def status(self): - """Gets the status code of the response.""" - raise NotImplementedError() - - @status.setter - def status(self, value): - """Sets the status code of the response.""" - raise NotImplementedError() - - @property - def body(self): - """Returns the current value of the response body.""" - return self._body - - @body.setter - def body(self, value): - """Sets the response body to the passed value. - - @note - During asynchronous or streaming responses, remember that - the `body` property refers to the portion of the response *not* - sent to the client. - """ - self._body = value - - def bind(self, resource): - """Binds this to the passed resource object. - - @sa armet.http.request.Request.bind - """ - self._resource = weakref.proxy(resource) - - @property - def resource(self): - return self._resource - - @property - def encoding(self): - if self._encoding is not None: - # Encoding has been set manually. - return self._encoding - - # Get the `Content-Type` header, if available. - content_type = self.headers.get('Content-Type') - if content_type: - # Parse out the primary type and parameters from the media type. - ptype, _, params = mimeparse.parse_mime_type(content_type) - - # Return the specified charset or the default depending on the - # primary type. - default = 'utf-8' if ptype == 'application' else 'iso-8859-1' - return params.get('charset', default) - - # No encoding found. - - @encoding.setter - def encoding(self, value): - # Explicitly set the character encoding to use. - self._encoding = value - - @abc.abstractmethod - def close(self): - """Flush and close the stream. - - This is called automatically by the base resource on resources - unless the resource is operating asynchronously; in that case, - this method MUST be called in order to signal the end of the request. - If not the request will simply hang as it is waiting for some - thread to tell it to return to the client. - """ - - # Ensure we're not closed. - self.require_not_closed() - - if not self.streaming or self.asynchronous: - # We're not streaming, auto-write content-length if not - # already set. - if 'Content-Length' not in self.headers: - self.headers['Content-Length'] = self.tell() - - # Flush out the current buffer. - self.flush() - - # We're done with the response; inform the HTTP connector - # to close the response stream. - self._closed = True - - @property - def closed(self): - """True if the stream is closed.""" - return self._closed - - def tell(self): - """Return the current stream position.""" - return self._length - - def write(self, chunk, serialize=False, format=None): - """Writes the given chunk to the output buffer. - - @param[in] chunk - Either a byte array, a unicode string, or a generator. If `chunk` - is a generator then calling `self.write()` is - equivalent to: - - @code - for x in : - self.write(x) - self.flush() - @endcode - - @param[in] serialize - True to serialize the lines in a determined serializer. - - @param[in] format - A specific format to serialize in; if provided, no detection is - done. If not provided, the accept header (as well as the URL - extension) is looked at to determine an appropriate serializer. - """ - - # Ensure we're not closed. - self.require_not_closed() - - if chunk is None: - # There is nothing here. - return - - if serialize or format is not None: - # Forward to the serializer to serialize the chunk - # before it gets written to the response. - self.serialize(chunk, format=format) - return # `serialize` invokes write(...) - - if type(chunk) is six.binary_type: - # Update the stream length. - self._length += len(chunk) - - # If passed a byte string, we hope the user encoded it properly. - self._stream.write(chunk) - - elif isinstance(chunk, six.string_types): - encoding = self.encoding - if encoding is not None: - # If passed a string, we can encode it for the user. - chunk = chunk.encode(encoding) - - else: - # Bail; we don't have an encoding. - raise exceptions.InvalidOperation( - 'Attempting to write textual data without an encoding.') - - # Update the stream length. - self._length += len(chunk) - - # Write the encoded data into the byte stream. - self._stream.write(chunk) - - elif isinstance(chunk, collections.Iterable): - # If passed some kind of iterator, attempt to recurse into - # oblivion. - for section in chunk: - self.write(section) - - else: - # Bail; we have no idea what to do with this. - raise exceptions.InvalidOperation( - 'Attempting to write something not recognized.') - - def serialize(self, data, format=None): - """Serializes the data into this response using a serializer. - - @param[in] data - The data to be serialized. - - @param[in] format - A specific format to serialize in; if provided, no detection is - done. If not provided, the accept header (as well as the URL - extension) is looked at to determine an appropriate serializer. - - @returns - A tuple of the serialized text and an instance of the - serializer used. - """ - return self._resource.serialize(data, response=self, format=format) - - def flush(self): - """Flush the write buffers of the stream. - - This results in writing the current contents of the write buffer to - the transport layer, initiating the HTTP/1.1 response. This initiates - a streaming response. If the `Content-Length` header is not given - then the chunked `Transfer-Encoding` is applied. - """ - - # Ensure we're not closed. - self.require_not_closed() - - # Pull out the accumulated chunk. - chunk = self._stream.getvalue() - self._stream.truncate(0) - self._stream.seek(0) - - # Append the chunk to the body. - self.body = chunk if (self._body is None) else (self._body + chunk) - - if self.asynchronous: - # We are now streaming because we're asynchronous. - self.streaming = True - - def send(self, *args, **kwargs): - """Writes the passed chunk and flushes it to the client.""" - self.write(*args, **kwargs) - self.flush() - - def end(self, *args, **kwargs): - """ - Writes the passed chunk, flushes it to the client, - and terminates the connection. - """ - self.send(*args, **kwargs) - self.close() - - def __getitem__(self, name): - """Retrieves a header with the passed name.""" - return self.headers[name] - - def __setitem__(self, name, value): - """Stores a header with the passed name.""" - self.headers[name] = value - - def __delitem__(self, name): - """Removes a header with the passed name.""" - del self.headers - - def __len__(self): - """Retrieves the actual length of the response.""" - return self.tell() - - def __nonzero__(self): - """Test if the response is closed.""" - return not self._closed - - def __bool__(self): - """Test if the response is closed.""" - return not self._closed - - def __contains__(self, name): - """Tests if the passed header exists in the response.""" - return name in self.headers - - def append(self, name, value): - """Add a value to the end of the list for the named header.""" - return self.headers.append(name, value) - - def extend(self, name, values): - """Extend the list for the named header by appending all values.""" - return self.headers.extend(name, values) - - def insert(self, name, index, value): - """Insert a value at the passed index in the named header.""" - return self.headers.insert(index, value) - - def remove(self, name, value): - """ - Remove the first item with the passed value from the - list for the named header. - """ - return self.headers.remove(name, value) - - def popvalue(self, name, index=None): - """Remove the item at the given position in the named header list.""" - return self.headers.popvalue(name, index) - - def index(self, name, value): - """ - Return the index in the list of the first item whose value is x in - the values of the named header. - """ - return self.headers.index(name, value) - - def count(self, name, value): - """ - Return the number of times a value appears in the list of the values - of the named header. - """ - return self.headers.count(name, value) - - def sort(self, name): - """Sort the items of the list, in place.""" - return self.headers.sort(name) - - def reverse(self, name): - """Reverse the elements of the list, in place.""" - return self.headers.reverse(name) - - def getlist(self, name): - """Retrieves the passed header as a sequence of its values.""" - return self.headers.getlist(name) diff --git a/armet/media_types.py b/armet/media_types.py deleted file mode 100644 index 88e89c5..0000000 --- a/armet/media_types.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tuples of the accepted media types for the various supported content types. - -The first media type of each respective listing is what armet will -provide for its `Content-Type` upon serialization; armet will accept -any of the listed types when receiving content or when asked to -serialize in a specific format. -""" -from __future__ import absolute_import, unicode_literals, division - -#! Multipart form data; as defined by HTML5. -#! -#! http://www.w3.org/TR/html5/constraints.html#multipart-form-data -FORM_DATA = 'multipart/form-data', - -#! URL encoded form data; as defined by HTML5. -#! -#! http://www.w3.org/TR/html5/constraints.html#url-encoded-form-data -URL = 'application/x-www-form-urlencoded', - -#! JavaScript Object Notation, is a text-based open standard designed -#! for human-readable data interchange. -JSON = ( - # Offical; as per RFC 4627. - 'application/json', - - # Widely used (thanks .) - 'application/jsonrequest', - - # Miscellaneous mimetypes that are used frequently (incorrectly). - 'application/x-json', - 'text/json; charset=utf-8', - - # Widely used (incorrectly) thanks to ruby. - 'text/x-json; charset=utf-8', -) - -#! Extensible Markup Language (XML) is a markup language that defines a set -#! of rules for encoding documents in a format that is both human-readable -#! and machine-readable. -XML = ( - # To be used when the XML is NOT human-readable as per RFC 3023. - # Our XML is not considered human-readable as it is minified before - # transfer. - 'application/xml', - - # To be used when the XML is human-readable as per RFC 3023. - 'text/xml; charset=utf-8', -) - -#! YAML is a human friendly data serialization standard -#! for all programming languages. -#! -#! @note -#! There is no standard media type for YAML. -YAML = ( - 'application/x-yaml', - 'application/yaml', - 'text/yaml; charset=utf-8', - 'text/x-yaml; charset=utf-8', -) diff --git a/armet/pagination.py b/armet/pagination.py deleted file mode 100644 index 0828c84..0000000 --- a/armet/pagination.py +++ /dev/null @@ -1,100 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Implementation of a pagination interface using a combination of the HTTP/1.1 -range header and set specifiers. -""" -from armet.http import exceptions, client - -#! The range specifier to use. -RANGE_SPECIFIER = 'items' - - -def parse(specifiers): - """ - Consumes set specifiers as text and forms a generator to retrieve - the requested ranges. - - @param[in] specifiers - Expected syntax is from the byte-range-specifier ABNF found in the - [RFC 2616]; eg. 15-17,151,-16,26-278,15 - - @returns - Consecutive tuples that describe the requested range; eg. (1, 72) or - (1, 1) [read as 1 to 72 or 1 to 1]. - """ - specifiers = "".join(specifiers.split()) - for specifier in specifiers.split(','): - if len(specifier) == 0: - raise ValueError("Range: Invalid syntax; missing specifier.") - - count = specifier.count('-') - if (count and specifier[0] == '-') or not count: - # Single specifier; return as a tuple to itself. - yield int(specifier), int(specifier) - continue - - specifier = list(map(int, specifier.split('-'))) - if len(specifier) == 2: - # Range specifier; return as a tuple. - if specifier[0] < 0 or specifier[1] < 0: - # Negative indexing is not supported in range specifiers - # as stated in the HTTP/1.1 Range header specification. - raise ValueError( - "Range: Invalid syntax; negative indexing " - "not supported in a range specifier.") - - if specifier[1] < specifier[0]: - # Range must be for at least one item. - raise ValueError( - "Range: Invalid syntax; stop is less than start.") - - # Return them as a immutable tuple. - yield tuple(specifier) - continue - - # Something weird happened. - raise ValueError("Range: Invalid syntax.") - - -def paginate(request, response, items): - """Paginate an iterable during a request. - - Magically splicling an iterable in our supported ORMs allows LIMIT and - OFFSET queries. We should probably delegate this to the ORM or something - in the future. - """ - # TODO: support dynamic rangewords and page lengths - # TODO: support multi-part range requests - - # Get the header - header = request.headers.get('Range') - if not header: - # No range header; move along. - return items - - # do some validation - prefix = RANGE_SPECIFIER + '=' - if not header.find(prefix) == 0: - # This is not using a range specifier that we understand - raise exceptions.RequestedRangeNotSatisfiable() - else: - # Chop the prefix off the header and parse it - ranges = parse(header[len(prefix):]) - - ranges = list(ranges) - if len(ranges) > 1: - raise exceptions.RequestedRangeNotSatisfiable( - 'Multiple ranges in a single request is not yet supported.') - start, end = ranges[0] - - # Make sure the length is not higher than the total number allowed. - max_length = request.resource.count(items) - end = min(end, max_length) - - response.status = client.PARTIAL_CONTENT - response.headers['Content-Range'] = '%d-%d/%d' % (start, end, max_length) - response.headers['Accept-Ranges'] = RANGE_SPECIFIER - - # Splice and return the items. - items = items[start:end + 1] - return items diff --git a/armet/query/__init__.py b/armet/query/__init__.py deleted file mode 100644 index ea771ac..0000000 --- a/armet/query/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .parser import (Query, QuerySegment, NoopQuerySegment, - BinarySegmentCombinator, UnarySegmentCombinator) - -__all__ = [ - 'Query', - 'QuerySegment', - 'NoopQuerySegment', - 'BinarySegmentCombinator', - 'UnarySegmentCombinator', -] diff --git a/armet/query/constants.py b/armet/query/constants.py deleted file mode 100644 index 5094fa7..0000000 --- a/armet/query/constants.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import operator -import re - - -#! Equality -OPERATOR_EQUAL = 'exact', '==' - -#! Case-insensitve equality -OPERATOR_IEQUAL = 'iexact', '=' - -#! Less than -OPERATOR_LT = 'lt', '<' - -#! Less than or equal to -OPERATOR_LTE = 'lte', '<=' - -#! Greater than -OPERATOR_GT = 'gt', '>' - -#! Greater than or equal to -OPERATOR_GTE = 'gte', '>=' - -#! Regular expression match -OPERATOR_REGEX = 'regex', '*=' - -#! Null test -OPERATOR_ISNULL = 'isnull', None - -#! Contains (x in y) -OPERATOR_ICONTAINS = 'icontains', None - -#! The fallback to use in the case that a more specific one isn't defined. -OPERATOR_FALLBACK = OPERATOR_IEQUAL -OPERATOR_SUFFIX_FALLBACK = OPERATOR_FALLBACK[0] -OPERATOR_EQUALITY_FALLBACK = OPERATOR_FALLBACK[1] - -#! Operator map relating operations to python operations. -# TODO: the values of the operator map are somewhat unwieldy to work with -# perhaps the value stored in a Segment() type should instead be one of -# the keys instead of the values. The values just show a convenient thing -# to work with in pythonland. -OPERATOR_MAP = { - OPERATOR_EQUAL: operator.eq, - OPERATOR_IEQUAL: lambda x, y: x.lower() == y.lower(), - OPERATOR_LT: operator.lt, - OPERATOR_LTE: operator.le, - OPERATOR_GT: operator.gt, - OPERATOR_GTE: operator.ge, - OPERATOR_REGEX: lambda x, y: re.search(y, x), - OPERATOR_ISNULL: lambda x: x is None, - OPERATOR_ICONTAINS: operator.contains, -} - -#! Operator set containing all operators. -OPERATORS = set(OPERATOR_MAP.keys()) - -#! Operators restricted to suffixed paths (foo.gte=bar) -OPERATOR_SUFFIXES = set(filter(None, map(operator.itemgetter(0), OPERATORS))) - -#! Operators restricted to equality comparators (foo>=bar) -OPERATOR_EQUALITIES = set(filter(None, map(operator.itemgetter(1), OPERATORS))) - -#! Operator map limited to suffixed paths {"gte": operator.ge} -OPERATOR_SUFFIX_MAP = dict( - filter(operator.itemgetter(0), - map(lambda x: (x[0][0], x[1]), - OPERATOR_MAP.items()))) - -#! Operator map limited to equality checks {">=": operator.ge} -OPERATOR_EQUALITY_MAP = dict( - filter(operator.itemgetter(0), - map(lambda x: (x[0][1], x[1]), - OPERATOR_MAP.items()))) - -#! Negation -PATH_NEGATION = 'not' -OPERATOR_NEGATION = '!' - -NEGATION = (PATH_NEGATION, OPERATOR_NEGATION) - -#! Logical -LOGICAL_AND = '&' -LOGICAL_OR = ';' - -#! Path separator -SEP_PATH = '.' - -#! Value separator -SEP_VALUE = ',' - -#! Directive initiator -DIRECTIVE = ':' - -#! Grouping characters -GROUP_BEGIN = '(' -GROUP_END = ')' diff --git a/armet/query/parser.py b/armet/query/parser.py deleted file mode 100644 index 0e9e1f1..0000000 --- a/armet/query/parser.py +++ /dev/null @@ -1,366 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from six.moves import cStringIO as StringIO -import operator -import itertools -from . import constants - - -#! Operation to combinator map. -COMBINATORS = { - constants.LOGICAL_AND: operator.and_, - constants.LOGICAL_OR: operator.or_} - - -#! Set of characters that begin an operator. -OPERATOR_BEGIN_CHARS = set(x[0] for _, x in constants.OPERATORS if x) -OPERATOR_BEGIN_CHARS.add(constants.NEGATION[1]) - -#! Dictionary of operator symbols to operators. -OPERATOR_SYMBOL_MAP = dict((v, k) for k, v in constants.OPERATORS if v) - - -#! List of operator keywords. -OPERATOR_KEYWORDS = set(k for k, _ in constants.OPERATORS) - - -# Make a reversed suffix map for convenient stringification. -REVERSED_OPERATOR_SUFFIX_MAP = dict( - (v, k) for k, v in constants.OPERATOR_SUFFIX_MAP.items() -) - - -class Query(object): - """Represents a complete query expression. - """ - - def __init__(self, original, parsed): - # Keep a copy of the original querystring. - self.original = original - self.parsed = parsed - - -class QuerySegment(object): - """ - Represents a single query segment with a subject path (`x.a.g`), - an operator (`=` or `<=`), optional directives (`sort`), and - a set of values (`5,12,56`). - """ - - def __init__(self, **kwargs): - #! Path to the attribute being tested (as a list of segments). - self.path = kwargs.get('path', []) - - #! This is the operator that is being applied to the attribute path. - self.operator = kwargs.get( - 'operator', - constants.OPERATOR_MAP[constants.OPERATOR_IEQUAL] - ) - - #! Negation; if this operation has been negated. - self.negated = kwargs.get('negated', False) - - #! Directives. Directives are a key-value way of specifying commands - #! on an attribute path. - self.directives = kwargs.get('directives', []) - - #! Values. Set of values that the attribute path is being checked - #! against. Only one has to match. - self.values = kwargs.get('values', []) - - def __repr__(self): - return str(self) - - def __str__(self): - """ - Format this query segment in a human-readable representation - intended for debugging. - """ - - o = StringIO() - - o.write('(') - - if self.negated: - o.write('not ') - - o.write('.'.join(self.path)) - - if self.values: - o.write(' :%s ' % REVERSED_OPERATOR_SUFFIX_MAP[self.operator]) - - o.write(' | '.join(map(lambda x: "'{}'".format(str(x)), self.values))) - - o.write(')') - return o.getvalue() - - -class NoopQuerySegment(object): - """A query segment that doesn't perform an operation. For the purposes - of binary and unary combinations, this should be treated as True. - A NoopQuerySegment should only be encountered when the entire query string - is missing.""" - - def __repr__(self): - return str(self) - - def __str__(self): - return "TRUE" - - -class BinarySegmentCombinator(object): - """ - Represents the combination of 2 query segments `(x=y)&(y=z)` - """ - - def __init__(self, left, right, operation=operator.and_): - self.left = left - self.right = right - self.operation = operation - - def __repr__(self): - return str(self) - - def __str__(self): - combinators = { - operator.and_: 'AND', - operator.or_: 'OR' - } - - return "{} {} {}".format( - str(self.left), - combinators[self.operation], - str(self.right) - ) - - -class UnarySegmentCombinator(object): - """ - Represents a unary combination of 2 query segments. `!(x=y)` - """ - def __init__(self, operand, operation=operator.not_): - self.operand = operand - self.operation = operation - - def __repr__(self): - return str(self) - - def __str__(self): - return "{} {}".format('NOT', self.operand) - - -def parse(text, encoding='utf8'): - """Parse the querystring into a normalized form.""" - - # Decode the text if we got bytes. - if isinstance(text, six.binary_type): - text = text.decode(encoding) - - return Query(text, split_segments(text)) - - -def reset_stringio(buf): - # This combination of functions happens way too often in here. - buf.truncate(0) - buf.seek(0) - - -def split_segments(text, closing_paren=False): - """Return objects representing segments.""" - buf = StringIO() - - # The segments we're building, and the combinators used to combine them. - # Note that after this is complete, this should be true: - # len(segments) == len(combinators) + 1 - # Thus we can understand the relationship between segments and combinators - # like so: - # s1 (c1) s2 (c2) s3 (c3) where sN are segments and cN are combination - # functions. - # TODO: Figure out exactly where the querystring died and post cool - # error messages about it. - segments = [] - combinators = [] - - # A flag dictating if the last character we processed was a group. - # This is used to determine if the next character (being a combinator) - # is allowed to - last_group = False - - # The recursive nature of this function relies on keeping track of the - # state of iteration. This iterator will be passed down to recursed calls. - iterator = iter(text) - - # Detection for exclamation points. only matters for this situation: - # foo=bar&!(bar=baz) - last_negation = False - - for character in iterator: - if character in COMBINATORS: - - if last_negation: - buf.write(constants.OPERATOR_NEGATION) - - # The string representation of our segment. - val = buf.getvalue() - reset_stringio(buf) - - if not last_group and not len(val): - raise ValueError('Unexpected %s.' % character) - - # When a group happens, the previous value is empty. - if len(val): - segments.append(parse_segment(val)) - - combinators.append(COMBINATORS[character]) - - elif character == constants.GROUP_BEGIN: - # Recursively go into the next group. - - if buf.tell(): - raise ValueError('Unexpected %s' % character) - - seg = split_segments(iterator, True) - - if last_negation: - seg = UnarySegmentCombinator(seg) - - segments.append(seg) - - # Flag that the last entry was a grouping, so that we don't panic - # when the next character is a logical combinator - last_group = True - continue - - elif character == constants.GROUP_END: - # Build the segment for anything remaining, and then combine - # all the segments. - val = buf.getvalue() - - # Check for unbalanced parens or an empty thing: foo=bar&();bar=baz - if not buf.tell() or not closing_paren: - raise ValueError('Unexpected %s' % character) - - segments.append(parse_segment(val)) - return combine(segments, combinators) - - elif character == constants.OPERATOR_NEGATION and not buf.tell(): - last_negation = True - continue - - else: - if last_negation: - buf.write(constants.OPERATOR_NEGATION) - if last_group: - raise ValueError('Unexpected %s' % character) - buf.write(character) - - last_negation = False - last_group = False - else: - # Check and see if the iterator exited early (unbalanced parens) - if closing_paren: - raise ValueError('Expected %s.' % constants.GROUP_END) - - if not last_group: - # Add the final segment. - segments.append(parse_segment(buf.getvalue())) - - # Everything completed normally, combine all the segments into one - # and return them. - return combine(segments, combinators) - - -def combine(segments, combinators): - # We get [a,b,c] in segments and combinators that should be applied as - # [a(op)b, result(op)c] - operands = iter(segments) - operators = iter(combinators) - first = next(operands) - reducer = lambda x, y: BinarySegmentCombinator(x, y, next(operators)) - return six.moves.reduce(reducer, operands, first) - - -def parse_directive(key): - """ - Takes a key of type (foo:bar) and returns either the key and the - directive, or the key and None (for no directive.) - """ - if constants.DIRECTIVE in key: - return key.split(constants.DIRECTIVE, 1) - else: - return key, None - - -def parse_segment(text): - "we expect foo=bar" - - if not len(text): - return NoopQuerySegment() - - q = QuerySegment() - - # First we need to split the segment into key/value pairs. This is done - # by attempting to split the sequence for each equality comparison. Then - # discard any that did not split properly. Then chose the smallest key - # (greedily chose the first comparator we encounter in the string) - # followed by the smallest value (greedily chose the largest comparator - # possible.) - - # translate into [('=', 'foo=bar')] - equalities = zip(constants.OPERATOR_EQUALITIES, itertools.repeat(text)) - # Translate into [('=', ['foo', 'bar'])] - equalities = map(lambda x: (x[0], x[1].split(x[0], 1)), equalities) - # Remove unsplit entries and translate into [('=': ['foo', 'bar'])] - # Note that the result from this stage is iterated over twice. - equalities = list(filter(lambda x: len(x[1]) > 1, equalities)) - # Get the smallest key and use the length of that to remove other items - key_len = len(min((x[1][0] for x in equalities), key=len)) - equalities = filter(lambda x: len(x[1][0]) == key_len, equalities) - - # Get the smallest value length. thus we have the earliest key and the - # smallest value. - op, (key, value) = min(equalities, key=lambda x: len(x[1][1])) - - key, directive = parse_directive(key) - if directive: - op = constants.OPERATOR_EQUALITY_FALLBACK - q.directive = directive - - # Process negation. This comes in both foo.not= and foo!= forms. - path = key.split(constants.SEP_PATH) - last = path[-1] - - # Check for != - if last.endswith(constants.OPERATOR_NEGATION): - last = last[:-1] - q.negated = not q.negated - - # Check for foo.not= - if last == constants.PATH_NEGATION: - path.pop(-1) - q.negated = not q.negated - - q.values = value.split(constants.SEP_VALUE) - - # Check for suffixed operators (foo.gte=bar). Prioritize suffixed - # entries over actual equality checks. - if path[-1] in constants.OPERATOR_SUFFIXES: - - # The case where foo.gte<=bar, which obviously makes no sense. - if op not in constants.OPERATOR_FALLBACK: - raise ValueError( - 'Both path-style operator and equality style operator ' - 'provided. Please provide only a single style operator.') - - q.operator = constants.OPERATOR_SUFFIX_MAP[path[-1]] - path.pop(-1) - else: - q.operator = constants.OPERATOR_EQUALITY_MAP[op] - - if not len(path): - raise ValueError('No attribute navigation path provided.') - - q.path = path - - return q diff --git a/armet/registry.py b/armet/registry.py new file mode 100644 index 0000000..926e2c3 --- /dev/null +++ b/armet/registry.py @@ -0,0 +1,106 @@ +from collections import defaultdict, Iterable + + +class Registry: + + def __init__(self, fallback=None, index=None): + """Generic registry for easy lookup. + + If the item is not found, on find() And a "fallback" registry is + provided, the item requested will be searched through the fallback + registry. + """ + self.map = defaultdict(dict) + self.fallback = fallback + self.index = index + self.metadata = {} + + def register(self, obj=None, **kwargs): + + def callback(obj): + if obj is None: + raise TypeError("'%s' object cannot be registered" % + type(obj).__name__) + + metadata = {} + for key, value in kwargs.items(): + if isinstance(value, Iterable) and not isinstance(value, str): + metadata[key] = [] + for val in value: + metadata[key].append(val) + if self.index is None or key in self.index: + self.map[key][val] = obj + + else: + metadata[key] = value + if self.index is None or key in self.index: + self.map[key][value] = obj + + self.metadata[obj] = metadata + + return obj + + if obj is None: + # If no object was passed in we assume the user is attempting + # to use this as a decorator. + return callback + + # Just invoke the callback directly + callback(obj) + + def rfind(self, value): + # FIXME: Implement a cache on the value + for key_name, registry in self.map.items(): + for key, test in registry.items(): + if test is value: + return key + + def find(self, **kwargs): + if len(kwargs) > 1: + raise TypeError( + "%s.find expected at most 1 keyword argument, got %d" % ( + type(self).__name__, len(kwargs))) + + try: + # Pop the (key, value) pair to pass to the lookup method. + key, value = kwargs.popitem() + + # Resolve a `find_FOO` method. + # The idea here is that a derived class could define + # a custom lookup method for a specific attribute. + lookup = getattr(self, "find_%s" % key, + lambda v: self.map[key][v]) + + # Utilize the lookup method to attempt to find the object + # by the passed value. + obj = lookup(value) + return obj, self.metadata[obj] + + except KeyError: + if self.fallback is not None: + # If we don't find what they were looking for; return nothing. + return self.fallback.find(**{key: value}) + + # Re-raise the key-error + raise + + def remove(self, *args, **kwargs): + # For each passed object we need to iterate through each nested + # dictionary and remove each reference to it. + for obj in args: + for registry_name, registry in list(self.map.items()): + for name, item in list(registry.items()): + if item == obj: + del registry[name] + + if not registry: + del self.map[registry_name] + + # For each passed reference we retrieve the object at the reference + # and recurse into removing every reference for that object. + for key, value in kwargs.items(): + try: + self.remove(self.map[key][value]) + + except KeyError: + pass diff --git a/armet/relationship.py b/armet/relationship.py deleted file mode 100644 index 51dcd2a..0000000 --- a/armet/relationship.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from importlib import import_module - - -class Relationship(object): - """ - Track a relationship on a specific key of a target object. - - NOTE: The idea is to do eventual self-discovery of the resource argument. - """ - - def __init__(self, key, resource, link=True, write=False, list=False): - self.key = key - self._resource = resource - self._resource_cls = None - self.link = link - self.list = list - self.write = write - - @property - def resource(self): - if not self._resource_cls: - if isinstance(self._resource, six.string_types): - parts = self._resource.split('.') - mod = import_module('.'.join(parts[:-1])) - self._resource_cls = getattr(mod, parts[-1]) - - else: - self._resource_cls = self._resource - - return self._resource_cls diff --git a/armet/resources.py b/armet/resources.py new file mode 100644 index 0000000..dd0a87b --- /dev/null +++ b/armet/resources.py @@ -0,0 +1,175 @@ + + +class CycleRegistry(dict): + + def __init__(self, fallback=None): + super().__init__() + + # A fallback dictionary to use if the key is not in this dictionary. + self._fallback = fallback + + def __missing__(self, key): + # If the key is a "type" we should attempt to iterate back through + # its MRO to resolve it based on inheritance. + if isinstance(key, type): + for cls in key.__mro__: + if cls in self: + # Cache this for the next lookup. + self[key] = self[cls] + + # Return the result. + return self[cls] + + # If we have a fallback dictionary, use that. + if self._fallback: + return self._fallback[key] + + # Raise a KeyError as we don't have this. + raise KeyError + + +# Registry of preparation functions (by name and type) +_prepares = CycleRegistry() + +# Registry of cleaning functions (by name and type) +_cleans = CycleRegistry() + + +class Resource: + + # Set of named attributes to faclitiate from the `item` returned + # from the `read` method. + attributes = set() + + # Set of resource names that can be traversed to from this resource. + relationships = set() + + def __init__(self, slug=None, context=None): + """ + :param slug: Identifier that represents which item of the resource + to return, if present. + :type context: str or None + + :param context: Context in which this resource is being called (eg. + a nested resource will receive the result of a `read` + from the parent resource in its context). + :type context: dict or None + """ + self.slug = slug + self.context = context or {} + + def prepare_item(self, item): + # data = OrderedDict() + data = {} + for name in self.attributes: + try: + # Attempt to get the attribute from the item. + value = getattr(item, name) + + except AttributeError: + # Item does not have the attribute; just put `None` + # in the object. + data[name] = None + continue + + try: + # Attempt to get a preparation function, by type. + prepare = _prepares[type(value)] + + except KeyError: + try: + # Attempt to get a preparation function, by name. + prepare = _prepares[name] + + except KeyError: + # No preparation function; continue. + data[name] = value + continue + + # Prepare and push the attribute in the object. + data[name] = prepare(item, name, value) + + return data + + def prepare(self, items): + return [self.prepare_item(item) for item in items] + + +def prepares(*clauses): + """ + Registers a preparation function to be invoked for each clause in + the decorator. + + A preparation clause is invoked on each attribute after it is retrieved + from the item returned from the `read` function. + + A clause may be a string or a type. If it is a string then the clause + describes an attribute of a resource by name. If it is a type then the + clause would be applied to all attributes of that type (or are an instance + of that type). + + A preparation function may be "scoped" to a specific resource + by decorating a method (instead of a module function). + + :: + @prepares(datetime.datetime) + def prepare_datetime(item, key, value): + return value.isoformat() + + @prepares("id") + def prepare_id(item, key, value): + return str(value) + + class User(Resource): + + @prepares("id") + def prepare_id(self, item, key, value): + return value.hex + """ + def decorator(function): + # Register this preparation function for each passed clause. + for clause in clauses: + _prepares[clause] = function + + # Return the original function. + return function + + return decorator + + +def cleans(*clauses): + """ + Registers a cleaning function to be invoked for each clause in + the decorator. + + A cleaning clause is invoked on each attribute after it is received + from the decoder and before it is sent to the `update` or `create` + functions. + + Otherwise the semantics are equivalent to the `prepares` decorator. + + :: + @cleans(datetime.datetime) + def clean_datetime(key, value): + from dateutil import parse + return parse(value) + + @cleans("id") + def clean_id(key, value): + return UUID(value) + + class User(Resource): + + @cleans("id") + def clean_id(self, key, value): + return UUID(value) + """ + def decorator(function): + # Register this preparation function for each passed clause. + for clause in clauses: + _cleans[clause] = function + + # Return the original function. + return function + + return decorator diff --git a/armet/resources/__init__.py b/armet/resources/__init__.py index 8fc1f86..8082724 100644 --- a/armet/resources/__init__.py +++ b/armet/resources/__init__.py @@ -1,11 +1,9 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .resource import Resource -from .managed import ManagedResource -from .model import ModelResource +from .registry import prepares, cleans +from .base import Resource + __all__ = [ 'Resource', - 'ManagedResource', - 'ModelResource' + 'prepares', + 'cleans', ] diff --git a/armet/resources/base.py b/armet/resources/base.py new file mode 100644 index 0000000..c6c438c --- /dev/null +++ b/armet/resources/base.py @@ -0,0 +1,91 @@ +from .registry import _prepare + + +class ResourceMeta(type): + + def __init__(self, name, bases, attrs): + super().__init__(name, bases, attrs) + + # Collect `Meta` information and store on `_meta` + # This aggregates all base class `Meta` information + metadata = {} + + for base in bases: + _meta = getattr(base, "_meta", None) + if _meta is None: + _meta = {} + else: + _meta = vars(_meta) + metadata.update(_meta) + + meta = attrs.get("Meta") + if meta: + for name, value in vars(meta).items(): + if not name.startswith("_"): + metadata[name] = value + + # Create and store the metadata as `_meta` + self._meta = type("Meta", (), metadata) + + +class Resource(metaclass=ResourceMeta): + + class Meta: + # Set of named attributes to faclitiate from the `item` returned + # from the `read` method. + attributes = set() + attribute_labels = {} + relationships = set() + + def __init__(self, slug=None, context=None): + """ + :param slug: Identifier that represents which item of the resource + to return, if present. + :type context: str or None + + :param context: Context in which this resource is being called (eg. + a nested resource will receive the result of a `read` + from the parent resource in its context). + :type context: dict or None + """ + self.slug = slug + self.context = context or {} + + @classmethod + def prepare_item(cls, item): + data = {} + for name in cls.attributes: + try: + # Attempt to get the attribute from the item. + value = getattr(item, name) + + # Run the value through the preparation cycle. + value = _prepare(item, name, value) + + except AttributeError: + # Item does not have the attribute; just put `None` + # in the object. + value = None + continue + + cls._set_item(data, name, value) + + return data + + @classmethod + def _set_item(cls, data, name, value): + if name in cls._meta.attribute_labels: + data[cls._meta.attribute_labels[name]] = value + + else: + data[name] = value + + def filter(self, items, query): + # TODO: Apply `slug` filtering + # TODO: Apply `context` filtering + # TODO: Apply `query` filtering + return query + + @classmethod + def prepare(cls, items): + return [cls.prepare_item(item) for item in items] diff --git a/armet/resources/managed/__init__.py b/armet/resources/managed/__init__.py deleted file mode 100644 index 50940b0..0000000 --- a/armet/resources/managed/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .base import ManagedResource as BaseManagedResource -from .meta import ManagedResourceBase - -__all__ = [ - 'ManagedResource' -] - - -class ManagedResource( - six.with_metaclass(ManagedResourceBase, BaseManagedResource)): - """Implements the RESTful resource protocol for managed resources. - """ diff --git a/armet/resources/managed/base.py b/armet/resources/managed/base.py deleted file mode 100644 index 587d748..0000000 --- a/armet/resources/managed/base.py +++ /dev/null @@ -1,498 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import logging -from collections import Sequence, MutableSequence, Iterable -from armet import http, pagination -from armet.exceptions import ValidationError -from armet.resources.resource import base - - -logger = logging.getLogger(__name__) - - -class ManagedResource(base.Resource): - """Implements the RESTful resource protocol for managed resources. - - @note - This is not the class to derive from when implementing your own - resources. Derive from `armet.resources.ManagedResource` (defined in - the `__init__.py`). - """ - - class Meta: - # The managed resource access pattern. - # Traversal is handled a bit more dyanmically than allowed with - # the simple pattern syntax. - patterns = [ - r'^' - r'(?:\:(?P[^/\(\)]*))?' - r'(?:\((?P[^/]*)\))?' - r'(?:/(?P[^/]+?))?' - r'(?:/(?P.+?))??' - r'(?:\.(?P[^/]+?))??/??$' - ] - - #! An ordered dictionary of attributes that are gathered from - #! attributes defined directly on the resource. - #! - #! @code - #! from armet import resources - #! - #! class Resource(resources.Resource): - #! name = resources.Attribute() - #! created = resources.Attribute('created') - #! @endcode - attributes = None - - #! The slug that is used to identify the resource an item is being - #! requested. None if a list is being requested. - slug = None - - @classmethod - def parse(cls, path): - result = super(ManagedResource, cls).parse(path) - if result: - # Found something; parse the result. - resource, data, end = result - - # Normalize the list arguments. - for sep, name in (('.', 'extensions'), (':', 'directives')): - if data[name]: - data[name] = data[name].split(sep) - - else: - data[name] = [] - - # Reset the result. - result = resource, data, end - - # Return what we got. - return result - - def __init__(self, *args, **kwargs): - super(ManagedResource, self).__init__(*args, **kwargs) - - #! Map of errors that have occurred in the clean cycle. - self._errors = {} - - @property - def allowed_operations(self): - """Retrieves the allowed operations for this request.""" - if self.slug is not None: - return self.meta.detail_allowed_operations - - return self.meta.list_allowed_operations - - def assert_operations(self, *args): - """Assets if the requested operations are allowed in this context.""" - if not set(args).issubset(self.allowed_operations): - raise http.exceptions.Forbidden() - - def make_response(self, data=None): - """Fills the response object from the passed data.""" - if data is not None: - # Prepare the data for transmission. - data = self.prepare(data) - - # Encode the data using a desired encoder. - self.response.write(data, serialize=True) - - def prepare(self, data): - if data is None: - # No data; return nothing. - return None - - if self.path: - # Ensure we are dealing with only one segment. - if '/' in self.path: - raise http.exceptions.NotFound() - - if (isinstance(data, Iterable) - and not isinstance(data, six.string_types)): - # Resolve the sequence only if we need to. - if not isinstance(data, MutableSequence): - data = list(data) - - # Attempt to prepare each item of the iterable (as long as - # we're not a string or some sort of mapping). - for index, value in enumerate(data): - data[index] = self.item_prepare(data[index]) - - return data - - # Prepare just the singular value and return. - data = self.item_prepare(data) - if data is None: - raise http.exceptions.NotFound() - - return data - - def attribute_prepare(self, name, attribute, item): - # Run the attribute through its prepare cycle. - return attribute.prepare( - # Optional preparation cycle on the resource. - self.preparers[name]( - # Retrieves the value from the object. - self, item, attribute.get(item))) - - def item_prepare(self, item): - # If we are the root resource, clear the map. - if type(self) == self.request._resource.__class__: - self.request._embed_related.clear() - - # We've started doing this one. - self.request._embed_related.add(type(self)) - - # Check for a path first. - if self.path: - attribute = self.attributes.get(self.path) - if attribute is None: - raise http.exceptions.NotFound() - - if not attribute.read: - raise http.exceptions.Forbidden() - - return self.attribute_prepare(self.path, attribute, item) - - # Initialize the object that hold the resultant item. - obj = {} - - # Iterate through the attributes and build the object from the item. - for name, attribute in self.attributes.items(): - if attribute.include: - # Run the attribute through its prepare cycle. - obj[attribute.name] = self.attribute_prepare( - name, attribute, item) - - if type(self) == self.request._resource.__class__: - # Iterate through the relationships and build their objects. - # FIXME: This should be opt-in - for key, relationship in self.relationships.items(): - # If we did this one; skip - if relationship.resource in self.request._embed_related: - continue - - # If we are in list access and this is not okay for - # lists then skip. - if self.slug is None and not relationship.list: - continue - - # Say that we did this one. - self.request._embed_related.add(relationship.resource) - - # Construct the related resource - related = relationship.resource( - self.request, self.response) - # related.require_authentication(self.request) - - # Get the related items. - related_items = self.read_related( - item, related, relationship.key) - - # Prepare and add to the attribute. - obj[key] = related.prepare(related_items) - - # Say that we're done with this one. - self.request._embed_related = { - x for x in self.request._embed_related - if not issubclass(x, relationship.resource) - } - - # TODO: Remove all that we are. - self.request._embed_related = { - x for x in self.request._embed_related - if not isinstance(self, x) - } - - # Return the resultant object. - return obj - - def _clean(self, target, data): - # Wrap clean so that it can be extended and have validation - # errors properly handled. - - # HACK: Replace this later with passing item down through clean(...) -- - # however this is to fix a bug and should not break the API. - self.__target = target - - try: - data = self.clean(data) - - except AssertionError as ex: - self._errors['__all__'] = [str(ex)] - - except ValidationError as ex: - self._errors['__all__'] = ex.errors - - if self._errors: - # Collect errors and raise a BadRequest message to the client. - raise http.exceptions.BadRequest(self._errors) - - return data - - def clean(self, data): - if not data: - # If no data; just alias it to an empty dictionary. - data = {} - - if (isinstance(data, Sequence) - and not isinstance(data, six.string_types)): - # Attempt to clean each item of the iterable (as long as - # we're not a string or some sort of mapping). - for index, value in enumerate(data): - data[index] = self.item_clean(data[index]) - - return data - - # Clean just the singular value and return. - return self.item_clean(data) - - def item_clean(self, item): - # Iterate through the attributes and build the object from the item. - obj = {} - - for name, attribute in self.attributes.items(): - value = item.get(attribute.name) - - try: - if value is not None: - # Run the attribute through its clean cycle. - value = self.cleaners[name]( - # Micro preparation cycle on the attribute object. - self, attribute.clean(value)) - - # Check if this attribute is writeable. - if not attribute.write: - if (not self.__target - or attribute.get(self.__target) != value): - raise ValidationError('Attribute is read-only.') - - # Ensure that we don't have a null or it is provided. - if value is None or ( - isinstance(value, six.string_types) and value == ''): - if name in item and not attribute.null: - raise ValidationError('Must not be null.') - - elif attribute.required: - raise ValidationError('Must be provided.') - - except (ValueError, AssertionError) as ex: - self._errors[attribute.name] = [str(ex)] - value = None - - except ValidationError as ex: - self._errors[attribute.name] = ex.errors - value = None - - obj[name] = value - - # Iterate through write-able relations - for name, relation in six.iteritems(self.relationships): - if relation.write: - value = item.get(name) - try: - value = self.clean_related(relation, value) - obj[name] = value - - except (ValueError, AssertionError) as ex: - self._errors[attribute.name] = [str(ex)] - value = None - - except ValidationError as ex: - self._errors[attribute.name] = ex.errors - value = None - - return obj - - @property - def http_allowed_methods(self): - if self.slug is None: - # No slug means that we're accessing this as a list. - return self.meta.http_list_allowed_methods - - # A slug exists; this is being accessed as an item. - return self.meta.http_detail_allowed_methods - - def require_http_allowed_method(self, request): - # No super call as the following replaces it by checking - # only against the more specific `list_*` or `detail_*` http - # allowed methods. - - # Check against `list_*` or `detail_*` allowed methods. - if request.method not in self.http_allowed_methods: - # Current method is found to not be allowed for - # this type of access; raise. - raise http.exceptions.MethodNotAllowed(self.http_allowed_methods) - - def get(self, request, response): - """Processes a `GET` request.""" - # Ensure we're allowed to read the resource. - self.assert_operations('read') - - # Delegate to `read` to retrieve the items. - items = self.read() - - # if self.slug is not None and not items: - # # Requested a specific resource but nothing is returned. - - # # Attempt to resolve by changing what we understand as - # # a slug to a path. - # self.path = self.path + self.slug if self.path else self.slug - # self.slug = None - - # # Attempt to retreive the resource again. - # items = self.read() - - # Ensure that if we have a slug and still no items that a 404 - # is rasied appropriately. - if not items: - raise http.exceptions.NotFound() - - if (isinstance(items, Iterable) - and not isinstance(items, six.string_types)) and items: - # Paginate over the collection. - items = pagination.paginate(self.request, self.response, items) - - # Build the response object. - self.make_response(items) - - def post(self, request, response): - """Processes a `POST` request.""" - if self.slug is not None: - # Don't know what to do an item access. - raise http.exceptions.NotImplemented() - - # Ensure we're allowed to create a resource. - self.assert_operations('create') - - # Deserialize and clean the incoming object. - data = self._clean(None, self.request.read(deserialize=True)) - - # Delegate to `create` to create the item. - item = self.create(data) - - # Build the response object. - self.response.status = http.client.CREATED - self.make_response(item) - - def put(self, request, response): - """Processes a `PUT` request.""" - if self.slug is None: - # Mass-PUT is not implemented. - raise http.exceptions.NotImplemented() - - # Check if the resource exists. - target = self.read() - - # Deserialize and clean the incoming object. - data = self._clean(target, self.request.read(deserialize=True)) - - if target is not None: - # Ensure we're allowed to update the resource. - self.assert_operations('update') - - try: - # Delegate to `update` to create the item. - self.update(target, data) - - except AttributeError: - # No read method defined. - raise http.exceptions.NotImplemented() - - # Build the response object. - self.make_response(target) - - else: - # Ensure we're allowed to create the resource. - self.assert_operations('create') - - # Delegate to `create` to create the item. - target = self.create(data) - - # Build the response object. - self.response.status = http.client.CREATED - self.make_response(target) - - def delete(self, request, response): - """Processes a `DELETE` request.""" - if self.slug is None: - # Mass-DELETE is not implemented. - raise http.exceptions.NotImplemented() - - # Ensure we're allowed to destroy a resource. - self.assert_operations('destroy') - - # Delegate to `destroy` to destroy the item. - self.destroy() - - # Build the response object. - self.response.status = http.client.NO_CONTENT - self.make_response() - - def _parse_link_headers(self, header): - # Collect all the passed link headers. - links = [] - for link_header in header.split(','): - link = link_header.split(';') - attrs = {k.strip(): v for (k, v) in [ - x.split('=') for x in link[1:]]} - attrs['uri'] = link[0][1:-1] - links.append(attrs) - - # Return the collected links. - return links - - def link(self, request, response): - """Processes a `LINK` request. - - A `LINK` request is asking to create a relation from the currently - represented URI to all of the `Link` request headers. - """ - from armet.resources.managed.request import read - - if self.slug is None: - # Mass-LINK is not implemented. - raise http.exceptions.NotImplemented() - - # Get the current target. - target = self.read() - - # Collect all the passed link headers. - links = self._parse_link_headers(request['Link']) - - # Pull targets for each represented link. - for link in links: - # Delegate to a connector. - self.relate(target, read(self, link['uri'])) - - # Build the response object. - self.response.status = http.client.NO_CONTENT - self.make_response() - - def unlink(self, request, response): - """Processes a `UNLINK` request. - - A `UNLINK` request is asking to revoke a relation from the currently - represented URI to all of the `Link` request headers. - """ - from armet.resources.managed.request import read - - if self.slug is None: - # Mass-LINK is not implemented. - raise http.exceptions.NotImplemented() - - # Get the current target. - target = self.read() - - # Collect all the passed link headers. - links = self._parse_link_headers(request['Link']) - - # Pull targets for each represented link. - for link in links: - # Delegate to a connector. - self.unrelate(target, read(self, link['uri'])) - - # Build the response object. - self.response.status = http.client.NO_CONTENT - self.make_response() diff --git a/armet/resources/managed/meta.py b/armet/resources/managed/meta.py deleted file mode 100644 index 42b8cca..0000000 --- a/armet/resources/managed/meta.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import collections -from armet.exceptions import ImproperlyConfigured -from armet.attributes import Attribute -from ..resource.meta import ResourceBase -from armet.relationship import Relationship -from . import options -import copy - - -class ManagedResourceBase(ResourceBase): - - options = options.ManagedResourceOptions - - def __new__(cls, name, bases, attrs): - # Construct the class object. - self = super(ManagedResourceBase, cls).__new__(cls, name, bases, attrs) - - if not cls._is_resource(name, bases): - # This is not an actual resource. - return self - - # Gather declared attributes from ourself and base classes. - # TODO: We'll likely need a hook here for ORMs - self.attributes = attributes = collections.OrderedDict() - for base in bases: - if getattr(base, 'attributes', None): - attributes.update(base.attributes) - - for index, attribute in six.iteritems(attrs): - if isinstance(attribute, Attribute): - attributes[index] = attribute - - # Ensure all attributes are unique instances and assign names. - # This is done so that when attributes are resolved; the resolution - # cache does not clobber base resources (for inherited attributes). - for attr in attributes: - attributes[attr] = attributes[attr].clone() - if not attributes[attr].name: - attributes[attr].name = attr - - # Resolve the slug reference to an attribute. - if self.meta.slug not in attributes: - if not self.meta.abstract: - raise ImproperlyConfigured( - 'slug must reference an existing attribute') - - else: - self.meta.slug = attributes[self.meta.slug] - - # Cache access to the attribute preparation cycle. - self.preparers = preparers = {} - for key in attributes: - prepare = getattr(self, 'prepare_{}'.format(key), None) - if not prepare: - prepare = lambda self, obj, value: value - preparers[key] = prepare - - # Cache access to the attribute clean cycle. - self.cleaners = cleaners = {} - for key in attributes: - clean = getattr(self, 'clean_{}'.format(key), None) - if not clean: - clean = lambda self, value: value - cleaners[key] = clean - - # Collect all relationships and store them by their model key. - self.relationships = relationships = collections.OrderedDict() - for base in bases: - if getattr(base, 'relationships', None): - relationships.update(base.relationships) - - for name, attr in six.iteritems(attrs): - if isinstance(attr, Relationship): - relationships[name] = attr - - # Ensure all relationships are unique instances. - for attr in relationships: - relationships[attr] = copy.copy(relationships[attr]) - - # Return the constructed class object. - return self diff --git a/armet/resources/managed/options.py b/armet/resources/managed/options.py deleted file mode 100644 index 4a71d02..0000000 --- a/armet/resources/managed/options.py +++ /dev/null @@ -1,153 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from ..resource import options - - -def _method_to_operation(method): - if method == 'GET': - return set(['read']) - - if method == 'PUT': - return set(['update', 'create', 'delete']) - - if method == 'POST': - return set(['create']) - - if method == 'PATCH': - return set(['update', 'create']) - - if method == 'DELETE': - return set(['destroy']) - - -def _methods_to_operations(methods): - operations = set() - for method in methods: - operations.update(_method_to_operation(method)) - - return operations - - -def _operation_to_method(operation): - if operation == 'read': - return set(['GET']) - - if operation == 'update': - return set(['PUT', 'PATCH', 'LINK', 'UNLINK']) - - if operation == 'create': - return set(['PUT', 'PATCH', 'POST']) - - if operation == 'destroy': - return set(['PUT', 'DELETE']) - - -def _operations_to_methods(operations): - methods = set(['HEAD', 'OPTIONS']) - for operation in operations: - methods.update(_operation_to_method(operation)) - - return methods - - -class ManagedResourceOptions(options.ResourceOptions): - - def __init__(self, meta, name, data, bases): - # Initalize base resource options. - super(ManagedResourceOptions, self).__init__(meta, name, data, bases) - - #! List of allowed operations. - #! Resource operations are meant to generalize and blur the - #! differences between "PATCH and PUT", "PUT = create / update", - #! etc. - #! - #! If not provided and http_allowed_methods was provided instead - #! the methods are appropriately mapped; else, the default - #! configuration is provided. - self.allowed_operations = meta.get('allowed_operations') - if self.allowed_operations is None: - if meta.get('http_allowed_methods'): - self.allowed_operations = _methods_to_operations(meta.get( - 'http_allowed_methods')) - - else: - self.allowed_operations = ( - 'read', - 'create', - 'update', - 'destroy', - ) - - # Coerce http allowed methods from the - # allowed operations. - if meta.get('http_allowed_methods') is None: - if meta.get('allowed_operations'): - self.http_allowed_methods = _operations_to_methods(meta.get( - 'allowed_operations')) - - #! List of allowed HTTP methods against a whole - #! resource (eg /user); if undeclared or None, will be defaulted - #! to `http_allowed_methods`. - self.http_list_allowed_methods = meta.get( - 'http_list_allowed_methods') - - if self.http_list_allowed_methods is None: - if meta.get('list_allowed_operations'): - self.http_list_allowed_methods = _operations_to_methods( - meta.get('list_allowed_operations')) - - else: - self.http_list_allowed_methods = self.http_allowed_methods - - #! List of allowed HTTP methods against a single - #! resource (eg /user/1); if undeclared or None, will be defaulted - #! to `http_allowed_methods`. - self.http_detail_allowed_methods = meta.get( - 'http_detail_allowed_methods') - - if self.http_detail_allowed_methods is None: - if meta.get('detail_allowed_operations'): - self.http_detail_allowed_methods = _operations_to_methods( - meta.get('detail_allowed_operations')) - - else: - self.http_detail_allowed_methods = self.http_allowed_methods - - #! List of allowed operations against a whole resource. - #! If undeclared or None, will be defaulted to `allowed_operations`. - self.list_allowed_operations = meta.get('list_allowed_operations') - - if self.list_allowed_operations is None: - if meta.get('http_list_allowed_methods'): - self.list_allowed_operations = _methods_to_operations( - meta.get('http_list_allowed_methods')) - - else: - self.list_allowed_operations = self.allowed_operations - - #! List of allowed operations against a single resource. - #! If undeclared or None, will be defaulted to `allowed_operations`. - self.detail_allowed_operations = meta.get( - 'detail_allowed_operations') - - if self.detail_allowed_operations is None: - if meta.get('http_detail_allowed_methods'): - self.detail_allowed_operations = _methods_to_operations( - meta.get('http_detail_allowed_methods')) - - else: - self.detail_allowed_operations = self.allowed_operations - - #! Attribute to use for the slug or url segment - #! that identifies the resource. The slug attribute is - #! a special attribute; there are a couple of requirements. - #! One is that it must be a unique reference. A /url/slug must - #! return at most one item. Second is that as it is a special - #! attribute that is not part of the body there is not - #! a `prepare_slug` method. - self.slug = meta.get('slug') - if self.slug is None: - # The slug defaults to `id`; which on most model engines - # is the primary key. This is as good as a default as any I - # suppose. - self.slug = 'id' diff --git a/armet/resources/managed/request.py b/armet/resources/managed/request.py deleted file mode 100644 index 7367daf..0000000 --- a/armet/resources/managed/request.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - - -def read(resource, url): - """ - Perform a `read` request in the passed `resource` context against - the given `url`. - - Returns what a `read` would return (the managed target item). - """ - return resource._request_read(url) diff --git a/armet/resources/model/__init__.py b/armet/resources/model/__init__.py deleted file mode 100644 index 67932f2..0000000 --- a/armet/resources/model/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .base import ModelResource as BaseModelResource -from .meta import ModelResourceBase - -__all__ = [ - 'ModelResource' -] - - -class ModelResource(six.with_metaclass(ModelResourceBase, BaseModelResource)): - """Implements the RESTful resource protocol for model resources. - """ diff --git a/armet/resources/model/base.py b/armet/resources/model/base.py deleted file mode 100644 index ec217c6..0000000 --- a/armet/resources/model/base.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import logging -from ..managed import base - - -logger = logging.getLogger(__name__) - - -class ModelResource(base.ManagedResource): - """Implements the RESTful resource protocol for model-bound resources. - - @note - This is not the class to derive from when implementing your own - resources. Derive from `armet.resources.ModelResource` (defined in - the `__init__.py`). - """ diff --git a/armet/resources/model/meta.py b/armet/resources/model/meta.py deleted file mode 100644 index 6edcf90..0000000 --- a/armet/resources/model/meta.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from ..managed import meta -from . import options -import six - - -# Mapping of the canonical resource for a model. -_canonical_resources = {} - - -class ModelResourceBase(meta.ManagedResourceBase): - - options = options.ModelResourceOptions - - connectors = ['http', 'model'] - - _related_models_cache = None - - def __new__(cls, name, bases, attrs): - # Construct the class object. - self = super(ModelResourceBase, cls).__new__(cls, name, bases, attrs) - - if self.meta and not self.meta.abstract: - # Add this to the canonical resource dictionary. - _canonical_resources[self.meta.model] = self - - # Return the constructed class object. - return self - - @property - def _related_models(self): - if not self._related_models_cache: - if hasattr(self, 'relationships'): - # Construct a mapping of all relationships and their models. - self._related_models_cache = {} - for key, relation in six.iteritems(self.relationships): - self._related_models_cache[ - relation.resource.meta.model] = key - - return self._related_models_cache diff --git a/armet/resources/model/options.py b/armet/resources/model/options.py deleted file mode 100644 index 8528b5d..0000000 --- a/armet/resources/model/options.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet.exceptions import ImproperlyConfigured -from ..managed import options - - -class ModelResourceOptions(options.ManagedResourceOptions): - - def __init__(self, meta, name, data, bases): - # Initalize base resource options. - super(ModelResourceOptions, self).__init__(meta, name, data, bases) - - #! Reference to the declarative model defined by - #! the Object Relational Mapper (ORM). - self.model = meta.get('model') - if self.model is None and not self.abstract: - raise ImproperlyConfigured( - 'Model resources must be bound to a model.') diff --git a/armet/resources/registry.py b/armet/resources/registry.py new file mode 100644 index 0000000..7a4a447 --- /dev/null +++ b/armet/resources/registry.py @@ -0,0 +1,138 @@ +from functools import partial + + +class CycleRegistry(dict): + + def __init__(self, fallback=None): + super().__init__() + + # A fallback dictionary to use if the key is not in this dictionary. + self._fallback = fallback + + def __missing__(self, key): + # If the key is a "type" we should attempt to iterate back through + # its MRO to resolve it based on inheritance. + if isinstance(key, type): + for cls in key.__mro__: + if cls in self: + # Cache this for the next lookup. + self[key] = self[cls] + + # Return the result. + return self[cls] + + # If we have a fallback dictionary, use that. + if self._fallback: + return self._fallback[key] + + # Raise a KeyError as we don't have this. + raise KeyError + + +# Registry of preparation functions (by name and type) +_prepares = CycleRegistry() + +# Registry of cleaning functions (by name and type) +_cleans = CycleRegistry() + + +def _cycle_attribute(registry, obj, name, value): + """Send an attribute through the preparation/cleaning cycle. + """ + try: + # Attempt to get a preparation function, by type. + cycler = registry[type(value)] + except KeyError: + try: + # Look up preparation function by name. + cycler = registry[name] + except KeyError: + # No preparation function. Just return the value. + return value + + # Prepare the value. + return cycler(obj, name, value) + + +_prepare = partial(_cycle_attribute, _prepares) +_clean = partial(_cycle_attribute, _cleans) + + +def prepares(*clauses): + """ + Registers a preparation function to be invoked for each clause in + the decorator. + + A preparation clause is invoked on each attribute after it is retrieved + from the item returned from the `read` function. + + A clause may be a string or a type. If it is a string then the clause + describes an attribute of a resource by name. If it is a type then the + clause would be applied to all attributes of that type (or are an instance + of that type). + + A preparation function may be "scoped" to a specific resource + by decorating a method (instead of a module function). + + :: + @prepares(datetime.datetime) + def prepare_datetime(item, key, value): + return value.isoformat() + + @prepares("id") + def prepare_id(item, key, value): + return str(value) + + class User(Resource): + + @prepares("id") + def prepare_id(self, item, key, value): + return value.hex + """ + def decorator(function): + # Register this preparation function for each passed clause. + for clause in clauses: + _prepares[clause] = function + + # Return the original function. + return function + + return decorator + + +def cleans(*clauses): + """ + Registers a cleaning function to be invoked for each clause in + the decorator. + + A cleaning clause is invoked on each attribute after it is received + from the decoder and before it is sent to the `update` or `create` + functions. + + Otherwise the semantics are equivalent to the `prepares` decorator. + + :: + @cleans(datetime.datetime) + def clean_datetime(key, value): + from dateutil import parse + return parse(value) + + @cleans("id") + def clean_id(key, value): + return UUID(value) + + class User(Resource): + + @cleans("id") + def clean_id(self, key, value): + return UUID(value) + """ + def decorator(function): + # Register this preparation function for each passed clause. + for clause in clauses: + _cleans[clause] = function + + # Return the original function. + return function + + return decorator diff --git a/armet/resources/resource/__init__.py b/armet/resources/resource/__init__.py deleted file mode 100644 index d8c2d2d..0000000 --- a/armet/resources/resource/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .base import Resource as BaseResource -from .meta import ResourceBase - -__all__ = [ - 'Resource' -] - - -class Resource(six.with_metaclass(ResourceBase, BaseResource)): - """Implements the RESTful resource protocol for abstract resources. - """ diff --git a/armet/resources/resource/base.py b/armet/resources/resource/base.py deleted file mode 100644 index 06ce78f..0000000 --- a/armet/resources/resource/base.py +++ /dev/null @@ -1,580 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import logging -import re -import six -import collections -import mimeparse -from armet import http, utils - - -logger = logging.getLogger(__name__) - - -class Resource(object): - """Implements the RESTful resource protocol for abstract resources. - - @note - This is not the class to derive from when implementing your own - resources. Derive from `armet.resources.Resource` (defined in - the `__init__.py`). - """ - - #! Instantiated options class; contains all merged properties - #! from the class hierarchy's Meta classes. - meta = None - - #! Maps media ranges to deserializer names. - #! Generated by the metaclass. - _deserializer_map = None - - #! Maps media ranges to serializer names. - #! Generated by the metaclass. - _serializer_map = None - - def __new__(cls, request, response, *args, **kwargs): - # Parse any arguments out of the path and traverse down the - # path using any defined patterns. - cls, params = cls.traverse(request) - - # Actually construct the resource and return the instance. - obj = super(Resource, cls).__new__(cls) - - # Update our instance dictionary with the arugments from `parse`. - obj.__dict__.update(params) - - # Return our constructed instance. - return obj - - @classmethod - def redirect(cls, request, response): - """Redirect to the canonical URI for this resource.""" - if cls.meta.legacy_redirect: - if request.method in ('GET', 'HEAD',): - # A SAFE request is allowed to redirect using a 301 - response.status = http.client.MOVED_PERMANENTLY - - else: - # All other requests must use a 307 - response.status = http.client.TEMPORARY_REDIRECT - - else: - # Modern redirects are allowed. Let's have some fun. - # Hopefully you're client supports this. - # The RFC explicitly discourages UserAgent sniffing. - response.status = http.client.PERMANENT_REDIRECT - - # Terminate the connection. - response.close() - - @classmethod - def view(cls, request, response): - """ - Entry-point of the request / response cycle; Handles resource creation - and delegation. - - @param[in] requset - The HTTP request object; containing accessors for information - about the request. - - @param[in] response - The HTTP response object; contains accessors for modifying - the information that will be sent to the client. - """ - # Determine if we need to redirect. - test = cls.meta.trailing_slash - if test ^ request.path.endswith('/'): - # Construct a new URL by removing or adding the trailing slash. - path = request.path + '/' if test else request.path[:-1] - response['Location'] = '{}://{}{}{}{}'.format( - request.protocol.lower(), - request.host, - request.mount_point, - path, - '?' + request.query if request.query else '') - - # Redirect to the version with the correct trailing slash. - return cls.redirect(request, response) - - try: - # Instantiate the resource. - obj = cls(request, response) - - # Bind the request and response objects to the constructed - # resource. - request.bind(obj) - response.bind(obj) - - # Bind the request object to the resource. - # This is used to facilitate the serializer and deserializer. - obj._request = request - - # Initiate the dispatch cycle and handle its result on - # synchronous requests. - result = obj.dispatch(request, response) - - if not response.asynchronous: - # There is several things that dispatch is allowed to return. - if (isinstance(result, collections.Iterable) and - not isinstance(result, six.string_types)): - # Return the stream generator. - return cls.stream(response, result) - - else: - # Leave it up to the response to throw or write whatever - # we got back. - response.end(result) - if response.body: - # Return the body if there was any set. - return response.body - - except http.exceptions.BaseHTTPException as e: - # Something that we can handle and return properly happened. - # Set response properties from the exception. - response.status = e.status - response.headers.update(e.headers) - - if e.content: - # Write the exception body if present and close - # the response. - # TODO: Use the plain-text encoder. - response.send(e.content, serialize=True, format='json') - - # Terminate the connection and return the body. - response.close() - if response.body: - return response.body - - except Exception: - # Something unexpected happened. - # Log error message to the logger. - logger.exception('Internal server error') - - # Write a debug message for the client. - if not response.streaming and not response.closed: - response.status = http.client.INTERNAL_SERVER_ERROR - response.headers.clear() - response.close() - - @classmethod - def parse(cls, path): - """Parses out parameters and separates them out of the path. - - This uses one of the many defined patterns on the options class. But, - it defaults to a no-op if there are no defined patterns. - """ - # Iterate through the available patterns. - for resource, pattern in cls.meta.patterns: - # Attempt to match the path. - match = re.match(pattern, path) - if match is not None: - # Found something. - return resource, match.groupdict(), match.string[match.end():] - - # No patterns at all; return unsuccessful. - return None if not cls.meta.patterns else False - - @classmethod - def traverse(cls, request, params=None): - """Traverses down the path and determines the accessed resource. - - This makes use of the patterns array to implement simple traversal. - This defaults to a no-op if there are no defined patterns. - """ - # Attempt to parse the path using a pattern. - result = cls.parse(request.path) - if result is None: - # No parsing was requested; no-op. - return cls, {} - - elif not result: - # Parsing failed; raise 404. - raise http.exceptions.NotFound() - - # Partition out the result. - resource, data, rest = result - - if params: - # Append params to data. - data.update(params) - - if resource is None: - # No traversal; return parameters. - return cls, data - - # Modify the path appropriately. - if data.get('path') is not None: - request.path = data.pop('path') - - elif rest is not None: - request.path = rest - - # Send us through traversal again. - result = resource.traverse(request, params=data) - return result - - @classmethod - def stream(cls, response, sequence): - """ - Helper method used in conjunction with the view handler to - stream responses to the client. - """ - # Construct the iterator and run the sequence once in order - # to capture any headers and status codes set. - iterator = iter(sequence) - data = {'chunk': next(iterator)} - response.streaming = True - - def streamer(): - # Iterate through the iterator and yield its content - while True: - if response.asynchronous: - # Yield our current chunk. - yield data['chunk'] - - else: - # Write the chunk to the response - response.send(data['chunk']) - - # Yield its body - yield response.body - - # Unset the body. - response.body = None - - try: - # Get the next chunk. - data['chunk'] = next(iterator) - - except StopIteration: - # Get out of the loop. - break - - if not response.asynchronous: - # Close the response. - response.close() - - # Return the streaming function. - return streamer() - - @utils.boundmethod - def deserialize(self, request=None, text=None, format=None): - """Deserializes the text using a determined deserializer. - - @param[in] request - The request object to pull information from; normally used to - determine the deserialization format (when `format` is - not provided). - - @param[in] text - The text to be deserialized. Can be left blank and the - request will be read. - - @param[in] format - A specific format to deserialize in; if provided, no detection is - done. If not provided, the content-type header is looked at to - determine an appropriate deserializer. - - @returns - A tuple of the deserialized data and an instance of the - deserializer used. - """ - if isinstance(self, Resource): - if not request: - # Ensure we have a response object. - request = self._request - - Deserializer = None - if format: - # An explicit format was given; do not attempt to auto-detect - # a deserializer. - Deserializer = self.meta.deserializers[format] - - if not Deserializer: - # Determine an appropriate deserializer to use by - # introspecting the request object and looking at - # the `Content-Type` header. - media_ranges = request.get('Content-Type') - if media_ranges: - # Parse the media ranges and determine the deserializer - # that is the closest match. - media_types = six.iterkeys(self._deserializer_map) - media_type = mimeparse.best_match(media_types, media_ranges) - if media_type: - format = self._deserializer_map[media_type] - Deserializer = self.meta.deserializers[format] - - else: - # Client didn't provide a content-type; we're supposed - # to auto-detect. - # TODO: Implement this. - pass - - if Deserializer: - try: - # Attempt to deserialize the data using the determined - # deserializer. - deserializer = Deserializer() - data = deserializer.deserialize(request=request, text=text) - return data, deserializer - - except ValueError: - # Failed to deserialize the data. - pass - - # Failed to determine a deserializer; or failed to deserialize. - raise http.exceptions.UnsupportedMediaType() - - @utils.boundmethod - def serialize(self, data, response=None, request=None, format=None): - """Serializes the data using a determined serializer. - - @param[in] data - The data to be serialized. - - @param[in] response - The response object to serialize the data to. - If this method is invoked as an instance method, the response - object can be omitted and it will be taken from the instance. - - @param[in] request - The request object to pull information from; normally used to - determine the serialization format (when `format` is not provided). - May be used by some serializers as well to pull additional headers. - If this method is invoked as an instance method, the request - object can be omitted and it will be taken from the instance. - - @param[in] format - A specific format to serialize in; if provided, no detection is - done. If not provided, the accept header (as well as the URL - extension) is looked at to determine an appropriate serializer. - - @returns - A tuple of the serialized text and an instance of the - serializer used. - """ - if isinstance(self, Resource): - if not request: - # Ensure we have a response object. - request = self._request - - Serializer = None - if format: - # An explicit format was given; do not attempt to auto-detect - # a serializer. - Serializer = self.meta.serializers[format] - - if not Serializer: - # Determine an appropriate serializer to use by - # introspecting the request object and looking at the `Accept` - # header. - media_ranges = (request.get('Accept') or '*/*').strip() - if not media_ranges: - # Default the media ranges to */* - media_ranges = '*/*' - - if media_ranges != '*/*': - # Parse the media ranges and determine the serializer - # that is the closest match. - media_types = six.iterkeys(self._serializer_map) - media_type = mimeparse.best_match(media_types, media_ranges) - if media_type: - format = self._serializer_map[media_type] - Serializer = self.meta.serializers[format] - - else: - # Client indicated no preference; use the default. - default = self.meta.default_serializer - Serializer = self.meta.serializers[default] - - if Serializer: - try: - # Attempt to serialize the data using the determined - # serializer. - serializer = Serializer(request, response) - return serializer.serialize(data), serializer - - except ValueError: - # Failed to serialize the data. - pass - - # Either failed to determine a serializer or failed to serialize - # the data; construct a list of available and valid encoders. - available = {} - for name in self.meta.allowed_serializers: - Serializer = self.meta.serializers[name] - instance = Serializer(request, None) - if instance.can_serialize(data): - available[name] = Serializer.media_types[0] - - # Raise a Not Acceptable exception. - raise http.exceptions.NotAcceptable(available) - - @classmethod - def _process_cross_domain_request(cls, request, response): - """Facilitate Cross-Origin Requests (CORs). - """ - - # Step 1 - # Check for Origin header. - origin = request.get('Origin') - if not origin: - return - - # Step 2 - # Check if the origin is in the list of allowed origins. - if not (origin in cls.meta.http_allowed_origins or - '*' == cls.meta.http_allowed_origins): - return - - # Step 3 - # Try to parse the Request-Method header if it exists. - method = request.get('Access-Control-Request-Method') - if method and method not in cls.meta.http_allowed_methods: - return - - # Step 4 - # Try to parse the Request-Header header if it exists. - headers = request.get('Access-Control-Request-Headers', ()) - if headers: - headers = [h.strip() for h in headers.split(',')] - - # Step 5 - # Check if the headers are allowed on this resource. - allowed_headers = [h.lower() for h in cls.meta.http_allowed_headers] - if any(h.lower() not in allowed_headers for h in headers): - return - - # Step 6 - # Always add the origin. - response['Access-Control-Allow-Origin'] = origin - - # TODO: Check if we can provide credentials. - response['Access-Control-Allow-Credentials'] = 'true' - - # Step 7 - # TODO: Optionally add Max-Age header. - - # Step 8 - # Add the allowed methods. - allowed_methods = ', '.join(cls.meta.http_allowed_methods) - response['Access-Control-Allow-Methods'] = allowed_methods - - # Step 9 - # Add any allowed headers. - allowed_headers = ', '.join(cls.meta.http_allowed_headers) - if allowed_headers: - response['Access-Control-Allow-Headers'] = allowed_headers - - # Step 10 - # Add any exposed headers. - exposed_headers = ', '.join(cls.meta.http_exposed_headers) - if exposed_headers: - response['Access-Control-Expose-Headers'] = exposed_headers - - def __init__(self, request, response): - # Store the request and response objects on self. - self.request = request - self.response = response - - def dispatch(self, request, response): - """Entry-point of the dispatch cycle for this resource. - - Performs common work such as authentication, decoding, etc. before - handing complete control of the result to a function with the - same name as the request method. - """ - # Assert authentication and attempt to get a valid user object. - self.require_authentication(request) - - # Assert accessibiltiy of the resource in question. - self.require_accessibility(request.user, request.method) - - # Facilitate CORS by applying various headers. - # This must be done on every request. - # TODO: Provide cross_domain configuration that turns this off. - self._process_cross_domain_request(request, response) - - # Route the HTTP/1.1 request to an appropriate method. - return self.route(request, response) - - def require_authentication(self, request): - """Ensure we are authenticated.""" - request.user = user = None - - if request.method == 'OPTIONS': - # Authentication should not be checked on an OPTIONS request. - return - - for auth in self.meta.authentication: - user = auth.authenticate(request) - if user is False: - # Authentication protocol failed to authenticate; - # pass the baton. - continue - - if user is None and not auth.allow_anonymous: - # Authentication protocol determined the user is - # unauthenticated. - auth.unauthenticated() - - # Authentication protocol determined the user is indeed - # authenticated (or not); Store the user for later reference. - request.user = user - return - - if not user and not auth.allow_anonymous: - # No authenticated user found and protocol doesn't allow - # anonymous users. - auth.unauthenticated() - - def require_accessibility(self, user, method): - """Ensure we are allowed to access this resource.""" - if method == 'OPTIONS': - # Authorization should not be checked on an OPTIONS request. - return - - authz = self.meta.authorization - if not authz.is_accessible(user, method, self): - # User is not authorized; raise an appropriate message. - authz.unaccessible() - - def require_http_allowed_method(cls, request): - """Ensure that we're allowed to use this HTTP method.""" - allowed = cls.meta.http_allowed_methods - if request.method not in allowed: - # The specified method is not allowed for the resource - # identified by the request URI. - # RFC 2616 § 10.4.6 — 405 Method Not Allowed - raise http.exceptions.MethodNotAllowed(allowed) - - def route(self, request, response): - """Processes every request. - - Directs control flow to the appropriate HTTP/1.1 method. - """ - # Ensure that we're allowed to use this HTTP method. - self.require_http_allowed_method(request) - - # Retrieve the function corresponding to this HTTP method. - function = getattr(self, request.method.lower(), None) - if function is None: - # Server is not capable of supporting it. - raise http.exceptions.NotImplemented() - - # Delegate to the determined function to process the request. - return function(request, response) - - def options(self, request, response): - """Process an `OPTIONS` request. - - Used to initiate a cross-origin request. All handling specific to - CORS requests is done on every request however this method also - returns a list of available methods. - """ - # Gather a list available HTTP/1.1 methods for this URI. - response['Allowed'] = ', '.join(self.meta.http_allowed_methods) - - # All CORS handling is done for every HTTP/1.1 method. - # No more handling is neccesary; set the response to 200 and return. - response.status = http.client.OK diff --git a/armet/resources/resource/meta.py b/armet/resources/resource/meta.py deleted file mode 100644 index 71514ab..0000000 --- a/armet/resources/resource/meta.py +++ /dev/null @@ -1,192 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import armet -from collections import OrderedDict -from armet import utils -from . import options - - -#! Map of connector class objects in their connector modules. -CONNECTORS = OrderedDict([ - ('http', { - 'resource': ('{}.resources', 'Resource', 'resource.base',), - 'options': ('{}.resources', 'ResourceOptions',) - }), - ('model', { - 'resource': ('{}.resources', 'ModelResource', 'model.base',), - 'options': ('{}.resources', 'ModelResourceOptions',) - }) -]) - - -#! Set of connector classes. -connector_set = set() - - -def apply_connectors(meta, name, bases, metadata, base_meta): - - # Apply the declared connectors. What this entails is inserting the - # connector base classes at the correct location in the base class list. - - # The correct location would be directly before one of the base resource - # classes. - - # What is not implemented (and will not be) is replacing existing - # connectors. You may have an inheritance chain of as many "abstract" - # resources as warranted but as soon as a connector is supplied on a - # non-abstract resource then that connector is "locked-in" so-to-speak. - - # Order the connectors found in meta by the order declared above. - connectors = OrderedDict() - for key in reversed(CONNECTORS): - if key in meta.connectors: - connectors[key] = meta.connectors[key] - - meta.connectors = connectors - - # Iterate through the declared connectors. - cmap = CONNECTORS - new_bases = list(bases) - offset = 0 - for key, ref in six.iteritems(meta.connectors): - - try: - # Resolve an optional options class that may be provided by - # the connector. - options = utils.import_module(cmap[key]['options'][0].format(ref)) - options = getattr(options, cmap[key]['options'][1]) - options_instance = options(metadata, name, base_meta) - meta.__dict__.update(**options_instance.__dict__) - - except (AttributeError, KeyError): - pass - - try: - # Resolve the connector itself. - module = utils.import_module(cmap[key]['resource'][0].format(ref)) - class_ = getattr(module, cmap[key]['resource'][1], None) - - # Ensure the connector has not already been applied. - applied = False - for base in bases: - if issubclass(base, class_): - applied = True - - if applied: - continue - - # Find where to insert this connector. - module_key = 'armet.resources.%s' % cmap[key]['resource'][2] - module = utils.import_module(module_key) - armet_resource = getattr(module, cmap[key]['resource'][1]) - for index, base in enumerate(bases): - if issubclass(base, armet_resource): - new_bases.insert(index + offset, class_) - offset += 1 - break - - except (AttributeError, KeyError): - pass - - return tuple(new_bases) - - -class ResourceBase(type): - - #! Options class to use to expand options. - options = options.ResourceOptions - - #! Connectors to instantiate and mixin to the inheritance. - connectors = ['http'] - - @classmethod - def _is_resource(cls, name, bases): - if name == 'NewBase': - # This is a six contrivance; not a real class. - return False - - if name.startswith('armet.connector:'): - # This is special mixed connector class; not something - # to run the metaclass over. - return False - - for base in bases: - if base.__name__ == 'NewBase': - # This is a six contrivance; move along. - continue - - if isinstance(base, cls): - # This is some sort of derived resource; good. - return True - - # This is not derived at all from Resource (eg. is base). - return False - - @classmethod - def _gather_metadata(cls, metadata, bases): - for base in bases: - if isinstance(base, cls) and hasattr(base, 'Meta'): - # Append metadata. - metadata.append(getattr(base, 'Meta')) - - # Recurse. - cls._gather_metadata(metadata, base.__bases__) - - def __new__(cls, name, bases, attrs): - if not cls._is_resource(name, bases): - # This is not an actual resource. - return super(ResourceBase, cls).__new__(cls, name, bases, attrs) - - # Gather the attributes of all options classes. - # Start with the base configuration. - metadata = armet.use().copy() - values = lambda x: {n: getattr(x, n) for n in dir(x)} - - # Expand the options class with the gathered metadata. - base_meta = [] - cls._gather_metadata(base_meta, bases) - - # Apply the configuration from each class in the chain. - for meta in base_meta: - metadata.update(**values(meta)) - - cur_meta = {} - if attrs.get('Meta'): - # Apply the configuration from the current class. - cur_meta = values(attrs['Meta']) - metadata.update(**cur_meta) - - # Gather and construct the options object. - meta = attrs['meta'] = cls.options(metadata, name, cur_meta, base_meta) - - # Apply the found connectors. - bases = apply_connectors(meta, name, bases, metadata, base_meta) - - # Construct the class object. - self = super(ResourceBase, cls).__new__(cls, name, bases, attrs) - - # Generate a serializer map that maps media ranges to serializer - # names. - self._serializer_map = smap = {} - for key in self.meta.allowed_serializers: - serializer = self.meta.serializers[key] - for media_type in serializer.media_types: - smap[media_type] = key - - # Generate a deserializer map that maps media ranges to deserializer - # names. - self._deserializer_map = dmap = {} - for key in self.meta.allowed_deserializers: - deserializer = self.meta.deserializers[key] - for media_type in deserializer.media_types: - dmap[media_type] = key - - # Filter the available connectors according to the - # metaclass restriction set. - for key in list(meta.connectors.keys()): - if key not in cls.connectors: - del meta.connectors[key] - - # Return the constructed instance. - return self diff --git a/armet/resources/resource/options.py b/armet/resources/resource/options.py deleted file mode 100644 index 528e662..0000000 --- a/armet/resources/resource/options.py +++ /dev/null @@ -1,356 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import re -import six -from importlib import import_module -from armet import utils, authentication, authorization -from armet.exceptions import ImproperlyConfigured -from armet import connectors as included_connectors - - -def _merge(options, name, bases, default=None): - """Merges a named option collection.""" - result = None - for base in bases: - if base is None: - continue - - value = getattr(base, name, None) - if value is None: - continue - - result = utils.cons(result, value) - - value = options.get(name) - if value is not None: - result = utils.cons(result, value) - - return result or default - - -class ResourceOptions(object): - - def __init__(self, meta, name, data, bases): - """ - Initializes the options object and defaults configuration not - specified. - - @param[in] meta - Dictionary of the merged meta attributes. - - @param[in] name - Name of the resource class this is being instantiataed for. - """ - #! Whether to allow display of debugging information - #! to the client. - self.debug = meta.get('debug') - if self.debug is None: - self.debug = False - - #! Whether to not actualize a resource from the described class. - #! Abstract resources are meant as generic base classes. - #! - #! @note - #! Abstract only counts if it is directly set on the resource. - self.abstract = data.get('abstract') - - #! Name of the resource to use in URIs; defaults to the dasherized - #! version of the camel cased class name (eg. SomethingHere becomes - #! something-here). The defaulted version also strips a trailing - #! Resource from the name (eg. SomethingHereResource still becomes - #! something-here). - self.name = meta.get('name') - if self.name is None: - # Generate a name for the resource if one is not provided. - # PascalCaseThis => pascal-case-this - dashed = utils.dasherize(name).strip() - if dashed: - # Strip off a trailing Resource as well. - self.name = re.sub(r'-resource$', '', dashed) - - else: - # Something went wrong; just use the class name - self.name = name - - elif callable(self.name): - # If name is callable; pass in the class name and use what - # we got back. - self.name = self.name(name) - - #! True if the resource is expected to operate asynchronously. - #! - #! The side-effect of setting this to True is that returning from - #! `dispatch` (and by extension `get`, `post`, etc.) does not - #! terminate the connection. You must invoke `response.close()` to - #! terminate the connection. - self.asynchronous = meta.get('asynchronous', False) - - #! Connectors to use to connect to the environment. - #! - #! This is a dictionary that maps hooks (keys) to the connector to use - #! for the hook. - #! - #! There is only 1 hook available currently, 'http', and it is - #! required. - #! - #! The available connectors are as follows: - #! - http: - #! > django - #! > flask - #! - #! They may be used as follows: - #! - #! @code - #! from armet import resources - #! class Resource(resources.Resource): - #! class Meta: - #! connectors = {'http': 'django'} - #! @endcode - #! - #! Connectors may also be specified as full paths to the connector - #! module (if a connector is located somewhere other than - #! armet.connectors) as follows: - #! - #! @code - #! from armet import resources - #! class Resource(resources.Resource): - #! class Meta: - #! connectors = {'http': 'some.other.place'} - #! @endcode - self.connectors = connectors = _merge(meta, 'connectors', bases, {}) - - if not connectors.get('http') and not self.abstract: - raise ImproperlyConfigured('No valid HTTP connector was detected.') - - # Pull out the connectors and convert them into module references. - for key in connectors: - connector = connectors[key] - if isinstance(connector, six.string_types): - if connector in getattr(included_connectors, key): - # Included shortname; prepend base. - connectors[key] = 'armet.connectors.{}'.format(connector) - - #! Additional options to handle and merge into the meta object - #! at the class object level. - #! - #! This should be a simple set/list/tuple of options. - #! - #! @code - #! from armet import resources - #! class Resource(resources.Resource): - #! class Meta: - #! options = {'color', 'plant'} - #! - #! @endcode - self.options = options = _merge(meta, 'options', bases, {}) - for name in options: - # Pull out each option and stick it on the meta. - setattr(self, name, meta.get(name)) - - #! Regular-expression patterns to apply to the request path - #! and pull arguments and traversal out of it. - #! - #! @code - #! from armet import resources - #! class Resource(resources.Resource): - #! class Meta: - #! patterns = [ - #! # Match nothing after the resource name. - #! r'^$', - #! - #! # Match a slug after the resource name. - #! r'^/(?P[^/]+)/?$', - #! ] - #! - #! @endcode - #! - #! Named parameters get auto-attached to `self` on the resource - #! instance. - #! - #! This may be a list of lists/tuples as well to indicate simple - #! traversal. - #! - #! @code - #! from armet import resources - #! class Resource(resources.Resource): - #! class Meta: - #! patterns = [ - #! # Match nothing and don't traverse. - #! (None, r'^$'), - #! - #! # Match the word 'user' and traverse. - #! # The remainder of the path is taken or - #! # the named gruop, "path" (if the whole path was - #! # matched) or the last-matched group (if none are - #! # named "path") - #! (UserResource, r'^/user'), - #! ] - #! - #! @endcode - self.patterns = meta.get('patterns', []) - for index, pattern in enumerate(self.patterns): - # Coerce simple form. - if isinstance(pattern, six.string_types): - pattern = (None, pattern) - - # Compile the expression. - self.patterns[index] = (pattern[0], re.compile(pattern[1])) - - #! Trailing slash handling. - #! The value indicates which URI is the canonical URI and the - #! alternative URI is then made to redirect (with a 301) to the - #! canonical URI. - self.trailing_slash = meta.get('trailing_slash', True) - - #! List of allowed HTTP methods. - #! If not provided and allowed_operations was provided instead - #! the operations are appropriately mapped; else, the default - #! configuration is provided. - self.http_allowed_methods = meta.get('http_allowed_methods') - if self.http_allowed_methods is None: - self.http_allowed_methods = ( - 'HEAD', - 'OPTIONS', - 'GET', - 'POST', - 'PUT', - 'PATCH', - 'DELETE', - 'LINK', - 'UNLINK' - ) - - #! List of allowed HTTP headers. - #! This is used only to request or prevent CORS requests. - self.http_allowed_headers = meta.get('http_allowed_headers') - if self.http_allowed_headers is None: - self.http_allowed_headers = ( - 'Content-Type', - 'Authorization', - 'Accept', - 'Origin' - ) - - #! List of exposed HTTP headers. - #! This is used only to show headers to the client. - self.http_exposed_headers = meta.get('http_exposed_headers') - if self.http_exposed_headers is None: - self.http_exposed_headers = ( - 'Content-Type', - 'Authorization', - 'Accept', - 'Origin' - ) - - #! List of allowed HTTP origins. - #! This is used to request or prevent CORS requests. - #! No CORS requests will be allowed, at-all, unless this - #! property is set. - #! NOTE: This can be set to '*' to indicate any origin. - self.http_allowed_origins = meta.get('http_allowed_origins') - if self.http_allowed_origins is None: - self.http_allowed_origins = () - - #! Whether to use legacy redirects or not to inform the - #! client the resource is available elsewhere. Legacy redirects - #! require a combination of 301 and 307 in which 307 is not cacheable. - #! Modern redirecting uses 308 and is in effect 307 with cacheing. - #! Unfortunately unknown 3xx codes are treated as - #! a 300 (Multiple choices) in which the user is supposed to chose - #! the alternate link so the client is not supposed to auto follow - #! redirects. Ensure all supported clients understand 308 before - #! turning off legacy redirecting. - #! As of 19 March 2013 only Firefox supports it since a year ago. - self.legacy_redirect = meta.get('legacy_redirect', True) - - #! Mapping of serializers known by this resource. - #! Values may either be a string reference to the serializer type - #! or an serializer class object. - self.serializers = serializers = meta.get('serializers') - if not serializers: - self.serializers = { - 'json': 'armet.serializers.JSONSerializer', - 'url': 'armet.serializers.URLSerializer' - } - - # Expand the serializer name references. - for name, serializer in six.iteritems(self.serializers): - if isinstance(serializer, six.string_types): - segments = serializer.split('.') - module = '.'.join(segments[:-1]) - module = import_module(module) - self.serializers[name] = getattr(module, segments[-1]) - - #! List of allowed serializers of the understood serializers. - self.allowed_serializers = meta.get('allowed_serializers') - if not self.allowed_serializers: - self.allowed_serializers = tuple(self.serializers.keys()) - - # Check to ensure that all allowed serializers are - # understood serializers. - for name in self.allowed_serializers: - if name not in self.serializers: - raise ImproperlyConfigured( - 'The allowed serializer, {}, is not one of the ' - 'understood serializers'.format(name)) - - #! Name of the default serializer of the list of - #! understood serializers. - self.default_serializer = meta.get('default_serializer') - if not self.default_serializer: - if 'json' in self.allowed_serializers: - self.default_serializer = 'json' - - else: - self.default_serializer = self.allowed_serializers[0] - - if self.default_serializer not in self.allowed_serializers: - raise ImproperlyConfigured( - 'The chosen default serializer, {}, is not one of the ' - 'allowed serializers'.format(self.default_serializer)) - - #! Mapping of deserializers known by this resource. - #! Values may either be a string reference to the deserializer type - #! or an deserializer class object. - self.deserializers = deserializers = meta.get('deserializers') - if not deserializers: - self.deserializers = { - 'json': 'armet.deserializers.JSONDeserializer', - 'url': 'armet.deserializers.URLDeserializer' - } - - # Expand the deserializer name references. - for name, deserializer in six.iteritems(self.deserializers): - if isinstance(deserializer, six.string_types): - segments = deserializer.split('.') - module = '.'.join(segments[:-1]) - module = import_module(module) - self.deserializers[name] = getattr(module, segments[-1]) - - #! List of allowed deserializers of the understood deserializers. - self.allowed_deserializers = meta.get('allowed_deserializers') - if not self.allowed_deserializers: - self.allowed_deserializers = tuple(self.deserializers.keys()) - - # Check to ensure that all allowed deserializers are - # understood deserializers. - for name in self.allowed_deserializers: - if name not in self.deserializers: - raise ImproperlyConfigured( - 'The allowed deserializer, {}, is not one of the ' - 'understood deserializers'.format(name)) - - #! List of authentication protocols to attempt in sequence - #! to determine the authenticated user. - self.authentication = meta.get('authentication') - if self.authentication is None: - # Default to a single instance of pass-through authentication. - self.authentication = (authentication.Authentication(),) - - #! The authorization protocol to attempt to use - #! to determine the if the user can access or is otherwise - #! authorized. - self.authorization = meta.get('authorization') - if self.authorization is None: - # Default is the pass-through authorization. - self.authorization = authorization.Authorization() diff --git a/armet/serializers/__init__.py b/armet/serializers/__init__.py deleted file mode 100644 index c290dc6..0000000 --- a/armet/serializers/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -from .base import Serializer -from .json import JSONSerializer -from .url import URLSerializer - -__all__ = [ - 'Serializer', - 'JSONSerializer', - 'URLSerializer' -] diff --git a/armet/serializers/base.py b/armet/serializers/base.py deleted file mode 100644 index 1c0a68f..0000000 --- a/armet/serializers/base.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - - -class Serializer(object): - - #! Applicable media types for this serializer. - media_types = () - - def __init__(self, request=None, response=None): - #! The request and response objects to use. - self.request = request - self.response = response - - def can_serialize(self, data=None): - """Tests this serializer to see if it can serialize.""" - try: - # Attet to serialize the object. - self.serialize(data) - - # The serialization process is assumed to have succeed. - return True - - except ValueError: - # The object was of an unsupported type. - return False - - def serialize(self, data=None): - """ - Transforms the object into an acceptable format for transmission. - - @throws ValueError - To indicate this serializer does not support the encoding of the - specified object. - """ - if data is not None and self.response is not None: - # Set the content type. - self.response['Content-Type'] = self.media_types[0] - - # Write the encoded and prepared data to the response. - self.response.write(data) - - # Return the serialized data. - # This has normally been transformed by a base class. - return data diff --git a/armet/serializers/json.py b/armet/serializers/json.py deleted file mode 100644 index ba327f7..0000000 --- a/armet/serializers/json.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import ujson as json -from collections import Iterable, Sequence, Mapping -from .base import Serializer -from armet import media_types - - -class JSONSerializer(Serializer): - - media_types = media_types.JSON - - def serialize(self, obj=None): - # If we have nothing; serialize as an empty object. - if obj is None: - return '{}' - - # Ensure generators are evaluated. - if (isinstance(obj, Iterable) - and not isinstance(obj, Sequence) - and not isinstance(obj, Mapping)): - obj = list(obj) - - # Ensure it is atleast wrapped in an array. - if isinstance(obj, six.string_types) or not isinstance(obj, Iterable): - obj = [obj] - - # Serialize the resultant text. - text = json.dumps(obj, ensure_ascii=False) - - # Return us to the base to enclose it inside of a response object. - return super(JSONSerializer, self).serialize(text) diff --git a/armet/serializers/url.py b/armet/serializers/url.py deleted file mode 100644 index 47a7e77..0000000 --- a/armet/serializers/url.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -from .base import Serializer -from armet import media_types - -# Import urlencode -if six.PY3: - from urllib.parse import urlencode - -else: - from urllib import urlencode - - -class URLSerializer(Serializer): - - media_types = media_types.URL - - def serialize(self, obj=None): - # If we have nothing; serialize as an empty object. - if obj is None: - return '' - - try: - # Attempt to serialize the incoming object using the URL encoder. - return super(URLSerializer, self).serialize(urlencode(obj, True)) - - except TypeError: - # Raise up our hands; we cannot serialize this. - raise ValueError diff --git a/armet/test.py b/armet/test.py deleted file mode 100644 index 71a04b3..0000000 --- a/armet/test.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import httplib2 -import unittest -import socket -import errno -import json -import base64 -import six -from . import http - -__all__ = [ - 'http', - 'is_available', - 'skipUnlessAvailable', - 'Client', - 'ResourceTestCase' -] - - -def is_available(host='localhost', port=5000): - try: - httplib2.Http().request('http://{}:{}/'.format(host, port)) - return True - - except socket.error as ex: - return ex.errno != errno.ECONNREFUSED - - -def skipUnlessAvailable(host='localhost', port=5000): - if not is_available(host, port): - raise unittest.SkipTest( - 'server is not available at {}:{}'.format(host, port)) - - -class Client: - - def __init__(self, host='localhost', port=5000): - # Set the default host and port. - self.host = host - self.port = port - - # Setup the HTTP/1.1 connection. - self.connection = httplib2.Http() - self.connection.follow_redirects = False - - def request(self, path='/', method='GET', body='', headers=None, - url=None, username=None, password=None): - if url is None: - # Construct the URI. - url = 'http://{}:{}{}'.format(self.host, self.port, path) - - # Default headers. - if headers is None: - headers = {} - - # Construct authorization if needed. - if username or password: - creds = '{}:{}'.format(username or '', password or '') - creds = base64.b64encode(creds.encode('utf8')) - headers['authorization'] = 'basic ' + creds.decode('utf8') - - # Serialize the body if neccessary. - # TODO: Support more than JSON. - if not isinstance(body, six.string_types): - body = json.dumps(body) - headers['content-type'] = 'application/json' - - # Perform the response. Remember this does not go anywhere; it is - # caught by wsgi-intercept and mocked into Armet. - return self.connection.request( - url, method, - body=body, - headers=headers) - - def options(self, *args, **kwargs): - kwargs.setdefault('method', 'OPTIONS') - return self.request(*args, **kwargs) - - def head(self, *args, **kwargs): - kwargs.setdefault('method', 'HEAD') - return self.request(*args, **kwargs) - - def get(self, *args, **kwargs): - return self.request(*args, **kwargs) - - def post(self, *args, **kwargs): - kwargs.setdefault('method', 'POST') - return self.request(*args, **kwargs) - - def put(self, *args, **kwargs): - kwargs.setdefault('method', 'PUT') - return self.request(*args, **kwargs) - - def delete(self, *args, **kwargs): - kwargs.setdefault('method', 'DELETE') - return self.request(*args, **kwargs) - - -class ResourceTestCase(unittest.TestCase): - - host = 'localhost' - - port = 5000 - - def setUp(self): - # Skip this test module unless the server is available. - skipUnlessAvailable(self.host, self.port) - - # Initialize the test client. - self.client = Client(self.host, self.port) diff --git a/armet/utils.py b/armet/utils.py new file mode 100644 index 0000000..6da104a --- /dev/null +++ b/armet/utils.py @@ -0,0 +1,72 @@ +import functools +import itertools +import warnings + +def dasherize(text): + result = '' + for item in text: + if item.isupper(): + result += "-" + item.lower() + elif item == "_": + result += "-" + else: + result += item + + if result[0] == "-": + result = result[1:] + + return result + + +def chunk(data, chunk_size=16*1024): + """Simple chunking function to easily make encoders into generators. + + Invocations of this should be replaced when more streaming-friendly + encoders are implemented. + """ + while True: + buf = data[:chunk_size] + data = data[chunk_size:] + if not buf: + break + yield buf + + +def merge_headers(*headers): + """Merge lists of headers into a new list of headers. + + Headers are merged prioritizing attributes from the leftmost list of + headers. That is to say that the first list keeps all of its headers. + """ + + exist = set() + new = [] + for entry in itertools.chain.from_iterable(headers): + if entry[0] not in exist: + exist.add(entry[0]) + new.append(entry) + return new + + +def memoize(obj): + cache = obj.cache = {} + + @functools.wraps(obj) + def memoizer(*args, **kwargs): + key = str(args) + str(kwargs) + if key not in cache: + cache[key] = obj(*args, **kwargs) + return cache[key] + + return memoizer + + +class classproperty(object): + """Declares a read-only `property` that acts on the class object. + """ + + def __init__(self, getter): + self.getter = getter + + def __get__(self, obj, cls): + return self.getter(cls) diff --git a/armet/utils/__init__.py b/armet/utils/__init__.py deleted file mode 100644 index d4b0dea..0000000 --- a/armet/utils/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from .decorators import classproperty, boundmethod -from .functional import cons, compose -from .string import dasherize -from .package import import_module - -__all__ = [ - 'classproperty', - 'boundmethod', - 'cons', - 'compose', - 'import_module', - 'dasherize', -] diff --git a/armet/utils/decorators.py b/armet/utils/decorators.py deleted file mode 100644 index 6b49441..0000000 --- a/armet/utils/decorators.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division - - -class classproperty(object): - """Declares a read-only `property` that acts on the class object. - """ - - def __init__(self, getter): - self.getter = getter - - def __get__(self, obj, cls): - return self.getter(cls) - - -class boundmethod(object): - """ - Declares a method that can be invoked as both an instance method - and a class method. - """ - - def __init__(self, method): - self.method = method - - def __get__(self, obj, cls): - self.obj = obj if obj else cls - return self - - def __call__(self, *args, **kwargs): - return self.method(self.obj, *args, **kwargs) diff --git a/armet/utils/functional.py b/armet/utils/functional.py deleted file mode 100644 index 18a11a3..0000000 --- a/armet/utils/functional.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import collections - - -def cons(collection, value): - """Extends a collection with a value.""" - if isinstance(value, collections.Mapping): - if collection is None: - collection = {} - collection.update(**value) - - elif isinstance(value, six.string_types): - if collection is None: - collection = [] - collection.append(value) - - elif isinstance(value, collections.Iterable): - if collection is None: - collection = [] - collection.extend(value) - - else: - if collection is None: - collection = [] - collection.append(value) - - return collection - - -def compose(*functions): - - def composed(x): - for func in functions: - x = func(x) - - return x - - return composed diff --git a/armet/utils/package.py b/armet/utils/package.py deleted file mode 100644 index d077aee..0000000 --- a/armet/utils/package.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import print_function, unicode_literals, division -import importlib - - -def import_module(name): - """Attempt to import a module; returns None if unsuccessful.""" - try: - return importlib.import_module(name) - - except ImportError: - return None diff --git a/armet/utils/string.py b/armet/utils/string.py deleted file mode 100644 index a46a9d0..0000000 --- a/armet/utils/string.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import re - - -def dasherize(value): - """Dasherizes the passed value.""" - value = value.strip() - value = re.sub(r'([A-Z])', r'-\1', value) - value = re.sub(r'[-_\s]+', r'-', value) - value = re.sub(r'^-', r'', value) - value = value.lower() - return value diff --git a/demo.md b/demo.md new file mode 100644 index 0000000..a557e29 --- /dev/null +++ b/demo.md @@ -0,0 +1,93 @@ +# Models + + - Poll + - question + - publish_date + - close_date + - choice(s) [relationship] + + - Choice + - poll [relationship] + - text + + - Vote + - choice [relationship] + - date + +# Stories +> Using "user" in the general sense (no auth yet) + +### User creates a poll + +``` +POST /poll + +{ + "question": "...." +} +``` + +### User creates a poll with choices + +``` +POST /poll + +{ + "question": "...", + "choice": [ + { + "text": "...." + }, + { + "text": "...." + }, + { + "text": "...." + } + ] +} +``` + +### User adds a choice to a poll + +``` +POST /poll/{id}/choice + +{ + "text": "...." +} +``` + +### User removes a choice from a poll + +``` +DELETE /poll/{id}/choice/{id} +``` + +### User publishes a poll (?) + +``` +POST /poll/{id}/publish + +{ + // No data needed +} +``` + +``` +PATCH /poll/{id} + +{ + "published": true +} +``` + +### User votes for a choice on a poll + +``` +POST /poll/{id}/choice/{id}/vote + +{ + // No data needed +} +``` diff --git a/examples/flask/requirements.txt b/examples/flask/requirements.txt new file mode 100644 index 0000000..e3e9a71 --- /dev/null +++ b/examples/flask/requirements.txt @@ -0,0 +1 @@ +Flask diff --git a/examples/flask/server.py b/examples/flask/server.py new file mode 100644 index 0000000..525ef22 --- /dev/null +++ b/examples/flask/server.py @@ -0,0 +1,97 @@ +from flask import Flask +from armet.api import Api +from armet.resources import Resource, prepares +from armet.http.exceptions import NotFound +from werkzeug.wsgi import DispatcherMiddleware +from datetime import datetime, timedelta + +app = Flask(__name__) + + +@app.route('/hello') +def flask_route(): + # Flask routes still work. + return "Hello World!" + + +class UserResource(Resource): + + # Data that this resource returns. + users = [ + { + 'id': 1, + 'username': 'bob', + 'email': 'bob@example.com', + 'password': 'secret', + 'created_date': datetime.now() - timedelta(days=2), + }, + { + 'id': 2, + 'username': 'joe', + 'email': 'joe@example.com', + 'password': 'hunter2', + 'created_date': datetime.now(), + } + ] + + # All the attributes that will be returned when being read. + attributes = {'id', 'username', 'email', 'created_date'} + + def read(self): + # Implement slug based routing (/users/1 routes to a single one) + if self.slug is None: + # TODO: This is stupid. + data = [type('StupidObject', (), x) for x in self.users] + else: + try: + # Shoddy way of mapping ids to slugs (via array index.) + slug = int(self.slug) - 1 + except ValueError as ex: + raise NotFound() from ex + + if slug >= len(self.users) or slug < 0: + raise NotFound() + + # TODO: This is stupid. + data = type('StupidObject', (), self.users[slug]) + + return data + + +@prepares(datetime) +def prepare_datetime(item, key, value): + # Datetime objects are not inherently understood by many encoders + # (xml being an exception) + # so this function is in charge of normalizing the data into something + # that can be understood by most encdoders. Encoders will only + # use this preparation function when they cannot inherantly encode + # this format. + return value.isoformat() + + +if __name__ == '__main__': + # Create an api that can contain this resource. + # Note that apis are valid wsgi applications, so we can mount/remount them + # as we would any other wsgi application. + api = Api(debug=True) + + # Registering the resource within the application implicitly defines + # a route to the resource. The default route name is determined from the + # class name (cls.__name__). Take note of how capitalization maps: + # * UserResource maps to '/user' + # * MultiWordResource maps to '/user/multi-word' + api.register(UserResource) + + # Create a new wsgi application based on the flask routes and mount our + # armet api on /api + application = DispatcherMiddleware(app, { + '/api': api, + }) + + app.debug = True + + from werkzeug.serving import run_simple + run_simple( + 'localhost', 5000, application, + use_debugger=False, + use_reloader=True) diff --git a/play b/play new file mode 100644 index 0000000..19b1fde --- /dev/null +++ b/play @@ -0,0 +1,132 @@ + +1. "client" sends "request" to "server" + +GET /poll +Accept: application/json + + +POST /poll +Accept: application/json +Content-Type: application/json +{ + "question": "wat" +} + +------------------------------------------------------------------------------- + +[x] 2. determine the "resource" to use + +/poll/12/choice + +"poll" (slug=12) +"choice" + + +------------------------------------------------------------------------------- + +[ ] 3. require "authentication" + +The idea is that an "API" may require authentication as a whole or +a "resource" may require a specific "kind" of authentication or allow +an additional kind of authentication that would only work for that resource + + - basic + - digest + - session + - bearer "token" + +------------------------------------------------------------------------------- + +[ ] 4. require "accessibilty" + +Is this "bearer" allowed to know this "resource" exists + +------------------------------------------------------------------------------- + +[ ] 5. require "method" + +Is this "bearer" (or in general is anyone) allowed to use this +specific "method" (GET, POST, PUT, ..) on this resource. + +> raise 405 + +------------------------------------------------------------------------------- + +[x] 6. "decoder" takes the textual data from the request body and converts it + into a usable python dict or list + +> raise 415 + +------------------------------------------------------------------------------- + +[ ] 7. Create and run the resource "operation" that corresponds to the method + for only one resource + +"GET" + +/poll/5/choice + +>>> poll = resource_map["poll"](slug="5").read() +>>> choice = resource_map["choice"](context={"poll": poll}).read() + +------------------------------------------------------------------------------- + +[ ] 8. require (or filter by) "authorization" + +------------------------------------------------------------------------------- + +[ ] 9. traverse and continue the chain (go back to 7 unless we're at the + final resource) + +------------------------------------------------------------------------------- + +[x] 10. take the response data and "encode" it using an encoder that the user + asked for (or the default encoder "json") + +=============================================================================== + +[ ] Resource base class + + + + +POST /poll/13/choice/5/vote + +>>> poll_res = PollResource(slug=13) +>>> poll = poll_res.read() +>>> choice_res = ChoiceResource(slug=5, context={"poll": poll}) +>>> choice = choice_res.read() +>>> vote_res = VoteResource(context={"poll": poll, "choice": choice}) +>>> vote_res.create(data) + +POST /poll/choice/5/vote + +>>> poll_res = PollResource(slug="choice") +>>> poll = poll_res.read() +# raise or return None +>>> poll_res = PollResource() +>>> poll = poll_res.read() +>>> choice_res = ChoiceResource(slug=5, context={"poll": poll}) +>>> choice = choice_res.read() +>>> vote_res = VoteResource(context={"poll": poll, "choice": choice}) +>>> vote_res.create(data) + +POST /user/1/poll/choice/5/vote + +>>> user_res = UserResource(slug=1) +>>> user = user_res.read() +>>> poll_res = PollResource(slug="choice", context={"user": user}) +>>> poll = poll_res.read() +# raise or return None +>>> poll_res = PollResource(context={"user": user}) +>>> poll = poll_res.read() +>>> choice_res = ChoiceResource(slug=5, context={"poll": poll, "user": user}) +>>> choice = choice_res.read() +>>> vote_res = VoteResource(context={"poll": poll, "choice": choice, "user": user}) +>>> vote_res.create(data) + + +GET /poll/1/choice/2/vote +GET /choice/2/poll/vote + +/user/1/poll/5/user diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index eb9b2be..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -addopts = -s -rx diff --git a/setup.py b/setup.py index 6b6f48c..cbab837 100644 --- a/setup.py +++ b/setup.py @@ -1,58 +1,43 @@ #! /usr/bin/env python -# -*- coding: utf-8 -*- -from setuptools import setup, find_packages -import sys -import platform +from setuptools import setup, find_packages, Command from imp import load_source +import subprocess -# Required test dependencies. -test_dependencies = [ - # Test runner. - 'pytest', - - # Ensure PEP8 conformance. - 'pytest-pep8', - - # Ensure test coverage. - 'pytest-cov', +class PyTest(Command): + # user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] + user_options = [] - # Benchmarking. - 'pytest-bench', - - # Installs a WSGI application that intercepts requests made to a hostname - # and port combination for testing. - 'wsgi_intercept >= 0.6.0', + def initialize_options(self): + # super().initialize_options() + self.pytest_args = [ + '--verbose', + '--pep8', + '--cov', 'armet' + ] - # HTTP request abstraction layer over httplib. - 'httplib2', + def finalize_options(self): + pass - # The Web framework for perfectionists with deadlines. - 'django', + def run(self): + errno = subprocess.call(['py.test'] + self.pytest_args) + raise SystemExit(errno) - # A microframework based on Werkzeug, Jinja2 and good intentions. - 'flask', - # Bottle is a fast and simple micro-framework for small web applications. - 'bottle', +extras_require = { + 'test': [ + 'SQLAlchemy' + ] +} - # SQLAlchemy is the Python SQL toolkit and Object Relational Mapper - # that gives application developers the full power and flexibility of SQL. - 'sqlalchemy', +# Note: Should be ordered in reverse dependency order (why?) +tests_require = [ + 'pytest-pep8', + 'pytest-cov', + 'pytest-bench', + 'pytest', ] - -if sys.version_info[0] == 2: - if platform.python_implementation() != 'PyPy': - # Test dependencies that don't work in PyPy. - test_dependencies += [ - # gevent is a coroutine-based Python networking library that uses - # greenlet to provide a high-level synchronous API on top of the - # libevent event loop. - 'gevent', - ] - - setup( name='armet', version=load_source('', 'armet/_version.py').__version__, @@ -62,9 +47,9 @@ classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Web Environment', - 'Framework :: Bottle', - 'Framework :: Flask', - 'Framework :: Django', + # 'Framework :: Bottle', + # 'Framework :: Flask', + # 'Framework :: Django', # 'Framework :: CherryPy', # 'Framework :: Twisted', # 'Framework :: Pylons', @@ -74,11 +59,7 @@ 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Topic :: Database', - 'Topic :: Internet', 'Topic :: Software Development', 'Topic :: Software Development :: Libraries', 'Topic :: Software Development :: Libraries :: Python Modules', @@ -86,16 +67,11 @@ url='http://github.com/armet/python-armet', packages=find_packages('.'), install_requires=[ - # Python 2 and 3 normalization layer - 'six', - - # For parsing accept and content-type headers - 'python-mimeparse', - - # Ultra fast JSON encoder and decoder for Python. - 'ujson' + 'ujson', + 'werkzeug', + 'python-mimeparse' ], - extras_require={ - 'test': test_dependencies - } + cmdclass={'test': PyTest}, + extras_require=extras_require, + tests_require=tests_require, ) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..027c9cc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,3 @@ + +# # Register fixtures. +# from .fixtures import * # flake8: noqa diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..aac9073 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,80 @@ +import pytest +import sqlalchemy as sa +from armet import api +import werkzeug.test +import json + + +class DB: + + def __init__(self): + from sqlalchemy.ext.declarative import declarative_base + + self.engine = sa.create_engine('sqlite:///:memory:') + self.Base = declarative_base() + self.Session = sa.orm.sessionmaker(bind=self.engine) + + def create_all(self): + self.Base.metadata.create_all(self.engine) + + +@pytest.fixture(scope="function") +def db(): + return DB() + + +@pytest.yield_fixture(scope='function') +def session(request, db): + """Adds a sqlalchemy session object to the test.""" + request.instance.session = session = db.Session() + + yield request.instance.session + + session.rollback() + session.close() + + +class MyResponse(werkzeug.Response): + + @property + def json(self): + return json.loads(self.data.decode(self.charset)) + + +class HTTP: + def __init__(self): + self.app = api.Api() + self.client = werkzeug.test.Client(self.app, MyResponse) + + def request(self, path, **kwargs): + # method and path are required arguments for sanity checking + # purposes. + + # By default, use json. + headers = kwargs.get('headers', {}) + headers.setdefault('Content-Type', 'application/json') + headers.setdefault('Accept', 'application/json') + + environ = werkzeug.test.create_environ( + path=path, + **kwargs) + + return self.client.open(environ) + + # Helper functions! + def get(self, *args, **kwargs): + return self.request(*args, method='GET', **kwargs) + + def post(self, *args, **kwargs): + return self.request(*args, method='POST', **kwargs) + + def put(self, *args, **kwargs): + return self.request(*args, method='PUT', **kwargs) + + def delete(self, *args, **kwargs): + return self.request(*args, method='DELETE', **kwargs) + + +@pytest.fixture(scope='function') +def http(): + return HTTP() diff --git a/tests/connectors/__init__.py b/tests/connectors/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/connectors/base.py b/tests/connectors/base.py deleted file mode 100644 index ad8c61a..0000000 --- a/tests/connectors/base.py +++ /dev/null @@ -1,60 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import sys -from importlib import import_module -from wsgi_intercept.httplib2_intercept import install -import pytest -import armet -from armet import test - - -class BaseResourceTest(object): - - #! Host at which the intercept hook is installed. - host = 'localhost' - - #! Port at which the intercept hook is installed. - port = 5000 - - @classmethod - def setup_class(cls): - # Initialize the test client. - cls.client = test.Client(cls.host, cls.port) - - @pytest.fixture(autouse=True, scope='class') - def initialize(self, request, connectors): - # Install the WSGI interception layer. - install() - - # Unload django. - for module in list(sys.modules.keys()): - if module and 'django' in module: - del sys.modules[module] - - # Reset and clear all global cache in armet. - from armet import decorators - - decorators._resources = {} - # decorators._handlers = {} - armet.use.config = {} - - # Re-initialize the configuration. - armet.use(connectors=connectors, debug=True) - - prefix = 'tests.connectors.' - callback = None - if 'model' in connectors: - # Initialize the database access layer. - model = import_module(prefix + connectors['model']) - callback = model.model_setup - - # Add the models module so that it can be generically imported. - sys.modules[prefix + 'models'] = model - request.cls.models = model - - # Initialize the http access layer. - http = import_module(prefix + connectors['http']) - http.http_setup(connectors, self.host, self.port, callback=callback) - - # Add a finalizer to teardown the http layer. - request.addfinalizer(lambda: http.http_teardown(self.host, self.port)) diff --git a/tests/connectors/bottle.py b/tests/connectors/bottle.py deleted file mode 100644 index 6daec00..0000000 --- a/tests/connectors/bottle.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import bottle -import wsgi_intercept -from wsgi_intercept.httplib2_intercept import install -from .utils import force_import_module - - -def http_setup(connectors, host, port, callback): - # Install the WSGI interception layer. - install() - - # Bottle is pretty straightforward. - # We just need to push an application context. - application = bottle.Bottle(__name__) - application.catchall = False - - # Ensure we're debugging. - bottle.debug(True) - - # Invoke the callback if we got one. - if callback: - callback() - - # Then import the resources; iterate and mount each one. - module = force_import_module('tests.connectors.resources') - for name in module.__all__: - getattr(module, name).mount(r'/api/', application) - - # Enable the WSGI interception layer. - wsgi_intercept.add_wsgi_intercept(host, port, lambda: application) - - -def http_teardown(host, port): - # Remove the WSGI interception layer. - wsgi_intercept.remove_wsgi_intercept(host, port) diff --git a/tests/connectors/conftest.py b/tests/connectors/conftest.py deleted file mode 100644 index dc644a5..0000000 --- a/tests/connectors/conftest.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet import connectors - - -def pytest_generate_tests(metafunc): - # Build a collection of connector hashes to represent every possible - # arrangement. - http = connectors.http - model = connectors.model - scenarios = [[{'http': x, 'model': y}] for x in http for y in model] - ids = ['{}:{}'.format(x, y) for x in http for y in model] - - # Parameterize all test classes. - metafunc.parametrize(['connectors'], scenarios, ids=ids, scope="class") diff --git a/tests/connectors/data.json b/tests/connectors/data.json deleted file mode 100644 index e4dab84..0000000 --- a/tests/connectors/data.json +++ /dev/null @@ -1 +0,0 @@ -[{"pk": 1, "model": "django.poll", "fields": {"available": true, "question": "Are you an innie or an outie?"}}, {"pk": 2, "model": "django.poll", "fields": {"available": false, "question": "Have you ever written a song?"}}, {"pk": 3, "model": "django.poll", "fields": {"available": true, "question": "Can you make change for a dollar right now?"}}, {"pk": 4, "model": "django.poll", "fields": {"available": false, "question": "Have you ever been in the opposite sex's public toilet?"}}, {"pk": 5, "model": "django.poll", "fields": {"available": true, "question": "Have you ever written a poem?"}}, {"pk": 6, "model": "django.poll", "fields": {"available": false, "question": "Do you like catsup on or beside your fries?"}}, {"pk": 7, "model": "django.poll", "fields": {"available": true, "question": "Have you ever been a boy/girl scout?"}}, {"pk": 8, "model": "django.poll", "fields": {"available": false, "question": "Have you ever written a book?"}}, {"pk": 9, "model": "django.poll", "fields": {"question": "Have you ever broken a mirror?"}}, {"pk": 10, "model": "django.poll", "fields": {"question": "Are you superstitious?"}}, {"pk": 11, "model": "django.poll", "fields": {"question": "What is your biggest pet peeve?"}}, {"pk": 12, "model": "django.poll", "fields": {"question": "Do you slurp your drink after it's gone?"}}, {"pk": 13, "model": "django.poll", "fields": {"question": "Have you ever blown bubbles in your milk?"}}, {"pk": 14, "model": "django.poll", "fields": {"question": "Would you rather eat a Big Mac or a Whopper?"}}, {"pk": 15, "model": "django.poll", "fields": {"question": "Have you ever gone skinny-dipping?"}}, {"pk": 16, "model": "django.poll", "fields": {"question": "Would you ever parachute out of a plane?"}}, {"pk": 17, "model": "django.poll", "fields": {"question": "What's the most daring thing you've done?"}}, {"pk": 18, "model": "django.poll", "fields": {"question": "When you are at the grocery store, do you ask for paper or plastic?"}}, {"pk": 19, "model": "django.poll", "fields": {"question": "True or False: You would rather eat steak than pizza."}}, {"pk": 20, "model": "django.poll", "fields": {"question": "Did you have a baby blanket?"}}, {"pk": 21, "model": "django.poll", "fields": {"question": "Have you ever tried to cut your own hair?"}}, {"pk": 22, "model": "django.poll", "fields": {"question": "How did that turn out?"}}, {"pk": 23, "model": "django.poll", "fields": {"question": "Have you ever sleepwalked?"}}, {"pk": 24, "model": "django.poll", "fields": {"question": "Have you ever had a birthday party at McDonalds?"}}, {"pk": 25, "model": "django.poll", "fields": {"question": "Can you flip your eye-lids up?"}}, {"pk": 26, "model": "django.poll", "fields": {"question": "Are you double jointed?"}}, {"pk": 27, "model": "django.poll", "fields": {"question": "If you could be any age, what age would you be?"}}, {"pk": 28, "model": "django.poll", "fields": {"question": "Have you ever gotten gum stuck in your hair?"}}, {"pk": 29, "model": "django.poll", "fields": {"question": "Do you ride roller coasters?"}}, {"pk": 30, "model": "django.poll", "fields": {"question": "What's your favorite carnival ride?"}}, {"pk": 31, "model": "django.poll", "fields": {"question": "What is your dream car?"}}, {"pk": 32, "model": "django.poll", "fields": {"question": "What is your favorite cartoon of all time?"}}, {"pk": 33, "model": "django.poll", "fields": {"question": "Have you ever eaten a dog biscuit?"}}, {"pk": 34, "model": "django.poll", "fields": {"question": "If so, would you eat another one?"}}, {"pk": 35, "model": "django.poll", "fields": {"question": "If you were in a car sinking in a lake, what would you do first?"}}, {"pk": 36, "model": "django.poll", "fields": {"question": "Have you ever ridden in an ambulance?"}}, {"pk": 37, "model": "django.poll", "fields": {"question": "Can you pick something up with your toes?"}}, {"pk": 38, "model": "django.poll", "fields": {"question": "How many remote controls do you have in your house?"}}, {"pk": 39, "model": "django.poll", "fields": {"question": "Have you ever fallen asleep in school?"}}, {"pk": 40, "model": "django.poll", "fields": {"question": "How many times have you flown in an airplane in the last year?"}}, {"pk": 41, "model": "django.poll", "fields": {"question": "How many foreign countries have you visited?"}}, {"pk": 42, "model": "django.poll", "fields": {"question": "If you were out of shape, would you compete in a triathlon if you were somehow guaranteed to win a big, gaudy medal?"}}, {"pk": 43, "model": "django.poll", "fields": {"question": "Would you rather be rich and unhappy, or poor and happy?"}}, {"pk": 44, "model": "django.poll", "fields": {"question": "If you fell into quicksand, would you try to swim or try to float?"}}, {"pk": 45, "model": "django.poll", "fields": {"question": "Do you ask for directions when you are lost?"}}, {"pk": 46, "model": "django.poll", "fields": {"question": "Have you ever held a Mexican jumping bean?"}}, {"pk": 47, "model": "django.poll", "fields": {"question": "Are you more like Cinderella or Alice in Wonderland?"}}, {"pk": 48, "model": "django.poll", "fields": {"question": "Would you rather have an ant farm with no ants or a box of crayons with broken points?"}}, {"pk": 49, "model": "django.poll", "fields": {"question": "Do you prefer light or dark bread?"}}, {"pk": 50, "model": "django.poll", "fields": {"question": "Do you prefer scrambled or fried eggs?"}}, {"pk": 51, "model": "django.poll", "fields": {"question": "Have you ever been in a car that ran out of gas?"}}, {"pk": 52, "model": "django.poll", "fields": {"question": "Do you talk in your sleep?"}}, {"pk": 53, "model": "django.poll", "fields": {"question": "Would you rather shovel snow or mow the lawn?"}}, {"pk": 54, "model": "django.poll", "fields": {"question": "Have you ever played in the rain?"}}, {"pk": 55, "model": "django.poll", "fields": {"question": "Did you make mud pies?"}}, {"pk": 56, "model": "django.poll", "fields": {"question": "Have you ever broken a bone?"}}, {"pk": 57, "model": "django.poll", "fields": {"question": "Would you climb a very high tree to save a kitten?"}}, {"pk": 58, "model": "django.poll", "fields": {"question": "Can you tell the difference between a crocodile and an alligator?"}}, {"pk": 59, "model": "django.poll", "fields": {"question": "Do you drink pepsi or coke?"}}, {"pk": 60, "model": "django.poll", "fields": {"question": "What's your favorite number?"}}, {"pk": 61, "model": "django.poll", "fields": {"question": "If you were a car, would you be an SUV or a sports car?"}}, {"pk": 62, "model": "django.poll", "fields": {"question": "Have you ever accidentally taken something from a hotel?"}}, {"pk": 63, "model": "django.poll", "fields": {"question": "Have you ever slipped in the bathtub?"}}, {"pk": 64, "model": "django.poll", "fields": {"question": "Do you use regular or deodorant soap?"}}, {"pk": 65, "model": "django.poll", "fields": {"question": "Have you ever locked yourself out of the house?"}}, {"pk": 66, "model": "django.poll", "fields": {"question": "Would you rather make your living as a singing cowboy or as one of the Simpsons voices?"}}, {"pk": 67, "model": "django.poll", "fields": {"question": "If you could invite any movie star to your home for dinner, who would it be?"}}, {"pk": 68, "model": "django.poll", "fields": {"question": "Do you need corrective lenses?"}}, {"pk": 69, "model": "django.poll", "fields": {"question": "Would you hang out with / date someone your best friend didn't like?"}}, {"pk": 70, "model": "django.poll", "fields": {"question": "Would you hang out with someone your best friend liked, but you didn't like?"}}, {"pk": 71, "model": "django.poll", "fields": {"question": "Have you ever returned a gift?"}}, {"pk": 72, "model": "django.poll", "fields": {"question": "Would you give someone else a gift that had been given to you?"}}, {"pk": 73, "model": "django.poll", "fields": {"question": "If you could attend an Olympic Event, what would it be?"}}, {"pk": 74, "model": "django.poll", "fields": {"question": "If you could participate in an Olympic Event, what would it be?"}}, {"pk": 75, "model": "django.poll", "fields": {"question": "How many pairs of shoes do you own?"}}, {"pk": 76, "model": "django.poll", "fields": {"question": "If your grandmother gave you a gift that you already have, would you tell her?"}}, {"pk": 77, "model": "django.poll", "fields": {"question": "Do you sing in the car?"}}, {"pk": 78, "model": "django.poll", "fields": {"question": "What is your favorite breed of dog?"}}, {"pk": 79, "model": "django.poll", "fields": {"question": "Would you donate money to feed starving animals in the winter?"}}, {"pk": 80, "model": "django.poll", "fields": {"question": "What is your favorite fruit?"}}, {"pk": 81, "model": "django.poll", "fields": {"question": "What is your least favorite fruit?"}}, {"pk": 82, "model": "django.poll", "fields": {"question": "What kind of fruit have you never had?"}}, {"pk": 83, "model": "django.poll", "fields": {"question": "If you won a $5,000 shopping spree to any store, which store would you pick?"}}, {"pk": 84, "model": "django.poll", "fields": {"question": "What brand sports apparel do you wear the most?"}}, {"pk": 85, "model": "django.poll", "fields": {"question": "Are/were you a good student?"}}, {"pk": 86, "model": "django.poll", "fields": {"question": "Among your friends, who could you arm wrestle and beat?"}}, {"pk": 87, "model": "django.poll", "fields": {"question": "If you had to choose, what branch of the military would you be in?"}}, {"pk": 88, "model": "django.poll", "fields": {"question": "What do you think is your best feature?"}}, {"pk": 89, "model": "django.poll", "fields": {"question": "If you were to win a Grammy, what kind of music would it be for?"}}, {"pk": 90, "model": "django.poll", "fields": {"question": "If you were to win an Osacr, what kind of movie would it be for?"}}, {"pk": 91, "model": "django.poll", "fields": {"question": "What is your favorite season?"}}, {"pk": 92, "model": "django.poll", "fields": {"question": "How many members do you have in your immediate family?"}}, {"pk": 93, "model": "django.poll", "fields": {"question": "Which of the five senses is most important to you?"}}, {"pk": 94, "model": "django.poll", "fields": {"question": "Would you be a more successful painter or singer?"}}, {"pk": 95, "model": "django.poll", "fields": {"question": "How many years will/did you end up going to college?"}}, {"pk": 96, "model": "django.poll", "fields": {"question": "Have you ever had surgery?"}}, {"pk": 97, "model": "django.poll", "fields": {"question": "Would you rather be a professional figure skater or professional football player?"}}, {"pk": 98, "model": "django.poll", "fields": {"question": "What do you like to collect?"}}, {"pk": 99, "model": "django.poll", "fields": {"question": "How many collectibles do you have?"}}, {"pk": 100, "model": "django.poll", "fields": {"question": "What one question would you add to this survey?"}}] diff --git a/tests/connectors/django/__init__.py b/tests/connectors/django/__init__.py deleted file mode 100644 index cab8169..0000000 --- a/tests/connectors/django/__init__.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import os -import wsgi_intercept -from wsgi_intercept.httplib2_intercept import install - -# Setup the environment variables. -os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.connectors.django.settings' - -from .models import Poll - -__all__ = [ - 'Poll' -] - - -def http_setup(connectors, host, port, callback): - # Setup the environment variables. - os.environ['DJANGO_SETTINGS_MODULE'] = ( - 'tests.connectors.django.settings') - - # Invoke the callback if we got one. - if callback: - callback() - - # Install the WSGI interception layer. - install() - - # Import and grab the django application. - from django.core.wsgi import get_wsgi_application - - # Enable the WSGI interception layer. - wsgi_intercept.add_wsgi_intercept(host, port, get_wsgi_application) - - -def http_teardown(host, port): - # Remove the WSGI interception layer. - wsgi_intercept.remove_wsgi_intercept(host, port) - - -def model_setup(): - # Setup the environment variables. - os.environ['DJANGO_SETTINGS_MODULE'] = ( - 'tests.connectors.django.settings') - - # Initialize the database and create all models. - from django.db import connections, DEFAULT_DB_ALIAS - connection = connections[DEFAULT_DB_ALIAS] - connection.creation.create_test_db(verbosity=0) - - # Load the data fixture. - from django.core import management - data = os.path.join(os.path.dirname(__file__), '..', 'data.json') - management.call_command('loaddata', data, verbosity=0, interactive=0) diff --git a/tests/connectors/django/models.py b/tests/connectors/django/models.py deleted file mode 100644 index 5c863ea..0000000 --- a/tests/connectors/django/models.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from django.db import models - - -class Poll(models.Model): - - question = models.CharField(max_length=1024) - - available = models.NullBooleanField() - - votes = models.IntegerField(null=True) diff --git a/tests/connectors/django/settings.py b/tests/connectors/django/settings.py deleted file mode 100644 index 2c2f758..0000000 --- a/tests/connectors/django/settings.py +++ /dev/null @@ -1,153 +0,0 @@ -# -*- coding: utf-8 -*- -# from __future__ import absolute_import, unicode_literals, division -from os import path - -DEBUG = True -TEMPLATE_DEBUG = DEBUG - -PROJECT_ROOT = path.abspath(path.dirname(__file__)) - -ADMINS = ( - # ('Your Name', 'your_email@example.com'), -) - -MANAGERS = ADMINS - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - 'USER': '', - 'PASSWORD': '', - 'HOST': '', - 'PORT': '', - } -} - -# Turn off auto slash handling. -APPEND_SLASH = False - -# Hosts/domain names that are valid for this site; required if DEBUG is False -# See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts -ALLOWED_HOSTS = [] - -# Local time zone for this installation. Choices can be found here: -# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name -# although not all choices may be available on all operating systems. -# In a Windows environment this must be set to your system time zone. -TIME_ZONE = 'America/Chicago' - -# Language code for this installation. All choices can be found here: -# http://www.i18nguy.com/unicode/language-identifiers.html -LANGUAGE_CODE = 'en-us' - -SITE_ID = 1 - -# URL configuration. -ROOT_URLCONF = 'tests.connectors.django.urls' - -# If you set this to False, Django will make some optimizations so as not -# to load the internationalization machinery. -USE_I18N = True - -# If you set this to False, Django will not format dates, numbers and -# calendars according to the current locale. -USE_L10N = True - -# If you set this to False, Django will not use timezone-aware datetimes. -USE_TZ = True - -# Absolute filesystem path to the directory that will hold user-uploaded files. -# Example: "/var/www/example.com/media/" -MEDIA_ROOT = '' - -# URL that handles the media served from MEDIA_ROOT. Make sure to use a -# trailing slash. -# Examples: "http://example.com/media/", "http://media.example.com/" -MEDIA_URL = '' - -# Absolute path to the directory static files should be collected to. -# Don't put anything in this directory yourself; store your static files -# in apps' "static/" subdirectories and in STATICFILES_DIRS. -# Example: "/var/www/example.com/static/" -STATIC_ROOT = '' - -# URL prefix for static files. -# Example: "http://example.com/static/", "http://static.example.com/" -STATIC_URL = '/static/' - -# Additional locations of static files -STATICFILES_DIRS = ( - # Put strings here, like "/home/html/static" or "C:/www/django/static". - # Always use forward slashes, even on Windows. - # Don't forget to use absolute paths, not relative paths. -) - -# List of finder classes that know how to find static files in -# various locations. -STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', -) - -# Make this unique, and don't share it with anybody. -SECRET_KEY = '_lhu2gqrh7j0+9aa_*n-fzerhsar+n$tm1nf+6i+f+$abx#$q@' - -# List of callables that know how to import templates from various sources. -TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', -) - -MIDDLEWARE_CLASSES = ( - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - # Uncomment the next line for simple clickjacking protection: - # 'django.middleware.clickjacking.XFrameOptionsMiddleware', -) - -TEMPLATE_DIRS = ( - # Put strings here, like "/home/html/django_templates" - # Always use forward slashes, even on Windows. - # Don't forget to use absolute paths, not relative paths. -) - -INSTALLED_APPS = ( - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'tests.connectors.django' -) - -# A sample logging configuration. The only tangible logging -# performed by this configuration is to send an email to -# the site admins on every HTTP 500 error when DEBUG=False. -# See http://docs.djangoproject.com/en/dev/topics/logging for -# more details on how to customize your logging configuration. -LOGGING = { - 'version': 1, - 'disable_existing_loggers': True, - 'formatters': { - 'simple': { - 'format': '%(levelname)s %(message)s' - }, - }, - 'handlers': { - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'simple' - }, - }, - 'loggers': { - 'armet': { - 'handlers': ['console'], - 'level': 'DEBUG', - }, - } -} diff --git a/tests/connectors/django/urls.py b/tests/connectors/django/urls.py deleted file mode 100644 index 76039eb..0000000 --- a/tests/connectors/django/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from django.conf.urls import patterns, include, url -from ..utils import force_import_module - - -# Initial URL configuration. -urlpatterns = patterns('') - -# Import the resources; iterate and mount each one. -module = force_import_module('tests.connectors.resources') -for name in module.__all__: - cls = getattr(module, name) - urlpatterns += patterns('', url(r'^api/', include(cls.urls))) diff --git a/tests/connectors/flask.py b/tests/connectors/flask.py deleted file mode 100644 index eb7824c..0000000 --- a/tests/connectors/flask.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import flask -import wsgi_intercept -from wsgi_intercept.httplib2_intercept import install -from .utils import force_import_module - - -def http_setup(connectors, host, port, callback): - # Install the WSGI interception layer. - install() - - # Flask is pretty straightforward. - # We just need to push an application context. - application = flask.Flask(__name__) - application.debug = True - - # Invoke the callback if we got one. - if callback: - callback() - - # Then import the resources; iterate and mount each one. - module = force_import_module('tests.connectors.resources') - for name in module.__all__: - getattr(module, name).mount(r'/api/', application) - - # Enable the WSGI interception layer. - wsgi_intercept.add_wsgi_intercept(host, port, lambda: application) - - -def http_teardown(host, port): - # Remove the WSGI interception layer. - wsgi_intercept.remove_wsgi_intercept(host, port) diff --git a/tests/connectors/resources.py b/tests/connectors/resources.py deleted file mode 100644 index 7031a8f..0000000 --- a/tests/connectors/resources.py +++ /dev/null @@ -1,474 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import sys -import json -import armet -from armet import resources, attributes, exceptions - -# Request the generic models module inserted by the test runner. -models = sys.modules['tests.connectors.models'] - -__all__ = [ - 'SimpleResource', - 'SimpleTrailingResource', - 'PollResource', - 'StreamingResource', - 'AsyncResource', - 'AsyncStreamResource', - 'lightweight', - 'lightweight_streaming', - 'lightweight_async', - 'LeftResource', - 'RightResource', - 'echo', - 'cookie', - 'DirectResource', - 'ModelDirectResource', - 'IndirectResource', - 'ModelIndirectResource', - 'TwiceIndirectResource', - 'ModelTwiceIndirectResource', - 'ThriceIndirectResource', - 'ModelThriceIndirectResource', - 'MixinResource', - 'PollExcludeResource', - 'PollUnreadResource', - 'PollUnwriteResource', - 'PollNoNullResource', - 'PollRequiredResource', - 'PollValidResource', - 'PollNamedResource', - 'DirectConnectorResource', - 'IndirectConnectorResource', - 'TwiceIndirectConnectorResource', - 'ThriceIndirectConnectorResource', - 'DirectModelConnectorResource', - 'IndirectModelConnectorResource', - 'TwiceIndirectModelConnectorResource', - 'ThriceIndirectModelConnectorResource', - 'DirectModelConnectorMixinResource', - 'IndirectModelConnectorMixinResource', - 'TwiceIndirectModelConnectorMixinResource', - 'ThriceIndirectModelConnectorMixinResource', -] - - -class SimpleResource(resources.Resource): - - def get(self, request, response): - # Do nothing and return nothing. - pass - - -class SimpleTrailingResource(resources.Resource): - - class Meta: - trailing_slash = False - - def get(self, request, response): - # Do nothing and return nothing. - pass - - -class StreamingResource(resources.Resource): - - def get(self, request, response): - response.status = 412 - response['Content-Type'] = 'text/plain' - - yield 'this\n' - - response.write('where\n') - response.flush() - response.write('whence\n') - - yield - yield 'that\n' - - response.write('why\n') - - yield 'and the other' - - -def spawn(connectors, function): - import gevent - gevent.spawn(function) - - -class AsyncResource(resources.Resource): - - class Meta: - asynchronous = True - - def get(self, request, response): - - def writer(): - response.status = 412 - response['Content-Type'] = 'text/plain' - response.write('Hello') - response.close() - - spawn(self.meta.connectors, writer) - - -class AsyncStreamResource(resources.Resource): - - class Meta: - asynchronous = True - - def get(self, request, response): - - def streamer(): - response.status = 412 - response['Content-Type'] = 'text/plain' - response.write('this\n') - response.flush() - - response.write('where\n') - response.flush() - - response.write('whence\n') - response.write('that\n') - response.flush() - - response.write('why\n') - response.write('and the other') - response.close() - - spawn(self.meta.connectors, streamer) - - -@armet.resource(methods='GET') -def lightweight(request, response): - response.status = 412 - response['Content-Type'] = 'text/plain' - response.write('Hello') - - -@armet.resource(methods='POST') -def lightweight(request, response): - response.status = 414 - response['Content-Type'] = 'text/plain' - response.write('Hello POST') - - -@armet.resource(methods='GET') -def lightweight_streaming(request, response): - response.status = 412 - response['Content-Type'] = 'text/plain' - - yield 'this\n' - - response.write('where\n') - response.flush() - response.write('whence\n') - - yield - yield 'that\n' - - response.write('why\n') - - yield 'and the other' - - -@armet.asynchronous -@armet.resource(methods='GET') -def lightweight_async(request, response): - def writer(): - response.status = 412 - response['Content-Type'] = 'text/plain' - response.write('Hello') - response.close() - - spawn(lightweight_async.meta.connectors, writer) - - -class PollResource(resources.ModelResource): - - class Meta: - model = models.Poll - - slug = 'id' - - id = attributes.IntegerAttribute('id') - - question = attributes.TextAttribute('question') - - available = attributes.BooleanAttribute('available') - - -class LeftResource(resources.Resource): - - class Meta: - http_allowed_methods = ('HEAD', 'GET', 'OPTIONS',) - http_allowed_origins = ('http://127.0.0.1:80',) - http_allowed_headers = ('Content-Type', 'Content-MD5', 'Accept',) - - def get(self): - # Do nothing. - pass - - -class RightResource(resources.Resource): - - class Meta: - http_allowed_methods = ('HEAD', 'GET', 'DELETE', 'OPTIONS',) - http_allowed_origins = ('*',) - http_allowed_headers = ('Content-Type', 'Content-MD5', 'Accept') - - def get(self): - # Do nothing. - pass - - -@armet.resource(methods='POST') -def echo(request, response): - # Read in the given data in the given format. - data = request.read(deserialize=True) - - # Write out the parsed data in the requested format. - response.write(data, serialize=True) - - -@armet.resource(methods='GET') -def cookie(request, response): - response['Content-Type'] = 'text/plain' - response.write(request.cookies['blue'].value) - - -class DirectResource(resources.Resource): - - def route(self, request, response): - return super(DirectResource, self).route(request, response) - - def get(self, request, response): - response.write(b'42') - - -class ModelDirectResource(resources.ModelResource): - - class Meta: - model = models.Poll - - id = attributes.IntegerAttribute('id') - - question = attributes.TextAttribute('question') - - def route(self, request, response): - return super(ModelDirectResource, self).route(request, response) - - -class IndirectResource(DirectResource): - - def route(self, request, response): - return super(IndirectResource, self).route(request, response) - - def get(self, request, response): - response.write(b'84') - - -class ModelIndirectResource(ModelDirectResource): - - class Meta: - model = models.Poll - - def read(self): - return super(ModelIndirectResource, self).read() - - -class TwiceIndirectResource(IndirectResource): - - def route(self, request, response): - return super(TwiceIndirectResource, self).route(request, response) - - def get(self, request, response): - response.write(b'84') - - -class ModelTwiceIndirectResource(ModelIndirectResource): - - class Meta: - model = models.Poll - - def read(self): - return super(ModelTwiceIndirectResource, self).read() - - -class ThriceIndirectResource(IndirectResource): - - def route(self, request, response): - return super(ThriceIndirectResource, self).route(request, response) - - def get(self, request, response): - response.write(b'84') - - -class ModelThriceIndirectResource(ModelIndirectResource): - - class Meta: - model = models.Poll - - def read(self): - return super(ModelThriceIndirectResource, self).read() - - -class ExtraStuff(object): - - def dispatch(self, *args): - self.content = b'Hello' - return super(ExtraStuff, self).dispatch(*args) - - -class MixinResource(ExtraStuff, IndirectResource): - - content = b'World' - - def route(self, request, response): - return super(MixinResource, self).route(request, response) - - def get(self, request, response): - response.write(self.content) - - -class PollExcludeResource(PollResource): - - question = attributes.TextAttribute('question', include=False) - - -class PollUnreadResource(PollResource): - - question = attributes.TextAttribute('question', read=False) - - -class PollUnwriteResource(PollResource): - - question = attributes.TextAttribute('question', write=False) - - -class PollNoNullResource(PollResource): - - question = attributes.TextAttribute('question', null=False) - - -class PollRequiredResource(PollResource): - - question = attributes.TextAttribute('question', required=True) - - -class PollNamedResource(PollResource): - - question = attributes.TextAttribute('question', name='superQuestion') - - -class PollValidResource(PollResource): - - votes = attributes.IntegerAttribute('votes') - - def clean_votes(self, value): - assert value > 0, 'Must be greater than 0.' - assert value < 51, 'Must be less than 51.' - return value - - def clean_question(self, value): - errors = [] - - if len(value) <= 15: - errors.append('Must be more than 15 characters.') - - if value.find('?') == -1: - errors.append('Must have at least one question mark.') - - if errors: - raise exceptions.ValidationError(*errors) - - return value - - -class DirectConnectorResource(resources.Resource): - - @classmethod - def view(cls, *args, **kwargs): - cls.status = 205 - return super(DirectConnectorResource, cls).view(*args, **kwargs) - - def get(self, request, response): - response.status = self.status - response.write(b'Hello World\n') - - -class IndirectConnectorResource(DirectConnectorResource): - pass - - -class TwiceIndirectConnectorResource(IndirectConnectorResource): - pass - - -class ThriceIndirectConnectorResource(TwiceIndirectConnectorResource): - pass - - -class DirectModelConnectorResource(resources.ModelResource): - - class Meta: - model = models.Poll - - id = attributes.IntegerAttribute('id') - - def route(self, request, response): - response.status = 205 - return super(DirectModelConnectorResource, self).route( - request, response) - - def get(self, request, response): - response.write(json.dumps(self.read()).encode('utf8')) - - def read(self): - return ['Hello', 'World'] - - -class IndirectModelConnectorResource(DirectModelConnectorResource): - pass - - -class TwiceIndirectModelConnectorResource(IndirectModelConnectorResource): - pass - - -class ThriceIndirectModelConnectorResource( - TwiceIndirectModelConnectorResource): - pass - - -class DirectModelConnectorMixin(object): - - def destroy(self): - self.response.status = 402 - return None - - -class DirectModelConnectorMixinResource( - DirectModelConnectorMixin, resources.ModelResource): - - class Meta: - model = models.Poll - - id = attributes.IntegerAttribute('id') - - def delete(self, request, response): - self.response.status = 405 - self.destroy() - - -class IndirectModelConnectorMixinResource(DirectModelConnectorMixinResource): - pass - - -class TwiceIndirectModelConnectorMixinResource( - IndirectModelConnectorMixinResource): - pass - - -class ThriceIndirectModelConnectorMixinResource( - TwiceIndirectModelConnectorMixinResource): - pass diff --git a/tests/connectors/sqlalchemy.py b/tests/connectors/sqlalchemy.py deleted file mode 100644 index d43eac1..0000000 --- a/tests/connectors/sqlalchemy.py +++ /dev/null @@ -1,73 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import os -import json -import armet -import sqlalchemy as sa -from sqlalchemy import orm -from sqlalchemy.ext.declarative import declarative_base - -# Instantiate the declarative base. -Base = declarative_base() - -# Instantiate the engine used to access the models. -engine = sa.create_engine('sqlite:///:memory:') - -# Construct the session factory. -Session = orm.sessionmaker(bind=engine) - - -class Poll(Base): - - __tablename__ = 'poll' - - id = sa.Column(sa.Integer, primary_key=True) - - question = sa.Column(sa.String(1024)) - - available = sa.Column(sa.Boolean) - - votes = sa.Column(sa.Integer) - - -def _load_fixture(filename): - """ - Loads the passed fixture into the database following the - django format. - """ - - # Read the binary data into text - with open(filename, 'rb') as stream: - content = stream.read().decode('utf-8') - - # Decode the data as JSON - data = json.loads(content) - - # Instantiate a session. - session = Session() - - # Iterate through the entries to add them one by one. - for item in data: - # Resolve model from the table reference. - table = Base.metadata.tables[item['model'].split('.')[-1]] - - # Add the primary key. - item['fields']['id'] = item['pk'] - - # Add a new row. - session.connection().execute(table.insert().values(**item['fields'])) - - # Commit the session to the database. - session.commit() - - -def model_setup(): - # Initialize the database and create all models. - Base.metadata.drop_all(engine) - Base.metadata.create_all(engine) - - # Load the data fixture. - _load_fixture(os.path.join(os.path.dirname(__file__), 'data.json')) - - # Configure armet and provide the session factory. - armet.use(Session=Session) diff --git a/tests/connectors/test_access.py b/tests/connectors/test_access.py deleted file mode 100644 index 0022f9f..0000000 --- a/tests/connectors/test_access.py +++ /dev/null @@ -1,147 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet import http -from .base import BaseResourceTest -import pytest - -# Shortcut to the skipif marker. -skipif = pytest.mark.skipif - - -class TestResourceAccess(BaseResourceTest): - - def test_simple(self, connectors): - response, _ = self.client.get('/api/simple/') - - assert response.status == http.client.OK - - def test_redirect(self, connectors): - response, _ = self.client.get('/api/simple') - - assert response.status == http.client.MOVED_PERMANENTLY - - uri = 'http://{}:{}/api/simple/'.format(self.host, self.port) - assert response.get('location') == uri - - def test_redirect_trailing(self, connectors): - response, _ = self.client.get('/api/simple-trailing/') - - assert response.status == http.client.MOVED_PERMANENTLY - - uri = 'http://{}:{}/api/simple-trailing'.format(self.host, self.port) - assert response.get('location') == uri - - # NOTE: The below test fails in flask / werkzeug and I don't want - # to add crazy hacks to make it work. - # - # def test_redirect_complex(self, connectors): - # response, _ = self.client.get('/api/simple:hello(x=y)?x=3&y=4') - - # assert response.status == http.client.MOVED_PERMANENTLY - - # uri = 'http://{}:{}/api/simple:hello(x=y)/?x=3&y=4'.format(self.host, - # self.port) - # assert response.get('location') == uri - - def test_not_found(self, connectors): - response, _ = self.client.get('/api/unknown') - - assert response.status == http.client.NOT_FOUND - - response, _ = self.client.get('/api/unknown/') - - assert response.status == http.client.NOT_FOUND - - def test_unknown(self, connectors): - response, _ = self.client.request('/api/simple/', method='APPLE') - - assert response.status == http.client.METHOD_NOT_ALLOWED - - def test_not_allowed(self, connectors): - response, _ = self.client.request('/api/simple/', method='CONNECT') - - assert response.status == http.client.METHOD_NOT_ALLOWED - - def test_not_implemented(self, connectors): - response, _ = self.client.request('/api/simple/', method='PATCH') - - assert response.status == http.client.NOT_IMPLEMENTED - - def test_streaming(self, connectors): - response, content = self.client.request('/api/streaming/') - data = content.decode('utf-8') - - assert response.status == 412 - assert response.get('content-type') == 'text/plain' - assert data == 'this\nwhere\nwhence\nthat\nwhy\nand the other' - - # @skipif("sys.version_info >= (3, 0)") - # @skipif("__import__('platform').python_implementation() == 'PyPy'") - # def test_async(self, connectors): - # response, content = self.client.request('/api/async/') - # data = content.decode('utf-8') - - # assert response.status == 412 - # assert response.get('content-type') == 'text/plain' - # assert data == 'Hello' - - # @skipif("sys.version_info >= (3, 0)") - # @skipif("__import__('platform').python_implementation() == 'PyPy'") - # def test_async_stream(self, connectors): - # response, content = self.client.request('/api/async-stream/') - # content = content.decode('utf-8') - - # assert response.status == 412 - # assert response.get('content-type') == 'text/plain' - # assert content == 'this\nwhere\nwhence\nthat\nwhy\nand the other' - - -class TestResourceLightweight(BaseResourceTest): - - def test_lightweight(self, connectors): - response, content = self.client.get('/api/lightweight/') - data = content.decode('utf-8') - - assert response.status == 412 - assert response.get('content-type') == 'text/plain' - assert data == 'Hello' - - def test_lightweight_post(self, connectors): - response, content = self.client.post('/api/lightweight/') - data = content.decode('utf-8') - - assert response.status == 414 - assert response.get('content-type') == 'text/plain' - assert data == 'Hello POST' - - def test_lightweight_streaming(self, connectors): - response, content = self.client.request('/api/lightweight-streaming/') - data = content.decode('utf-8') - - assert response.status == 412 - assert response.get('content-type') == 'text/plain' - assert data == 'this\nwhere\nwhence\nthat\nwhy\nand the other' - - # @skipif("sys.version_info >= (3, 0)") - # @skipif("__import__('platform').python_implementation() == 'PyPy'") - # def test_lightweight_async(self, connectors): - # response, content = self.client.request('/api/lightweight-async/') - # data = content.decode('utf-8') - - # assert response.status == 412 - # assert response.get('content-type') == 'text/plain' - # assert data == 'Hello' - - -class TestResourceCookie(BaseResourceTest): - - def test_send_and_check(self, connectors): - response, content = self.client.request( - path='/api/cookie/', - headers={'Cookie': 'blue=color'}) - - data = content.decode('utf-8') - - assert response.status == 200 - assert response.get('content-type') == 'text/plain' - assert data == 'color' diff --git a/tests/connectors/test_attributes.py b/tests/connectors/test_attributes.py deleted file mode 100644 index 16b190e..0000000 --- a/tests/connectors/test_attributes.py +++ /dev/null @@ -1,135 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import json -from armet import http -from .base import BaseResourceTest - - -class TestResourceAttributeProperties(BaseResourceTest): - - def test_not_include(self): - response, content = self.client.get('/api/poll-exclude/1/') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert 'id' in data - assert 'question' not in data - - def test_not_include_access(self): - response, content = self.client.get('/api/poll-exclude/1/question/') - - assert response.status == http.client.OK - assert content.decode('utf8') == '["Are you an innie or an outie?"]' - - def test_not_read(self): - response, content = self.client.get('/api/poll-unread/1/question/') - - assert response.status == http.client.FORBIDDEN - - def test_not_include_but_create(self): - data = {'question': 'Is anybody really out there?'} - body = json.dumps(data) - response, content = self.client.post( - path='/api/poll-exclude/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.CREATED - - data = json.loads(content.decode('utf8')) - - assert 'question' not in data - assert data['id'] == 101 - - response, content = self.client.get('/api/poll/101/') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert data['question'] == 'Is anybody really out there?' - - def test_not_include_but_replace(self): - data = {'id': 1, 'question': 'Is anybody really out there?'} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll-exclude/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert 'question' not in data - assert data['id'] == 1 - - response, content = self.client.get('/api/poll/1/') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert data['question'] == 'Is anybody really out there?' - - def test_not_write(self): - data = {'id': 123, 'question': 'gr9uer0gn2w'} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll-unwrite/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.BAD_REQUEST - - data = json.loads(content.decode('utf8')) - - assert data['question'] == ['Attribute is read-only.'] - - def test_not_write_same(self): - data = {'id': 1, 'question': 'Is anybody really out there?'} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll-unwrite/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.OK - - def test_no_null(self): - data = {'question': None} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll-no-null/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.BAD_REQUEST - - data = json.loads(content.decode('utf8')) - - assert data['question'] == ['Must not be null.'] - - def test_required(self): - data = {} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll-required/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.BAD_REQUEST - - data = json.loads(content.decode('utf8')) - - assert data['question'] == ['Must be provided.'] - - def test_name(self): - data = {'superQuestion': 'something', 'id': 1} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll-named/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert 'superQuestion' in data - assert data['superQuestion'] == 'something' diff --git a/tests/connectors/test_delete.py b/tests/connectors/test_delete.py deleted file mode 100644 index 037f2e8..0000000 --- a/tests/connectors/test_delete.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet import http -from .base import BaseResourceTest - - -class TestResourceDelete(BaseResourceTest): - - def test_delete_existing(self, connectors): - response, content = self.client.delete( - path='/api/poll/1/', - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.NO_CONTENT diff --git a/tests/connectors/test_get.py b/tests/connectors/test_get.py deleted file mode 100644 index f368a5e..0000000 --- a/tests/connectors/test_get.py +++ /dev/null @@ -1,197 +0,0 @@ -# -*- coding: utf-8 -*- - -import json -from armet import http -from .base import BaseResourceTest -from pytest import mark - - -@mark.bench('self.client.request', iterations=1000) -class TestResourceGet(BaseResourceTest): - - def test_list(self): - response, content = self.client.request('/api/poll/') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 100 - assert data[0]['question'] == 'Are you an innie or an outie?' - assert (data[-1]['question'] == - 'What one question would you add to this survey?') - - def test_get_not_found(self, connectors): - response, _ = self.client.get('/api/poll/101/') - - assert response.status == http.client.NOT_FOUND - - def test_single(self, connectors): - response, content = self.client.request('/api/poll/1/') - data = json.loads(content.decode('utf-8')) - - assert response.status == http.client.OK - assert isinstance(data, dict) - assert data['question'] == 'Are you an innie or an outie?' - - response, content = self.client.request('/api/poll/100/') - data = json.loads(content.decode('utf-8')) - - assert response.status == http.client.OK - assert isinstance(data, dict) - assert (data['question'] == - 'What one question would you add to this survey?') - - -@mark.bench('self.client.request', iterations=1000) -class TestResourceQuery(BaseResourceTest): - - def test_param_eq_one(self, connectors): - response, content = self.client.request('/api/poll/?id=1') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 1 - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_param_eq_same_and(self, connectors): - response, content = self.client.request('/api/poll/?id=1&id=2') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 0 - - def test_param_eq_same_or(self, connectors): - response, content = self.client.request('/api/poll/?id=1;id=2') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 2 - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_param_eq_same_or_simple(self, connectors): - response, content = self.client.request('/api/poll/?id=1,2') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 2 - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_param_gt_one(self, connectors): - response, content = self.client.request('/api/poll/?id>99') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 1 - - question = data[0]['question'] - assert question == 'What one question would you add to this survey?' - - def test_param_available_true(self, connectors): - response, content = self.client.request('/api/poll/?available=true') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 4 - - def test_param_available_false(self, connectors): - response, content = self.client.request('/api/poll/?available=false') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert isinstance(data, list) - assert len(data) == 4 - - -@mark.bench('self.client.request', iterations=1000) -class TestResourceTraversal(BaseResourceTest): - - def test_item_attribute(self): - response, content = self.client.request('/api/poll/1/question/') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert data[0] == 'Are you an innie or an outie?' - - def test_item_attribute_not_found(self): - response, content = self.client.request('/api/poll/1/blah/') - - assert response.status == http.client.NOT_FOUND - - def test_item_attribute_not_found_path(self): - response, content = self.client.request('/api/poll/1/question/blah/') - - assert response.status == http.client.NOT_FOUND - - def test_list_attribute(self): - response, content = self.client.request('/api/poll/question/') - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf-8')) - - assert data[0] == 'Are you an innie or an outie?' - assert data[-1] == 'What one question would you add to this survey?' - - def test_list_attribute_not_found(self): - response, content = self.client.request('/api/poll/blah/') - - assert response.status == http.client.NOT_FOUND - - def test_list_attribute_not_found_path(self): - response, content = self.client.request('/api/poll/question/blah/') - - assert response.status == http.client.NOT_FOUND - - -@mark.bench('self.client.request', iterations=1000) -class TestResourcePagination(BaseResourceTest): - - def test_get_range(self): - response, content = self.client.request( - path='/api/poll/', - headers={ - 'range': 'items=10-20'}) - - assert response.status == http.client.PARTIAL_CONTENT - - data = json.loads(content.decode('utf-8')) - - assert len(data) == 11 - assert data[0]['question'] == 'What is your biggest pet peeve?' - - def test_get_single(self): - response, content = self.client.request( - path='/api/poll/', - headers={ - 'range': 'items=10'}) - - assert response.status == http.client.PARTIAL_CONTENT - - data = json.loads(content.decode('utf-8')) - - assert len(data) == 1 - assert data[0]['question'] == 'What is your biggest pet peeve?' diff --git a/tests/connectors/test_options.py b/tests/connectors/test_options.py deleted file mode 100644 index f69f04f..0000000 --- a/tests/connectors/test_options.py +++ /dev/null @@ -1,120 +0,0 @@ -# -*- coding: utf-8 -*- -from armet import http -from .base import BaseResourceTest - - -class TestResourceOptions(BaseResourceTest): - - @classmethod - def setup_class(cls): - super(TestResourceOptions, cls).setup_class() - cls.right_path = '/api/left/' - cls.left_path = '/api/right/' - cls.right_origin = 'http://127.0.0.1:80' - cls.left_origin = 'http://10.0.0.1' - cls.default_method = 'GET' - - def test_origin_header(self, connectors): - # Check an endpoint with a specific origin. - # Try with no Origin request. - # Should just send back 200. - response, _ = self.client.options(self.right_path) - - assert response.status == http.client.OK - - # Try with Origin not in the list of allowed origins. - response, _ = self.client.options( - self.right_path, - headers={'Origin': self.left_origin}) - - assert response.status == http.client.OK - - # Try with the correct Origin. - response, _ = self.client.options( - self.right_path, - headers={'Origin': self.right_origin}) - - assert response.status == http.client.OK - - # Check an endpoint which allows all origins. - # Try with no Origin request. - # Should just send back 200. - response, _ = self.client.options(self.left_path) - - assert response.status == http.client.OK - - # Try with any random origin. - response, _ = self.client.options( - self.left_path, - headers={'Origin': self.left_origin}) - - assert response.status == http.client.OK - - def access_control_headers(self, header): - """ - Test each Access-Control-Allow header here, since they - all do the same thing. - """ - - # Check an endpoint that has a specific origin. - # Check with no origin. - response, _ = self.client.options(self.right_path) - - # Should give back a 200 and no Allow-Headers header. - assert response.status == http.client.OK - assert header not in response - - # Set the origin to the correct place. - response, _ = self.client.options( - self.right_path, - headers={ - 'Origin': self.right_origin, - 'Access-Control-Request-Method': self.default_method}) - - # Should give back a 200 and an Allow-Headers header. - assert response.status == http.client.OK - assert header not in response - - # Do it again with asterisk. - response, _ = self.client.options( - self.right_path, headers={'Origin': '*'}) - - # Should give back a 200 and no Allow-Headers header. - assert response.status == http.client.OK - assert header not in response - - # TODO: Should probably check that we're getting the - # correct methods back. - - # Now try with an enpoint that allows any origin. - # Check with no origin. - response, _ = self.client.options(self.left_path) - - # Should give back a 200 and no Allow-Headers header. - assert response.status == http.client.OK - assert header not in response - - # Set the origin to the correct place. - response, _ = self.client.options( - self.left_path, - headers={ - 'Origin': self.left_origin, - 'Access-Control-Request-Method': self.default_method}) - - # Should give back a 200 and no Allow-Headers header. - assert response.status == http.client.OK - assert header not in response - - # Should probably check that we're getting the correct methods back. - - def test_access_control_allow_headers(self, connectors): - """Test the Access-Control-Allow-Headers header.""" - self.access_control_headers('Access-Control-Allow-Headers') - - def test_access_control_allow_methods(self, connectors): - """Test the Access-Control-Allow-Methods header.""" - self.access_control_headers('Access-Control-Allow-Methods') - - def test_access_control_allow_origin(self, connectors): - """Test the Access-Control-Allow-Origin header.""" - self.access_control_headers('Access-Control-Allow-Origin') diff --git a/tests/connectors/test_post.py b/tests/connectors/test_post.py deleted file mode 100644 index 91d6d4a..0000000 --- a/tests/connectors/test_post.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import json -from armet import http, serializers, deserializers -from .base import BaseResourceTest - - -class TestResourcePost(BaseResourceTest): - - def test_post_single(self, connectors): - data = {'question': 'Is anybody really out there?'} - body = json.dumps(data) - response, content = self.client.post( - path='/api/poll/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.CREATED - - data = json.loads(content.decode('utf8')) - - assert isinstance(data, dict) - assert data['question'] == 'Is anybody really out there?' - assert data['id'] == 101 - - -class TestResourceEcho(BaseResourceTest): - - @classmethod - def setup_class(cls): - super(TestResourceEcho, cls).setup_class() - - cls.body = {'x': ['1', '2', '3'], 'y': ['4', '5']} - - cls.serializers = {} - cls.serializers['json'] = serializers.JSONSerializer() - cls.serializers['url'] = serializers.URLSerializer() - - cls.deserializers = {} - cls.deserializers['json'] = deserializers.JSONDeserializer() - cls.deserializers['url'] = deserializers.URLDeserializer() - - def echo(self, in_format, out_format): - body = self.serializers[in_format].serialize(self.body) - response, content = self.client.post( - path='/api/echo/', body=body, - headers={ - 'Content-Type': self.serializers[in_format].media_types[0], - 'Accept': self.serializers[out_format].media_types[0]}) - - assert response.status == http.client.OK - - data = self.deserializers[out_format].deserialize(text=content) - - assert data == self.body - - def test_echo_json_json(self, connectors): - self.echo('json', 'json') - - def test_echo_json_url(self, connectors): - self.echo('json', 'url') - - def test_echo_url_url(self, connectors): - self.echo('url', 'url') - - def test_echo_url_json(self, connectors): - self.echo('url', 'json') diff --git a/tests/connectors/test_put.py b/tests/connectors/test_put.py deleted file mode 100644 index e8311f0..0000000 --- a/tests/connectors/test_put.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import json -from armet import http -from .base import BaseResourceTest - - -class TestResourcePut(BaseResourceTest): - - def test_put_existing(self, connectors): - data = {'id': 1, 'question': 'Is anybody really out there?'} - body = json.dumps(data) - response, content = self.client.put( - path='/api/poll/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert isinstance(data, dict) - assert data['question'] == 'Is anybody really out there?' - assert data['id'] == 1 - - def test_put_nonexistant(self): - request_data = {'id': 101, 'question': 'Is there anybody home?'} - body = json.dumps(request_data) - response, content = self.client.put( - path='/api/poll/1/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.OK - - data = json.loads(content.decode('utf8')) - - assert isinstance(data, dict) - assert set(request_data.items()) <= set(data.items()) diff --git a/tests/connectors/test_resource.py b/tests/connectors/test_resource.py deleted file mode 100644 index 2f5668a..0000000 --- a/tests/connectors/test_resource.py +++ /dev/null @@ -1,339 +0,0 @@ -# -*- coding: utf-8 -*- -import armet -from armet import exceptions, resources -from .base import BaseResourceTest -import pytest -import json - - -class TestResource(BaseResourceTest): - - def test_modelless_model_resource(self, connectors): - with pytest.raises(exceptions.ImproperlyConfigured): - class Resource(resources.ModelResource): - pass - - def test_connectorless_resource(self, connectors): - # Unset configuration. - old = armet.use.config - armet.use.config = {} - - with pytest.raises(exceptions.ImproperlyConfigured): - class Resource(resources.Resource): - pass - - # Reset the configuration - armet.use.config = old - - def test_connectorless_abstract_resource(self, connectors): - # Unset configuration. - old = armet.use.config - armet.use.config = {} - - class Resource(resources.Resource): - class Meta: - abstract = True - - assert Resource.meta.abstract - - # Reset the configuration - armet.use.config = old - - -class TestAllowed(BaseResourceTest): - - def test_http_allowed_methods(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - http_allowed_methods = 'GET', - - meta = Resource.meta - - assert 'GET' in meta.http_allowed_methods - assert 'GET' in meta.http_list_allowed_methods - assert 'GET' in meta.http_detail_allowed_methods - - def test_http_allowed_methods_to_operations(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - http_allowed_methods = 'GET', - - meta = Resource.meta - - assert 'GET' in meta.http_allowed_methods - assert 'GET' in meta.http_list_allowed_methods - assert 'GET' in meta.http_detail_allowed_methods - - assert 'read' in meta.list_allowed_operations - assert 'read' in meta.detail_allowed_operations - assert 'read' in meta.allowed_operations - - def test_allowed_operations(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - allowed_operations = 'read', - - meta = Resource.meta - - assert 'read' in meta.allowed_operations - assert 'read' in meta.list_allowed_operations - assert 'read' in meta.detail_allowed_operations - - def test_allowed_operations_to_methods(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - allowed_operations = 'read', - - meta = Resource.meta - - assert 'GET' in meta.http_allowed_methods - assert 'GET' in meta.http_list_allowed_methods - assert 'GET' in meta.http_detail_allowed_methods - - assert 'read' in meta.list_allowed_operations - assert 'read' in meta.detail_allowed_operations - assert 'read' in meta.allowed_operations - - def test_allowed_methods_list(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - http_allowed_methods = 'GET', - - http_list_allowed_methods = 'PUT', - - meta = Resource.meta - - assert 'GET' in meta.http_allowed_methods - assert 'GET' not in meta.http_list_allowed_methods - assert 'PUT' in meta.http_list_allowed_methods - assert 'GET' in meta.http_detail_allowed_methods - - def test_allowed_methods_list_to_operations(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - http_allowed_methods = 'GET', - - http_list_allowed_methods = 'PUT', - - meta = Resource.meta - - assert 'read' in meta.allowed_operations - assert 'read' not in meta.list_allowed_operations - assert 'update' in meta.list_allowed_operations - assert 'read' in meta.detail_allowed_operations - - assert 'GET' in meta.http_allowed_methods - assert 'GET' not in meta.http_list_allowed_methods - assert 'PUT' in meta.http_list_allowed_methods - assert 'GET' in meta.http_detail_allowed_methods - - def test_allowed_operations_list(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - allowed_operations = 'read', - - list_allowed_operations = 'update', - - meta = Resource.meta - - assert 'read' in meta.allowed_operations - assert 'read' not in meta.list_allowed_operations - assert 'update' in meta.list_allowed_operations - assert 'read' in meta.detail_allowed_operations - - def test_allowed_operations_list_to_methods(self): - - class Resource(resources.ModelResource): - - class Meta: - abstract = True - - allowed_operations = 'read', - - list_allowed_operations = 'update', - - meta = Resource.meta - - assert 'read' in meta.allowed_operations - assert 'read' not in meta.list_allowed_operations - assert 'update' in meta.list_allowed_operations - assert 'read' in meta.detail_allowed_operations - - assert 'GET' in meta.http_allowed_methods - assert 'GET' not in meta.http_list_allowed_methods - assert 'PUT' in meta.http_list_allowed_methods - assert 'GET' in meta.http_detail_allowed_methods - - -class TestResolution(BaseResourceTest): - - def test_super_direct_resource(self, connectors): - response, content = self.client.get('/api/direct/') - - assert response.status == 200 - assert content.decode('utf8') == '42' - - def test_super_direct_model_resource(self, connectors): - response, content = self.client.get('/api/model-direct/') - - assert response.status == 200 - - data = json.loads(content.decode('utf8')) - - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_super_indirect_resource(self, connectors): - response, content = self.client.get('/api/indirect/') - - assert response.status == 200 - assert content.decode('utf8') == '84' - - def test_super_indirect_model_resource(self, connectors): - response, content = self.client.get('/api/model-indirect/') - - assert response.status == 200 - - data = json.loads(content.decode('utf8')) - - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_super_twice_indirect_resource(self, connectors): - response, content = self.client.get('/api/twice-indirect/') - - assert response.status == 200 - assert content.decode('utf8') == '84' - - def test_super_twice_indirect_model_resource(self, connectors): - response, content = self.client.get('/api/model-twice-indirect/') - - assert response.status == 200 - - data = json.loads(content.decode('utf8')) - - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_super_thrice_indirect_resource(self, connectors): - response, content = self.client.get('/api/thrice-indirect/') - - assert response.status == 200 - assert content.decode('utf8') == '84' - - def test_super_thrice_indirect_model_resource(self, connectors): - response, content = self.client.get('/api/model-thrice-indirect/') - - assert response.status == 200 - - data = json.loads(content.decode('utf8')) - - assert data[0]['question'] == 'Are you an innie or an outie?' - - def test_mixin_resource(self, connectors): - response, content = self.client.get('/api/mixin/') - - assert response.status == 200 - assert content.decode('utf8') == 'Hello' - - -class TestConnectorResolution(BaseResourceTest): - - def test_direct_resource(self, connectors): - response, content = self.client.get('/api/direct-connector/') - - assert response.status == 205 - assert content.decode('utf8') == 'Hello World\n' - - def test_indirect_resource(self, connectors): - response, content = self.client.get('/api/indirect-connector/') - - assert response.status == 205 - assert content.decode('utf8') == 'Hello World\n' - - def test_twice_indirect_resource(self, connectors): - response, content = self.client.get('/api/twice-indirect-connector/') - - assert response.status == 205 - assert content.decode('utf8') == 'Hello World\n' - - def test_thrice_indirect_resource(self, connectors): - response, content = self.client.get('/api/thrice-indirect-connector/') - - assert response.status == 205 - assert content.decode('utf8') == 'Hello World\n' - - def test_direct_model_resource(self, connectors): - response, content = self.client.get('/api/direct-model-connector/') - - assert response.status == 205 - assert json.loads(content.decode('utf8')) == ['Hello', 'World'] - - def test_indirect_model_resource(self, connectors): - response, content = self.client.get('/api/indirect-model-connector/') - - assert response.status == 205 - assert json.loads(content.decode('utf8')) == ['Hello', 'World'] - - def test_twice_indirect_model_resource(self, connectors): - response, content = self.client.get( - '/api/twice-indirect-model-connector/') - - assert response.status == 205 - assert json.loads(content.decode('utf8')) == ['Hello', 'World'] - - def test_thrice_indirect_model_resource(self, connectors): - response, content = self.client.get( - '/api/thrice-indirect-model-connector/') - - assert response.status == 205 - assert json.loads(content.decode('utf8')) == ['Hello', 'World'] - - def test_direct_model_connector_mixin(self, connectors): - path = '/api/direct-model-connector-mixin/10/' - response, content = self.client.delete(path) - - assert response.status == 402 - - def test_indirect_model_connector_mixin(self, connectors): - path = '/api/indirect-model-connector-mixin/10/' - response, content = self.client.delete(path) - - assert response.status == 402 - - def test_twice_indirect_model_connector_mixin(self, connectors): - path = '/api/twice-indirect-model-connector-mixin/10/' - response, content = self.client.delete(path) - - assert response.status == 402 - - def test_thrice_indirect_model_connector_mixin(self, connectors): - path = '/api/thrice-indirect-model-connector-mixin/10/' - response, content = self.client.delete(path) - - assert response.status == 402 diff --git a/tests/connectors/test_validation.py b/tests/connectors/test_validation.py deleted file mode 100644 index f17f5cc..0000000 --- a/tests/connectors/test_validation.py +++ /dev/null @@ -1,53 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import json -from armet import http -from .base import BaseResourceTest - - -class TestResourceValidation(BaseResourceTest): - - def test_post_too_low(self, connectors): - data = {'votes': -14} - body = json.dumps(data) - response, content = self.client.post( - path='/api/poll-valid/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.BAD_REQUEST - - data = json.loads(content.decode('utf8')) - - assert 'votes' in data - assert data['votes'] == ['Must be greater than 0.'] - - def test_post_too_high(self, connectors): - data = {'votes': 54} - body = json.dumps(data) - response, content = self.client.post( - path='/api/poll-valid/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.BAD_REQUEST - - data = json.loads(content.decode('utf8')) - - assert 'votes' in data - assert data['votes'] == ['Must be less than 51.'] - - def test_post_lots_wrong(self, connectors): - data = {'votes': 0, 'question': 'This'} - body = json.dumps(data) - response, content = self.client.post( - path='/api/poll-valid/', body=body, - headers={'Content-Type': 'application/json'}) - - assert response.status == http.client.BAD_REQUEST - - data = json.loads(content.decode('utf8')) - - assert 'votes' in data - assert 'question' in data - assert data['votes'][0] == 'Must be greater than 0.' - assert data['question'][0] == 'Must be more than 15 characters.' - assert data['question'][1] == 'Must have at least one question mark.' diff --git a/tests/connectors/utils.py b/tests/connectors/utils.py deleted file mode 100644 index cbaba64..0000000 --- a/tests/connectors/utils.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import sys -from six.moves import reload_module -from importlib import import_module - - -def force_import_module(name): - if name in sys.modules: - return reload_module(sys.modules[name]) - - else: - return import_module(name) - - -def unload_module(name): - if name in sys.modules: - del sys.modules[name] diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..2ee1a2e --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,181 @@ +from pytest import mark +from unittest import mock +from armet.resources import Resource +from armet.api import Api + + +class TestAPI: + + @mark.bench("http.app.register") + def test_register_name_with_resource_attribute(self, http): + # Create an example resource to register in the API + class FooResource: + name = "bar" + + resource = FooResource + + http.app.register(resource) + + assert http.app._registry.find(name='bar')[0] is resource + + @mark.bench("http.app.register") + def test_register_name_with_class_name(self, http): + class FooResource: + pass + + resource = FooResource + + http.app.register(resource) + + assert http.app._registry.find(name='foo')[0] is resource + + @mark.bench("http.app.register") + def test_register_name_with_kwargs(self, http): + class FooResource: + pass + + resource = FooResource + + http.app.register(resource, name="bar") + + assert http.app._registry.find(name='bar')[0] is resource + + @mark.bench("http.app.__call__") + def test_40x_exception_debug(self, http): + + response = http.get('/unknown-resource') + + assert response.status_code == 404 + + @mark.bench("http.app.__call__") + def test_internal_server_error(self, http): + + class TestResource(Resource): + + def read(self): + raise Exception("This test raises an exception, and" + " prints to the console.") + + http.app.register(TestResource, name="test") + + http.app.debug = True + + with mock.patch('traceback.print_exc') as mocked: + response = http.get('/test') + assert mocked.called + + assert response.status_code == 500 + + @mark.bench("http.app.__call__") + def test_redirect_get(self, http): + response = http.get('/get/') + assert response.status_code == 301 + assert response.headers["Location"].endswith("/get") + + @mark.bench("http.app.__call__") + def test_redirect_get_inverse(self, http): + http.app.trailing_slash = True + + response = http.get('/get/') + assert response.status_code == 404 + + response = http.get('/get') + assert response.status_code == 301 + + @mark.bench("http.app.__call__") + def test_redirect_post(self, http): + response = http.post('/post/') + assert response.status_code == 307 + assert response.headers["Location"].endswith("/post") + + @mark.bench("http.app.__call__") + def test_no_content(self, http): + + class TestResource(Resource): + + def read(self): + return None + + http.app.register(TestResource, name="test") + + response = http.get('/test') + + assert response.status_code == 204 + + @mark.bench("http.app.__call__") + def test_route(self, http): + + class TestResource(Resource): + + relationships = {"test2"} + + def prepare(self, item): + return item + + def read(self): + return "data" + + class Test2Resource(Resource): + + def prepare(self, item): + return item + + def read(self): + return "data2" + + http.app.register(TestResource, name="test") + http.app.register(Test2Resource, name="test2") + + response = http.get('/test/1/test2') + + assert response.status_code == 200 + assert response.data == b'["data2"]' + + def test_add_subapi(self, http): + + class PersonalApi(Api): + pass + + class SubResource(Resource): + attributes = {'success'} + + def read(self): + return [{'success': True}] + + # Assert that all methods of accessing an api via name work. + apis = [ + (Api(), {'name': 'test'}), + (Api(name='new_test'), {}), + (PersonalApi(), {}), + ] + + for api, kwargs in apis: + api.register(SubResource, name='endpoint') + http.app.register(api, **kwargs) + + assert http.get('/test/endpoint').status_code == 200 + assert http.get('/new_test/endpoint').status_code == 200 + assert http.get('/personal/endpoint').status_code == 200 + + def test_multiname_route_with_invalid_resource(self, http): + """Test that we indeed get a 404 on a request with + 2+ "names", i.e /name/slug/name""" + + response = http.get('/name/slug/name') + + assert response.status_code == 404 + + def test_get_on_root(self, http): + response = http.get('/') + + assert response.status_code == 404 + + def test_method_not_allowed(self, http): + class FooResource(Resource): + pass + + http.app.register(FooResource) + + response = http.request('/foo', method="GARBAGE") + + assert response.status_code == 405 diff --git a/tests/test_decoders.py b/tests/test_decoders.py new file mode 100644 index 0000000..867580a --- /dev/null +++ b/tests/test_decoders.py @@ -0,0 +1,89 @@ +from armet import decoders +import pytest +from pytest import mark +import json + + +def test_decoders_api_methods(): + assert decoders.find + assert decoders.register + assert decoders.remove + + +@mark.bench('self.decode', iterations=10000) +class TestURLDecoder: + + def setup(self): + self.decode, _ = decoders.find(name='url') + + def test_decode_normal(self): + data = 'foo=bar&bar=baz&fiz=buzz' + + expected = { + 'foo': 'bar', + 'bar': 'baz', + 'fiz': 'buzz'} + + assert self.decode(data) == expected + + def test_decode_object(self): + with pytest.raises(TypeError): + self.decode([{'foo': 'bar'}]) + + +@mark.bench('self.decode', iterations=10000) +class TestJSONDecoder: + + def setup(self): + self.decode, _ = decoders.find(name='json') + + def test_decode_normal(self): + data = { + 'foo': 5, + 'bar': None, + 'baz': ['a', 'b', 'c'], + 'bang': {'buzz': 'boop'}} + + assert self.decode(json.dumps(data)) == data + + def test_decode_failure(self): + with pytest.raises(TypeError): + self.decode('fail') + + +@mark.bench('self.decode', iterations=10000) +class TestFormDecoder: + + def setup(self): + self.decode, _ = decoders.find(name='form') + + def test_decode_normal(self): + boundary = 'abc123' + + data = ( + b'--abc123\r\n' + b'Content-Disposition: form-data; name=foo\r\n' + b'\r\n' + b'bar\r\n' + b'--abc123\r\n' + b'Content-Disposition: form-data; name=bar\r\n' + b'\r\n' + b'baz\r\n' + b'--abc123\r\n' + b'Content-Disposition: form-data; name=fiz\r\n' + b'\r\n' + b'buzz\r\n' + b'--abc123\r\n' + b'Content-Disposition: form-data; name=fiz\r\n' + b'\r\n' + b'bang\r\n' + b'--abc123--' + ) + + expected = { + 'foo': 'bar', + 'bar': 'baz', + 'fiz': ['buzz', 'bang'] + } + + assert self.decode(data, boundary=boundary) == expected diff --git a/tests/test_deserializers.py b/tests/test_deserializers.py deleted file mode 100644 index 333c8d7..0000000 --- a/tests/test_deserializers.py +++ /dev/null @@ -1,100 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import unittest -import uuid -from armet import deserializers -import ujson as json -from pytest import mark - - -class DeserializerTestCase(unittest.TestCase): - - Deserializer = None - - @classmethod - def setup_class(cls): - cls.deserializer = cls.Deserializer() - - def deserialize(self, text): - self.data = self.deserializer.deserialize(text=text) - - -@mark.bench('self.deserialize', iterations=10000) -class JSONDeserializerTestCase(DeserializerTestCase): - - Deserializer = deserializers.JSONDeserializer - - def test_scalar(self): - self.assertRaises(ValueError, self.deserialize, 124) - - def test_number(self): - self.deserialize(b'[42]') - - assert self.data == [42] - - def test_boolean(self): - self.deserialize(b'[true]') - - assert self.data == [True] - - self.deserialize(b'[false]') - - assert self.data == [False] - - def test_array(self): - self.deserialize(b'[1, 2, 3]') - - assert self.data == [1, 2, 3] - - def test_dict(self): - self.deserialize(b'{"x": 2, "y": "bob"}') - - assert self.data == {"x": 2, "y": "bob"} - - def test_dict_array(self): - self.deserialize(b'{"x": [1, 2], "y": "bob"}') - - assert self.data == {"x": [1, 2], "y": "bob"} - - def test_large(self): - payload_item = { - 'organization': uuid.uuid4().hex, - 'user': uuid.uuid4().hex, - 'id': uuid.uuid4().hex, - 'is_active': False, - 'is_pending': True, - # 'members': [ - # { - # 'id': uuid.uuid4().hex, - # 'organization': uuid.uuid4().hex, - # 'is_dead': False - # } - # ], - } - - payload = [payload_item] * 100 - - text = json.dumps(payload, ensure_ascii=False) - - self.deserialize(text.encode('utf8')) - - assert self.data == payload - - -@mark.bench('self.deserialize', iterations=10000) -class URLDeserializerTestCase(DeserializerTestCase): - - Deserializer = deserializers.URLDeserializer - - def test_scalar(self): - self.assertRaises(ValueError, self.deserialize, 124) - - def test_sequence(self): - self.deserialize(b'x=2&y=51') - - assert self.data == {'x': ['2'], 'y': ['51']} - - def test_multi_sequence(self): - self.deserialize(b'x=2&y=51&x=781&y=165') - - assert self.data == {"x": ['2', '781'], "y": ['51', '165']} diff --git a/tests/test_encoders.py b/tests/test_encoders.py new file mode 100644 index 0000000..3a6eb19 --- /dev/null +++ b/tests/test_encoders.py @@ -0,0 +1,159 @@ +from armet import encoders +import json +from collections import OrderedDict +import pytest +from pytest import mark +from unittest import mock +from functools import reduce +import operator + + +def test_encoders_api_methods(): + assert encoders.find + assert encoders.register + assert encoders.remove + + +@pytest.fixture(scope='function') +def registry(): + return encoders.CodecRegistry() + + +@pytest.fixture(scope='function') +def example_encoders(registry): + # Create some example objects that can operate as encoders. This works + # Becuase the registry is just in charge of registering and returning + # things (no introspection) + example = type('example', (), {}) + counter = type('example', (), {}) + + registry.register( + name=['test', 'example'], + mime_type=['application/octet-stream', 'test/test'])(example) + registry.register( + mime_type=['application/xbel+xml', 'example/xml'])(counter) + + return example, counter + + +def test_lookup_by_media_range(registry, example_encoders): + example, counter = example_encoders + + mime = 'example/*;q=0.5,*/*; q=0.1' + assert registry.find(media_range=mime)[0] == counter + + mime = 'test/*;q=0.5,*/*; q=0.1' + assert registry.find(media_range=mime)[0] == example + + +@pytest.mark.usefixtures('example_encoders') +def test_malformed_media_range(registry): + with pytest.raises(KeyError): + registry.find(media_range='asdf') + + +class BaseEncoderTest: + + def encode(self, data): + """Simple helper that makes encoder checking nicer.""" + encoded = reduce(operator.add, self.encoder(data, 'utf-8')) + return encoded.decode('utf-8') + + +@mark.bench('self.encoder', iterations=10000) +class TestURLEncoder(BaseEncoderTest): + + def setup(self): + self.encoder, _ = encoders.find(name='url') + + def test_encode_normal(self): + data = OrderedDict(( + ('foo', 'bar'), + ('bar', 'baz'), + ('fiz', 'buzz'))) + + expected = 'foo=bar&bar=baz&fiz=buzz' + + assert self.encode(data) == expected + + def test_unable_to_encode(self): + with pytest.raises(TypeError): + self.encode([{'foo': 'bar'}]) + + +@mark.bench('self.encoder', iterations=10000) +class TestJSONEncoder(BaseEncoderTest): + + def setup(self): + self.encoder, _ = encoders.find(name='json') + + def test_encode_scalar(self): + data = False + + assert self.encode(data) == "[false]" + + def test_encode_null(self): + data = None + + assert self.encode(data) == "[null]" + + def test_encode_large_simple_list(self): + data = list(range(1, 10000)) + + text = self.encode(data) + assert json.loads(text) == data + + def test_encode_normal(self): + data = { + 'foo': 5, + 'bar': None, + 'baz': ['a', 'b', 'c'], + 'bang': {'buzz': 'boop'}} + + assert json.loads(self.encode(data)) == data + + +@mark.bench('self.encoder', iterations=10000) +class TestFormEncoder(BaseEncoderTest): + + def setup(self): + self.encoder, _ = encoders.find(name='form') + + def test_encode_normal(self): + with mock.patch('armet.encoders.form.generate_boundary') as mocked: + # Assert that the mocked function always returns the same value. + mocked.return_value = 'abc123' + + data = OrderedDict(( + ('foo', 'bar'), + ('bar', 'baz'), + ('fiz', ['buzz', 'bang']))) + + expected = ( + '--abc123\r\n' + 'Content-Disposition: form-data; name=foo\r\n' + '\r\n' + 'bar\r\n' + '--abc123\r\n' + 'Content-Disposition: form-data; name=bar\r\n' + '\r\n' + 'baz\r\n' + '--abc123\r\n' + 'Content-Disposition: form-data; name=fiz\r\n' + '\r\n' + 'buzz\r\n' + '--abc123\r\n' + 'Content-Disposition: form-data; name=fiz\r\n' + '\r\n' + 'bang\r\n' + '--abc123--' + ) + + assert self.encode(data) == expected + + def test_encode_failure(self): + with pytest.raises(TypeError): + self.encode([{'a': 'b'}]) + + with pytest.raises(TypeError): + self.encode({'a': {'b': 'c'}}) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..a941a01 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,31 @@ +from armet.http import exceptions +from armet.resources import Resource as BaseResource + + +class TestExceptions: + + def test_exception_contains_error_in_response(self, http): + class ThrowingResource(BaseResource): + def read(self): + raise exceptions.BadRequest({'test': 'testing'}) + + http.app.register(ThrowingResource, name='error') + + response = http.get('/error') + + assert response.status_code == 400 + assert response.json == {'test': 'testing'} + assert response.headers['Content-Type'] == 'application/json' + + def test_500_error_has_no_content(self, http): + class TypeErrorResource(BaseResource): + def read(self): + raise TypeError + http.app.register(TypeErrorResource, name='error') + return TypeErrorResource + + response = http.get('/error') + + assert response.status_code == 500 + assert response.headers['Content-Type'] == 'text/plain' + assert len(response.data) == 0 diff --git a/tests/test_parse.py b/tests/test_parse.py deleted file mode 100644 index 811e9b3..0000000 --- a/tests/test_parse.py +++ /dev/null @@ -1,175 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import six -import unittest -from armet import resources -from pytest import mark - - -# @mark.bench('self.Resource.parse', iterations=10000) -@mark.bench('self.request', iterations=10000) -class ParseTestCase(unittest.TestCase): - - def setUp(self): - super(ParseTestCase, self).setUp() - - # Construct a resource. - class Resource(resources.ManagedResource): - class Meta: - abstract = True - - # Store the resource - self.Resource = Resource - - def request(self, url): - # Execute the parse method. - _, arguments, _ = self.Resource.parse(url) - - # Assert that we got enough of them. - assert 'slug' in arguments - assert 'query' in arguments - assert 'extensions' in arguments - assert 'directives' in arguments - assert 'path' in arguments - - # Return the arguments - return arguments - - def assert_arguments(self, arguments, **kwargs): - for argument in kwargs: - value = arguments.pop(argument) - assert value == kwargs[argument] - - for name, value in six.iteritems(arguments): - test = [] if name.endswith('s') else None - assert value == test - - def test_simple(self): - arguments = self.request('/') - - self.assert_arguments(arguments) - - def test_slug(self): - arguments = self.request('/123/') - - self.assert_arguments(arguments, slug='123') - - def test_slug_long(self): - arguments = self.request('/sdf-sdg-sgh-sh234-bf/') - - self.assert_arguments(arguments, slug='sdf-sdg-sgh-sh234-bf') - - def test_query(self): - arguments = self.request('(stuff=32)/') - - self.assert_arguments(arguments, query='stuff=32') - - def test_query_long(self): - query = 'x=32&y=61;z=135,15&x=16;!(h=134&b=324)' - arguments = self.request('({})/'.format(query)) - - self.assert_arguments(arguments, query=query) - - def test_query_directives(self): - query = 'x:asc=32&y:desc=61;z:-=135,15&x:descending=16;!(h=134&b=324)' - arguments = self.request('({})/'.format(query)) - - self.assert_arguments(arguments, query=query) - - def test_directive(self): - arguments = self.request(':random/') - - self.assert_arguments(arguments, directives=['random']) - - def test_directives(self): - directives = ['random', 'rand', 'other', 'weird', 'wan'] - url = ':{}/'.format(':'.join(directives)) - arguments = self.request(url) - - self.assert_arguments(arguments, directives=directives) - - def test_directives_query(self): - directives = ['random', 'rand', 'other', 'weird', 'wan'] - url = ':{}(x=4&y=234&z=34)/'.format(':'.join(directives)) - arguments = self.request(url) - - self.assert_arguments( - arguments, directives=directives, query='x=4&y=234&z=34') - - def test_path(self): - path = 'this/is/a/path/to/somewhere' - arguments = self.request('/124/{}/'.format(path)) - - self.assert_arguments(arguments, slug='124', path=path) - - def test_query_path(self): - query = 'x=34&t:desc=324&(x=234&x:asc=2134)' - path = 'this/is/a(x=324)/path(y=3124&x=23)/to/somewhere' - arguments = self.request('({})/555/{}/'.format( - query, path)) - - self.assert_arguments(arguments, slug='555', query=query, path=path) - - def test_path_query(self): - path = 'this/is/a(x=324)/path(y=3124&x=23)/to/somewhere' - arguments = self.request('/124/{}/'.format(path)) - - self.assert_arguments(arguments, slug='124', path=path) - - def test_extension(self): - ext = 'json' - arguments = self.request('.{}/'.format(ext)) - - self.assert_arguments(arguments, extensions=[ext]) - - def test_extensions(self): - exts = ['schema', 'json'] - arguments = self.request('.{}/'.format('.'.join(exts))) - - self.assert_arguments(arguments, extensions=exts) - - def test_extensions_slug(self): - exts = ['schema', 'json'] - arguments = self.request('/234.{}/'.format('.'.join(exts))) - - self.assert_arguments(arguments, slug='234', extensions=exts) - - def test_extensions_path(self): - exts = ['schema', 'json'] - url = '/234/from/this/to/that.{}/'.format('.'.join(exts)) - arguments = self.request(url) - - self.assert_arguments( - arguments, slug='234', extensions=exts, - path='from/this/to/that') - - def test_all(self): - extensions = ['schema', 'json'] - directives = ['random', 'rand', 'okay', 'because'] - slug = '2342fsdg8920sd-235-f-g325-sg23-532' - path = '/from/this(x=235)/to:random/that(because)/and/this' - query = 'x=345&xv=32;!(x=34)' - url = ':{}({})/{}/{}.{}/'.format( - ':'.join(directives), - query, - slug, - path, - '.'.join(extensions)) - arguments = self.request(url) - - self.assert_arguments( - arguments, - slug=slug, - extensions=extensions, - directives=directives, - path=path, - query=query) - - def test_extension_path(self): - url = '/23/from/this.hub/32/from.hub/32/' - arguments = self.request(url) - - self.assert_arguments( - arguments, - slug='23', - path='from/this.hub/32/from.hub/32') diff --git a/tests/test_query.py b/tests/test_query.py deleted file mode 100644 index 853aa50..0000000 --- a/tests/test_query.py +++ /dev/null @@ -1,105 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import unittest -import six -from armet.query import parser, constants -from pytest import mark - - -@mark.bench('parser.parse', iterations=10000) -class QueryTestCase(unittest.TestCase): - - def parse(self, text): - """Simple convenience function to unwrap the array of parameters.""" - return parser.parse(text).parsed - - def test_empty(self): - """Test an empty query string.""" - item = self.parse('') - - assert isinstance(item, parser.NoopQuerySegment) - - def test_simple_filter(self): - item = self.parse('foo=bar') - - assert item.path == ['foo'] - assert item.operator, constants.OPERATOR_IEQUAL[0] - assert not item.negated - assert item.values == ['bar'] - - def test_binary(self): - item = self.parse(b'foo=bar') - - assert item.path == ['foo'] - assert item.values == ['bar'] - - def test_negation(self): - queries = ['foo!=bar', 'foo.not=bar'] - for query in queries: - item = self.parse(query) - - assert item.negated - - def test_relational_filter(self): - item = self.parse('bread.sticks=delicious') - - assert item.path == ['bread', 'sticks'] - assert item.operator, constants.OPERATOR_IEQUAL[0] - assert not item.negated - assert item.values == ['delicious'] - - def test_values(self): - item = self.parse('fruit=apples,oranges') - - assert item.path == ['fruit'] - assert item.operator, constants.OPERATOR_IEQUAL[0] - assert not item.negated - assert item.values == ['apples', 'oranges'] - - def test_bogus(self): - """Test some bogusy query strings.""" - queries = [ - 'foo:asc&;bar:desc', - 'foo.lte<=3', - 'foo.!negate', - 'lte=3', - ] - for query in queries: - self.assertRaises(ValueError, self.parse, query) - - def test_operations(self): - for name, symbol in constants.OPERATORS: - item = self.parse('crazy.{}=true'.format(name)) - - assert item.path == ['crazy'] - assert item.operator == constants.OPERATOR_SUFFIX_MAP[name] - assert not item.negated - assert item.values == ['true'] - - if symbol is not None: - item = self.parse('crazy{}true'.format(symbol)) - - assert item.path == ['crazy'] - assert item.operator == constants.OPERATOR_EQUALITY_MAP[symbol] - assert not item.negated - assert item.values == ['true'] - - def test_fusion(self): - """Test something from everything combined""" - q = ('the.rolling.stones.iregex.not:asc=sympathy,for,the,devil;' - '!(guns.n.roses=paradise,city&queen:asc)') - - item = self.parse(q) - - # I'm lazy and don't want to walk the tree, so lets just test the repr. - assert (six.text_type(repr(item)) == - "(the.rolling.stones.iregex.not:asc :iexact 'sympathy' " - "| 'for' | 'the' | 'devil') OR NOT (guns.n.roses :iexact " - "'paradise' | 'city') AND (queen :iexact '')") - - def test_grouping(self): - item = self.parse('foo=bar&(a=b;b=c)') - - assert item.left.path == ['foo'] - assert item.right.left.path == ['a'] - assert item.right.right.path == ['b'] diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..142a9e4 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,68 @@ +from armet.registry import Registry +import pytest + + +class TestRegistry: + + def setup(self): + self.registry = Registry() + for x in range(5): + self.registry.register("obj_%d" % x, name="test_%d" % x) + self.registry.register("obj_%d" % x, name2="test_%d" % x) + + def test_register_decorator(self): + @self.registry.register(name="test_12") + def test_registry_decorator(): + pass + + assert self.registry.find(name="test_12")[0] is test_registry_decorator + + def test_register_none(self): + with pytest.raises(TypeError): + self.registry.register(name="not_valid")(None) + + def test_find_multiple(self): + with pytest.raises(TypeError): + self.registry.find(name="this", multiple_kwargs="not_valid") + + def test_find_from_fallback(self): + # Test the not-found exception in find, raises key-error, returns None. + fallback = Registry() + fallback.register("fallback_obj", name="fallback") + self.registry.fallback = fallback + + assert self.registry.find(name="fallback")[0] == "fallback_obj" + + def test_remove_object(self): + # Test removing with just the object + self.registry.remove("obj_1") + with pytest.raises(KeyError): + assert self.registry.find(name="test_1") + + with pytest.raises(KeyError): + # assert that every single reference is removed + assert self.registry.find(name2="test_1") + + def test_remove_keyword(self): + # Test removing with just kwargs: + self.registry.remove(name="test_2") + with pytest.raises(KeyError): + assert self.registry.find(name="test_2") + + with pytest.raises(KeyError): + # assert that every single reference is removed + assert self.registry.find(name2="test_2") + + self.registry.remove(name="test_3", name2="test_3") + with pytest.raises(KeyError): + assert self.registry.find(name2="test_3") + + def test_remove_empty_key(self): + # Test removing the entire "test" key if it holds no items + self.registry.register("obj_1", test="test_1") + self.registry.remove(test="test_1") + + assert self.registry.map.get("test") is None + + # Test removing a non-existing value + self.registry.remove(non_existant="value") diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..2d9cfb3 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,65 @@ +import pytest +import armet +import sqlalchemy as sa + + +@pytest.fixture(autouse=True, scope="function") +def User(db): + class UserModel(db.Base): + __tablename__ = 'user' + id = sa.Column(sa.Integer, autoincrement=True, primary_key=True) + name = sa.Column(sa.Unicode, nullable=False, default='') + + db.create_all() + return UserModel + + +@pytest.fixture(autouse=True, scope="function") +def users_fixture(request, db, User, session): + # 10 users. + request.instance.names = names = [ + 'joe', 'jerry', 'jon', 'jane', 'jim', + 'jack', 'jeff', 'jay', 'james', 'johansen'] + + for name in names: + session.add(User(name=name)) + + session.commit() + + +@pytest.mark.usefixtures('users_fixture') +class TestSqlalchemyResource: + + @pytest.fixture(autouse=True) + def setup_resource(self, User, db, http): + class UserResource(armet.resources.SQLAlchemyResource): + model = User + session = db.Session + slug_attribute = 'id' + attributes = {'id', 'name'} + + http.app.register(UserResource, name='test') + + def test_read_all_success(self, http): + response = http.get('/test') + + assert response.status_code == 200 + + def test_read_all_return_all_data(self, http): + response = http.get('/test') + + assert response.status_code == 200 + + assert len(response.json) == 10 + + def test_read_item_uses_slug_attribute(self, http): + slugs = range(1, len(self.names) + 1) + + responses = [http.get('/test/{}'.format(x)) for x in slugs] + + http.app.debug = True + + for response, slug in zip(responses, slugs): + assert response.status_code == 200 + + assert response.json['id'] == slug diff --git a/tests/test_serializers.py b/tests/test_serializers.py deleted file mode 100644 index 67243cb..0000000 --- a/tests/test_serializers.py +++ /dev/null @@ -1,123 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -import ujson as json -import six -import uuid -from armet import serializers -from pytest import mark, raises - - -class TestSerializer: - - media_type = None - - Serializer = None - - @classmethod - def setup_class(cls): - cls.serializer = cls.Serializer() - - def serialize(self, data): - content = self.serializer.serialize(data) - if content and isinstance(content, six.binary_type): - content = content.decode('utf8') - - self.content = content - - -@mark.bench('self.serializer.serialize', iterations=10000) -class TestJSONSerializer(TestSerializer): - - media_type = 'application/json' - - Serializer = serializers.JSONSerializer - - def test_none(self): - self.serialize(None) - - assert self.content == '{}' - - def test_number(self): - self.serialize(42) - - assert self.content == '[42]' - - def test_boolean(self): - self.serialize(True) - - assert self.content == '[true]' - - self.serialize(False) - - assert self.content == '[false]' - - def test_array(self): - self.serialize([1, 2, 3]) - - assert self.content == '[1,2,3]' - - def test_array_nested(self): - self.serialize([1, [2, 4, 5], 3]) - - assert self.content == '[1,[2,4,5],3]' - - def test_dict(self): - message = {'x': 1, 'y': 2} - self.serialize(message) - - assert json.loads(self.content) == message - - def test_generator(self): - self.serialize(x for x in range(10)) - - assert self.content == '[0,1,2,3,4,5,6,7,8,9]' - - def test_large(self): - payload_item = { - 'organization': uuid.uuid4().hex, - 'user': uuid.uuid4().hex, - 'id': uuid.uuid4().hex, - 'is_active': False, - 'is_pending': True, - 'members': [ - { - 'id': uuid.uuid4().hex, - 'organization': uuid.uuid4().hex, - 'is_dead': False - } - ], - } - - payload = [payload_item] * 100 - - self.serialize(payload) - - assert self.content == json.dumps(payload, ensure_ascii=False) - - -@mark.bench('self.serializer.serialize', iterations=10000) -class TestURLSerializer(TestSerializer): - - media_type = 'application/x-www-form-urlencoded' - - Serializer = serializers.URLSerializer - - def test_none(self): - self.serialize(None) - - assert self.content == '' - - def test_nested(self): - self.serialize({"foo": [1, 2, 3]}) - - assert self.content == 'foo=1&foo=2&foo=3' - - def test_impossible(self): - data = [{"foo": "bar"}, {"bar": "baz"}] - with raises(ValueError): - self.serialize(data) - - def test_tuple(self): - self.serialize([('foo', 'bar'), ('bar', 'baz')]) - - assert self.content == "foo=bar&bar=baz" diff --git a/tests/test_utils.py b/tests/test_utils.py index 831a577..121705c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,59 +1,26 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals, division -from armet import utils from pytest import mark +from armet import utils -@mark.bench('utils.cons', number=1000000) -class TestConstruction: - - def test_mapping(self): - lhs = {'x': 10, 'y': 20} - rhs = {'z': 15} - - value = utils.cons(lhs, rhs) - - assert value == {'x': 10, 'y': 20, 'z': 15} - - def test_list(self): - lhs = [10, 20] - rhs = [15] - - value = utils.cons(lhs, rhs) - - assert value == [10, 20, 15] - - def test_scalar(self): - lhs = [10, 20] - rhs = 15 - - value = utils.cons(lhs, rhs) - - assert value == [10, 20, 15] - - def test_string(self): - lhs = ['10', 20] - rhs = '15' - - value = utils.cons(lhs, rhs) +@mark.bench("utils.dasherize", iterations=100000) +class TestDasherize: - assert value == ['10', 20, '15'] + def test_from_pascal_case(self): + text = utils.dasherize("ContactPhoto") + assert text == "contact-photo" -@mark.bench('utils.dasherize', number=1000000) -class TestDasherize: + def test_from_camel_case(self): + text = utils.dasherize("contactPhoto") - def test_word(self): - assert utils.dasherize('word') == 'word' + assert text == "contact-photo" - def test_camel(self): - assert utils.dasherize('camelCase') == 'camel-case' + def test_from_underscores(self): + text = utils.dasherize("contact_photo") - def test_pascal(self): - assert utils.dasherize('PascalCase') == 'pascal-case' + assert text == "contact-photo" - def test_underscore(self): - assert utils.dasherize('under_score') == 'under-score' + def test_from_dashed(self): + text = utils.dasherize("contact-photo") - def test_dashed(self): - assert utils.dasherize('dashed-words') == 'dashed-words' + assert text == "contact-photo"