diff --git a/.travis.yml b/.travis.yml index c572eae..f132d06 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,12 @@ language: python +cache: pip python: - "2.7" - "3.3" - "3.4" + - "3.5" + - "3.6" install: - pip install -r requirements.txt diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..e92b342 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,29 @@ +# License + +Copyright © 2017-present, Tom Christie. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 43b274f..249b288 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,58 @@ -# Python client library - -Python client library for [Core API][core-api]. +# [Python client library][docs] [![build-status-image]][travis] [![pypi-version]][pypi] -**Requirements**: Python 2.7, 3.3+ - ---- - -### Installation - -To install, use pip. - - pip install coreapi - ---- - -### Retrieving and inspecting documents - -To initially access a Core API interface, create a client, and use the `get()` method. - - >>> import coreapi - >>> client = coreapi.Client() - >>> doc = client.get('http://coreapi.herokuapp.com') - -We can now inspect the returned document. - - >>> print(doc) - - 'notes': [ - - 'complete': False, - 'description': 'Schedule design meeting', - 'delete': link(), - 'edit': link([description], [complete]), - - 'complete: True, - 'description': 'Write release notes', - 'delete': link(), - 'edit': link([description], [complete]) - ], - 'add_note': link(description) - -Documents are key-value pairs, and their elements can be accessed by indexing into them. - - >>> doc['notes'][0]['description'] - 'Schedule design meeting' - -You can also inspect the document URL and title. +Python client library for [Core API][core-api]. - >>> doc.url - 'http://coreapi.herokuapp.com/' - >>> doc.title - 'Notes' +**Requirements**: Python 2.7, 3.3+ --- -### Interacting with documents +## Installation -Documents in the Python Core API library are immutable objects. To perform a transition we use the `.action()` method and assign the resulting new document. +Install from PyPI, using pip: - >>> doc = client.action(doc, ['add_note'], params={'description': 'My new note.'}) + $ pip install coreapi -The arguments to `.action()` are: +## Quickstart -* The existing document. -* A string or list of strings or integers, indexing the link to act on. -* Any parameters to use when the acting on the link. +Create a client instance: -Transitions may update part of the document tree. + from coreapi import Client + client = Client() - >>> doc = client.action(doc, ['notes', 0, 'edit'], params={'complete': True}) - >>> doc['notes'][0]['complete'] - True +Retrieve an API schema: -Or they may remove part of the document tree. + document = client.get('https://api.example.org/') - >>> while doc['notes']: - >>> doc = client.action(doc, ['notes', 0, 'delete']) - >>> len(doc['notes']) - 0 - ---- +Interact with the API: -### Saving and loading documents + data = client.action(document, ['flights', 'search'], params={ + 'from': 'LHR', + 'to': 'PA', + 'date': '2016-10-12' + }) -To save or load documents into raw bytestrings, instantiate a codec and then -use the `dump()` and `load()` methods. +## Supported formats -For example, to save a document to disk. +The following schema and hypermedia formats are currently supported, either +through built-in support, or as a third-party codec: - codec = coreapi.codecs.CoreJSONCodec() - content = codec.dump(doc) - file = open('doc.json', 'wb') - file.write(content) - file.close() +Name | Media type | Notes +--------------------|----------------------------|------------------------------------ +CoreJSON | `application/coreapi+json` | Supports both Schemas & Hypermedia. +OpenAPI ("Swagger") | `application/openapi+json` | Schema support. +JSON Hyper-Schema | `application/schema+json` | Schema support. +HAL | `application/hal+json` | Hypermedia support. -To load the same document back again. +Additionally, the following plain data content types are supported: - file = open('doc.json', 'rb') - content = file.read() - file.close() - doc = codec.load(content) +Name | Media type | Notes +------------|--------------------|--------------------------------- +JSON | `application/json` | Returns Python primitive types. +Plain text | `text/*` | Returns a Python string instance. +Other media | `*/*` | Returns a temporary download file. --- @@ -131,6 +81,7 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +[docs]: http://core-api.github.io/python-client/ [core-api]: https://github.com/core-api/core-api/ [build-status-image]: https://secure.travis-ci.org/core-api/python-client.svg?branch=master [travis]: http://travis-ci.org/core-api/python-client?branch=master diff --git a/coreapi/__init__.py b/coreapi/__init__.py index f894276..92ac890 100644 --- a/coreapi/__init__.py +++ b/coreapi/__init__.py @@ -1,12 +1,12 @@ # coding: utf-8 -from coreapi import codecs, exceptions, transports, utils +from coreapi import auth, codecs, exceptions, transports, utils from coreapi.client import Client from coreapi.document import Array, Document, Link, Object, Error, Field -__version__ = '1.32.3' +__version__ = '2.3.3' __all__ = [ 'Array', 'Document', 'Link', 'Object', 'Error', 'Field', 'Client', - 'codecs', 'exceptions', 'transports', 'utils' + 'auth', 'codecs', 'exceptions', 'transports', 'utils', ] diff --git a/coreapi/auth.py b/coreapi/auth.py new file mode 100644 index 0000000..e110547 --- /dev/null +++ b/coreapi/auth.py @@ -0,0 +1,69 @@ +from coreapi.utils import domain_matches +from requests.auth import AuthBase, HTTPBasicAuth + + +class BasicAuthentication(HTTPBasicAuth): + allow_cookies = False + + def __init__(self, username, password, domain=None): + self.domain = domain + super(BasicAuthentication, self).__init__(username, password) + + def __call__(self, request): + if not domain_matches(request, self.domain): + return request + + return super(BasicAuthentication, self).__call__(request) + + +class TokenAuthentication(AuthBase): + allow_cookies = False + scheme = 'Bearer' + + def __init__(self, token, scheme=None, domain=None): + """ + * Use an unauthenticated client, and make a request to obtain a token. + * Create an authenticated client using eg. `TokenAuthentication(token="")` + """ + self.token = token + self.domain = domain + if scheme is not None: + self.scheme = scheme + + def __call__(self, request): + if not domain_matches(request, self.domain): + return request + + request.headers['Authorization'] = '%s %s' % (self.scheme, self.token) + return request + + +class SessionAuthentication(AuthBase): + """ + Enables session based login. + + * Make an initial request to obtain a CSRF token. + * Make a login request. + """ + allow_cookies = True + safe_methods = ('GET', 'HEAD', 'OPTIONS', 'TRACE') + + def __init__(self, csrf_cookie_name=None, csrf_header_name=None, domain=None): + self.csrf_cookie_name = csrf_cookie_name + self.csrf_header_name = csrf_header_name + self.csrf_token = None + self.domain = domain + + def store_csrf_token(self, response, **kwargs): + if self.csrf_cookie_name in response.cookies: + self.csrf_token = response.cookies[self.csrf_cookie_name] + + def __call__(self, request): + if not domain_matches(request, self.domain): + return request + + if self.csrf_token and self.csrf_header_name is not None and (request.method not in self.safe_methods): + request.headers[self.csrf_header_name] = self.csrf_token + if self.csrf_cookie_name is not None: + request.register_hook('response', self.store_csrf_token) + return request diff --git a/coreapi/client.py b/coreapi/client.py index 90fa4c0..00b0057 100644 --- a/coreapi/client.py +++ b/coreapi/client.py @@ -1,7 +1,7 @@ from coreapi import codecs, exceptions, transports from coreapi.compat import string_types from coreapi.document import Document, Link -from coreapi.utils import determine_transport +from coreapi.utils import determine_transport, get_installed_codecs import collections import itypes @@ -51,19 +51,61 @@ def _lookup_link(document, keys): return (node, link_ancestors) -class Client(itypes.Object): - DEFAULT_TRANSPORTS = [ - transports.HTTPTransport() +def _validate_parameters(link, parameters): + """ + Ensure that parameters passed to the link are correct. + Raises a `ParameterError` if any parameters do not validate. + """ + provided = set(parameters.keys()) + required = set([ + field.name for field in link.fields if field.required + ]) + optional = set([ + field.name for field in link.fields if not field.required + ]) + + errors = {} + + # Determine if any required field names not supplied. + missing = required - provided + for item in missing: + errors[item] = 'This parameter is required.' + + # Determine any parameter names supplied that are not valid. + unexpected = provided - (optional | required) + for item in unexpected: + errors[item] = 'Unknown parameter.' + + if errors: + raise exceptions.ParameterError(errors) + + +def get_default_decoders(): + return [ + codecs.CoreJSONCodec(), + codecs.JSONCodec(), + codecs.TextCodec(), + codecs.DownloadCodec() ] - DEFAULT_DECODERS = [ - codecs.CoreJSONCodec(), codecs.JSONCodec(), codecs.TextCodec() + + +def get_default_transports(auth=None, session=None): + return [ + transports.HTTPTransport(auth=auth, session=session) ] - def __init__(self, decoders=None, transports=None): + +class Client(itypes.Object): + def __init__(self, decoders=None, transports=None, auth=None, session=None): + assert transports is None or auth is None, ( + "Cannot specify both 'auth' and 'transports'. " + "When specifying transport instances explicitly you should set " + "the authentication directly on the transport." + ) if decoders is None: - decoders = self.DEFAULT_DECODERS + decoders = get_default_decoders() if transports is None: - transports = self.DEFAULT_TRANSPORTS + transports = get_default_transports(auth=auth, session=session) self._decoders = itypes.List(decoders) self._transports = itypes.List(transports) @@ -75,34 +117,61 @@ def decoders(self): def transports(self): return self._transports - def get(self, url, force_codec=False): + def get(self, url, format=None, force_codec=False): link = Link(url, action='get') + decoders = self.decoders + if format: + force_codec = True + decoders = [decoder for decoder in self.decoders if decoder.format == format] + if not decoders: + installed_codecs = get_installed_codecs() + if format in installed_codecs: + decoders = [installed_codecs[format]] + else: + raise ValueError("No decoder available with format='%s'" % format) + # Perform the action, and return a new document. transport = determine_transport(self.transports, link.url) - return transport.transition(link, self.decoders, force_codec=force_codec) + return transport.transition(link, decoders, force_codec=force_codec) - def reload(self, document, force_codec=False): - url = document.url - link = Link(url, action='get') + def reload(self, document, format=None, force_codec=False): + # Fallback for v1.x. To be removed in favour of explict `get` style. + return self.get(document.url, format=format, force_codec=force_codec) - # Perform the action, and return a new document. - transport = determine_transport(self.transports, link.url) - return transport.transition(link, self.decoders, force_codec=force_codec) + def action(self, document, keys, params=None, validate=True, overrides=None, + action=None, encoding=None, transform=None): + if (action is not None) or (encoding is not None) or (transform is not None): + # Fallback for v1.x overrides. + # Will be removed at some point, most likely in a 2.1 release. + if overrides is None: + overrides = {} + if action is not None: + overrides['action'] = action + if encoding is not None: + overrides['encoding'] = encoding + if transform is not None: + overrides['transform'] = transform - def action(self, document, keys, params=None, action=None, encoding=None, transform=None): if isinstance(keys, string_types): keys = [keys] + if params is None: + params = {} + # Validate the keys and link parameters. link, link_ancestors = _lookup_link(document, keys) + if validate: + _validate_parameters(link, params) - if (action is not None) or (encoding is not None) or (transform is not None): + if overrides: # Handle any explicit overrides. - action = link.action if (action is None) else action - encoding = link.encoding if (encoding is None) else encoding - transform = link.transform if (transform is None) else transform - link = Link(link.url, action=action, encoding=encoding, transform=transform, fields=link.fields) + url = overrides.get('url', link.url) + action = overrides.get('action', link.action) + encoding = overrides.get('encoding', link.encoding) + transform = overrides.get('transform', link.transform) + fields = overrides.get('fields', link.fields) + link = Link(url, action=action, encoding=encoding, transform=transform, fields=fields) # Perform the action, and return a new document. transport = determine_transport(self.transports, link.url) diff --git a/coreapi/codecs/__init__.py b/coreapi/codecs/__init__.py index 2649513..4fa6a1a 100644 --- a/coreapi/codecs/__init__.py +++ b/coreapi/codecs/__init__.py @@ -2,6 +2,7 @@ from coreapi.codecs.base import BaseCodec from coreapi.codecs.corejson import CoreJSONCodec from coreapi.codecs.display import DisplayCodec +from coreapi.codecs.download import DownloadCodec from coreapi.codecs.jsondata import JSONCodec from coreapi.codecs.python import PythonCodec from coreapi.codecs.text import TextCodec @@ -9,5 +10,5 @@ __all__ = [ 'BaseCodec', 'CoreJSONCodec', 'DisplayCodec', - 'JSONCodec', 'PythonCodec', 'TextCodec' + 'JSONCodec', 'PythonCodec', 'TextCodec', 'DownloadCodec' ] diff --git a/coreapi/codecs/base.py b/coreapi/codecs/base.py index 02694c0..6f20044 100644 --- a/coreapi/codecs/base.py +++ b/coreapi/codecs/base.py @@ -3,10 +3,42 @@ class BaseCodec(itypes.Object): media_type = None - supports = [] # 'encoding', 'decoding', 'data' - def load(self, bytes, base_url=None): - raise NotImplementedError() # pragma: nocover + # We don't implement stubs, to ensure that we can check which of these + # two operations a codec supports. For example: + # `if hasattr(codec, 'decode'): ...` - def dump(self, document, **kwargs): - raise NotImplementedError() # pragma: nocover + # def decode(self, bytestring, **options): + # pass + + # def encode(self, document, **options): + # pass + + # The following will be removed at some point, most likely in a 2.1 release: + def dump(self, *args, **kwargs): + # Fallback for v1.x interface + return self.encode(*args, **kwargs) + + def load(self, *args, **kwargs): + # Fallback for v1.x interface + return self.decode(*args, **kwargs) + + @property + def supports(self): + # Fallback for v1.x interface. + if '+' not in self.media_type: + return ['data'] + + ret = [] + if hasattr(self, 'encode'): + ret.append('encoding') + if hasattr(self, 'decode'): + ret.append('decoding') + return ret + + def get_media_types(self): + # Fallback, while transitioning from `application/vnd.coreapi+json` + # to simply `application/coreapi+json`. + if hasattr(self, 'media_types'): + return list(self.media_types) + return [self.media_type] diff --git a/coreapi/codecs/corejson.py b/coreapi/codecs/corejson.py index 3eb1fa5..f025533 100644 --- a/coreapi/codecs/corejson.py +++ b/coreapi/codecs/corejson.py @@ -5,13 +5,71 @@ from coreapi.compat import COMPACT_SEPARATORS, VERBOSE_SEPARATORS from coreapi.document import Document, Link, Array, Object, Error, Field from coreapi.exceptions import ParseError +import coreschema import json +# Schema encoding and decoding. +# Just a naive first-pass at this point. + +SCHEMA_CLASS_TO_TYPE_ID = { + coreschema.Object: 'object', + coreschema.Array: 'array', + coreschema.Number: 'number', + coreschema.Integer: 'integer', + coreschema.String: 'string', + coreschema.Boolean: 'boolean', + coreschema.Null: 'null', + coreschema.Enum: 'enum', + coreschema.Anything: 'anything' +} + +TYPE_ID_TO_SCHEMA_CLASS = { + value: key + for key, value + in SCHEMA_CLASS_TO_TYPE_ID.items() +} + + +def encode_schema_to_corejson(schema): + if hasattr(schema, 'typename'): + type_id = schema.typename + else: + type_id = SCHEMA_CLASS_TO_TYPE_ID.get(schema.__class__, 'anything') + retval = { + '_type': type_id, + 'title': schema.title, + 'description': schema.description + } + if hasattr(schema, 'enum'): + retval['enum'] = schema.enum + return retval + + +def decode_schema_from_corejson(data): + type_id = _get_string(data, '_type') + title = _get_string(data, 'title') + description = _get_string(data, 'description') + + kwargs = {} + if type_id == 'enum': + kwargs['enum'] = _get_list(data, 'enum') + + schema_cls = TYPE_ID_TO_SCHEMA_CLASS.get(type_id, coreschema.Anything) + return schema_cls(title=title, description=description, **kwargs) + + # Robust dictionary lookups, that always return an item of the correct # type, using an empty default if an incorrect type exists. # Useful for liberal parsing of inputs. +def _get_schema(item, key): + schema_data = _get_dict(item, key) + if schema_data: + return decode_schema_from_corejson(schema_data) + return None + + def _get_string(item, key): value = item.get(key) if isinstance(value, string_types): @@ -81,15 +139,15 @@ def _get_content(item, base_url=None): Return a dictionary of content, for documents, objects and errors. """ return { - _unescape_key(key): _primative_to_document(value, base_url) + _unescape_key(key): _primitive_to_document(value, base_url) for key, value in item.items() if key not in ('_type', '_meta') } -def _document_to_primative(node, base_url=None): +def _document_to_primitive(node, base_url=None): """ - Take a Core API document and return Python primatives + Take a Core API document and return Python primitives ready to be rendered into the JSON style encoding. """ if isinstance(node, Document): @@ -102,12 +160,14 @@ def _document_to_primative(node, base_url=None): meta['url'] = url if node.title: meta['title'] = node.title + if node.description: + meta['description'] = node.description if meta: ret['_meta'] = meta # Fill in key-value content. ret.update([ - (_escape_key(key), _document_to_primative(value, base_url=url)) + (_escape_key(key), _document_to_primitive(value, base_url=url)) for key, value in node.items() ]) return ret @@ -121,7 +181,7 @@ def _document_to_primative(node, base_url=None): # Fill in key-value content. ret.update([ - (_escape_key(key), _document_to_primative(value, base_url=base_url)) + (_escape_key(key), _document_to_primitive(value, base_url=base_url)) for key, value in node.items() ]) return ret @@ -138,11 +198,13 @@ def _document_to_primative(node, base_url=None): ret['encoding'] = node.encoding if node.transform: ret['transform'] = node.transform + if node.title: + ret['title'] = node.title if node.description: ret['description'] = node.description if node.fields: ret['fields'] = [ - _document_to_primative(field) for field in node.fields + _document_to_primitive(field) for field in node.fields ] return ret @@ -152,25 +214,25 @@ def _document_to_primative(node, base_url=None): ret['required'] = node.required if node.location: ret['location'] = node.location - if node.description: - ret['description'] = node.description + if node.schema: + ret['schema'] = encode_schema_to_corejson(node.schema) return ret elif isinstance(node, Object): return OrderedDict([ - (_escape_key(key), _document_to_primative(value, base_url=base_url)) + (_escape_key(key), _document_to_primitive(value, base_url=base_url)) for key, value in node.items() ]) elif isinstance(node, Array): - return [_document_to_primative(value) for value in node] + return [_document_to_primitive(value) for value in node] return node -def _primative_to_document(data, base_url=None): +def _primitive_to_document(data, base_url=None): """ - Take Python primatives as returned from parsing JSON content, + Take Python primitives as returned from parsing JSON content, and return a Core API document. """ if isinstance(data, dict) and data.get('_type') == 'document': @@ -179,8 +241,15 @@ def _primative_to_document(data, base_url=None): url = _get_string(meta, 'url') url = urlparse.urljoin(base_url, url) title = _get_string(meta, 'title') + description = _get_string(meta, 'description') content = _get_content(data, base_url=url) - return Document(url=url, title=title, content=content) + return Document( + url=url, + title=title, + description=description, + media_type='application/coreapi+json', + content=content + ) if isinstance(data, dict) and data.get('_type') == 'error': # Error @@ -196,6 +265,7 @@ def _primative_to_document(data, base_url=None): action = _get_string(data, 'action') encoding = _get_string(data, 'encoding') transform = _get_string(data, 'transform') + title = _get_string(data, 'title') description = _get_string(data, 'description') fields = _get_list(data, 'fields') fields = [ @@ -203,13 +273,13 @@ def _primative_to_document(data, base_url=None): name=_get_string(item, 'name'), required=_get_bool(item, 'required'), location=_get_string(item, 'location'), - description=_get_string(item, 'description') + schema=_get_schema(item, 'schema') ) for item in fields if isinstance(item, dict) ] return Link( url=url, action=action, encoding=encoding, transform=transform, - description=description, fields=fields + title=title, description=description, fields=fields ) elif isinstance(data, dict): @@ -219,7 +289,7 @@ def _primative_to_document(data, base_url=None): elif isinstance(data, list): # Array - content = [_primative_to_document(item, base_url) for item in data] + content = [_primitive_to_document(item, base_url) for item in data] return Array(content) # String, Integer, Number, Boolean, null. @@ -227,19 +297,24 @@ def _primative_to_document(data, base_url=None): class CoreJSONCodec(BaseCodec): - media_type = 'application/vnd.coreapi+json' - supports = ['encoding', 'decoding'] + media_type = 'application/coreapi+json' + format = 'corejson' + + # The following is due to be deprecated... + media_types = ['application/coreapi+json', 'application/vnd.coreapi+json'] - def load(self, bytes, base_url=None): + def decode(self, bytestring, **options): """ Takes a bytestring and returns a document. """ + base_url = options.get('base_url') + try: - data = json.loads(bytes.decode('utf-8')) + data = json.loads(bytestring.decode('utf-8')) except ValueError as exc: raise ParseError('Malformed JSON. %s' % exc) - doc = _primative_to_document(data, base_url) + doc = _primitive_to_document(data, base_url) if isinstance(doc, Object): doc = Document(content=dict(doc)) @@ -248,22 +323,24 @@ def load(self, bytes, base_url=None): return doc - def dump(self, document, indent=False, **kwargs): + def encode(self, document, **options): """ Takes a document and returns a bytestring. """ + indent = options.get('indent') + if indent: - options = { + kwargs = { 'ensure_ascii': False, 'indent': 4, 'separators': VERBOSE_SEPARATORS } else: - options = { + kwargs = { 'ensure_ascii': False, 'indent': None, 'separators': COMPACT_SEPARATORS } - data = _document_to_primative(document) - return force_bytes(json.dumps(data, **options)) + data = _document_to_primitive(document) + return force_bytes(json.dumps(data, **kwargs)) diff --git a/coreapi/codecs/display.py b/coreapi/codecs/display.py index 62e4c32..250e0cc 100644 --- a/coreapi/codecs/display.py +++ b/coreapi/codecs/display.py @@ -1,3 +1,6 @@ +# Note that `DisplayCodec` is deliberately omitted from the documentation, +# as it is considered an implementation detail. +# It may move into a utility function in the future. from __future__ import unicode_literals from coreapi.codecs.base import BaseCodec from coreapi.compat import console_style, string_types @@ -115,7 +118,7 @@ class DisplayCodec(BaseCodec): A plaintext representation of a Document, intended for readability. """ media_type = 'text/plain' - supports = ['encoding'] - def dump(self, node, colorize=False, **kwargs): - return _to_plaintext(node, colorize=colorize) + def encode(self, document, **options): + colorize = options.get('colorize', False) + return _to_plaintext(document, colorize=colorize) diff --git a/coreapi/codecs/download.py b/coreapi/codecs/download.py new file mode 100644 index 0000000..0995690 --- /dev/null +++ b/coreapi/codecs/download.py @@ -0,0 +1,149 @@ +# coding: utf-8 +from coreapi.codecs.base import BaseCodec +from coreapi.compat import urlparse +from coreapi.utils import DownloadedFile, guess_extension +import cgi +import os +import posixpath +import tempfile + + +def _unique_output_path(path): + """ + Given a path like '/a/b/c.txt' + + Return the first available filename that doesn't already exist, + using an incrementing suffix if needed. + + For example: '/a/b/c.txt' or '/a/b/c (1).txt' or '/a/b/c (2).txt'... + """ + basename, ext = os.path.splitext(path) + idx = 0 + while os.path.exists(path): + idx += 1 + path = "%s (%d)%s" % (basename, idx, ext) + return path + + +def _safe_filename(filename): + """ + Sanitize output filenames, to remove any potentially unsafe characters. + """ + filename = os.path.basename(filename) + + keepcharacters = (' ', '.', '_', '-') + filename = ''.join( + char for char in filename + if char.isalnum() or char in keepcharacters + ).strip().strip('.') + + return filename + + +def _get_filename_from_content_disposition(content_disposition): + """ + Determine an output filename based on the `Content-Disposition` header. + """ + params = value, params = cgi.parse_header(content_disposition) + + if 'filename*' in params: + try: + charset, lang, filename = params['filename*'].split('\'', 2) + filename = urlparse.unquote(filename) + filename = filename.encode('iso-8859-1').decode(charset) + return _safe_filename(filename) + except (ValueError, LookupError): + pass + + if 'filename' in params: + filename = params['filename'] + return _safe_filename(filename) + + return None + + +def _get_filename_from_url(url, content_type=None): + """ + Determine an output filename based on the download URL. + """ + parsed = urlparse.urlparse(url) + final_path_component = posixpath.basename(parsed.path.rstrip('/')) + filename = _safe_filename(final_path_component) + suffix = guess_extension(content_type or '') + + if filename: + if '.' not in filename: + return filename + suffix + return filename + elif suffix: + return 'download' + suffix + + return None + + +def _get_filename(base_url=None, content_type=None, content_disposition=None): + """ + Determine an output filename to use for the download. + """ + filename = None + if content_disposition: + filename = _get_filename_from_content_disposition(content_disposition) + if base_url and not filename: + filename = _get_filename_from_url(base_url, content_type) + if not filename: + return None # Ensure empty filenames return as `None` for consistency. + return filename + + +class DownloadCodec(BaseCodec): + """ + A codec to handle raw file downloads, such as images and other media. + """ + media_type = '*/*' + format = 'download' + + def __init__(self, download_dir=None): + """ + `download_dir` - The path to use for file downloads. + """ + self._delete_on_close = download_dir is None + self._download_dir = download_dir + + @property + def download_dir(self): + return self._download_dir + + def decode(self, bytestring, **options): + base_url = options.get('base_url') + content_type = options.get('content_type') + content_disposition = options.get('content_disposition') + + # Write the download to a temporary .download file. + fd, temp_path = tempfile.mkstemp(suffix='.download') + file_handle = os.fdopen(fd, 'wb') + file_handle.write(bytestring) + file_handle.close() + + # Determine the output filename. + output_filename = _get_filename(base_url, content_type, content_disposition) + if output_filename is None: + output_filename = os.path.basename(temp_path) + + # Determine the output directory. + output_dir = self._download_dir + if output_dir is None: + output_dir = os.path.dirname(temp_path) + + # Determine the full output path. + output_path = os.path.join(output_dir, output_filename) + + # Move the temporary download file to the final location. + if output_path != temp_path: + output_path = _unique_output_path(output_path) + os.rename(temp_path, output_path) + + # Open the file and return the file object. + output_file = open(output_path, 'rb') + downloaded = DownloadedFile(output_file, output_path, delete=self._delete_on_close) + downloaded.basename = output_filename + return downloaded diff --git a/coreapi/codecs/jsondata.py b/coreapi/codecs/jsondata.py index bb35a42..9fa1732 100644 --- a/coreapi/codecs/jsondata.py +++ b/coreapi/codecs/jsondata.py @@ -7,13 +7,16 @@ class JSONCodec(BaseCodec): media_type = 'application/json' - supports = ['data'] + format = 'json' - def load(self, bytes, base_url=None): + def decode(self, bytestring, **options): """ Return raw JSON data. """ try: - return json.loads(bytes.decode('utf-8'), object_pairs_hook=collections.OrderedDict) + return json.loads( + bytestring.decode('utf-8'), + object_pairs_hook=collections.OrderedDict + ) except ValueError as exc: raise ParseError('Malformed JSON. %s' % exc) diff --git a/coreapi/codecs/python.py b/coreapi/codecs/python.py index 8117134..6265a28 100644 --- a/coreapi/codecs/python.py +++ b/coreapi/codecs/python.py @@ -1,3 +1,6 @@ +# Note that `DisplayCodec` is deliberately omitted from the documentation, +# as it is considered an implementation detail. +# It may move into a utility function in the future. from __future__ import unicode_literals from coreapi.codecs.base import BaseCodec from coreapi.document import Document, Link, Array, Object, Error, Field @@ -41,6 +44,8 @@ def _to_repr(node): args += ", encoding=%s" % repr(node.encoding) if node.transform: args += ", transform=%s" % repr(node.transform) + if node.description: + args += ", description=%s" % repr(node.description) if node.fields: fields_repr = ', '.join(_to_repr(item) for item in node.fields) args += ", fields=[%s]" % fields_repr @@ -54,6 +59,8 @@ def _to_repr(node): args += ', required=True' if node.location: args += ', location=%s' % repr(node.location) + if node.schema: + args += ', schema=%s' % repr(node.schema) return 'Field(%s)' % args return repr(node) @@ -64,13 +71,12 @@ class PythonCodec(BaseCodec): A Python representation of a Document, for use with '__repr__'. """ media_type = 'text/python' - supports = ['encoding'] - def dump(self, node, **kwargs): + def encode(self, document, **options): # Object and Array only have the class name wrapper if they # are the outermost element. - if isinstance(node, Object): - return 'Object(%s)' % _to_repr(node) - elif isinstance(node, Array): - return 'Array(%s)' % _to_repr(node) - return _to_repr(node) + if isinstance(document, Object): + return 'Object(%s)' % _to_repr(document) + elif isinstance(document, Array): + return 'Array(%s)' % _to_repr(document) + return _to_repr(document) diff --git a/coreapi/codecs/text.py b/coreapi/codecs/text.py index 72ce2e3..7760498 100644 --- a/coreapi/codecs/text.py +++ b/coreapi/codecs/text.py @@ -4,7 +4,7 @@ class TextCodec(BaseCodec): media_type = 'text/*' - supports = ['data'] + format = 'text' - def load(self, bytes, base_url=None): - return bytes.decode('utf-8') + def decode(self, bytestring, **options): + return bytestring.decode('utf-8') diff --git a/coreapi/compat.py b/coreapi/compat.py index ce896e2..9890ec1 100644 --- a/coreapi/compat.py +++ b/coreapi/compat.py @@ -12,15 +12,13 @@ try: # Python 2 import urlparse + import cookielib as cookiejar string_types = (basestring,) text_type = unicode COMPACT_SEPARATORS = (b',', b':') VERBOSE_SEPARATORS = (b',', b': ') - def is_file(obj): - return isinstance(obj, file) - def b64encode(input_string): # Provide a consistently-as-unicode interface across 2.x and 3.x return base64.b64encode(input_string) @@ -29,15 +27,13 @@ def b64encode(input_string): # Python 3 import urllib.parse as urlparse from io import IOBase + from http import cookiejar string_types = (str,) text_type = str COMPACT_SEPARATORS = (',', ':') VERBOSE_SEPARATORS = (',', ': ') - def is_file(obj): - return isinstance(obj, IOBase) - def b64encode(input_string): # Provide a consistently-as-unicode interface across 2.x and 3.x return base64.b64encode(input_string.encode('ascii')).decode('ascii') @@ -61,3 +57,9 @@ def force_text(string): except ImportError: def console_style(text, **kwargs): return text + + +try: + from tempfile import _TemporaryFileWrapper +except ImportError: + _TemporaryFileWrapper = None diff --git a/coreapi/document.py b/coreapi/document.py index 57b452e..c6c9ceb 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -15,32 +15,42 @@ def _to_immutable(value): def _repr(node): from coreapi.codecs.python import PythonCodec - return PythonCodec().dump(node) + return PythonCodec().encode(node) def _str(node): from coreapi.codecs.display import DisplayCodec - return DisplayCodec().dump(node) + return DisplayCodec().encode(node) def _key_sorting(item): """ Document and Object sorting. - Regular attributes sorted alphabetically, then links sorted alphabetically. + Regular attributes sorted alphabetically. + Links are sorted based on their URL and action. """ key, value = item if isinstance(value, Link): - return (1, key) + action_priority = { + 'get': 0, + 'post': 1, + 'put': 2, + 'patch': 3, + 'delete': 4 + }.get(value.action, 5) + return (1, (value.url, action_priority)) return (0, key) # The field class, as used by Link objects: -Field = namedtuple('Field', ['name', 'required', 'location', 'description']) -Field.__new__.__defaults__ = (False, '', '') +# NOTE: 'type', 'description' and 'example' are now deprecated, +# in favor of 'schema'. +Field = namedtuple('Field', ['name', 'required', 'location', 'schema', 'description', 'type', 'example']) +Field.__new__.__defaults__ = (False, '', None, None, None, None) -# The Core API primatives: +# The Core API primitives: class Document(itypes.Dict): """ @@ -49,24 +59,30 @@ class Document(itypes.Dict): Expresses the data that the client may access, and the actions that the client may perform. """ - def __init__(self, url=None, title=None, content=None): - data = {} if (content is None) else content + def __init__(self, url=None, title=None, description=None, media_type=None, content=None): + content = {} if (content is None) else content if url is not None and not isinstance(url, string_types): raise TypeError("'url' must be a string.") if title is not None and not isinstance(title, string_types): raise TypeError("'title' must be a string.") - if content is not None and not isinstance(content, dict): + if description is not None and not isinstance(description, string_types): + raise TypeError("'description' must be a string.") + if media_type is not None and not isinstance(media_type, string_types): + raise TypeError("'media_type' must be a string.") + if not isinstance(content, dict): raise TypeError("'content' must be a dict.") - if any([not isinstance(key, string_types) for key in data.keys()]): + if any([not isinstance(key, string_types) for key in content.keys()]): raise TypeError('content keys must be strings.') self._url = '' if (url is None) else url self._title = '' if (title is None) else title - self._data = {key: _to_immutable(value) for key, value in data.items()} + self._description = '' if (description is None) else description + self._media_type = '' if (media_type is None) else media_type + self._data = {key: _to_immutable(value) for key, value in content.items()} def clone(self, data): - return self.__class__(self.url, self.title, data) + return self.__class__(self.url, self.title, self.description, self.media_type, data) def __iter__(self): items = sorted(self._data.items(), key=_key_sorting) @@ -95,6 +111,14 @@ def url(self): def title(self): return self._title + @property + def description(self): + return self._description + + @property + def media_type(self): + return self._media_type + @property def data(self): return OrderedDict([ @@ -163,7 +187,7 @@ class Link(itypes.Object): """ Links represent the actions that a client may perform. """ - def __init__(self, url=None, action=None, encoding=None, transform=None, description=None, fields=None): + def __init__(self, url=None, action=None, encoding=None, transform=None, title=None, description=None, fields=None): if (url is not None) and (not isinstance(url, string_types)): raise TypeError("Argument 'url' must be a string.") if (action is not None) and (not isinstance(action, string_types)): @@ -172,6 +196,8 @@ def __init__(self, url=None, action=None, encoding=None, transform=None, descrip raise TypeError("Argument 'encoding' must be a string.") if (transform is not None) and (not isinstance(transform, string_types)): raise TypeError("Argument 'transform' must be a string.") + if (title is not None) and (not isinstance(title, string_types)): + raise TypeError("Argument 'title' must be a string.") if (description is not None) and (not isinstance(description, string_types)): raise TypeError("Argument 'description' must be a string.") if (fields is not None) and (not isinstance(fields, (list, tuple))): @@ -186,6 +212,7 @@ def __init__(self, url=None, action=None, encoding=None, transform=None, descrip self._action = '' if (action is None) else action self._encoding = '' if (encoding is None) else encoding self._transform = '' if (transform is None) else transform + self._title = '' if (title is None) else title self._description = '' if (description is None) else description self._fields = () if (fields is None) else tuple([ item if isinstance(item, Field) else Field(item, required=False, location='') @@ -208,6 +235,10 @@ def encoding(self): def transform(self): return self._transform + @property + def title(self): + return self._title + @property def description(self): return self._description @@ -224,7 +255,7 @@ def __eq__(self, other): self.encoding == other.encoding and self.transform == other.transform and self.description == other.description and - set(self.fields) == set(other.fields) + sorted(self.fields, key=lambda f: f.name) == sorted(other.fields, key=lambda f: f.name) ) def __repr__(self): diff --git a/coreapi/exceptions.py b/coreapi/exceptions.py index 0b17dfa..fee52f0 100644 --- a/coreapi/exceptions.py +++ b/coreapi/exceptions.py @@ -2,43 +2,53 @@ from __future__ import unicode_literals -class ParseError(Exception): +class CoreAPIException(Exception): """ - Raised when an invalid Core API encoding is encountered. + A base class for all `coreapi` exceptions. """ pass -class UnsupportedContentType(Exception): +class ParseError(CoreAPIException): """ - Raised when the media specified in the reponse 'Content-Type' header - is not supported. + Raised when an invalid Core API encoding is encountered. """ pass -class NotAcceptable(Exception): +class NoCodecAvailable(CoreAPIException): """ - Raised when the client 'Accept' header could not be satisfied. + Raised when there is no available codec that can handle the given media. """ pass -class TransportError(Exception): +class NetworkError(CoreAPIException): """ Raised when the transport layer fails to make a request or get a response. """ pass -class LinkLookupError(Exception): +class LinkLookupError(CoreAPIException): """ Raised when `.action` fails to index a link in the document. """ pass -class ErrorMessage(Exception): +class ParameterError(CoreAPIException): + """ + Raised when the parameters passed do not match the link fields. + + * A required field was not included. + * An unknown field was included. + * A field was passed an invalid type for the link location/encoding. + """ + pass + + +class ErrorMessage(CoreAPIException): """ Raised when the transition returns an error message. """ @@ -46,7 +56,7 @@ def __init__(self, error): self.error = error def __repr__(self): - return 'ErrorMessage(%s)' % repr(self.error) + return '%s(%s)' % (self.__class__.__name__, repr(self.error)) def __str__(self): return str(self.error) diff --git a/coreapi/transports/base.py b/coreapi/transports/base.py index 08c2c60..b19f0cc 100644 --- a/coreapi/transports/base.py +++ b/coreapi/transports/base.py @@ -5,5 +5,5 @@ class BaseTransport(itypes.Object): schemes = None - def transition(self, link, params=None, decoders=None, link_ancestors=None, force_codec=False): + def transition(self, link, decoders, params=None, link_ancestors=None, force_codec=False): raise NotImplementedError() # pragma: nocover diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index 36952ee..7338e61 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -1,21 +1,83 @@ # coding: utf-8 from __future__ import unicode_literals from collections import OrderedDict -from coreapi.compat import is_file, urlparse +from coreapi import exceptions, utils +from coreapi.compat import cookiejar, urlparse from coreapi.document import Document, Object, Link, Array, Error -from coreapi.exceptions import ErrorMessage from coreapi.transports.base import BaseTransport -from coreapi.utils import negotiate_decoder +from coreapi.utils import guess_filename, is_file, File import collections import requests import itypes import mimetypes -import os import uritemplate +import warnings -Params = collections.namedtuple('Params', ['path', 'query', 'headers', 'body', 'data', 'files']) -empty_params = Params({}, {}, {}, None, {}, {}) +Params = collections.namedtuple('Params', ['path', 'query', 'data', 'files']) +empty_params = Params({}, {}, {}, {}) + + +class ForceMultiPartDict(dict): + """ + A dictionary that always evaluates as True. + Allows us to force requests to use multipart encoding, even when no + file parameters are passed. + """ + def __bool__(self): + return True + + def __nonzero__(self): + return True + + +class BlockAll(cookiejar.CookiePolicy): + """ + A cookie policy that rejects all cookies. + Used to override the default `requests` behavior. + """ + return_ok = set_ok = domain_return_ok = path_return_ok = lambda self, *args, **kwargs: False + netscape = True + rfc2965 = hide_cookie2 = False + + +class DomainCredentials(requests.auth.AuthBase): + """ + Custom auth class to support deprecated 'credentials' argument. + """ + allow_cookies = False + credentials = None + + def __init__(self, credentials=None): + self.credentials = credentials + + def __call__(self, request): + if not self.credentials: + return request + + # Include any authorization credentials relevant to this domain. + url_components = urlparse.urlparse(request.url) + host = url_components.hostname + if host in self.credentials: + request.headers['Authorization'] = self.credentials[host] + return request + + +class CallbackAdapter(requests.adapters.HTTPAdapter): + """ + Custom requests HTTP adapter, to support deprecated callback arguments. + """ + def __init__(self, request_callback=None, response_callback=None): + self.request_callback = request_callback + self.response_callback = response_callback + + def send(self, request, **kwargs): + if self.request_callback is not None: + self.request_callback(request) + response = super(CallbackAdapter, self).send(request, **kwargs) + if self.response_callback is not None: + self.response_callback(response) + return response def _get_method(action): @@ -24,9 +86,15 @@ def _get_method(action): return action.upper() -def _get_params(method, fields, params=None): +def _get_encoding(encoding): + if not encoding: + return 'application/json' + return encoding + + +def _get_params(method, encoding, fields, params=None): """ - Separate the params into their location types. + Separate the params into the various types. """ if params is None: return empty_params @@ -35,11 +103,15 @@ def _get_params(method, fields, params=None): path = {} query = {} - headers = {} - body = None data = {} files = {} + errors = {} + + # Ensure graceful behavior in edge-case where both location='body' and + # location='form' fields are present. + seen_body = False + for key, value in params.items(): if key not in field_map or not field_map[key].location: # Default is 'query' for 'GET' and 'DELETE', and 'form' for others. @@ -47,37 +119,34 @@ def _get_params(method, fields, params=None): else: location = field_map[key].location - if location == 'path': - path[key] = value - elif location == 'query': - query[key] = value - elif location == 'header': - headers[key] = value - elif location == 'body': - body = value - elif location == 'form': - if is_file(value): - files[key] = value - else: - data[key] = value - - return Params(path, query, headers, body, data, files) + if location == 'form' and encoding == 'application/octet-stream': + # Raw uploads should always use 'body', not 'form'. + location = 'body' + try: + if location == 'path': + path[key] = utils.validate_path_param(value) + elif location == 'query': + query[key] = utils.validate_query_param(value) + elif location == 'body': + data = utils.validate_body_param(value, encoding=encoding) + seen_body = True + elif location == 'form': + if not seen_body: + data[key] = utils.validate_form_param(value, encoding=encoding) + except exceptions.ParameterError as exc: + errors[key] = "%s" % exc -def _get_encoding(encoding, params): - if encoding: - return encoding + if errors: + raise exceptions.ParameterError(errors) - if params.body is not None: - if is_file(params.body): - return 'application/octet-stream' - return 'application/json' - elif params.files: - return 'multipart/form-data' - elif params.data: - return 'application/json' + # Move any files from 'data' into 'files'. + if isinstance(data, dict): + for key, value in list(data.items()): + if is_file(data[key]): + files[key] = data.pop(key) - return '' + return Params(path, query, data, files) def _get_url(url, path_params): @@ -90,37 +159,45 @@ def _get_url(url, path_params): return url -def _get_headers(url, decoders, credentials=None): +def _get_headers(url, decoders): """ Return a dictionary of HTTP headers to use in the outgoing request. """ - accept = '%s, */*' % decoders[0].media_type + accept_media_types = decoders[0].get_media_types() + if '*/*' not in accept_media_types: + accept_media_types.append('*/*') headers = { - 'accept': accept, + 'accept': ', '.join(accept_media_types), 'user-agent': 'coreapi' } - if credentials: - # Include any authorization credentials relevant to this domain. - url_components = urlparse.urlparse(url) - host = url_components.hostname - if host in credentials: - headers['authorization'] = credentials[host] - return headers -def _get_content_type(file_obj): +def _get_upload_headers(file_obj): """ - When a raw file upload is made, determine a content-type where possible. + When a raw file upload is made, determine the Content-Type and + Content-Disposition headers to use with the request. """ - name = getattr(file_obj, 'name', None) - if name is not None: + name = guess_filename(file_obj) + content_type = None + content_disposition = None + + # Determine the content type of the upload. + if getattr(file_obj, 'content_type', None): + content_type = file_obj.content_type + elif name: content_type, encoding = mimetypes.guess_type(name) - else: - content_type = None - return content_type + + # Determine the content disposition of the upload. + if name: + content_disposition = 'attachment; filename="%s"' % name + + return { + 'Content-Type': content_type or 'application/octet-stream', + 'Content-Disposition': content_disposition or 'attachment' + } def _build_http_request(session, url, method, headers=None, encoding=None, params=empty_params): @@ -134,29 +211,21 @@ def _build_http_request(session, url, method, headers=None, encoding=None, param if params.query: opts['params'] = params.query - if (params.body is not None) or params.data or params.files: + if params.data or params.files: if encoding == 'application/json': - if params.body is not None: - opts['json'] = params.body - else: - opts['json'] = params.data + opts['json'] = params.data elif encoding == 'multipart/form-data': opts['data'] = params.data - opts['files'] = params.files + opts['files'] = ForceMultiPartDict(params.files) elif encoding == 'application/x-www-form-urlencoded': opts['data'] = params.data elif encoding == 'application/octet-stream': - opts['data'] = params.body - content_type = _get_content_type(params.body) - if content_type: - opts['headers']['content-type'] = content_type - - if hasattr(params.body, 'name'): - filename = os.path.basename(params.body.name) - content_disposition = 'attachment; filename="%s"' % filename + if isinstance(params.data, File): + opts['data'] = params.data.content else: - content_disposition = 'attachment' - opts['headers']['content-disposition'] = content_disposition + opts['data'] = params.data + upload_headers = _get_upload_headers(params.data) + opts['headers'].update(upload_headers) request = requests.Request(method, url, **opts) return session.prepare_request(request) @@ -212,8 +281,17 @@ def _decode_result(response, decoders, force_codec=False): codec = decoders[0] else: content_type = response.headers.get('content-type') - codec = negotiate_decoder(decoders, content_type) - result = codec.load(response.content, base_url=response.url) + codec = utils.negotiate_decoder(decoders, content_type) + + options = { + 'base_url': response.url + } + if 'content-type' in response.headers: + options['content_type'] = response.headers['content-type'] + if 'content-disposition' in response.headers: + options['content_disposition'] = response.headers['content-disposition'] + + result = codec.load(response.content, **options) else: # No content returned in response. result = None @@ -221,7 +299,8 @@ def _decode_result(response, decoders, force_codec=False): # Coerce 4xx and 5xx codes into errors. is_error = response.status_code >= 400 and response.status_code <= 599 if is_error and not isinstance(result, Error): - result = _coerce_to_error(result, default_title=response.reason) + default_title = '%d %s' % (response.status_code, response.reason) + result = _coerce_to_error(result, default_title=default_title) return result @@ -255,21 +334,33 @@ def _handle_inplace_replacements(document, link, link_ancestors): class HTTPTransport(BaseTransport): schemes = ['http', 'https'] - def __init__(self, credentials=None, headers=None, session=None, - request_callback=None, response_callback=None): + def __init__(self, credentials=None, headers=None, auth=None, session=None, request_callback=None, response_callback=None): if headers: headers = {key.lower(): value for key, value in headers.items()} if session is None: session = requests.Session() - self._credentials = itypes.Dict(credentials or {}) + if auth is not None: + session.auth = auth + if not getattr(session.auth, 'allow_cookies', False): + session.cookies.set_policy(BlockAll()) + + if credentials is not None: + warnings.warn( + "The 'credentials' argument is now deprecated in favor of 'auth'.", + DeprecationWarning + ) + auth = DomainCredentials(credentials) + if request_callback is not None or response_callback is not None: + warnings.warn( + "The 'request_callback' and 'response_callback' arguments are now deprecated. " + "Use a custom 'session' instance instead.", + DeprecationWarning + ) + session.mount('https://', CallbackAdapter(request_callback, response_callback)) + session.mount('http://', CallbackAdapter(request_callback, response_callback)) + self._headers = itypes.Dict(headers or {}) self._session = session - self._request_callback = request_callback - self._response_callback = response_callback - - @property - def credentials(self): - return self._credentials @property def headers(self): @@ -278,26 +369,21 @@ def headers(self): def transition(self, link, decoders, params=None, link_ancestors=None, force_codec=False): session = self._session method = _get_method(link.action) - params = _get_params(method, link.fields, params) - encoding = _get_encoding(link.encoding, params) + encoding = _get_encoding(link.encoding) + params = _get_params(method, encoding, link.fields, params) url = _get_url(link.url, params.path) - headers = _get_headers(url, decoders, self.credentials) + headers = _get_headers(url, decoders) headers.update(self.headers) request = _build_http_request(session, url, method, headers, encoding, params) - if self._request_callback: - self._request_callback(request) - - response = session.send(request) - if self._response_callback: - self._response_callback(response) - + settings = session.merge_environment_settings(request.url, None, None, None, None) + response = session.send(request, **settings) result = _decode_result(response, decoders, force_codec) if isinstance(result, Document) and link_ancestors: result = _handle_inplace_replacements(result, link, link_ancestors) if isinstance(result, Error): - raise ErrorMessage(result) + raise exceptions.ErrorMessage(result) return result diff --git a/coreapi/utils.py b/coreapi/utils.py index 758dc6d..fb7ade4 100644 --- a/coreapi/utils.py +++ b/coreapi/utils.py @@ -1,7 +1,170 @@ from coreapi import exceptions -from coreapi.compat import urlparse +from coreapi.compat import string_types, text_type, urlparse, _TemporaryFileWrapper +from collections import namedtuple +import os +import pkg_resources +import tempfile +def domain_matches(request, domain): + """ + Domain string matching against an outgoing request. + Patterns starting with '*' indicate a wildcard domain. + """ + if (domain is None) or (domain == '*'): + return True + + host = urlparse.urlparse(request.url).hostname + if domain.startswith('*'): + return host.endswith(domain[1:]) + return host == domain + + +def get_installed_codecs(): + packages = [ + (package, package.load()) for package in + pkg_resources.iter_entry_points(group='coreapi.codecs') + ] + return { + package.name: cls() for (package, cls) in packages + } + + +# File utilities for upload and download support. + +File = namedtuple('File', 'name content content_type') +File.__new__.__defaults__ = (None,) + + +def is_file(obj): + if isinstance(obj, File): + return True + + if hasattr(obj, '__iter__') and not isinstance(obj, (string_types, list, tuple, dict)): + # A stream object. + return True + + return False + + +def guess_filename(obj): + name = getattr(obj, 'name', None) + if name and isinstance(name, string_types) and name[0] != '<' and name[-1] != '>': + return os.path.basename(name) + return None + + +def guess_extension(content_type): + """ + Python's `mimetypes.guess_extension` is no use because it simply returns + the first of an unordered set. We use the same set of media types here, + but take a reasonable preference on what extension to map to. + """ + return { + 'application/javascript': '.js', + 'application/msword': '.doc', + 'application/octet-stream': '.bin', + 'application/oda': '.oda', + 'application/pdf': '.pdf', + 'application/pkcs7-mime': '.p7c', + 'application/postscript': '.ps', + 'application/vnd.apple.mpegurl': '.m3u', + 'application/vnd.ms-excel': '.xls', + 'application/vnd.ms-powerpoint': '.ppt', + 'application/x-bcpio': '.bcpio', + 'application/x-cpio': '.cpio', + 'application/x-csh': '.csh', + 'application/x-dvi': '.dvi', + 'application/x-gtar': '.gtar', + 'application/x-hdf': '.hdf', + 'application/x-latex': '.latex', + 'application/x-mif': '.mif', + 'application/x-netcdf': '.nc', + 'application/x-pkcs12': '.p12', + 'application/x-pn-realaudio': '.ram', + 'application/x-python-code': '.pyc', + 'application/x-sh': '.sh', + 'application/x-shar': '.shar', + 'application/x-shockwave-flash': '.swf', + 'application/x-sv4cpio': '.sv4cpio', + 'application/x-sv4crc': '.sv4crc', + 'application/x-tar': '.tar', + 'application/x-tcl': '.tcl', + 'application/x-tex': '.tex', + 'application/x-texinfo': '.texinfo', + 'application/x-troff': '.tr', + 'application/x-troff-man': '.man', + 'application/x-troff-me': '.me', + 'application/x-troff-ms': '.ms', + 'application/x-ustar': '.ustar', + 'application/x-wais-source': '.src', + 'application/xml': '.xml', + 'application/zip': '.zip', + 'audio/basic': '.au', + 'audio/mpeg': '.mp3', + 'audio/x-aiff': '.aif', + 'audio/x-pn-realaudio': '.ra', + 'audio/x-wav': '.wav', + 'image/gif': '.gif', + 'image/ief': '.ief', + 'image/jpeg': '.jpe', + 'image/png': '.png', + 'image/svg+xml': '.svg', + 'image/tiff': '.tiff', + 'image/vnd.microsoft.icon': '.ico', + 'image/x-cmu-raster': '.ras', + 'image/x-ms-bmp': '.bmp', + 'image/x-portable-anymap': '.pnm', + 'image/x-portable-bitmap': '.pbm', + 'image/x-portable-graymap': '.pgm', + 'image/x-portable-pixmap': '.ppm', + 'image/x-rgb': '.rgb', + 'image/x-xbitmap': '.xbm', + 'image/x-xpixmap': '.xpm', + 'image/x-xwindowdump': '.xwd', + 'message/rfc822': '.eml', + 'text/css': '.css', + 'text/csv': '.csv', + 'text/html': '.html', + 'text/plain': '.txt', + 'text/richtext': '.rtx', + 'text/tab-separated-values': '.tsv', + 'text/x-python': '.py', + 'text/x-setext': '.etx', + 'text/x-sgml': '.sgml', + 'text/x-vcard': '.vcf', + 'text/xml': '.xml', + 'video/mp4': '.mp4', + 'video/mpeg': '.mpeg', + 'video/quicktime': '.mov', + 'video/webm': '.webm', + 'video/x-msvideo': '.avi', + 'video/x-sgi-movie': '.movie' + }.get(content_type, '') + + +if _TemporaryFileWrapper: + # Ideally we subclass this so that we can present a custom representation. + class DownloadedFile(_TemporaryFileWrapper): + basename = None + + def __repr__(self): + state = "closed" if self.closed else "open" + mode = "" if self.closed else " '%s'" % self.file.mode + return "" % (self.name, state, mode) + + def __str__(self): + return self.__repr__() +else: + # On some platforms (eg GAE) the private _TemporaryFileWrapper may not be + # available, just use the standard `NamedTemporaryFile` function + # in this case. + DownloadedFile = tempfile.NamedTemporaryFile + + +# Negotiation utilities. USed to determine which codec or transport class +# should be used, given a list of supported instances. + def determine_transport(transports, url): """ Given a URL determine the appropriate transport instance. @@ -11,16 +174,16 @@ def determine_transport(transports, url): netloc = url_components.netloc if not scheme: - raise exceptions.TransportError("URL missing scheme '%s'." % url) + raise exceptions.NetworkError("URL missing scheme '%s'." % url) if not netloc: - raise exceptions.TransportError("URL missing hostname '%s'." % url) + raise exceptions.NetworkError("URL missing hostname '%s'." % url) for transport in transports: if scheme in transport.schemes: return transport - raise exceptions.TransportError("Unsupported URL scheme '%s'." % scheme) + raise exceptions.NetworkError("Unsupported URL scheme '%s'." % scheme) def negotiate_decoder(decoders, content_type=None): @@ -33,12 +196,15 @@ def negotiate_decoder(decoders, content_type=None): content_type = content_type.split(';')[0].strip().lower() main_type = content_type.split('/')[0] + '/*' + wildcard_type = '*/*' + for codec in decoders: - if (codec.media_type == content_type) or (codec.media_type == main_type): - return codec + for media_type in codec.get_media_types(): + if media_type in (content_type, main_type, wildcard_type): + return codec msg = "Unsupported media in Content-Type header '%s'" % content_type - raise exceptions.UnsupportedContentType(msg) + raise exceptions.NoCodecAvailable(msg) def negotiate_encoder(encoders, accept=None): @@ -55,15 +221,116 @@ def negotiate_encoder(encoders, accept=None): ]) for codec in encoders: - if codec.media_type in acceptable: - return codec + for media_type in codec.get_media_types(): + if media_type in acceptable: + return codec for codec in encoders: - if codec.media_type.split('/')[0] + '/*' in acceptable: - return codec + for media_type in codec.get_media_types(): + if codec.media_type.split('/')[0] + '/*' in acceptable: + return codec if '*/*' in acceptable: return encoders[0] msg = "Unsupported media in Accept header '%s'" % accept - raise exceptions.NotAcceptable(msg) + raise exceptions.NoCodecAvailable(msg) + + +# Validation utilities. Used to ensure that we get consistent validation +# exceptions when invalid types are passed as a parameter, rather than +# an exception occuring when the request is made. + +def validate_path_param(value): + value = _validate_form_field(value, allow_list=False) + if not value: + msg = 'Parameter %s: May not be empty.' + raise exceptions.ParameterError(msg) + return value + + +def validate_query_param(value): + return _validate_form_field(value) + + +def validate_body_param(value, encoding): + if encoding == 'application/json': + return _validate_json_data(value) + elif encoding == 'multipart/form-data': + return _validate_form_object(value, allow_files=True) + elif encoding == 'application/x-www-form-urlencoded': + return _validate_form_object(value) + elif encoding == 'application/octet-stream': + if not is_file(value): + msg = 'Must be an file upload.' + raise exceptions.ParameterError(msg) + return value + msg = 'Unsupported encoding "%s" for outgoing request.' + raise exceptions.NetworkError(msg % encoding) + + +def validate_form_param(value, encoding): + if encoding == 'application/json': + return _validate_json_data(value) + elif encoding == 'multipart/form-data': + return _validate_form_field(value, allow_files=True) + elif encoding == 'application/x-www-form-urlencoded': + return _validate_form_field(value) + msg = 'Unsupported encoding "%s" for outgoing request.' + raise exceptions.NetworkError(msg % encoding) + + +def _validate_form_object(value, allow_files=False): + """ + Ensure that `value` can be encoded as form data or as query parameters. + """ + if not isinstance(value, dict): + msg = 'Must be an object.' + raise exceptions.ParameterError(msg) + return { + text_type(item_key): _validate_form_field(item_val, allow_files=allow_files) + for item_key, item_val in value.items() + } + + +def _validate_form_field(value, allow_files=False, allow_list=True): + """ + Ensure that `value` can be encoded as a single form data or a query parameter. + Basic types that has a simple string representation are supported. + A list of basic types is also valid. + """ + if isinstance(value, string_types): + return value + elif isinstance(value, bool) or (value is None): + return {True: 'true', False: 'false', None: ''}[value] + elif isinstance(value, (int, float)): + return "%s" % value + elif allow_list and isinstance(value, (list, tuple)) and not is_file(value): + # Only the top-level element may be a list. + return [ + _validate_form_field(item, allow_files=False, allow_list=False) + for item in value + ] + elif allow_files and is_file(value): + return value + + msg = 'Must be a primitive type.' + raise exceptions.ParameterError(msg) + + +def _validate_json_data(value): + """ + Ensure that `value` can be encoded into JSON. + """ + if (value is None) or isinstance(value, (bool, int, float, string_types)): + return value + elif isinstance(value, (list, tuple)) and not is_file(value): + return [_validate_json_data(item) for item in value] + elif isinstance(value, dict): + return { + text_type(item_key): _validate_json_data(item_val) + for item_key, item_val in value.items() + } + + msg = 'Must be a JSON primitive.' + raise exceptions.ParameterError(msg) diff --git a/docs/api-guide/auth.md b/docs/api-guide/auth.md new file mode 100644 index 0000000..c4210e4 --- /dev/null +++ b/docs/api-guide/auth.md @@ -0,0 +1,84 @@ +# Authentication + +Authentication instances are responsible for handling the network authentication. + +## Using authentication + +Typically, you'll provide authentication configuration by passing an authentication instance to the client. + + import coreapi + + auth = coreapi.auth.BasicAuthentication(username='...', password='...') + coreapi.Client(auth=auth) + +It's recommended that you limit authentication scheme to only provide credentials to endpoints that match the expected domain. + + auth = coreapi.auth.BasicAuthentication( + username='...', + password='...', + domain='api.example.com' + ) + +You can also provide wildcard domains: + + auth = coreapi.auth.BasicAuthentication( + username='...', + password='...', + domain='*.example.com' + ) + +--- + +## Available authentication schemes + +The following authentication schemes are provided as built-in options... + +### BasicAuthentication + +Uses [HTTP Basic Authentication][basic-auth]. + +**Signature**: `BasicAuthentication(username, password, domain='*')` + +### TokenAuthentication + +Uses [HTTP Bearer token authentication][bearer-auth], and can be used for OAuth 2, JWT, and custom token authentication schemes. + +Outgoing requests will include the provided token in the request `Authorization` headers, in the following format: + + Authorization: Bearer xxxx-xxxxxxxx-xxxx + +The scheme name may be customized if required, in order to support HTTP authentication schemes that are not [officially registered][http-auth-schemes]. + +A typical authentication flow using `TokenAuthentication` would be: + +* Using an unauthenticated client make a request providing the users credentials to an endpoint to that returns an API token. +* Instantiate an authenticated client using the returned token, and use this for all future requests. + +**Signature**: `TokenAuthentication(token, scheme='Bearer', domain='*')` + +### SessionAuthentication + +This authentication scheme enables cookies in order to allow a session cookie to be saved and maintained throughout the client's session. + +In order to support CSRF protected sessions, this scheme also supports saving CSRF tokens in the incoming response cookies, and mirroring those tokens back to the server by using a CSRF header in any subsequent outgoing requests. + +A typical authentication flow using `SessionAuthentication` would be: + +* Using an unauthenticated client make an initial request to an endpoint that returns a CSRF cookie. +* Use the unauthenticated client to make a request to a login endpoint, providing the users credentials. +* Subsequent requests by the client will now be authenticated. + +**Signature**: `SessionAuthentication(csrf_cookie_name=None, csrf_header_name=None, domain='*')` + +--- + +## Custom authentication + +Custom authentication classes may be created by subclassing `requests.AuthBase`, and implmenting the following: + +* Set the `allow_cookies` class attribute to either `True` or `False`. +* Provide a `__call__(self, request)` method, which should return an authenticated request instance. + +[basic-auth]: https://tools.ietf.org/html/rfc7617 +[bearer-auth]: https://tools.ietf.org/html/rfc6750 +[http-auth-schemes]: https://www.iana.org/assignments/http-authschemes/ diff --git a/docs/api-guide/client.md b/docs/api-guide/client.md new file mode 100644 index 0000000..e08926f --- /dev/null +++ b/docs/api-guide/client.md @@ -0,0 +1,114 @@ +# Clients + +In order to interact with an API using Core API, a client instance is required. + +The client is used to fetch the initial API description, and to then perform +interactions against the API. + +An example client session might look something like this: + + from coreapi import Client + + client = Client() + document = client.get('https://api.example.org/') + data = client.action(document, ['flights', 'search'], params={ + 'from': 'LHR', + 'to': 'PA', + 'date': '2016-10-12' + }) + +--- + +## Instantiating a client + +The default client may be obtained by instantiating an object, without +passing any parameters. + + client = Client() + +A client instance holds the configuration about which transports are available +for making network requests, and which codecs are available for decoding the +content of network responses. + +The signature of the `Client` class is: + + Client(decoders=None, transports=None, auth=None, session=None) + +Arguments: + +* `decoders` - A list of decoder instances for decoding the content of responses. +* `transports` - A list of transport instances available for making network requests. +* `auth` - A authentication instance. Used when instantiating the default HTTP transport. +* `session` - A `requests` session instance. Used when instantiating the default HTTP transport. + +For example the following would instantiate a client, authenticated using HTTP basic auth, that is capable of decoding either Core JSON schema responses, or decoding plain JSON +data responses: + + from coreapi import codecs + from coreapi.auth import BasicAuthentication + + decoders = [ + codecs.CoreJSONCodec(), + codecs.JSONCodec() + ] + auth = BasicAuthentication(domain='*', username='example', password='xxx') + client = Client(decoders=decoders, auth=auth) + +When no arguments are passed, the following defaults are used: + + decoders = [ + codecs.CoreJSONCodec(), # application/vnd.coreapi+json + codecs.JSONCodec(), # application/json + codecs.TextCodec(), # text/* + codecs.DownloadCodec() # */* + ] + + transports = [ + transports.HTTPTransport(auth=auth, session=session) # http, https + ] + +The configured decoders and transports are made available as read-only +properties on a client instance: + +* `.decoders` +* `.transports` + +--- + +## Making an initial request + +**Signature**: `get(url)` + +Make a network request to the given URL. If fetching an API schema or hypermedia +resource, then this should typically return a decoded `Document`. + +* `url` - The URL that should be retrieved. +* `format` - Optional. Force the given codec to be used when decoding the response. + +For example: + + document = client.get('https://api.example.org/') + +--- + +## Interacting with an API + +**Signature**: `action(self, document, keys, params=None)` + +Effect an interaction against the given document. + +* `document` - A `Document` instance. +* `keys` - A list of strings that index a `Link` within the document. +* `params` - A dictionary of parameters to use for the API interaction. + +For example, making a request without any parameters: + + data = client.action(document, ['flights', 'list_airports']) + +Or making a request, with parameters included: + + data = client.action(document, ['flights', 'search'], params={ + 'from': 'LHR', + 'to': 'PA', + 'date': '2016-10-12' + }) diff --git a/docs/api-guide/codecs.md b/docs/api-guide/codecs.md new file mode 100644 index 0000000..ee85bfa --- /dev/null +++ b/docs/api-guide/codecs.md @@ -0,0 +1,265 @@ +# Codecs + +Codecs are responsible for decoding a bytestring into a `Document` instance, +or for encoding a `Document` instance into a bytestring. + +A codec is associated with a media type. For example in HTTP responses, +the `Content-Type` header is used to indicate the media type of +the bytestring returned in the body of the response. + +When using a Core API client, HTTP responses are decoded with an appropriate +codec, based on the `Content-Type` of the response. + +## Using a codec + +All the codecs provided by the `coreapi` library are instantiated without +arguments, for example: + + from coreapi import codecs + + codec = codecs.CoreJSONCodec() + +A codec will provide either one or both of the `decode()` or `encode()` methods. + +#### Decoding + +**Signature**: `decode(bytestring, **options)` + +Given a bytestring, returns a decoded `Document`, `Error`, or raw data. + +An example of decoding a document: + + bytestring = open('document.corejson', 'rb').read() + document = codec.decode(bytestring) + +The available `options` keywords depend on the codec. + +#### Encoding + +**Signature**: `encode(document, **options)` + +Given a `Document` or `Error`, return an encoded representation as a bytestring. + +An example of encoding a document: + + bytestring = codec.encode(document) + output = open('document.corejson', 'wb') + output.write(bytestring) + output.close() + +The available `options` keywords depend on the codec. + +#### Attributes + +The following attribute is available on codec instances: + +* `media_type` - A string indicating the media type that the codec represents. + +--- + +## Available codecs + +### CoreJSONCodec + +Supports decoding or encoding the Core JSON format. + +**.media_type**: `application/coreapi+json` +**.format**: `openapi` + +Example of decoding a Core JSON bytestring into a `Document` instance: + + >>> from coreapi import codecs + >>> codec = codecs.CoreJSONCodec() + >>> content = b'{"_type": "document", ...}' + >>> document = codec.decode(content) + >>> print(document) + + 'search': link(from, to, date) + +Example of encoding a `Document` instance into a Core JSON bytestring: + + >>> content = codec.encode(document, indent=True) + >>> print(content) + { + "_type": "document" + } + +#### Encoding options + +**indent**: Set to `True` for an indented representation. The default is to generate a compact representation. + +#### Decoding options + +**base_url**: The URL from which the document was retrieved. Used to resolve any relative +URLs in the document. + +--- + +### JSONCodec + +Supports decoding JSON data. + +**.media_type**: `application/json` +**.format**: `json` + +Example: + + >>> from coreapi import codecs + >>> codec = codecs.JSONCodec() + >>> content = b'{"string": "abc", "boolean": true, "null": null}' + >>> data = codec.decode(content) + >>> print(data) + {"string": "abc", "boolean": True, "null": None} + +--- + +### TextCodec + +Supports decoding plain-text responses. + +**.media_type**: `text/*` +**.format**: `text` + +Example: + + >>> from coreapi import codecs + >>> codec = codecs.TextCodec() + >>> data = codec.decode(b'hello, world!') + >>> print(data) + hello, world! + +--- + +### DownloadCodec + +Supports decoding arbitrary media as a download file. Returns a [temporary file][tempfile] +that will be deleted once it goes out of scope. + +**.media_type**: `*/*` +**.format**: `download` + +Example: + + >>> codec = codecs.DownloadCodec() + >>> download = codec.decode(b'abc...xyz') + >>> print(download) + + >>> content = download.read() + >>> print(content) + abc...xyz + +The download filename will be determined by either the `Content-Disposition` +header, or based on the request URL and the `Content-Type` header. Download +collisions are avoided by using incrementing filenames where required. +The original name used for the download file can be inspected using `.basename`. + + >>> download = codec.decode(b'abc...xyz', content_type='image/png', base_url='http://example.com/download/') + >>> download.name + '/var/folders/2k/qjf3np5s28zf2f58963pz2k40000gn/T/download.png' + >>> download.basename + 'download.png' + +#### Instantiation + +By default this codec returns a temporary file that will be deleted once it +goes out of scope. If you want to return temporary files that are not +deleted when they go out of scope then you can instantiate the `DownloadCodec` +with a `download_dir` argument. + +For example, to download files to the current working directory: + + >>> import os + >>> codecs.DownloadCodec(download_dir=os.getcwd()) + +#### Decoding options + +**base_url**: The URL from which the document was retrieved. May be used to +generate an output filename if no `Content-Disposition` header exists. + +**content_type**: The response Content-Type header. May be used to determine a +suffix for the output filename if no `Content-Disposition` header exists. + +**content_disposition**: The response Content-Disposition header. May be [used to +indicate the download filename][content-disposition-filename]. + +--- + +## Custom codecs + +Custom codec classes may be created by inheriting from `BaseCodec`, setting +the `media_type` and `format` properties, and implementing one or both +of the `decode` or `encode` methods. + +For example: + + from coreapi import codecs + import yaml + + class YAMLCodec(codecs.BaseCodec): + media_type = 'application/yaml' + format = 'yaml' + + def decode(content, **options): + return yaml.safe_load(content) + +### The codec registry + +Tools such as the Core API command line client require a method of discovering +which codecs are installed on the system. This is enabled by using a registry +system. + +In order to register a custom codec, the PyPI package must contain a correctly +configured `entry_points` option. Typically this needs to be added in a +`setup.py` module, which is run whenever publishing a new package version. + +The `entry_points` option must be a dictionary, containing a `coreapi.codecs` +item listing the available codec classes. As an example, the listing for the +codecs which are registered by the `coreapi` package itself is as follows: + + setup( + name='coreapi', + license='BSD', + ... + entry_points={ + 'coreapi.codecs': [ + 'corejson=coreapi.codecs:CoreJSONCodec', + 'json=coreapi.codecs:JSONCodec', + 'text=coreapi.codecs:TextCodec', + 'download=coreapi.codecs:DownloadCodec', + ] + } + ) + +--- + +## External packages + +The following third-party packages are available. + +### OpenAPI + +A codec for [OpenAPI][openapi] schemas, also known as "Swagger". Installable [from PyPI][openapi-pypi] as `openapi-codec`, and [available on GitHub][openapi-github]. + +### JSON Hyper-Schema + +A codec for [JSON Hyper-Schema][jsonhyperschema]. Installable [from PyPI][jsonhyperschema-pypi] as `jsonhyperschema-codec`, and [available on GitHub][jsonhyperschema-github]. + +### HAL + +A codec for the [HAL][hal] hypermedia format. Installable [from PyPI][hal-pypi] as `hal-codec`, and [available on GitHub][hal-github]. + +[content-disposition-filename]: https://tools.ietf.org/html/draft-ietf-httpbis-content-disp-00#section-3.3 +[click-ansi]: http://click.pocoo.org/5/utils/#ansi-colors +[tempfile]: https://docs.python.org/3/library/tempfile.html#tempfile.TemporaryFile + +[openapi]: https://openapis.org/specification +[openapi-pypi]: https://pypi.python.org/pypi/openapi-codec +[openapi-github]: https://github.com/core-api/python-openapi-codec + +[jsonhyperschema]: http://json-schema.org/latest/json-schema-hypermedia.html +[jsonhyperschema-pypi]: https://pypi.python.org/pypi/jsonhyperschema-codec +[jsonhyperschema-github]: https://github.com/core-api/python-jsonhyperschema-codec + +[hal]: http://stateless.co/hal_specification.html +[hal-pypi]: https://pypi.python.org/pypi/hal-codec +[hal-github]: https://github.com/core-api/python-hal-codec diff --git a/docs/api-guide/document.md b/docs/api-guide/document.md new file mode 100644 index 0000000..3886937 --- /dev/null +++ b/docs/api-guide/document.md @@ -0,0 +1,165 @@ +# Documents + +A CoreAPI document is a primitive that may be used to represent either schema of hypermedia responses. + +By including information about the available interactions that an API exposes, +the document allows users to interact with the API at an interface level, rather +than a network level. + +In the schema case a document will include only links. Interactions to the API +endpoints will typically return plain data. + +In the hypermedia case a document will include both links and data. interactions +to the API endpoints will typically return a new document. + +--- + +## Usage + +### Retrieving a document + +Typically a Document will first be obtained by making a request with a +client instance. + + >>> document = client.get('https://api.example.com/users/') + +A document can also be loaded from a raw bytestring, by using a codec instance. + + >>> codec = codecs.CoreJSONCodec() + >>> bytestring = open('document.corejson', 'rb').read() + >>> document = codec.decode(bytestring) + +### Inspecting a document + +A document has some associated metadata that may be inspected. + + >>> document.url + 'https://api.example.com/' + >>> document.title + 'Example API' + +A document may contain content, which may include nested dictionaries and list. +The top level element is always a dictionary. The instance may be accessed using +Python's standard dictionary lookup syntax. + +Schema type documents will contain `Link` instances as the leaf nodes in the content. + + >>> document['users']['create'] + Link(url='https://api.example.com/users/', action='post', fields=[...]) + +Hypermedia documents will also contain `Link` instances, but may also contain +data, or nested `Document` instances. + + >>> document['results']['count'] + 45 + >>> document['results']['items'][0] + Document(url='https://api.example.com/users/0/', content={...}) + >>> document['results']['items'][0]['username'] + 'tomchristie' + +### Interacting with a document + +In order to interact with an API, a document is passed as the first argument to +a client instance. A list of strings indexing into a link in the document is passed +as the second argument. + + >>> data = client.action(document, ['users', 'list']) + +Some links may accept a set of parameters, each of which may be either required or optional. + + >>> data = client.action(document, ['users', 'list'], params={'is_admin': True}) + +A document may be reloaded, by fetching the `document.url` property. + + >>> document = client.get(document.url) # Reload the current document + +--- + +## Document primitives + +When using the `coreapi` library as an API client, you won't typically be instantiating +document instances, but rather retrieving them using the client. + +However, if you're using the `coreapi` library on the server-side, you can use +the document primitives directly, in order to create schema or hypermedia representations. +The document should then be encoded using an available codec in order to form the schema response. + +### Document + +The following are available attributes, and may be passed when instantiating a `Document`: + +* `url` - A string giving the canonical URL for this document. +* `title` - A string describing this document. +* `content` - A dictionary containing all the data and links made available by this document. + +A document instance also supports dictionary-style lookup on it's contents. + + >>> document['results']['count'] + 45 + +The following properties are available on a document instance, and on any +nested dictionaries it contains: + +* `links` - A dictionary-like property including only items that are `Link` instances. +* `data` - A dictionary-like property including only items that are not `Link` instances. + +### Link + +The following are available attributes, and may be passed when instantiating a `Link`: + +* `url` - A string giving the URL against which the request should be made. +* `action` - A string giving the type of outgoing request that should be made. +* `encoding` - A string giving the encoding used for outgoing requests. +* `transform` - A string describing how the response should +* `description` - A string describing this link. +* `fields` - A list of field instances. + +Note that the behaviour of link attributes is defined at the transport level, +rather than at the document level. See [the `HTTPTransport` documentation for more details][link-behaviour]. + +### Field + +The following are available attributes, and may be passed when instantiating a `Field`: + +* `name` - A string describing a short name for the parameter. +* `required` - A boolean indicating if this is a required parameter on the link. +* `location` - A string describing how this parameter should be included in the outgoing request. +* `type` - A string describing the kind of [input control][html5-input-control] this parameter represents. +* `description` - A string describing this parameter on the link. + +Note that the behaviour of the `location` attribute is defined at the transport level, +rather than at the document level. See [the `HTTPTransport` documentation for more details][link-behaviour]. + +--- + +## Handling errors + +Error responses are similar to Document responses. Both contain a dictionary of +content. However, an error does not represent a network resource, and so does +not have an associated URL, in the same way as a `Document` does. + +When an error response is returned by an API, the `ErrorMessage` exception is raised. +The `Error` instance itself is available on the exception as the `.error` attribute. + + params = { + 'location_code': 'berlin-4353', + 'start_date': '2018-01-03', + 'end_date': '2018-01-07', + 'room_type': 'double', + } + try: + data = client.action(document, ['bookings', 'create'], params=params) + except coreapi.exceptions.ErrorMessage as exc: + print("Error: %s" % exc.error) + else: + print("Success: %s" % data) + +### Error + +The following are available attributes, and may be passed when instantiating an `Error`: + +* `title` - A string describing the error. +* `content` - A dictionary containing all the data or links made available by this error. + +[link-behaviour]: transports.md#making-requests +[html5-input-control]: https://www.w3.org/TR/html-markup/input.html diff --git a/docs/api-guide/exceptions.md b/docs/api-guide/exceptions.md new file mode 100644 index 0000000..8d12ea5 --- /dev/null +++ b/docs/api-guide/exceptions.md @@ -0,0 +1,59 @@ +# Exceptions + +Any of the exceptions raised by the `coreapi` library may be imported from the `coreapi.exceptions` module: + + from coreapi.exceptions import CoreAPIException + +## The base class + +#### CoreAPIException + +A base class for all `coreapi` exceptions. + +--- + +## Server errors + +The following exception occurs when the server returns an error response. + +#### ErrorMessage + +The server returned a CoreAPI [Error][error] document. + +--- + +## Client errors + +The following exceptions indicate that an incorrect interaction was attempted using the client. + +#### LinkLookupError + +The keys passed in a [`client.action()`][action] call did not reference a link in the document. + +#### ParameterError + +The parameters passed in a [`client.action()`][action] call did not match the set of required and optional fields made available by the link, or if the type of parameters passed could +not be supported by the given encoding on the link. + +--- + +## Request errors + +The following exceptions indicate that an error occurred when handling +some aspect of the API request. + +#### ParseError + +A response was returned with malformed content. + +#### NoCodecAvailable + +Raised when there is no available codec that can handle the given media. + +#### NetworkError + +An issue occurred with the network request. + + +[action]: /api-guide/client.md#interacting-with-an-api +[error]: /api-guide/document.md#error diff --git a/docs/api-guide/transports.md b/docs/api-guide/transports.md new file mode 100644 index 0000000..08c3639 --- /dev/null +++ b/docs/api-guide/transports.md @@ -0,0 +1,90 @@ +# Transports + +Transports are responsible for making the actual network requests, and handling +the responses. + +Whenever an action is taken on a link, the scheme of the URL is inspected, and +the responsibility for making a request is passed to an appropriate transport class. + +By default only an HTTP transport implementation is included, but this approach means +that other network protocols can also be supported by Core API, while remaining +transparent to the user of the client library. + +## Available transports + +### HTTPTransport + +The `HTTPTransport` class supports the `http` and `https` schemes. + +#### Instantiation + +**Signature**: `HTTPTransport(auth=None, headers=None, session=None)` + +* `auth` - An authentication instance, or None. +* `headers` - A dictionary of items that should be included in the outgoing request headers. +* `session` - A [requests session instance][sessions] to use when sending requests. This can be used to further customize how HTTP requests and responses are handled, for instance by allowing [transport adapters][transport-adapters] to be attached to the underlying session. + +#### Making requests + +The following describes how the various Link and Field properties are used when +making an HTTP network request. + +**Link.action** + +The link `action` property is uppercased and then used to determine the HTTP +method for the request. + +If left blank then the `GET` method is used. + +**Link.encoding** + +The link `encoding` property is used to determine how any `location='form'` or +`location='body'` parameters should be encoded in order to form the body of +the request. + +Supported encodings are: + +* `'application/json'` - Suitable for primitive and composite types. +* `'application/x-www-form-urlencoded'` - Suitable for primitive types. +* `'multipart/form-data'` - Suitable for primitive types and file uploads. +* `'application/octet-stream'` - Suitable for raw file uploads, with a `location='body'` field. + +If left blank and a request body is included, then `'application/json'` is used. + +**Link.transform** + +The link `transform` property is *only relevant when the link is contained in an +embedded document*. This allows hypermedia documents to effect partial updates. + +* `'new'` - The response document should be returned as the result. +* `'inplace'` - The embedded document should be updated in-place, and the resulting + top-level document returned as the result. + +If left blank and a link in an embedded document is acted on, then `'inplace'` is used for `'PUT'`, `'PATCH'`, and `'DELETE'` requests. For any other request `'new'` is used. + +**Field.location** + +The link `location` property determines how the parameter is used to build the outgoing request. + +* `'path'` - The parameter is included in the URL, with the link + 'url' value acting as a [URI template][uri-template]. +* `'query'` - The parameter is included as a URL query parameter. +* `'body'` - The parameter is encoded and included as the body of the request. +* `'form'` - The parameter is treated as a single key-value item in an + dictionary of items. It should be encoded together with any other form + parameters, and included as the body of the request. + +If left blank, then `'query'` is used for `'GET'` and `'DELETE'` requests. For any other request `'form'` is used. + +## Custom transports + +The transport interface is not yet finalized, as it may still be subject to minor +changes in a future release. + +## External packages + +No third party transport classes are currently available. + +[sessions]: http://docs.python-requests.org/en/master/user/advanced/#session-objects +[transport-adapters]: http://docs.python-requests.org/en/master/user/advanced/#transport-adapters +[uri-template]: https://tools.ietf.org/html/rfc6570 diff --git a/docs/api-guide/utils.md b/docs/api-guide/utils.md new file mode 100644 index 0000000..d59888f --- /dev/null +++ b/docs/api-guide/utils.md @@ -0,0 +1,137 @@ +# Utilities + +The `coreapi.utils` module provides a number of helper functions that +may be useful if writing a custom client or transport class. + +--- + +## File utilities + +The following classes are used to indicate upload and download file content. + +### File + +May be used as a parameter with links that require a file input. + +**Signature**: `File(name, content, content_type=None)` + +* `name` - The filename. +* `content` - A string, bytestring, or stream object. +* `content_type` - An optional string representing the content type of the file. + +An open file or other stream may also be used directly as a parameter, instead +of a `File` instance, but the `File` instance makes it easier to specify the +filename and content in code. + +Example: + + >>> from coreapi.utils import File + >>> upload = File('example.csv', 'a,b,c\n1,2,3\n4,5,6\n') + >>> data = client.action(document, ['store', 'upload_media'], params={'upload': upload}) + +### DownloadedFile + +A temporary file instance, used to represent downloaded media. + +Available attributes: + +* `name` - The full filename, including the path. +* `basename` - The filename as determined at the point of download. + +Example: + + >>> download = client.action(document, ['user', 'get_profile_image']) + >>> download.basename + 'avatar.png' + >>> download.read() + b'...' + +By default the file will be deleted when this object goes out of scope. See +[the `DownloadCodec` documentation][download-codec] for more details. + +--- + +## Negotiation utilities + +The following functions are used to determine which of a set of transports +or codecs should be used when performing an API interaction. + +### determine_transport + +**Signature**: `determine_transport(transports, url)` + +Given a list of transports and a URL, return the appropriate transport for +making network requests to that URL. + +May raise `NetworkError`. + +### negotiate_decoder + +**Signature**: `negotiate_decoder(codecs, content_type=None)` + +Given a list of codecs, and the value of an HTTP response `Content-Type` header, +return the appropriate codec for decoding the response content. + +May raise `NoCodecAvailable`. + +### negotiate_encoder + +**Signature**: `negotiate_encoder(codecs, accept=None)` + +Given a list of codecs, and the value of an incoming HTTP request `Accept` +header, return the appropriate codec for encoding the outgoing response content. + +Allows server implementations to provide for client-driven content negotiation. + +May raise `NoCodecAvailable`. + +--- + +## Validation utilities + +Different request encodings have different capabilities. For example, `application/json` +supports a range of data primitives, but does not support file uploads. In contrast, +`multipart/form-data` only supports string primitives and file uploads. + +The following helper functions validate that the types passed to an action are suitable +for use with the given encoding, and ensure that a consistent exception type is raised +if an invalid value is passed. + +### validate_path_param + +**Signature**: `validate_path_param(value)` + +Returns the value, coerced into a string primitive. Validates that the value that is suitable for use in URI-encoded path parameters. Empty strings and composite types such as dictionaries are disallowed. + +May raise `ParameterError`. + +### validate_query_param + +**Signature**: `validate_query_param(value)` + +Returns the value, coerced into a string primitive. Validates that the value is suitable for use in URL query parameters. + +May raise `ParameterError`. + +### validate_body_param + +**Signature**: `validate_body_param(value, encoding)` + +Returns the value, coerced into a primitive that is valid for the given encoding. Validates that the parameter types provided may be used as the body of the outgoing request. + +Valid encodings are `application/json`, `x-www-form-urlencoded`, `multipart/form-data` and `application/octet-stream`. + +May raise `ParameterError` for an invalid value, or `NetworkError` for an unsupported encoding. + +### validate_form_param + +**Signature**: `validate_body_param(value, encoding)` + +Returns the value, coerced into a primitive that is valid for the given encoding. Validates that the parameter types provided may be used as a key-value item for part of the body of the outgoing request. + +Valid encodings are `application/json`, `x-www-form-urlencoded`, `multipart/form-data`. + +May raise `ParameterError`, or `NetworkError` for an unsupported encoding. + + +[download-codec]: codecs.md#downloadcodec diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..51886bb --- /dev/null +++ b/docs/index.md @@ -0,0 +1,87 @@ +# Core API - Python Client + +Python client library for [Core API][core-api]. + +Allows you to interact with any API that exposes a supported schema or hypermedia format. + +## Installation + +Install [from PyPI][coreapi-pypi], using pip: + + $ pip install coreapi + +## Quickstart + +Create a client instance: + + from coreapi import Client + client = Client() + +Retrieve an API schema: + + document = client.get('https://api.example.org/') + +Interact with the API: + + data = client.action(document, ['flights', 'search'], params={ + 'from': 'LHR', + 'to': 'PA', + 'date': '2016-10-12' + }) + +Creating an authenticated client instance: + + auth = coreapi.auth.TokenAuthentication(token='xxxx-xxxxxxxx-xxxx') + client = Client(auth=auth) + +## Supported formats + +The following schema and hypermedia formats are currently supported, either +through [built-in support][built-in-codecs], or as a [third-party codec][third-party-codecs]: + +Name | Media type | Notes +--------------------|----------------------------|------------------------------------ +CoreJSON | `application/coreapi+json` | Supports both Schemas & Hypermedia. +OpenAPI ("Swagger") | `application/openapi+json` | Schema support. +JSON Hyper-Schema | `application/schema+json` | Schema support. +HAL | `application/hal+json` | Hypermedia support. + +Additionally, the following plain data content types [are supported][built-in-codecs]: + +Name | Media type | Notes +------------|--------------------|--------------------------------- +JSON | `application/json` | Returns Python primitive types. +Plain text | `text/*` | Returns a Python string instance. +Other media | `*/*` | Returns a temporary download file. + +--- + +## License + +Copyright © 2015-2017, Tom Christie. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. +Redistributions in binary form must reproduce the above copyright notice, this +list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +[core-api]: http://www.coreapi.org/ +[built-in-codecs]: api-guide/codecs.md#available-codecs +[third-party-codecs]: api-guide/codecs.md#external-packages +[coreapi-pypi]: https://pypi.python.org/pypi/coreapi diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md new file mode 100644 index 0000000..357ab09 --- /dev/null +++ b/docs/topics/release-notes.md @@ -0,0 +1,25 @@ +# Release Notes + +## 2.0 + +* Upload and download support. +* Media type changes from `application/vnd.coreapi+json` to `application/coreapi+json`. + For backwards compatibility, either are currently accepted. +* Codec methods `dump()`/`load()` become `encode()`/`decode()`. The old style + methods currently continue to work for backward compatibility. +* The client instance validates that passed parameters match the available parameter names. + Fails if unknown parameters are included, or required parameters are not included. +* `.action()` now accepts a `validate=False` argument, to turn off parameter validation. +* Parameter values are validated against the encoding used on the link to ensure + that they can be represented in the request. +* `type` annotation added to `Field` instances. +* `multipart/form-data` is now consistently used on multipart links, even when + no file arguments are passed. +* `action`, `encoding`, and `transform` parameters to `.action()` now replaced with a + single `overrides` argument. The old style arguments currently continue to work for + backward compatibility. +* The `supports` attribute is no longer used when defining codec classes. A + `supports` property currently exists on the base class, to provide backwards + compatibility for `coreapi-cli`. + +The various backwards compatibility shims are planned to be removed in the 2.1 release. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..3575753 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,17 @@ +site_name: Core API - Python Client + +pages: +- Home: index.md +- API Guide: + - Clients: api-guide/client.md + - Documents: api-guide/document.md + - Authentication: api-guide/auth.md + - Codecs: api-guide/codecs.md + - Transports: api-guide/transports.md + - Exceptions: api-guide/exceptions.md + - Utilities: api-guide/utils.md +- Topics: + - Release Notes: topics/release-notes.md + +repo_url: https://github.com/core-api/python-client/ +copyright: Copyright © 2015, Tom Christie. diff --git a/requirements.txt b/requirements.txt index 926da25..71ddd11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ # Package requirements +coreschema itypes requests uritemplate @@ -7,3 +8,6 @@ uritemplate coverage flake8 pytest + +# Packaging requirements +wheel diff --git a/runtests b/runtests index 54a984d..8b260d9 100755 --- a/runtests +++ b/runtests @@ -35,10 +35,10 @@ def flake8_main(args): def report_coverage(cov, fail_if_not_100=False): - precent_covered = cov.report( + percent_covered = cov.report( file=NullFile(), **COVERAGE_OPTIONS ) - if precent_covered == 100: + if percent_covered == 100: print('100% coverage') return if fail_if_not_100: diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..5e40900 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[wheel] +universal = 1 diff --git a/setup.py b/setup.py index 0c4cc24..7dabead 100755 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ def get_package_data(package): if sys.argv[-1] == 'publish': - os.system("python setup.py sdist upload") + os.system("python setup.py sdist bdist_wheel upload") print("You probably want to also tag the version now:") print(" git tag -a %s -m 'version %s'" % (version, version)) print(" git push --tags") @@ -63,6 +63,7 @@ def get_package_data(package): packages=get_packages('coreapi'), package_data=get_package_data('coreapi'), install_requires=[ + 'coreschema', 'requests', 'itypes', 'uritemplate' @@ -72,6 +73,10 @@ def get_package_data(package): 'corejson=coreapi.codecs:CoreJSONCodec', 'json=coreapi.codecs:JSONCodec', 'text=coreapi.codecs:TextCodec', + 'download=coreapi.codecs:DownloadCodec', + ], + 'coreapi.transports': [ + 'http=coreapi.transports:HTTPTransport', ] }, classifiers=[ @@ -81,6 +86,13 @@ def get_package_data(package): 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', 'Topic :: Internet :: WWW/HTTP', ] ) diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 84af4e3..32458cd 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -1,9 +1,10 @@ # coding: utf-8 from coreapi.codecs import CoreJSONCodec -from coreapi.codecs.corejson import _document_to_primative, _primative_to_document +from coreapi.codecs.corejson import _document_to_primitive, _primitive_to_document from coreapi.document import Document, Link, Error, Field -from coreapi.exceptions import ParseError, UnsupportedContentType, NotAcceptable +from coreapi.exceptions import ParseError, NoCodecAvailable from coreapi.utils import negotiate_decoder, negotiate_encoder +from coreschema import Enum, String import pytest @@ -21,16 +22,22 @@ def doc(): 'integer': 123, 'dict': {'key': 'value'}, 'list': [1, 2, 3], - 'link': Link(url='http://example.org/', fields=[Field(name='example')]), + 'link': Link( + url='http://example.org/', + fields=[ + Field(name='noschema'), + Field(name='string_example', schema=String()), + Field(name='enum_example', schema=Enum(['a', 'b', 'c'])), + ]), 'nested': {'child': Link(url='http://example.org/123')}, '_type': 'needs escaping' }) -# Documents have a mapping to python primatives in JSON style. +# Documents have a mapping to python primitives in JSON style. -def test_document_to_primative(doc): - data = _document_to_primative(doc) +def test_document_to_primitive(doc): + data = _document_to_primitive(doc) assert data == { '_type': 'document', '_meta': { @@ -40,13 +47,32 @@ def test_document_to_primative(doc): 'integer': 123, 'dict': {'key': 'value'}, 'list': [1, 2, 3], - 'link': {'_type': 'link', 'fields': [{'name': 'example'}]}, + 'link': {'_type': 'link', 'fields': [ + {'name': 'noschema'}, + { + 'name': 'string_example', + 'schema': { + '_type': 'string', + 'title': '', + 'description': '', + }, + }, + { + 'name': 'enum_example', + 'schema': { + '_type': 'enum', + 'title': '', + 'description': '', + 'enum': ['a', 'b', 'c'], + }, + }, + ]}, 'nested': {'child': {'_type': 'link', 'url': '/123'}}, '__type': 'needs escaping' } -def test_primative_to_document(doc): +def test_primitive_to_document(doc): data = { '_type': 'document', '_meta': { @@ -56,31 +82,54 @@ def test_primative_to_document(doc): 'integer': 123, 'dict': {'key': 'value'}, 'list': [1, 2, 3], - 'link': {'_type': 'link', 'url': 'http://example.org/', 'fields': [{'name': 'example'}]}, + 'link': { + '_type': 'link', + 'url': 'http://example.org/', + 'fields': [ + {'name': 'noschema'}, + { + 'name': 'string_example', + 'schema': { + '_type': 'string', + 'title': '', + 'description': '', + }, + }, + { + 'name': 'enum_example', + 'schema': { + '_type': 'enum', + 'title': '', + 'description': '', + 'enum': ['a', 'b', 'c'], + }, + }, + ], + }, 'nested': {'child': {'_type': 'link', 'url': 'http://example.org/123'}}, '__type': 'needs escaping' } - assert _primative_to_document(data) == doc + assert _primitive_to_document(data) == doc -def test_error_to_primative(): +def test_error_to_primitive(): error = Error(title='Failure', content={'messages': ['failed']}) data = { '_type': 'error', '_meta': {'title': 'Failure'}, 'messages': ['failed'] } - assert _document_to_primative(error) == data + assert _document_to_primitive(error) == data -def test_primative_to_error(): +def test_primitive_to_error(): error = Error(title='Failure', content={'messages': ['failed']}) data = { '_type': 'error', '_meta': {'title': 'Failure'}, 'messages': ['failed'] } - assert _primative_to_document(data) == error + assert _primitive_to_document(data) == error # Codecs can load a document successfully. @@ -89,7 +138,7 @@ def test_minimal_document(json_codec): """ Ensure we can load the smallest possible valid JSON encoding. """ - doc = json_codec.load(b'{"_type":"document"}') + doc = json_codec.decode(b'{"_type":"document"}') assert isinstance(doc, Document) assert doc.url == '' assert doc.title == '' @@ -100,7 +149,7 @@ def test_minimal_error(json_codec): """ Ensure we can load a minimal error message encoding. """ - error = json_codec.load(b'{"_type":"error","_meta":{"title":"Failure"},"messages":["failed"]}') + error = json_codec.decode(b'{"_type":"error","_meta":{"title":"Failure"},"messages":["failed"]}') assert error == Error(title="Failure", content={'messages': ['failed']}) @@ -111,27 +160,27 @@ def test_malformed_json(json_codec): Invalid JSON should raise a ParseError. """ with pytest.raises(ParseError): - json_codec.load(b'_') + json_codec.decode(b'_') def test_not_a_document(json_codec): """ Valid JSON that does not return a document should be coerced into one. """ - assert json_codec.load(b'{}') == Document() + assert json_codec.decode(b'{}') == Document() # Encodings may have a verbose and a compact style. def test_compact_style(json_codec): doc = Document(content={'a': 123, 'b': 456}) - bytes = json_codec.dump(doc) + bytes = json_codec.encode(doc) assert bytes == b'{"_type":"document","a":123,"b":456}' def test_verbose_style(json_codec): doc = Document(content={'a': 123, 'b': 456}) - bytes = json_codec.dump(doc, indent=True) + bytes = json_codec.encode(doc, indent=True) assert bytes == b"""{ "_type": "document", "a": 123, @@ -149,7 +198,7 @@ def test_link_encodings(json_codec): fields=['optional', Field('required', required=True, location='path')] ) }) - bytes = json_codec.dump(doc, indent=True) + bytes = json_codec.encode(doc, indent=True) assert bytes == b"""{ "_type": "document", "link": { @@ -173,27 +222,27 @@ def test_link_encodings(json_codec): # Tests for graceful omissions. def test_invalid_document_meta_ignored(json_codec): - doc = json_codec.load(b'{"_type": "document", "_meta": 1, "a": 1}') + doc = json_codec.decode(b'{"_type": "document", "_meta": 1, "a": 1}') assert doc == Document(content={"a": 1}) def test_invalid_document_url_ignored(json_codec): - doc = json_codec.load(b'{"_type": "document", "_meta": {"url": 1}, "a": 1}') + doc = json_codec.decode(b'{"_type": "document", "_meta": {"url": 1}, "a": 1}') assert doc == Document(content={"a": 1}) def test_invalid_document_title_ignored(json_codec): - doc = json_codec.load(b'{"_type": "document", "_meta": {"title": 1}, "a": 1}') + doc = json_codec.decode(b'{"_type": "document", "_meta": {"title": 1}, "a": 1}') assert doc == Document(content={"a": 1}) def test_invalid_link_url_ignored(json_codec): - doc = json_codec.load(b'{"_type": "document", "link": {"_type": "link", "url": 1}}') + doc = json_codec.decode(b'{"_type": "document", "link": {"_type": "link", "url": 1}}') assert doc == Document(content={"link": Link()}) def test_invalid_link_fields_ignored(json_codec): - doc = json_codec.load(b'{"_type": "document", "link": {"_type": "link", "fields": 1}}') + doc = json_codec.decode(b'{"_type": "document", "link": {"_type": "link", "fields": 1}}') assert doc == Document(content={"link": Link()}) @@ -215,7 +264,7 @@ def test_get_supported_decoder_with_parameters(): def test_get_unsupported_decoder(): - with pytest.raises(UnsupportedContentType): + with pytest.raises(NoCodecAvailable): negotiate_decoder([CoreJSONCodec()], 'application/csv') @@ -251,7 +300,7 @@ def test_get_underspecified_encoder(): def test_get_unsupported_encoder(): - with pytest.raises(NotAcceptable): + with pytest.raises(NoCodecAvailable): negotiate_encoder([CoreJSONCodec()], 'application/csv') diff --git a/tests/test_document.py b/tests/test_document.py index 289a83d..6b06de7 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -50,7 +50,7 @@ def error(): def _dedent(string): """ - Convience function for dedenting multiline strings, + Convenience function for dedenting multiline strings, for string comparison purposes. """ lines = string.splitlines() @@ -129,7 +129,7 @@ def test_error_does_not_support_property_assignment(): error.integer = 456 -# Children in documents are immutable primatives. +# Children in documents are immutable primitives. def test_document_dictionaries_coerced_to_objects(doc): assert isinstance(doc['dict'], Object) diff --git a/tests/test_integration.py b/tests/test_integration.py index 6a761af..0a2e602 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -13,7 +13,7 @@ @pytest.fixture def document(): codec = coreapi.codecs.CoreJSONCodec() - return codec.load(encoded) + return codec.decode(encoded) class MockResponse(object): @@ -28,7 +28,7 @@ def __init__(self, content): def test_load(): codec = coreapi.codecs.CoreJSONCodec() - assert codec.load(encoded) == { + assert codec.decode(encoded) == { "a": 123, "next": coreapi.Link(url='http://example.org') } @@ -36,12 +36,12 @@ def test_load(): def test_dump(document): codec = coreapi.codecs.CoreJSONCodec() - content = codec.dump(document) + content = codec.encode(document) assert content == encoded def test_get(monkeypatch): - def mockreturn(self, request): + def mockreturn(self, request, *args, **kwargs): return MockResponse(b'{"_type": "document", "example": 123}') monkeypatch.setattr(requests.Session, 'send', mockreturn) @@ -52,7 +52,7 @@ def mockreturn(self, request): def test_follow(monkeypatch, document): - def mockreturn(self, request): + def mockreturn(self, request, *args, **kwargs): return MockResponse(b'{"_type": "document", "example": 123}') monkeypatch.setattr(requests.Session, 'send', mockreturn) @@ -63,7 +63,7 @@ def mockreturn(self, request): def test_reload(monkeypatch): - def mockreturn(self, request): + def mockreturn(self, request, *args, **kwargs): return MockResponse(b'{"_type": "document", "example": 123}') monkeypatch.setattr(requests.Session, 'send', mockreturn) @@ -75,7 +75,7 @@ def mockreturn(self, request): def test_error(monkeypatch, document): - def mockreturn(self, request): + def mockreturn(self, request, *args, **kwargs): return MockResponse(b'{"_type": "error", "message": ["failed"]}') monkeypatch.setattr(requests.Session, 'send', mockreturn) diff --git a/tests/test_transitions.py b/tests/test_transitions.py index 02ae5ff..a31ba13 100644 --- a/tests/test_transitions.py +++ b/tests/test_transitions.py @@ -72,12 +72,12 @@ def test_delete(doc): # Test overrides def test_override_action(doc): - new = client.action(doc, ['nested', 'follow'], action='put') + new = client.action(doc, ['nested', 'follow'], overrides={'action': 'put'}) assert new == {'nested': {'new': 123, 'foo': None}} assert new.title == 'original' def test_override_transform(doc): - new = client.action(doc, ['nested', 'update'], params={'foo': 456}, transform='new') + new = client.action(doc, ['nested', 'update'], params={'foo': 456}, overrides={'transform': 'new'}) assert new == {'new': 123, 'foo': 456} assert new.title == 'new' diff --git a/tests/test_transport.py b/tests/test_transport.py index fd0470c..eeeb17a 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -2,7 +2,7 @@ from coreapi import Document, Link, Field from coreapi.codecs import CoreJSONCodec from coreapi.compat import force_text -from coreapi.exceptions import TransportError +from coreapi.exceptions import NetworkError from coreapi.transports import HTTPTransport from coreapi.utils import determine_transport import pytest @@ -30,24 +30,24 @@ def __init__(self, content): # Test transport errors. def test_unknown_scheme(): - with pytest.raises(TransportError): + with pytest.raises(NetworkError): determine_transport(transports, 'ftp://example.org') def test_missing_scheme(): - with pytest.raises(TransportError): + with pytest.raises(NetworkError): determine_transport(transports, 'example.org') def test_missing_hostname(): - with pytest.raises(TransportError): + with pytest.raises(NetworkError): determine_transport(transports, 'http://') # Test basic transition types. def test_get(monkeypatch, http): - def mockreturn(self, request): + def mockreturn(self, request, *args, **kwargs): return MockResponse(b'{"_type": "document", "example": 123}') monkeypatch.setattr(requests.Session, 'send', mockreturn) @@ -58,7 +58,7 @@ def mockreturn(self, request): def test_get_with_parameters(monkeypatch, http): - def mockreturn(self, request): + def mockreturn(self, request, *args, **kwargs): insert = request.path_url.encode('utf-8') return MockResponse( b'{"_type": "document", "url": "' + insert + b'"}' @@ -72,7 +72,7 @@ def mockreturn(self, request): def test_get_with_path_parameter(monkeypatch, http): - def mockreturn(self, request): + def mockreturn(self, request, *args, **kwargs): insert = request.url.encode('utf-8') return MockResponse( b'{"_type": "document", "example": "' + insert + b'"}' @@ -90,10 +90,10 @@ def mockreturn(self, request): def test_post(monkeypatch, http): - def mockreturn(self, request): + def mockreturn(self, request, *args, **kwargs): codec = CoreJSONCodec() body = force_text(request.body) - content = codec.dump(Document(content={'data': json.loads(body)})) + content = codec.encode(Document(content={'data': json.loads(body)})) return MockResponse(content) monkeypatch.setattr(requests.Session, 'send', mockreturn) @@ -104,7 +104,7 @@ def mockreturn(self, request): def test_delete(monkeypatch, http): - def mockreturn(self, request): + def mockreturn(self, request, *args, **kwargs): return MockResponse(b'') monkeypatch.setattr(requests.Session, 'send', mockreturn) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..53550e9 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,90 @@ +from coreapi import exceptions, utils +import datetime +import pytest + + +def test_validate_path_param(): + assert utils.validate_path_param(1) == '1' + assert utils.validate_path_param(True) == 'true' + with pytest.raises(exceptions.ParameterError): + utils.validate_path_param(None) + with pytest.raises(exceptions.ParameterError): + utils.validate_path_param('') + with pytest.raises(exceptions.ParameterError): + utils.validate_path_param({}) + with pytest.raises(exceptions.ParameterError): + utils.validate_path_param([]) + + +def test_validate_query_param(): + assert utils.validate_query_param(1) == '1' + assert utils.validate_query_param(True) == 'true' + assert utils.validate_query_param(None) == '' + assert utils.validate_query_param('') == '' + assert utils.validate_query_param([1, 2, 3]) == ['1', '2', '3'] + with pytest.raises(exceptions.ParameterError): + utils.validate_query_param({}) + with pytest.raises(exceptions.ParameterError): + utils.validate_query_param([1, 2, {}]) + + +def test_validate_form_data(): + # Valid JSON + data = { + 'string': 'abc', + 'integer': 123, + 'number': 123.456, + 'boolean': True, + 'null': None, + 'array': [1, 2, 3], + 'object': {'a': 1, 'b': 2, 'c': 3} + } + assert utils.validate_form_param(data, 'application/json') == data + assert utils.validate_body_param(data, 'application/json') == data + + # Invalid JSON + data = datetime.datetime.now() + with pytest.raises(exceptions.ParameterError): + utils.validate_form_param(data, 'application/json') + with pytest.raises(exceptions.ParameterError): + utils.validate_body_param(data, 'application/json') + + data = utils.File('abc.txt', None) + with pytest.raises(exceptions.ParameterError): + utils.validate_form_param(data, 'application/json') + with pytest.raises(exceptions.ParameterError): + utils.validate_body_param(data, 'application/json') + + # URL Encoded + assert utils.validate_form_param(123, 'application/x-www-form-urlencoded') == '123' + assert utils.validate_body_param({'a': 123}, 'application/x-www-form-urlencoded') == {'a': '123'} + with pytest.raises(exceptions.ParameterError): + utils.validate_form_param({'a': {'foo': 'bar'}}, 'application/x-www-form-urlencoded') + with pytest.raises(exceptions.ParameterError): + utils.validate_body_param(123, 'application/x-www-form-urlencoded') + with pytest.raises(exceptions.ParameterError): + utils.validate_form_param(utils.File('abc.txt', None), 'application/x-www-form-urlencoded') + with pytest.raises(exceptions.ParameterError): + utils.validate_body_param({'a': utils.File('abc.txt', None)}, 'application/x-www-form-urlencoded') + + # Multipart + assert utils.validate_form_param(123, 'multipart/form-data') == '123' + assert utils.validate_form_param(utils.File('abc.txt', None), 'multipart/form-data') == utils.File('abc.txt', None) + assert utils.validate_body_param({'a': 123}, 'multipart/form-data') == {'a': '123'} + assert utils.validate_body_param({'a': utils.File('abc.txt', None)}, 'multipart/form-data') == {'a': utils.File('abc.txt', None)} + with pytest.raises(exceptions.ParameterError): + utils.validate_form_param({'a': {'foo': 'bar'}}, 'multipart/form-data') + with pytest.raises(exceptions.ParameterError): + utils.validate_body_param(123, 'multipart/form-data') + + # Raw upload + with pytest.raises(exceptions.ParameterError): + utils.validate_body_param(123, 'application/octet-stream') + + # Invalid encoding on outgoing request + with pytest.raises(exceptions.NetworkError): + assert utils.validate_form_param(123, 'invalid/media-type') + with pytest.raises(exceptions.NetworkError): + assert utils.validate_form_param(123, '') + with pytest.raises(exceptions.NetworkError): + assert utils.validate_body_param(123, 'invalid/media-type')