├── .gitignore ├── .travis.yml ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── coreapi ├── __init__.py ├── auth.py ├── client.py ├── codecs │ ├── __init__.py │ ├── base.py │ ├── corejson.py │ ├── display.py │ ├── download.py │ ├── jsondata.py │ ├── python.py │ └── text.py ├── compat.py ├── document.py ├── exceptions.py ├── transports │ ├── __init__.py │ ├── base.py │ └── http.py └── utils.py ├── docs ├── api-guide │ ├── auth.md │ ├── client.md │ ├── codecs.md │ ├── document.md │ ├── exceptions.md │ ├── transports.md │ └── utils.md ├── index.md └── topics │ └── release-notes.md ├── mkdocs.yml ├── requirements.txt ├── runtests ├── setup.cfg ├── setup.py ├── tests ├── test_codecs.py ├── test_document.py ├── test_exceptions.py ├── test_integration.py ├── test_transitions.py ├── test_transport.py └── test_utils.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | .* 4 | 5 | /coreapi.egg-info/ 6 | /dist/ 7 | /env/ 8 | /htmlcov/ 9 | 10 | !.gitignore 11 | !.travis.yml 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | 4 | python: 5 | - "2.7" 6 | - "3.3" 7 | - "3.4" 8 | - "3.5" 9 | - "3.6" 10 | 11 | install: 12 | - pip install -r requirements.txt 13 | 14 | script: 15 | - ./runtests 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | Copyright © 2017-present, Tom Christie. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | global-exclude __pycache__ 2 | global-exclude *.pyc 3 | global-exclude *.pyo 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Python client library][docs] 2 | 3 | [![build-status-image]][travis] 4 | [![pypi-version]][pypi] 5 | 6 | Python client library for [Core API][core-api]. 7 | 8 | **Requirements**: Python 2.7, 3.3+ 9 | 10 | --- 11 | 12 | ## Installation 13 | 14 | Install from PyPI, using pip: 15 | 16 | $ pip install coreapi 17 | 18 | ## Quickstart 19 | 20 | Create a client instance: 21 | 22 | from coreapi import Client 23 | client = Client() 24 | 25 | Retrieve an API schema: 26 | 27 | document = client.get('https://api.example.org/') 28 | 29 | Interact with the API: 30 | 31 | data = client.action(document, ['flights', 'search'], params={ 32 | 'from': 'LHR', 33 | 'to': 'PA', 34 | 'date': '2016-10-12' 35 | }) 36 | 37 | ## Supported formats 38 | 39 | The following schema and hypermedia formats are currently supported, either 40 | through built-in support, or as a third-party codec: 41 | 42 | Name | Media type | Notes 43 | --------------------|----------------------------|------------------------------------ 44 | CoreJSON | `application/coreapi+json` | Supports both Schemas & Hypermedia. 45 | OpenAPI ("Swagger") | `application/openapi+json` | Schema support. 46 | JSON Hyper-Schema | `application/schema+json` | Schema support. 47 | HAL | `application/hal+json` | Hypermedia support. 48 | 49 | Additionally, the following plain data content types are supported: 50 | 51 | Name | Media type | Notes 52 | ------------|--------------------|--------------------------------- 53 | JSON | `application/json` | Returns Python primitive types. 54 | Plain text | `text/*` | Returns a Python string instance. 55 | Other media | `*/*` | Returns a temporary download file. 56 | 57 | --- 58 | 59 | ## License 60 | 61 | Copyright © 2015-2016, Tom Christie. 62 | All rights reserved. 63 | 64 | Redistribution and use in source and binary forms, with or without 65 | modification, are permitted provided that the following conditions are met: 66 | 67 | Redistributions of source code must retain the above copyright notice, this 68 | list of conditions and the following disclaimer. 69 | Redistributions in binary form must reproduce the above copyright notice, this 70 | list of conditions and the following disclaimer in the documentation and/or 71 | other materials provided with the distribution. 72 | 73 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 74 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 75 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 76 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 77 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 78 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 79 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 80 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 81 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 82 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 83 | 84 | [docs]: http://core-api.github.io/python-client/ 85 | [core-api]: https://github.com/core-api/core-api/ 86 | [build-status-image]: https://secure.travis-ci.org/core-api/python-client.svg?branch=master 87 | [travis]: http://travis-ci.org/core-api/python-client?branch=master 88 | [pypi-version]: https://img.shields.io/pypi/v/coreapi.svg 89 | [pypi]: https://pypi.python.org/pypi/coreapi 90 | -------------------------------------------------------------------------------- /coreapi/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from coreapi import auth, codecs, exceptions, transports, utils 3 | from coreapi.client import Client 4 | from coreapi.document import Array, Document, Link, Object, Error, Field 5 | 6 | 7 | __version__ = '2.3.3' 8 | __all__ = [ 9 | 'Array', 'Document', 'Link', 'Object', 'Error', 'Field', 10 | 'Client', 11 | 'auth', 'codecs', 'exceptions', 'transports', 'utils', 12 | ] 13 | -------------------------------------------------------------------------------- /coreapi/auth.py: -------------------------------------------------------------------------------- 1 | from coreapi.utils import domain_matches 2 | from requests.auth import AuthBase, HTTPBasicAuth 3 | 4 | 5 | class BasicAuthentication(HTTPBasicAuth): 6 | allow_cookies = False 7 | 8 | def __init__(self, username, password, domain=None): 9 | self.domain = domain 10 | super(BasicAuthentication, self).__init__(username, password) 11 | 12 | def __call__(self, request): 13 | if not domain_matches(request, self.domain): 14 | return request 15 | 16 | return super(BasicAuthentication, self).__call__(request) 17 | 18 | 19 | class TokenAuthentication(AuthBase): 20 | allow_cookies = False 21 | scheme = 'Bearer' 22 | 23 | def __init__(self, token, scheme=None, domain=None): 24 | """ 25 | * Use an unauthenticated client, and make a request to obtain a token. 26 | * Create an authenticated client using eg. `TokenAuthentication(token="")` 27 | """ 28 | self.token = token 29 | self.domain = domain 30 | if scheme is not None: 31 | self.scheme = scheme 32 | 33 | def __call__(self, request): 34 | if not domain_matches(request, self.domain): 35 | return request 36 | 37 | request.headers['Authorization'] = '%s %s' % (self.scheme, self.token) 38 | return request 39 | 40 | 41 | class SessionAuthentication(AuthBase): 42 | """ 43 | Enables session based login. 44 | 45 | * Make an initial request to obtain a CSRF token. 46 | * Make a login request. 47 | """ 48 | allow_cookies = True 49 | safe_methods = ('GET', 'HEAD', 'OPTIONS', 'TRACE') 50 | 51 | def __init__(self, csrf_cookie_name=None, csrf_header_name=None, domain=None): 52 | self.csrf_cookie_name = csrf_cookie_name 53 | self.csrf_header_name = csrf_header_name 54 | self.csrf_token = None 55 | self.domain = domain 56 | 57 | def store_csrf_token(self, response, **kwargs): 58 | if self.csrf_cookie_name in response.cookies: 59 | self.csrf_token = response.cookies[self.csrf_cookie_name] 60 | 61 | def __call__(self, request): 62 | if not domain_matches(request, self.domain): 63 | return request 64 | 65 | if self.csrf_token and self.csrf_header_name is not None and (request.method not in self.safe_methods): 66 | request.headers[self.csrf_header_name] = self.csrf_token 67 | if self.csrf_cookie_name is not None: 68 | request.register_hook('response', self.store_csrf_token) 69 | return request 70 | -------------------------------------------------------------------------------- /coreapi/client.py: -------------------------------------------------------------------------------- 1 | from coreapi import codecs, exceptions, transports 2 | from coreapi.compat import string_types 3 | from coreapi.document import Document, Link 4 | from coreapi.utils import determine_transport, get_installed_codecs 5 | import collections 6 | import itypes 7 | 8 | 9 | LinkAncestor = collections.namedtuple('LinkAncestor', ['document', 'keys']) 10 | 11 | 12 | def _lookup_link(document, keys): 13 | """ 14 | Validates that keys looking up a link are correct. 15 | 16 | Returns a two-tuple of (link, link_ancestors). 17 | """ 18 | if not isinstance(keys, (list, tuple)): 19 | msg = "'keys' must be a list of strings or ints." 20 | raise TypeError(msg) 21 | if any([ 22 | not isinstance(key, string_types) and not isinstance(key, int) 23 | for key in keys 24 | ]): 25 | raise TypeError("'keys' must be a list of strings or ints.") 26 | 27 | # Determine the link node being acted on, and its parent document. 28 | # 'node' is the link we're calling the action for. 29 | # 'document_keys' is the list of keys to the link's parent document. 30 | node = document 31 | link_ancestors = [LinkAncestor(document=document, keys=[])] 32 | for idx, key in enumerate(keys): 33 | try: 34 | node = node[key] 35 | except (KeyError, IndexError, TypeError): 36 | index_string = ''.join('[%s]' % repr(key).strip('u') for key in keys) 37 | msg = 'Index %s did not reference a link. Key %s was not found.' 38 | raise exceptions.LinkLookupError(msg % (index_string, repr(key).strip('u'))) 39 | if isinstance(node, Document): 40 | ancestor = LinkAncestor(document=node, keys=keys[:idx + 1]) 41 | link_ancestors.append(ancestor) 42 | 43 | # Ensure that we've correctly indexed into a link. 44 | if not isinstance(node, Link): 45 | index_string = ''.join('[%s]' % repr(key).strip('u') for key in keys) 46 | msg = "Can only call 'action' on a Link. Index %s returned type '%s'." 47 | raise exceptions.LinkLookupError( 48 | msg % (index_string, type(node).__name__) 49 | ) 50 | 51 | return (node, link_ancestors) 52 | 53 | 54 | def _validate_parameters(link, parameters): 55 | """ 56 | Ensure that parameters passed to the link are correct. 57 | Raises a `ParameterError` if any parameters do not validate. 58 | """ 59 | provided = set(parameters.keys()) 60 | required = set([ 61 | field.name for field in link.fields if field.required 62 | ]) 63 | optional = set([ 64 | field.name for field in link.fields if not field.required 65 | ]) 66 | 67 | errors = {} 68 | 69 | # Determine if any required field names not supplied. 70 | missing = required - provided 71 | for item in missing: 72 | errors[item] = 'This parameter is required.' 73 | 74 | # Determine any parameter names supplied that are not valid. 75 | unexpected = provided - (optional | required) 76 | for item in unexpected: 77 | errors[item] = 'Unknown parameter.' 78 | 79 | if errors: 80 | raise exceptions.ParameterError(errors) 81 | 82 | 83 | def get_default_decoders(): 84 | return [ 85 | codecs.CoreJSONCodec(), 86 | codecs.JSONCodec(), 87 | codecs.TextCodec(), 88 | codecs.DownloadCodec() 89 | ] 90 | 91 | 92 | def get_default_transports(auth=None, session=None): 93 | return [ 94 | transports.HTTPTransport(auth=auth, session=session) 95 | ] 96 | 97 | 98 | class Client(itypes.Object): 99 | def __init__(self, decoders=None, transports=None, auth=None, session=None): 100 | assert transports is None or auth is None, ( 101 | "Cannot specify both 'auth' and 'transports'. " 102 | "When specifying transport instances explicitly you should set " 103 | "the authentication directly on the transport." 104 | ) 105 | if decoders is None: 106 | decoders = get_default_decoders() 107 | if transports is None: 108 | transports = get_default_transports(auth=auth, session=session) 109 | self._decoders = itypes.List(decoders) 110 | self._transports = itypes.List(transports) 111 | 112 | @property 113 | def decoders(self): 114 | return self._decoders 115 | 116 | @property 117 | def transports(self): 118 | return self._transports 119 | 120 | def get(self, url, format=None, force_codec=False): 121 | link = Link(url, action='get') 122 | 123 | decoders = self.decoders 124 | if format: 125 | force_codec = True 126 | decoders = [decoder for decoder in self.decoders if decoder.format == format] 127 | if not decoders: 128 | installed_codecs = get_installed_codecs() 129 | if format in installed_codecs: 130 | decoders = [installed_codecs[format]] 131 | else: 132 | raise ValueError("No decoder available with format='%s'" % format) 133 | 134 | # Perform the action, and return a new document. 135 | transport = determine_transport(self.transports, link.url) 136 | return transport.transition(link, decoders, force_codec=force_codec) 137 | 138 | def reload(self, document, format=None, force_codec=False): 139 | # Fallback for v1.x. To be removed in favour of explict `get` style. 140 | return self.get(document.url, format=format, force_codec=force_codec) 141 | 142 | def action(self, document, keys, params=None, validate=True, overrides=None, 143 | action=None, encoding=None, transform=None): 144 | if (action is not None) or (encoding is not None) or (transform is not None): 145 | # Fallback for v1.x overrides. 146 | # Will be removed at some point, most likely in a 2.1 release. 147 | if overrides is None: 148 | overrides = {} 149 | if action is not None: 150 | overrides['action'] = action 151 | if encoding is not None: 152 | overrides['encoding'] = encoding 153 | if transform is not None: 154 | overrides['transform'] = transform 155 | 156 | if isinstance(keys, string_types): 157 | keys = [keys] 158 | 159 | if params is None: 160 | params = {} 161 | 162 | # Validate the keys and link parameters. 163 | link, link_ancestors = _lookup_link(document, keys) 164 | if validate: 165 | _validate_parameters(link, params) 166 | 167 | if overrides: 168 | # Handle any explicit overrides. 169 | url = overrides.get('url', link.url) 170 | action = overrides.get('action', link.action) 171 | encoding = overrides.get('encoding', link.encoding) 172 | transform = overrides.get('transform', link.transform) 173 | fields = overrides.get('fields', link.fields) 174 | link = Link(url, action=action, encoding=encoding, transform=transform, fields=fields) 175 | 176 | # Perform the action, and return a new document. 177 | transport = determine_transport(self.transports, link.url) 178 | return transport.transition(link, self.decoders, params=params, link_ancestors=link_ancestors) 179 | -------------------------------------------------------------------------------- /coreapi/codecs/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from coreapi.codecs.base import BaseCodec 3 | from coreapi.codecs.corejson import CoreJSONCodec 4 | from coreapi.codecs.display import DisplayCodec 5 | from coreapi.codecs.download import DownloadCodec 6 | from coreapi.codecs.jsondata import JSONCodec 7 | from coreapi.codecs.python import PythonCodec 8 | from coreapi.codecs.text import TextCodec 9 | 10 | 11 | __all__ = [ 12 | 'BaseCodec', 'CoreJSONCodec', 'DisplayCodec', 13 | 'JSONCodec', 'PythonCodec', 'TextCodec', 'DownloadCodec' 14 | ] 15 | -------------------------------------------------------------------------------- /coreapi/codecs/base.py: -------------------------------------------------------------------------------- 1 | import itypes 2 | 3 | 4 | class BaseCodec(itypes.Object): 5 | media_type = None 6 | 7 | # We don't implement stubs, to ensure that we can check which of these 8 | # two operations a codec supports. For example: 9 | # `if hasattr(codec, 'decode'): ...` 10 | 11 | # def decode(self, bytestring, **options): 12 | # pass 13 | 14 | # def encode(self, document, **options): 15 | # pass 16 | 17 | # The following will be removed at some point, most likely in a 2.1 release: 18 | def dump(self, *args, **kwargs): 19 | # Fallback for v1.x interface 20 | return self.encode(*args, **kwargs) 21 | 22 | def load(self, *args, **kwargs): 23 | # Fallback for v1.x interface 24 | return self.decode(*args, **kwargs) 25 | 26 | @property 27 | def supports(self): 28 | # Fallback for v1.x interface. 29 | if '+' not in self.media_type: 30 | return ['data'] 31 | 32 | ret = [] 33 | if hasattr(self, 'encode'): 34 | ret.append('encoding') 35 | if hasattr(self, 'decode'): 36 | ret.append('decoding') 37 | return ret 38 | 39 | def get_media_types(self): 40 | # Fallback, while transitioning from `application/vnd.coreapi+json` 41 | # to simply `application/coreapi+json`. 42 | if hasattr(self, 'media_types'): 43 | return list(self.media_types) 44 | return [self.media_type] 45 | -------------------------------------------------------------------------------- /coreapi/codecs/corejson.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from collections import OrderedDict 3 | from coreapi.codecs.base import BaseCodec 4 | from coreapi.compat import force_bytes, string_types, urlparse 5 | from coreapi.compat import COMPACT_SEPARATORS, VERBOSE_SEPARATORS 6 | from coreapi.document import Document, Link, Array, Object, Error, Field 7 | from coreapi.exceptions import ParseError 8 | import coreschema 9 | import json 10 | 11 | 12 | # Schema encoding and decoding. 13 | # Just a naive first-pass at this point. 14 | 15 | SCHEMA_CLASS_TO_TYPE_ID = { 16 | coreschema.Object: 'object', 17 | coreschema.Array: 'array', 18 | coreschema.Number: 'number', 19 | coreschema.Integer: 'integer', 20 | coreschema.String: 'string', 21 | coreschema.Boolean: 'boolean', 22 | coreschema.Null: 'null', 23 | coreschema.Enum: 'enum', 24 | coreschema.Anything: 'anything' 25 | } 26 | 27 | TYPE_ID_TO_SCHEMA_CLASS = { 28 | value: key 29 | for key, value 30 | in SCHEMA_CLASS_TO_TYPE_ID.items() 31 | } 32 | 33 | 34 | def encode_schema_to_corejson(schema): 35 | if hasattr(schema, 'typename'): 36 | type_id = schema.typename 37 | else: 38 | type_id = SCHEMA_CLASS_TO_TYPE_ID.get(schema.__class__, 'anything') 39 | retval = { 40 | '_type': type_id, 41 | 'title': schema.title, 42 | 'description': schema.description 43 | } 44 | if hasattr(schema, 'enum'): 45 | retval['enum'] = schema.enum 46 | return retval 47 | 48 | 49 | def decode_schema_from_corejson(data): 50 | type_id = _get_string(data, '_type') 51 | title = _get_string(data, 'title') 52 | description = _get_string(data, 'description') 53 | 54 | kwargs = {} 55 | if type_id == 'enum': 56 | kwargs['enum'] = _get_list(data, 'enum') 57 | 58 | schema_cls = TYPE_ID_TO_SCHEMA_CLASS.get(type_id, coreschema.Anything) 59 | return schema_cls(title=title, description=description, **kwargs) 60 | 61 | 62 | # Robust dictionary lookups, that always return an item of the correct 63 | # type, using an empty default if an incorrect type exists. 64 | # Useful for liberal parsing of inputs. 65 | 66 | def _get_schema(item, key): 67 | schema_data = _get_dict(item, key) 68 | if schema_data: 69 | return decode_schema_from_corejson(schema_data) 70 | return None 71 | 72 | 73 | def _get_string(item, key): 74 | value = item.get(key) 75 | if isinstance(value, string_types): 76 | return value 77 | return '' 78 | 79 | 80 | def _get_dict(item, key): 81 | value = item.get(key) 82 | if isinstance(value, dict): 83 | return value 84 | return {} 85 | 86 | 87 | def _get_list(item, key): 88 | value = item.get(key) 89 | if isinstance(value, list): 90 | return value 91 | return [] 92 | 93 | 94 | def _get_bool(item, key): 95 | value = item.get(key) 96 | if isinstance(value, bool): 97 | return value 98 | return False 99 | 100 | 101 | def _graceful_relative_url(base_url, url): 102 | """ 103 | Return a graceful link for a URL relative to a base URL. 104 | 105 | * If they are the same, return an empty string. 106 | * If the have the same scheme and hostname, return the path & query params. 107 | * Otherwise return the full URL. 108 | """ 109 | if url == base_url: 110 | return '' 111 | base_prefix = '%s://%s' % urlparse.urlparse(base_url or '')[0:2] 112 | url_prefix = '%s://%s' % urlparse.urlparse(url or '')[0:2] 113 | if base_prefix == url_prefix and url_prefix != '://': 114 | return url[len(url_prefix):] 115 | return url 116 | 117 | 118 | def _escape_key(string): 119 | """ 120 | The '_type' and '_meta' keys are reserved. 121 | Prefix with an additional '_' if they occur. 122 | """ 123 | if string.startswith('_') and string.lstrip('_') in ('type', 'meta'): 124 | return '_' + string 125 | return string 126 | 127 | 128 | def _unescape_key(string): 129 | """ 130 | Unescape '__type' and '__meta' keys if they occur. 131 | """ 132 | if string.startswith('__') and string.lstrip('_') in ('type', 'meta'): 133 | return string[1:] 134 | return string 135 | 136 | 137 | def _get_content(item, base_url=None): 138 | """ 139 | Return a dictionary of content, for documents, objects and errors. 140 | """ 141 | return { 142 | _unescape_key(key): _primitive_to_document(value, base_url) 143 | for key, value in item.items() 144 | if key not in ('_type', '_meta') 145 | } 146 | 147 | 148 | def _document_to_primitive(node, base_url=None): 149 | """ 150 | Take a Core API document and return Python primitives 151 | ready to be rendered into the JSON style encoding. 152 | """ 153 | if isinstance(node, Document): 154 | ret = OrderedDict() 155 | ret['_type'] = 'document' 156 | 157 | meta = OrderedDict() 158 | url = _graceful_relative_url(base_url, node.url) 159 | if url: 160 | meta['url'] = url 161 | if node.title: 162 | meta['title'] = node.title 163 | if node.description: 164 | meta['description'] = node.description 165 | if meta: 166 | ret['_meta'] = meta 167 | 168 | # Fill in key-value content. 169 | ret.update([ 170 | (_escape_key(key), _document_to_primitive(value, base_url=url)) 171 | for key, value in node.items() 172 | ]) 173 | return ret 174 | 175 | elif isinstance(node, Error): 176 | ret = OrderedDict() 177 | ret['_type'] = 'error' 178 | 179 | if node.title: 180 | ret['_meta'] = {'title': node.title} 181 | 182 | # Fill in key-value content. 183 | ret.update([ 184 | (_escape_key(key), _document_to_primitive(value, base_url=base_url)) 185 | for key, value in node.items() 186 | ]) 187 | return ret 188 | 189 | elif isinstance(node, Link): 190 | ret = OrderedDict() 191 | ret['_type'] = 'link' 192 | url = _graceful_relative_url(base_url, node.url) 193 | if url: 194 | ret['url'] = url 195 | if node.action: 196 | ret['action'] = node.action 197 | if node.encoding: 198 | ret['encoding'] = node.encoding 199 | if node.transform: 200 | ret['transform'] = node.transform 201 | if node.title: 202 | ret['title'] = node.title 203 | if node.description: 204 | ret['description'] = node.description 205 | if node.fields: 206 | ret['fields'] = [ 207 | _document_to_primitive(field) for field in node.fields 208 | ] 209 | return ret 210 | 211 | elif isinstance(node, Field): 212 | ret = OrderedDict({'name': node.name}) 213 | if node.required: 214 | ret['required'] = node.required 215 | if node.location: 216 | ret['location'] = node.location 217 | if node.schema: 218 | ret['schema'] = encode_schema_to_corejson(node.schema) 219 | return ret 220 | 221 | elif isinstance(node, Object): 222 | return OrderedDict([ 223 | (_escape_key(key), _document_to_primitive(value, base_url=base_url)) 224 | for key, value in node.items() 225 | ]) 226 | 227 | elif isinstance(node, Array): 228 | return [_document_to_primitive(value) for value in node] 229 | 230 | return node 231 | 232 | 233 | def _primitive_to_document(data, base_url=None): 234 | """ 235 | Take Python primitives as returned from parsing JSON content, 236 | and return a Core API document. 237 | """ 238 | if isinstance(data, dict) and data.get('_type') == 'document': 239 | # Document 240 | meta = _get_dict(data, '_meta') 241 | url = _get_string(meta, 'url') 242 | url = urlparse.urljoin(base_url, url) 243 | title = _get_string(meta, 'title') 244 | description = _get_string(meta, 'description') 245 | content = _get_content(data, base_url=url) 246 | return Document( 247 | url=url, 248 | title=title, 249 | description=description, 250 | media_type='application/coreapi+json', 251 | content=content 252 | ) 253 | 254 | if isinstance(data, dict) and data.get('_type') == 'error': 255 | # Error 256 | meta = _get_dict(data, '_meta') 257 | title = _get_string(meta, 'title') 258 | content = _get_content(data, base_url=base_url) 259 | return Error(title=title, content=content) 260 | 261 | elif isinstance(data, dict) and data.get('_type') == 'link': 262 | # Link 263 | url = _get_string(data, 'url') 264 | url = urlparse.urljoin(base_url, url) 265 | action = _get_string(data, 'action') 266 | encoding = _get_string(data, 'encoding') 267 | transform = _get_string(data, 'transform') 268 | title = _get_string(data, 'title') 269 | description = _get_string(data, 'description') 270 | fields = _get_list(data, 'fields') 271 | fields = [ 272 | Field( 273 | name=_get_string(item, 'name'), 274 | required=_get_bool(item, 'required'), 275 | location=_get_string(item, 'location'), 276 | schema=_get_schema(item, 'schema') 277 | ) 278 | for item in fields if isinstance(item, dict) 279 | ] 280 | return Link( 281 | url=url, action=action, encoding=encoding, transform=transform, 282 | title=title, description=description, fields=fields 283 | ) 284 | 285 | elif isinstance(data, dict): 286 | # Map 287 | content = _get_content(data, base_url=base_url) 288 | return Object(content) 289 | 290 | elif isinstance(data, list): 291 | # Array 292 | content = [_primitive_to_document(item, base_url) for item in data] 293 | return Array(content) 294 | 295 | # String, Integer, Number, Boolean, null. 296 | return data 297 | 298 | 299 | class CoreJSONCodec(BaseCodec): 300 | media_type = 'application/coreapi+json' 301 | format = 'corejson' 302 | 303 | # The following is due to be deprecated... 304 | media_types = ['application/coreapi+json', 'application/vnd.coreapi+json'] 305 | 306 | def decode(self, bytestring, **options): 307 | """ 308 | Takes a bytestring and returns a document. 309 | """ 310 | base_url = options.get('base_url') 311 | 312 | try: 313 | data = json.loads(bytestring.decode('utf-8')) 314 | except ValueError as exc: 315 | raise ParseError('Malformed JSON. %s' % exc) 316 | 317 | doc = _primitive_to_document(data, base_url) 318 | 319 | if isinstance(doc, Object): 320 | doc = Document(content=dict(doc)) 321 | elif not (isinstance(doc, Document) or isinstance(doc, Error)): 322 | raise ParseError('Top level node should be a document or error.') 323 | 324 | return doc 325 | 326 | def encode(self, document, **options): 327 | """ 328 | Takes a document and returns a bytestring. 329 | """ 330 | indent = options.get('indent') 331 | 332 | if indent: 333 | kwargs = { 334 | 'ensure_ascii': False, 335 | 'indent': 4, 336 | 'separators': VERBOSE_SEPARATORS 337 | } 338 | else: 339 | kwargs = { 340 | 'ensure_ascii': False, 341 | 'indent': None, 342 | 'separators': COMPACT_SEPARATORS 343 | } 344 | 345 | data = _document_to_primitive(document) 346 | return force_bytes(json.dumps(data, **kwargs)) 347 | -------------------------------------------------------------------------------- /coreapi/codecs/display.py: -------------------------------------------------------------------------------- 1 | # Note that `DisplayCodec` is deliberately omitted from the documentation, 2 | # as it is considered an implementation detail. 3 | # It may move into a utility function in the future. 4 | from __future__ import unicode_literals 5 | from coreapi.codecs.base import BaseCodec 6 | from coreapi.compat import console_style, string_types 7 | from coreapi.document import Document, Link, Array, Object, Error 8 | import json 9 | 10 | 11 | def _colorize_document(text): 12 | return console_style(text, fg='green') # pragma: nocover 13 | 14 | 15 | def _colorize_error(text): 16 | return console_style(text, fg='red') # pragma: nocover 17 | 18 | 19 | def _colorize_keys(text): 20 | return console_style(text, fg='cyan') # pragma: nocover 21 | 22 | 23 | def _to_plaintext(node, indent=0, base_url=None, colorize=False, extra_offset=None): 24 | colorize_document = _colorize_document if colorize else lambda x: x 25 | colorize_error = _colorize_error if colorize else lambda x: x 26 | colorize_keys = _colorize_keys if colorize else lambda x: x 27 | 28 | if isinstance(node, Document): 29 | head_indent = ' ' * indent 30 | body_indent = ' ' * (indent + 1) 31 | 32 | body = '\n'.join([ 33 | body_indent + colorize_keys(str(key) + ': ') + 34 | _to_plaintext(value, indent + 1, base_url=base_url, colorize=colorize, extra_offset=len(str(key))) 35 | for key, value in node.data.items() 36 | ] + [ 37 | body_indent + colorize_keys(str(key) + '(') + 38 | _fields_to_plaintext(value, colorize=colorize) + colorize_keys(')') 39 | for key, value in node.links.items() 40 | ]) 41 | 42 | head = colorize_document('<%s %s>' % ( 43 | node.title.strip() or 'Document', 44 | json.dumps(node.url) 45 | )) 46 | 47 | return head if (not body) else head + '\n' + body 48 | 49 | elif isinstance(node, Object): 50 | head_indent = ' ' * indent 51 | body_indent = ' ' * (indent + 1) 52 | 53 | body = '\n'.join([ 54 | body_indent + colorize_keys(str(key)) + ': ' + 55 | _to_plaintext(value, indent + 1, base_url=base_url, colorize=colorize, extra_offset=len(str(key))) 56 | for key, value in node.data.items() 57 | ] + [ 58 | body_indent + colorize_keys(str(key) + '(') + 59 | _fields_to_plaintext(value, colorize=colorize) + colorize_keys(')') 60 | for key, value in node.links.items() 61 | ]) 62 | 63 | return '{}' if (not body) else '{\n' + body + '\n' + head_indent + '}' 64 | 65 | if isinstance(node, Error): 66 | head_indent = ' ' * indent 67 | body_indent = ' ' * (indent + 1) 68 | 69 | body = '\n'.join([ 70 | body_indent + colorize_keys(str(key) + ': ') + 71 | _to_plaintext(value, indent + 1, base_url=base_url, colorize=colorize, extra_offset=len(str(key))) 72 | for key, value in node.items() 73 | ]) 74 | 75 | head = colorize_error('' % node.title.strip() if node.title else '') 76 | 77 | return head if (not body) else head + '\n' + body 78 | 79 | elif isinstance(node, Array): 80 | head_indent = ' ' * indent 81 | body_indent = ' ' * (indent + 1) 82 | 83 | body = ',\n'.join([ 84 | body_indent + _to_plaintext(value, indent + 1, base_url=base_url, colorize=colorize) 85 | for value in node 86 | ]) 87 | 88 | return '[]' if (not body) else '[\n' + body + '\n' + head_indent + ']' 89 | 90 | elif isinstance(node, Link): 91 | return ( 92 | colorize_keys('link(') + 93 | _fields_to_plaintext(node, colorize=colorize) + 94 | colorize_keys(')') 95 | ) 96 | 97 | if isinstance(node, string_types) and (extra_offset is not None) and ('\n' in node): 98 | # Display newlines in strings gracefully. 99 | text = json.dumps(node) 100 | spacing = (' ' * indent) + (' ' * extra_offset) + ' ' 101 | return text.replace('\\n', '\n' + spacing) 102 | 103 | return json.dumps(node) 104 | 105 | 106 | def _fields_to_plaintext(link, colorize=False): 107 | colorize_keys = _colorize_keys if colorize else lambda x: x 108 | 109 | return colorize_keys(', ').join([ 110 | field.name for field in link.fields if field.required 111 | ] + [ 112 | '[%s]' % field.name for field in link.fields if not field.required 113 | ]) 114 | 115 | 116 | class DisplayCodec(BaseCodec): 117 | """ 118 | A plaintext representation of a Document, intended for readability. 119 | """ 120 | media_type = 'text/plain' 121 | 122 | def encode(self, document, **options): 123 | colorize = options.get('colorize', False) 124 | return _to_plaintext(document, colorize=colorize) 125 | -------------------------------------------------------------------------------- /coreapi/codecs/download.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from coreapi.codecs.base import BaseCodec 3 | from coreapi.compat import urlparse 4 | from coreapi.utils import DownloadedFile, guess_extension 5 | import cgi 6 | import os 7 | import posixpath 8 | import tempfile 9 | 10 | 11 | def _unique_output_path(path): 12 | """ 13 | Given a path like '/a/b/c.txt' 14 | 15 | Return the first available filename that doesn't already exist, 16 | using an incrementing suffix if needed. 17 | 18 | For example: '/a/b/c.txt' or '/a/b/c (1).txt' or '/a/b/c (2).txt'... 19 | """ 20 | basename, ext = os.path.splitext(path) 21 | idx = 0 22 | while os.path.exists(path): 23 | idx += 1 24 | path = "%s (%d)%s" % (basename, idx, ext) 25 | return path 26 | 27 | 28 | def _safe_filename(filename): 29 | """ 30 | Sanitize output filenames, to remove any potentially unsafe characters. 31 | """ 32 | filename = os.path.basename(filename) 33 | 34 | keepcharacters = (' ', '.', '_', '-') 35 | filename = ''.join( 36 | char for char in filename 37 | if char.isalnum() or char in keepcharacters 38 | ).strip().strip('.') 39 | 40 | return filename 41 | 42 | 43 | def _get_filename_from_content_disposition(content_disposition): 44 | """ 45 | Determine an output filename based on the `Content-Disposition` header. 46 | """ 47 | params = value, params = cgi.parse_header(content_disposition) 48 | 49 | if 'filename*' in params: 50 | try: 51 | charset, lang, filename = params['filename*'].split('\'', 2) 52 | filename = urlparse.unquote(filename) 53 | filename = filename.encode('iso-8859-1').decode(charset) 54 | return _safe_filename(filename) 55 | except (ValueError, LookupError): 56 | pass 57 | 58 | if 'filename' in params: 59 | filename = params['filename'] 60 | return _safe_filename(filename) 61 | 62 | return None 63 | 64 | 65 | def _get_filename_from_url(url, content_type=None): 66 | """ 67 | Determine an output filename based on the download URL. 68 | """ 69 | parsed = urlparse.urlparse(url) 70 | final_path_component = posixpath.basename(parsed.path.rstrip('/')) 71 | filename = _safe_filename(final_path_component) 72 | suffix = guess_extension(content_type or '') 73 | 74 | if filename: 75 | if '.' not in filename: 76 | return filename + suffix 77 | return filename 78 | elif suffix: 79 | return 'download' + suffix 80 | 81 | return None 82 | 83 | 84 | def _get_filename(base_url=None, content_type=None, content_disposition=None): 85 | """ 86 | Determine an output filename to use for the download. 87 | """ 88 | filename = None 89 | if content_disposition: 90 | filename = _get_filename_from_content_disposition(content_disposition) 91 | if base_url and not filename: 92 | filename = _get_filename_from_url(base_url, content_type) 93 | if not filename: 94 | return None # Ensure empty filenames return as `None` for consistency. 95 | return filename 96 | 97 | 98 | class DownloadCodec(BaseCodec): 99 | """ 100 | A codec to handle raw file downloads, such as images and other media. 101 | """ 102 | media_type = '*/*' 103 | format = 'download' 104 | 105 | def __init__(self, download_dir=None): 106 | """ 107 | `download_dir` - The path to use for file downloads. 108 | """ 109 | self._delete_on_close = download_dir is None 110 | self._download_dir = download_dir 111 | 112 | @property 113 | def download_dir(self): 114 | return self._download_dir 115 | 116 | def decode(self, bytestring, **options): 117 | base_url = options.get('base_url') 118 | content_type = options.get('content_type') 119 | content_disposition = options.get('content_disposition') 120 | 121 | # Write the download to a temporary .download file. 122 | fd, temp_path = tempfile.mkstemp(suffix='.download') 123 | file_handle = os.fdopen(fd, 'wb') 124 | file_handle.write(bytestring) 125 | file_handle.close() 126 | 127 | # Determine the output filename. 128 | output_filename = _get_filename(base_url, content_type, content_disposition) 129 | if output_filename is None: 130 | output_filename = os.path.basename(temp_path) 131 | 132 | # Determine the output directory. 133 | output_dir = self._download_dir 134 | if output_dir is None: 135 | output_dir = os.path.dirname(temp_path) 136 | 137 | # Determine the full output path. 138 | output_path = os.path.join(output_dir, output_filename) 139 | 140 | # Move the temporary download file to the final location. 141 | if output_path != temp_path: 142 | output_path = _unique_output_path(output_path) 143 | os.rename(temp_path, output_path) 144 | 145 | # Open the file and return the file object. 146 | output_file = open(output_path, 'rb') 147 | downloaded = DownloadedFile(output_file, output_path, delete=self._delete_on_close) 148 | downloaded.basename = output_filename 149 | return downloaded 150 | -------------------------------------------------------------------------------- /coreapi/codecs/jsondata.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from coreapi.codecs.base import BaseCodec 3 | from coreapi.exceptions import ParseError 4 | import collections 5 | import json 6 | 7 | 8 | class JSONCodec(BaseCodec): 9 | media_type = 'application/json' 10 | format = 'json' 11 | 12 | def decode(self, bytestring, **options): 13 | """ 14 | Return raw JSON data. 15 | """ 16 | try: 17 | return json.loads( 18 | bytestring.decode('utf-8'), 19 | object_pairs_hook=collections.OrderedDict 20 | ) 21 | except ValueError as exc: 22 | raise ParseError('Malformed JSON. %s' % exc) 23 | -------------------------------------------------------------------------------- /coreapi/codecs/python.py: -------------------------------------------------------------------------------- 1 | # Note that `DisplayCodec` is deliberately omitted from the documentation, 2 | # as it is considered an implementation detail. 3 | # It may move into a utility function in the future. 4 | from __future__ import unicode_literals 5 | from coreapi.codecs.base import BaseCodec 6 | from coreapi.document import Document, Link, Array, Object, Error, Field 7 | 8 | 9 | def _to_repr(node): 10 | if isinstance(node, Document): 11 | content = ', '.join([ 12 | '%s: %s' % (repr(key), _to_repr(value)) 13 | for key, value in node.items() 14 | ]) 15 | return 'Document(url=%s, title=%s, content={%s})' % ( 16 | repr(node.url), repr(node.title), content 17 | ) 18 | 19 | elif isinstance(node, Error): 20 | content = ', '.join([ 21 | '%s: %s' % (repr(key), _to_repr(value)) 22 | for key, value in node.items() 23 | ]) 24 | return 'Error(title=%s, content={%s})' % ( 25 | repr(node.title), content 26 | ) 27 | 28 | elif isinstance(node, Object): 29 | return '{%s}' % ', '.join([ 30 | '%s: %s' % (repr(key), _to_repr(value)) 31 | for key, value in node.items() 32 | ]) 33 | 34 | elif isinstance(node, Array): 35 | return '[%s]' % ', '.join([ 36 | _to_repr(value) for value in node 37 | ]) 38 | 39 | elif isinstance(node, Link): 40 | args = "url=%s" % repr(node.url) 41 | if node.action: 42 | args += ", action=%s" % repr(node.action) 43 | if node.encoding: 44 | args += ", encoding=%s" % repr(node.encoding) 45 | if node.transform: 46 | args += ", transform=%s" % repr(node.transform) 47 | if node.description: 48 | args += ", description=%s" % repr(node.description) 49 | if node.fields: 50 | fields_repr = ', '.join(_to_repr(item) for item in node.fields) 51 | args += ", fields=[%s]" % fields_repr 52 | return "Link(%s)" % args 53 | 54 | elif isinstance(node, Field): 55 | args = repr(node.name) 56 | if not node.required and not node.location: 57 | return args 58 | if node.required: 59 | args += ', required=True' 60 | if node.location: 61 | args += ', location=%s' % repr(node.location) 62 | if node.schema: 63 | args += ', schema=%s' % repr(node.schema) 64 | return 'Field(%s)' % args 65 | 66 | return repr(node) 67 | 68 | 69 | class PythonCodec(BaseCodec): 70 | """ 71 | A Python representation of a Document, for use with '__repr__'. 72 | """ 73 | media_type = 'text/python' 74 | 75 | def encode(self, document, **options): 76 | # Object and Array only have the class name wrapper if they 77 | # are the outermost element. 78 | if isinstance(document, Object): 79 | return 'Object(%s)' % _to_repr(document) 80 | elif isinstance(document, Array): 81 | return 'Array(%s)' % _to_repr(document) 82 | return _to_repr(document) 83 | -------------------------------------------------------------------------------- /coreapi/codecs/text.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from coreapi.codecs.base import BaseCodec 3 | 4 | 5 | class TextCodec(BaseCodec): 6 | media_type = 'text/*' 7 | format = 'text' 8 | 9 | def decode(self, bytestring, **options): 10 | return bytestring.decode('utf-8') 11 | -------------------------------------------------------------------------------- /coreapi/compat.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import base64 4 | 5 | 6 | __all__ = [ 7 | 'urlparse', 'string_types', 8 | 'COMPACT_SEPARATORS', 'VERBOSE_SEPARATORS' 9 | ] 10 | 11 | 12 | try: 13 | # Python 2 14 | import urlparse 15 | import cookielib as cookiejar 16 | 17 | string_types = (basestring,) 18 | text_type = unicode 19 | COMPACT_SEPARATORS = (b',', b':') 20 | VERBOSE_SEPARATORS = (b',', b': ') 21 | 22 | def b64encode(input_string): 23 | # Provide a consistently-as-unicode interface across 2.x and 3.x 24 | return base64.b64encode(input_string) 25 | 26 | except ImportError: 27 | # Python 3 28 | import urllib.parse as urlparse 29 | from io import IOBase 30 | from http import cookiejar 31 | 32 | string_types = (str,) 33 | text_type = str 34 | COMPACT_SEPARATORS = (',', ':') 35 | VERBOSE_SEPARATORS = (',', ': ') 36 | 37 | def b64encode(input_string): 38 | # Provide a consistently-as-unicode interface across 2.x and 3.x 39 | return base64.b64encode(input_string.encode('ascii')).decode('ascii') 40 | 41 | 42 | def force_bytes(string): 43 | if isinstance(string, string_types): 44 | return string.encode('utf-8') 45 | return string 46 | 47 | 48 | def force_text(string): 49 | if not isinstance(string, string_types): 50 | return string.decode('utf-8') 51 | return string 52 | 53 | 54 | try: 55 | import click 56 | console_style = click.style 57 | except ImportError: 58 | def console_style(text, **kwargs): 59 | return text 60 | 61 | 62 | try: 63 | from tempfile import _TemporaryFileWrapper 64 | except ImportError: 65 | _TemporaryFileWrapper = None 66 | -------------------------------------------------------------------------------- /coreapi/document.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals 3 | from collections import OrderedDict, namedtuple 4 | from coreapi.compat import string_types 5 | import itypes 6 | 7 | 8 | def _to_immutable(value): 9 | if isinstance(value, dict): 10 | return Object(value) 11 | elif isinstance(value, list): 12 | return Array(value) 13 | return value 14 | 15 | 16 | def _repr(node): 17 | from coreapi.codecs.python import PythonCodec 18 | return PythonCodec().encode(node) 19 | 20 | 21 | def _str(node): 22 | from coreapi.codecs.display import DisplayCodec 23 | return DisplayCodec().encode(node) 24 | 25 | 26 | def _key_sorting(item): 27 | """ 28 | Document and Object sorting. 29 | Regular attributes sorted alphabetically. 30 | Links are sorted based on their URL and action. 31 | """ 32 | key, value = item 33 | if isinstance(value, Link): 34 | action_priority = { 35 | 'get': 0, 36 | 'post': 1, 37 | 'put': 2, 38 | 'patch': 3, 39 | 'delete': 4 40 | }.get(value.action, 5) 41 | return (1, (value.url, action_priority)) 42 | return (0, key) 43 | 44 | 45 | # The field class, as used by Link objects: 46 | 47 | # NOTE: 'type', 'description' and 'example' are now deprecated, 48 | # in favor of 'schema'. 49 | Field = namedtuple('Field', ['name', 'required', 'location', 'schema', 'description', 'type', 'example']) 50 | Field.__new__.__defaults__ = (False, '', None, None, None, None) 51 | 52 | 53 | # The Core API primitives: 54 | 55 | class Document(itypes.Dict): 56 | """ 57 | The Core API document type. 58 | 59 | Expresses the data that the client may access, 60 | and the actions that the client may perform. 61 | """ 62 | def __init__(self, url=None, title=None, description=None, media_type=None, content=None): 63 | content = {} if (content is None) else content 64 | 65 | if url is not None and not isinstance(url, string_types): 66 | raise TypeError("'url' must be a string.") 67 | if title is not None and not isinstance(title, string_types): 68 | raise TypeError("'title' must be a string.") 69 | if description is not None and not isinstance(description, string_types): 70 | raise TypeError("'description' must be a string.") 71 | if media_type is not None and not isinstance(media_type, string_types): 72 | raise TypeError("'media_type' must be a string.") 73 | if not isinstance(content, dict): 74 | raise TypeError("'content' must be a dict.") 75 | if any([not isinstance(key, string_types) for key in content.keys()]): 76 | raise TypeError('content keys must be strings.') 77 | 78 | self._url = '' if (url is None) else url 79 | self._title = '' if (title is None) else title 80 | self._description = '' if (description is None) else description 81 | self._media_type = '' if (media_type is None) else media_type 82 | self._data = {key: _to_immutable(value) for key, value in content.items()} 83 | 84 | def clone(self, data): 85 | return self.__class__(self.url, self.title, self.description, self.media_type, data) 86 | 87 | def __iter__(self): 88 | items = sorted(self._data.items(), key=_key_sorting) 89 | return iter([key for key, value in items]) 90 | 91 | def __repr__(self): 92 | return _repr(self) 93 | 94 | def __str__(self): 95 | return _str(self) 96 | 97 | def __eq__(self, other): 98 | if self.__class__ == other.__class__: 99 | return ( 100 | self.url == other.url and 101 | self.title == other.title and 102 | self._data == other._data 103 | ) 104 | return super(Document, self).__eq__(other) 105 | 106 | @property 107 | def url(self): 108 | return self._url 109 | 110 | @property 111 | def title(self): 112 | return self._title 113 | 114 | @property 115 | def description(self): 116 | return self._description 117 | 118 | @property 119 | def media_type(self): 120 | return self._media_type 121 | 122 | @property 123 | def data(self): 124 | return OrderedDict([ 125 | (key, value) for key, value in self.items() 126 | if not isinstance(value, Link) 127 | ]) 128 | 129 | @property 130 | def links(self): 131 | return OrderedDict([ 132 | (key, value) for key, value in self.items() 133 | if isinstance(value, Link) 134 | ]) 135 | 136 | 137 | class Object(itypes.Dict): 138 | """ 139 | An immutable mapping of strings to values. 140 | """ 141 | def __init__(self, *args, **kwargs): 142 | data = dict(*args, **kwargs) 143 | if any([not isinstance(key, string_types) for key in data.keys()]): 144 | raise TypeError('Object keys must be strings.') 145 | self._data = {key: _to_immutable(value) for key, value in data.items()} 146 | 147 | def __iter__(self): 148 | items = sorted(self._data.items(), key=_key_sorting) 149 | return iter([key for key, value in items]) 150 | 151 | def __repr__(self): 152 | return _repr(self) 153 | 154 | def __str__(self): 155 | return _str(self) 156 | 157 | @property 158 | def data(self): 159 | return OrderedDict([ 160 | (key, value) for key, value in self.items() 161 | if not isinstance(value, Link) 162 | ]) 163 | 164 | @property 165 | def links(self): 166 | return OrderedDict([ 167 | (key, value) for key, value in self.items() 168 | if isinstance(value, Link) 169 | ]) 170 | 171 | 172 | class Array(itypes.List): 173 | """ 174 | An immutable list type container. 175 | """ 176 | def __init__(self, *args): 177 | self._data = [_to_immutable(value) for value in list(*args)] 178 | 179 | def __repr__(self): 180 | return _repr(self) 181 | 182 | def __str__(self): 183 | return _str(self) 184 | 185 | 186 | class Link(itypes.Object): 187 | """ 188 | Links represent the actions that a client may perform. 189 | """ 190 | def __init__(self, url=None, action=None, encoding=None, transform=None, title=None, description=None, fields=None): 191 | if (url is not None) and (not isinstance(url, string_types)): 192 | raise TypeError("Argument 'url' must be a string.") 193 | if (action is not None) and (not isinstance(action, string_types)): 194 | raise TypeError("Argument 'action' must be a string.") 195 | if (encoding is not None) and (not isinstance(encoding, string_types)): 196 | raise TypeError("Argument 'encoding' must be a string.") 197 | if (transform is not None) and (not isinstance(transform, string_types)): 198 | raise TypeError("Argument 'transform' must be a string.") 199 | if (title is not None) and (not isinstance(title, string_types)): 200 | raise TypeError("Argument 'title' must be a string.") 201 | if (description is not None) and (not isinstance(description, string_types)): 202 | raise TypeError("Argument 'description' must be a string.") 203 | if (fields is not None) and (not isinstance(fields, (list, tuple))): 204 | raise TypeError("Argument 'fields' must be a list.") 205 | if (fields is not None) and any([ 206 | not (isinstance(item, string_types) or isinstance(item, Field)) 207 | for item in fields 208 | ]): 209 | raise TypeError("Argument 'fields' must be a list of strings or fields.") 210 | 211 | self._url = '' if (url is None) else url 212 | self._action = '' if (action is None) else action 213 | self._encoding = '' if (encoding is None) else encoding 214 | self._transform = '' if (transform is None) else transform 215 | self._title = '' if (title is None) else title 216 | self._description = '' if (description is None) else description 217 | self._fields = () if (fields is None) else tuple([ 218 | item if isinstance(item, Field) else Field(item, required=False, location='') 219 | for item in fields 220 | ]) 221 | 222 | @property 223 | def url(self): 224 | return self._url 225 | 226 | @property 227 | def action(self): 228 | return self._action 229 | 230 | @property 231 | def encoding(self): 232 | return self._encoding 233 | 234 | @property 235 | def transform(self): 236 | return self._transform 237 | 238 | @property 239 | def title(self): 240 | return self._title 241 | 242 | @property 243 | def description(self): 244 | return self._description 245 | 246 | @property 247 | def fields(self): 248 | return self._fields 249 | 250 | def __eq__(self, other): 251 | return ( 252 | isinstance(other, Link) and 253 | self.url == other.url and 254 | self.action == other.action and 255 | self.encoding == other.encoding and 256 | self.transform == other.transform and 257 | self.description == other.description and 258 | sorted(self.fields, key=lambda f: f.name) == sorted(other.fields, key=lambda f: f.name) 259 | ) 260 | 261 | def __repr__(self): 262 | return _repr(self) 263 | 264 | def __str__(self): 265 | return _str(self) 266 | 267 | 268 | class Error(itypes.Dict): 269 | def __init__(self, title=None, content=None): 270 | data = {} if (content is None) else content 271 | 272 | if title is not None and not isinstance(title, string_types): 273 | raise TypeError("'title' must be a string.") 274 | if content is not None and not isinstance(content, dict): 275 | raise TypeError("'content' must be a dict.") 276 | if any([not isinstance(key, string_types) for key in data.keys()]): 277 | raise TypeError('content keys must be strings.') 278 | 279 | self._title = '' if (title is None) else title 280 | self._data = {key: _to_immutable(value) for key, value in data.items()} 281 | 282 | def __iter__(self): 283 | items = sorted(self._data.items(), key=_key_sorting) 284 | return iter([key for key, value in items]) 285 | 286 | def __repr__(self): 287 | return _repr(self) 288 | 289 | def __str__(self): 290 | return _str(self) 291 | 292 | def __eq__(self, other): 293 | return ( 294 | isinstance(other, Error) and 295 | self.title == other.title and 296 | self._data == other._data 297 | ) 298 | 299 | @property 300 | def title(self): 301 | return self._title 302 | 303 | def get_messages(self): 304 | messages = [] 305 | for value in self.values(): 306 | if isinstance(value, Array): 307 | messages += [ 308 | item for item in value if isinstance(item, string_types) 309 | ] 310 | return messages 311 | -------------------------------------------------------------------------------- /coreapi/exceptions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals 3 | 4 | 5 | class CoreAPIException(Exception): 6 | """ 7 | A base class for all `coreapi` exceptions. 8 | """ 9 | pass 10 | 11 | 12 | class ParseError(CoreAPIException): 13 | """ 14 | Raised when an invalid Core API encoding is encountered. 15 | """ 16 | pass 17 | 18 | 19 | class NoCodecAvailable(CoreAPIException): 20 | """ 21 | Raised when there is no available codec that can handle the given media. 22 | """ 23 | pass 24 | 25 | 26 | class NetworkError(CoreAPIException): 27 | """ 28 | Raised when the transport layer fails to make a request or get a response. 29 | """ 30 | pass 31 | 32 | 33 | class LinkLookupError(CoreAPIException): 34 | """ 35 | Raised when `.action` fails to index a link in the document. 36 | """ 37 | pass 38 | 39 | 40 | class ParameterError(CoreAPIException): 41 | """ 42 | Raised when the parameters passed do not match the link fields. 43 | 44 | * A required field was not included. 45 | * An unknown field was included. 46 | * A field was passed an invalid type for the link location/encoding. 47 | """ 48 | pass 49 | 50 | 51 | class ErrorMessage(CoreAPIException): 52 | """ 53 | Raised when the transition returns an error message. 54 | """ 55 | def __init__(self, error): 56 | self.error = error 57 | 58 | def __repr__(self): 59 | return '%s(%s)' % (self.__class__.__name__, repr(self.error)) 60 | 61 | def __str__(self): 62 | return str(self.error) 63 | -------------------------------------------------------------------------------- /coreapi/transports/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from coreapi.transports.base import BaseTransport 3 | from coreapi.transports.http import HTTPTransport 4 | 5 | 6 | __all__ = [ 7 | 'BaseTransport', 'HTTPTransport' 8 | ] 9 | -------------------------------------------------------------------------------- /coreapi/transports/base.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import itypes 3 | 4 | 5 | class BaseTransport(itypes.Object): 6 | schemes = None 7 | 8 | def transition(self, link, decoders, params=None, link_ancestors=None, force_codec=False): 9 | raise NotImplementedError() # pragma: nocover 10 | -------------------------------------------------------------------------------- /coreapi/transports/http.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals 3 | from collections import OrderedDict 4 | from coreapi import exceptions, utils 5 | from coreapi.compat import cookiejar, urlparse 6 | from coreapi.document import Document, Object, Link, Array, Error 7 | from coreapi.transports.base import BaseTransport 8 | from coreapi.utils import guess_filename, is_file, File 9 | import collections 10 | import requests 11 | import itypes 12 | import mimetypes 13 | import uritemplate 14 | import warnings 15 | 16 | 17 | Params = collections.namedtuple('Params', ['path', 'query', 'data', 'files']) 18 | empty_params = Params({}, {}, {}, {}) 19 | 20 | 21 | class ForceMultiPartDict(dict): 22 | """ 23 | A dictionary that always evaluates as True. 24 | Allows us to force requests to use multipart encoding, even when no 25 | file parameters are passed. 26 | """ 27 | def __bool__(self): 28 | return True 29 | 30 | def __nonzero__(self): 31 | return True 32 | 33 | 34 | class BlockAll(cookiejar.CookiePolicy): 35 | """ 36 | A cookie policy that rejects all cookies. 37 | Used to override the default `requests` behavior. 38 | """ 39 | return_ok = set_ok = domain_return_ok = path_return_ok = lambda self, *args, **kwargs: False 40 | netscape = True 41 | rfc2965 = hide_cookie2 = False 42 | 43 | 44 | class DomainCredentials(requests.auth.AuthBase): 45 | """ 46 | Custom auth class to support deprecated 'credentials' argument. 47 | """ 48 | allow_cookies = False 49 | credentials = None 50 | 51 | def __init__(self, credentials=None): 52 | self.credentials = credentials 53 | 54 | def __call__(self, request): 55 | if not self.credentials: 56 | return request 57 | 58 | # Include any authorization credentials relevant to this domain. 59 | url_components = urlparse.urlparse(request.url) 60 | host = url_components.hostname 61 | if host in self.credentials: 62 | request.headers['Authorization'] = self.credentials[host] 63 | return request 64 | 65 | 66 | class CallbackAdapter(requests.adapters.HTTPAdapter): 67 | """ 68 | Custom requests HTTP adapter, to support deprecated callback arguments. 69 | """ 70 | def __init__(self, request_callback=None, response_callback=None): 71 | self.request_callback = request_callback 72 | self.response_callback = response_callback 73 | 74 | def send(self, request, **kwargs): 75 | if self.request_callback is not None: 76 | self.request_callback(request) 77 | response = super(CallbackAdapter, self).send(request, **kwargs) 78 | if self.response_callback is not None: 79 | self.response_callback(response) 80 | return response 81 | 82 | 83 | def _get_method(action): 84 | if not action: 85 | return 'GET' 86 | return action.upper() 87 | 88 | 89 | def _get_encoding(encoding): 90 | if not encoding: 91 | return 'application/json' 92 | return encoding 93 | 94 | 95 | def _get_params(method, encoding, fields, params=None): 96 | """ 97 | Separate the params into the various types. 98 | """ 99 | if params is None: 100 | return empty_params 101 | 102 | field_map = {field.name: field for field in fields} 103 | 104 | path = {} 105 | query = {} 106 | data = {} 107 | files = {} 108 | 109 | errors = {} 110 | 111 | # Ensure graceful behavior in edge-case where both location='body' and 112 | # location='form' fields are present. 113 | seen_body = False 114 | 115 | for key, value in params.items(): 116 | if key not in field_map or not field_map[key].location: 117 | # Default is 'query' for 'GET' and 'DELETE', and 'form' for others. 118 | location = 'query' if method in ('GET', 'DELETE') else 'form' 119 | else: 120 | location = field_map[key].location 121 | 122 | if location == 'form' and encoding == 'application/octet-stream': 123 | # Raw uploads should always use 'body', not 'form'. 124 | location = 'body' 125 | 126 | try: 127 | if location == 'path': 128 | path[key] = utils.validate_path_param(value) 129 | elif location == 'query': 130 | query[key] = utils.validate_query_param(value) 131 | elif location == 'body': 132 | data = utils.validate_body_param(value, encoding=encoding) 133 | seen_body = True 134 | elif location == 'form': 135 | if not seen_body: 136 | data[key] = utils.validate_form_param(value, encoding=encoding) 137 | except exceptions.ParameterError as exc: 138 | errors[key] = "%s" % exc 139 | 140 | if errors: 141 | raise exceptions.ParameterError(errors) 142 | 143 | # Move any files from 'data' into 'files'. 144 | if isinstance(data, dict): 145 | for key, value in list(data.items()): 146 | if is_file(data[key]): 147 | files[key] = data.pop(key) 148 | 149 | return Params(path, query, data, files) 150 | 151 | 152 | def _get_url(url, path_params): 153 | """ 154 | Given a templated URL and some parameters that have been provided, 155 | expand the URL. 156 | """ 157 | if path_params: 158 | return uritemplate.expand(url, path_params) 159 | return url 160 | 161 | 162 | def _get_headers(url, decoders): 163 | """ 164 | Return a dictionary of HTTP headers to use in the outgoing request. 165 | """ 166 | accept_media_types = decoders[0].get_media_types() 167 | if '*/*' not in accept_media_types: 168 | accept_media_types.append('*/*') 169 | 170 | headers = { 171 | 'accept': ', '.join(accept_media_types), 172 | 'user-agent': 'coreapi' 173 | } 174 | 175 | return headers 176 | 177 | 178 | def _get_upload_headers(file_obj): 179 | """ 180 | When a raw file upload is made, determine the Content-Type and 181 | Content-Disposition headers to use with the request. 182 | """ 183 | name = guess_filename(file_obj) 184 | content_type = None 185 | content_disposition = None 186 | 187 | # Determine the content type of the upload. 188 | if getattr(file_obj, 'content_type', None): 189 | content_type = file_obj.content_type 190 | elif name: 191 | content_type, encoding = mimetypes.guess_type(name) 192 | 193 | # Determine the content disposition of the upload. 194 | if name: 195 | content_disposition = 'attachment; filename="%s"' % name 196 | 197 | return { 198 | 'Content-Type': content_type or 'application/octet-stream', 199 | 'Content-Disposition': content_disposition or 'attachment' 200 | } 201 | 202 | 203 | def _build_http_request(session, url, method, headers=None, encoding=None, params=empty_params): 204 | """ 205 | Make an HTTP request and return an HTTP response. 206 | """ 207 | opts = { 208 | "headers": headers or {} 209 | } 210 | 211 | if params.query: 212 | opts['params'] = params.query 213 | 214 | if params.data or params.files: 215 | if encoding == 'application/json': 216 | opts['json'] = params.data 217 | elif encoding == 'multipart/form-data': 218 | opts['data'] = params.data 219 | opts['files'] = ForceMultiPartDict(params.files) 220 | elif encoding == 'application/x-www-form-urlencoded': 221 | opts['data'] = params.data 222 | elif encoding == 'application/octet-stream': 223 | if isinstance(params.data, File): 224 | opts['data'] = params.data.content 225 | else: 226 | opts['data'] = params.data 227 | upload_headers = _get_upload_headers(params.data) 228 | opts['headers'].update(upload_headers) 229 | 230 | request = requests.Request(method, url, **opts) 231 | return session.prepare_request(request) 232 | 233 | 234 | def _coerce_to_error_content(node): 235 | """ 236 | Errors should not contain nested documents or links. 237 | If we get a 4xx or 5xx response with a Document, then coerce 238 | the document content into plain data. 239 | """ 240 | if isinstance(node, (Document, Object)): 241 | # Strip Links from Documents, treat Documents as plain dicts. 242 | return OrderedDict([ 243 | (key, _coerce_to_error_content(value)) 244 | for key, value in node.data.items() 245 | ]) 246 | elif isinstance(node, Array): 247 | # Strip Links from Arrays. 248 | return [ 249 | _coerce_to_error_content(item) 250 | for item in node 251 | if not isinstance(item, Link) 252 | ] 253 | return node 254 | 255 | 256 | def _coerce_to_error(obj, default_title): 257 | """ 258 | Given an arbitrary return result, coerce it into an Error instance. 259 | """ 260 | if isinstance(obj, Document): 261 | return Error( 262 | title=obj.title or default_title, 263 | content=_coerce_to_error_content(obj) 264 | ) 265 | elif isinstance(obj, dict): 266 | return Error(title=default_title, content=obj) 267 | elif isinstance(obj, list): 268 | return Error(title=default_title, content={'messages': obj}) 269 | elif obj is None: 270 | return Error(title=default_title) 271 | return Error(title=default_title, content={'message': obj}) 272 | 273 | 274 | def _decode_result(response, decoders, force_codec=False): 275 | """ 276 | Given an HTTP response, return the decoded Core API document. 277 | """ 278 | if response.content: 279 | # Content returned in response. We should decode it. 280 | if force_codec: 281 | codec = decoders[0] 282 | else: 283 | content_type = response.headers.get('content-type') 284 | codec = utils.negotiate_decoder(decoders, content_type) 285 | 286 | options = { 287 | 'base_url': response.url 288 | } 289 | if 'content-type' in response.headers: 290 | options['content_type'] = response.headers['content-type'] 291 | if 'content-disposition' in response.headers: 292 | options['content_disposition'] = response.headers['content-disposition'] 293 | 294 | result = codec.load(response.content, **options) 295 | else: 296 | # No content returned in response. 297 | result = None 298 | 299 | # Coerce 4xx and 5xx codes into errors. 300 | is_error = response.status_code >= 400 and response.status_code <= 599 301 | if is_error and not isinstance(result, Error): 302 | default_title = '%d %s' % (response.status_code, response.reason) 303 | result = _coerce_to_error(result, default_title=default_title) 304 | 305 | return result 306 | 307 | 308 | def _handle_inplace_replacements(document, link, link_ancestors): 309 | """ 310 | Given a new document, and the link/ancestors it was created, 311 | determine if we should: 312 | 313 | * Make an inline replacement and then return the modified document tree. 314 | * Return the new document as-is. 315 | """ 316 | if not link.transform: 317 | if link.action.lower() in ('put', 'patch', 'delete'): 318 | transform = 'inplace' 319 | else: 320 | transform = 'new' 321 | else: 322 | transform = link.transform 323 | 324 | if transform == 'inplace': 325 | root = link_ancestors[0].document 326 | keys_to_link_parent = link_ancestors[-1].keys 327 | if document is None: 328 | return root.delete_in(keys_to_link_parent) 329 | return root.set_in(keys_to_link_parent, document) 330 | 331 | return document 332 | 333 | 334 | class HTTPTransport(BaseTransport): 335 | schemes = ['http', 'https'] 336 | 337 | def __init__(self, credentials=None, headers=None, auth=None, session=None, request_callback=None, response_callback=None): 338 | if headers: 339 | headers = {key.lower(): value for key, value in headers.items()} 340 | if session is None: 341 | session = requests.Session() 342 | if auth is not None: 343 | session.auth = auth 344 | if not getattr(session.auth, 'allow_cookies', False): 345 | session.cookies.set_policy(BlockAll()) 346 | 347 | if credentials is not None: 348 | warnings.warn( 349 | "The 'credentials' argument is now deprecated in favor of 'auth'.", 350 | DeprecationWarning 351 | ) 352 | auth = DomainCredentials(credentials) 353 | if request_callback is not None or response_callback is not None: 354 | warnings.warn( 355 | "The 'request_callback' and 'response_callback' arguments are now deprecated. " 356 | "Use a custom 'session' instance instead.", 357 | DeprecationWarning 358 | ) 359 | session.mount('https://', CallbackAdapter(request_callback, response_callback)) 360 | session.mount('http://', CallbackAdapter(request_callback, response_callback)) 361 | 362 | self._headers = itypes.Dict(headers or {}) 363 | self._session = session 364 | 365 | @property 366 | def headers(self): 367 | return self._headers 368 | 369 | def transition(self, link, decoders, params=None, link_ancestors=None, force_codec=False): 370 | session = self._session 371 | method = _get_method(link.action) 372 | encoding = _get_encoding(link.encoding) 373 | params = _get_params(method, encoding, link.fields, params) 374 | url = _get_url(link.url, params.path) 375 | headers = _get_headers(url, decoders) 376 | headers.update(self.headers) 377 | 378 | request = _build_http_request(session, url, method, headers, encoding, params) 379 | settings = session.merge_environment_settings(request.url, None, None, None, None) 380 | response = session.send(request, **settings) 381 | result = _decode_result(response, decoders, force_codec) 382 | 383 | if isinstance(result, Document) and link_ancestors: 384 | result = _handle_inplace_replacements(result, link, link_ancestors) 385 | 386 | if isinstance(result, Error): 387 | raise exceptions.ErrorMessage(result) 388 | 389 | return result 390 | -------------------------------------------------------------------------------- /coreapi/utils.py: -------------------------------------------------------------------------------- 1 | from coreapi import exceptions 2 | from coreapi.compat import string_types, text_type, urlparse, _TemporaryFileWrapper 3 | from collections import namedtuple 4 | import os 5 | import pkg_resources 6 | import tempfile 7 | 8 | 9 | def domain_matches(request, domain): 10 | """ 11 | Domain string matching against an outgoing request. 12 | Patterns starting with '*' indicate a wildcard domain. 13 | """ 14 | if (domain is None) or (domain == '*'): 15 | return True 16 | 17 | host = urlparse.urlparse(request.url).hostname 18 | if domain.startswith('*'): 19 | return host.endswith(domain[1:]) 20 | return host == domain 21 | 22 | 23 | def get_installed_codecs(): 24 | packages = [ 25 | (package, package.load()) for package in 26 | pkg_resources.iter_entry_points(group='coreapi.codecs') 27 | ] 28 | return { 29 | package.name: cls() for (package, cls) in packages 30 | } 31 | 32 | 33 | # File utilities for upload and download support. 34 | 35 | File = namedtuple('File', 'name content content_type') 36 | File.__new__.__defaults__ = (None,) 37 | 38 | 39 | def is_file(obj): 40 | if isinstance(obj, File): 41 | return True 42 | 43 | if hasattr(obj, '__iter__') and not isinstance(obj, (string_types, list, tuple, dict)): 44 | # A stream object. 45 | return True 46 | 47 | return False 48 | 49 | 50 | def guess_filename(obj): 51 | name = getattr(obj, 'name', None) 52 | if name and isinstance(name, string_types) and name[0] != '<' and name[-1] != '>': 53 | return os.path.basename(name) 54 | return None 55 | 56 | 57 | def guess_extension(content_type): 58 | """ 59 | Python's `mimetypes.guess_extension` is no use because it simply returns 60 | the first of an unordered set. We use the same set of media types here, 61 | but take a reasonable preference on what extension to map to. 62 | """ 63 | return { 64 | 'application/javascript': '.js', 65 | 'application/msword': '.doc', 66 | 'application/octet-stream': '.bin', 67 | 'application/oda': '.oda', 68 | 'application/pdf': '.pdf', 69 | 'application/pkcs7-mime': '.p7c', 70 | 'application/postscript': '.ps', 71 | 'application/vnd.apple.mpegurl': '.m3u', 72 | 'application/vnd.ms-excel': '.xls', 73 | 'application/vnd.ms-powerpoint': '.ppt', 74 | 'application/x-bcpio': '.bcpio', 75 | 'application/x-cpio': '.cpio', 76 | 'application/x-csh': '.csh', 77 | 'application/x-dvi': '.dvi', 78 | 'application/x-gtar': '.gtar', 79 | 'application/x-hdf': '.hdf', 80 | 'application/x-latex': '.latex', 81 | 'application/x-mif': '.mif', 82 | 'application/x-netcdf': '.nc', 83 | 'application/x-pkcs12': '.p12', 84 | 'application/x-pn-realaudio': '.ram', 85 | 'application/x-python-code': '.pyc', 86 | 'application/x-sh': '.sh', 87 | 'application/x-shar': '.shar', 88 | 'application/x-shockwave-flash': '.swf', 89 | 'application/x-sv4cpio': '.sv4cpio', 90 | 'application/x-sv4crc': '.sv4crc', 91 | 'application/x-tar': '.tar', 92 | 'application/x-tcl': '.tcl', 93 | 'application/x-tex': '.tex', 94 | 'application/x-texinfo': '.texinfo', 95 | 'application/x-troff': '.tr', 96 | 'application/x-troff-man': '.man', 97 | 'application/x-troff-me': '.me', 98 | 'application/x-troff-ms': '.ms', 99 | 'application/x-ustar': '.ustar', 100 | 'application/x-wais-source': '.src', 101 | 'application/xml': '.xml', 102 | 'application/zip': '.zip', 103 | 'audio/basic': '.au', 104 | 'audio/mpeg': '.mp3', 105 | 'audio/x-aiff': '.aif', 106 | 'audio/x-pn-realaudio': '.ra', 107 | 'audio/x-wav': '.wav', 108 | 'image/gif': '.gif', 109 | 'image/ief': '.ief', 110 | 'image/jpeg': '.jpe', 111 | 'image/png': '.png', 112 | 'image/svg+xml': '.svg', 113 | 'image/tiff': '.tiff', 114 | 'image/vnd.microsoft.icon': '.ico', 115 | 'image/x-cmu-raster': '.ras', 116 | 'image/x-ms-bmp': '.bmp', 117 | 'image/x-portable-anymap': '.pnm', 118 | 'image/x-portable-bitmap': '.pbm', 119 | 'image/x-portable-graymap': '.pgm', 120 | 'image/x-portable-pixmap': '.ppm', 121 | 'image/x-rgb': '.rgb', 122 | 'image/x-xbitmap': '.xbm', 123 | 'image/x-xpixmap': '.xpm', 124 | 'image/x-xwindowdump': '.xwd', 125 | 'message/rfc822': '.eml', 126 | 'text/css': '.css', 127 | 'text/csv': '.csv', 128 | 'text/html': '.html', 129 | 'text/plain': '.txt', 130 | 'text/richtext': '.rtx', 131 | 'text/tab-separated-values': '.tsv', 132 | 'text/x-python': '.py', 133 | 'text/x-setext': '.etx', 134 | 'text/x-sgml': '.sgml', 135 | 'text/x-vcard': '.vcf', 136 | 'text/xml': '.xml', 137 | 'video/mp4': '.mp4', 138 | 'video/mpeg': '.mpeg', 139 | 'video/quicktime': '.mov', 140 | 'video/webm': '.webm', 141 | 'video/x-msvideo': '.avi', 142 | 'video/x-sgi-movie': '.movie' 143 | }.get(content_type, '') 144 | 145 | 146 | if _TemporaryFileWrapper: 147 | # Ideally we subclass this so that we can present a custom representation. 148 | class DownloadedFile(_TemporaryFileWrapper): 149 | basename = None 150 | 151 | def __repr__(self): 152 | state = "closed" if self.closed else "open" 153 | mode = "" if self.closed else " '%s'" % self.file.mode 154 | return "" % (self.name, state, mode) 155 | 156 | def __str__(self): 157 | return self.__repr__() 158 | else: 159 | # On some platforms (eg GAE) the private _TemporaryFileWrapper may not be 160 | # available, just use the standard `NamedTemporaryFile` function 161 | # in this case. 162 | DownloadedFile = tempfile.NamedTemporaryFile 163 | 164 | 165 | # Negotiation utilities. USed to determine which codec or transport class 166 | # should be used, given a list of supported instances. 167 | 168 | def determine_transport(transports, url): 169 | """ 170 | Given a URL determine the appropriate transport instance. 171 | """ 172 | url_components = urlparse.urlparse(url) 173 | scheme = url_components.scheme.lower() 174 | netloc = url_components.netloc 175 | 176 | if not scheme: 177 | raise exceptions.NetworkError("URL missing scheme '%s'." % url) 178 | 179 | if not netloc: 180 | raise exceptions.NetworkError("URL missing hostname '%s'." % url) 181 | 182 | for transport in transports: 183 | if scheme in transport.schemes: 184 | return transport 185 | 186 | raise exceptions.NetworkError("Unsupported URL scheme '%s'." % scheme) 187 | 188 | 189 | def negotiate_decoder(decoders, content_type=None): 190 | """ 191 | Given the value of a 'Content-Type' header, return the appropriate 192 | codec for decoding the request content. 193 | """ 194 | if content_type is None: 195 | return decoders[0] 196 | 197 | content_type = content_type.split(';')[0].strip().lower() 198 | main_type = content_type.split('/')[0] + '/*' 199 | wildcard_type = '*/*' 200 | 201 | for codec in decoders: 202 | for media_type in codec.get_media_types(): 203 | if media_type in (content_type, main_type, wildcard_type): 204 | return codec 205 | 206 | msg = "Unsupported media in Content-Type header '%s'" % content_type 207 | raise exceptions.NoCodecAvailable(msg) 208 | 209 | 210 | def negotiate_encoder(encoders, accept=None): 211 | """ 212 | Given the value of a 'Accept' header, return the appropriate codec for 213 | encoding the response content. 214 | """ 215 | if accept is None: 216 | return encoders[0] 217 | 218 | acceptable = set([ 219 | item.split(';')[0].strip().lower() 220 | for item in accept.split(',') 221 | ]) 222 | 223 | for codec in encoders: 224 | for media_type in codec.get_media_types(): 225 | if media_type in acceptable: 226 | return codec 227 | 228 | for codec in encoders: 229 | for media_type in codec.get_media_types(): 230 | if codec.media_type.split('/')[0] + '/*' in acceptable: 231 | return codec 232 | 233 | if '*/*' in acceptable: 234 | return encoders[0] 235 | 236 | msg = "Unsupported media in Accept header '%s'" % accept 237 | raise exceptions.NoCodecAvailable(msg) 238 | 239 | 240 | # Validation utilities. Used to ensure that we get consistent validation 241 | # exceptions when invalid types are passed as a parameter, rather than 242 | # an exception occuring when the request is made. 243 | 244 | def validate_path_param(value): 245 | value = _validate_form_field(value, allow_list=False) 246 | if not value: 247 | msg = 'Parameter %s: May not be empty.' 248 | raise exceptions.ParameterError(msg) 249 | return value 250 | 251 | 252 | def validate_query_param(value): 253 | return _validate_form_field(value) 254 | 255 | 256 | def validate_body_param(value, encoding): 257 | if encoding == 'application/json': 258 | return _validate_json_data(value) 259 | elif encoding == 'multipart/form-data': 260 | return _validate_form_object(value, allow_files=True) 261 | elif encoding == 'application/x-www-form-urlencoded': 262 | return _validate_form_object(value) 263 | elif encoding == 'application/octet-stream': 264 | if not is_file(value): 265 | msg = 'Must be an file upload.' 266 | raise exceptions.ParameterError(msg) 267 | return value 268 | msg = 'Unsupported encoding "%s" for outgoing request.' 269 | raise exceptions.NetworkError(msg % encoding) 270 | 271 | 272 | def validate_form_param(value, encoding): 273 | if encoding == 'application/json': 274 | return _validate_json_data(value) 275 | elif encoding == 'multipart/form-data': 276 | return _validate_form_field(value, allow_files=True) 277 | elif encoding == 'application/x-www-form-urlencoded': 278 | return _validate_form_field(value) 279 | msg = 'Unsupported encoding "%s" for outgoing request.' 280 | raise exceptions.NetworkError(msg % encoding) 281 | 282 | 283 | def _validate_form_object(value, allow_files=False): 284 | """ 285 | Ensure that `value` can be encoded as form data or as query parameters. 286 | """ 287 | if not isinstance(value, dict): 288 | msg = 'Must be an object.' 289 | raise exceptions.ParameterError(msg) 290 | return { 291 | text_type(item_key): _validate_form_field(item_val, allow_files=allow_files) 292 | for item_key, item_val in value.items() 293 | } 294 | 295 | 296 | def _validate_form_field(value, allow_files=False, allow_list=True): 297 | """ 298 | Ensure that `value` can be encoded as a single form data or a query parameter. 299 | Basic types that has a simple string representation are supported. 300 | A list of basic types is also valid. 301 | """ 302 | if isinstance(value, string_types): 303 | return value 304 | elif isinstance(value, bool) or (value is None): 305 | return {True: 'true', False: 'false', None: ''}[value] 306 | elif isinstance(value, (int, float)): 307 | return "%s" % value 308 | elif allow_list and isinstance(value, (list, tuple)) and not is_file(value): 309 | # Only the top-level element may be a list. 310 | return [ 311 | _validate_form_field(item, allow_files=False, allow_list=False) 312 | for item in value 313 | ] 314 | elif allow_files and is_file(value): 315 | return value 316 | 317 | msg = 'Must be a primitive type.' 318 | raise exceptions.ParameterError(msg) 319 | 320 | 321 | def _validate_json_data(value): 322 | """ 323 | Ensure that `value` can be encoded into JSON. 324 | """ 325 | if (value is None) or isinstance(value, (bool, int, float, string_types)): 326 | return value 327 | elif isinstance(value, (list, tuple)) and not is_file(value): 328 | return [_validate_json_data(item) for item in value] 329 | elif isinstance(value, dict): 330 | return { 331 | text_type(item_key): _validate_json_data(item_val) 332 | for item_key, item_val in value.items() 333 | } 334 | 335 | msg = 'Must be a JSON primitive.' 336 | raise exceptions.ParameterError(msg) 337 | -------------------------------------------------------------------------------- /docs/api-guide/auth.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | Authentication instances are responsible for handling the network authentication. 4 | 5 | ## Using authentication 6 | 7 | Typically, you'll provide authentication configuration by passing an authentication instance to the client. 8 | 9 | import coreapi 10 | 11 | auth = coreapi.auth.BasicAuthentication(username='...', password='...') 12 | coreapi.Client(auth=auth) 13 | 14 | It's recommended that you limit authentication scheme to only provide credentials to endpoints that match the expected domain. 15 | 16 | auth = coreapi.auth.BasicAuthentication( 17 | username='...', 18 | password='...', 19 | domain='api.example.com' 20 | ) 21 | 22 | You can also provide wildcard domains: 23 | 24 | auth = coreapi.auth.BasicAuthentication( 25 | username='...', 26 | password='...', 27 | domain='*.example.com' 28 | ) 29 | 30 | --- 31 | 32 | ## Available authentication schemes 33 | 34 | The following authentication schemes are provided as built-in options... 35 | 36 | ### BasicAuthentication 37 | 38 | Uses [HTTP Basic Authentication][basic-auth]. 39 | 40 | **Signature**: `BasicAuthentication(username, password, domain='*')` 41 | 42 | ### TokenAuthentication 43 | 44 | Uses [HTTP Bearer token authentication][bearer-auth], and can be used for OAuth 2, JWT, and custom token authentication schemes. 45 | 46 | Outgoing requests will include the provided token in the request `Authorization` headers, in the following format: 47 | 48 | Authorization: Bearer xxxx-xxxxxxxx-xxxx 49 | 50 | The scheme name may be customized if required, in order to support HTTP authentication schemes that are not [officially registered][http-auth-schemes]. 51 | 52 | A typical authentication flow using `TokenAuthentication` would be: 53 | 54 | * Using an unauthenticated client make a request providing the users credentials to an endpoint to that returns an API token. 55 | * Instantiate an authenticated client using the returned token, and use this for all future requests. 56 | 57 | **Signature**: `TokenAuthentication(token, scheme='Bearer', domain='*')` 58 | 59 | ### SessionAuthentication 60 | 61 | This authentication scheme enables cookies in order to allow a session cookie to be saved and maintained throughout the client's session. 62 | 63 | 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. 64 | 65 | A typical authentication flow using `SessionAuthentication` would be: 66 | 67 | * Using an unauthenticated client make an initial request to an endpoint that returns a CSRF cookie. 68 | * Use the unauthenticated client to make a request to a login endpoint, providing the users credentials. 69 | * Subsequent requests by the client will now be authenticated. 70 | 71 | **Signature**: `SessionAuthentication(csrf_cookie_name=None, csrf_header_name=None, domain='*')` 72 | 73 | --- 74 | 75 | ## Custom authentication 76 | 77 | Custom authentication classes may be created by subclassing `requests.AuthBase`, and implmenting the following: 78 | 79 | * Set the `allow_cookies` class attribute to either `True` or `False`. 80 | * Provide a `__call__(self, request)` method, which should return an authenticated request instance. 81 | 82 | [basic-auth]: https://tools.ietf.org/html/rfc7617 83 | [bearer-auth]: https://tools.ietf.org/html/rfc6750 84 | [http-auth-schemes]: https://www.iana.org/assignments/http-authschemes/ 85 | -------------------------------------------------------------------------------- /docs/api-guide/client.md: -------------------------------------------------------------------------------- 1 | # Clients 2 | 3 | In order to interact with an API using Core API, a client instance is required. 4 | 5 | The client is used to fetch the initial API description, and to then perform 6 | interactions against the API. 7 | 8 | An example client session might look something like this: 9 | 10 | from coreapi import Client 11 | 12 | client = Client() 13 | document = client.get('https://api.example.org/') 14 | data = client.action(document, ['flights', 'search'], params={ 15 | 'from': 'LHR', 16 | 'to': 'PA', 17 | 'date': '2016-10-12' 18 | }) 19 | 20 | --- 21 | 22 | ## Instantiating a client 23 | 24 | The default client may be obtained by instantiating an object, without 25 | passing any parameters. 26 | 27 | client = Client() 28 | 29 | A client instance holds the configuration about which transports are available 30 | for making network requests, and which codecs are available for decoding the 31 | content of network responses. 32 | 33 | The signature of the `Client` class is: 34 | 35 | Client(decoders=None, transports=None, auth=None, session=None) 36 | 37 | Arguments: 38 | 39 | * `decoders` - A list of decoder instances for decoding the content of responses. 40 | * `transports` - A list of transport instances available for making network requests. 41 | * `auth` - A authentication instance. Used when instantiating the default HTTP transport. 42 | * `session` - A `requests` session instance. Used when instantiating the default HTTP transport. 43 | 44 | 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 45 | data responses: 46 | 47 | from coreapi import codecs 48 | from coreapi.auth import BasicAuthentication 49 | 50 | decoders = [ 51 | codecs.CoreJSONCodec(), 52 | codecs.JSONCodec() 53 | ] 54 | auth = BasicAuthentication(domain='*', username='example', password='xxx') 55 | client = Client(decoders=decoders, auth=auth) 56 | 57 | When no arguments are passed, the following defaults are used: 58 | 59 | decoders = [ 60 | codecs.CoreJSONCodec(), # application/vnd.coreapi+json 61 | codecs.JSONCodec(), # application/json 62 | codecs.TextCodec(), # text/* 63 | codecs.DownloadCodec() # */* 64 | ] 65 | 66 | transports = [ 67 | transports.HTTPTransport(auth=auth, session=session) # http, https 68 | ] 69 | 70 | The configured decoders and transports are made available as read-only 71 | properties on a client instance: 72 | 73 | * `.decoders` 74 | * `.transports` 75 | 76 | --- 77 | 78 | ## Making an initial request 79 | 80 | **Signature**: `get(url)` 81 | 82 | Make a network request to the given URL. If fetching an API schema or hypermedia 83 | resource, then this should typically return a decoded `Document`. 84 | 85 | * `url` - The URL that should be retrieved. 86 | * `format` - Optional. Force the given codec to be used when decoding the response. 87 | 88 | For example: 89 | 90 | document = client.get('https://api.example.org/') 91 | 92 | --- 93 | 94 | ## Interacting with an API 95 | 96 | **Signature**: `action(self, document, keys, params=None)` 97 | 98 | Effect an interaction against the given document. 99 | 100 | * `document` - A `Document` instance. 101 | * `keys` - A list of strings that index a `Link` within the document. 102 | * `params` - A dictionary of parameters to use for the API interaction. 103 | 104 | For example, making a request without any parameters: 105 | 106 | data = client.action(document, ['flights', 'list_airports']) 107 | 108 | Or making a request, with parameters included: 109 | 110 | data = client.action(document, ['flights', 'search'], params={ 111 | 'from': 'LHR', 112 | 'to': 'PA', 113 | 'date': '2016-10-12' 114 | }) 115 | -------------------------------------------------------------------------------- /docs/api-guide/codecs.md: -------------------------------------------------------------------------------- 1 | # Codecs 2 | 3 | Codecs are responsible for decoding a bytestring into a `Document` instance, 4 | or for encoding a `Document` instance into a bytestring. 5 | 6 | A codec is associated with a media type. For example in HTTP responses, 7 | the `Content-Type` header is used to indicate the media type of 8 | the bytestring returned in the body of the response. 9 | 10 | When using a Core API client, HTTP responses are decoded with an appropriate 11 | codec, based on the `Content-Type` of the response. 12 | 13 | ## Using a codec 14 | 15 | All the codecs provided by the `coreapi` library are instantiated without 16 | arguments, for example: 17 | 18 | from coreapi import codecs 19 | 20 | codec = codecs.CoreJSONCodec() 21 | 22 | A codec will provide either one or both of the `decode()` or `encode()` methods. 23 | 24 | #### Decoding 25 | 26 | **Signature**: `decode(bytestring, **options)` 27 | 28 | Given a bytestring, returns a decoded `Document`, `Error`, or raw data. 29 | 30 | An example of decoding a document: 31 | 32 | bytestring = open('document.corejson', 'rb').read() 33 | document = codec.decode(bytestring) 34 | 35 | The available `options` keywords depend on the codec. 36 | 37 | #### Encoding 38 | 39 | **Signature**: `encode(document, **options)` 40 | 41 | Given a `Document` or `Error`, return an encoded representation as a bytestring. 42 | 43 | An example of encoding a document: 44 | 45 | bytestring = codec.encode(document) 46 | output = open('document.corejson', 'wb') 47 | output.write(bytestring) 48 | output.close() 49 | 50 | The available `options` keywords depend on the codec. 51 | 52 | #### Attributes 53 | 54 | The following attribute is available on codec instances: 55 | 56 | * `media_type` - A string indicating the media type that the codec represents. 57 | 58 | --- 59 | 60 | ## Available codecs 61 | 62 | ### CoreJSONCodec 63 | 64 | Supports decoding or encoding the Core JSON format. 65 | 66 | **.media_type**: `application/coreapi+json` 67 | **.format**: `openapi` 68 | 69 | Example of decoding a Core JSON bytestring into a `Document` instance: 70 | 71 | >>> from coreapi import codecs 72 | >>> codec = codecs.CoreJSONCodec() 73 | >>> content = b'{"_type": "document", ...}' 74 | >>> document = codec.decode(content) 75 | >>> print(document) 76 | 77 | 'search': link(from, to, date) 78 | 79 | Example of encoding a `Document` instance into a Core JSON bytestring: 80 | 81 | >>> content = codec.encode(document, indent=True) 82 | >>> print(content) 83 | { 84 | "_type": "document" 85 | } 86 | 87 | #### Encoding options 88 | 89 | **indent**: Set to `True` for an indented representation. The default is to generate a compact representation. 90 | 91 | #### Decoding options 92 | 93 | **base_url**: The URL from which the document was retrieved. Used to resolve any relative 94 | URLs in the document. 95 | 96 | --- 97 | 98 | ### JSONCodec 99 | 100 | Supports decoding JSON data. 101 | 102 | **.media_type**: `application/json` 103 | **.format**: `json` 104 | 105 | Example: 106 | 107 | >>> from coreapi import codecs 108 | >>> codec = codecs.JSONCodec() 109 | >>> content = b'{"string": "abc", "boolean": true, "null": null}' 110 | >>> data = codec.decode(content) 111 | >>> print(data) 112 | {"string": "abc", "boolean": True, "null": None} 113 | 114 | --- 115 | 116 | ### TextCodec 117 | 118 | Supports decoding plain-text responses. 119 | 120 | **.media_type**: `text/*` 121 | **.format**: `text` 122 | 123 | Example: 124 | 125 | >>> from coreapi import codecs 126 | >>> codec = codecs.TextCodec() 127 | >>> data = codec.decode(b'hello, world!') 128 | >>> print(data) 129 | hello, world! 130 | 131 | --- 132 | 133 | ### DownloadCodec 134 | 135 | Supports decoding arbitrary media as a download file. Returns a [temporary file][tempfile] 136 | that will be deleted once it goes out of scope. 137 | 138 | **.media_type**: `*/*` 139 | **.format**: `download` 140 | 141 | Example: 142 | 143 | >>> codec = codecs.DownloadCodec() 144 | >>> download = codec.decode(b'abc...xyz') 145 | >>> print(download) 146 | 147 | >>> content = download.read() 148 | >>> print(content) 149 | abc...xyz 150 | 151 | The download filename will be determined by either the `Content-Disposition` 152 | header, or based on the request URL and the `Content-Type` header. Download 153 | collisions are avoided by using incrementing filenames where required. 154 | The original name used for the download file can be inspected using `.basename`. 155 | 156 | >>> download = codec.decode(b'abc...xyz', content_type='image/png', base_url='http://example.com/download/') 157 | >>> download.name 158 | '/var/folders/2k/qjf3np5s28zf2f58963pz2k40000gn/T/download.png' 159 | >>> download.basename 160 | 'download.png' 161 | 162 | #### Instantiation 163 | 164 | By default this codec returns a temporary file that will be deleted once it 165 | goes out of scope. If you want to return temporary files that are not 166 | deleted when they go out of scope then you can instantiate the `DownloadCodec` 167 | with a `download_dir` argument. 168 | 169 | For example, to download files to the current working directory: 170 | 171 | >>> import os 172 | >>> codecs.DownloadCodec(download_dir=os.getcwd()) 173 | 174 | #### Decoding options 175 | 176 | **base_url**: The URL from which the document was retrieved. May be used to 177 | generate an output filename if no `Content-Disposition` header exists. 178 | 179 | **content_type**: The response Content-Type header. May be used to determine a 180 | suffix for the output filename if no `Content-Disposition` header exists. 181 | 182 | **content_disposition**: The response Content-Disposition header. May be [used to 183 | indicate the download filename][content-disposition-filename]. 184 | 185 | --- 186 | 187 | ## Custom codecs 188 | 189 | Custom codec classes may be created by inheriting from `BaseCodec`, setting 190 | the `media_type` and `format` properties, and implementing one or both 191 | of the `decode` or `encode` methods. 192 | 193 | For example: 194 | 195 | from coreapi import codecs 196 | import yaml 197 | 198 | class YAMLCodec(codecs.BaseCodec): 199 | media_type = 'application/yaml' 200 | format = 'yaml' 201 | 202 | def decode(content, **options): 203 | return yaml.safe_load(content) 204 | 205 | ### The codec registry 206 | 207 | Tools such as the Core API command line client require a method of discovering 208 | which codecs are installed on the system. This is enabled by using a registry 209 | system. 210 | 211 | In order to register a custom codec, the PyPI package must contain a correctly 212 | configured `entry_points` option. Typically this needs to be added in a 213 | `setup.py` module, which is run whenever publishing a new package version. 214 | 215 | The `entry_points` option must be a dictionary, containing a `coreapi.codecs` 216 | item listing the available codec classes. As an example, the listing for the 217 | codecs which are registered by the `coreapi` package itself is as follows: 218 | 219 | setup( 220 | name='coreapi', 221 | license='BSD', 222 | ... 223 | entry_points={ 224 | 'coreapi.codecs': [ 225 | 'corejson=coreapi.codecs:CoreJSONCodec', 226 | 'json=coreapi.codecs:JSONCodec', 227 | 'text=coreapi.codecs:TextCodec', 228 | 'download=coreapi.codecs:DownloadCodec', 229 | ] 230 | } 231 | ) 232 | 233 | --- 234 | 235 | ## External packages 236 | 237 | The following third-party packages are available. 238 | 239 | ### OpenAPI 240 | 241 | A codec for [OpenAPI][openapi] schemas, also known as "Swagger". Installable [from PyPI][openapi-pypi] as `openapi-codec`, and [available on GitHub][openapi-github]. 242 | 243 | ### JSON Hyper-Schema 244 | 245 | A codec for [JSON Hyper-Schema][jsonhyperschema]. Installable [from PyPI][jsonhyperschema-pypi] as `jsonhyperschema-codec`, and [available on GitHub][jsonhyperschema-github]. 246 | 247 | ### HAL 248 | 249 | A codec for the [HAL][hal] hypermedia format. Installable [from PyPI][hal-pypi] as `hal-codec`, and [available on GitHub][hal-github]. 250 | 251 | [content-disposition-filename]: https://tools.ietf.org/html/draft-ietf-httpbis-content-disp-00#section-3.3 252 | [click-ansi]: http://click.pocoo.org/5/utils/#ansi-colors 253 | [tempfile]: https://docs.python.org/3/library/tempfile.html#tempfile.TemporaryFile 254 | 255 | [openapi]: https://openapis.org/specification 256 | [openapi-pypi]: https://pypi.python.org/pypi/openapi-codec 257 | [openapi-github]: https://github.com/core-api/python-openapi-codec 258 | 259 | [jsonhyperschema]: http://json-schema.org/latest/json-schema-hypermedia.html 260 | [jsonhyperschema-pypi]: https://pypi.python.org/pypi/jsonhyperschema-codec 261 | [jsonhyperschema-github]: https://github.com/core-api/python-jsonhyperschema-codec 262 | 263 | [hal]: http://stateless.co/hal_specification.html 264 | [hal-pypi]: https://pypi.python.org/pypi/hal-codec 265 | [hal-github]: https://github.com/core-api/python-hal-codec 266 | -------------------------------------------------------------------------------- /docs/api-guide/document.md: -------------------------------------------------------------------------------- 1 | # Documents 2 | 3 | A CoreAPI document is a primitive that may be used to represent either schema of hypermedia responses. 4 | 5 | By including information about the available interactions that an API exposes, 6 | the document allows users to interact with the API at an interface level, rather 7 | than a network level. 8 | 9 | In the schema case a document will include only links. Interactions to the API 10 | endpoints will typically return plain data. 11 | 12 | In the hypermedia case a document will include both links and data. interactions 13 | to the API endpoints will typically return a new document. 14 | 15 | --- 16 | 17 | ## Usage 18 | 19 | ### Retrieving a document 20 | 21 | Typically a Document will first be obtained by making a request with a 22 | client instance. 23 | 24 | >>> document = client.get('https://api.example.com/users/') 25 | 26 | A document can also be loaded from a raw bytestring, by using a codec instance. 27 | 28 | >>> codec = codecs.CoreJSONCodec() 29 | >>> bytestring = open('document.corejson', 'rb').read() 30 | >>> document = codec.decode(bytestring) 31 | 32 | ### Inspecting a document 33 | 34 | A document has some associated metadata that may be inspected. 35 | 36 | >>> document.url 37 | 'https://api.example.com/' 38 | >>> document.title 39 | 'Example API' 40 | 41 | A document may contain content, which may include nested dictionaries and list. 42 | The top level element is always a dictionary. The instance may be accessed using 43 | Python's standard dictionary lookup syntax. 44 | 45 | Schema type documents will contain `Link` instances as the leaf nodes in the content. 46 | 47 | >>> document['users']['create'] 48 | Link(url='https://api.example.com/users/', action='post', fields=[...]) 49 | 50 | Hypermedia documents will also contain `Link` instances, but may also contain 51 | data, or nested `Document` instances. 52 | 53 | >>> document['results']['count'] 54 | 45 55 | >>> document['results']['items'][0] 56 | Document(url='https://api.example.com/users/0/', content={...}) 57 | >>> document['results']['items'][0]['username'] 58 | 'tomchristie' 59 | 60 | ### Interacting with a document 61 | 62 | In order to interact with an API, a document is passed as the first argument to 63 | a client instance. A list of strings indexing into a link in the document is passed 64 | as the second argument. 65 | 66 | >>> data = client.action(document, ['users', 'list']) 67 | 68 | Some links may accept a set of parameters, each of which may be either required or optional. 69 | 70 | >>> data = client.action(document, ['users', 'list'], params={'is_admin': True}) 71 | 72 | A document may be reloaded, by fetching the `document.url` property. 73 | 74 | >>> document = client.get(document.url) # Reload the current document 75 | 76 | --- 77 | 78 | ## Document primitives 79 | 80 | When using the `coreapi` library as an API client, you won't typically be instantiating 81 | document instances, but rather retrieving them using the client. 82 | 83 | However, if you're using the `coreapi` library on the server-side, you can use 84 | the document primitives directly, in order to create schema or hypermedia representations. 85 | The document should then be encoded using an available codec in order to form the schema response. 86 | 87 | ### Document 88 | 89 | The following are available attributes, and may be passed when instantiating a `Document`: 90 | 91 | * `url` - A string giving the canonical URL for this document. 92 | * `title` - A string describing this document. 93 | * `content` - A dictionary containing all the data and links made available by this document. 94 | 95 | A document instance also supports dictionary-style lookup on it's contents. 96 | 97 | >>> document['results']['count'] 98 | 45 99 | 100 | The following properties are available on a document instance, and on any 101 | nested dictionaries it contains: 102 | 103 | * `links` - A dictionary-like property including only items that are `Link` instances. 104 | * `data` - A dictionary-like property including only items that are not `Link` instances. 105 | 106 | ### Link 107 | 108 | The following are available attributes, and may be passed when instantiating a `Link`: 109 | 110 | * `url` - A string giving the URL against which the request should be made. 111 | * `action` - A string giving the type of outgoing request that should be made. 112 | * `encoding` - A string giving the encoding used for outgoing requests. 113 | * `transform` - A string describing how the response should 114 | * `description` - A string describing this link. 115 | * `fields` - A list of field instances. 116 | 117 | Note that the behaviour of link attributes is defined at the transport level, 118 | rather than at the document level. See [the `HTTPTransport` documentation for more details][link-behaviour]. 119 | 120 | ### Field 121 | 122 | The following are available attributes, and may be passed when instantiating a `Field`: 123 | 124 | * `name` - A string describing a short name for the parameter. 125 | * `required` - A boolean indicating if this is a required parameter on the link. 126 | * `location` - A string describing how this parameter should be included in the outgoing request. 127 | * `type` - A string describing the kind of [input control][html5-input-control] this parameter represents. 128 | * `description` - A string describing this parameter on the link. 129 | 130 | Note that the behaviour of the `location` attribute is defined at the transport level, 131 | rather than at the document level. See [the `HTTPTransport` documentation for more details][link-behaviour]. 132 | 133 | --- 134 | 135 | ## Handling errors 136 | 137 | Error responses are similar to Document responses. Both contain a dictionary of 138 | content. However, an error does not represent a network resource, and so does 139 | not have an associated URL, in the same way as a `Document` does. 140 | 141 | When an error response is returned by an API, the `ErrorMessage` exception is raised. 142 | The `Error` instance itself is available on the exception as the `.error` attribute. 143 | 144 | params = { 145 | 'location_code': 'berlin-4353', 146 | 'start_date': '2018-01-03', 147 | 'end_date': '2018-01-07', 148 | 'room_type': 'double', 149 | } 150 | try: 151 | data = client.action(document, ['bookings', 'create'], params=params) 152 | except coreapi.exceptions.ErrorMessage as exc: 153 | print("Error: %s" % exc.error) 154 | else: 155 | print("Success: %s" % data) 156 | 157 | ### Error 158 | 159 | The following are available attributes, and may be passed when instantiating an `Error`: 160 | 161 | * `title` - A string describing the error. 162 | * `content` - A dictionary containing all the data or links made available by this error. 163 | 164 | [link-behaviour]: transports.md#making-requests 165 | [html5-input-control]: https://www.w3.org/TR/html-markup/input.html 166 | -------------------------------------------------------------------------------- /docs/api-guide/exceptions.md: -------------------------------------------------------------------------------- 1 | # Exceptions 2 | 3 | Any of the exceptions raised by the `coreapi` library may be imported from the `coreapi.exceptions` module: 4 | 5 | from coreapi.exceptions import CoreAPIException 6 | 7 | ## The base class 8 | 9 | #### CoreAPIException 10 | 11 | A base class for all `coreapi` exceptions. 12 | 13 | --- 14 | 15 | ## Server errors 16 | 17 | The following exception occurs when the server returns an error response. 18 | 19 | #### ErrorMessage 20 | 21 | The server returned a CoreAPI [Error][error] document. 22 | 23 | --- 24 | 25 | ## Client errors 26 | 27 | The following exceptions indicate that an incorrect interaction was attempted using the client. 28 | 29 | #### LinkLookupError 30 | 31 | The keys passed in a [`client.action()`][action] call did not reference a link in the document. 32 | 33 | #### ParameterError 34 | 35 | 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 36 | not be supported by the given encoding on the link. 37 | 38 | --- 39 | 40 | ## Request errors 41 | 42 | The following exceptions indicate that an error occurred when handling 43 | some aspect of the API request. 44 | 45 | #### ParseError 46 | 47 | A response was returned with malformed content. 48 | 49 | #### NoCodecAvailable 50 | 51 | Raised when there is no available codec that can handle the given media. 52 | 53 | #### NetworkError 54 | 55 | An issue occurred with the network request. 56 | 57 | 58 | [action]: /api-guide/client.md#interacting-with-an-api 59 | [error]: /api-guide/document.md#error 60 | -------------------------------------------------------------------------------- /docs/api-guide/transports.md: -------------------------------------------------------------------------------- 1 | # Transports 2 | 3 | Transports are responsible for making the actual network requests, and handling 4 | the responses. 5 | 6 | Whenever an action is taken on a link, the scheme of the URL is inspected, and 7 | the responsibility for making a request is passed to an appropriate transport class. 8 | 9 | By default only an HTTP transport implementation is included, but this approach means 10 | that other network protocols can also be supported by Core API, while remaining 11 | transparent to the user of the client library. 12 | 13 | ## Available transports 14 | 15 | ### HTTPTransport 16 | 17 | The `HTTPTransport` class supports the `http` and `https` schemes. 18 | 19 | #### Instantiation 20 | 21 | **Signature**: `HTTPTransport(auth=None, headers=None, session=None)` 22 | 23 | * `auth` - An authentication instance, or None. 24 | * `headers` - A dictionary of items that should be included in the outgoing request headers. 25 | * `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. 26 | 27 | #### Making requests 28 | 29 | The following describes how the various Link and Field properties are used when 30 | making an HTTP network request. 31 | 32 | **Link.action** 33 | 34 | The link `action` property is uppercased and then used to determine the HTTP 35 | method for the request. 36 | 37 | If left blank then the `GET` method is used. 38 | 39 | **Link.encoding** 40 | 41 | The link `encoding` property is used to determine how any `location='form'` or 42 | `location='body'` parameters should be encoded in order to form the body of 43 | the request. 44 | 45 | Supported encodings are: 46 | 47 | * `'application/json'` - Suitable for primitive and composite types. 48 | * `'application/x-www-form-urlencoded'` - Suitable for primitive types. 49 | * `'multipart/form-data'` - Suitable for primitive types and file uploads. 50 | * `'application/octet-stream'` - Suitable for raw file uploads, with a `location='body'` field. 51 | 52 | If left blank and a request body is included, then `'application/json'` is used. 53 | 54 | **Link.transform** 55 | 56 | The link `transform` property is *only relevant when the link is contained in an 57 | embedded document*. This allows hypermedia documents to effect partial updates. 58 | 59 | * `'new'` - The response document should be returned as the result. 60 | * `'inplace'` - The embedded document should be updated in-place, and the resulting 61 | top-level document returned as the result. 62 | 63 | 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. 64 | 65 | **Field.location** 66 | 67 | The link `location` property determines how the parameter is used to build the outgoing request. 68 | 69 | * `'path'` - The parameter is included in the URL, with the link 70 | 'url' value acting as a [URI template][uri-template]. 71 | * `'query'` - The parameter is included as a URL query parameter. 72 | * `'body'` - The parameter is encoded and included as the body of the request. 73 | * `'form'` - The parameter is treated as a single key-value item in an 74 | dictionary of items. It should be encoded together with any other form 75 | parameters, and included as the body of the request. 76 | 77 | If left blank, then `'query'` is used for `'GET'` and `'DELETE'` requests. For any other request `'form'` is used. 78 | 79 | ## Custom transports 80 | 81 | The transport interface is not yet finalized, as it may still be subject to minor 82 | changes in a future release. 83 | 84 | ## External packages 85 | 86 | No third party transport classes are currently available. 87 | 88 | [sessions]: http://docs.python-requests.org/en/master/user/advanced/#session-objects 89 | [transport-adapters]: http://docs.python-requests.org/en/master/user/advanced/#transport-adapters 90 | [uri-template]: https://tools.ietf.org/html/rfc6570 91 | -------------------------------------------------------------------------------- /docs/api-guide/utils.md: -------------------------------------------------------------------------------- 1 | # Utilities 2 | 3 | The `coreapi.utils` module provides a number of helper functions that 4 | may be useful if writing a custom client or transport class. 5 | 6 | --- 7 | 8 | ## File utilities 9 | 10 | The following classes are used to indicate upload and download file content. 11 | 12 | ### File 13 | 14 | May be used as a parameter with links that require a file input. 15 | 16 | **Signature**: `File(name, content, content_type=None)` 17 | 18 | * `name` - The filename. 19 | * `content` - A string, bytestring, or stream object. 20 | * `content_type` - An optional string representing the content type of the file. 21 | 22 | An open file or other stream may also be used directly as a parameter, instead 23 | of a `File` instance, but the `File` instance makes it easier to specify the 24 | filename and content in code. 25 | 26 | Example: 27 | 28 | >>> from coreapi.utils import File 29 | >>> upload = File('example.csv', 'a,b,c\n1,2,3\n4,5,6\n') 30 | >>> data = client.action(document, ['store', 'upload_media'], params={'upload': upload}) 31 | 32 | ### DownloadedFile 33 | 34 | A temporary file instance, used to represent downloaded media. 35 | 36 | Available attributes: 37 | 38 | * `name` - The full filename, including the path. 39 | * `basename` - The filename as determined at the point of download. 40 | 41 | Example: 42 | 43 | >>> download = client.action(document, ['user', 'get_profile_image']) 44 | >>> download.basename 45 | 'avatar.png' 46 | >>> download.read() 47 | b'...' 48 | 49 | By default the file will be deleted when this object goes out of scope. See 50 | [the `DownloadCodec` documentation][download-codec] for more details. 51 | 52 | --- 53 | 54 | ## Negotiation utilities 55 | 56 | The following functions are used to determine which of a set of transports 57 | or codecs should be used when performing an API interaction. 58 | 59 | ### determine_transport 60 | 61 | **Signature**: `determine_transport(transports, url)` 62 | 63 | Given a list of transports and a URL, return the appropriate transport for 64 | making network requests to that URL. 65 | 66 | May raise `NetworkError`. 67 | 68 | ### negotiate_decoder 69 | 70 | **Signature**: `negotiate_decoder(codecs, content_type=None)` 71 | 72 | Given a list of codecs, and the value of an HTTP response `Content-Type` header, 73 | return the appropriate codec for decoding the response content. 74 | 75 | May raise `NoCodecAvailable`. 76 | 77 | ### negotiate_encoder 78 | 79 | **Signature**: `negotiate_encoder(codecs, accept=None)` 80 | 81 | Given a list of codecs, and the value of an incoming HTTP request `Accept` 82 | header, return the appropriate codec for encoding the outgoing response content. 83 | 84 | Allows server implementations to provide for client-driven content negotiation. 85 | 86 | May raise `NoCodecAvailable`. 87 | 88 | --- 89 | 90 | ## Validation utilities 91 | 92 | Different request encodings have different capabilities. For example, `application/json` 93 | supports a range of data primitives, but does not support file uploads. In contrast, 94 | `multipart/form-data` only supports string primitives and file uploads. 95 | 96 | The following helper functions validate that the types passed to an action are suitable 97 | for use with the given encoding, and ensure that a consistent exception type is raised 98 | if an invalid value is passed. 99 | 100 | ### validate_path_param 101 | 102 | **Signature**: `validate_path_param(value)` 103 | 104 | 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. 105 | 106 | May raise `ParameterError`. 107 | 108 | ### validate_query_param 109 | 110 | **Signature**: `validate_query_param(value)` 111 | 112 | Returns the value, coerced into a string primitive. Validates that the value is suitable for use in URL query parameters. 113 | 114 | May raise `ParameterError`. 115 | 116 | ### validate_body_param 117 | 118 | **Signature**: `validate_body_param(value, encoding)` 119 | 120 | 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. 121 | 122 | Valid encodings are `application/json`, `x-www-form-urlencoded`, `multipart/form-data` and `application/octet-stream`. 123 | 124 | May raise `ParameterError` for an invalid value, or `NetworkError` for an unsupported encoding. 125 | 126 | ### validate_form_param 127 | 128 | **Signature**: `validate_body_param(value, encoding)` 129 | 130 | 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. 131 | 132 | Valid encodings are `application/json`, `x-www-form-urlencoded`, `multipart/form-data`. 133 | 134 | May raise `ParameterError`, or `NetworkError` for an unsupported encoding. 135 | 136 | 137 | [download-codec]: codecs.md#downloadcodec 138 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Core API - Python Client 2 | 3 | Python client library for [Core API][core-api]. 4 | 5 | Allows you to interact with any API that exposes a supported schema or hypermedia format. 6 | 7 | ## Installation 8 | 9 | Install [from PyPI][coreapi-pypi], using pip: 10 | 11 | $ pip install coreapi 12 | 13 | ## Quickstart 14 | 15 | Create a client instance: 16 | 17 | from coreapi import Client 18 | client = Client() 19 | 20 | Retrieve an API schema: 21 | 22 | document = client.get('https://api.example.org/') 23 | 24 | Interact with the API: 25 | 26 | data = client.action(document, ['flights', 'search'], params={ 27 | 'from': 'LHR', 28 | 'to': 'PA', 29 | 'date': '2016-10-12' 30 | }) 31 | 32 | Creating an authenticated client instance: 33 | 34 | auth = coreapi.auth.TokenAuthentication(token='xxxx-xxxxxxxx-xxxx') 35 | client = Client(auth=auth) 36 | 37 | ## Supported formats 38 | 39 | The following schema and hypermedia formats are currently supported, either 40 | through [built-in support][built-in-codecs], or as a [third-party codec][third-party-codecs]: 41 | 42 | Name | Media type | Notes 43 | --------------------|----------------------------|------------------------------------ 44 | CoreJSON | `application/coreapi+json` | Supports both Schemas & Hypermedia. 45 | OpenAPI ("Swagger") | `application/openapi+json` | Schema support. 46 | JSON Hyper-Schema | `application/schema+json` | Schema support. 47 | HAL | `application/hal+json` | Hypermedia support. 48 | 49 | Additionally, the following plain data content types [are supported][built-in-codecs]: 50 | 51 | Name | Media type | Notes 52 | ------------|--------------------|--------------------------------- 53 | JSON | `application/json` | Returns Python primitive types. 54 | Plain text | `text/*` | Returns a Python string instance. 55 | Other media | `*/*` | Returns a temporary download file. 56 | 57 | --- 58 | 59 | ## License 60 | 61 | Copyright © 2015-2017, Tom Christie. 62 | All rights reserved. 63 | 64 | Redistribution and use in source and binary forms, with or without 65 | modification, are permitted provided that the following conditions are met: 66 | 67 | Redistributions of source code must retain the above copyright notice, this 68 | list of conditions and the following disclaimer. 69 | Redistributions in binary form must reproduce the above copyright notice, this 70 | list of conditions and the following disclaimer in the documentation and/or 71 | other materials provided with the distribution. 72 | 73 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 74 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 75 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 76 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 77 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 78 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 79 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 80 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 81 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 82 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 83 | 84 | [core-api]: http://www.coreapi.org/ 85 | [built-in-codecs]: api-guide/codecs.md#available-codecs 86 | [third-party-codecs]: api-guide/codecs.md#external-packages 87 | [coreapi-pypi]: https://pypi.python.org/pypi/coreapi 88 | -------------------------------------------------------------------------------- /docs/topics/release-notes.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## 2.0 4 | 5 | * Upload and download support. 6 | * Media type changes from `application/vnd.coreapi+json` to `application/coreapi+json`. 7 | For backwards compatibility, either are currently accepted. 8 | * Codec methods `dump()`/`load()` become `encode()`/`decode()`. The old style 9 | methods currently continue to work for backward compatibility. 10 | * The client instance validates that passed parameters match the available parameter names. 11 | Fails if unknown parameters are included, or required parameters are not included. 12 | * `.action()` now accepts a `validate=False` argument, to turn off parameter validation. 13 | * Parameter values are validated against the encoding used on the link to ensure 14 | that they can be represented in the request. 15 | * `type` annotation added to `Field` instances. 16 | * `multipart/form-data` is now consistently used on multipart links, even when 17 | no file arguments are passed. 18 | * `action`, `encoding`, and `transform` parameters to `.action()` now replaced with a 19 | single `overrides` argument. The old style arguments currently continue to work for 20 | backward compatibility. 21 | * The `supports` attribute is no longer used when defining codec classes. A 22 | `supports` property currently exists on the base class, to provide backwards 23 | compatibility for `coreapi-cli`. 24 | 25 | The various backwards compatibility shims are planned to be removed in the 2.1 release. 26 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Core API - Python Client 2 | 3 | pages: 4 | - Home: index.md 5 | - API Guide: 6 | - Clients: api-guide/client.md 7 | - Documents: api-guide/document.md 8 | - Authentication: api-guide/auth.md 9 | - Codecs: api-guide/codecs.md 10 | - Transports: api-guide/transports.md 11 | - Exceptions: api-guide/exceptions.md 12 | - Utilities: api-guide/utils.md 13 | - Topics: 14 | - Release Notes: topics/release-notes.md 15 | 16 | repo_url: https://github.com/core-api/python-client/ 17 | copyright: Copyright © 2015, Tom Christie. 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Package requirements 2 | coreschema 3 | itypes 4 | requests 5 | uritemplate 6 | 7 | # Testing requirements 8 | coverage 9 | flake8 10 | pytest 11 | 12 | # Packaging requirements 13 | wheel 14 | -------------------------------------------------------------------------------- /runtests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import coverage 3 | import os 4 | import pytest 5 | import subprocess 6 | import sys 7 | 8 | 9 | PYTEST_ARGS = ['tests', '--tb=short'] 10 | FLAKE8_ARGS = ['coreapi', 'tests', '--ignore=E501', '--exclude', 'compat.py'] 11 | COVERAGE_OPTIONS = { 12 | 'include': ['coreapi/*', 'tests/*'], 13 | 'omit': ['coreapi/compat.py'] 14 | } 15 | 16 | 17 | sys.path.append(os.path.dirname(__file__)) 18 | 19 | 20 | class NullFile(object): 21 | def write(self, data): 22 | pass 23 | 24 | 25 | def exit_on_failure(ret, message=None): 26 | if ret: 27 | sys.exit(ret) 28 | 29 | 30 | def flake8_main(args): 31 | print('Running flake8 code linting') 32 | ret = subprocess.call(['flake8'] + args) 33 | print('flake8 failed' if ret else 'flake8 passed') 34 | return ret 35 | 36 | 37 | def report_coverage(cov, fail_if_not_100=False): 38 | percent_covered = cov.report( 39 | file=NullFile(), **COVERAGE_OPTIONS 40 | ) 41 | if percent_covered == 100: 42 | print('100% coverage') 43 | return 44 | if fail_if_not_100: 45 | print('Tests passed, but not 100% coverage.') 46 | cov.report(**COVERAGE_OPTIONS) 47 | cov.html_report(**COVERAGE_OPTIONS) 48 | if fail_if_not_100: 49 | sys.exit(1) 50 | 51 | 52 | def split_class_and_function(string): 53 | class_string, function_string = string.split('.', 1) 54 | return "%s and %s" % (class_string, function_string) 55 | 56 | 57 | def is_function(string): 58 | # `True` if it looks like a test function is included in the string. 59 | return string.startswith('test_') or '.test_' in string 60 | 61 | 62 | def is_class(string): 63 | # `True` if first character is uppercase - assume it's a class name. 64 | return string[0] == string[0].upper() 65 | 66 | 67 | if __name__ == "__main__": 68 | if len(sys.argv) > 1: 69 | pytest_args = sys.argv[1:] 70 | first_arg = pytest_args[0] 71 | if first_arg.startswith('-'): 72 | # `runtests.py [flags]` 73 | pytest_args = PYTEST_ARGS + pytest_args 74 | elif is_class(first_arg) and is_function(first_arg): 75 | # `runtests.py TestCase.test_function [flags]` 76 | expression = split_class_and_function(first_arg) 77 | pytest_args = PYTEST_ARGS + ['-k', expression] + pytest_args[1:] 78 | elif is_class(first_arg) or is_function(first_arg): 79 | # `runtests.py TestCase [flags]` 80 | # `runtests.py test_function [flags]` 81 | pytest_args = PYTEST_ARGS + ['-k', pytest_args[0]] + pytest_args[1:] 82 | else: 83 | pytest_args = PYTEST_ARGS 84 | 85 | cov = coverage.coverage() 86 | cov.start() 87 | exit_on_failure(pytest.main(pytest_args)) 88 | cov.stop() 89 | exit_on_failure(flake8_main(FLAKE8_ARGS)) 90 | report_coverage(cov) 91 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | import re 6 | import os 7 | import shutil 8 | import sys 9 | 10 | 11 | def get_version(package): 12 | """ 13 | Return package version as listed in `__version__` in `init.py`. 14 | """ 15 | init_py = open(os.path.join(package, '__init__.py')).read() 16 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 17 | 18 | 19 | def get_packages(package): 20 | """ 21 | Return root package and all sub-packages. 22 | """ 23 | return [dirpath 24 | for dirpath, dirnames, filenames in os.walk(package) 25 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 26 | 27 | 28 | def get_package_data(package): 29 | """ 30 | Return all files under the root package, that are not in a 31 | package themselves. 32 | """ 33 | walk = [(dirpath.replace(package + os.sep, '', 1), filenames) 34 | for dirpath, dirnames, filenames in os.walk(package) 35 | if not os.path.exists(os.path.join(dirpath, '__init__.py'))] 36 | 37 | filepaths = [] 38 | for base, filenames in walk: 39 | filepaths.extend([os.path.join(base, filename) 40 | for filename in filenames]) 41 | return {package: filepaths} 42 | 43 | 44 | version = get_version('coreapi') 45 | 46 | 47 | if sys.argv[-1] == 'publish': 48 | os.system("python setup.py sdist bdist_wheel upload") 49 | print("You probably want to also tag the version now:") 50 | print(" git tag -a %s -m 'version %s'" % (version, version)) 51 | print(" git push --tags") 52 | sys.exit() 53 | 54 | 55 | setup( 56 | name='coreapi', 57 | version=version, 58 | url='https://github.com/core-api/python-client', 59 | license='BSD', 60 | description='Python client library for Core API.', 61 | author='Tom Christie', 62 | author_email='tom@tomchristie.com', 63 | packages=get_packages('coreapi'), 64 | package_data=get_package_data('coreapi'), 65 | install_requires=[ 66 | 'coreschema', 67 | 'requests', 68 | 'itypes', 69 | 'uritemplate' 70 | ], 71 | entry_points={ 72 | 'coreapi.codecs': [ 73 | 'corejson=coreapi.codecs:CoreJSONCodec', 74 | 'json=coreapi.codecs:JSONCodec', 75 | 'text=coreapi.codecs:TextCodec', 76 | 'download=coreapi.codecs:DownloadCodec', 77 | ], 78 | 'coreapi.transports': [ 79 | 'http=coreapi.transports:HTTPTransport', 80 | ] 81 | }, 82 | classifiers=[ 83 | 'Development Status :: 3 - Alpha', 84 | 'Environment :: Web Environment', 85 | 'Intended Audience :: Developers', 86 | 'License :: OSI Approved :: BSD License', 87 | 'Operating System :: OS Independent', 88 | 'Programming Language :: Python', 89 | 'Programming Language :: Python :: 2', 90 | 'Programming Language :: Python :: 2.7', 91 | 'Programming Language :: Python :: 3', 92 | 'Programming Language :: Python :: 3.3', 93 | 'Programming Language :: Python :: 3.4', 94 | 'Programming Language :: Python :: 3.5', 95 | 'Programming Language :: Python :: 3.6', 96 | 'Topic :: Internet :: WWW/HTTP', 97 | ] 98 | ) 99 | -------------------------------------------------------------------------------- /tests/test_codecs.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from coreapi.codecs import CoreJSONCodec 3 | from coreapi.codecs.corejson import _document_to_primitive, _primitive_to_document 4 | from coreapi.document import Document, Link, Error, Field 5 | from coreapi.exceptions import ParseError, NoCodecAvailable 6 | from coreapi.utils import negotiate_decoder, negotiate_encoder 7 | from coreschema import Enum, String 8 | import pytest 9 | 10 | 11 | @pytest.fixture 12 | def json_codec(): 13 | return CoreJSONCodec() 14 | 15 | 16 | @pytest.fixture 17 | def doc(): 18 | return Document( 19 | url='http://example.org/', 20 | title='Example', 21 | content={ 22 | 'integer': 123, 23 | 'dict': {'key': 'value'}, 24 | 'list': [1, 2, 3], 25 | 'link': Link( 26 | url='http://example.org/', 27 | fields=[ 28 | Field(name='noschema'), 29 | Field(name='string_example', schema=String()), 30 | Field(name='enum_example', schema=Enum(['a', 'b', 'c'])), 31 | ]), 32 | 'nested': {'child': Link(url='http://example.org/123')}, 33 | '_type': 'needs escaping' 34 | }) 35 | 36 | 37 | # Documents have a mapping to python primitives in JSON style. 38 | 39 | def test_document_to_primitive(doc): 40 | data = _document_to_primitive(doc) 41 | assert data == { 42 | '_type': 'document', 43 | '_meta': { 44 | 'url': 'http://example.org/', 45 | 'title': 'Example' 46 | }, 47 | 'integer': 123, 48 | 'dict': {'key': 'value'}, 49 | 'list': [1, 2, 3], 50 | 'link': {'_type': 'link', 'fields': [ 51 | {'name': 'noschema'}, 52 | { 53 | 'name': 'string_example', 54 | 'schema': { 55 | '_type': 'string', 56 | 'title': '', 57 | 'description': '', 58 | }, 59 | }, 60 | { 61 | 'name': 'enum_example', 62 | 'schema': { 63 | '_type': 'enum', 64 | 'title': '', 65 | 'description': '', 66 | 'enum': ['a', 'b', 'c'], 67 | }, 68 | }, 69 | ]}, 70 | 'nested': {'child': {'_type': 'link', 'url': '/123'}}, 71 | '__type': 'needs escaping' 72 | } 73 | 74 | 75 | def test_primitive_to_document(doc): 76 | data = { 77 | '_type': 'document', 78 | '_meta': { 79 | 'url': 'http://example.org/', 80 | 'title': 'Example' 81 | }, 82 | 'integer': 123, 83 | 'dict': {'key': 'value'}, 84 | 'list': [1, 2, 3], 85 | 'link': { 86 | '_type': 'link', 87 | 'url': 'http://example.org/', 88 | 'fields': [ 89 | {'name': 'noschema'}, 90 | { 91 | 'name': 'string_example', 92 | 'schema': { 93 | '_type': 'string', 94 | 'title': '', 95 | 'description': '', 96 | }, 97 | }, 98 | { 99 | 'name': 'enum_example', 100 | 'schema': { 101 | '_type': 'enum', 102 | 'title': '', 103 | 'description': '', 104 | 'enum': ['a', 'b', 'c'], 105 | }, 106 | }, 107 | ], 108 | }, 109 | 'nested': {'child': {'_type': 'link', 'url': 'http://example.org/123'}}, 110 | '__type': 'needs escaping' 111 | } 112 | assert _primitive_to_document(data) == doc 113 | 114 | 115 | def test_error_to_primitive(): 116 | error = Error(title='Failure', content={'messages': ['failed']}) 117 | data = { 118 | '_type': 'error', 119 | '_meta': {'title': 'Failure'}, 120 | 'messages': ['failed'] 121 | } 122 | assert _document_to_primitive(error) == data 123 | 124 | 125 | def test_primitive_to_error(): 126 | error = Error(title='Failure', content={'messages': ['failed']}) 127 | data = { 128 | '_type': 'error', 129 | '_meta': {'title': 'Failure'}, 130 | 'messages': ['failed'] 131 | } 132 | assert _primitive_to_document(data) == error 133 | 134 | 135 | # Codecs can load a document successfully. 136 | 137 | def test_minimal_document(json_codec): 138 | """ 139 | Ensure we can load the smallest possible valid JSON encoding. 140 | """ 141 | doc = json_codec.decode(b'{"_type":"document"}') 142 | assert isinstance(doc, Document) 143 | assert doc.url == '' 144 | assert doc.title == '' 145 | assert doc == {} 146 | 147 | 148 | def test_minimal_error(json_codec): 149 | """ 150 | Ensure we can load a minimal error message encoding. 151 | """ 152 | error = json_codec.decode(b'{"_type":"error","_meta":{"title":"Failure"},"messages":["failed"]}') 153 | assert error == Error(title="Failure", content={'messages': ['failed']}) 154 | 155 | 156 | # Parse errors should be raised for invalid encodings. 157 | 158 | def test_malformed_json(json_codec): 159 | """ 160 | Invalid JSON should raise a ParseError. 161 | """ 162 | with pytest.raises(ParseError): 163 | json_codec.decode(b'_') 164 | 165 | 166 | def test_not_a_document(json_codec): 167 | """ 168 | Valid JSON that does not return a document should be coerced into one. 169 | """ 170 | assert json_codec.decode(b'{}') == Document() 171 | 172 | 173 | # Encodings may have a verbose and a compact style. 174 | 175 | def test_compact_style(json_codec): 176 | doc = Document(content={'a': 123, 'b': 456}) 177 | bytes = json_codec.encode(doc) 178 | assert bytes == b'{"_type":"document","a":123,"b":456}' 179 | 180 | 181 | def test_verbose_style(json_codec): 182 | doc = Document(content={'a': 123, 'b': 456}) 183 | bytes = json_codec.encode(doc, indent=True) 184 | assert bytes == b"""{ 185 | "_type": "document", 186 | "a": 123, 187 | "b": 456 188 | }""" 189 | 190 | 191 | # Links should use compact format for optional fields, verbose for required. 192 | 193 | def test_link_encodings(json_codec): 194 | doc = Document(content={ 195 | 'link': Link( 196 | action='post', 197 | transform='inplace', 198 | fields=['optional', Field('required', required=True, location='path')] 199 | ) 200 | }) 201 | bytes = json_codec.encode(doc, indent=True) 202 | assert bytes == b"""{ 203 | "_type": "document", 204 | "link": { 205 | "_type": "link", 206 | "action": "post", 207 | "transform": "inplace", 208 | "fields": [ 209 | { 210 | "name": "optional" 211 | }, 212 | { 213 | "name": "required", 214 | "required": true, 215 | "location": "path" 216 | } 217 | ] 218 | } 219 | }""" 220 | 221 | 222 | # Tests for graceful omissions. 223 | 224 | def test_invalid_document_meta_ignored(json_codec): 225 | doc = json_codec.decode(b'{"_type": "document", "_meta": 1, "a": 1}') 226 | assert doc == Document(content={"a": 1}) 227 | 228 | 229 | def test_invalid_document_url_ignored(json_codec): 230 | doc = json_codec.decode(b'{"_type": "document", "_meta": {"url": 1}, "a": 1}') 231 | assert doc == Document(content={"a": 1}) 232 | 233 | 234 | def test_invalid_document_title_ignored(json_codec): 235 | doc = json_codec.decode(b'{"_type": "document", "_meta": {"title": 1}, "a": 1}') 236 | assert doc == Document(content={"a": 1}) 237 | 238 | 239 | def test_invalid_link_url_ignored(json_codec): 240 | doc = json_codec.decode(b'{"_type": "document", "link": {"_type": "link", "url": 1}}') 241 | assert doc == Document(content={"link": Link()}) 242 | 243 | 244 | def test_invalid_link_fields_ignored(json_codec): 245 | doc = json_codec.decode(b'{"_type": "document", "link": {"_type": "link", "fields": 1}}') 246 | assert doc == Document(content={"link": Link()}) 247 | 248 | 249 | # Tests for 'Content-Type' header lookup. 250 | 251 | def test_get_default_decoder(): 252 | codec = negotiate_decoder([CoreJSONCodec()]) 253 | assert isinstance(codec, CoreJSONCodec) 254 | 255 | 256 | def test_get_supported_decoder(): 257 | codec = negotiate_decoder([CoreJSONCodec()], 'application/vnd.coreapi+json') 258 | assert isinstance(codec, CoreJSONCodec) 259 | 260 | 261 | def test_get_supported_decoder_with_parameters(): 262 | codec = negotiate_decoder([CoreJSONCodec()], 'application/vnd.coreapi+json; verison=1.0') 263 | assert isinstance(codec, CoreJSONCodec) 264 | 265 | 266 | def test_get_unsupported_decoder(): 267 | with pytest.raises(NoCodecAvailable): 268 | negotiate_decoder([CoreJSONCodec()], 'application/csv') 269 | 270 | 271 | # Tests for 'Accept' header lookup. 272 | 273 | def test_get_default_encoder(): 274 | codec = negotiate_encoder([CoreJSONCodec()]) 275 | assert isinstance(codec, CoreJSONCodec) 276 | 277 | 278 | def test_encoder_preference(): 279 | codec = negotiate_encoder( 280 | [CoreJSONCodec()], 281 | accept='text/html; q=1.0, application/vnd.coreapi+json; q=1.0' 282 | ) 283 | assert isinstance(codec, CoreJSONCodec) 284 | 285 | 286 | def test_get_accepted_encoder(): 287 | codec = negotiate_encoder( 288 | [CoreJSONCodec()], 289 | accept='application/vnd.coreapi+json' 290 | ) 291 | assert isinstance(codec, CoreJSONCodec) 292 | 293 | 294 | def test_get_underspecified_encoder(): 295 | codec = negotiate_encoder( 296 | [CoreJSONCodec()], 297 | accept='application/*' 298 | ) 299 | assert isinstance(codec, CoreJSONCodec) 300 | 301 | 302 | def test_get_unsupported_encoder(): 303 | with pytest.raises(NoCodecAvailable): 304 | negotiate_encoder([CoreJSONCodec()], 'application/csv') 305 | 306 | 307 | def test_get_unsupported_encoder_with_fallback(): 308 | codec = negotiate_encoder([CoreJSONCodec()], accept='application/csv, */*') 309 | assert isinstance(codec, CoreJSONCodec) 310 | -------------------------------------------------------------------------------- /tests/test_document.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from coreapi import Client 3 | from coreapi import Array, Document, Object, Link, Error, Field 4 | from coreapi.exceptions import LinkLookupError 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def doc(): 10 | return Document( 11 | url='http://example.org', 12 | title='Example', 13 | content={ 14 | 'integer': 123, 15 | 'dict': {'key': 'value'}, 16 | 'list': [1, 2, 3], 17 | 'link': Link( 18 | url='/', 19 | action='post', 20 | transform='inplace', 21 | fields=['optional', Field('required', required=True, location='path')] 22 | ), 23 | 'nested': {'child': Link(url='/123')} 24 | }) 25 | 26 | 27 | @pytest.fixture 28 | def obj(): 29 | return Object({'key': 'value', 'nested': {'abc': 123}}) 30 | 31 | 32 | @pytest.fixture 33 | def array(): 34 | return Array([{'a': 1}, {'b': 2}, {'c': 3}]) 35 | 36 | 37 | @pytest.fixture 38 | def link(): 39 | return Link( 40 | url='/', 41 | action='post', 42 | fields=[Field('required', required=True), 'optional'] 43 | ) 44 | 45 | 46 | @pytest.fixture 47 | def error(): 48 | return Error(title='', content={'messages': ['failed']}) 49 | 50 | 51 | def _dedent(string): 52 | """ 53 | Convenience function for dedenting multiline strings, 54 | for string comparison purposes. 55 | """ 56 | lines = string.splitlines() 57 | if not lines[0].strip(): 58 | lines = lines[1:] 59 | if not lines[-1].strip(): 60 | lines = lines[:-1] 61 | leading_spaces = len(lines[0]) - len(lines[0].lstrip(' ')) 62 | return '\n'.join(line[leading_spaces:] for line in lines) 63 | 64 | 65 | # Documents are immutable. 66 | 67 | def test_document_does_not_support_key_assignment(doc): 68 | with pytest.raises(TypeError): 69 | doc['integer'] = 456 70 | 71 | 72 | def test_document_does_not_support_property_assignment(doc): 73 | with pytest.raises(TypeError): 74 | doc.integer = 456 75 | 76 | 77 | def test_document_does_not_support_key_deletion(doc): 78 | with pytest.raises(TypeError): 79 | del doc['integer'] 80 | 81 | 82 | # Objects are immutable. 83 | 84 | def test_object_does_not_support_key_assignment(obj): 85 | with pytest.raises(TypeError): 86 | obj['key'] = 456 87 | 88 | 89 | def test_object_does_not_support_property_assignment(obj): 90 | with pytest.raises(TypeError): 91 | obj.integer = 456 92 | 93 | 94 | def test_object_does_not_support_key_deletion(obj): 95 | with pytest.raises(TypeError): 96 | del obj['key'] 97 | 98 | 99 | # Arrays are immutable. 100 | 101 | def test_array_does_not_support_item_assignment(array): 102 | with pytest.raises(TypeError): 103 | array[1] = 456 104 | 105 | 106 | def test_array_does_not_support_property_assignment(array): 107 | with pytest.raises(TypeError): 108 | array.integer = 456 109 | 110 | 111 | def test_array_does_not_support_item_deletion(array): 112 | with pytest.raises(TypeError): 113 | del array[1] 114 | 115 | 116 | # Links are immutable. 117 | 118 | def test_link_does_not_support_property_assignment(): 119 | link = Link() 120 | with pytest.raises(TypeError): 121 | link.integer = 456 122 | 123 | 124 | # Errors are immutable. 125 | 126 | def test_error_does_not_support_property_assignment(): 127 | error = Error(content={'messages': ['failed']}) 128 | with pytest.raises(TypeError): 129 | error.integer = 456 130 | 131 | 132 | # Children in documents are immutable primitives. 133 | 134 | def test_document_dictionaries_coerced_to_objects(doc): 135 | assert isinstance(doc['dict'], Object) 136 | 137 | 138 | def test_document_lists_coerced_to_arrays(doc): 139 | assert isinstance(doc['list'], Array) 140 | 141 | 142 | # The `delete` and `set` methods return new instances. 143 | 144 | def test_document_delete(doc): 145 | new = doc.delete('integer') 146 | assert doc is not new 147 | assert set(new.keys()) == set(doc.keys()) - set(['integer']) 148 | for key in new.keys(): 149 | assert doc[key] is new[key] 150 | 151 | 152 | def test_document_set(doc): 153 | new = doc.set('integer', 456) 154 | assert doc is not new 155 | assert set(new.keys()) == set(doc.keys()) 156 | for key in set(new.keys()) - set(['integer']): 157 | assert doc[key] is new[key] 158 | 159 | 160 | def test_object_delete(obj): 161 | new = obj.delete('key') 162 | assert obj is not new 163 | assert set(new.keys()) == set(obj.keys()) - set(['key']) 164 | for key in new.keys(): 165 | assert obj[key] is new[key] 166 | 167 | 168 | def test_object_set(obj): 169 | new = obj.set('key', 456) 170 | assert obj is not new 171 | assert set(new.keys()) == set(obj.keys()) 172 | for key in set(new.keys()) - set(['key']): 173 | assert obj[key] is new[key] 174 | 175 | 176 | def test_array_delete(array): 177 | new = array.delete(1) 178 | assert array is not new 179 | assert len(new) == len(array) - 1 180 | assert new[0] is array[0] 181 | assert new[1] is array[2] 182 | 183 | 184 | def test_array_set(array): 185 | new = array.set(1, 456) 186 | assert array is not new 187 | assert len(new) == len(array) 188 | assert new[0] is array[0] 189 | assert new[1] == 456 190 | assert new[2] is array[2] 191 | 192 | 193 | # The `delete_in` and `set_in` functions return new instances. 194 | 195 | def test_delete_in(): 196 | nested = Object({'key': [{'abc': 123}, {'def': 456}], 'other': 0}) 197 | 198 | assert nested.delete_in(['key', 0]) == {'key': [{'def': 456}], 'other': 0} 199 | assert nested.delete_in(['key']) == {'other': 0} 200 | assert nested.delete_in([]) is None 201 | 202 | 203 | def test_set_in(): 204 | nested = Object({'key': [{'abc': 123}, {'def': 456}], 'other': 0}) 205 | insert = Object({'xyz': 789}) 206 | 207 | assert ( 208 | nested.set_in(['key', 0], insert) == 209 | {'key': [{'xyz': 789}, {'def': 456}], 'other': 0} 210 | ) 211 | assert ( 212 | nested.set_in(['key'], insert) == 213 | {'key': {'xyz': 789}, 'other': 0} 214 | ) 215 | assert nested.set_in([], insert) == {'xyz': 789} 216 | 217 | 218 | # Container types have a uniquely identifying representation. 219 | 220 | def test_document_repr(doc): 221 | assert repr(doc) == ( 222 | "Document(url='http://example.org', title='Example', content={" 223 | "'dict': {'key': 'value'}, " 224 | "'integer': 123, " 225 | "'list': [1, 2, 3], " 226 | "'nested': {'child': Link(url='/123')}, " 227 | "'link': Link(url='/', action='post', transform='inplace', " 228 | "fields=['optional', Field('required', required=True, location='path')])" 229 | "})" 230 | ) 231 | assert eval(repr(doc)) == doc 232 | 233 | 234 | def test_object_repr(obj): 235 | assert repr(obj) == "Object({'key': 'value', 'nested': {'abc': 123}})" 236 | assert eval(repr(obj)) == obj 237 | 238 | 239 | def test_array_repr(array): 240 | assert repr(array) == "Array([{'a': 1}, {'b': 2}, {'c': 3}])" 241 | assert eval(repr(array)) == array 242 | 243 | 244 | def test_link_repr(link): 245 | assert repr(link) == "Link(url='/', action='post', fields=[Field('required', required=True), 'optional'])" 246 | assert eval(repr(link)) == link 247 | 248 | 249 | def test_error_repr(error): 250 | assert repr(error) == "Error(title='', content={'messages': ['failed']})" 251 | assert eval(repr(error)) == error 252 | 253 | 254 | # Container types have a convenient string representation. 255 | 256 | def test_document_str(doc): 257 | assert str(doc) == _dedent(""" 258 | 259 | dict: { 260 | key: "value" 261 | } 262 | integer: 123 263 | list: [ 264 | 1, 265 | 2, 266 | 3 267 | ] 268 | nested: { 269 | child() 270 | } 271 | link(required, [optional]) 272 | """) 273 | 274 | 275 | def test_newline_str(): 276 | doc = Document(content={'foo': '1\n2'}) 277 | assert str(doc) == _dedent(""" 278 | 279 | foo: "1 280 | 2" 281 | """) 282 | 283 | 284 | def test_object_str(obj): 285 | assert str(obj) == _dedent(""" 286 | { 287 | key: "value" 288 | nested: { 289 | abc: 123 290 | } 291 | } 292 | """) 293 | 294 | 295 | def test_array_str(array): 296 | assert str(array) == _dedent(""" 297 | [ 298 | { 299 | a: 1 300 | }, 301 | { 302 | b: 2 303 | }, 304 | { 305 | c: 3 306 | } 307 | ] 308 | """) 309 | 310 | 311 | def test_link_str(link): 312 | assert str(link) == "link(required, [optional])" 313 | 314 | 315 | def test_error_str(error): 316 | assert str(error) == _dedent(""" 317 | 318 | messages: [ 319 | "failed" 320 | ] 321 | """) 322 | 323 | 324 | def test_document_urls(): 325 | doc = Document(url='http://example.org/', title='Example', content={ 326 | 'a': Document(title='Full', url='http://example.com/123'), 327 | 'b': Document(title='Path', url='http://example.org/123'), 328 | 'c': Document(title='None', url='http://example.org/') 329 | }) 330 | assert str(doc) == _dedent(""" 331 | 332 | a: 333 | b: 334 | c: 335 | """) 336 | 337 | 338 | # Container types support equality functions. 339 | 340 | def test_document_equality(doc): 341 | assert doc == { 342 | 'integer': 123, 343 | 'dict': {'key': 'value'}, 344 | 'list': [1, 2, 3], 345 | 'link': Link( 346 | url='/', 347 | action='post', 348 | transform='inplace', 349 | fields=['optional', Field('required', required=True, location='path')] 350 | ), 351 | 'nested': {'child': Link(url='/123')} 352 | } 353 | 354 | 355 | def test_object_equality(obj): 356 | assert obj == {'key': 'value', 'nested': {'abc': 123}} 357 | 358 | 359 | def test_array_equality(array): 360 | assert array == [{'a': 1}, {'b': 2}, {'c': 3}] 361 | 362 | 363 | # Container types support len. 364 | 365 | def test_document_len(doc): 366 | assert len(doc) == 5 367 | 368 | 369 | def test_object_len(obj): 370 | assert len(obj) == 2 371 | 372 | 373 | # Documents meet the Core API constraints. 374 | 375 | def test_document_url_must_be_string(): 376 | with pytest.raises(TypeError): 377 | Document(url=123) 378 | 379 | 380 | def test_document_title_must_be_string(): 381 | with pytest.raises(TypeError): 382 | Document(title=123) 383 | 384 | 385 | def test_document_content_must_be_dict(): 386 | with pytest.raises(TypeError): 387 | Document(content=123) 388 | 389 | 390 | def test_document_keys_must_be_strings(): 391 | with pytest.raises(TypeError): 392 | Document(content={0: 123}) 393 | 394 | 395 | def test_object_keys_must_be_strings(): 396 | with pytest.raises(TypeError): 397 | Object(content={0: 123}) 398 | 399 | 400 | def test_error_title_must_be_string(): 401 | with pytest.raises(TypeError): 402 | Error(title=123) 403 | 404 | 405 | def test_error_content_must_be_dict(): 406 | with pytest.raises(TypeError): 407 | Error(content=123) 408 | 409 | 410 | def test_error_keys_must_be_strings(): 411 | with pytest.raises(TypeError): 412 | Error(content={0: 123}) 413 | 414 | 415 | # Link arguments must be valid. 416 | 417 | def test_link_url_must_be_string(): 418 | with pytest.raises(TypeError): 419 | Link(url=123) 420 | 421 | 422 | def test_link_action_must_be_string(): 423 | with pytest.raises(TypeError): 424 | Link(action=123) 425 | 426 | 427 | def test_link_transform_must_be_string(): 428 | with pytest.raises(TypeError): 429 | Link(transform=123) 430 | 431 | 432 | def test_link_fields_must_be_list(): 433 | with pytest.raises(TypeError): 434 | Link(fields=123) 435 | 436 | 437 | def test_link_field_items_must_be_valid(): 438 | with pytest.raises(TypeError): 439 | Link(fields=[123]) 440 | 441 | 442 | # Invalid calls to '.action()' should error. 443 | 444 | def test_keys_should_be_a_list_or_string(doc): 445 | client = Client() 446 | with pytest.raises(TypeError): 447 | client.action(doc, True) 448 | 449 | 450 | def test_keys_should_be_a_list_of_strings_or_ints(doc): 451 | client = Client() 452 | with pytest.raises(TypeError): 453 | client.action(doc, ['nested', {}]) 454 | 455 | 456 | def test_keys_should_be_valid_indexes(doc): 457 | client = Client() 458 | with pytest.raises(LinkLookupError): 459 | client.action(doc, 'dummy') 460 | 461 | 462 | def test_keys_should_access_a_link(doc): 463 | client = Client() 464 | with pytest.raises(LinkLookupError): 465 | client.action(doc, 'dict') 466 | 467 | 468 | # Documents and Objects have `.data` and `.links` attributes 469 | 470 | def test_document_data_and_links_properties(): 471 | doc = Document(content={'a': 1, 'b': 2, 'c': Link(), 'd': Link()}) 472 | assert sorted(list(doc.data.keys())) == ['a', 'b'] 473 | assert sorted(list(doc.links.keys())) == ['c', 'd'] 474 | 475 | 476 | def test_object_data_and_links_properties(): 477 | obj = Object({'a': 1, 'b': 2, 'c': Link(), 'd': Link()}) 478 | assert sorted(list(obj.data.keys())) == ['a', 'b'] 479 | assert sorted(list(obj.links.keys())) == ['c', 'd'] 480 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from coreapi.exceptions import ErrorMessage 3 | 4 | 5 | def test_error_message_repr(): 6 | error = ErrorMessage(['failed']) 7 | assert repr(error) == "ErrorMessage(['failed'])" 8 | 9 | 10 | def test_error_message_str(): 11 | error = ErrorMessage(['failed']) 12 | assert str(error) == "['failed']" 13 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import coreapi 3 | import requests 4 | import pytest 5 | 6 | 7 | encoded = ( 8 | b'{"_type":"document","_meta":{"url":"http://example.org"},' 9 | b'"a":123,"next":{"_type":"link"}}' 10 | ) 11 | 12 | 13 | @pytest.fixture 14 | def document(): 15 | codec = coreapi.codecs.CoreJSONCodec() 16 | return codec.decode(encoded) 17 | 18 | 19 | class MockResponse(object): 20 | def __init__(self, content): 21 | self.content = content 22 | self.headers = {} 23 | self.url = 'http://example.org' 24 | self.status_code = 200 25 | 26 | 27 | # Basic integration tests. 28 | 29 | def test_load(): 30 | codec = coreapi.codecs.CoreJSONCodec() 31 | assert codec.decode(encoded) == { 32 | "a": 123, 33 | "next": coreapi.Link(url='http://example.org') 34 | } 35 | 36 | 37 | def test_dump(document): 38 | codec = coreapi.codecs.CoreJSONCodec() 39 | content = codec.encode(document) 40 | assert content == encoded 41 | 42 | 43 | def test_get(monkeypatch): 44 | def mockreturn(self, request, *args, **kwargs): 45 | return MockResponse(b'{"_type": "document", "example": 123}') 46 | 47 | monkeypatch.setattr(requests.Session, 'send', mockreturn) 48 | 49 | client = coreapi.Client() 50 | doc = client.get('http://example.org') 51 | assert doc == {'example': 123} 52 | 53 | 54 | def test_follow(monkeypatch, document): 55 | def mockreturn(self, request, *args, **kwargs): 56 | return MockResponse(b'{"_type": "document", "example": 123}') 57 | 58 | monkeypatch.setattr(requests.Session, 'send', mockreturn) 59 | 60 | client = coreapi.Client() 61 | doc = client.action(document, ['next']) 62 | assert doc == {'example': 123} 63 | 64 | 65 | def test_reload(monkeypatch): 66 | def mockreturn(self, request, *args, **kwargs): 67 | return MockResponse(b'{"_type": "document", "example": 123}') 68 | 69 | monkeypatch.setattr(requests.Session, 'send', mockreturn) 70 | 71 | client = coreapi.Client() 72 | doc = coreapi.Document(url='http://example.org') 73 | doc = client.reload(doc) 74 | assert doc == {'example': 123} 75 | 76 | 77 | def test_error(monkeypatch, document): 78 | def mockreturn(self, request, *args, **kwargs): 79 | return MockResponse(b'{"_type": "error", "message": ["failed"]}') 80 | 81 | monkeypatch.setattr(requests.Session, 'send', mockreturn) 82 | 83 | client = coreapi.Client() 84 | with pytest.raises(coreapi.exceptions.ErrorMessage): 85 | client.action(document, ['next']) 86 | -------------------------------------------------------------------------------- /tests/test_transitions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from coreapi import Document, Link, Client 3 | from coreapi.transports import HTTPTransport 4 | from coreapi.transports.http import _handle_inplace_replacements 5 | import pytest 6 | 7 | 8 | class MockTransport(HTTPTransport): 9 | schemes = ['mock'] 10 | 11 | def transition(self, link, decoders, params=None, link_ancestors=None): 12 | if link.action == 'get': 13 | document = Document(title='new', content={'new': 123}) 14 | elif link.action in ('put', 'post'): 15 | if params is None: 16 | params = {} 17 | document = Document(title='new', content={'new': 123, 'foo': params.get('foo')}) 18 | else: 19 | document = None 20 | 21 | return _handle_inplace_replacements(document, link, link_ancestors) 22 | 23 | 24 | client = Client(transports=[MockTransport()]) 25 | 26 | 27 | @pytest.fixture 28 | def doc(): 29 | return Document(title='original', content={ 30 | 'nested': Document(content={ 31 | 'follow': Link(url='mock://example.com', action='get'), 32 | 'action': Link(url='mock://example.com', action='post', transform='inplace', fields=['foo']), 33 | 'create': Link(url='mock://example.com', action='post', fields=['foo']), 34 | 'update': Link(url='mock://example.com', action='put', fields=['foo']), 35 | 'delete': Link(url='mock://example.com', action='delete') 36 | }) 37 | }) 38 | 39 | 40 | # Test valid transitions. 41 | 42 | def test_get(doc): 43 | new = client.action(doc, ['nested', 'follow']) 44 | assert new == {'new': 123} 45 | assert new.title == 'new' 46 | 47 | 48 | def test_inline_post(doc): 49 | new = client.action(doc, ['nested', 'action'], params={'foo': 123}) 50 | assert new == {'nested': {'new': 123, 'foo': 123}} 51 | assert new.title == 'original' 52 | 53 | 54 | def test_post(doc): 55 | new = client.action(doc, ['nested', 'create'], params={'foo': 456}) 56 | assert new == {'new': 123, 'foo': 456} 57 | assert new.title == 'new' 58 | 59 | 60 | def test_put(doc): 61 | new = client.action(doc, ['nested', 'update'], params={'foo': 789}) 62 | assert new == {'nested': {'new': 123, 'foo': 789}} 63 | assert new.title == 'original' 64 | 65 | 66 | def test_delete(doc): 67 | new = client.action(doc, ['nested', 'delete']) 68 | assert new == {} 69 | assert new.title == 'original' 70 | 71 | 72 | # Test overrides 73 | 74 | def test_override_action(doc): 75 | new = client.action(doc, ['nested', 'follow'], overrides={'action': 'put'}) 76 | assert new == {'nested': {'new': 123, 'foo': None}} 77 | assert new.title == 'original' 78 | 79 | 80 | def test_override_transform(doc): 81 | new = client.action(doc, ['nested', 'update'], params={'foo': 456}, overrides={'transform': 'new'}) 82 | assert new == {'new': 123, 'foo': 456} 83 | assert new.title == 'new' 84 | -------------------------------------------------------------------------------- /tests/test_transport.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from coreapi import Document, Link, Field 3 | from coreapi.codecs import CoreJSONCodec 4 | from coreapi.compat import force_text 5 | from coreapi.exceptions import NetworkError 6 | from coreapi.transports import HTTPTransport 7 | from coreapi.utils import determine_transport 8 | import pytest 9 | import requests 10 | import json 11 | 12 | 13 | decoders = [CoreJSONCodec()] 14 | transports = [HTTPTransport()] 15 | 16 | 17 | @pytest.fixture 18 | def http(): 19 | return HTTPTransport() 20 | 21 | 22 | class MockResponse(object): 23 | def __init__(self, content): 24 | self.content = content 25 | self.headers = {} 26 | self.url = 'http://example.org' 27 | self.status_code = 200 28 | 29 | 30 | # Test transport errors. 31 | 32 | def test_unknown_scheme(): 33 | with pytest.raises(NetworkError): 34 | determine_transport(transports, 'ftp://example.org') 35 | 36 | 37 | def test_missing_scheme(): 38 | with pytest.raises(NetworkError): 39 | determine_transport(transports, 'example.org') 40 | 41 | 42 | def test_missing_hostname(): 43 | with pytest.raises(NetworkError): 44 | determine_transport(transports, 'http://') 45 | 46 | 47 | # Test basic transition types. 48 | 49 | def test_get(monkeypatch, http): 50 | def mockreturn(self, request, *args, **kwargs): 51 | return MockResponse(b'{"_type": "document", "example": 123}') 52 | 53 | monkeypatch.setattr(requests.Session, 'send', mockreturn) 54 | 55 | link = Link(url='http://example.org', action='get') 56 | doc = http.transition(link, decoders) 57 | assert doc == {'example': 123} 58 | 59 | 60 | def test_get_with_parameters(monkeypatch, http): 61 | def mockreturn(self, request, *args, **kwargs): 62 | insert = request.path_url.encode('utf-8') 63 | return MockResponse( 64 | b'{"_type": "document", "url": "' + insert + b'"}' 65 | ) 66 | 67 | monkeypatch.setattr(requests.Session, 'send', mockreturn) 68 | 69 | link = Link(url='http://example.org', action='get') 70 | doc = http.transition(link, decoders, params={'example': 'abc'}) 71 | assert doc == {'url': '/?example=abc'} 72 | 73 | 74 | def test_get_with_path_parameter(monkeypatch, http): 75 | def mockreturn(self, request, *args, **kwargs): 76 | insert = request.url.encode('utf-8') 77 | return MockResponse( 78 | b'{"_type": "document", "example": "' + insert + b'"}' 79 | ) 80 | 81 | monkeypatch.setattr(requests.Session, 'send', mockreturn) 82 | 83 | link = Link( 84 | url='http://example.org/{user_id}/', 85 | action='get', 86 | fields=[Field(name='user_id', location='path')] 87 | ) 88 | doc = http.transition(link, decoders, params={'user_id': 123}) 89 | assert doc == {'example': 'http://example.org/123/'} 90 | 91 | 92 | def test_post(monkeypatch, http): 93 | def mockreturn(self, request, *args, **kwargs): 94 | codec = CoreJSONCodec() 95 | body = force_text(request.body) 96 | content = codec.encode(Document(content={'data': json.loads(body)})) 97 | return MockResponse(content) 98 | 99 | monkeypatch.setattr(requests.Session, 'send', mockreturn) 100 | 101 | link = Link(url='http://example.org', action='post') 102 | doc = http.transition(link, decoders, params={'example': 'abc'}) 103 | assert doc == {'data': {'example': 'abc'}} 104 | 105 | 106 | def test_delete(monkeypatch, http): 107 | def mockreturn(self, request, *args, **kwargs): 108 | return MockResponse(b'') 109 | 110 | monkeypatch.setattr(requests.Session, 'send', mockreturn) 111 | 112 | link = Link(url='http://example.org', action='delete') 113 | doc = http.transition(link, decoders) 114 | assert doc is None 115 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from coreapi import exceptions, utils 2 | import datetime 3 | import pytest 4 | 5 | 6 | def test_validate_path_param(): 7 | assert utils.validate_path_param(1) == '1' 8 | assert utils.validate_path_param(True) == 'true' 9 | with pytest.raises(exceptions.ParameterError): 10 | utils.validate_path_param(None) 11 | with pytest.raises(exceptions.ParameterError): 12 | utils.validate_path_param('') 13 | with pytest.raises(exceptions.ParameterError): 14 | utils.validate_path_param({}) 15 | with pytest.raises(exceptions.ParameterError): 16 | utils.validate_path_param([]) 17 | 18 | 19 | def test_validate_query_param(): 20 | assert utils.validate_query_param(1) == '1' 21 | assert utils.validate_query_param(True) == 'true' 22 | assert utils.validate_query_param(None) == '' 23 | assert utils.validate_query_param('') == '' 24 | assert utils.validate_query_param([1, 2, 3]) == ['1', '2', '3'] 25 | with pytest.raises(exceptions.ParameterError): 26 | utils.validate_query_param({}) 27 | with pytest.raises(exceptions.ParameterError): 28 | utils.validate_query_param([1, 2, {}]) 29 | 30 | 31 | def test_validate_form_data(): 32 | # Valid JSON 33 | data = { 34 | 'string': 'abc', 35 | 'integer': 123, 36 | 'number': 123.456, 37 | 'boolean': True, 38 | 'null': None, 39 | 'array': [1, 2, 3], 40 | 'object': {'a': 1, 'b': 2, 'c': 3} 41 | } 42 | assert utils.validate_form_param(data, 'application/json') == data 43 | assert utils.validate_body_param(data, 'application/json') == data 44 | 45 | # Invalid JSON 46 | data = datetime.datetime.now() 47 | with pytest.raises(exceptions.ParameterError): 48 | utils.validate_form_param(data, 'application/json') 49 | with pytest.raises(exceptions.ParameterError): 50 | utils.validate_body_param(data, 'application/json') 51 | 52 | data = utils.File('abc.txt', None) 53 | with pytest.raises(exceptions.ParameterError): 54 | utils.validate_form_param(data, 'application/json') 55 | with pytest.raises(exceptions.ParameterError): 56 | utils.validate_body_param(data, 'application/json') 57 | 58 | # URL Encoded 59 | assert utils.validate_form_param(123, 'application/x-www-form-urlencoded') == '123' 60 | assert utils.validate_body_param({'a': 123}, 'application/x-www-form-urlencoded') == {'a': '123'} 61 | with pytest.raises(exceptions.ParameterError): 62 | utils.validate_form_param({'a': {'foo': 'bar'}}, 'application/x-www-form-urlencoded') 63 | with pytest.raises(exceptions.ParameterError): 64 | utils.validate_body_param(123, 'application/x-www-form-urlencoded') 65 | with pytest.raises(exceptions.ParameterError): 66 | utils.validate_form_param(utils.File('abc.txt', None), 'application/x-www-form-urlencoded') 67 | with pytest.raises(exceptions.ParameterError): 68 | utils.validate_body_param({'a': utils.File('abc.txt', None)}, 'application/x-www-form-urlencoded') 69 | 70 | # Multipart 71 | assert utils.validate_form_param(123, 'multipart/form-data') == '123' 72 | assert utils.validate_form_param(utils.File('abc.txt', None), 'multipart/form-data') == utils.File('abc.txt', None) 73 | assert utils.validate_body_param({'a': 123}, 'multipart/form-data') == {'a': '123'} 74 | assert utils.validate_body_param({'a': utils.File('abc.txt', None)}, 'multipart/form-data') == {'a': utils.File('abc.txt', None)} 75 | with pytest.raises(exceptions.ParameterError): 76 | utils.validate_form_param({'a': {'foo': 'bar'}}, 'multipart/form-data') 77 | with pytest.raises(exceptions.ParameterError): 78 | utils.validate_body_param(123, 'multipart/form-data') 79 | 80 | # Raw upload 81 | with pytest.raises(exceptions.ParameterError): 82 | utils.validate_body_param(123, 'application/octet-stream') 83 | 84 | # Invalid encoding on outgoing request 85 | with pytest.raises(exceptions.NetworkError): 86 | assert utils.validate_form_param(123, 'invalid/media-type') 87 | with pytest.raises(exceptions.NetworkError): 88 | assert utils.validate_form_param(123, '') 89 | with pytest.raises(exceptions.NetworkError): 90 | assert utils.validate_body_param(123, 'invalid/media-type') 91 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py34,py33,py27 3 | [testenv] 4 | deps = -rrequirements.txt 5 | commands = ./runtests 6 | --------------------------------------------------------------------------------