diff --git a/coreapi/__init__.py b/coreapi/__init__.py index 92ac890..2bef8bb 100644 --- a/coreapi/__init__.py +++ b/coreapi/__init__.py @@ -1,12 +1,12 @@ # coding: utf-8 -from coreapi import auth, codecs, exceptions, transports, utils +from coreapi import auth, codecs, exceptions, transports, typesys, utils from coreapi.client import Client -from coreapi.document import Array, Document, Link, Object, Error, Field +from coreapi.document import Document, Link, Object, Error, Field, Array -__version__ = '2.3.3' +__version__ = '3.0.0' __all__ = [ - 'Array', 'Document', 'Link', 'Object', 'Error', 'Field', + 'Document', 'Link', 'Object', 'Error', 'Field', 'Array', 'Client', - 'auth', 'codecs', 'exceptions', 'transports', 'utils', + 'auth', 'codecs', 'exceptions', 'transports', 'typesys', 'utils', ] diff --git a/coreapi/client.py b/coreapi/client.py index 00b0057..9a99c8f 100644 --- a/coreapi/client.py +++ b/coreapi/client.py @@ -1,19 +1,14 @@ from coreapi import codecs, exceptions, transports from coreapi.compat import string_types -from coreapi.document import Document, Link +from coreapi.document import Link from coreapi.utils import determine_transport, get_installed_codecs -import collections -import itypes - - -LinkAncestor = collections.namedtuple('LinkAncestor', ['document', 'keys']) def _lookup_link(document, keys): """ Validates that keys looking up a link are correct. - Returns a two-tuple of (link, link_ancestors). + Returns the Link. """ if not isinstance(keys, (list, tuple)): msg = "'keys' must be a list of strings or ints." @@ -28,7 +23,6 @@ def _lookup_link(document, keys): # 'node' is the link we're calling the action for. # 'document_keys' is the list of keys to the link's parent document. node = document - link_ancestors = [LinkAncestor(document=document, keys=[])] for idx, key in enumerate(keys): try: node = node[key] @@ -36,9 +30,6 @@ def _lookup_link(document, keys): index_string = ''.join('[%s]' % repr(key).strip('u') for key in keys) msg = 'Index %s did not reference a link. Key %s was not found.' raise exceptions.LinkLookupError(msg % (index_string, repr(key).strip('u'))) - if isinstance(node, Document): - ancestor = LinkAncestor(document=node, keys=keys[:idx + 1]) - link_ancestors.append(ancestor) # Ensure that we've correctly indexed into a link. if not isinstance(node, Link): @@ -48,7 +39,7 @@ def _lookup_link(document, keys): msg % (index_string, type(node).__name__) ) - return (node, link_ancestors) + return node def _validate_parameters(link, parameters): @@ -95,7 +86,7 @@ def get_default_transports(auth=None, session=None): ] -class Client(itypes.Object): +class Client(object): def __init__(self, decoders=None, transports=None, auth=None, session=None): assert transports is None or auth is None, ( "Cannot specify both 'auth' and 'transports'. " @@ -106,8 +97,8 @@ def __init__(self, decoders=None, transports=None, auth=None, session=None): decoders = get_default_decoders() if transports is None: transports = get_default_transports(auth=auth, session=session) - self._decoders = itypes.List(decoders) - self._transports = itypes.List(transports) + self._decoders = list(decoders) + self._transports = list(transports) @property def decoders(self): @@ -118,7 +109,7 @@ def transports(self): return self._transports def get(self, url, format=None, force_codec=False): - link = Link(url, action='get') + link = Link(url, method='get') decoders = self.decoders if format: @@ -135,24 +126,7 @@ def get(self, url, format=None, force_codec=False): transport = determine_transport(self.transports, link.url) return transport.transition(link, decoders, force_codec=force_codec) - def reload(self, document, format=None, force_codec=False): - # Fallback for v1.x. To be removed in favour of explict `get` style. - return self.get(document.url, format=format, force_codec=force_codec) - - def action(self, document, keys, params=None, validate=True, overrides=None, - action=None, encoding=None, transform=None): - if (action is not None) or (encoding is not None) or (transform is not None): - # Fallback for v1.x overrides. - # Will be removed at some point, most likely in a 2.1 release. - if overrides is None: - overrides = {} - if action is not None: - overrides['action'] = action - if encoding is not None: - overrides['encoding'] = encoding - if transform is not None: - overrides['transform'] = transform - + def action(self, document, keys, params=None, validate=True, overrides=None): if isinstance(keys, string_types): keys = [keys] @@ -160,19 +134,19 @@ def action(self, document, keys, params=None, validate=True, overrides=None, params = {} # Validate the keys and link parameters. - link, link_ancestors = _lookup_link(document, keys) + link = _lookup_link(document, keys) if validate: _validate_parameters(link, params) if overrides: # Handle any explicit overrides. url = overrides.get('url', link.url) - action = overrides.get('action', link.action) + method = overrides.get('method', link.method) + method = overrides.get('action', method) # TODO: Deprecate encoding = overrides.get('encoding', link.encoding) - transform = overrides.get('transform', link.transform) fields = overrides.get('fields', link.fields) - link = Link(url, action=action, encoding=encoding, transform=transform, fields=fields) + link = Link(url, method=method, encoding=encoding, fields=fields) # Perform the action, and return a new document. transport = determine_transport(self.transports, link.url) - return transport.transition(link, self.decoders, params=params, link_ancestors=link_ancestors) + return transport.transition(link, self.decoders, params=params) diff --git a/coreapi/codecs/__init__.py b/coreapi/codecs/__init__.py index 4fa6a1a..8c3596f 100644 --- a/coreapi/codecs/__init__.py +++ b/coreapi/codecs/__init__.py @@ -4,11 +4,14 @@ from coreapi.codecs.display import DisplayCodec from coreapi.codecs.download import DownloadCodec from coreapi.codecs.jsondata import JSONCodec +from coreapi.codecs.jsonschema import JSONSchemaCodec +from coreapi.codecs.openapi import OpenAPICodec from coreapi.codecs.python import PythonCodec from coreapi.codecs.text import TextCodec __all__ = [ - 'BaseCodec', 'CoreJSONCodec', 'DisplayCodec', - 'JSONCodec', 'PythonCodec', 'TextCodec', 'DownloadCodec' + 'BaseCodec', 'CoreJSONCodec', 'DisplayCodec', 'JSONCodec', + 'JSONSchemaCodec', 'OpenAPICodec', 'PythonCodec', 'TextCodec', + 'DownloadCodec' ] diff --git a/coreapi/codecs/base.py b/coreapi/codecs/base.py index 6f20044..d041cb3 100644 --- a/coreapi/codecs/base.py +++ b/coreapi/codecs/base.py @@ -1,7 +1,4 @@ -import itypes - - -class BaseCodec(itypes.Object): +class BaseCodec(object): media_type = None # We don't implement stubs, to ensure that we can check which of these @@ -14,28 +11,6 @@ class BaseCodec(itypes.Object): # def encode(self, document, **options): # pass - # The following will be removed at some point, most likely in a 2.1 release: - def dump(self, *args, **kwargs): - # Fallback for v1.x interface - return self.encode(*args, **kwargs) - - def load(self, *args, **kwargs): - # Fallback for v1.x interface - return self.decode(*args, **kwargs) - - @property - def supports(self): - # Fallback for v1.x interface. - if '+' not in self.media_type: - return ['data'] - - ret = [] - if hasattr(self, 'encode'): - ret.append('encoding') - if hasattr(self, 'decode'): - ret.append('decoding') - return ret - def get_media_types(self): # Fallback, while transitioning from `application/vnd.coreapi+json` # to simply `application/coreapi+json`. diff --git a/coreapi/codecs/corejson.py b/coreapi/codecs/corejson.py index f025533..da089da 100644 --- a/coreapi/codecs/corejson.py +++ b/coreapi/codecs/corejson.py @@ -1,11 +1,11 @@ from __future__ import unicode_literals from collections import OrderedDict +from coreapi import typesys from coreapi.codecs.base import BaseCodec from coreapi.compat import force_bytes, string_types, urlparse from coreapi.compat import COMPACT_SEPARATORS, VERBOSE_SEPARATORS -from coreapi.document import Document, Link, Array, Object, Error, Field +from coreapi.document import Document, Link, Object, Error, Field from coreapi.exceptions import ParseError -import coreschema import json @@ -13,15 +13,13 @@ # Just a naive first-pass at this point. SCHEMA_CLASS_TO_TYPE_ID = { - coreschema.Object: 'object', - coreschema.Array: 'array', - coreschema.Number: 'number', - coreschema.Integer: 'integer', - coreschema.String: 'string', - coreschema.Boolean: 'boolean', - coreschema.Null: 'null', - coreschema.Enum: 'enum', - coreschema.Anything: 'anything' + typesys.Object: 'object', + typesys.Array: 'array', + typesys.Number: 'number', + typesys.Integer: 'integer', + typesys.String: 'string', + typesys.Boolean: 'boolean', + typesys.Any: 'anything' } TYPE_ID_TO_SCHEMA_CLASS = { @@ -32,16 +30,18 @@ def encode_schema_to_corejson(schema): - if hasattr(schema, 'typename'): - type_id = schema.typename + for cls, type_id in SCHEMA_CLASS_TO_TYPE_ID.items(): + if isinstance(schema, cls): + break else: - type_id = SCHEMA_CLASS_TO_TYPE_ID.get(schema.__class__, 'anything') + type_id = 'anything' + retval = { '_type': type_id, - 'title': schema.title, - 'description': schema.description + 'title': schema.title or '', + 'description': schema.description or '' } - if hasattr(schema, 'enum'): + if getattr(schema, 'enum', None) is not None: retval['enum'] = schema.enum return retval @@ -51,12 +51,15 @@ def decode_schema_from_corejson(data): title = _get_string(data, 'title') description = _get_string(data, 'description') - kwargs = {} + kwargs = {'title': title, 'description': description} if type_id == 'enum': + type_id = 'string' kwargs['enum'] = _get_list(data, 'enum') + elif 'enum' in data: + kwargs['enum'] = data['enum'] - schema_cls = TYPE_ID_TO_SCHEMA_CLASS.get(type_id, coreschema.Anything) - return schema_cls(title=title, description=description, **kwargs) + schema_cls = TYPE_ID_TO_SCHEMA_CLASS.get(type_id, typesys.Any) + return schema_cls(**kwargs) # Robust dictionary lookups, that always return an item of the correct @@ -196,8 +199,6 @@ def _document_to_primitive(node, base_url=None): ret['action'] = node.action if node.encoding: ret['encoding'] = node.encoding - if node.transform: - ret['transform'] = node.transform if node.title: ret['title'] = node.title if node.description: @@ -224,9 +225,6 @@ def _document_to_primitive(node, base_url=None): for key, value in node.items() ]) - elif isinstance(node, Array): - return [_document_to_primitive(value) for value in node] - return node @@ -264,7 +262,6 @@ def _primitive_to_document(data, base_url=None): url = urlparse.urljoin(base_url, url) action = _get_string(data, 'action') encoding = _get_string(data, 'encoding') - transform = _get_string(data, 'transform') title = _get_string(data, 'title') description = _get_string(data, 'description') fields = _get_list(data, 'fields') @@ -278,7 +275,7 @@ def _primitive_to_document(data, base_url=None): for item in fields if isinstance(item, dict) ] return Link( - url=url, action=action, encoding=encoding, transform=transform, + url=url, method=action, encoding=encoding, title=title, description=description, fields=fields ) @@ -287,11 +284,6 @@ def _primitive_to_document(data, base_url=None): content = _get_content(data, base_url=base_url) return Object(content) - elif isinstance(data, list): - # Array - content = [_primitive_to_document(item, base_url) for item in data] - return Array(content) - # String, Integer, Number, Boolean, null. return data diff --git a/coreapi/codecs/display.py b/coreapi/codecs/display.py index 250e0cc..1dfa8cb 100644 --- a/coreapi/codecs/display.py +++ b/coreapi/codecs/display.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals from coreapi.codecs.base import BaseCodec from coreapi.compat import console_style, string_types -from coreapi.document import Document, Link, Array, Object, Error +from coreapi.document import Document, Link, Object, Error import json @@ -76,17 +76,6 @@ def _to_plaintext(node, indent=0, base_url=None, colorize=False, extra_offset=No return head if (not body) else head + '\n' + body - elif isinstance(node, Array): - head_indent = ' ' * indent - body_indent = ' ' * (indent + 1) - - body = ',\n'.join([ - body_indent + _to_plaintext(value, indent + 1, base_url=base_url, colorize=colorize) - for value in node - ]) - - return '[]' if (not body) else '[\n' + body + '\n' + head_indent + ']' - elif isinstance(node, Link): return ( colorize_keys('link(') + diff --git a/coreapi/codecs/download.py b/coreapi/codecs/download.py index 0995690..8547c1a 100644 --- a/coreapi/codecs/download.py +++ b/coreapi/codecs/download.py @@ -1,13 +1,102 @@ # coding: utf-8 from coreapi.codecs.base import BaseCodec from coreapi.compat import urlparse -from coreapi.utils import DownloadedFile, guess_extension +from coreapi.utils import DownloadedFile import cgi import os import posixpath import tempfile +def _guess_extension(content_type): + """ + Python's `mimetypes.guess_extension` is no use because it simply returns + the first of an unordered set. We use the same set of media types here, + but take a reasonable preference on what extension to map to. + """ + return { + 'application/javascript': '.js', + 'application/msword': '.doc', + 'application/octet-stream': '.bin', + 'application/oda': '.oda', + 'application/pdf': '.pdf', + 'application/pkcs7-mime': '.p7c', + 'application/postscript': '.ps', + 'application/vnd.apple.mpegurl': '.m3u', + 'application/vnd.ms-excel': '.xls', + 'application/vnd.ms-powerpoint': '.ppt', + 'application/x-bcpio': '.bcpio', + 'application/x-cpio': '.cpio', + 'application/x-csh': '.csh', + 'application/x-dvi': '.dvi', + 'application/x-gtar': '.gtar', + 'application/x-hdf': '.hdf', + 'application/x-latex': '.latex', + 'application/x-mif': '.mif', + 'application/x-netcdf': '.nc', + 'application/x-pkcs12': '.p12', + 'application/x-pn-realaudio': '.ram', + 'application/x-python-code': '.pyc', + 'application/x-sh': '.sh', + 'application/x-shar': '.shar', + 'application/x-shockwave-flash': '.swf', + 'application/x-sv4cpio': '.sv4cpio', + 'application/x-sv4crc': '.sv4crc', + 'application/x-tar': '.tar', + 'application/x-tcl': '.tcl', + 'application/x-tex': '.tex', + 'application/x-texinfo': '.texinfo', + 'application/x-troff': '.tr', + 'application/x-troff-man': '.man', + 'application/x-troff-me': '.me', + 'application/x-troff-ms': '.ms', + 'application/x-ustar': '.ustar', + 'application/x-wais-source': '.src', + 'application/xml': '.xml', + 'application/zip': '.zip', + 'audio/basic': '.au', + 'audio/mpeg': '.mp3', + 'audio/x-aiff': '.aif', + 'audio/x-pn-realaudio': '.ra', + 'audio/x-wav': '.wav', + 'image/gif': '.gif', + 'image/ief': '.ief', + 'image/jpeg': '.jpe', + 'image/png': '.png', + 'image/svg+xml': '.svg', + 'image/tiff': '.tiff', + 'image/vnd.microsoft.icon': '.ico', + 'image/x-cmu-raster': '.ras', + 'image/x-ms-bmp': '.bmp', + 'image/x-portable-anymap': '.pnm', + 'image/x-portable-bitmap': '.pbm', + 'image/x-portable-graymap': '.pgm', + 'image/x-portable-pixmap': '.ppm', + 'image/x-rgb': '.rgb', + 'image/x-xbitmap': '.xbm', + 'image/x-xpixmap': '.xpm', + 'image/x-xwindowdump': '.xwd', + 'message/rfc822': '.eml', + 'text/css': '.css', + 'text/csv': '.csv', + 'text/html': '.html', + 'text/plain': '.txt', + 'text/richtext': '.rtx', + 'text/tab-separated-values': '.tsv', + 'text/x-python': '.py', + 'text/x-setext': '.etx', + 'text/x-sgml': '.sgml', + 'text/x-vcard': '.vcf', + 'text/xml': '.xml', + 'video/mp4': '.mp4', + 'video/mpeg': '.mpeg', + 'video/quicktime': '.mov', + 'video/webm': '.webm', + 'video/x-msvideo': '.avi', + 'video/x-sgi-movie': '.movie' + }.get(content_type, '') + + def _unique_output_path(path): """ Given a path like '/a/b/c.txt' @@ -69,7 +158,7 @@ def _get_filename_from_url(url, content_type=None): parsed = urlparse.urlparse(url) final_path_component = posixpath.basename(parsed.path.rstrip('/')) filename = _safe_filename(final_path_component) - suffix = guess_extension(content_type or '') + suffix = _guess_extension(content_type or '') if filename: if '.' not in filename: diff --git a/coreapi/codecs/jsonschema.py b/coreapi/codecs/jsonschema.py new file mode 100644 index 0000000..38df4a0 --- /dev/null +++ b/coreapi/codecs/jsonschema.py @@ -0,0 +1,238 @@ +from coreapi import typesys +from coreapi.codecs.base import BaseCodec +from coreapi.compat import COMPACT_SEPARATORS, VERBOSE_SEPARATORS, force_bytes, string_types +from coreapi.exceptions import ParseError +from coreapi.schemas import JSONSchema +import collections +import json + + +def decode(struct): + types = get_types(struct) + if is_any(types, struct): + return typesys.Any() + + allow_null = False + if 'null' in types: + allow_null = True + types.remove('null') + + if len(types) == 1: + return load_type(types.pop(), struct, allow_null) + else: + items = [load_type(typename, struct, False) for typename in types] + return typesys.Union(items=items, allow_null=allow_null) + + +def get_types(struct): + """ + Return the valid schema types as a set. + """ + types = struct.get('type', []) + if isinstance(types, string_types): + types = set([types]) + else: + types = set(types) + + if not types: + types = set(['null', 'boolean', 'object', 'array', 'number', 'string']) + + if 'integer' in types and 'number' in types: + types.remove('integer') + + return types + + +def is_any(types, struct): + """ + Return true if all types are valid, and there are no type constraints. + """ + ALL_PROPERTY_NAMES = set([ + 'exclusiveMaximum', 'format', 'minItems', 'pattern', 'required', + 'multipleOf', 'maximum', 'minimum', 'maxItems', 'minLength', + 'uniqueItems', 'additionalItems', 'maxLength', 'items', + 'exclusiveMinimum', 'properties', 'additionalProperties', + 'minProperties', 'maxProperties', 'patternProperties' + ]) + return len(types) == 6 and not set(struct.keys()) & ALL_PROPERTY_NAMES + + +def load_type(typename, struct, allow_null): + attrs = {'allow_null': True} if allow_null else {} + + if typename == 'string': + if 'minLength' in struct: + attrs['min_length'] = struct['minLength'] + if 'maxLength' in struct: + attrs['max_length'] = struct['maxLength'] + if 'pattern' in struct: + attrs['pattern'] = struct['pattern'] + if 'format' in struct: + attrs['format'] = struct['format'] + return typesys.String(**attrs) + + if typename in ['number', 'integer']: + if 'minimum' in struct: + attrs['minimum'] = struct['minimum'] + if 'maximum' in struct: + attrs['maximum'] = struct['maximum'] + if 'exclusiveMinimum' in struct: + attrs['exclusive_minimum'] = struct['exclusiveMinimum'] + if 'exclusiveMaximum' in struct: + attrs['exclusive_maximum'] = struct['exclusiveMaximum'] + if 'multipleOf' in struct: + attrs['multiple_of'] = struct['multipleOf'] + if 'format' in struct: + attrs['format'] = struct['format'] + if typename == 'integer': + return typesys.Integer(**attrs) + return typesys.Number(**attrs) + + if typename == 'boolean': + return typesys.Boolean(**attrs) + + if typename == 'object': + if 'properties' in struct: + attrs['properties'] = { + key: decode(value) + for key, value in struct['properties'].items() + } + if 'required' in struct: + attrs['required'] = struct['required'] + if 'minProperties' in struct: + attrs['min_properties'] = struct['minProperties'] + if 'maxProperties' in struct: + attrs['max_properties'] = struct['maxProperties'] + if 'required' in struct: + attrs['required'] = struct['required'] + if 'patternProperties' in struct: + attrs['pattern_properties'] = { + key: decode(value) + for key, value in struct['patternProperties'].items() + } + if 'additionalProperties' in struct: + if isinstance(struct['additionalProperties'], bool): + attrs['additional_properties'] = struct['additionalProperties'] + else: + attrs['additional_properties'] = decode(struct['additionalProperties']) + return typesys.Object(**attrs) + + if typename == 'array': + if 'items' in struct: + if isinstance(struct['items'], list): + attrs['items'] = [decode(item) for item in struct['items']] + else: + attrs['items'] = decode(struct['items']) + if 'additionalItems' in struct: + if isinstance(struct['additionalItems'], bool): + attrs['additional_items'] = struct['additionalItems'] + else: + attrs['additional_items'] = decode(struct['additionalItems']) + if 'minItems' in struct: + attrs['min_items'] = struct['minItems'] + if 'maxItems' in struct: + attrs['max_items'] = struct['maxItems'] + if 'uniqueItems' in struct: + attrs['unique_items'] = struct['uniqueItems'] + return typesys.Array(**attrs) + + assert False + + +class JSONSchemaCodec(BaseCodec): + media_type = 'application/schema+json' + + def decode(self, bytestring, **options): + try: + data = json.loads( + bytestring.decode('utf-8'), + object_pairs_hook=collections.OrderedDict + ) + except ValueError as exc: + raise ParseError('Malformed JSON. %s' % exc) + jsonschema = JSONSchema.validate(data) + return decode(jsonschema) + + def decode_from_data_structure(self, struct): + jsonschema = JSONSchema.validate(struct) + return decode(jsonschema) + + def encode(self, item, **options): + struct = self.encode_to_data_structure(item) + indent = options.get('indent') + if indent: + kwargs = { + 'ensure_ascii': False, + 'indent': 4, + 'separators': VERBOSE_SEPARATORS + } + else: + kwargs = { + 'ensure_ascii': False, + 'indent': None, + 'separators': COMPACT_SEPARATORS + } + return force_bytes(json.dumps(struct, **kwargs)) + + def encode_to_data_structure(self, item): + if isinstance(item, typesys.String): + value = {'type': 'string'} + if item.max_length is not None: + value['maxLength'] = item.max_length + if item.min_length is not None: + value['minLength'] = item.min_length + if item.pattern is not None: + value['pattern'] = item.pattern + if item.format is not None: + value['format'] = item.format + return value + + if isinstance(item, typesys.NumericType): + if isinstance(item, typesys.Integer): + value = {'type': 'integer'} + else: + value = {'type': 'number'} + + if item.minimum is not None: + value['minimum'] = item.minimum + if item.maximum is not None: + value['maximum'] = item.maximum + if item.exclusive_minimum: + value['exclusiveMinimum'] = item.exclusive_minimum + if item.exclusive_maximum: + value['exclusiveMaximum'] = item.exclusive_maximum + if item.multiple_of is not None: + value['multipleOf'] = item.multiple_of + if item.format is not None: + value['format'] = item.format + return value + + if isinstance(item, typesys.Boolean): + return {'type': 'boolean'} + + if isinstance(item, typesys.Object): + value = {'type': 'object'} + if item.properties: + value['properties'] = { + key: self.encode_to_data_structure(value) + for key, value in item.properties.items() + } + if item.required: + value['required'] = item.required + return value + + if isinstance(item, typesys.Array): + value = {'type': 'array'} + if item.items is not None: + value['items'] = self.encode_to_data_structure(item.items) + if item.additional_items: + value['additionalItems'] = item.additional_items + if item.min_items is not None: + value['minItems'] = item.min_items + if item.max_items is not None: + value['maxItems'] = item.max_items + if item.unique_items is not None: + value['uniqueItems'] = item.unique_items + return value + + raise Exception('Cannot encode item %s' % item) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py new file mode 100644 index 0000000..902190a --- /dev/null +++ b/coreapi/codecs/openapi.py @@ -0,0 +1,241 @@ +from coreapi import typesys +from coreapi.codecs import BaseCodec, JSONSchemaCodec +from coreapi.compat import VERBOSE_SEPARATORS, dict_type, force_bytes, string_types, urlparse +from coreapi.document import Document, Link, Field, Section +from coreapi.exceptions import ParseError +from coreapi.schemas import OpenAPI +import json +import re + + +METHODS = [ + 'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace' +] + + +def lookup(value, keys, default=None): + for key in keys: + try: + value = value[key] + except (KeyError, IndexError, TypeError): + return default + return value + + +def _relative_url(base_url, url): + """ + Return a graceful link for a URL relative to a base URL. + + * If the have the same scheme and hostname, return the path & query params. + * Otherwise return the full URL. + """ + base_prefix = '%s://%s' % urlparse.urlparse(base_url or '')[0:2] + url_prefix = '%s://%s' % urlparse.urlparse(url or '')[0:2] + if base_prefix == url_prefix and url_prefix != '://': + return url[len(url_prefix):] + return url + + +def _simple_slugify(text): + text = text.lower() + text = re.sub(r'[^a-z0-9]+', '_', text) + text = re.sub(r'[_]+', '_', text) + return text.strip('_') + + +class OpenAPICodec(BaseCodec): + media_type = 'application/vnd.oai.openapi' + format = 'openapi' + + def decode(self, bytestring, **options): + try: + data = json.loads(bytestring.decode('utf-8')) + except ValueError as exc: + raise ParseError('Malformed JSON. %s' % exc) + + openapi = OpenAPI.validate(data) + title = lookup(openapi, ['info', 'title']) + description = lookup(openapi, ['info', 'description']) + version = lookup(openapi, ['info', 'version']) + base_url = lookup(openapi, ['servers', 0, 'url']) + schema_definitions = self.get_schema_definitions(openapi) + sections = self.get_sections(openapi, base_url, schema_definitions) + return Document(title=title, description=description, version=version, url=base_url, sections=sections) + + def get_schema_definitions(self, openapi): + definitions = {} + schemas = lookup(openapi, ['components', 'schemas'], {}) + for key, value in schemas.items(): + definitions[key] = JSONSchemaCodec().decode_from_data_structure(value) + return definitions + + def get_sections(self, openapi, base_url, schema_definitions): + """ + Return all the links in the document, layed out by tag and operationId. + """ + links = dict_type() + + for path, path_info in openapi.get('paths', {}).items(): + operations = { + key: path_info[key] for key in path_info + if key in METHODS + } + for operation, operation_info in operations.items(): + tag = lookup(operation_info, ['tags', 0], default='') + link = self.get_link(base_url, path, path_info, operation, operation_info, schema_definitions) + if link is None: + continue + + if tag not in links: + links[tag] = [] + links[tag].append(link) + + return [ + Section(id=_simple_slugify(key), title=key.title(), links=value) + for key, value in links.items() + ] + + def get_link(self, base_url, path, path_info, operation, operation_info, schema_definitions): + """ + Return a single link in the document. + """ + id = operation_info.get('operationId') + title = operation_info.get('summary') + description = operation_info.get('description') + + if id is None: + id = _simple_slugify(title) + if not id: + return None + + # Allow path info and operation info to override the base url. + base_url = lookup(path_info, ['servers', 0, 'url'], default=base_url) + base_url = lookup(operation_info, ['servers', 0, 'url'], default=base_url) + + # Parameters are taken both from the path info, and from the operation. + parameters = path_info.get('parameters', []) + parameters += operation_info.get('parameters', []) + + fields = [ + self.get_field(parameter, schema_definitions) + for parameter in parameters + ] + + # TODO: Handle media type generically here... + body_schema = lookup(operation_info, ['requestBody', 'content', 'application/json', 'schema']) + + encoding = None + if body_schema: + encoding = 'application/json' + if '$ref' in body_schema: + ref = body_schema['$ref'][len('#/components/schemas/'):] + schema = schema_definitions.get(ref) + else: + schema = JSONSchemaCodec().decode_from_data_structure(body_schema) + if isinstance(schema, typesys.Object): + for key, value in schema.properties.items(): + fields += [Field(name=key, location='form', schema=value)] + + return Link( + id=id, + url=urlparse.urljoin(base_url, path), + method=operation, + title=title, + description=description, + fields=fields, + encoding=encoding + ) + + def get_field(self, parameter, schema_definitions): + """ + Return a single field in a link. + """ + name = parameter.get('name') + location = parameter.get('in') + description = parameter.get('description') + required = parameter.get('required', False) + schema = parameter.get('schema') + example = parameter.get('example') + + if schema is not None: + if '$ref' in schema: + ref = schema['$ref'][len('#/components/schemas/'):] + schema = schema_definitions.get(ref) + else: + schema = JSONSchemaCodec().decode_from_data_structure(schema) + + return Field( + name=name, + location=location, + description=description, + required=required, + schema=schema, + example=example + ) + + def encode(self, document, **options): + paths = self.get_paths(document) + openapi = OpenAPI.validate({ + 'openapi': '3.0.0', + 'info': { + 'version': document.version, + 'title': document.title, + 'description': document.description + }, + 'servers': [{ + 'url': document.url + }], + 'paths': paths + }) + + kwargs = { + 'ensure_ascii': False, + 'indent': 4, + 'separators': VERBOSE_SEPARATORS + } + return force_bytes(json.dumps(openapi, **kwargs)) + + def get_paths(self, document): + paths = dict_type() + + for operation_id, link in document.links.items(): + url = urlparse.urlparse(link.url) + if url.path not in paths: + paths[url.path] = {} + paths[url.path][link.action] = self.get_operation(link, operation_id) + + for tag, links in document.data.items(): + for operation_id, link in links.links.items(): + url = urlparse.urlparse(link.url) + if url.path not in paths: + paths[url.path] = {} + paths[url.path][link.action] = self.get_operation(link, operation_id, tag=tag) + + return paths + + def get_operation(self, link, operation_id, tag=None): + operation = { + 'operationId': operation_id + } + if link.title: + operation['summary'] = link.title + if link.description: + operation['description'] = link.description + if tag: + operation['tags'] = [tag] + if link.fields: + operation['parameters'] = [self.get_parameter(field) for field in link.fields] + return operation + + def get_parameter(self, field): + parameter = { + 'name': field.name, + 'in': field.location + } + if field.required: + parameter['required'] = True + if field.description: + parameter['description'] = field.description + if field.schema: + parameter['schema'] = JSONSchemaCodec().encode_to_data_structure(field.schema) + return parameter diff --git a/coreapi/codecs/python.py b/coreapi/codecs/python.py index 6265a28..0a1137a 100644 --- a/coreapi/codecs/python.py +++ b/coreapi/codecs/python.py @@ -3,7 +3,7 @@ # It may move into a utility function in the future. from __future__ import unicode_literals from coreapi.codecs.base import BaseCodec -from coreapi.document import Document, Link, Array, Object, Error, Field +from coreapi.document import Document, Link, Object, Error, Field def _to_repr(node): @@ -31,19 +31,12 @@ def _to_repr(node): for key, value in node.items() ]) - elif isinstance(node, Array): - return '[%s]' % ', '.join([ - _to_repr(value) for value in node - ]) - elif isinstance(node, Link): args = "url=%s" % repr(node.url) - if node.action: - args += ", action=%s" % repr(node.action) + if node.method: + args += ", method=%s" % repr(node.method) if node.encoding: args += ", encoding=%s" % repr(node.encoding) - if node.transform: - args += ", transform=%s" % repr(node.transform) if node.description: args += ", description=%s" % repr(node.description) if node.fields: @@ -73,10 +66,7 @@ class PythonCodec(BaseCodec): media_type = 'text/python' def encode(self, document, **options): - # Object and Array only have the class name wrapper if they - # are the outermost element. + # Object only has the class name wrapper if it is the outermost element. if isinstance(document, Object): return 'Object(%s)' % _to_repr(document) - elif isinstance(document, Array): - return 'Array(%s)' % _to_repr(document) return _to_repr(document) diff --git a/coreapi/compat.py b/coreapi/compat.py index 9890ec1..a8d5d44 100644 --- a/coreapi/compat.py +++ b/coreapi/compat.py @@ -1,6 +1,8 @@ # coding: utf-8 import base64 +import collections +import sys __all__ = [ @@ -13,6 +15,7 @@ # Python 2 import urlparse import cookielib as cookiejar + import math string_types = (basestring,) text_type = unicode @@ -23,11 +26,17 @@ def b64encode(input_string): # Provide a consistently-as-unicode interface across 2.x and 3.x return base64.b64encode(input_string) + def isfinite(num): + if math.isinf(num) or math.isnan(num): + return False + return True + except ImportError: # Python 3 import urllib.parse as urlparse from io import IOBase from http import cookiejar + from math import isfinite string_types = (str,) text_type = str @@ -39,6 +48,37 @@ def b64encode(input_string): return base64.b64encode(input_string.encode('ascii')).decode('ascii') +try: + import coreschema +except ImportError: + # Temporary shim, to support 'coreschema' until it's fully deprecated. + def coreschema_to_typesys(item): + return item +else: + def coreschema_to_typesys(item): + from coreapi import typesys + + # We were only ever using the type and title/description, + # so we don't both to include the full set of keyword arguments here. + if isinstance(item, coreschema.String): + return typesys.string(title=item.title, description=item.description) + elif isinstance(item, coreschema.Integer): + return typesys.integer(title=item.title, description=item.description) + elif isinstance(item, coreschema.Number): + return typesys.number(title=item.title, description=item.description) + elif isinstance(item, coreschema.Boolean): + return typesys.boolean(title=item.title, description=item.description) + elif isinstance(item, coreschema.Enum): + return typesys.enum(title=item.title, description=item.description, enum=item.enum) + elif isinstance(item, coreschema.Array): + return typesys.array(title=item.title, description=item.description) + elif isinstance(item, coreschema.Object): + return typesys.obj(title=item.title, description=item.description) + elif isinstance(item, coreschema.Anything): + return typesys.any(title=item.title, description=item.description) + + return item + def force_bytes(string): if isinstance(string, string_types): return string.encode('utf-8') @@ -51,6 +91,12 @@ def force_text(string): return string +if sys.version_info < (3, 6): + dict_type = collections.OrderedDict +else: + dict_type = dict + + try: import click console_style = click.style diff --git a/coreapi/document.py b/coreapi/document.py index c6c9ceb..2136931 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -1,15 +1,12 @@ # coding: utf-8 from __future__ import unicode_literals -from collections import OrderedDict, namedtuple -from coreapi.compat import string_types -import itypes +from collections import Mapping, OrderedDict +from coreapi.compat import coreschema_to_typesys, string_types -def _to_immutable(value): +def _to_objects(value): if isinstance(value, dict): return Object(value) - elif isinstance(value, list): - return Array(value) return value @@ -27,39 +24,31 @@ def _key_sorting(item): """ Document and Object sorting. Regular attributes sorted alphabetically. - Links are sorted based on their URL and action. + Links are sorted based on their URL and method. """ key, value = item if isinstance(value, Link): - action_priority = { + method_priority = { 'get': 0, 'post': 1, 'put': 2, 'patch': 3, 'delete': 4 - }.get(value.action, 5) - return (1, (value.url, action_priority)) + }.get(value.method, 5) + return (1, (value.url, method_priority)) return (0, key) -# The field class, as used by Link objects: - -# NOTE: 'type', 'description' and 'example' are now deprecated, -# in favor of 'schema'. -Field = namedtuple('Field', ['name', 'required', 'location', 'schema', 'description', 'type', 'example']) -Field.__new__.__defaults__ = (False, '', None, None, None, None) - - # The Core API primitives: -class Document(itypes.Dict): +class Document(Mapping): """ The Core API document type. Expresses the data that the client may access, and the actions that the client may perform. """ - def __init__(self, url=None, title=None, description=None, media_type=None, content=None): + def __init__(self, url=None, title=None, description=None, version=None, media_type=None, content=None, sections=None): content = {} if (content is None) else content if url is not None and not isinstance(url, string_types): @@ -68,6 +57,8 @@ def __init__(self, url=None, title=None, description=None, media_type=None, cont raise TypeError("'title' must be a string.") if description is not None and not isinstance(description, string_types): raise TypeError("'description' must be a string.") + if version is not None and not isinstance(version, string_types): + raise TypeError("'version' must be a string.") if media_type is not None and not isinstance(media_type, string_types): raise TypeError("'media_type' must be a string.") if not isinstance(content, dict): @@ -78,16 +69,32 @@ def __init__(self, url=None, title=None, description=None, media_type=None, cont self._url = '' if (url is None) else url self._title = '' if (title is None) else title self._description = '' if (description is None) else description + self._version = '' if (version is None) else version self._media_type = '' if (media_type is None) else media_type - self._data = {key: _to_immutable(value) for key, value in content.items()} - def clone(self, data): - return self.__class__(self.url, self.title, self.description, self.media_type, data) + if sections: + for section in sections: + if not section.id: + for link in section.links: + content[link.id] = link + else: + content[section.id] = {} + for link in section.links: + content[section.id][link.id] = link + self.sections = sections + + self._data = {key: _to_objects(value) for key, value in content.items()} def __iter__(self): items = sorted(self._data.items(), key=_key_sorting) return iter([key for key, value in items]) + def __len__(self): + return len(self._data) + + def __getitem__(self, key): + return self._data[key] + def __repr__(self): return _repr(self) @@ -115,6 +122,10 @@ def title(self): def description(self): return self._description + @property + def version(self): + return self._version + @property def media_type(self): return self._media_type @@ -134,7 +145,15 @@ def links(self): ]) -class Object(itypes.Dict): +class Section(object): + def __init__(self, id=None, title=None, description=None, links=None): + self.id = id + self.title = title + self.description = description + self.links = links + + +class Object(Mapping): """ An immutable mapping of strings to values. """ @@ -142,12 +161,18 @@ def __init__(self, *args, **kwargs): data = dict(*args, **kwargs) if any([not isinstance(key, string_types) for key in data.keys()]): raise TypeError('Object keys must be strings.') - self._data = {key: _to_immutable(value) for key, value in data.items()} + self._data = {key: _to_objects(value) for key, value in data.items()} def __iter__(self): items = sorted(self._data.items(), key=_key_sorting) return iter([key for key, value in items]) + def __len__(self): + return len(self._data) + + def __getitem__(self, key): + return self._data[key] + def __repr__(self): return _repr(self) @@ -169,33 +194,20 @@ def links(self): ]) -class Array(itypes.List): - """ - An immutable list type container. - """ - def __init__(self, *args): - self._data = [_to_immutable(value) for value in list(*args)] - - def __repr__(self): - return _repr(self) - - def __str__(self): - return _str(self) - - -class Link(itypes.Object): +class Link(object): """ Links represent the actions that a client may perform. """ - def __init__(self, url=None, action=None, encoding=None, transform=None, title=None, description=None, fields=None): + def __init__(self, url=None, method=None, encoding=None, title=None, description=None, fields=None, action=None, id=None): + if action is not None: + method = action # Deprecated + if (url is not None) and (not isinstance(url, string_types)): raise TypeError("Argument 'url' must be a string.") - if (action is not None) and (not isinstance(action, string_types)): - raise TypeError("Argument 'action' must be a string.") + if (method is not None) and (not isinstance(method, string_types)): + raise TypeError("Argument 'method' must be a string.") if (encoding is not None) and (not isinstance(encoding, string_types)): raise TypeError("Argument 'encoding' must be a string.") - if (transform is not None) and (not isinstance(transform, string_types)): - raise TypeError("Argument 'transform' must be a string.") if (title is not None) and (not isinstance(title, string_types)): raise TypeError("Argument 'title' must be a string.") if (description is not None) and (not isinstance(description, string_types)): @@ -208,10 +220,10 @@ def __init__(self, url=None, action=None, encoding=None, transform=None, title=N ]): raise TypeError("Argument 'fields' must be a list of strings or fields.") + self.id = id self._url = '' if (url is None) else url - self._action = '' if (action is None) else action + self._method = '' if (method is None) else method self._encoding = '' if (encoding is None) else encoding - self._transform = '' if (transform is None) else transform self._title = '' if (title is None) else title self._description = '' if (description is None) else description self._fields = () if (fields is None) else tuple([ @@ -224,17 +236,13 @@ def url(self): return self._url @property - def action(self): - return self._action + def method(self): + return self._method @property def encoding(self): return self._encoding - @property - def transform(self): - return self._transform - @property def title(self): return self._title @@ -247,13 +255,26 @@ def description(self): def fields(self): return self._fields + @property + def action(self): + # Deprecated + return self._method + + def path_fields(self): + return [field for field in self.fields if field.location == 'path'] + + def query_fields(self): + return [field for field in self.fields if field.location == 'query'] + + def form_fields(self): + return [field for field in self.fields if field.location == 'form'] + def __eq__(self, other): return ( isinstance(other, Link) and self.url == other.url and - self.action == other.action and + self.method == other.method and self.encoding == other.encoding and - self.transform == other.transform and self.description == other.description and sorted(self.fields, key=lambda f: f.name) == sorted(other.fields, key=lambda f: f.name) ) @@ -265,7 +286,29 @@ def __str__(self): return _str(self) -class Error(itypes.Dict): +class Field(object): + def __init__(self, name, title='', description='', required=False, location='', schema=None, example=None): + self.name = name + self.title = title + self.description = description + self.location = location + self.required = required + self.schema = coreschema_to_typesys(schema) + self.example = example + + def __eq__(self, other): + return ( + isinstance(other, Field) and + self.name == other.name and + self.required == other.required and + self.location == other.location and + self.description == other.description and + self.schema.__class__ == other.schema.__class__ and + self.example == other.example + ) + + +class Error(Mapping): def __init__(self, title=None, content=None): data = {} if (content is None) else content @@ -277,12 +320,18 @@ def __init__(self, title=None, content=None): raise TypeError('content keys must be strings.') self._title = '' if (title is None) else title - self._data = {key: _to_immutable(value) for key, value in data.items()} + self._data = {key: _to_objects(value) for key, value in data.items()} def __iter__(self): items = sorted(self._data.items(), key=_key_sorting) return iter([key for key, value in items]) + def __len__(self): + return len(self._data) + + def __getitem__(self, key): + return self._data[key] + def __repr__(self): return _repr(self) @@ -303,8 +352,15 @@ def title(self): def get_messages(self): messages = [] for value in self.values(): - if isinstance(value, Array): + if isinstance(value, list): messages += [ item for item in value if isinstance(item, string_types) ] + elif isinstance(value, string_types): + messages += [value] return messages + + +class Array(object): + def __init__(self): + assert False, 'Array is deprecated' diff --git a/coreapi/schemas/__init__.py b/coreapi/schemas/__init__.py new file mode 100644 index 0000000..16e7864 --- /dev/null +++ b/coreapi/schemas/__init__.py @@ -0,0 +1,5 @@ +from coreapi.schemas.jsonschema import JSONSchema +from coreapi.schemas.openapi import OpenAPI + + +__all__ = ['JSONSchema', 'OpenAPI'] diff --git a/coreapi/schemas/jsonschema.py b/coreapi/schemas/jsonschema.py new file mode 100644 index 0000000..d9e62c6 --- /dev/null +++ b/coreapi/schemas/jsonschema.py @@ -0,0 +1,40 @@ +from coreapi import typesys + + +JSONSchema = typesys.Object( + self_ref='JSONSchema', + properties=[ + ('$ref', typesys.String()), + ('type', typesys.String() | typesys.Array(items=typesys.String())), + ('enum', typesys.Array(unique_items=True, min_items=1)), + ('definitions', typesys.Object(additional_properties=typesys.Ref('JSONSchema'))), + + # String + ('minLength', typesys.Integer(minimum=0, default=0)), + ('maxLength', typesys.Integer(minimum=0)), + ('pattern', typesys.String(format='regex')), + ('format', typesys.String()), + + # Numeric + ('minimum', typesys.Number()), + ('maximum', typesys.Number()), + ('exclusiveMinimum', typesys.Boolean(default=False)), + ('exclusiveMaximum', typesys.Boolean(default=False)), + ('multipleOf', typesys.Number(minimum=0.0, exclusive_minimum=True)), + + # Object + ('properties', typesys.Object(additional_properties=typesys.Ref('JSONSchema'))), + ('minProperties', typesys.Integer(minimum=0, default=0)), + ('maxProperties', typesys.Integer(minimum=0)), + ('patternProperties', typesys.Object(additional_properties=typesys.Ref('JSONSchema'))), + ('additionalProperties', typesys.Ref('JSONSchema') | typesys.Boolean()), + ('required', typesys.Array(items=typesys.String(), min_items=1, unique_items=True)), + + # Array + ('items', typesys.Ref('JSONSchema') | typesys.Array(items=typesys.Ref('JSONSchema'), min_items=1)), + ('additionalItems', typesys.Ref('JSONSchema') | typesys.Boolean()), + ('minItems', typesys.Integer(minimum=0, default=9)), + ('maxItems', typesys.Integer(minimum=0)), + ('uniqueItems', typesys.Boolean()), + ] +) diff --git a/coreapi/schemas/openapi.py b/coreapi/schemas/openapi.py new file mode 100644 index 0000000..a395f51 --- /dev/null +++ b/coreapi/schemas/openapi.py @@ -0,0 +1,155 @@ +from coreapi import typesys +from coreapi.schemas import JSONSchema + + +SchemaRef = typesys.Object( + properties={'$ref': typesys.String(pattern='^#/components/schemas/')} +) + + +OpenAPI = typesys.Object( + self_ref='OpenAPI', + title='OpenAPI', + properties=[ + ('openapi', typesys.String()), + ('info', typesys.Ref('Info')), + ('servers', typesys.Array(items=typesys.Ref('Server'))), + ('paths', typesys.Ref('Paths')), + ('components', typesys.Ref('Components')), + ('security', typesys.Ref('SecurityRequirement')), + ('tags', typesys.Array(items=typesys.Ref('Tag'))), + ('externalDocs', typesys.Ref('ExternalDocumentation')) + ], + required=['openapi', 'info'], + definitions={ + 'Info': typesys.Object( + properties=[ + ('title', typesys.String()), + ('description', typesys.String(format='textarea')), + ('termsOfService', typesys.String(format='url')), + ('contact', typesys.Ref('Contact')), + ('license', typesys.Ref('License')), + ('version', typesys.String()) + ], + required=['title', 'version'] + ), + 'Contact': typesys.Object( + properties=[ + ('name', typesys.String()), + ('url', typesys.String(format='url')), + ('email', typesys.String(format='email')) + ] + ), + 'License': typesys.Object( + properties=[ + ('name', typesys.String()), + ('url', typesys.String(format='url')) + ], + required=['name'] + ), + 'Server': typesys.Object( + properties=[ + ('url', typesys.String()), + ('description', typesys.String(format='textarea')), + ('variables', typesys.Object(additional_properties=typesys.Ref('ServerVariable'))) + ], + required=['url'] + ), + 'ServerVariable': typesys.Object( + properties=[ + ('enum', typesys.Array(items=typesys.String())), + ('default', typesys.String()), + ('description', typesys.String(format='textarea')) + ], + required=['default'] + ), + 'Paths': typesys.Object( + pattern_properties=[ + ('^/', typesys.Ref('Path')) # TODO: Path | ReferenceObject + ] + ), + 'Path': typesys.Object( + properties=[ + ('summary', typesys.String()), + ('description', typesys.String(format='textarea')), + ('get', typesys.Ref('Operation')), + ('put', typesys.Ref('Operation')), + ('post', typesys.Ref('Operation')), + ('delete', typesys.Ref('Operation')), + ('options', typesys.Ref('Operation')), + ('head', typesys.Ref('Operation')), + ('patch', typesys.Ref('Operation')), + ('trace', typesys.Ref('Operation')), + ('servers', typesys.Array(items=typesys.Ref('Server'))), + ('parameters', typesys.Array(items=typesys.Ref('Parameter'))) # TODO: Parameter | ReferenceObject + ] + ), + 'Operation': typesys.Object( + properties=[ + ('tags', typesys.Array(items=typesys.String())), + ('summary', typesys.String()), + ('description', typesys.String(format='textarea')), + ('externalDocs', typesys.Ref('ExternalDocumentation')), + ('operationId', typesys.String()), + ('parameters', typesys.Array(items=typesys.Ref('Parameter'))), # TODO: Parameter | ReferenceObject + ('requestBody', typesys.Ref('RequestBody')), # TODO: RequestBody | ReferenceObject + # TODO: 'responses' + # TODO: 'callbacks' + ('deprecated', typesys.Boolean()), + ('security', typesys.Array(typesys.Ref('SecurityRequirement'))), + ('servers', typesys.Array(items=typesys.Ref('Server'))) + ] + ), + 'ExternalDocumentation': typesys.Object( + properties=[ + ('description', typesys.String(format='textarea')), + ('url', typesys.String(format='url')) + ], + required=['url'] + ), + 'Parameter': typesys.Object( + properties=[ + ('name', typesys.String()), + ('in', typesys.String(enum=['query', 'header', 'path', 'cookie'])), + ('description', typesys.String(format='textarea')), + ('required', typesys.Boolean()), + ('deprecated', typesys.Boolean()), + ('allowEmptyValue', typesys.Boolean()), + ('schema', JSONSchema | SchemaRef), + ('example', typesys.Any()) + # TODO: Other fields + ], + required=['name', 'in'] + ), + 'RequestBody': typesys.Object( + properties=[ + ('description', typesys.String()), + ('content', typesys.Object(additional_properties=typesys.Ref('MediaType'))), + ('required', typesys.Boolean()) + ] + ), + 'MediaType': typesys.Object( + properties=[ + ('schema', JSONSchema | SchemaRef), + ('example', typesys.Any()) + # TODO 'examples', 'encoding' + ] + ), + 'Components': typesys.Object( + properties=[ + ('schemas', typesys.Object(additional_properties=JSONSchema)), + ] + ), + 'Tag': typesys.Object( + properties=[ + ('name', typesys.String()), + ('description', typesys.String(format='textarea')), + ('externalDocs', typesys.Ref('ExternalDocumentation')) + ], + required=['name'] + ), + 'SecurityRequirement': typesys.Object( + additional_properties=typesys.Array(items=typesys.String()) + ) + } +) diff --git a/coreapi/transports/base.py b/coreapi/transports/base.py index b19f0cc..baef9f8 100644 --- a/coreapi/transports/base.py +++ b/coreapi/transports/base.py @@ -1,8 +1,7 @@ # coding: utf-8 -import itypes -class BaseTransport(itypes.Object): +class BaseTransport(object): schemes = None def transition(self, link, decoders, params=None, link_ancestors=None, force_codec=False): diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index 7338e61..5d6b830 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -2,16 +2,14 @@ from __future__ import unicode_literals from collections import OrderedDict from coreapi import exceptions, utils -from coreapi.compat import cookiejar, urlparse -from coreapi.document import Document, Object, Link, Array, Error +from coreapi.compat import cookiejar +from coreapi.document import Document, Object, Error from coreapi.transports.base import BaseTransport from coreapi.utils import guess_filename, is_file, File import collections import requests -import itypes import mimetypes import uritemplate -import warnings Params = collections.namedtuple('Params', ['path', 'query', 'data', 'files']) @@ -41,49 +39,10 @@ class BlockAll(cookiejar.CookiePolicy): rfc2965 = hide_cookie2 = False -class DomainCredentials(requests.auth.AuthBase): - """ - Custom auth class to support deprecated 'credentials' argument. - """ - allow_cookies = False - credentials = None - - def __init__(self, credentials=None): - self.credentials = credentials - - def __call__(self, request): - if not self.credentials: - return request - - # Include any authorization credentials relevant to this domain. - url_components = urlparse.urlparse(request.url) - host = url_components.hostname - if host in self.credentials: - request.headers['Authorization'] = self.credentials[host] - return request - - -class CallbackAdapter(requests.adapters.HTTPAdapter): - """ - Custom requests HTTP adapter, to support deprecated callback arguments. - """ - def __init__(self, request_callback=None, response_callback=None): - self.request_callback = request_callback - self.response_callback = response_callback - - def send(self, request, **kwargs): - if self.request_callback is not None: - self.request_callback(request) - response = super(CallbackAdapter, self).send(request, **kwargs) - if self.response_callback is not None: - self.response_callback(response) - return response - - -def _get_method(action): - if not action: +def _get_method(method): + if not method: return 'GET' - return action.upper() + return method.upper() def _get_encoding(encoding): @@ -200,35 +159,35 @@ def _get_upload_headers(file_obj): } -def _build_http_request(session, url, method, headers=None, encoding=None, params=empty_params): +def _get_request_options(headers, encoding, params): """ - Make an HTTP request and return an HTTP response. + Returns a dictionary of keyword parameters to include when making + the outgoing request. """ - opts = { + options = { "headers": headers or {} } if params.query: - opts['params'] = params.query + options['params'] = params.query if params.data or params.files: if encoding == 'application/json': - opts['json'] = params.data + options['json'] = params.data elif encoding == 'multipart/form-data': - opts['data'] = params.data - opts['files'] = ForceMultiPartDict(params.files) + options['data'] = params.data + options['files'] = ForceMultiPartDict(params.files) elif encoding == 'application/x-www-form-urlencoded': - opts['data'] = params.data + options['data'] = params.data elif encoding == 'application/octet-stream': if isinstance(params.data, File): - opts['data'] = params.data.content + options['data'] = params.data.content else: - opts['data'] = params.data + options['data'] = params.data upload_headers = _get_upload_headers(params.data) - opts['headers'].update(upload_headers) + headers.update(upload_headers) - request = requests.Request(method, url, **opts) - return session.prepare_request(request) + return options def _coerce_to_error_content(node): @@ -243,13 +202,6 @@ def _coerce_to_error_content(node): (key, _coerce_to_error_content(value)) for key, value in node.data.items() ]) - elif isinstance(node, Array): - # Strip Links from Arrays. - return [ - _coerce_to_error_content(item) - for item in node - if not isinstance(item, Link) - ] return node @@ -291,7 +243,7 @@ def _decode_result(response, decoders, force_codec=False): if 'content-disposition' in response.headers: options['content_disposition'] = response.headers['content-disposition'] - result = codec.load(response.content, **options) + result = codec.decode(response.content, **options) else: # No content returned in response. result = None @@ -305,36 +257,10 @@ def _decode_result(response, decoders, force_codec=False): return result -def _handle_inplace_replacements(document, link, link_ancestors): - """ - Given a new document, and the link/ancestors it was created, - determine if we should: - - * Make an inline replacement and then return the modified document tree. - * Return the new document as-is. - """ - if not link.transform: - if link.action.lower() in ('put', 'patch', 'delete'): - transform = 'inplace' - else: - transform = 'new' - else: - transform = link.transform - - if transform == 'inplace': - root = link_ancestors[0].document - keys_to_link_parent = link_ancestors[-1].keys - if document is None: - return root.delete_in(keys_to_link_parent) - return root.set_in(keys_to_link_parent, document) - - return document - - class HTTPTransport(BaseTransport): schemes = ['http', 'https'] - def __init__(self, credentials=None, headers=None, auth=None, session=None, request_callback=None, response_callback=None): + def __init__(self, headers=None, auth=None, session=None): if headers: headers = {key.lower(): value for key, value in headers.items()} if session is None: @@ -344,45 +270,26 @@ def __init__(self, credentials=None, headers=None, auth=None, session=None, requ if not getattr(session.auth, 'allow_cookies', False): session.cookies.set_policy(BlockAll()) - if credentials is not None: - warnings.warn( - "The 'credentials' argument is now deprecated in favor of 'auth'.", - DeprecationWarning - ) - auth = DomainCredentials(credentials) - if request_callback is not None or response_callback is not None: - warnings.warn( - "The 'request_callback' and 'response_callback' arguments are now deprecated. " - "Use a custom 'session' instance instead.", - DeprecationWarning - ) - session.mount('https://', CallbackAdapter(request_callback, response_callback)) - session.mount('http://', CallbackAdapter(request_callback, response_callback)) - - self._headers = itypes.Dict(headers or {}) + self._headers = {} if (headers is None) else dict(headers) self._session = session @property def headers(self): return self._headers - def transition(self, link, decoders, params=None, link_ancestors=None, force_codec=False): + def transition(self, link, decoders, params=None, force_codec=False): session = self._session - method = _get_method(link.action) + method = _get_method(link.method) encoding = _get_encoding(link.encoding) params = _get_params(method, encoding, link.fields, params) url = _get_url(link.url, params.path) headers = _get_headers(url, decoders) headers.update(self.headers) - request = _build_http_request(session, url, method, headers, encoding, params) - settings = session.merge_environment_settings(request.url, None, None, None, None) - response = session.send(request, **settings) + options = _get_request_options(headers, encoding, params) + response = session.request(method, url, **options) result = _decode_result(response, decoders, force_codec) - if isinstance(result, Document) and link_ancestors: - result = _handle_inplace_replacements(result, link, link_ancestors) - if isinstance(result, Error): raise exceptions.ErrorMessage(result) diff --git a/coreapi/typesys.py b/coreapi/typesys.py new file mode 100644 index 0000000..b1468aa --- /dev/null +++ b/coreapi/typesys.py @@ -0,0 +1,520 @@ +from coreapi.compat import dict_type, isfinite, string_types +import re + + +NO_DEFAULT = object() +TRUE = object() +FALSE = object() + + +def hashable(element): + # Coerce a primitive into a uniquely hashable type, for uniqueness checks. + + if element is True: + return TRUE # Need to make `True` distinct from `1`. + elif element is False: + return FALSE # Need to make `False` distinct from `0`. + elif isinstance(element, list): + return ('list', tuple([ + hashable(item) for item in element + ])) + elif isinstance(element, dict): + return ('dict', tuple([ + (hashable(key), hashable(value)) for key, value in element.items() + ])) + + assert (element is None) or isinstance(element, (int, float, string_types)) + return element + + +class ValidationError(Exception): + def __init__(self, detail): + assert isinstance(detail, (string_types, dict)) + self.detail = detail + super(ValidationError, self).__init__(detail) + + +class Validator(object): + errors = {} + + def __init__(self, title='', description='', default=NO_DEFAULT, definitions=None, self_ref=None): + definitions = {} if (definitions is None) else dict_type(definitions) + + assert isinstance(title, string_types) + assert isinstance(description, string_types) + assert isinstance(definitions, dict) + assert all(isinstance(k, string_types) for k in definitions.keys()) + assert all(isinstance(v, Validator) for v in definitions.values()) + + self.title = title + self.description = description + self.definitions = definitions + self.self_ref = self_ref + + if default is not NO_DEFAULT: + self.default = default + + def validate(self, value, definitions=None): + raise NotImplementedError() + + def is_valid(self, value): + try: + self.validate(value) + except ValidationError: + return False + return True + + def has_default(self): + return hasattr(self, 'default') + + def error(self, code): + message = self.error_message(code) + raise ValidationError(message) + + def error_message(self, code): + return self.errors[code].format(**self.__dict__) + + def get_definitions(self, definitions=None): + if self.definitions is None and self.self_ref is None: + return definitions + + if definitions is None: + definitions = {} + if self.definitions is not None: + definitions.update(self.definitions) + if self.self_ref is not None: + definitions[self.self_ref] = self + return definitions + + def __or__(self, other): + if isinstance(self, Union): + items = self.items + else: + items = [self] + + if isinstance(other, Union): + items += other.items + else: + items += [other] + + return Union(items) + + +class String(Validator): + errors = { + 'type': 'Must be a string.', + 'null': 'May not be null.', + 'blank': 'Must not be blank.', + 'max_length': 'Must have no more than {max_length} characters.', + 'min_length': 'Must have at least {min_length} characters.', + 'pattern': 'Must match the pattern /{pattern}/.', + 'format': 'Must be a valid {format}.', + 'enum': 'Must be a valid choice.', + 'exact': 'Must be {exact}.' + } + + def __init__(self, max_length=None, min_length=None, pattern=None, enum=None, format=None, allow_null=False, **kwargs): + super(String, self).__init__(**kwargs) + + assert max_length is None or isinstance(max_length, int) + assert min_length is None or isinstance(min_length, int) + assert pattern is None or isinstance(pattern, string_types) + assert enum is None or isinstance(enum, list) and all([isinstance(i, string_types) for i in enum]) + assert format is None or isinstance(format, string_types) + + self.max_length = max_length + self.min_length = min_length + self.pattern = pattern + self.enum = enum + self.format = format + self.allow_null = allow_null + + def validate(self, value, definitions=None): + if value is None and self.allow_null: + return None + elif value is None: + self.error('null') + elif not isinstance(value, string_types): + self.error('type') + + if self.enum is not None: + if value not in self.enum: + if len(self.enum) == 1: + self.error('exact') + self.error('enum') + + if self.min_length is not None: + if len(value) < self.min_length: + if self.min_length == 1: + self.error('blank') + else: + self.error('min_length') + + if self.max_length is not None: + if len(value) > self.max_length: + self.error('max_length') + + if self.pattern is not None: + if not re.search(self.pattern, value): + self.error('pattern') + + return value + + +class NumericType(Validator): + """ + Base class for both `Number` and `Integer`. + """ + numeric_type = None # type: type + errors = { + 'type': 'Must be a number.', + 'null': 'May not be null.', + 'integer': 'Must be an integer.', + 'finite': 'Must be finite.', + 'minimum': 'Must be greater than or equal to {minimum}.', + 'exclusive_minimum': 'Must be greater than {minimum}.', + 'maximum': 'Must be less than or equal to {maximum}.', + 'exclusive_maximum': 'Must be less than {maximum}.', + 'multiple_of': 'Must be a multiple of {multiple_of}.', + } + + def __init__(self, minimum=None, maximum=None, exclusive_minimum=False, exclusive_maximum=False, multiple_of=None, enum=None, format=None, allow_null=False, **kwargs): + super(NumericType, self).__init__(**kwargs) + + assert minimum is None or isinstance(minimum, (int, float)) + assert maximum is None or isinstance(maximum, (int, float)) + assert isinstance(exclusive_minimum, bool) + assert isinstance(exclusive_maximum, bool) + assert multiple_of is None or isinstance(multiple_of, (int, float)) + assert enum is None or isinstance(enum, list) and all([isinstance(i, string_types) for i in enum]) + assert format is None or isinstance(format, string_types) + + self.minimum = minimum + self.maximum = maximum + self.exclusive_minimum = exclusive_minimum + self.exclusive_maximum = exclusive_maximum + self.multiple_of = multiple_of + self.enum = enum + self.format = format + self.allow_null = allow_null + + def validate(self, value, definitions=None): + if value is None and self.allow_null: + return None + elif value is None: + self.error('null') + elif not isinstance(value, (int, float)) or isinstance(value, bool): + self.error('type') + elif isinstance(value, float) and not isfinite(value): + self.error('finite') + elif self.numeric_type is int and isinstance(value, float) and not value.is_integer(): + self.error('integer') + + value = self.numeric_type(value) + + if self.enum is not None: + if value not in self.enum: + if len(self.enum) == 1: + self.error('exact') + self.error('enum') + + if self.minimum is not None: + if self.exclusive_minimum: + if value <= self.minimum: + self.error('exclusive_minimum') + else: + if value < self.minimum: + self.error('minimum') + + if self.maximum is not None: + if self.exclusive_maximum: + if value >= self.maximum: + self.error('exclusive_maximum') + else: + if value > self.maximum: + self.error('maximum') + + if self.multiple_of is not None: + if isinstance(self.multiple_of, float): + if not (value * (1 / self.multiple_of)).is_integer(): + self.error('multiple_of') + else: + if value % self.multiple_of: + self.error('multiple_of') + + return value + + +class Number(NumericType): + numeric_type = float + + +class Integer(NumericType): + numeric_type = int + + +class Boolean(Validator): + errors = { + 'type': 'Must be a valid boolean.', + 'null': 'May not be null.', + } + + def __init__(self, allow_null=False, **kwargs): + super(Boolean, self).__init__(**kwargs) + + self.allow_null = allow_null + + def validate(self, value, definitions=None): + if value is None and self.allow_null: + return None + elif value is None: + self.error('null') + elif not isinstance(value, bool): + self.error('type') + return value + + +class Object(Validator): + errors = { + 'type': 'Must be an object.', + 'null': 'May not be null.', + 'invalid_key': 'Object keys must be strings.', + 'required': 'This field is required.', + 'no_additional_properties': 'Unknown properties are not allowed.', + 'empty': 'Must not be empty.', + 'max_properties': 'Must have no more than {max_properties} properties.', + 'min_properties': 'Must have at least {min_properties} properties.', + } + + def __init__(self, properties=None, pattern_properties=None, additional_properties=True, min_properties=None, max_properties=None, required=None, allow_null=False, **kwargs): + super(Object, self).__init__(**kwargs) + + properties = {} if (properties is None) else dict_type(properties) + pattern_properties = {} if (pattern_properties is None) else dict_type(pattern_properties) + required = list(required) if isinstance(required, (list, tuple)) else required + required = [] if (required is None) else required + + assert all(isinstance(k, string_types) for k in properties.keys()) + assert all(isinstance(v, Validator) for v in properties.values()) + assert all(isinstance(k, string_types) for k in pattern_properties.keys()) + assert all(isinstance(v, Validator) for v in pattern_properties.values()) + assert additional_properties is None or isinstance(additional_properties, (bool, Validator)) + assert min_properties is None or isinstance(min_properties, int) + assert max_properties is None or isinstance(max_properties, int) + assert all(isinstance(i, string_types) for i in required) + + self.properties = properties + self.pattern_properties = pattern_properties + self.additional_properties = additional_properties + self.min_properties = min_properties + self.max_properties = max_properties + self.required = required + self.allow_null = allow_null + + def validate(self, value, definitions=None): + if value is None and self.allow_null: + return None + elif value is None: + self.error('null') + elif not isinstance(value, dict): + self.error('type') + + definitions = self.get_definitions(definitions) + validated = dict_type() + + # Ensure all property keys are strings. + errors = {} + if any(not isinstance(key, string_types) for key in value.keys()): + self.error('invalid_key') + + # Min/Max properties + if self.min_properties is not None: + if len(value) < self.min_properties: + if self.min_properties == 1: + self.error('empty') + else: + self.error('min_properties') + if self.max_properties is not None: + if len(value) > self.max_properties: + self.error('max_properties') + + # Required properties + for key in self.required: + if key not in value: + errors[key] = self.error_message('required') + + # Properties + for key, child_schema in self.properties.items(): + if key not in value: + continue + item = value[key] + try: + validated[key] = child_schema.validate(item, definitions=definitions) + except ValidationError as exc: + errors[key] = exc.detail + + # Pattern properties + if self.pattern_properties: + for key in list(value.keys()): + for pattern, child_schema in self.pattern_properties.items(): + if re.search(pattern, key): + item = value[key] + try: + validated[key] = child_schema.validate(item, definitions=definitions) + except ValidationError as exc: + errors[key] = exc.detail + + # Additional properties + remaining = [ + key for key in value.keys() + if key not in set(validated.keys()) + ] + + if self.additional_properties is True: + for key in remaining: + validated[key] = value[key] + elif self.additional_properties is False: + for key in remaining: + errors[key] = self.error_message('no_additional_properties') + elif self.additional_properties is not None: + child_schema = self.additional_properties + for key in remaining: + item = value[key] + try: + validated[key] = child_schema.validate(item, definitions=definitions) + except ValidationError as exc: + errors[key] = exc.detail + + if errors: + raise ValidationError(errors) + + return validated + + +class Array(Validator): + errors = { + 'type': 'Must be an array.', + 'null': 'May not be null.', + 'empty': 'Must not be empty.', + 'exact_items': 'Must have {min_items} items.', + 'min_items': 'Must have at least {min_items} items.', + 'max_items': 'Must have no more than {max_items} items.', + 'additional_items': 'May not contain additional items.', + 'unique_items': 'This item is not unique.', + } + + def __init__(self, items=None, additional_items=None, min_items=None, max_items=None, unique_items=False, allow_null=False, **kwargs): + super(Array, self).__init__(**kwargs) + + items = list(items) if isinstance(items, (list, tuple)) else items + + assert items is None or isinstance(items, Validator) or isinstance(items, list) and all(isinstance(i, Validator) for i in items) + assert additional_items is None or isinstance(additional_items, (bool, Validator)) + assert min_items is None or isinstance(min_items, int) + assert max_items is None or isinstance(max_items, int) + assert isinstance(unique_items, bool) + + self.items = items + self.additional_items = additional_items + self.min_items = min_items + self.max_items = max_items + self.unique_items = unique_items + self.allow_null = allow_null + + def validate(self, value, definitions=None): + if value is None and self.allow_null: + return None + elif value is None: + self.error('null') + elif not isinstance(value, list): + self.error('type') + + definitions = self.get_definitions(definitions) + validated = [] + + if self.min_items is not None and self.min_items == self.max_items and len(value) != self.min_items: + self.error('exact_items') + if self.min_items is not None and len(value) < self.min_items: + if self.min_items == 1: + self.error('empty') + self.error('min_items') + elif self.max_items is not None and len(value) > self.max_items: + self.error('max_items') + elif isinstance(self.items, list) and (self.additional_items is False) and len(value) > len(self.items): + self.error('additional_items') + + # Ensure all items are of the right type. + errors = {} + if self.unique_items: + seen_items = set() + + for pos, item in enumerate(value): + try: + if isinstance(self.items, list): + if pos < len(self.items): + item = self.items[pos].validate(item, definitions=definitions) + elif isinstance(self.additional_items, Validator): + item = self.additional_items.validate(item, definitions=definitions) + elif self.items is not None: + item = self.items.validate(item, definitions=definitions) + + if self.unique_items: + hashable_item = hashable(item) + if hashable_item in seen_items: + self.error('unique_items') + else: + seen_items.add(hashable_item) + + validated.append(item) + except ValidationError as exc: + errors[pos] = exc.detail + + if errors: + raise ValidationError(errors) + + return validated + + +class Any(Validator): + def validate(self, value, definitions=None): + # TODO: Validate value matches primitive types + return value + + +class Union(Validator): + errors = { + 'null': 'Must not be null.', + 'union': 'Must match one of the union types.' + } + + def __init__(self, items, allow_null=False): + assert isinstance(items, list) and all(isinstance(i, Validator) for i in items) + self.items = list(items) + self.allow_null = allow_null + + def validate(self, value, definitions=None): + if value is None and self.allow_null: + return None + elif value is None: + self.error('null') + + for item in self.items: + try: + return item.validate(value, definitions=definitions) + except ValidationError: + pass + self.error('union') + + +class Ref(Validator): + def __init__(self, ref, **kwargs): + super(Ref, self).__init__(**kwargs) + assert isinstance(ref, string_types) + self.ref = ref + + def validate(self, value, definitions=None): + assert definitions is not None, 'Ref.validate() requires definitions' + assert self.ref in definitions, 'Ref "%s" not in definitions' % self.ref + + child_schema = definitions[self.ref] + return child_schema.validate(value, definitions=definitions) diff --git a/coreapi/utils.py b/coreapi/utils.py index fb7ade4..f39ed8a 100644 --- a/coreapi/utils.py +++ b/coreapi/utils.py @@ -54,95 +54,6 @@ def guess_filename(obj): return None -def guess_extension(content_type): - """ - Python's `mimetypes.guess_extension` is no use because it simply returns - the first of an unordered set. We use the same set of media types here, - but take a reasonable preference on what extension to map to. - """ - return { - 'application/javascript': '.js', - 'application/msword': '.doc', - 'application/octet-stream': '.bin', - 'application/oda': '.oda', - 'application/pdf': '.pdf', - 'application/pkcs7-mime': '.p7c', - 'application/postscript': '.ps', - 'application/vnd.apple.mpegurl': '.m3u', - 'application/vnd.ms-excel': '.xls', - 'application/vnd.ms-powerpoint': '.ppt', - 'application/x-bcpio': '.bcpio', - 'application/x-cpio': '.cpio', - 'application/x-csh': '.csh', - 'application/x-dvi': '.dvi', - 'application/x-gtar': '.gtar', - 'application/x-hdf': '.hdf', - 'application/x-latex': '.latex', - 'application/x-mif': '.mif', - 'application/x-netcdf': '.nc', - 'application/x-pkcs12': '.p12', - 'application/x-pn-realaudio': '.ram', - 'application/x-python-code': '.pyc', - 'application/x-sh': '.sh', - 'application/x-shar': '.shar', - 'application/x-shockwave-flash': '.swf', - 'application/x-sv4cpio': '.sv4cpio', - 'application/x-sv4crc': '.sv4crc', - 'application/x-tar': '.tar', - 'application/x-tcl': '.tcl', - 'application/x-tex': '.tex', - 'application/x-texinfo': '.texinfo', - 'application/x-troff': '.tr', - 'application/x-troff-man': '.man', - 'application/x-troff-me': '.me', - 'application/x-troff-ms': '.ms', - 'application/x-ustar': '.ustar', - 'application/x-wais-source': '.src', - 'application/xml': '.xml', - 'application/zip': '.zip', - 'audio/basic': '.au', - 'audio/mpeg': '.mp3', - 'audio/x-aiff': '.aif', - 'audio/x-pn-realaudio': '.ra', - 'audio/x-wav': '.wav', - 'image/gif': '.gif', - 'image/ief': '.ief', - 'image/jpeg': '.jpe', - 'image/png': '.png', - 'image/svg+xml': '.svg', - 'image/tiff': '.tiff', - 'image/vnd.microsoft.icon': '.ico', - 'image/x-cmu-raster': '.ras', - 'image/x-ms-bmp': '.bmp', - 'image/x-portable-anymap': '.pnm', - 'image/x-portable-bitmap': '.pbm', - 'image/x-portable-graymap': '.pgm', - 'image/x-portable-pixmap': '.ppm', - 'image/x-rgb': '.rgb', - 'image/x-xbitmap': '.xbm', - 'image/x-xpixmap': '.xpm', - 'image/x-xwindowdump': '.xwd', - 'message/rfc822': '.eml', - 'text/css': '.css', - 'text/csv': '.csv', - 'text/html': '.html', - 'text/plain': '.txt', - 'text/richtext': '.rtx', - 'text/tab-separated-values': '.tsv', - 'text/x-python': '.py', - 'text/x-setext': '.etx', - 'text/x-sgml': '.sgml', - 'text/x-vcard': '.vcf', - 'text/xml': '.xml', - 'video/mp4': '.mp4', - 'video/mpeg': '.mpeg', - 'video/quicktime': '.mov', - 'video/webm': '.webm', - 'video/x-msvideo': '.avi', - 'video/x-sgi-movie': '.movie' - }.get(content_type, '') - - if _TemporaryFileWrapper: # Ideally we subclass this so that we can present a custom representation. class DownloadedFile(_TemporaryFileWrapper): diff --git a/requirements.txt b/requirements.txt index 71ddd11..401ec84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,4 @@ # Package requirements -coreschema -itypes requests uritemplate diff --git a/setup.py b/setup.py index 7dabead..575653a 100755 --- a/setup.py +++ b/setup.py @@ -63,14 +63,13 @@ def get_package_data(package): packages=get_packages('coreapi'), package_data=get_package_data('coreapi'), install_requires=[ - 'coreschema', 'requests', - 'itypes', 'uritemplate' ], entry_points={ 'coreapi.codecs': [ 'corejson=coreapi.codecs:CoreJSONCodec', + 'openapi=coreapi.codecs:OpenAPICodec', 'json=coreapi.codecs:JSONCodec', 'text=coreapi.codecs:TextCodec', 'download=coreapi.codecs:DownloadCodec', diff --git a/tests/codecs/test_openapi.py b/tests/codecs/test_openapi.py new file mode 100644 index 0000000..a51b05a --- /dev/null +++ b/tests/codecs/test_openapi.py @@ -0,0 +1,347 @@ +from coreapi import typesys +from coreapi.codecs import OpenAPICodec +from coreapi.compat import dict_type +from coreapi.document import Document, Link, Field +import pytest + + +@pytest.fixture +def openapi_codec(): + return OpenAPICodec() + + +@pytest.fixture +def petstore_schema(): + return b'''{ + "openapi": "3.0.0", + "info": { + "title": "Swagger Petstore", + "version": "1.0.0", + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1" + } + ], + "paths": { + "/pets": { + "get": { + "tags": [ + "pets" + ], + "summary": "List all pets", + "operationId": "listPets", + "parameters": [ + { + "in": "query", + "description": "How many items to return at one time (max 100)", + "name": "limit", + "schema": { + "format": "int32", + "type": "integer" + }, + "required": false + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pets" + } + } + }, + "description": "An paged array of pets", + "headers": { + "x-next": { + "description": "A link to the next page of responses", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "unexpected error" + } + } + }, + "post": { + "tags": [ + "pets" + ], + "summary": "Create a pet", + "operationId": "createPets", + "responses": { + "201": { + "description": "Null response" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "unexpected error" + } + } + } + }, + "/pets/{petId}": { + "get": { + "tags": [ + "pets" + ], + "summary": "Info for a specific pet", + "operationId": "showPetById", + "parameters": [ + { + "in": "path", + "description": "The id of the pet to retrieve", + "name": "petId", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pets" + } + } + }, + "description": "Expected response to a valid request" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "unexpected error" + } + } + } + } + }, + "components": { + "schemas": { + "Error": { + "properties": { + "code": { + "format": "int32", + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ] + }, + "Pets": { + "items": { + "$ref": "#/components/schemas/Pet" + }, + "type": "array" + }, + "Pet": { + "properties": { + "tag": { + "type": "string" + }, + "id": { + "format": "int64", + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + } + } +}''' + + +@pytest.fixture +def minimal_petstore_schema(): + return b'''{ + "openapi": "3.0.0", + "info": { + "title": "Swagger Petstore", + "description": "", + "version": "" + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1" + } + ], + "paths": { + "/pets": { + "get": { + "tags": [ + "pets" + ], + "summary": "List all pets", + "operationId": "listPets", + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "schema": { + "type": "integer", + "format": "int32" + } + } + ] + }, + "post": { + "tags": [ + "pets" + ], + "summary": "Create a pet", + "operationId": "createPets" + } + }, + "/pets/{petId}": { + "get": { + "tags": [ + "pets" + ], + "summary": "Info for a specific pet", + "operationId": "showPetById", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "The id of the pet to retrieve", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + } + } +}''' + + +def test_decode_openapi(openapi_codec, petstore_schema): + doc = openapi_codec.decode(petstore_schema) + expected = Document( + title='Swagger Petstore', + url='http://petstore.swagger.io/v1', + content={ + 'pets': dict_type([ + ('listPets', Link( + action='get', + url='http://petstore.swagger.io/pets', + title='List all pets', + fields=[ + Field( + name='limit', + location='query', + description='How many items to return at one time (max 100)', + required=False, + schema=typesys.Integer(format='int32') + ) + ] + )), + ('createPets', Link( + action='post', + url='http://petstore.swagger.io/pets', + title='Create a pet' + )), + ('showPetById', Link( + action='get', + url='http://petstore.swagger.io/pets/{petId}', + title='Info for a specific pet', + fields=[ + Field( + name='petId', + location='path', + description='The id of the pet to retrieve', + required=True, + schema=typesys.String() + ) + ] + )) + ]) + } + ) + assert doc == expected + + +def test_encode_openapi(openapi_codec, minimal_petstore_schema): + doc = Document( + title='Swagger Petstore', + url='http://petstore.swagger.io/v1', + content={ + 'pets': { + 'listPets': Link( + action='get', + url='http://petstore.swagger.io/pets', + title='List all pets', + fields=[ + Field( + name='limit', + location='query', + description='How many items to return at one time (max 100)', + required=False, + schema=typesys.Integer(format='int32') + ) + ] + ), + 'createPets': Link( + action='post', + url='http://petstore.swagger.io/pets', + title='Create a pet' + ), + 'showPetById': Link( + action='get', + url='http://petstore.swagger.io/pets/{petId}', + title='Info for a specific pet', + fields=[ + Field( + name='petId', + location='path', + description='The id of the pet to retrieve', + required=True, + schema=typesys.String() + ) + ] + ) + } + } + ) + schema = openapi_codec.encode(doc) + assert schema == minimal_petstore_schema diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 32458cd..a0d1f44 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -1,10 +1,10 @@ # coding: utf-8 +from coreapi import typesys from coreapi.codecs import CoreJSONCodec from coreapi.codecs.corejson import _document_to_primitive, _primitive_to_document from coreapi.document import Document, Link, Error, Field from coreapi.exceptions import ParseError, NoCodecAvailable from coreapi.utils import negotiate_decoder, negotiate_encoder -from coreschema import Enum, String import pytest @@ -26,8 +26,8 @@ def doc(): url='http://example.org/', fields=[ Field(name='noschema'), - Field(name='string_example', schema=String()), - Field(name='enum_example', schema=Enum(['a', 'b', 'c'])), + Field(name='string_example', schema=typesys.String()), + Field(name='enum_example', schema=typesys.String(enum=['a', 'b', 'c'])), ]), 'nested': {'child': Link(url='http://example.org/123')}, '_type': 'needs escaping' @@ -60,7 +60,7 @@ def test_document_to_primitive(doc): { 'name': 'enum_example', 'schema': { - '_type': 'enum', + '_type': 'string', 'title': '', 'description': '', 'enum': ['a', 'b', 'c'], @@ -98,7 +98,7 @@ def test_primitive_to_document(doc): { 'name': 'enum_example', 'schema': { - '_type': 'enum', + '_type': 'string', 'title': '', 'description': '', 'enum': ['a', 'b', 'c'], @@ -194,7 +194,6 @@ def test_link_encodings(json_codec): doc = Document(content={ 'link': Link( action='post', - transform='inplace', fields=['optional', Field('required', required=True, location='path')] ) }) @@ -204,7 +203,6 @@ def test_link_encodings(json_codec): "link": { "_type": "link", "action": "post", - "transform": "inplace", "fields": [ { "name": "optional" diff --git a/tests/test_document.py b/tests/test_document.py index 6b06de7..aced4d6 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -1,6 +1,6 @@ # coding: utf-8 from coreapi import Client -from coreapi import Array, Document, Object, Link, Error, Field +from coreapi import Document, Object, Link, Error, Field from coreapi.exceptions import LinkLookupError import pytest @@ -13,11 +13,9 @@ def doc(): content={ 'integer': 123, 'dict': {'key': 'value'}, - 'list': [1, 2, 3], 'link': Link( url='/', - action='post', - transform='inplace', + method='post', fields=['optional', Field('required', required=True, location='path')] ), 'nested': {'child': Link(url='/123')} @@ -29,16 +27,11 @@ def obj(): return Object({'key': 'value', 'nested': {'abc': 123}}) -@pytest.fixture -def array(): - return Array([{'a': 1}, {'b': 2}, {'c': 3}]) - - @pytest.fixture def link(): return Link( url='/', - action='post', + method='post', fields=[Field('required', required=True), 'optional'] ) @@ -69,11 +62,6 @@ def test_document_does_not_support_key_assignment(doc): doc['integer'] = 456 -def test_document_does_not_support_property_assignment(doc): - with pytest.raises(TypeError): - doc.integer = 456 - - def test_document_does_not_support_key_deletion(doc): with pytest.raises(TypeError): del doc['integer'] @@ -86,135 +74,17 @@ def test_object_does_not_support_key_assignment(obj): obj['key'] = 456 -def test_object_does_not_support_property_assignment(obj): - with pytest.raises(TypeError): - obj.integer = 456 - - def test_object_does_not_support_key_deletion(obj): with pytest.raises(TypeError): del obj['key'] -# Arrays are immutable. - -def test_array_does_not_support_item_assignment(array): - with pytest.raises(TypeError): - array[1] = 456 - - -def test_array_does_not_support_property_assignment(array): - with pytest.raises(TypeError): - array.integer = 456 - - -def test_array_does_not_support_item_deletion(array): - with pytest.raises(TypeError): - del array[1] - - -# Links are immutable. - -def test_link_does_not_support_property_assignment(): - link = Link() - with pytest.raises(TypeError): - link.integer = 456 - - -# Errors are immutable. - -def test_error_does_not_support_property_assignment(): - error = Error(content={'messages': ['failed']}) - with pytest.raises(TypeError): - error.integer = 456 - - # Children in documents are immutable primitives. def test_document_dictionaries_coerced_to_objects(doc): assert isinstance(doc['dict'], Object) -def test_document_lists_coerced_to_arrays(doc): - assert isinstance(doc['list'], Array) - - -# The `delete` and `set` methods return new instances. - -def test_document_delete(doc): - new = doc.delete('integer') - assert doc is not new - assert set(new.keys()) == set(doc.keys()) - set(['integer']) - for key in new.keys(): - assert doc[key] is new[key] - - -def test_document_set(doc): - new = doc.set('integer', 456) - assert doc is not new - assert set(new.keys()) == set(doc.keys()) - for key in set(new.keys()) - set(['integer']): - assert doc[key] is new[key] - - -def test_object_delete(obj): - new = obj.delete('key') - assert obj is not new - assert set(new.keys()) == set(obj.keys()) - set(['key']) - for key in new.keys(): - assert obj[key] is new[key] - - -def test_object_set(obj): - new = obj.set('key', 456) - assert obj is not new - assert set(new.keys()) == set(obj.keys()) - for key in set(new.keys()) - set(['key']): - assert obj[key] is new[key] - - -def test_array_delete(array): - new = array.delete(1) - assert array is not new - assert len(new) == len(array) - 1 - assert new[0] is array[0] - assert new[1] is array[2] - - -def test_array_set(array): - new = array.set(1, 456) - assert array is not new - assert len(new) == len(array) - assert new[0] is array[0] - assert new[1] == 456 - assert new[2] is array[2] - - -# The `delete_in` and `set_in` functions return new instances. - -def test_delete_in(): - nested = Object({'key': [{'abc': 123}, {'def': 456}], 'other': 0}) - - assert nested.delete_in(['key', 0]) == {'key': [{'def': 456}], 'other': 0} - assert nested.delete_in(['key']) == {'other': 0} - assert nested.delete_in([]) is None - - -def test_set_in(): - nested = Object({'key': [{'abc': 123}, {'def': 456}], 'other': 0}) - insert = Object({'xyz': 789}) - - assert ( - nested.set_in(['key', 0], insert) == - {'key': [{'xyz': 789}, {'def': 456}], 'other': 0} - ) - assert ( - nested.set_in(['key'], insert) == - {'key': {'xyz': 789}, 'other': 0} - ) - assert nested.set_in([], insert) == {'xyz': 789} - - # Container types have a uniquely identifying representation. def test_document_repr(doc): @@ -222,9 +92,8 @@ def test_document_repr(doc): "Document(url='http://example.org', title='Example', content={" "'dict': {'key': 'value'}, " "'integer': 123, " - "'list': [1, 2, 3], " "'nested': {'child': Link(url='/123')}, " - "'link': Link(url='/', action='post', transform='inplace', " + "'link': Link(url='/', method='post', " "fields=['optional', Field('required', required=True, location='path')])" "})" ) @@ -236,13 +105,8 @@ def test_object_repr(obj): assert eval(repr(obj)) == obj -def test_array_repr(array): - assert repr(array) == "Array([{'a': 1}, {'b': 2}, {'c': 3}])" - assert eval(repr(array)) == array - - def test_link_repr(link): - assert repr(link) == "Link(url='/', action='post', fields=[Field('required', required=True), 'optional'])" + assert repr(link) == "Link(url='/', method='post', fields=[Field('required', required=True), 'optional'])" assert eval(repr(link)) == link @@ -260,11 +124,6 @@ def test_document_str(doc): key: "value" } integer: 123 - list: [ - 1, - 2, - 3 - ] nested: { child() } @@ -292,22 +151,6 @@ def test_object_str(obj): """) -def test_array_str(array): - assert str(array) == _dedent(""" - [ - { - a: 1 - }, - { - b: 2 - }, - { - c: 3 - } - ] - """) - - def test_link_str(link): assert str(link) == "link(required, [optional])" @@ -315,9 +158,7 @@ def test_link_str(link): def test_error_str(error): assert str(error) == _dedent(""" - messages: [ - "failed" - ] + messages: ["failed"] """) @@ -341,11 +182,9 @@ def test_document_equality(doc): assert doc == { 'integer': 123, 'dict': {'key': 'value'}, - 'list': [1, 2, 3], 'link': Link( url='/', - action='post', - transform='inplace', + method='post', fields=['optional', Field('required', required=True, location='path')] ), 'nested': {'child': Link(url='/123')} @@ -356,14 +195,10 @@ def test_object_equality(obj): assert obj == {'key': 'value', 'nested': {'abc': 123}} -def test_array_equality(array): - assert array == [{'a': 1}, {'b': 2}, {'c': 3}] - - # Container types support len. def test_document_len(doc): - assert len(doc) == 5 + assert len(doc) == 4 def test_object_len(obj): @@ -419,14 +254,9 @@ def test_link_url_must_be_string(): Link(url=123) -def test_link_action_must_be_string(): - with pytest.raises(TypeError): - Link(action=123) - - -def test_link_transform_must_be_string(): +def test_link_mthod_must_be_string(): with pytest.raises(TypeError): - Link(transform=123) + Link(method=123) def test_link_fields_must_be_list(): diff --git a/tests/test_integration.py b/tests/test_integration.py index 0a2e602..44b0f43 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -70,7 +70,7 @@ def mockreturn(self, request, *args, **kwargs): client = coreapi.Client() doc = coreapi.Document(url='http://example.org') - doc = client.reload(doc) + doc = client.get(doc.url) assert doc == {'example': 123} diff --git a/tests/test_transitions.py b/tests/test_transitions.py index a31ba13..28e2ad0 100644 --- a/tests/test_transitions.py +++ b/tests/test_transitions.py @@ -1,24 +1,14 @@ # coding: utf-8 from coreapi import Document, Link, Client from coreapi.transports import HTTPTransport -from coreapi.transports.http import _handle_inplace_replacements import pytest class MockTransport(HTTPTransport): schemes = ['mock'] - def transition(self, link, decoders, params=None, link_ancestors=None): - if link.action == 'get': - document = Document(title='new', content={'new': 123}) - elif link.action in ('put', 'post'): - if params is None: - params = {} - document = Document(title='new', content={'new': 123, 'foo': params.get('foo')}) - else: - document = None - - return _handle_inplace_replacements(document, link, link_ancestors) + def transition(self, link, decoders, params=None): + return {'action': link.action, 'params': params} client = Client(transports=[MockTransport()]) @@ -29,7 +19,6 @@ def doc(): return Document(title='original', content={ 'nested': Document(content={ 'follow': Link(url='mock://example.com', action='get'), - 'action': Link(url='mock://example.com', action='post', transform='inplace', fields=['foo']), 'create': Link(url='mock://example.com', action='post', fields=['foo']), 'update': Link(url='mock://example.com', action='put', fields=['foo']), 'delete': Link(url='mock://example.com', action='delete') @@ -40,44 +29,27 @@ def doc(): # Test valid transitions. def test_get(doc): - new = client.action(doc, ['nested', 'follow']) - assert new == {'new': 123} - assert new.title == 'new' - - -def test_inline_post(doc): - new = client.action(doc, ['nested', 'action'], params={'foo': 123}) - assert new == {'nested': {'new': 123, 'foo': 123}} - assert new.title == 'original' + data = client.action(doc, ['nested', 'follow']) + assert data == {'action': 'get', 'params': {}} def test_post(doc): - new = client.action(doc, ['nested', 'create'], params={'foo': 456}) - assert new == {'new': 123, 'foo': 456} - assert new.title == 'new' + data = client.action(doc, ['nested', 'create'], params={'foo': 456}) + assert data == {'action': 'post', 'params': {'foo': 456}} def test_put(doc): - new = client.action(doc, ['nested', 'update'], params={'foo': 789}) - assert new == {'nested': {'new': 123, 'foo': 789}} - assert new.title == 'original' + data = client.action(doc, ['nested', 'update'], params={'foo': 789}) + assert data == {'action': 'put', 'params': {'foo': 789}} def test_delete(doc): - new = client.action(doc, ['nested', 'delete']) - assert new == {} - assert new.title == 'original' + data = client.action(doc, ['nested', 'delete']) + assert data == {'action': 'delete', 'params': {}} # Test overrides def test_override_action(doc): - new = client.action(doc, ['nested', 'follow'], overrides={'action': 'put'}) - assert new == {'nested': {'new': 123, 'foo': None}} - assert new.title == 'original' - - -def test_override_transform(doc): - new = client.action(doc, ['nested', 'update'], params={'foo': 456}, overrides={'transform': 'new'}) - assert new == {'new': 123, 'foo': 456} - assert new.title == 'new' + data = client.action(doc, ['nested', 'follow'], overrides={'action': 'put'}) + assert data == {'action': 'put', 'params': {}} diff --git a/tests/typesys/test_array.py b/tests/typesys/test_array.py new file mode 100644 index 0000000..7da67f3 --- /dev/null +++ b/tests/typesys/test_array.py @@ -0,0 +1,94 @@ +from coreapi.typesys import Array, String, Integer, ValidationError +import pytest + + +def test_array_type(): + schema = Array() + assert schema.validate([]) == [] + assert schema.validate(['a', 1]) == ['a', 1] + with pytest.raises(ValidationError) as exc: + schema.validate(1) + assert exc.value.detail == 'Must be an array.' + + +def test_array_items(): + schema = Array(items=String()) + assert schema.validate([]) == [] + assert schema.validate(['a', 'b', 'c']) == ['a', 'b', 'c'] + with pytest.raises(ValidationError) as exc: + schema.validate(['a', 'b', 123]) + assert exc.value.detail == {2: 'Must be a string.'} + + +def test_array_items_as_list(): + schema = Array(items=[String(), Integer()]) + assert schema.validate([]) == [] + assert schema.validate(['a', 123]) == ['a', 123] + with pytest.raises(ValidationError) as exc: + schema.validate(['a', 'b']) + assert exc.value.detail == {1: 'Must be a number.'} + + +def test_array_max_items(): + schema = Array(max_items=2) + assert schema.validate([1, 2]) == [1, 2] + with pytest.raises(ValidationError) as exc: + schema.validate([1, 2, 3]) + assert exc.value.detail == 'Must have no more than 2 items.' + + +def test_array_min_items(): + schema = Array(min_items=2) + assert schema.validate([1, 2]) == [1, 2] + with pytest.raises(ValidationError) as exc: + schema.validate([1]) + assert exc.value.detail == 'Must have at least 2 items.' + + +def test_array_empty(): + schema = Array(min_items=1) + assert schema.validate([1]) == [1] + with pytest.raises(ValidationError) as exc: + schema.validate([]) + assert exc.value.detail == 'Must not be empty.' + + +def test_array_null(): + schema = Array(allow_null=True) + assert schema.validate(None) is None + + schema = Array() + with pytest.raises(ValidationError) as exc: + schema.validate(None) + assert exc.value.detail == 'May not be null.' + + +def test_array_exact_count(): + schema = Array(min_items=3, max_items=3) + assert schema.validate([1, 2, 3]) == [1, 2, 3] + with pytest.raises(ValidationError) as exc: + schema.validate([1, 2, 3, 4]) + assert exc.value.detail == 'Must have 3 items.' + + +def test_array_unique_items(): + schema = Array(unique_items=True) + assert schema.validate([1, 2, 3]) == [1, 2, 3] + with pytest.raises(ValidationError) as exc: + schema.validate([1, 2, 1]) + assert exc.value.detail == {2: 'This item is not unique.'} + + +def test_array_additional_items_disallowed(): + schema = Array(items=[String(), Integer()]) + assert schema.validate(['a', 123, True]) == ['a', 123, True] + + schema = Array(items=[String(), Integer()], additional_items=False) + with pytest.raises(ValidationError) as exc: + schema.validate(['a', 123, True]) + assert exc.value.detail == 'May not contain additional items.' + + schema = Array(items=[String(), Integer()], additional_items=Integer()) + with pytest.raises(ValidationError) as exc: + schema.validate(['a', 123, 'c']) + assert exc.value.detail == {2: 'Must be a number.'} diff --git a/tests/typesys/test_object.py b/tests/typesys/test_object.py new file mode 100644 index 0000000..b5539f1 --- /dev/null +++ b/tests/typesys/test_object.py @@ -0,0 +1,91 @@ +from coreapi.typesys import Object, Integer, String, ValidationError +import pytest + + +def test_object_type(): + schema = Object() + assert schema.validate({}) == {} + assert schema.validate({'a': 1}) == {'a': 1} + with pytest.raises(ValidationError) as exc: + schema.validate(1) + assert exc.value.detail == 'Must be an object.' + + +def test_object_keys(): + schema = Object() + with pytest.raises(ValidationError) as exc: + schema.validate({1: 1}) + assert exc.value.detail == 'Object keys must be strings.' + + +def test_object_properties(): + schema = Object(properties={'num': Integer()}) + with pytest.raises(ValidationError) as exc: + schema.validate({'num': 'abc'}) + assert exc.value.detail == {'num': 'Must be a number.'} + + +def test_object_required(): + schema = Object(required=['name']) + assert schema.validate({'name': 1}) == {'name': 1} + with pytest.raises(ValidationError) as exc: + schema.validate({}) + assert exc.value.detail == {'name': 'This field is required.'} + + +def test_object_max_properties(): + schema = Object(max_properties=2) + assert schema.validate({'a': 1, 'b': 2}) == {'a': 1, 'b': 2} + with pytest.raises(ValidationError) as exc: + schema.validate({'a': 1, 'b': 2, 'c': 3}) + assert exc.value.detail == 'Must have no more than 2 properties.' + + +def test_object_min_properties(): + schema = Object(min_properties=2) + assert schema.validate({'a': 1, 'b': 2}) == {'a': 1, 'b': 2} + with pytest.raises(ValidationError) as exc: + assert schema.validate({'a': 1}) + assert exc.value.detail == 'Must have at least 2 properties.' + + +def test_object_empty(): + schema = Object(min_properties=1) + assert schema.validate({'a': 1}) == {'a': 1} + with pytest.raises(ValidationError) as exc: + schema.validate({}) + assert exc.value.detail == 'Must not be empty.' + + +def test_object_null(): + schema = Object(allow_null=True) + assert schema.validate(None) is None + + schema = Object() + with pytest.raises(ValidationError) as exc: + schema.validate(None) + assert exc.value.detail == 'May not be null.' + + +def test_object_pattern_properties(): + schema = Object(pattern_properties={'^x-': Integer()}) + assert schema.validate({'x-foo': 123}) == {'x-foo': 123} + with pytest.raises(ValidationError) as exc: + schema.validate({'x-foo': 'abc'}) + assert exc.value.detail == {'x-foo': 'Must be a number.'} + + +def test_object_additional_properties_as_boolean(): + schema = Object(properties={'a': String()}, additional_properties=False) + assert schema.validate({'a': 'abc'}) == {'a': 'abc'} + with pytest.raises(ValidationError) as exc: + schema.validate({'b': 'abc'}) + assert exc.value.detail == {'b': 'Unknown properties are not allowed.'} + + +def test_object_additional_properties_as_schema(): + schema = Object(properties={'a': String()}, additional_properties=Integer()) + assert schema.validate({'a': 'abc'}) == {'a': 'abc'} + with pytest.raises(ValidationError) as exc: + schema.validate({'b': 'abc'}) + assert exc.value.detail == {'b': 'Must be a number.'}