From 9bc4fbbd3ce32ae2c4bd4f1b6f548589ec5059e2 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 29 Jan 2018 14:27:39 +0000 Subject: [PATCH 01/60] Drop 'transform', and remove 'inplace' transformations. --- coreapi/client.py | 27 ++++++-------------- coreapi/codecs/corejson.py | 5 +--- coreapi/codecs/python.py | 2 -- coreapi/document.py | 10 +------- coreapi/transports/http.py | 31 +---------------------- tests/test_codecs.py | 2 -- tests/test_document.py | 9 +------ tests/test_transitions.py | 52 +++++++++----------------------------- 8 files changed, 24 insertions(+), 114 deletions(-) diff --git a/coreapi/client.py b/coreapi/client.py index d02a59c..7321ebd 100644 --- a/coreapi/client.py +++ b/coreapi/client.py @@ -1,19 +1,15 @@ 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 +24,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 +31,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 +40,7 @@ def _lookup_link(document, keys): msg % (index_string, type(node).__name__) ) - return (node, link_ancestors) + return node def _validate_parameters(link, parameters): @@ -140,8 +132,8 @@ def reload(self, document, format=None, force_codec=False): 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): + action=None, encoding=None): + if (action is not None) or (encoding 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: @@ -150,8 +142,6 @@ def action(self, document, keys, params=None, validate=True, overrides=None, overrides['action'] = action if encoding is not None: overrides['encoding'] = encoding - if transform is not None: - overrides['transform'] = transform if isinstance(keys, string_types): keys = [keys] @@ -160,7 +150,7 @@ 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) @@ -169,10 +159,9 @@ def action(self, document, keys, params=None, validate=True, overrides=None, url = overrides.get('url', link.url) action = overrides.get('action', link.action) encoding = overrides.get('encoding', link.encoding) - transform = overrides.get('transform', link.transform) fields = overrides.get('fields', link.fields) - link = Link(url, action=action, encoding=encoding, transform=transform, fields=fields) + link = Link(url, action=action, 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/corejson.py b/coreapi/codecs/corejson.py index f025533..92facc4 100644 --- a/coreapi/codecs/corejson.py +++ b/coreapi/codecs/corejson.py @@ -196,8 +196,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: @@ -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, action=action, encoding=encoding, title=title, description=description, fields=fields ) diff --git a/coreapi/codecs/python.py b/coreapi/codecs/python.py index 6265a28..6feec4e 100644 --- a/coreapi/codecs/python.py +++ b/coreapi/codecs/python.py @@ -42,8 +42,6 @@ def _to_repr(node): args += ", action=%s" % repr(node.action) 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: diff --git a/coreapi/document.py b/coreapi/document.py index c6c9ceb..dd498dc 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -187,15 +187,13 @@ class Link(itypes.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, action=None, encoding=None, title=None, description=None, fields=None): if (url is not None) and (not isinstance(url, string_types)): raise TypeError("Argument 'url' must be a string.") if (action is not None) and (not isinstance(action, string_types)): raise TypeError("Argument 'action' 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)): @@ -211,7 +209,6 @@ def __init__(self, url=None, action=None, encoding=None, transform=None, title=N self._url = '' if (url is None) else url self._action = '' if (action is None) else action self._encoding = '' if (encoding is None) else encoding - self._transform = '' if (transform is None) else transform self._title = '' if (title is None) else title self._description = '' if (description is None) else description self._fields = () if (fields is None) else tuple([ @@ -231,10 +228,6 @@ def action(self): def encoding(self): return self._encoding - @property - def transform(self): - return self._transform - @property def title(self): return self._title @@ -253,7 +246,6 @@ def __eq__(self, other): self.url == other.url and self.action == other.action 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) ) diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index a548024..cf63be9 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -305,32 +305,6 @@ 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'] @@ -366,7 +340,7 @@ def __init__(self, credentials=None, headers=None, auth=None, session=None, requ 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) encoding = _get_encoding(link.encoding) @@ -379,9 +353,6 @@ def transition(self, link, decoders, params=None, link_ancestors=None, force_cod response = session.send(request) 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/tests/test_codecs.py b/tests/test_codecs.py index 32458cd..2475293 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -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..c985fa9 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -17,7 +17,6 @@ def doc(): 'link': Link( url='/', action='post', - transform='inplace', fields=['optional', Field('required', required=True, location='path')] ), 'nested': {'child': Link(url='/123')} @@ -224,7 +223,7 @@ def test_document_repr(doc): "'integer': 123, " "'list': [1, 2, 3], " "'nested': {'child': Link(url='/123')}, " - "'link': Link(url='/', action='post', transform='inplace', " + "'link': Link(url='/', action='post', " "fields=['optional', Field('required', required=True, location='path')])" "})" ) @@ -345,7 +344,6 @@ def test_document_equality(doc): 'link': Link( url='/', action='post', - transform='inplace', fields=['optional', Field('required', required=True, location='path')] ), 'nested': {'child': Link(url='/123')} @@ -424,11 +422,6 @@ def test_link_action_must_be_string(): Link(action=123) -def test_link_transform_must_be_string(): - with pytest.raises(TypeError): - Link(transform=123) - - def test_link_fields_must_be_list(): with pytest.raises(TypeError): Link(fields=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': {}} From b9f9dd76a4b9a7f67e9744b5e506d52be80044e0 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 30 Jan 2018 14:26:57 +0000 Subject: [PATCH 02/60] Drop action= and encoding= parameters on client, in favor of override --- coreapi/client.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/coreapi/client.py b/coreapi/client.py index b005eae..ece3d27 100644 --- a/coreapi/client.py +++ b/coreapi/client.py @@ -131,18 +131,7 @@ 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): - if (action is not None) or (encoding 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 - + def action(self, document, keys, params=None, validate=True, overrides=None): if isinstance(keys, string_types): keys = [keys] From 4fb5e883c2dc2472b656f089d5298eb30e78c937 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 30 Jan 2018 14:29:33 +0000 Subject: [PATCH 03/60] Drop HTTPTransport 'credentials' in favor of 'auth' --- coreapi/transports/http.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index 5b78fc7..0b13b03 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -308,7 +308,7 @@ def _decode_result(response, decoders, force_codec=False): 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, request_callback=None, response_callback=None): if headers: headers = {key.lower(): value for key, value in headers.items()} if session is None: @@ -318,12 +318,6 @@ 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. " From b0eb95c1a22b6ef349e0207b314b492cb27bf8ff Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 30 Jan 2018 14:30:22 +0000 Subject: [PATCH 04/60] Drop HTTP request_callback and response_callback in favor of custom sessions --- coreapi/transports/http.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index 0b13b03..3bee6c0 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -308,7 +308,7 @@ def _decode_result(response, decoders, force_codec=False): class HTTPTransport(BaseTransport): schemes = ['http', 'https'] - def __init__(self, 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: @@ -318,15 +318,6 @@ def __init__(self, headers=None, auth=None, session=None, request_callback=None, if not getattr(session.auth, 'allow_cookies', False): session.cookies.set_policy(BlockAll()) - if request_callback is not None or response_callback is not None: - warnings.warn( - "The 'request_callback' and 'response_callback' arguments are now deprecated. " - "Use a custom 'session' instance instead.", - DeprecationWarning - ) - session.mount('https://', CallbackAdapter(request_callback, response_callback)) - session.mount('http://', CallbackAdapter(request_callback, response_callback)) - self._headers = itypes.Dict(headers or {}) self._session = session From ad00b0a273bd0a781c21f4d70f2e32840d9c8f54 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 30 Jan 2018 14:33:44 +0000 Subject: [PATCH 05/60] Drop unused import --- coreapi/transports/http.py | 1 - 1 file changed, 1 deletion(-) diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index 3bee6c0..f8268b6 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -11,7 +11,6 @@ import itypes import mimetypes import uritemplate -import warnings Params = collections.namedtuple('Params', ['path', 'query', 'data', 'files']) From 15dcdc9cbde6015963977814da205c22c89d55a9 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 30 Jan 2018 15:58:30 +0000 Subject: [PATCH 06/60] Removing data from documents. Drop 'Array'. --- coreapi/__init__.py | 4 +- coreapi/codecs/corejson.py | 10 +--- coreapi/codecs/display.py | 13 +---- coreapi/codecs/python.py | 12 +--- coreapi/document.py | 20 +------ coreapi/transports/http.py | 9 +-- tests/test_document.py | 109 +------------------------------------ 7 files changed, 13 insertions(+), 164 deletions(-) diff --git a/coreapi/__init__.py b/coreapi/__init__.py index 92ac890..0e1f79b 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.client import Client -from coreapi.document import Array, Document, Link, Object, Error, Field +from coreapi.document import Document, Link, Object, Error, Field __version__ = '2.3.3' __all__ = [ - 'Array', 'Document', 'Link', 'Object', 'Error', 'Field', + 'Document', 'Link', 'Object', 'Error', 'Field', 'Client', 'auth', 'codecs', 'exceptions', 'transports', 'utils', ] diff --git a/coreapi/codecs/corejson.py b/coreapi/codecs/corejson.py index 92facc4..4d6f75b 100644 --- a/coreapi/codecs/corejson.py +++ b/coreapi/codecs/corejson.py @@ -3,7 +3,7 @@ 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 @@ -222,9 +222,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 @@ -284,11 +281,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/python.py b/coreapi/codecs/python.py index 6feec4e..c1e34ac 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,11 +31,6 @@ 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: @@ -71,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/document.py b/coreapi/document.py index dd498dc..f625999 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -8,8 +8,6 @@ def _to_immutable(value): if isinstance(value, dict): return Object(value) - elif isinstance(value, list): - return Array(value) return value @@ -169,20 +167,6 @@ 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): """ Links represent the actions that a client may perform. @@ -295,8 +279,10 @@ 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 diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index f8268b6..c593532 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -3,7 +3,7 @@ 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.document import Document, Object, Error from coreapi.transports.base import BaseTransport from coreapi.utils import guess_filename, is_file, File import collections @@ -242,13 +242,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 diff --git a/tests/test_document.py b/tests/test_document.py index c985fa9..b9185f6 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,7 +13,6 @@ def doc(): content={ 'integer': 123, 'dict': {'key': 'value'}, - 'list': [1, 2, 3], 'link': Link( url='/', action='post', @@ -28,11 +27,6 @@ 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( @@ -95,23 +89,6 @@ def test_object_does_not_support_key_deletion(obj): 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(): @@ -134,10 +111,6 @@ 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): @@ -172,48 +145,6 @@ def test_object_set(obj): 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): @@ -221,7 +152,6 @@ 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', " "fields=['optional', Field('required', required=True, location='path')])" @@ -235,11 +165,6 @@ 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 eval(repr(link)) == link @@ -259,11 +184,6 @@ def test_document_str(doc): key: "value" } integer: 123 - list: [ - 1, - 2, - 3 - ] nested: { child() } @@ -291,22 +211,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])" @@ -314,9 +218,7 @@ def test_link_str(link): def test_error_str(error): assert str(error) == _dedent(""" - messages: [ - "failed" - ] + messages: ["failed"] """) @@ -340,7 +242,6 @@ def test_document_equality(doc): assert doc == { 'integer': 123, 'dict': {'key': 'value'}, - 'list': [1, 2, 3], 'link': Link( url='/', action='post', @@ -354,14 +255,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): From f3c8ddef2a9c4aaedbdf10fd80c50d2a64ed6b39 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 31 Jan 2018 12:06:13 +0000 Subject: [PATCH 07/60] Drop CallbackAdapater. We don't use it anywhere. Could later farm it out to an external lib or snippet if we want. --- coreapi/transports/http.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index c593532..18b61e2 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -62,23 +62,6 @@ def __call__(self, request): 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: return 'GET' From 7d06d9329283bbaed5feae8564352a7394277ce7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 31 Jan 2018 12:15:18 +0000 Subject: [PATCH 08/60] Drop DomainCredentials, now that we have proper auth classes --- coreapi/transports/http.py | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index 18b61e2..aa0fae6 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from collections import OrderedDict from coreapi import exceptions, utils -from coreapi.compat import cookiejar, urlparse +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 @@ -40,28 +40,6 @@ 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 - - def _get_method(action): if not action: return 'GET' From ced53cf0640d69b674706c7a9d54928d1744373b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 31 Jan 2018 15:14:03 +0000 Subject: [PATCH 09/60] Refactor to use session.request --- coreapi/transports/http.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index aa0fae6..bdfe0b4 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -160,35 +160,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=None, encoding=None, params=empty_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): @@ -287,9 +287,8 @@ def transition(self, link, decoders, params=None, force_codec=False): 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, Error): From 93946cd3112c41d71164e18cb531e9ad5102bcb7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 31 Jan 2018 15:26:08 +0000 Subject: [PATCH 10/60] Drop unneccessary default parameters --- coreapi/transports/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index bdfe0b4..e6f5256 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -160,7 +160,7 @@ def _get_upload_headers(file_obj): } -def _get_request_options(headers=None, encoding=None, params=empty_params): +def _get_request_options(headers, encoding, params): """ Returns a dictionary of keyword parameters to include when making the outgoing request. From e99f71b1a9b2ffb9189906602dc6a089ae233410 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 31 Jan 2018 15:27:12 +0000 Subject: [PATCH 11/60] Move _guess_extension out of utils and into codes/download --- coreapi/codecs/download.py | 93 +++++++++++++++++++++++++++++++++++++- coreapi/utils.py | 89 ------------------------------------ 2 files changed, 91 insertions(+), 91 deletions(-) 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/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): From 61d4198347ce81bcc7e6993108135986cabca13a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 13 Feb 2018 15:58:03 +0000 Subject: [PATCH 12/60] Drop 'Document.clone' --- coreapi/document.py | 3 --- tests/test_document.py | 34 ---------------------------------- 2 files changed, 37 deletions(-) diff --git a/coreapi/document.py b/coreapi/document.py index f625999..7b249c3 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -79,9 +79,6 @@ def __init__(self, url=None, title=None, description=None, media_type=None, cont 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) - def __iter__(self): items = sorted(self._data.items(), key=_key_sorting) return iter([key for key, value in items]) diff --git a/tests/test_document.py b/tests/test_document.py index b9185f6..6b10f97 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -111,40 +111,6 @@ def test_document_dictionaries_coerced_to_objects(doc): assert isinstance(doc['dict'], Object) -# 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] - - # Container types have a uniquely identifying representation. def test_document_repr(doc): From bc6aa9eb4f59ce6f393730a4970200eb0f291270 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 14 Feb 2018 13:01:32 +0000 Subject: [PATCH 13/60] Add typesys. Add OpenAPI, JSONSchema. --- coreapi/__init__.py | 6 +- coreapi/codecs/__init__.py | 7 +- coreapi/codecs/jsonschema.py | 166 ++++++++++++++++ coreapi/codecs/openapi.py | 94 +++++++++ coreapi/document.py | 31 ++- coreapi/schemas/__init__.py | 5 + coreapi/schemas/jsonschema.py | 32 +++ coreapi/schemas/openapi.py | 137 +++++++++++++ coreapi/typesys.py | 358 ++++++++++++++++++++++++++++++++++ tests/codecs/test_openapi.py | 170 ++++++++++++++++ 10 files changed, 992 insertions(+), 14 deletions(-) create mode 100644 coreapi/codecs/jsonschema.py create mode 100644 coreapi/codecs/openapi.py create mode 100644 coreapi/schemas/__init__.py create mode 100644 coreapi/schemas/jsonschema.py create mode 100644 coreapi/schemas/openapi.py create mode 100644 coreapi/typesys.py create mode 100644 tests/codecs/test_openapi.py diff --git a/coreapi/__init__.py b/coreapi/__init__.py index 0e1f79b..21ac6a9 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 Document, Link, Object, Error, Field -__version__ = '2.3.3' +__version__ = '3.0.0' __all__ = [ 'Document', 'Link', 'Object', 'Error', 'Field', 'Client', - 'auth', 'codecs', 'exceptions', 'transports', 'utils', + 'auth', 'codecs', 'exceptions', 'transports', 'typesys', 'utils', ] 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/jsonschema.py b/coreapi/codecs/jsonschema.py new file mode 100644 index 0000000..eae034c --- /dev/null +++ b/coreapi/codecs/jsonschema.py @@ -0,0 +1,166 @@ +from coreapi import typesys +from coreapi.codecs.base import BaseCodec +from coreapi.compat import COMPACT_SEPARATORS, VERBOSE_SEPARATORS, force_bytes +from coreapi.exceptions import ParseError +from coreapi.schemas import JSONSchema +import collections +import json + + +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(data) + return self.decode_from_data_structure(jsonschema) + + def decode_from_data_structure(self, struct): + attrs = {} + + if '$ref' in struct: + if struct['$ref'] == '#': + return typesys.ref() + name = struct['$ref'].split('/')[-1] + return typesys.ref(name) + + if struct['type'] == '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 struct['type'] in ['number', 'integer']: + if 'minimum' in struct: + attrs['minimum'] = struct['minimum'] + if 'maximum' in struct: + attrs['maximum'] = struct['maximum'] + if 'exclusiveMinimum' in struct: + attrs['exclusiveMinimum'] = struct['exclusiveMinimum'] + if 'exclusiveMaximum' in struct: + attrs['exclusiveMaximum'] = struct['exclusiveMaximum'] + if 'multipleOf' in struct: + attrs['multipleOf'] = struct['multipleOf'] + if struct['type'] == 'integer': + return typesys.integer(**attrs) + return typesys.number(**attrs) + + if struct['type'] == 'boolean': + return typesys.boolean() + + if struct['type'] == 'object': + if 'properties' in struct: + attrs['properties'] = { + key: self.decode_from_data_structure(value) + for key, value in struct['properties'].items() + } + if 'required' in struct: + attrs['required'] = struct['required'] + return typesys.obj(**attrs) + + if struct['type'] == 'array': + if 'items' in struct: + attrs['items'] = self.decode_from_data_structure(struct['items']) + if 'additionalItems' in struct: + attrs['additional_items'] = 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) + + def encode(self, cls, **options): + struct = self.encode_to_data_structure(cls) + 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, cls): + if issubclass(cls, typesys.String): + value = {'type': 'string'} + if cls.max_length is not None: + value['maxLength'] = cls.max_length + if cls.min_length is not None: + value['minLength'] = cls.min_length + if cls.pattern is not None: + value['pattern'] = cls.pattern + if cls.format is not None: + value['format'] = cls.format + return value + + if issubclass(cls, typesys.NumericType): + if issubclass(cls, typesys.Integer): + value = {'type': 'integer'} + else: + value = {'type': 'number'} + + if cls.minimum is not None: + value['minimum'] = cls.minimum + if cls.maximum is not None: + value['maximum'] = cls.maximum + if cls.exclusive_minimum: + value['exclusiveMinimum'] = cls.exclusive_minimum + if cls.exclusive_maximum: + value['exclusiveMaximum'] = cls.exclusive_maximum + if cls.multiple_of is not None: + value['multipleOf'] = cls.multiple_of + return value + + if issubclass(cls, typesys.Boolean): + return {'type': 'boolean'} + + if issubclass(cls, typesys.Object): + value = {'type': 'object'} + if cls.properties: + value['properties'] = { + key: self.encode_to_data_structure(value) + for key, value in cls.properties.items() + } + if cls.required: + value['required'] = cls.required + return value + + if issubclass(cls, typesys.Array): + value = {'type': 'array'} + if cls.items is not None: + value['items'] = self.encode_to_data_structure(cls.items) + if cls.additional_items: + value['additionalItems'] = cls.additional_items + if cls.min_items is not None: + value['minItems'] = cls.min_items + if cls.max_items is not None: + value['maxItems'] = cls.max_items + if cls.unique_items is not None: + value['uniqueItems'] = cls.unique_items + return value + + if issubclass(cls, typesys.Ref): + if not cls.to: + return {'$ref': '#'} + return {'$ref': '#/definitions/%s' % cls.to} + + raise Exception('Cannot encode class %s' % cls) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py new file mode 100644 index 0000000..732bda1 --- /dev/null +++ b/coreapi/codecs/openapi.py @@ -0,0 +1,94 @@ +from coreapi.codecs import BaseCodec, JSONSchemaCodec +from coreapi.document import Document, Link, Field +from coreapi.schemas import OpenAPI +import yaml + + +METHODS = [ + 'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace' +] + + +class OpenAPICodec(BaseCodec): + media_type = 'application/vnd.oai.openapi' + format = 'openapi' + + def decode(self, bytestring, **options): + data = yaml.safe_load(bytestring) + openapi = OpenAPI(data) + title = openapi.lookup(['info', 'title']) + description = openapi.lookup(['info', 'description']) + url = openapi.lookup(['servers', 0, 'url']) + content = self.get_links(openapi) + return Document(title=title, description=description, url=url, content=content) + + def get_links(self, openapi): + """ + Return all the links in the document, layed out by tag and operationId. + """ + content = {} + + 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(): + operationId = operation_info.get('operationId') + tag = operation_info.lookup(['tags', 0]) + if not operationId: + continue + + link = self.get_link(path, path_info, operation, operation_info) + if tag is None: + content[operationId] = link + else: + if tag in content: + content[tag][operationId] = link + else: + content[tag] = {operationId: link} + + return content + + def get_link(self, path, path_info, operation, operation_info): + """ + Return a single link in the document. + """ + title = operation_info.get('summary') + description = operation_info.get('description') + parameters = path_info.get('parameters', []) + parameters += operation_info.get('parameters', []) + + fields = [ + self.get_field(parameter) + for parameter in parameters + ] + + return Link( + url=path, + action=operation, + title=title, + description=description, + fields=fields + ) + + def get_field(self, parameter): + """ + 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') + + if schema is not None: + schema = JSONSchemaCodec().decode_from_data_structure(schema) + + return Field( + name=name, + location=location, + description=description, + required=required, + schema=schema + ) diff --git a/coreapi/document.py b/coreapi/document.py index 7b249c3..f2773cc 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -1,6 +1,6 @@ # coding: utf-8 from __future__ import unicode_literals -from collections import OrderedDict, namedtuple +from collections import OrderedDict from coreapi.compat import string_types import itypes @@ -40,14 +40,6 @@ def _key_sorting(item): 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): @@ -238,6 +230,27 @@ def __str__(self): return _str(self) +class Field(object): + def __init__(self, name, required=False, location='', schema=None, description=None, example=None): + self.name = name + self.required = required + self.location = location + self.schema = schema + self.description = description + 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(itypes.Dict): def __init__(self, title=None, content=None): data = {} if (content is None) else content 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..b408d0f --- /dev/null +++ b/coreapi/schemas/jsonschema.py @@ -0,0 +1,32 @@ +from coreapi import typesys + + +class JSONSchema(typesys.Object): + properties = { + '$ref': typesys.string(), + 'type': typesys.string(), + + # 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.obj(), # TODO: typesys.ref('JSONSchema'), + 'required': typesys.array(items=typesys.string(), min_items=1, unique=True), + + # Array + 'items': typesys.obj(), # TODO: typesys.ref('JSONSchema'), + 'additionalItems': typesys.boolean(), + 'minItems': typesys.integer(minimum=0), + '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..d572628 --- /dev/null +++ b/coreapi/schemas/openapi.py @@ -0,0 +1,137 @@ +from coreapi import typesys +from coreapi.schemas import JSONSchema + + +class Contact(typesys.Object): + properties = { + 'name': typesys.string(), + 'url': typesys.string(format='url'), + 'email': typesys.string(format='email') + } + + +class License(typesys.Object): + properties = { + 'name': typesys.string(), + 'url': typesys.string(format='url') + } + required = ['name'] + + +class Info(typesys.Object): + properties = { + 'title': typesys.string(), + 'description': typesys.string(format='textarea'), + 'termsOfService': typesys.string(format='url'), + 'contact': Contact, + 'license': License, + 'version': typesys.string() + } + required = ['title', 'version'] + + +class ServerVariable(typesys.Object): + properties = { + 'enum': typesys.array(items=typesys.string()), + 'default': typesys.string(), + 'description': typesys.string(format='textarea') + } + required = ['default'] + + +class Server(typesys.Object): + properties = { + 'url': typesys.string(), + 'description': typesys.string(format='textarea'), + 'variables': typesys.obj(additional_properties=ServerVariable) + } + required = ['url'] + + +class ExternalDocs(typesys.Object): + properties = { + 'description': typesys.string(format='textarea'), + 'url': typesys.string(format='url') + } + required = ['url'] + + +class SecurityRequirement(typesys.Object): + additional_properties = typesys.array(items=typesys.string()) + + +class Parameter(typesys.Object): + properties = { + 'name': typesys.string(), + 'in': typesys.enum(enum=['query', 'header', 'path', 'cookie']), + 'description': typesys.string(format='textarea'), + 'required': typesys.boolean(), + 'deprecated': typesys.boolean(), + 'allowEmptyValue': typesys.boolean(), + 'schema': JSONSchema, + # TODO: Other fields + } + required = ['name', 'in'] + + +class Operation(typesys.Object): + properties = { + 'tags': typesys.array(items=typesys.string()), + 'summary': typesys.string(), + 'description': typesys.string(format='textarea'), + 'externalDocs': ExternalDocs, + 'operationId': typesys.string(), + 'parameters': typesys.array(items=Parameter), # Parameter | ReferenceObject + # TODO: 'requestBody' + # TODO: 'responses' + # TODO: 'callbacks' + 'deprecated': typesys.boolean(), + 'security': SecurityRequirement, + 'servers': typesys.array(items=Server) + } + + +class Path(typesys.Object): + properties = { + 'summary': typesys.string(), + 'description': typesys.string(format='textarea'), + 'get': Operation, + 'put': Operation, + 'post': Operation, + 'delete': Operation, + 'options': Operation, + 'head': Operation, + 'patch': Operation, + 'trace': Operation, + 'servers': typesys.array(items=Server), + 'parameters': typesys.array(items=Parameter) # TODO: Parameter | ReferenceObject + } + + +class Paths(typesys.Object): + pattern_properties = { + '^/': Path # TODO: Path | ReferenceObject + } + + +class Tag(typesys.Object): + properties = { + 'name': typesys.string(), + 'description': typesys.string(format='textarea'), + 'externalDocs': ExternalDocs + } + required = ['name'] + + +class OpenAPI(typesys.Object): + properties = { + 'openapi': typesys.string(), + 'info': Info, + 'servers': typesys.array(items=Server), + 'paths': Paths, + # TODO: 'components': ..., + 'security': SecurityRequirement, + 'tags': typesys.array(items=Tag), + 'externalDocs': ExternalDocs + } + required = ['openapi', 'info'] diff --git a/coreapi/typesys.py b/coreapi/typesys.py new file mode 100644 index 0000000..167739a --- /dev/null +++ b/coreapi/typesys.py @@ -0,0 +1,358 @@ +import re +# from typing import Any, Dict, List, Optional, Tuple, Union, overload # noqa + + +# TODO: Error on unknown attributes +# TODO: allow_blank? +# TODO: format (check type at start and allow, coerce, .native) +# TODO: default=empty +# TODO: check 'required' exists in 'properties' +# TODO: smarter ordering +# TODO: extra_properties=False by default +# TODO: inf, -inf, nan +# TODO: Overriding errors +# TODO: Blank booleans as False? + + +class ValidationError(Exception): + def __init__(self, detail): + self.detail = detail + super(ValidationError, self).__init__(detail) + + +class String(str): + errors = { + 'type': 'Must be a string.', + '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}.', + } + max_length = None # type: int + min_length = None # type: int + pattern = None # type: str + format = None # type: Any + trim_whitespace = True + + def __new__(cls, value): + value = str.__new__(cls, value) + + if cls.trim_whitespace: + value = value.strip() + + if cls.min_length is not None: + if len(value) < cls.min_length: + if cls.min_length == 1: + cls.error('blank') + else: + cls.error('min_length') + + if cls.max_length is not None: + if len(value) > cls.max_length: + cls.error('max_length') + + if cls.pattern is not None: + if not re.search(cls.pattern, value): + cls.error('pattern') + + return value + + @classmethod + def error(cls, code): + message = cls.errors[code].format(**cls.__dict__) + raise ValidationError(message) # from None + + +class NumericType(object): + """ + Base class for both `Number` and `Integer`. + """ + numeric_type = None # type: type + errors = { + 'type': 'Must be a valid number.', + '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}.', + } + minimum = None # type: Union[float, int] + maximum = None # type: Union[float, int] + exclusive_minimum = False + exclusive_maximum = False + multiple_of = None # type: Union[float, int] + + def __new__(cls, value): + try: + value = cls.numeric_type.__new__(cls, value) + except (TypeError, ValueError): + cls.error('type') + + if cls.minimum is not None: + if cls.exclusive_minimum: + if value <= cls.minimum: + cls.error('exclusive_minimum') + else: + if value < cls.minimum: + cls.error('minimum') + + if cls.maximum is not None: + if cls.exclusive_maximum: + if value >= cls.maximum: + cls.error('exclusive_maximum') + else: + if value > cls.maximum: + cls.error('maximum') + + if cls.multiple_of is not None: + if isinstance(cls.multiple_of, float): + failed = not (value * (1 / cls.multiple_of)).is_integer() + else: + failed = value % cls.multiple_of + if failed: + cls.error('multiple_of') + + return value + + @classmethod + def error(cls, code): + message = cls.errors[code].format(**cls.__dict__) + raise ValidationError(message) # from None + + +class Number(NumericType, float): + numeric_type = float + + +class Integer(NumericType, int): + numeric_type = int + + +class Boolean(object): + native_type = bool + errors = { + 'type': 'Must be a valid boolean.' + } + + def __new__(cls, value): + if isinstance(value, str): + try: + return { + 'true': True, + 'false': False, + '1': True, + '0': False + }[value.lower()] + except KeyError: + cls.error('type') + return bool(value) + + @classmethod + def error(cls, code): + message = cls.errors[code].format(**cls.__dict__) + raise ValidationError(message) # from None + + +class Enum(str): + errors = { + 'enum': 'Must be a valid choice.', + 'exact': 'Must be {exact}.' + } + enum = [] # type: List[str] + + def __new__(cls, value): + if value not in cls.enum: + if len(cls.enum) == 1: + cls.error('exact') + cls.error('enum') + return value + + @classmethod + def error(cls, code): + message = cls.errors[code].format(**cls.__dict__) + raise ValidationError(message) # from None + + +class Object(dict): + errors = { + 'type': 'Must be an object.', + 'invalid_key': 'Object keys must be strings.', + 'required': 'This field is required.', + } + properties = {} # type: Dict[str, type] + pattern_properties = None # type: Dict[str, type] + additional_properties = None # type: type + required = [] + + def __init__(self, value): + try: + value = dict(value) + except TypeError: + self.error('type') + + # Ensure all property keys are strings. + errors = {} + if any(not isinstance(key, str) for key in value.keys()): + self.error('invalid_key') + + # Properties + for key, child_schema in self.properties.items(): + try: + item = value.pop(key) + except KeyError: + if key in self.required: + errors[key] = self.error_message('required') + else: + # Coerce value into the given schema type if needed. + if isinstance(item, child_schema): + self[key] = item + else: + try: + self[key] = child_schema(item) + except ValidationError as exc: + errors[key] = exc.detail + + # Pattern properties + if self.pattern_properties is not None: + for key in list(value.keys()): + for pattern, child_schema in self.pattern_properties.items(): + if re.search(pattern, key): + item = value.pop(key) + try: + self[key] = child_schema(item) + except ValidationError as exc: + errors[key] = exc.detail + + # Additional properties + if self.additional_properties is not None: + child_schema = self.additional_properties + for key in list(value.keys()): + item = value.pop(key) + try: + self[key] = child_schema(item) + except ValidationError as exc: + errors[key] = exc.detail + + if errors: + raise ValidationError(errors) + + def lookup(self, keys, default=None): + try: + item = self[keys[0]] + except KeyError: + return default + + if len(keys) == 1: + return item + return item.lookup(keys[1:], default) + + def error(self, code): + message = self.errors[code].format(**self.__dict__) + raise ValidationError(message) # from None + + def error_message(self, code): + return self.errors[code].format(**self.__dict__) + + +class Array(list): + errors = { + 'type': 'Must be a list.', + 'min_items': 'Not enough items.', + 'max_items': 'Too many items.', + 'unique_items': 'This item is not unique.', + } + items = None # type: Union[type, List[type]] + additional_items = False # type: bool + min_items = None # type: Optional[int] + max_items = None # type: Optional[int] + unique_items = False # type: bool + + def __init__(self, value): + try: + value = list(value) + except TypeError: + self.error('type') + + if isinstance(self.items, list) and len(self.items) > 1: + if len(value) < len(self.items): + self.error('min_items') + elif len(value) > len(self.items) and not self.additional_items: + self.error('max_items') + + if self.min_items is not None and len(value) < self.min_items: + self.error('min_items') + elif self.max_items is not None and len(value) > self.max_items: + self.error('max_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](item) + elif self.items is not None: + item = self.items(item) + + if self.unique_items: + if item in seen_items: + self.error('unique_items') + else: + seen_items.add(item) + + self.append(item) + except ValidationError as exc: + errors[pos] = exc.detail + + if errors: + raise ValidationError(errors) + + def lookup(self, keys, default=None): + try: + item = self[keys[0]] + except (TypeError, IndexError): + return default + + if len(keys) == 1: + return item + return item.lookup(keys[1:], default) + + def error(self, code): + message = self.errors[code].format(**self.__dict__) + raise ValidationError(message) # from None + + +def string(**kwargs): + return type('String', (String,), kwargs) + + +def integer(**kwargs): + return type('Integer', (Integer,), kwargs) + + +def number(**kwargs): + return type('Number', (Number,), kwargs) + + +def boolean(**kwargs): + return type('Boolean', (Boolean,), kwargs) + + +def enum(**kwargs): + return type('Enum', (Enum,), kwargs) + + +def array(**kwargs): + return type('Array', (Array,), kwargs) + + +def obj(**kwargs): + return type('Object', (Object,), kwargs) + + +# def ref(to=''): +# return type('Ref', (Ref,), {'to': to}) diff --git a/tests/codecs/test_openapi.py b/tests/codecs/test_openapi.py new file mode 100644 index 0000000..cf9c3b2 --- /dev/null +++ b/tests/codecs/test_openapi.py @@ -0,0 +1,170 @@ +from coreapi import typesys +from coreapi.codecs import OpenAPICodec +from coreapi.document import Document, Link, Field +import pytest + + +@pytest.fixture +def openapi_codec(): + return OpenAPICodec() + + +@pytest.fixture +def petstore_schema(): + return ''' +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: http://petstore.swagger.io/v1 +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: An paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: "#/components/schemas/Pet" + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string +''' + + +def test_openapi(openapi_codec, petstore_schema): + doc = openapi_codec.decode(petstore_schema) + expected = Document( + title='Swagger Petstore', + url='http://petstore.swagger.io/v1', + content={ + 'pets': { + 'listPets': Link( + action='get', + url='/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='/pets', + title='Create a pet' + ), + 'showPetById': Link( + action='get', + url='/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 From df8696645a86e52a683b9e691c928b9902798631 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 14 Feb 2018 13:33:40 +0000 Subject: [PATCH 14/60] .errors always a classmethod --- coreapi/typesys.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/coreapi/typesys.py b/coreapi/typesys.py index 167739a..5eb5ece 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -247,12 +247,14 @@ def lookup(self, keys, default=None): return item return item.lookup(keys[1:], default) - def error(self, code): - message = self.errors[code].format(**self.__dict__) + @classmethod + def error(cls, code): + message = cls.errors[code].format(**cls.__dict__) raise ValidationError(message) # from None - def error_message(self, code): - return self.errors[code].format(**self.__dict__) + @classmethod + def error_message(cls, code): + return cls.errors[code].format(**cls.__dict__) class Array(list): @@ -321,11 +323,17 @@ def lookup(self, keys, default=None): return item return item.lookup(keys[1:], default) - def error(self, code): - message = self.errors[code].format(**self.__dict__) + @classmethod + def error(cls, code): + message = cls.errors[code].format(**cls.__dict__) raise ValidationError(message) # from None +class Any(object): + def __new__(self, value): + return value + + def string(**kwargs): return type('String', (String,), kwargs) From f106bd020d68af3ba02e350bb343add563ef7577 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 14 Feb 2018 13:34:33 +0000 Subject: [PATCH 15/60] Create absolute URLs for Links from OpenAPI --- coreapi/codecs/openapi.py | 25 +++++++++++++++++-------- coreapi/schemas/openapi.py | 1 + tests/codecs/test_openapi.py | 6 +++--- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index 732bda1..c706148 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -1,4 +1,5 @@ from coreapi.codecs import BaseCodec, JSONSchemaCodec +from coreapi.compat import urlparse from coreapi.document import Document, Link, Field from coreapi.schemas import OpenAPI import yaml @@ -18,11 +19,11 @@ def decode(self, bytestring, **options): openapi = OpenAPI(data) title = openapi.lookup(['info', 'title']) description = openapi.lookup(['info', 'description']) - url = openapi.lookup(['servers', 0, 'url']) - content = self.get_links(openapi) - return Document(title=title, description=description, url=url, content=content) + base_url = openapi.lookup(['servers', 0, 'url']) + content = self.get_links(openapi, base_url) + return Document(title=title, description=description, url=base_url, content=content) - def get_links(self, openapi): + def get_links(self, openapi, base_url): """ Return all the links in the document, layed out by tag and operationId. """ @@ -39,7 +40,7 @@ def get_links(self, openapi): if not operationId: continue - link = self.get_link(path, path_info, operation, operation_info) + link = self.get_link(base_url, path, path_info, operation, operation_info) if tag is None: content[operationId] = link else: @@ -50,12 +51,18 @@ def get_links(self, openapi): return content - def get_link(self, path, path_info, operation, operation_info): + def get_link(self, base_url, path, path_info, operation, operation_info): """ Return a single link in the document. """ title = operation_info.get('summary') description = operation_info.get('description') + + # Allow path info and operation info to override the base url. + base_url = path_info.lookup(['servers', 0, 'url'], default=base_url) + base_url = operation_info.lookup(['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', []) @@ -65,7 +72,7 @@ def get_link(self, path, path_info, operation, operation_info): ] return Link( - url=path, + url=urlparse.urljoin(base_url, path), action=operation, title=title, description=description, @@ -81,6 +88,7 @@ def get_field(self, parameter): description = parameter.get('description') required = parameter.get('required', False) schema = parameter.get('schema') + example = parameter.get('example') if schema is not None: schema = JSONSchemaCodec().decode_from_data_structure(schema) @@ -90,5 +98,6 @@ def get_field(self, parameter): location=location, description=description, required=required, - schema=schema + schema=schema, + example=example ) diff --git a/coreapi/schemas/openapi.py b/coreapi/schemas/openapi.py index d572628..443370c 100644 --- a/coreapi/schemas/openapi.py +++ b/coreapi/schemas/openapi.py @@ -69,6 +69,7 @@ class Parameter(typesys.Object): 'deprecated': typesys.boolean(), 'allowEmptyValue': typesys.boolean(), 'schema': JSONSchema, + 'example': typesys.Any # TODO: Other fields } required = ['name', 'in'] diff --git a/tests/codecs/test_openapi.py b/tests/codecs/test_openapi.py index cf9c3b2..da82094 100644 --- a/tests/codecs/test_openapi.py +++ b/tests/codecs/test_openapi.py @@ -133,7 +133,7 @@ def test_openapi(openapi_codec, petstore_schema): 'pets': { 'listPets': Link( action='get', - url='/pets', + url='http://petstore.swagger.io/pets', title='List all pets', fields=[ Field( @@ -147,12 +147,12 @@ def test_openapi(openapi_codec, petstore_schema): ), 'createPets': Link( action='post', - url='/pets', + url='http://petstore.swagger.io/pets', title='Create a pet' ), 'showPetById': Link( action='get', - url='/pets/{petId}', + url='http://petstore.swagger.io/pets/{petId}', title='Info for a specific pet', fields=[ Field( From 6b3296abc71da4877bffb7853f8db69600a64e82 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 14 Feb 2018 13:59:28 +0000 Subject: [PATCH 16/60] Drop coreschema --- coreapi/codecs/corejson.py | 34 ++++++++++++++++++---------------- coreapi/typesys.py | 15 +++++++++++++++ requirements.txt | 1 - setup.py | 1 - tests/test_codecs.py | 6 +++--- 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/coreapi/codecs/corejson.py b/coreapi/codecs/corejson.py index 4d6f75b..07345ec 100644 --- a/coreapi/codecs/corejson.py +++ b/coreapi/codecs/corejson.py @@ -1,5 +1,6 @@ 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 @@ -13,15 +14,14 @@ # 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.Enum: 'enum', + typesys.Any: 'anything' } TYPE_ID_TO_SCHEMA_CLASS = { @@ -32,14 +32,16 @@ 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 issubclass(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'): retval['enum'] = schema.enum @@ -51,12 +53,12 @@ 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': kwargs['enum'] = _get_list(data, 'enum') schema_cls = TYPE_ID_TO_SCHEMA_CLASS.get(type_id, coreschema.Anything) - return schema_cls(title=title, description=description, **kwargs) + return type(schema_cls.__name__, (schema_cls,), kwargs) # Robust dictionary lookups, that always return an item of the correct diff --git a/coreapi/typesys.py b/coreapi/typesys.py index 5eb5ece..befded6 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -29,6 +29,8 @@ class String(str): 'pattern': 'Must match the pattern /{pattern}/.', 'format': 'Must be a valid {format}.', } + title = None # type: str + description = None # type: str max_length = None # type: int min_length = None # type: int pattern = None # type: str @@ -77,6 +79,8 @@ class NumericType(object): 'exclusive_maximum': 'Must be less than {maximum}.', 'multiple_of': 'Must be a multiple of {multiple_of}.', } + title = None # type: str + description = None # type: str minimum = None # type: Union[float, int] maximum = None # type: Union[float, int] exclusive_minimum = False @@ -134,6 +138,8 @@ class Boolean(object): errors = { 'type': 'Must be a valid boolean.' } + title = None # type: str + description = None # type: str def __new__(cls, value): if isinstance(value, str): @@ -159,6 +165,8 @@ class Enum(str): 'enum': 'Must be a valid choice.', 'exact': 'Must be {exact}.' } + title = None # type: str + description = None # type: str enum = [] # type: List[str] def __new__(cls, value): @@ -180,6 +188,8 @@ class Object(dict): 'invalid_key': 'Object keys must be strings.', 'required': 'This field is required.', } + title = None # type: str + description = None # type: str properties = {} # type: Dict[str, type] pattern_properties = None # type: Dict[str, type] additional_properties = None # type: type @@ -264,6 +274,8 @@ class Array(list): 'max_items': 'Too many items.', 'unique_items': 'This item is not unique.', } + title = None # type: str + description = None # type: str items = None # type: Union[type, List[type]] additional_items = False # type: bool min_items = None # type: Optional[int] @@ -330,6 +342,9 @@ def error(cls, code): class Any(object): + title = None # type: str + description = None # type: str + def __new__(self, value): return value diff --git a/requirements.txt b/requirements.txt index 71ddd11..4a7d2a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ # Package requirements -coreschema itypes requests uritemplate diff --git a/setup.py b/setup.py index 7dabead..2fa32a5 100755 --- a/setup.py +++ b/setup.py @@ -63,7 +63,6 @@ def get_package_data(package): packages=get_packages('coreapi'), package_data=get_package_data('coreapi'), install_requires=[ - 'coreschema', 'requests', 'itypes', 'uritemplate' diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 2475293..170ef69 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.enum(enum=['a', 'b', 'c'])), ]), 'nested': {'child': Link(url='http://example.org/123')}, '_type': 'needs escaping' From 86446895a4996494ea130b0179ba60f7cabaaf83 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 14 Feb 2018 16:39:02 +0000 Subject: [PATCH 17/60] Ordered schema representations. Support OpenAPI encoding. --- coreapi/codecs/jsonschema.py | 4 + coreapi/codecs/openapi.py | 137 ++++++++++++++++++++++++++++- coreapi/compat.py | 8 ++ coreapi/schemas/jsonschema.py | 41 ++++----- coreapi/schemas/openapi.py | 161 +++++++++++++++++----------------- coreapi/typesys.py | 6 +- tests/codecs/test_openapi.py | 96 +++++++++++++++++++- 7 files changed, 347 insertions(+), 106 deletions(-) diff --git a/coreapi/codecs/jsonschema.py b/coreapi/codecs/jsonschema.py index eae034c..cd2506e 100644 --- a/coreapi/codecs/jsonschema.py +++ b/coreapi/codecs/jsonschema.py @@ -52,6 +52,8 @@ def decode_from_data_structure(self, struct): attrs['exclusiveMaximum'] = struct['exclusiveMaximum'] if 'multipleOf' in struct: attrs['multipleOf'] = struct['multipleOf'] + if 'format' in struct: + attrs['format'] = struct['format'] if struct['type'] == 'integer': return typesys.integer(**attrs) return typesys.number(**attrs) @@ -128,6 +130,8 @@ def encode_to_data_structure(self, cls): value['exclusiveMaximum'] = cls.exclusive_maximum if cls.multiple_of is not None: value['multipleOf'] = cls.multiple_of + if cls.format is not None: + value['format'] = cls.format return value if issubclass(cls, typesys.Boolean): diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index c706148..e819695 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -1,5 +1,5 @@ from coreapi.codecs import BaseCodec, JSONSchemaCodec -from coreapi.compat import urlparse +from coreapi.compat import dict_type, urlparse from coreapi.document import Document, Link, Field from coreapi.schemas import OpenAPI import yaml @@ -10,6 +10,80 @@ ] +def represent_odict(dump, tag, mapping, flow_style=None): + """Like BaseRepresenter.represent_mapping, but does not issue the sort(). + """ + value = [] + node = yaml.MappingNode(tag, value, flow_style=flow_style) + if dump.alias_key is not None: + dump.represented_objects[dump.alias_key] = node + best_style = True + if hasattr(mapping, 'items'): + mapping = mapping.items() + for item_key, item_value in mapping: + node_key = dump.represent_data(item_key) + node_value = dump.represent_data(item_value) + if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style): + best_style = False + if not (isinstance(node_value, yaml.ScalarNode) and not node_value.style): + best_style = False + value.append((node_key, node_value)) + if flow_style is None: + if dump.default_flow_style is not None: + node.flow_style = dump.default_flow_style + else: + node.flow_style = best_style + return node + + +def represent_list(dump, tag, sequence, flow_style=None): + value = [] + node = yaml.SequenceNode(tag, value, flow_style=flow_style) + if dump.alias_key is not None: + dump.represented_objects[dump.alias_key] = node + best_style = True + for item in sequence: + node_item = dump.represent_data(item) + if not (isinstance(node_item, yaml.ScalarNode) and not node_item.style): + best_style = False + value.append(node_item) + if flow_style is None: + if dump.default_flow_style is not None: + node.flow_style = dump.default_flow_style + else: + node.flow_style = best_style + return node + + +class CustomSafeDumper(yaml.SafeDumper): + def increase_indent(self, flow=False, indentless=False): + return super(CustomSafeDumper, self).increase_indent(flow, False) + + +CustomSafeDumper.add_multi_representer( + dict_type, + lambda dumper, value: represent_odict(dumper, u'tag:yaml.org,2002:map', value) +) +CustomSafeDumper.add_multi_representer( + list, + lambda dumper, value: represent_list(dumper, u'tag:yaml.org,2002:seq', 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 + + class OpenAPICodec(BaseCodec): media_type = 'application/vnd.oai.openapi' format = 'openapi' @@ -101,3 +175,64 @@ def get_field(self, parameter): schema=schema, example=example ) + + def encode(self, document, **options): + paths = self.get_paths(document) + openapi = OpenAPI({ + 'openapi': '3.0.0', + 'info': { + 'version': '', + 'title': document.title, + 'description': document.description + }, + 'servers': [{ + 'url': document.url + }], + 'paths': paths + }) + return yaml.dump(openapi, Dumper=CustomSafeDumper, default_flow_style=False) + + def get_paths(self, document): + paths = {} + + 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/compat.py b/coreapi/compat.py index 9890ec1..c56cb44 100644 --- a/coreapi/compat.py +++ b/coreapi/compat.py @@ -1,6 +1,8 @@ # coding: utf-8 import base64 +import collections +import sys __all__ = [ @@ -51,6 +53,12 @@ def force_text(string): return string +if sys.version_info < (3, 5): + dict_type = collections.OrderedDict +else: + dict_type = dict + + try: import click console_style = click.style diff --git a/coreapi/schemas/jsonschema.py b/coreapi/schemas/jsonschema.py index b408d0f..1dacfc9 100644 --- a/coreapi/schemas/jsonschema.py +++ b/coreapi/schemas/jsonschema.py @@ -1,32 +1,33 @@ from coreapi import typesys +from coreapi.compat import dict_type class JSONSchema(typesys.Object): - properties = { - '$ref': typesys.string(), - 'type': typesys.string(), + properties = dict_type([ + ('$ref', typesys.string()), + ('type', typesys.string()), # String - 'minLength': typesys.integer(minimum=0, default=0), - 'maxLength': typesys.integer(minimum=0), - 'pattern': typesys.string(format='regex'), - 'format': typesys.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), + ('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.obj(), # TODO: typesys.ref('JSONSchema'), - 'required': typesys.array(items=typesys.string(), min_items=1, unique=True), + ('properties', typesys.obj()), # TODO: typesys.ref('JSONSchema'), + ('required', typesys.array(items=typesys.string(), min_items=1, unique=True)), # Array - 'items': typesys.obj(), # TODO: typesys.ref('JSONSchema'), - 'additionalItems': typesys.boolean(), - 'minItems': typesys.integer(minimum=0), - 'maxItems': typesys.integer(minimum=0), - 'uniqueItems': typesys.boolean(), - } + ('items', typesys.obj()), # TODO: typesys.ref('JSONSchema'), + ('additionalItems', typesys.boolean()), + ('minItems', typesys.integer(minimum=0)), + ('maxItems', typesys.integer(minimum=0)), + ('uniqueItems', typesys.boolean()), + ]) diff --git a/coreapi/schemas/openapi.py b/coreapi/schemas/openapi.py index 443370c..3940434 100644 --- a/coreapi/schemas/openapi.py +++ b/coreapi/schemas/openapi.py @@ -1,58 +1,59 @@ from coreapi import typesys +from coreapi.compat import dict_type from coreapi.schemas import JSONSchema class Contact(typesys.Object): - properties = { - 'name': typesys.string(), - 'url': typesys.string(format='url'), - 'email': typesys.string(format='email') - } + properties = dict_type([ + ('name', typesys.string()), + ('url', typesys.string(format='url')), + ('email', typesys.string(format='email')) + ]) class License(typesys.Object): - properties = { - 'name': typesys.string(), - 'url': typesys.string(format='url') - } + properties = dict_type([ + ('name', typesys.string()), + ('url', typesys.string(format='url')) + ]) required = ['name'] class Info(typesys.Object): - properties = { - 'title': typesys.string(), - 'description': typesys.string(format='textarea'), - 'termsOfService': typesys.string(format='url'), - 'contact': Contact, - 'license': License, - 'version': typesys.string() - } + properties = dict_type([ + ('title', typesys.string()), + ('description', typesys.string(format='textarea')), + ('termsOfService', typesys.string(format='url')), + ('contact', Contact), + ('license', License), + ('version', typesys.string()) + ]) required = ['title', 'version'] class ServerVariable(typesys.Object): - properties = { - 'enum': typesys.array(items=typesys.string()), - 'default': typesys.string(), - 'description': typesys.string(format='textarea') - } + properties = dict_type([ + ('enum', typesys.array(items=typesys.string())), + ('default', typesys.string()), + ('description', typesys.string(format='textarea')) + ]) required = ['default'] class Server(typesys.Object): - properties = { - 'url': typesys.string(), - 'description': typesys.string(format='textarea'), - 'variables': typesys.obj(additional_properties=ServerVariable) - } + properties = dict_type([ + ('url', typesys.string()), + ('description', typesys.string(format='textarea')), + ('variables', typesys.obj(additional_properties=ServerVariable)) + ]) required = ['url'] class ExternalDocs(typesys.Object): - properties = { - 'description': typesys.string(format='textarea'), - 'url': typesys.string(format='url') - } + properties = dict_type([ + ('description', typesys.string(format='textarea')), + ('url', typesys.string(format='url')) + ]) required = ['url'] @@ -61,52 +62,52 @@ class SecurityRequirement(typesys.Object): class Parameter(typesys.Object): - properties = { - 'name': typesys.string(), - 'in': typesys.enum(enum=['query', 'header', 'path', 'cookie']), - 'description': typesys.string(format='textarea'), - 'required': typesys.boolean(), - 'deprecated': typesys.boolean(), - 'allowEmptyValue': typesys.boolean(), - 'schema': JSONSchema, - 'example': typesys.Any + properties = dict_type([ + ('name', typesys.string()), + ('in', typesys.enum(enum=['query', 'header', 'path', 'cookie'])), + ('description', typesys.string(format='textarea')), + ('required', typesys.boolean()), + ('deprecated', typesys.boolean()), + ('allowEmptyValue', typesys.boolean()), + ('schema', JSONSchema), + ('example', typesys.Any) # TODO: Other fields - } + ]) required = ['name', 'in'] class Operation(typesys.Object): - properties = { - 'tags': typesys.array(items=typesys.string()), - 'summary': typesys.string(), - 'description': typesys.string(format='textarea'), - 'externalDocs': ExternalDocs, - 'operationId': typesys.string(), - 'parameters': typesys.array(items=Parameter), # Parameter | ReferenceObject + properties = dict_type([ + ('tags', typesys.array(items=typesys.string())), + ('summary', typesys.string()), + ('description', typesys.string(format='textarea')), + ('externalDocs', ExternalDocs), + ('operationId', typesys.string()), + ('parameters', typesys.array(items=Parameter)), # Parameter | ReferenceObject # TODO: 'requestBody' # TODO: 'responses' # TODO: 'callbacks' - 'deprecated': typesys.boolean(), - 'security': SecurityRequirement, - 'servers': typesys.array(items=Server) - } + ('deprecated', typesys.boolean()), + ('security', SecurityRequirement), + ('servers', typesys.array(items=Server)) + ]) class Path(typesys.Object): - properties = { - 'summary': typesys.string(), - 'description': typesys.string(format='textarea'), - 'get': Operation, - 'put': Operation, - 'post': Operation, - 'delete': Operation, - 'options': Operation, - 'head': Operation, - 'patch': Operation, - 'trace': Operation, - 'servers': typesys.array(items=Server), - 'parameters': typesys.array(items=Parameter) # TODO: Parameter | ReferenceObject - } + properties = dict_type([ + ('summary', typesys.string()), + ('description', typesys.string(format='textarea')), + ('get', Operation), + ('put', Operation), + ('post', Operation), + ('delete', Operation), + ('options', Operation), + ('head', Operation), + ('patch', Operation), + ('trace', Operation), + ('servers', typesys.array(items=Server)), + ('parameters', typesys.array(items=Parameter)) # TODO: Parameter | ReferenceObject + ]) class Paths(typesys.Object): @@ -116,23 +117,23 @@ class Paths(typesys.Object): class Tag(typesys.Object): - properties = { - 'name': typesys.string(), - 'description': typesys.string(format='textarea'), - 'externalDocs': ExternalDocs - } + properties = dict_type([ + ('name', typesys.string()), + ('description', typesys.string(format='textarea')), + ('externalDocs', ExternalDocs) + ]) required = ['name'] class OpenAPI(typesys.Object): - properties = { - 'openapi': typesys.string(), - 'info': Info, - 'servers': typesys.array(items=Server), - 'paths': Paths, + properties = dict_type([ + ('openapi', typesys.string()), + ('info', Info), + ('servers', typesys.array(items=Server)), + ('paths', Paths), # TODO: 'components': ..., - 'security': SecurityRequirement, - 'tags': typesys.array(items=Tag), - 'externalDocs': ExternalDocs - } + ('security', SecurityRequirement), + ('tags', typesys.array(items=Tag)), + ('externalDocs', ExternalDocs) + ]) required = ['openapi', 'info'] diff --git a/coreapi/typesys.py b/coreapi/typesys.py index befded6..8f66679 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -1,3 +1,4 @@ +from coreapi.compat import dict_type import re # from typing import Any, Dict, List, Optional, Tuple, Union, overload # noqa @@ -86,6 +87,7 @@ class NumericType(object): exclusive_minimum = False exclusive_maximum = False multiple_of = None # type: Union[float, int] + format = None def __new__(cls, value): try: @@ -182,7 +184,7 @@ def error(cls, code): raise ValidationError(message) # from None -class Object(dict): +class Object(dict_type): errors = { 'type': 'Must be an object.', 'invalid_key': 'Object keys must be strings.', @@ -196,6 +198,8 @@ class Object(dict): required = [] def __init__(self, value): + super(Object, self).__init__() + try: value = dict(value) except TypeError: diff --git a/tests/codecs/test_openapi.py b/tests/codecs/test_openapi.py index da82094..95ba951 100644 --- a/tests/codecs/test_openapi.py +++ b/tests/codecs/test_openapi.py @@ -11,8 +11,7 @@ def openapi_codec(): @pytest.fixture def petstore_schema(): - return ''' -openapi: "3.0.0" + return '''openapi: "3.0.0" info: version: 1.0.0 title: Swagger Petstore @@ -120,11 +119,54 @@ def petstore_schema(): type: integer format: int32 message: - type: string + type: string''' + + +@pytest.fixture +def minimal_petstore_schema(): + return '''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_openapi(openapi_codec, petstore_schema): +def test_decode_openapi(openapi_codec, petstore_schema): doc = openapi_codec.decode(petstore_schema) expected = Document( title='Swagger Petstore', @@ -168,3 +210,49 @@ def test_openapi(openapi_codec, petstore_schema): } ) 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 From 325d032aff95a6667fc7fcd9774f5f1c221278b1 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 14 Feb 2018 16:40:02 +0000 Subject: [PATCH 18/60] Include yaml in requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 4a7d2a8..651d676 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ itypes requests uritemplate +pyyaml # Testing requirements coverage From 5984a337c3e7e95db69b0da6dd903456641d33d3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 14 Feb 2018 16:45:03 +0000 Subject: [PATCH 19/60] Drop coreschema --- coreapi/codecs/corejson.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coreapi/codecs/corejson.py b/coreapi/codecs/corejson.py index 07345ec..cd1f000 100644 --- a/coreapi/codecs/corejson.py +++ b/coreapi/codecs/corejson.py @@ -6,7 +6,6 @@ from coreapi.compat import COMPACT_SEPARATORS, VERBOSE_SEPARATORS from coreapi.document import Document, Link, Object, Error, Field from coreapi.exceptions import ParseError -import coreschema import json @@ -57,7 +56,7 @@ def decode_schema_from_corejson(data): if type_id == 'enum': kwargs['enum'] = _get_list(data, 'enum') - schema_cls = TYPE_ID_TO_SCHEMA_CLASS.get(type_id, coreschema.Anything) + schema_cls = TYPE_ID_TO_SCHEMA_CLASS.get(type_id, typesys.Any) return type(schema_cls.__name__, (schema_cls,), kwargs) From 9444bc066aa865a8aa9dada5aa1d6675ae353eb1 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 14 Feb 2018 16:53:40 +0000 Subject: [PATCH 20/60] Preserve ordering of OpenAPI 'paths' across python versions --- coreapi/codecs/openapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index e819695..75d51be 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -193,7 +193,7 @@ def encode(self, document, **options): return yaml.dump(openapi, Dumper=CustomSafeDumper, default_flow_style=False) def get_paths(self, document): - paths = {} + paths = dict_type() for operation_id, link in document.links.items(): url = urlparse.urlparse(link.url) From b3fa5c2b89c0046c86e8f09af4e6bad4fadf958d Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 15 Feb 2018 15:42:27 +0000 Subject: [PATCH 21/60] Default to JSON-flavoured OpenAPI --- coreapi/__init__.py | 4 +- coreapi/codecs/openapi.py | 138 +++++++------ coreapi/compat.py | 31 +++ coreapi/document.py | 9 +- coreapi/typesys.py | 4 +- requirements.txt | 1 - tests/codecs/test_openapi.py | 386 +++++++++++++++++++++-------------- 7 files changed, 355 insertions(+), 218 deletions(-) diff --git a/coreapi/__init__.py b/coreapi/__init__.py index 21ac6a9..2bef8bb 100644 --- a/coreapi/__init__.py +++ b/coreapi/__init__.py @@ -1,12 +1,12 @@ # coding: utf-8 from coreapi import auth, codecs, exceptions, transports, typesys, utils from coreapi.client import Client -from coreapi.document import Document, Link, Object, Error, Field +from coreapi.document import Document, Link, Object, Error, Field, Array __version__ = '3.0.0' __all__ = [ - 'Document', 'Link', 'Object', 'Error', 'Field', + 'Document', 'Link', 'Object', 'Error', 'Field', 'Array', 'Client', 'auth', 'codecs', 'exceptions', 'transports', 'typesys', 'utils', ] diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index 75d51be..c4714b3 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -1,8 +1,9 @@ from coreapi.codecs import BaseCodec, JSONSchemaCodec -from coreapi.compat import dict_type, urlparse +from coreapi.compat import VERBOSE_SEPARATORS, dict_type, urlparse from coreapi.document import Document, Link, Field +from coreapi.exceptions import ParseError from coreapi.schemas import OpenAPI -import yaml +import json METHODS = [ @@ -10,64 +11,66 @@ ] -def represent_odict(dump, tag, mapping, flow_style=None): - """Like BaseRepresenter.represent_mapping, but does not issue the sort(). - """ - value = [] - node = yaml.MappingNode(tag, value, flow_style=flow_style) - if dump.alias_key is not None: - dump.represented_objects[dump.alias_key] = node - best_style = True - if hasattr(mapping, 'items'): - mapping = mapping.items() - for item_key, item_value in mapping: - node_key = dump.represent_data(item_key) - node_value = dump.represent_data(item_value) - if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style): - best_style = False - if not (isinstance(node_value, yaml.ScalarNode) and not node_value.style): - best_style = False - value.append((node_key, node_value)) - if flow_style is None: - if dump.default_flow_style is not None: - node.flow_style = dump.default_flow_style - else: - node.flow_style = best_style - return node - - -def represent_list(dump, tag, sequence, flow_style=None): - value = [] - node = yaml.SequenceNode(tag, value, flow_style=flow_style) - if dump.alias_key is not None: - dump.represented_objects[dump.alias_key] = node - best_style = True - for item in sequence: - node_item = dump.represent_data(item) - if not (isinstance(node_item, yaml.ScalarNode) and not node_item.style): - best_style = False - value.append(node_item) - if flow_style is None: - if dump.default_flow_style is not None: - node.flow_style = dump.default_flow_style - else: - node.flow_style = best_style - return node - - -class CustomSafeDumper(yaml.SafeDumper): - def increase_indent(self, flow=False, indentless=False): - return super(CustomSafeDumper, self).increase_indent(flow, False) - - -CustomSafeDumper.add_multi_representer( - dict_type, - lambda dumper, value: represent_odict(dumper, u'tag:yaml.org,2002:map', value) -) -CustomSafeDumper.add_multi_representer( - list, - lambda dumper, value: represent_list(dumper, u'tag:yaml.org,2002:seq', value) -) +# def represent_odict(dump, tag, mapping, flow_style=None): +# """Like BaseRepresenter.represent_mapping, but does not issue the sort(). +# """ +# value = [] +# node = yaml.MappingNode(tag, value, flow_style=flow_style) +# if dump.alias_key is not None: +# dump.represented_objects[dump.alias_key] = node +# best_style = True +# if hasattr(mapping, 'items'): +# mapping = mapping.items() +# for item_key, item_value in mapping: +# node_key = dump.represent_data(item_key) +# node_value = dump.represent_data(item_value) +# if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style): +# best_style = False +# if not (isinstance(node_value, yaml.ScalarNode) and not node_value.style): +# best_style = False +# value.append((node_key, node_value)) +# if flow_style is None: +# if dump.default_flow_style is not None: +# node.flow_style = dump.default_flow_style +# else: +# node.flow_style = best_style +# return node +# +# +# def represent_list(dump, tag, sequence, flow_style=None): +# value = [] +# node = yaml.SequenceNode(tag, value, flow_style=flow_style) +# if dump.alias_key is not None: +# dump.represented_objects[dump.alias_key] = node +# best_style = True +# for item in sequence: +# node_item = dump.represent_data(item) +# if not (isinstance(node_item, yaml.ScalarNode) and not node_item.style): +# best_style = False +# value.append(node_item) +# if flow_style is None: +# if dump.default_flow_style is not None: +# node.flow_style = dump.default_flow_style +# else: +# node.flow_style = best_style +# return node +# +# +# class CustomSafeDumper(yaml.SafeDumper): +# def increase_indent(self, flow=False, indentless=False): +# return super(CustomSafeDumper, self).increase_indent(flow, False) +# +# +# CustomSafeDumper.add_multi_representer( +# dict_type, +# lambda dumper, value: represent_odict(dumper, u'tag:yaml.org,2002:map', value) +# ) +# CustomSafeDumper.add_multi_representer( +# list, +# lambda dumper, value: represent_list(dumper, u'tag:yaml.org,2002:seq', value) +# ) +# +# return yaml.dump(openapi, Dumper=CustomSafeDumper, default_flow_style=False) def _relative_url(base_url, url): @@ -89,7 +92,11 @@ class OpenAPICodec(BaseCodec): format = 'openapi' def decode(self, bytestring, **options): - data = yaml.safe_load(bytestring) + try: + data = json.loads(bytestring.decode('utf-8')) + except ValueError as exc: + raise ParseError('Malformed JSON. %s' % exc) + openapi = OpenAPI(data) title = openapi.lookup(['info', 'title']) description = openapi.lookup(['info', 'description']) @@ -190,7 +197,14 @@ def encode(self, document, **options): }], 'paths': paths }) - return yaml.dump(openapi, Dumper=CustomSafeDumper, default_flow_style=False) + + kwargs = { + 'ensure_ascii': False, + 'indent': 4, + 'separators': VERBOSE_SEPARATORS + } + + return json.dumps(openapi, **kwargs) def get_paths(self, document): paths = dict_type() diff --git a/coreapi/compat.py b/coreapi/compat.py index c56cb44..d4fd043 100644 --- a/coreapi/compat.py +++ b/coreapi/compat.py @@ -41,6 +41,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') diff --git a/coreapi/document.py b/coreapi/document.py index f2773cc..70102fc 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -1,7 +1,7 @@ # coding: utf-8 from __future__ import unicode_literals from collections import OrderedDict -from coreapi.compat import string_types +from coreapi.compat import coreschema_to_typesys, string_types import itypes @@ -235,7 +235,7 @@ def __init__(self, name, required=False, location='', schema=None, description=N self.name = name self.required = required self.location = location - self.schema = schema + self.schema = coreschema_to_typesys(schema) self.description = description self.example = example @@ -296,3 +296,8 @@ def get_messages(self): elif isinstance(value, string_types): messages += [value] return messages + + +class Array(object): + def __init__(self): + assert False, 'Array is deprecated' diff --git a/coreapi/typesys.py b/coreapi/typesys.py index 8f66679..d1d139a 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -1,4 +1,4 @@ -from coreapi.compat import dict_type +from coreapi.compat import dict_type, string_types import re # from typing import Any, Dict, List, Optional, Tuple, Union, overload # noqa @@ -207,7 +207,7 @@ def __init__(self, value): # Ensure all property keys are strings. errors = {} - if any(not isinstance(key, str) for key in value.keys()): + if any(not isinstance(key, string_types) for key in value.keys()): self.error('invalid_key') # Properties diff --git a/requirements.txt b/requirements.txt index 651d676..4a7d2a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ itypes requests uritemplate -pyyaml # Testing requirements coverage diff --git a/tests/codecs/test_openapi.py b/tests/codecs/test_openapi.py index 95ba951..5ee1a93 100644 --- a/tests/codecs/test_openapi.py +++ b/tests/codecs/test_openapi.py @@ -11,159 +11,247 @@ def openapi_codec(): @pytest.fixture def petstore_schema(): - return '''openapi: "3.0.0" -info: - version: 1.0.0 - title: Swagger Petstore - license: - name: MIT -servers: - - url: http://petstore.swagger.io/v1 -paths: - /pets: - get: - summary: List all pets - operationId: listPets - tags: - - pets - parameters: - - name: limit - in: query - description: How many items to return at one time (max 100) - required: false - schema: - type: integer - format: int32 - responses: - '200': - description: An paged array of pets - headers: - x-next: - description: A link to the next page of responses - schema: - type: string - content: - application/json: - schema: - $ref: "#/components/schemas/Pets" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Create a pet - operationId: createPets - tags: - - pets - responses: - '201': - description: Null response - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /pets/{petId}: - get: - summary: Info for a specific pet - operationId: showPetById - tags: - - pets - parameters: - - name: petId - in: path - required: true - description: The id of the pet to retrieve - schema: - type: string - responses: - '200': - description: Expected response to a valid request - content: - application/json: - schema: - $ref: "#/components/schemas/Pets" - default: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" -components: - schemas: - Pet: - required: - - id - - name - properties: - id: - type: integer - format: int64 - name: - type: string - tag: - type: string - Pets: - type: array - items: - $ref: "#/components/schemas/Pet" - Error: - required: - - code - - message - properties: - code: - type: integer - format: int32 - message: - type: string''' + return '''{ + "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 '''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 -''' + return '''{ + "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): From 235f50e3fb89d2adedf96331b03bd0b6c0935cb5 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 15 Feb 2018 16:04:37 +0000 Subject: [PATCH 22/60] Schemas in tests should be bytestrings --- tests/codecs/test_openapi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/codecs/test_openapi.py b/tests/codecs/test_openapi.py index 5ee1a93..4a44e9c 100644 --- a/tests/codecs/test_openapi.py +++ b/tests/codecs/test_openapi.py @@ -11,7 +11,7 @@ def openapi_codec(): @pytest.fixture def petstore_schema(): - return '''{ + return b'''{ "openapi": "3.0.0", "info": { "title": "Swagger Petstore", @@ -190,7 +190,7 @@ def petstore_schema(): @pytest.fixture def minimal_petstore_schema(): - return '''{ + return b'''{ "openapi": "3.0.0", "info": { "title": "Swagger Petstore", From dc4edeae64c355a21508285b79e40a0b21029810 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 15 Feb 2018 16:26:30 +0000 Subject: [PATCH 23/60] Ensure OpenAPI.encode returns bytestrings --- coreapi/codecs/openapi.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index c4714b3..097fc7d 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -1,5 +1,5 @@ from coreapi.codecs import BaseCodec, JSONSchemaCodec -from coreapi.compat import VERBOSE_SEPARATORS, dict_type, urlparse +from coreapi.compat import VERBOSE_SEPARATORS, dict_type, force_bytes, urlparse from coreapi.document import Document, Link, Field from coreapi.exceptions import ParseError from coreapi.schemas import OpenAPI @@ -203,8 +203,7 @@ def encode(self, document, **options): 'indent': 4, 'separators': VERBOSE_SEPARATORS } - - return json.dumps(openapi, **kwargs) + return force_bytes(json.dumps(openapi, **kwargs)) def get_paths(self, document): paths = dict_type() From c85818c695cb6249c6f97fe5a0317e21107af8f7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 15 Feb 2018 16:57:14 +0000 Subject: [PATCH 24/60] dict is ordered in Python 3.6 --- coreapi/compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coreapi/compat.py b/coreapi/compat.py index d4fd043..0d3c46f 100644 --- a/coreapi/compat.py +++ b/coreapi/compat.py @@ -84,7 +84,7 @@ def force_text(string): return string -if sys.version_info < (3, 5): +if sys.version_info < (3, 6): dict_type = collections.OrderedDict else: dict_type = dict From 566abbe73915b7250456539a029f031804d22d10 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 15 Feb 2018 17:25:20 +0000 Subject: [PATCH 25/60] Force ordering of document using in test case --- tests/codecs/test_openapi.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/codecs/test_openapi.py b/tests/codecs/test_openapi.py index 4a44e9c..081d80b 100644 --- a/tests/codecs/test_openapi.py +++ b/tests/codecs/test_openapi.py @@ -1,5 +1,6 @@ from coreapi import typesys from coreapi.codecs import OpenAPICodec +from coreapi.compat import dict_type from coreapi.document import Document, Link, Field import pytest @@ -260,8 +261,8 @@ def test_decode_openapi(openapi_codec, petstore_schema): title='Swagger Petstore', url='http://petstore.swagger.io/v1', content={ - 'pets': { - 'listPets': Link( + 'pets': dict_type([ + ('listPets', Link( action='get', url='http://petstore.swagger.io/pets', title='List all pets', @@ -274,13 +275,13 @@ def test_decode_openapi(openapi_codec, petstore_schema): schema=typesys.integer(format='int32') ) ] - ), - 'createPets': Link( + )), + ('createPets', Link( action='post', url='http://petstore.swagger.io/pets', title='Create a pet' - ), - 'showPetById': Link( + )), + ('showPetById', Link( action='get', url='http://petstore.swagger.io/pets/{petId}', title='Info for a specific pet', @@ -293,8 +294,8 @@ def test_decode_openapi(openapi_codec, petstore_schema): schema=typesys.string() ) ] - ) - } + )) + ]) } ) assert doc == expected From 6530dc2b06eeffe3b7455f631aed624bf4e3ceb5 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 10:22:49 +0000 Subject: [PATCH 26/60] Enforced ordering in Object --- coreapi/typesys.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coreapi/typesys.py b/coreapi/typesys.py index d1d139a..721f9d7 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -201,7 +201,7 @@ def __init__(self, value): super(Object, self).__init__() try: - value = dict(value) + value = dict_type(value) except TypeError: self.error('type') From 09518cd567b142e9bb20bedd547f5b34ab3fa3df Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 10:23:17 +0000 Subject: [PATCH 27/60] Drop commented-out YAML code --- coreapi/codecs/openapi.py | 62 --------------------------------------- 1 file changed, 62 deletions(-) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index 097fc7d..9aaa551 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -11,68 +11,6 @@ ] -# def represent_odict(dump, tag, mapping, flow_style=None): -# """Like BaseRepresenter.represent_mapping, but does not issue the sort(). -# """ -# value = [] -# node = yaml.MappingNode(tag, value, flow_style=flow_style) -# if dump.alias_key is not None: -# dump.represented_objects[dump.alias_key] = node -# best_style = True -# if hasattr(mapping, 'items'): -# mapping = mapping.items() -# for item_key, item_value in mapping: -# node_key = dump.represent_data(item_key) -# node_value = dump.represent_data(item_value) -# if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style): -# best_style = False -# if not (isinstance(node_value, yaml.ScalarNode) and not node_value.style): -# best_style = False -# value.append((node_key, node_value)) -# if flow_style is None: -# if dump.default_flow_style is not None: -# node.flow_style = dump.default_flow_style -# else: -# node.flow_style = best_style -# return node -# -# -# def represent_list(dump, tag, sequence, flow_style=None): -# value = [] -# node = yaml.SequenceNode(tag, value, flow_style=flow_style) -# if dump.alias_key is not None: -# dump.represented_objects[dump.alias_key] = node -# best_style = True -# for item in sequence: -# node_item = dump.represent_data(item) -# if not (isinstance(node_item, yaml.ScalarNode) and not node_item.style): -# best_style = False -# value.append(node_item) -# if flow_style is None: -# if dump.default_flow_style is not None: -# node.flow_style = dump.default_flow_style -# else: -# node.flow_style = best_style -# return node -# -# -# class CustomSafeDumper(yaml.SafeDumper): -# def increase_indent(self, flow=False, indentless=False): -# return super(CustomSafeDumper, self).increase_indent(flow, False) -# -# -# CustomSafeDumper.add_multi_representer( -# dict_type, -# lambda dumper, value: represent_odict(dumper, u'tag:yaml.org,2002:map', value) -# ) -# CustomSafeDumper.add_multi_representer( -# list, -# lambda dumper, value: represent_list(dumper, u'tag:yaml.org,2002:seq', value) -# ) -# -# return yaml.dump(openapi, Dumper=CustomSafeDumper, default_flow_style=False) - - def _relative_url(base_url, url): """ Return a graceful link for a URL relative to a base URL. From 73c50154615b64ea9f8cc5d446165ad83039ea07 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 11:33:41 +0000 Subject: [PATCH 28/60] Drop itypes from transports/codecs/client --- coreapi/client.py | 7 +++---- coreapi/codecs/base.py | 5 +---- coreapi/transports/base.py | 3 +-- coreapi/transports/http.py | 3 +-- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/coreapi/client.py b/coreapi/client.py index ece3d27..027bf33 100644 --- a/coreapi/client.py +++ b/coreapi/client.py @@ -2,7 +2,6 @@ from coreapi.compat import string_types from coreapi.document import Link from coreapi.utils import determine_transport, get_installed_codecs -import itypes def _lookup_link(document, keys): @@ -87,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'. " @@ -98,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): diff --git a/coreapi/codecs/base.py b/coreapi/codecs/base.py index 6f20044..dda33be 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 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 e6f5256..cbe368e 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -8,7 +8,6 @@ from coreapi.utils import guess_filename, is_file, File import collections import requests -import itypes import mimetypes import uritemplate @@ -271,7 +270,7 @@ def __init__(self, headers=None, auth=None, session=None): if not getattr(session.auth, 'allow_cookies', False): session.cookies.set_policy(BlockAll()) - self._headers = itypes.Dict(headers or {}) + self._headers = {} if (headers is None) else dict(headers) self._session = session @property From 2d6e0bcff7b9fb651e521a7e1987d4074c84ea52 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 11:37:24 +0000 Subject: [PATCH 29/60] Drop BaseCodec dump/load/supports --- coreapi/codecs/base.py | 22 ---------------------- coreapi/transports/http.py | 2 +- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/coreapi/codecs/base.py b/coreapi/codecs/base.py index dda33be..d041cb3 100644 --- a/coreapi/codecs/base.py +++ b/coreapi/codecs/base.py @@ -11,28 +11,6 @@ class BaseCodec(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/transports/http.py b/coreapi/transports/http.py index cbe368e..4e0ccc0 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -243,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 From 48784f47990d63c75de28387e7b310e3d69b8a7e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 13:35:23 +0000 Subject: [PATCH 30/60] Add document.version --- coreapi/codecs/openapi.py | 5 +++-- coreapi/document.py | 9 ++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index 9aaa551..044ae78 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -38,9 +38,10 @@ def decode(self, bytestring, **options): openapi = OpenAPI(data) title = openapi.lookup(['info', 'title']) description = openapi.lookup(['info', 'description']) + version = openapi.lookup(['info', 'version']) base_url = openapi.lookup(['servers', 0, 'url']) content = self.get_links(openapi, base_url) - return Document(title=title, description=description, url=base_url, content=content) + return Document(title=title, description=description, version=version, url=base_url, content=content) def get_links(self, openapi, base_url): """ @@ -126,7 +127,7 @@ def encode(self, document, **options): openapi = OpenAPI({ 'openapi': '3.0.0', 'info': { - 'version': '', + 'version': document.version, 'title': document.title, 'description': document.description }, diff --git a/coreapi/document.py b/coreapi/document.py index 70102fc..f6467a2 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -49,7 +49,7 @@ class Document(itypes.Dict): Expresses the data that the client may access, and the actions that the client may perform. """ - def __init__(self, url=None, title=None, description=None, media_type=None, content=None): + def __init__(self, url=None, title=None, description=None, version=None, media_type=None, content=None): content = {} if (content is None) else content if url is not None and not isinstance(url, string_types): @@ -58,6 +58,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): @@ -68,6 +70,7 @@ 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()} @@ -102,6 +105,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 From 5e19106fd085cb5bc961af0467fcb3ce7e98f85f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 13:47:42 +0000 Subject: [PATCH 31/60] Drop itypes from Error --- coreapi/document.py | 10 ++++++++-- tests/test_document.py | 8 -------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/coreapi/document.py b/coreapi/document.py index f6467a2..c07610e 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -1,6 +1,6 @@ # coding: utf-8 from __future__ import unicode_literals -from collections import OrderedDict +from collections import Mapping, OrderedDict from coreapi.compat import coreschema_to_typesys, string_types import itypes @@ -258,7 +258,7 @@ def __eq__(self, other): ) -class Error(itypes.Dict): +class Error(Mapping): def __init__(self, title=None, content=None): data = {} if (content is None) else content @@ -276,6 +276,12 @@ 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) diff --git a/tests/test_document.py b/tests/test_document.py index 6b10f97..1da002e 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -97,14 +97,6 @@ def test_link_does_not_support_property_assignment(): 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): From d1c006f02803f7db33ab154fd3d3cbc39ce2ae7c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 13:48:44 +0000 Subject: [PATCH 32/60] Drop itypes from Link --- coreapi/document.py | 2 +- tests/test_document.py | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/coreapi/document.py b/coreapi/document.py index c07610e..e6a59da 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -163,7 +163,7 @@ def links(self): ]) -class Link(itypes.Object): +class Link(object): """ Links represent the actions that a client may perform. """ diff --git a/tests/test_document.py b/tests/test_document.py index 1da002e..5decd84 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -89,14 +89,6 @@ def test_object_does_not_support_key_deletion(obj): del obj['key'] -# Links are immutable. - -def test_link_does_not_support_property_assignment(): - link = Link() - with pytest.raises(TypeError): - link.integer = 456 - - # Children in documents are immutable primitives. def test_document_dictionaries_coerced_to_objects(doc): From b52553e0ed47eabdd4a6a9948be097d2467df0f3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 13:52:30 +0000 Subject: [PATCH 33/60] Drop itypes --- coreapi/document.py | 17 ++++++++++++++--- requirements.txt | 1 - setup.py | 1 - tests/test_document.py | 10 ---------- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/coreapi/document.py b/coreapi/document.py index e6a59da..ba54c07 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -2,7 +2,6 @@ from __future__ import unicode_literals from collections import Mapping, OrderedDict from coreapi.compat import coreschema_to_typesys, string_types -import itypes def _to_immutable(value): @@ -42,7 +41,7 @@ def _key_sorting(item): # The Core API primitives: -class Document(itypes.Dict): +class Document(Mapping): """ The Core API document type. @@ -78,6 +77,12 @@ 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) @@ -128,7 +133,7 @@ def links(self): ]) -class Object(itypes.Dict): +class Object(Mapping): """ An immutable mapping of strings to values. """ @@ -142,6 +147,12 @@ 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) diff --git a/requirements.txt b/requirements.txt index 4a7d2a8..401ec84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ # Package requirements -itypes requests uritemplate diff --git a/setup.py b/setup.py index 2fa32a5..dcfde78 100755 --- a/setup.py +++ b/setup.py @@ -64,7 +64,6 @@ def get_package_data(package): package_data=get_package_data('coreapi'), install_requires=[ 'requests', - 'itypes', 'uritemplate' ], entry_points={ diff --git a/tests/test_document.py b/tests/test_document.py index 5decd84..b9c7743 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -62,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'] @@ -79,11 +74,6 @@ 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'] From b1e34183cd0167292278b0d6a7e5e7922d9e58d4 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 15:12:07 +0000 Subject: [PATCH 34/60] Drop client.reload --- coreapi/client.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/coreapi/client.py b/coreapi/client.py index 027bf33..0432c2d 100644 --- a/coreapi/client.py +++ b/coreapi/client.py @@ -126,10 +126,6 @@ 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): if isinstance(keys, string_types): keys = [keys] From 7b0cf7d28218153c4ed6099843b269c0a3ddc868 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 15:12:36 +0000 Subject: [PATCH 35/60] _to_immutable -> _to_objects --- coreapi/document.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/coreapi/document.py b/coreapi/document.py index ba54c07..f5e792a 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -4,7 +4,7 @@ from coreapi.compat import coreschema_to_typesys, string_types -def _to_immutable(value): +def _to_objects(value): if isinstance(value, dict): return Object(value) return value @@ -71,7 +71,7 @@ def __init__(self, url=None, title=None, description=None, version=None, media_t 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()} + self._data = {key: _to_objects(value) for key, value in content.items()} def __iter__(self): items = sorted(self._data.items(), key=_key_sorting) @@ -141,7 +141,7 @@ 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) @@ -281,7 +281,7 @@ 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) From e663b9f1236fd58895b8a9e8b334a17f3ebc675f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 15:18:11 +0000 Subject: [PATCH 36/60] Drop reload from tests --- tests/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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} From f09ca743cfa3a15e8b309e3812e1184c202579de Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 15:27:43 +0000 Subject: [PATCH 37/60] 'enum' defined on types --- coreapi/codecs/corejson.py | 6 +++-- coreapi/schemas/jsonschema.py | 1 + coreapi/schemas/openapi.py | 4 ++-- coreapi/typesys.py | 42 +++++++++++++---------------------- tests/test_codecs.py | 6 ++--- 5 files changed, 26 insertions(+), 33 deletions(-) diff --git a/coreapi/codecs/corejson.py b/coreapi/codecs/corejson.py index cd1f000..b21c183 100644 --- a/coreapi/codecs/corejson.py +++ b/coreapi/codecs/corejson.py @@ -19,7 +19,6 @@ typesys.Integer: 'integer', typesys.String: 'string', typesys.Boolean: 'boolean', - typesys.Enum: 'enum', typesys.Any: 'anything' } @@ -42,7 +41,7 @@ def encode_schema_to_corejson(schema): '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 @@ -54,7 +53,10 @@ def decode_schema_from_corejson(data): 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, typesys.Any) return type(schema_cls.__name__, (schema_cls,), kwargs) diff --git a/coreapi/schemas/jsonschema.py b/coreapi/schemas/jsonschema.py index 1dacfc9..959eccf 100644 --- a/coreapi/schemas/jsonschema.py +++ b/coreapi/schemas/jsonschema.py @@ -6,6 +6,7 @@ class JSONSchema(typesys.Object): properties = dict_type([ ('$ref', typesys.string()), ('type', typesys.string()), + ('enum', typesys.Any), # String ('minLength', typesys.integer(minimum=0, default=0)), diff --git a/coreapi/schemas/openapi.py b/coreapi/schemas/openapi.py index 3940434..3eb22ed 100644 --- a/coreapi/schemas/openapi.py +++ b/coreapi/schemas/openapi.py @@ -64,7 +64,7 @@ class SecurityRequirement(typesys.Object): class Parameter(typesys.Object): properties = dict_type([ ('name', typesys.string()), - ('in', typesys.enum(enum=['query', 'header', 'path', 'cookie'])), + ('in', typesys.string(enum=['query', 'header', 'path', 'cookie'])), ('description', typesys.string(format='textarea')), ('required', typesys.boolean()), ('deprecated', typesys.boolean()), @@ -83,7 +83,7 @@ class Operation(typesys.Object): ('description', typesys.string(format='textarea')), ('externalDocs', ExternalDocs), ('operationId', typesys.string()), - ('parameters', typesys.array(items=Parameter)), # Parameter | ReferenceObject + ('parameters', typesys.array(items=Parameter)), # TODO: Parameter | ReferenceObject # TODO: 'requestBody' # TODO: 'responses' # TODO: 'callbacks' diff --git a/coreapi/typesys.py b/coreapi/typesys.py index 721f9d7..6958816 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -29,6 +29,8 @@ class String(str): '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}.' } title = None # type: str description = None # type: str @@ -36,6 +38,7 @@ class String(str): min_length = None # type: int pattern = None # type: str format = None # type: Any + enum = None # type: List[str] trim_whitespace = True def __new__(cls, value): @@ -44,6 +47,12 @@ def __new__(cls, value): if cls.trim_whitespace: value = value.strip() + if cls.enum is not None: + if value not in cls.enum: + if len(cls.enum) == 1: + cls.error('exact') + cls.error('enum') + if cls.min_length is not None: if len(value) < cls.min_length: if cls.min_length == 1: @@ -82,6 +91,7 @@ class NumericType(object): } title = None # type: str description = None # type: str + enum = None # type: List[Union[float, int]] minimum = None # type: Union[float, int] maximum = None # type: Union[float, int] exclusive_minimum = False @@ -95,6 +105,12 @@ def __new__(cls, value): except (TypeError, ValueError): cls.error('type') + if cls.enum is not None: + if value not in cls.enum: + if len(cls.enum) == 1: + cls.error('exact') + cls.error('enum') + if cls.minimum is not None: if cls.exclusive_minimum: if value <= cls.minimum: @@ -162,28 +178,6 @@ def error(cls, code): raise ValidationError(message) # from None -class Enum(str): - errors = { - 'enum': 'Must be a valid choice.', - 'exact': 'Must be {exact}.' - } - title = None # type: str - description = None # type: str - enum = [] # type: List[str] - - def __new__(cls, value): - if value not in cls.enum: - if len(cls.enum) == 1: - cls.error('exact') - cls.error('enum') - return value - - @classmethod - def error(cls, code): - message = cls.errors[code].format(**cls.__dict__) - raise ValidationError(message) # from None - - class Object(dict_type): errors = { 'type': 'Must be an object.', @@ -369,10 +363,6 @@ def boolean(**kwargs): return type('Boolean', (Boolean,), kwargs) -def enum(**kwargs): - return type('Enum', (Enum,), kwargs) - - def array(**kwargs): return type('Array', (Array,), kwargs) diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 170ef69..5d2f72d 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -27,7 +27,7 @@ def doc(): fields=[ Field(name='noschema'), Field(name='string_example', schema=typesys.string()), - Field(name='enum_example', schema=typesys.enum(enum=['a', 'b', 'c'])), + 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'], From 93482e9eb08d3d8ced7302fa752f24870ff0ddd0 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 16:27:06 +0000 Subject: [PATCH 38/60] Add openapi codec to setup --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index dcfde78..575653a 100755 --- a/setup.py +++ b/setup.py @@ -69,6 +69,7 @@ def get_package_data(package): entry_points={ 'coreapi.codecs': [ 'corejson=coreapi.codecs:CoreJSONCodec', + 'openapi=coreapi.codecs:OpenAPICodec', 'json=coreapi.codecs:JSONCodec', 'text=coreapi.codecs:TextCodec', 'download=coreapi.codecs:DownloadCodec', From 54199e3ee80145e6be2e305f91754e1d33655178 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 16:27:26 +0000 Subject: [PATCH 39/60] Add Field.title --- coreapi/document.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/coreapi/document.py b/coreapi/document.py index f5e792a..89852c6 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -249,12 +249,13 @@ def __str__(self): class Field(object): - def __init__(self, name, required=False, location='', schema=None, description=None, example=None): + def __init__(self, name, title='', description='', required=False, location='', schema=None, example=None): self.name = name - self.required = required + self.title = title + self.description = description self.location = location + self.required = required self.schema = coreschema_to_typesys(schema) - self.description = description self.example = example def __eq__(self, other): From 8c1748ea147c652f8bf47131524735034c10c3c2 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 20 Feb 2018 16:47:54 +0000 Subject: [PATCH 40/60] Use Link.method. Put .action towards deprecation. --- coreapi/client.py | 7 ++++--- coreapi/codecs/corejson.py | 2 +- coreapi/codecs/openapi.py | 2 +- coreapi/codecs/python.py | 4 ++-- coreapi/document.py | 30 +++++++++++++++++++----------- coreapi/transports/http.py | 8 ++++---- tests/test_document.py | 14 +++++++------- 7 files changed, 38 insertions(+), 29 deletions(-) diff --git a/coreapi/client.py b/coreapi/client.py index 0432c2d..9a99c8f 100644 --- a/coreapi/client.py +++ b/coreapi/client.py @@ -109,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: @@ -141,10 +141,11 @@ def action(self, document, keys, params=None, validate=True, overrides=None): 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) fields = overrides.get('fields', link.fields) - link = Link(url, action=action, encoding=encoding, 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) diff --git a/coreapi/codecs/corejson.py b/coreapi/codecs/corejson.py index b21c183..dcf2874 100644 --- a/coreapi/codecs/corejson.py +++ b/coreapi/codecs/corejson.py @@ -275,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, + url=url, method=action, encoding=encoding, title=title, description=description, fields=fields ) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index 044ae78..4bdd15f 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -93,7 +93,7 @@ def get_link(self, base_url, path, path_info, operation, operation_info): return Link( url=urlparse.urljoin(base_url, path), - action=operation, + method=operation, title=title, description=description, fields=fields diff --git a/coreapi/codecs/python.py b/coreapi/codecs/python.py index c1e34ac..0a1137a 100644 --- a/coreapi/codecs/python.py +++ b/coreapi/codecs/python.py @@ -33,8 +33,8 @@ def _to_repr(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.description: diff --git a/coreapi/document.py b/coreapi/document.py index 89852c6..4dd537e 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -24,18 +24,18 @@ 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) @@ -178,11 +178,14 @@ class Link(object): """ Links represent the actions that a client may perform. """ - def __init__(self, url=None, action=None, encoding=None, title=None, description=None, fields=None): + def __init__(self, url=None, method=None, encoding=None, title=None, description=None, fields=None, action=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 (title is not None) and (not isinstance(title, string_types)): @@ -198,7 +201,7 @@ def __init__(self, url=None, action=None, encoding=None, title=None, description raise TypeError("Argument 'fields' must be a list of strings or fields.") 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._title = '' if (title is None) else title self._description = '' if (description is None) else description @@ -212,8 +215,8 @@ def url(self): return self._url @property - def action(self): - return self._action + def method(self): + return self._method @property def encoding(self): @@ -231,11 +234,16 @@ def description(self): def fields(self): return self._fields + @property + def action(self): + # Deprecated + return self._method + 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.description == other.description and sorted(self.fields, key=lambda f: f.name) == sorted(other.fields, key=lambda f: f.name) diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index 4e0ccc0..5d6b830 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -39,10 +39,10 @@ class BlockAll(cookiejar.CookiePolicy): rfc2965 = hide_cookie2 = False -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): @@ -279,7 +279,7 @@ def headers(self): 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) diff --git a/tests/test_document.py b/tests/test_document.py index b9c7743..aced4d6 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -15,7 +15,7 @@ def doc(): 'dict': {'key': 'value'}, 'link': Link( url='/', - action='post', + method='post', fields=['optional', Field('required', required=True, location='path')] ), 'nested': {'child': Link(url='/123')} @@ -31,7 +31,7 @@ def obj(): def link(): return Link( url='/', - action='post', + method='post', fields=[Field('required', required=True), 'optional'] ) @@ -93,7 +93,7 @@ def test_document_repr(doc): "'dict': {'key': 'value'}, " "'integer': 123, " "'nested': {'child': Link(url='/123')}, " - "'link': Link(url='/', action='post', " + "'link': Link(url='/', method='post', " "fields=['optional', Field('required', required=True, location='path')])" "})" ) @@ -106,7 +106,7 @@ def test_object_repr(obj): 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 @@ -184,7 +184,7 @@ def test_document_equality(doc): 'dict': {'key': 'value'}, 'link': Link( url='/', - action='post', + method='post', fields=['optional', Field('required', required=True, location='path')] ), 'nested': {'child': Link(url='/123')} @@ -254,9 +254,9 @@ def test_link_url_must_be_string(): Link(url=123) -def test_link_action_must_be_string(): +def test_link_mthod_must_be_string(): with pytest.raises(TypeError): - Link(action=123) + Link(method=123) def test_link_fields_must_be_list(): From ae79ef584f39f714a92cd0ca360afe2cce7c9215 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 21 Feb 2018 10:56:05 +0000 Subject: [PATCH 41/60] Plain old classes for typesys --- coreapi/codecs/corejson.py | 4 +- coreapi/codecs/jsonschema.py | 117 ++++++------ coreapi/codecs/openapi.py | 27 ++- coreapi/schemas/jsonschema.py | 46 ++--- coreapi/schemas/openapi.py | 208 +++++++++++---------- coreapi/typesys.py | 329 ++++++++++++++-------------------- tests/codecs/test_openapi.py | 8 +- tests/test_codecs.py | 4 +- 8 files changed, 349 insertions(+), 394 deletions(-) diff --git a/coreapi/codecs/corejson.py b/coreapi/codecs/corejson.py index dcf2874..da089da 100644 --- a/coreapi/codecs/corejson.py +++ b/coreapi/codecs/corejson.py @@ -31,7 +31,7 @@ def encode_schema_to_corejson(schema): for cls, type_id in SCHEMA_CLASS_TO_TYPE_ID.items(): - if issubclass(schema, cls): + if isinstance(schema, cls): break else: type_id = 'anything' @@ -59,7 +59,7 @@ def decode_schema_from_corejson(data): kwargs['enum'] = data['enum'] schema_cls = TYPE_ID_TO_SCHEMA_CLASS.get(type_id, typesys.Any) - return type(schema_cls.__name__, (schema_cls,), kwargs) + return schema_cls(**kwargs) # Robust dictionary lookups, that always return an item of the correct diff --git a/coreapi/codecs/jsonschema.py b/coreapi/codecs/jsonschema.py index cd2506e..945944a 100644 --- a/coreapi/codecs/jsonschema.py +++ b/coreapi/codecs/jsonschema.py @@ -18,17 +18,17 @@ def decode(self, bytestring, **options): ) except ValueError as exc: raise ParseError('Malformed JSON. %s' % exc) - jsonschema = JSONSchema(data) + jsonschema = JSONSchema.validate(data) return self.decode_from_data_structure(jsonschema) def decode_from_data_structure(self, struct): attrs = {} - if '$ref' in struct: - if struct['$ref'] == '#': - return typesys.ref() - name = struct['$ref'].split('/')[-1] - return typesys.ref(name) + # if '$ref' in struct: + # if struct['$ref'] == '#': + # return typesys.ref() + # name = struct['$ref'].split('/')[-1] + # return typesys.ref(name) if struct['type'] == 'string': if 'minLength' in struct: @@ -39,7 +39,7 @@ def decode_from_data_structure(self, struct): attrs['pattern'] = struct['pattern'] if 'format' in struct: attrs['format'] = struct['format'] - return typesys.string(**attrs) + return typesys.String(**attrs) if struct['type'] in ['number', 'integer']: if 'minimum' in struct: @@ -55,11 +55,11 @@ def decode_from_data_structure(self, struct): if 'format' in struct: attrs['format'] = struct['format'] if struct['type'] == 'integer': - return typesys.integer(**attrs) - return typesys.number(**attrs) + return typesys.Integer(**attrs) + return typesys.Number(**attrs) if struct['type'] == 'boolean': - return typesys.boolean() + return typesys.Boolean() if struct['type'] == 'object': if 'properties' in struct: @@ -69,7 +69,7 @@ def decode_from_data_structure(self, struct): } if 'required' in struct: attrs['required'] = struct['required'] - return typesys.obj(**attrs) + return typesys.Object(**attrs) if struct['type'] == 'array': if 'items' in struct: @@ -82,10 +82,10 @@ def decode_from_data_structure(self, struct): attrs['max_items'] = struct['maxItems'] if 'uniqueItems' in struct: attrs['unique_items'] = struct['uniqueItems'] - return typesys.array(**attrs) + return typesys.Array(**attrs) - def encode(self, cls, **options): - struct = self.encode_to_data_structure(cls) + def encode(self, item, **options): + struct = self.encode_to_data_structure(item) indent = options.get('indent') if indent: kwargs = { @@ -101,70 +101,65 @@ def encode(self, cls, **options): } return force_bytes(json.dumps(struct, **kwargs)) - def encode_to_data_structure(self, cls): - if issubclass(cls, typesys.String): + def encode_to_data_structure(self, item): + if isinstance(item, typesys.String): value = {'type': 'string'} - if cls.max_length is not None: - value['maxLength'] = cls.max_length - if cls.min_length is not None: - value['minLength'] = cls.min_length - if cls.pattern is not None: - value['pattern'] = cls.pattern - if cls.format is not None: - value['format'] = cls.format + 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 issubclass(cls, typesys.NumericType): - if issubclass(cls, typesys.Integer): + if isinstance(item, typesys.NumericType): + if isinstance(item, typesys.Integer): value = {'type': 'integer'} else: value = {'type': 'number'} - if cls.minimum is not None: - value['minimum'] = cls.minimum - if cls.maximum is not None: - value['maximum'] = cls.maximum - if cls.exclusive_minimum: - value['exclusiveMinimum'] = cls.exclusive_minimum - if cls.exclusive_maximum: - value['exclusiveMaximum'] = cls.exclusive_maximum - if cls.multiple_of is not None: - value['multipleOf'] = cls.multiple_of - if cls.format is not None: - value['format'] = cls.format + 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 issubclass(cls, typesys.Boolean): + if isinstance(item, typesys.Boolean): return {'type': 'boolean'} - if issubclass(cls, typesys.Object): + if isinstance(item, typesys.Object): value = {'type': 'object'} - if cls.properties: + if item.properties: value['properties'] = { key: self.encode_to_data_structure(value) - for key, value in cls.properties.items() + for key, value in item.properties.items() } - if cls.required: - value['required'] = cls.required + if item.required: + value['required'] = item.required return value - if issubclass(cls, typesys.Array): + if isinstance(item, typesys.Array): value = {'type': 'array'} - if cls.items is not None: - value['items'] = self.encode_to_data_structure(cls.items) - if cls.additional_items: - value['additionalItems'] = cls.additional_items - if cls.min_items is not None: - value['minItems'] = cls.min_items - if cls.max_items is not None: - value['maxItems'] = cls.max_items - if cls.unique_items is not None: - value['uniqueItems'] = cls.unique_items + 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 - if issubclass(cls, typesys.Ref): - if not cls.to: - return {'$ref': '#'} - return {'$ref': '#/definitions/%s' % cls.to} - - raise Exception('Cannot encode class %s' % cls) + raise Exception('Cannot encode item %s' % item) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index 4bdd15f..ee360f4 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -11,6 +11,15 @@ ] +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. @@ -35,11 +44,11 @@ def decode(self, bytestring, **options): except ValueError as exc: raise ParseError('Malformed JSON. %s' % exc) - openapi = OpenAPI(data) - title = openapi.lookup(['info', 'title']) - description = openapi.lookup(['info', 'description']) - version = openapi.lookup(['info', 'version']) - base_url = openapi.lookup(['servers', 0, 'url']) + 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']) content = self.get_links(openapi, base_url) return Document(title=title, description=description, version=version, url=base_url, content=content) @@ -56,7 +65,7 @@ def get_links(self, openapi, base_url): } for operation, operation_info in operations.items(): operationId = operation_info.get('operationId') - tag = operation_info.lookup(['tags', 0]) + tag = lookup(operation_info, ['tags', 0]) if not operationId: continue @@ -79,8 +88,8 @@ def get_link(self, base_url, path, path_info, operation, operation_info): description = operation_info.get('description') # Allow path info and operation info to override the base url. - base_url = path_info.lookup(['servers', 0, 'url'], default=base_url) - base_url = operation_info.lookup(['servers', 0, 'url'], default=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', []) @@ -124,7 +133,7 @@ def get_field(self, parameter): def encode(self, document, **options): paths = self.get_paths(document) - openapi = OpenAPI({ + openapi = OpenAPI.validate({ 'openapi': '3.0.0', 'info': { 'version': document.version, diff --git a/coreapi/schemas/jsonschema.py b/coreapi/schemas/jsonschema.py index 959eccf..84b3bb5 100644 --- a/coreapi/schemas/jsonschema.py +++ b/coreapi/schemas/jsonschema.py @@ -1,34 +1,34 @@ from coreapi import typesys -from coreapi.compat import dict_type -class JSONSchema(typesys.Object): - properties = dict_type([ - ('$ref', typesys.string()), - ('type', typesys.string()), - ('enum', typesys.Any), +JSONSchema = typesys.Object( + properties=[ + ('$ref', typesys.String()), + ('type', typesys.String()), + ('enum', typesys.Any()), # String - ('minLength', typesys.integer(minimum=0, default=0)), - ('maxLength', typesys.integer(minimum=0)), - ('pattern', typesys.string(format='regex')), - ('format', typesys.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)), + ('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.obj()), # TODO: typesys.ref('JSONSchema'), - ('required', typesys.array(items=typesys.string(), min_items=1, unique=True)), + ('properties', typesys.Object()), # TODO: typesys.ref('JSONSchema'), + ('required', typesys.Array(items=typesys.String(), min_items=1, unique_items=True)), # Array - ('items', typesys.obj()), # TODO: typesys.ref('JSONSchema'), - ('additionalItems', typesys.boolean()), - ('minItems', typesys.integer(minimum=0)), - ('maxItems', typesys.integer(minimum=0)), - ('uniqueItems', typesys.boolean()), - ]) + ('items', typesys.Object()), # TODO: typesys.ref('JSONSchema'), + ('additionalItems', typesys.Boolean()), + ('minItems', typesys.Integer(minimum=0)), + ('maxItems', typesys.Integer(minimum=0)), + ('uniqueItems', typesys.Boolean()), + ] +) diff --git a/coreapi/schemas/openapi.py b/coreapi/schemas/openapi.py index 3eb22ed..df5f76d 100644 --- a/coreapi/schemas/openapi.py +++ b/coreapi/schemas/openapi.py @@ -1,102 +1,110 @@ from coreapi import typesys -from coreapi.compat import dict_type from coreapi.schemas import JSONSchema -class Contact(typesys.Object): - properties = dict_type([ - ('name', typesys.string()), - ('url', typesys.string(format='url')), - ('email', typesys.string(format='email')) - ]) +Contact = typesys.Object( + properties=[ + ('name', typesys.String()), + ('url', typesys.String(format='url')), + ('email', typesys.String(format='email')) + ] +) -class License(typesys.Object): - properties = dict_type([ - ('name', typesys.string()), - ('url', typesys.string(format='url')) - ]) - required = ['name'] +License = typesys.Object( + properties=[ + ('name', typesys.String()), + ('url', typesys.String(format='url')) + ], + required=['name'] +) -class Info(typesys.Object): - properties = dict_type([ - ('title', typesys.string()), - ('description', typesys.string(format='textarea')), - ('termsOfService', typesys.string(format='url')), +Info = typesys.Object( + properties=[ + ('title', typesys.String()), + ('description', typesys.String(format='textarea')), + ('termsOfService', typesys.String(format='url')), ('contact', Contact), ('license', License), - ('version', typesys.string()) - ]) - required = ['title', 'version'] - - -class ServerVariable(typesys.Object): - properties = dict_type([ - ('enum', typesys.array(items=typesys.string())), - ('default', typesys.string()), - ('description', typesys.string(format='textarea')) - ]) - required = ['default'] - - -class Server(typesys.Object): - properties = dict_type([ - ('url', typesys.string()), - ('description', typesys.string(format='textarea')), - ('variables', typesys.obj(additional_properties=ServerVariable)) - ]) - required = ['url'] - - -class ExternalDocs(typesys.Object): - properties = dict_type([ - ('description', typesys.string(format='textarea')), - ('url', typesys.string(format='url')) - ]) - required = ['url'] - - -class SecurityRequirement(typesys.Object): - additional_properties = typesys.array(items=typesys.string()) - - -class Parameter(typesys.Object): - properties = dict_type([ - ('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()), + ('version', typesys.String()) + ], + required=['title', 'version'] +) + + +ServerVariable = typesys.Object( + properties=[ + ('enum', typesys.Array(items=typesys.String())), + ('default', typesys.String()), + ('description', typesys.String(format='textarea')) + ], + required=['default'] +) + + +Server = typesys.Object( + properties=[ + ('url', typesys.String()), + ('description', typesys.String(format='textarea')), + ('variables', typesys.Object(additional_properties=ServerVariable)) + ], + required=['url'] +) + + +ExternalDocs = typesys.Object( + properties=[ + ('description', typesys.String(format='textarea')), + ('url', typesys.String(format='url')) + ], + required=['url'] +) + + +SecurityRequirement = typesys.Object( + additional_properties=typesys.Array(items=typesys.String()) +) + + +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), - ('example', typesys.Any) + ('example', typesys.Any()) # TODO: Other fields - ]) - required = ['name', 'in'] + ], + required=['name', 'in'] +) -class Operation(typesys.Object): - properties = dict_type([ - ('tags', typesys.array(items=typesys.string())), - ('summary', typesys.string()), - ('description', typesys.string(format='textarea')), +Operation = typesys.Object( + properties=[ + ('tags', typesys.Array(items=typesys.String())), + ('summary', typesys.String()), + ('description', typesys.String(format='textarea')), ('externalDocs', ExternalDocs), - ('operationId', typesys.string()), - ('parameters', typesys.array(items=Parameter)), # TODO: Parameter | ReferenceObject + ('operationId', typesys.String()), + ('parameters', typesys.Array(items=Parameter)), # TODO: Parameter | ReferenceObject # TODO: 'requestBody' # TODO: 'responses' # TODO: 'callbacks' - ('deprecated', typesys.boolean()), + ('deprecated', typesys.Boolean()), ('security', SecurityRequirement), - ('servers', typesys.array(items=Server)) - ]) + ('servers', typesys.Array(items=Server)) + ] +) -class Path(typesys.Object): - properties = dict_type([ - ('summary', typesys.string()), - ('description', typesys.string(format='textarea')), +Path = typesys.Object( + properties=[ + ('summary', typesys.String()), + ('description', typesys.String(format='textarea')), ('get', Operation), ('put', Operation), ('post', Operation), @@ -105,35 +113,39 @@ class Path(typesys.Object): ('head', Operation), ('patch', Operation), ('trace', Operation), - ('servers', typesys.array(items=Server)), - ('parameters', typesys.array(items=Parameter)) # TODO: Parameter | ReferenceObject - ]) + ('servers', typesys.Array(items=Server)), + ('parameters', typesys.Array(items=Parameter)) # TODO: Parameter | ReferenceObject + ] +) -class Paths(typesys.Object): - pattern_properties = { - '^/': Path # TODO: Path | ReferenceObject - } +Paths = typesys.Object( + pattern_properties=[ + ('^/', Path) # TODO: Path | ReferenceObject + ] +) -class Tag(typesys.Object): - properties = dict_type([ - ('name', typesys.string()), - ('description', typesys.string(format='textarea')), +Tag = typesys.Object( + properties=[ + ('name', typesys.String()), + ('description', typesys.String(format='textarea')), ('externalDocs', ExternalDocs) - ]) - required = ['name'] + ], + required=['name'] +) -class OpenAPI(typesys.Object): - properties = dict_type([ - ('openapi', typesys.string()), +OpenAPI = typesys.Object( + properties=[ + ('openapi', typesys.String()), ('info', Info), - ('servers', typesys.array(items=Server)), + ('servers', typesys.Array(items=Server)), ('paths', Paths), # TODO: 'components': ..., ('security', SecurityRequirement), - ('tags', typesys.array(items=Tag)), + ('tags', typesys.Array(items=Tag)), ('externalDocs', ExternalDocs) - ]) - required = ['openapi', 'info'] + ], + required=['openapi', 'info'] +) diff --git a/coreapi/typesys.py b/coreapi/typesys.py index 6958816..0795b34 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -21,7 +21,27 @@ def __init__(self, detail): super(ValidationError, self).__init__(detail) -class String(str): +class Validator(object): + errors = {} + + def __init__(self, title='', description='', default=None, definitions=None): + self.title = title + self.description = description + self.default = default + self.definitions = {} if (definitions is None) else definitions + + def validate(value): + raise NotImplementedError() + + def error(self, code): + message = self.error_message(code) + raise ValidationError(message) + + def error_message(self, code): + return self.errors[code].format(**self.__dict__) + + +class String(Validator): errors = { 'type': 'Must be a string.', 'blank': 'Must not be blank.', @@ -32,51 +52,43 @@ class String(str): 'enum': 'Must be a valid choice.', 'exact': 'Must be {exact}.' } - title = None # type: str - description = None # type: str - max_length = None # type: int - min_length = None # type: int - pattern = None # type: str - format = None # type: Any - enum = None # type: List[str] - trim_whitespace = True - - def __new__(cls, value): - value = str.__new__(cls, value) - - if cls.trim_whitespace: - value = value.strip() - - if cls.enum is not None: - if value not in cls.enum: - if len(cls.enum) == 1: - cls.error('exact') - cls.error('enum') - - if cls.min_length is not None: - if len(value) < cls.min_length: - if cls.min_length == 1: - cls.error('blank') + + def __init__(self, max_length=None, min_length=None, pattern=None, enum=None, format=None, **kwargs): + super(String, self).__init__(**kwargs) + self.max_length = max_length + self.min_length = min_length + self.pattern = pattern + self.enum = enum + self.format = format + + def validate(self, value): + value = str(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.min_length is not None: + if len(value) < self.min_length: + if self.min_length == 1: + self.error('blank') else: - cls.error('min_length') + self.error('min_length') - if cls.max_length is not None: - if len(value) > cls.max_length: - cls.error('max_length') + if self.max_length is not None: + if len(value) > self.max_length: + self.error('max_length') - if cls.pattern is not None: - if not re.search(cls.pattern, value): - cls.error('pattern') + if self.pattern is not None: + if not re.search(self.pattern, value): + self.error('pattern') return value - @classmethod - def error(cls, code): - message = cls.errors[code].format(**cls.__dict__) - raise ValidationError(message) # from None - -class NumericType(object): +class NumericType(Validator): """ Base class for both `Number` and `Integer`. """ @@ -89,78 +101,73 @@ class NumericType(object): 'exclusive_maximum': 'Must be less than {maximum}.', 'multiple_of': 'Must be a multiple of {multiple_of}.', } - title = None # type: str - description = None # type: str - enum = None # type: List[Union[float, int]] - minimum = None # type: Union[float, int] - maximum = None # type: Union[float, int] - exclusive_minimum = False - exclusive_maximum = False - multiple_of = None # type: Union[float, int] - format = None - - def __new__(cls, value): + + def __init__(self, minimum=None, maximum=None, exclusive_minimum=False, exclusive_maximum=False, multiple_of=None, enum=None, format=None, **kwargs): + super(NumericType, self).__init__(**kwargs) + 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 + + def validate(self, value): try: - value = cls.numeric_type.__new__(cls, value) + value = self.numeric_type(value) except (TypeError, ValueError): - cls.error('type') - - if cls.enum is not None: - if value not in cls.enum: - if len(cls.enum) == 1: - cls.error('exact') - cls.error('enum') - - if cls.minimum is not None: - if cls.exclusive_minimum: - if value <= cls.minimum: - cls.error('exclusive_minimum') + 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.minimum is not None: + if self.exclusive_minimum: + if value <= self.minimum: + self.error('exclusive_minimum') else: - if value < cls.minimum: - cls.error('minimum') + if value < self.minimum: + self.error('minimum') - if cls.maximum is not None: - if cls.exclusive_maximum: - if value >= cls.maximum: - cls.error('exclusive_maximum') + if self.maximum is not None: + if self.exclusive_maximum: + if value >= self.maximum: + self.error('exclusive_maximum') else: - if value > cls.maximum: - cls.error('maximum') + if value > self.maximum: + self.error('maximum') - if cls.multiple_of is not None: - if isinstance(cls.multiple_of, float): - failed = not (value * (1 / cls.multiple_of)).is_integer() + 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: - failed = value % cls.multiple_of - if failed: - cls.error('multiple_of') + if value % self.multiple_of: + self.error('multiple_of') return value - @classmethod - def error(cls, code): - message = cls.errors[code].format(**cls.__dict__) - raise ValidationError(message) # from None - -class Number(NumericType, float): +class Number(NumericType): numeric_type = float -class Integer(NumericType, int): +class Integer(NumericType): numeric_type = int -class Boolean(object): - native_type = bool +class Boolean(Validator): errors = { 'type': 'Must be a valid boolean.' } - title = None # type: str - description = None # type: str - def __new__(cls, value): - if isinstance(value, str): + def validate(self, value): + if isinstance(value, (int, float, bool)): + return bool(value) + elif isinstance(value, str): try: return { 'true': True, @@ -169,31 +176,26 @@ def __new__(cls, value): '0': False }[value.lower()] except KeyError: - cls.error('type') - return bool(value) - - @classmethod - def error(cls, code): - message = cls.errors[code].format(**cls.__dict__) - raise ValidationError(message) # from None + pass + self.error('type') -class Object(dict_type): +class Object(Validator): errors = { 'type': 'Must be an object.', 'invalid_key': 'Object keys must be strings.', 'required': 'This field is required.', } - title = None # type: str - description = None # type: str - properties = {} # type: Dict[str, type] - pattern_properties = None # type: Dict[str, type] - additional_properties = None # type: type - required = [] - def __init__(self, value): - super(Object, self).__init__() + def __init__(self, properties=None, pattern_properties=None, additional_properties=None, required=None, **kwargs): + super(Object, self).__init__(**kwargs) + self.properties = {} if (properties is None) else dict_type(properties) + self.pattern_properties = {} if (pattern_properties is None) else dict_type(pattern_properties) + self.additional_properties = additional_properties + self.required = [] if (required is None) else required + def validate(self, value): + validated = dict_type() try: value = dict_type(value) except TypeError: @@ -212,23 +214,19 @@ def __init__(self, value): if key in self.required: errors[key] = self.error_message('required') else: - # Coerce value into the given schema type if needed. - if isinstance(item, child_schema): - self[key] = item - else: - try: - self[key] = child_schema(item) - except ValidationError as exc: - errors[key] = exc.detail + try: + validated[key] = child_schema.validate(item) + except ValidationError as exc: + errors[key] = exc.detail # Pattern properties - if self.pattern_properties is not None: + 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.pop(key) try: - self[key] = child_schema(item) + validated[key] = child_schema.validate(item) except ValidationError as exc: errors[key] = exc.detail @@ -238,49 +236,34 @@ def __init__(self, value): for key in list(value.keys()): item = value.pop(key) try: - self[key] = child_schema(item) + validated[key] = child_schema.validate(item) except ValidationError as exc: errors[key] = exc.detail if errors: raise ValidationError(errors) - def lookup(self, keys, default=None): - try: - item = self[keys[0]] - except KeyError: - return default - - if len(keys) == 1: - return item - return item.lookup(keys[1:], default) - - @classmethod - def error(cls, code): - message = cls.errors[code].format(**cls.__dict__) - raise ValidationError(message) # from None + return validated - @classmethod - def error_message(cls, code): - return cls.errors[code].format(**cls.__dict__) - -class Array(list): +class Array(Validator): errors = { 'type': 'Must be a list.', 'min_items': 'Not enough items.', 'max_items': 'Too many items.', 'unique_items': 'This item is not unique.', } - title = None # type: str - description = None # type: str - items = None # type: Union[type, List[type]] - additional_items = False # type: bool - min_items = None # type: Optional[int] - max_items = None # type: Optional[int] - unique_items = False # type: bool - - def __init__(self, value): + + def __init__(self, items=None, additional_items=None, min_items=None, max_items=None, unique_items=False, **kwargs): + super(Array, self).__init__(**kwargs) + self.items = items + self.additional_items = additional_items + self.min_items = min_items + self.max_items = max_items + self.unique_items = unique_items + + def validate(self, value): + validated = [] try: value = list(value) except TypeError: @@ -306,9 +289,9 @@ def __init__(self, value): try: if isinstance(self.items, list): if pos < len(self.items): - item = self.items[pos](item) + item = self.items[pos].validate(item) elif self.items is not None: - item = self.items(item) + item = self.items.validate(item) if self.unique_items: if item in seen_items: @@ -316,60 +299,16 @@ def __init__(self, value): else: seen_items.add(item) - self.append(item) + validated.append(item) except ValidationError as exc: errors[pos] = exc.detail if errors: raise ValidationError(errors) - def lookup(self, keys, default=None): - try: - item = self[keys[0]] - except (TypeError, IndexError): - return default - - if len(keys) == 1: - return item - return item.lookup(keys[1:], default) - - @classmethod - def error(cls, code): - message = cls.errors[code].format(**cls.__dict__) - raise ValidationError(message) # from None + return validated -class Any(object): - title = None # type: str - description = None # type: str - - def __new__(self, value): +class Any(Validator): + def validate(self, value): return value - - -def string(**kwargs): - return type('String', (String,), kwargs) - - -def integer(**kwargs): - return type('Integer', (Integer,), kwargs) - - -def number(**kwargs): - return type('Number', (Number,), kwargs) - - -def boolean(**kwargs): - return type('Boolean', (Boolean,), kwargs) - - -def array(**kwargs): - return type('Array', (Array,), kwargs) - - -def obj(**kwargs): - return type('Object', (Object,), kwargs) - - -# def ref(to=''): -# return type('Ref', (Ref,), {'to': to}) diff --git a/tests/codecs/test_openapi.py b/tests/codecs/test_openapi.py index 081d80b..a51b05a 100644 --- a/tests/codecs/test_openapi.py +++ b/tests/codecs/test_openapi.py @@ -272,7 +272,7 @@ def test_decode_openapi(openapi_codec, petstore_schema): location='query', description='How many items to return at one time (max 100)', required=False, - schema=typesys.integer(format='int32') + schema=typesys.Integer(format='int32') ) ] )), @@ -291,7 +291,7 @@ def test_decode_openapi(openapi_codec, petstore_schema): location='path', description='The id of the pet to retrieve', required=True, - schema=typesys.string() + schema=typesys.String() ) ] )) @@ -317,7 +317,7 @@ def test_encode_openapi(openapi_codec, minimal_petstore_schema): location='query', description='How many items to return at one time (max 100)', required=False, - schema=typesys.integer(format='int32') + schema=typesys.Integer(format='int32') ) ] ), @@ -336,7 +336,7 @@ def test_encode_openapi(openapi_codec, minimal_petstore_schema): location='path', description='The id of the pet to retrieve', required=True, - schema=typesys.string() + schema=typesys.String() ) ] ) diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 5d2f72d..a0d1f44 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -26,8 +26,8 @@ def doc(): url='http://example.org/', fields=[ Field(name='noschema'), - Field(name='string_example', schema=typesys.string()), - Field(name='enum_example', schema=typesys.string(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' From 01a0a519fbcf8039fc8e0d35324fbb74497d399e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 21 Feb 2018 13:17:13 +0000 Subject: [PATCH 42/60] Add typesys.Ref --- coreapi/schemas/jsonschema.py | 4 +- coreapi/schemas/openapi.py | 261 ++++++++++++++++------------------ coreapi/typesys.py | 46 ++++-- 3 files changed, 156 insertions(+), 155 deletions(-) diff --git a/coreapi/schemas/jsonschema.py b/coreapi/schemas/jsonschema.py index 84b3bb5..fb80edb 100644 --- a/coreapi/schemas/jsonschema.py +++ b/coreapi/schemas/jsonschema.py @@ -21,11 +21,11 @@ ('multipleOf', typesys.Number(minimum=0.0, exclusive_minimum=True)), # Object - ('properties', typesys.Object()), # TODO: typesys.ref('JSONSchema'), + ('properties', typesys.Ref()), ('required', typesys.Array(items=typesys.String(), min_items=1, unique_items=True)), # Array - ('items', typesys.Object()), # TODO: typesys.ref('JSONSchema'), + ('items', typesys.Ref()), ('additionalItems', typesys.Boolean()), ('minItems', typesys.Integer(minimum=0)), ('maxItems', typesys.Integer(minimum=0)), diff --git a/coreapi/schemas/openapi.py b/coreapi/schemas/openapi.py index df5f76d..8ed7c27 100644 --- a/coreapi/schemas/openapi.py +++ b/coreapi/schemas/openapi.py @@ -2,150 +2,129 @@ from coreapi.schemas import JSONSchema -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'] -) - - -Info = typesys.Object( - properties=[ - ('title', typesys.String()), - ('description', typesys.String(format='textarea')), - ('termsOfService', typesys.String(format='url')), - ('contact', Contact), - ('license', License), - ('version', typesys.String()) - ], - required=['title', 'version'] -) - - -ServerVariable = typesys.Object( - properties=[ - ('enum', typesys.Array(items=typesys.String())), - ('default', typesys.String()), - ('description', typesys.String(format='textarea')) - ], - required=['default'] -) - - -Server = typesys.Object( - properties=[ - ('url', typesys.String()), - ('description', typesys.String(format='textarea')), - ('variables', typesys.Object(additional_properties=ServerVariable)) - ], - required=['url'] -) - - -ExternalDocs = typesys.Object( - properties=[ - ('description', typesys.String(format='textarea')), - ('url', typesys.String(format='url')) - ], - required=['url'] -) - - -SecurityRequirement = typesys.Object( - additional_properties=typesys.Array(items=typesys.String()) -) - - -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), - ('example', typesys.Any()) - # TODO: Other fields - ], - required=['name', 'in'] -) - - -Operation = typesys.Object( - properties=[ - ('tags', typesys.Array(items=typesys.String())), - ('summary', typesys.String()), - ('description', typesys.String(format='textarea')), - ('externalDocs', ExternalDocs), - ('operationId', typesys.String()), - ('parameters', typesys.Array(items=Parameter)), # TODO: Parameter | ReferenceObject - # TODO: 'requestBody' - # TODO: 'responses' - # TODO: 'callbacks' - ('deprecated', typesys.Boolean()), - ('security', SecurityRequirement), - ('servers', typesys.Array(items=Server)) - ] -) - - -Path = typesys.Object( - properties=[ - ('summary', typesys.String()), - ('description', typesys.String(format='textarea')), - ('get', Operation), - ('put', Operation), - ('post', Operation), - ('delete', Operation), - ('options', Operation), - ('head', Operation), - ('patch', Operation), - ('trace', Operation), - ('servers', typesys.Array(items=Server)), - ('parameters', typesys.Array(items=Parameter)) # TODO: Parameter | ReferenceObject - ] -) - - -Paths = typesys.Object( - pattern_properties=[ - ('^/', Path) # TODO: Path | ReferenceObject - ] -) - - -Tag = typesys.Object( - properties=[ - ('name', typesys.String()), - ('description', typesys.String(format='textarea')), - ('externalDocs', ExternalDocs) - ], - required=['name'] -) - - OpenAPI = typesys.Object( + title='OpenAPI', properties=[ ('openapi', typesys.String()), - ('info', Info), - ('servers', typesys.Array(items=Server)), - ('paths', Paths), + ('info', typesys.Ref('Info')), + ('servers', typesys.Array(items=typesys.Ref('Server'))), + ('paths', typesys.Ref('Paths')), # TODO: 'components': ..., - ('security', SecurityRequirement), - ('tags', typesys.Array(items=Tag)), - ('externalDocs', ExternalDocs) + ('security', typesys.Ref('SecurityRequirement')), + ('tags', typesys.Array(items=typesys.Ref('Tag'))), + ('externalDocs', typesys.Ref('ExternalDocumentation')) ], - required=['openapi', 'info'] + 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 + # TODO: 'requestBody' + # TODO: 'responses' + # TODO: 'callbacks' + ('deprecated', typesys.Boolean()), + ('security', 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), + ('example', typesys.Any()) + # TODO: Other fields + ], + required=['name', 'in'] + ), + '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/typesys.py b/coreapi/typesys.py index 0795b34..bb74ae6 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -30,7 +30,7 @@ def __init__(self, title='', description='', default=None, definitions=None): self.default = default self.definitions = {} if (definitions is None) else definitions - def validate(value): + def validate(value, definitions=None): raise NotImplementedError() def error(self, code): @@ -61,7 +61,7 @@ def __init__(self, max_length=None, min_length=None, pattern=None, enum=None, fo self.enum = enum self.format = format - def validate(self, value): + def validate(self, value, definitions=None): value = str(value) if self.enum is not None: @@ -112,7 +112,7 @@ def __init__(self, minimum=None, maximum=None, exclusive_minimum=False, exclusiv self.enum = enum self.format = format - def validate(self, value): + def validate(self, value, definitions=None): try: value = self.numeric_type(value) except (TypeError, ValueError): @@ -164,7 +164,7 @@ class Boolean(Validator): 'type': 'Must be a valid boolean.' } - def validate(self, value): + def validate(self, value, definitions=None): if isinstance(value, (int, float, bool)): return bool(value) elif isinstance(value, str): @@ -194,7 +194,11 @@ def __init__(self, properties=None, pattern_properties=None, additional_properti self.additional_properties = additional_properties self.required = [] if (required is None) else required - def validate(self, value): + def validate(self, value, definitions=None): + if definitions is None: + definitions = dict(self.definitions) + definitions[''] = self + validated = dict_type() try: value = dict_type(value) @@ -215,7 +219,7 @@ def validate(self, value): errors[key] = self.error_message('required') else: try: - validated[key] = child_schema.validate(item) + validated[key] = child_schema.validate(item, definitions=definitions) except ValidationError as exc: errors[key] = exc.detail @@ -226,7 +230,7 @@ def validate(self, value): if re.search(pattern, key): item = value.pop(key) try: - validated[key] = child_schema.validate(item) + validated[key] = child_schema.validate(item, definitions=definitions) except ValidationError as exc: errors[key] = exc.detail @@ -236,7 +240,7 @@ def validate(self, value): for key in list(value.keys()): item = value.pop(key) try: - validated[key] = child_schema.validate(item) + validated[key] = child_schema.validate(item, definitions=definitions) except ValidationError as exc: errors[key] = exc.detail @@ -262,7 +266,11 @@ def __init__(self, items=None, additional_items=None, min_items=None, max_items= self.max_items = max_items self.unique_items = unique_items - def validate(self, value): + def validate(self, value, definitions=None): + if definitions is None: + definitions = dict(self.definitions) + definitions[''] = self + validated = [] try: value = list(value) @@ -289,9 +297,9 @@ def validate(self, value): try: if isinstance(self.items, list): if pos < len(self.items): - item = self.items[pos].validate(item) + item = self.items[pos].validate(item, definitions=definitions) elif self.items is not None: - item = self.items.validate(item) + item = self.items.validate(item, definitions=definitions) if self.unique_items: if item in seen_items: @@ -310,5 +318,19 @@ def validate(self, value): class Any(Validator): - def validate(self, value): + def validate(self, value, definitions=None): + # TODO: Validate value matches primitive types return value + + +class Ref(Validator): + def __init__(self, ref='', **kwargs): + super(Ref, self).__init__(**kwargs) + 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) From 46a95595a2a0d5b6c7bcbe27d1659d576a801d74 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 21 Feb 2018 14:00:09 +0000 Subject: [PATCH 43/60] assert types on __init__ --- coreapi/typesys.py | 67 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/coreapi/typesys.py b/coreapi/typesys.py index bb74ae6..841a243 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -1,6 +1,5 @@ from coreapi.compat import dict_type, string_types import re -# from typing import Any, Dict, List, Optional, Tuple, Union, overload # noqa # TODO: Error on unknown attributes @@ -17,18 +16,30 @@ class ValidationError(Exception): def __init__(self, detail): + assert isinstance(detail, (string_types, dict)) self.detail = detail super(ValidationError, self).__init__(detail) +class NoDefault(object): + pass + + class Validator(object): errors = {} - def __init__(self, title='', description='', default=None, definitions=None): + def __init__(self, title='', description='', default=NoDefault, definitions=None): + definitions = {} if (definitions is None) else dict_type(definitions) + + assert isinstance(title, string_types) + assert isinstance(description, string_types) + 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.default = default - self.definitions = {} if (definitions is None) else definitions + self.definitions = definitions def validate(value, definitions=None): raise NotImplementedError() @@ -40,6 +51,9 @@ def error(self, code): def error_message(self, code): return self.errors[code].format(**self.__dict__) + def has_default(self): + return self.default is not NoDefault + class String(Validator): errors = { @@ -55,6 +69,13 @@ class String(Validator): def __init__(self, max_length=None, min_length=None, pattern=None, enum=None, format=None, **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 @@ -104,6 +125,15 @@ class NumericType(Validator): def __init__(self, minimum=None, maximum=None, exclusive_minimum=False, exclusive_maximum=False, multiple_of=None, enum=None, format=None, **kwargs): super(NumericType, self).__init__(**kwargs) + + assert minimum is None or isinstance(minimum, self.numeric_type) + assert maximum is None or isinstance(maximum, self.numeric_type) + assert isinstance(exclusive_minimum, bool) + assert isinstance(exclusive_maximum, bool) + assert multiple_of is None or isinstance(multiple_of, self.numeric_type) + 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 @@ -189,10 +219,21 @@ class Object(Validator): def __init__(self, properties=None, pattern_properties=None, additional_properties=None, required=None, **kwargs): super(Object, self).__init__(**kwargs) - self.properties = {} if (properties is None) else dict_type(properties) - self.pattern_properties = {} if (pattern_properties is None) else dict_type(pattern_properties) + + 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()) + + self.properties = properties + self.pattern_properties = pattern_properties self.additional_properties = additional_properties - self.required = [] if (required is None) else required + self.required = required def validate(self, value, definitions=None): if definitions is None: @@ -260,6 +301,15 @@ class Array(Validator): def __init__(self, items=None, additional_items=None, min_items=None, max_items=None, unique_items=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 @@ -280,7 +330,7 @@ def validate(self, value, definitions=None): if isinstance(self.items, list) and len(self.items) > 1: if len(value) < len(self.items): self.error('min_items') - elif len(value) > len(self.items) and not self.additional_items: + elif len(value) > len(self.items) and (self.additional_items is False): self.error('max_items') if self.min_items is not None and len(value) < self.min_items: @@ -298,6 +348,8 @@ def validate(self, value, definitions=None): 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) @@ -326,6 +378,7 @@ def validate(self, value, definitions=None): 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): From f7729921c0264ae748abdef8110be49b598fd42d Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 21 Feb 2018 16:25:08 +0000 Subject: [PATCH 44/60] Tests and refinements to typesys --- coreapi/typesys.py | 97 ++++++++++++++++++++++-------------- tests/typesys/test_array.py | 76 ++++++++++++++++++++++++++++ tests/typesys/test_object.py | 81 ++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 38 deletions(-) create mode 100644 tests/typesys/test_array.py create mode 100644 tests/typesys/test_object.py diff --git a/coreapi/typesys.py b/coreapi/typesys.py index 841a243..de53924 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -83,7 +83,8 @@ def __init__(self, max_length=None, min_length=None, pattern=None, enum=None, fo self.format = format def validate(self, value, definitions=None): - value = str(value) + if not isinstance(value, string_types): + self.error('type') if self.enum is not None: if value not in self.enum: @@ -115,7 +116,7 @@ class NumericType(Validator): """ numeric_type = None # type: type errors = { - 'type': 'Must be a valid number.', + 'type': 'Must be a number.', '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}.', @@ -143,11 +144,11 @@ def __init__(self, minimum=None, maximum=None, exclusive_minimum=False, exclusiv self.format = format def validate(self, value, definitions=None): - try: - value = self.numeric_type(value) - except (TypeError, ValueError): + if not isinstance(value, (int, float)) or isinstance(value, bool): self.error('type') + value = self.numeric_type(value) + if self.enum is not None: if value not in self.enum: if len(self.enum) == 1: @@ -195,19 +196,9 @@ class Boolean(Validator): } def validate(self, value, definitions=None): - if isinstance(value, (int, float, bool)): - return bool(value) - elif isinstance(value, str): - try: - return { - 'true': True, - 'false': False, - '1': True, - '0': False - }[value.lower()] - except KeyError: - pass - self.error('type') + if not isinstance(value, bool): + self.error('type') + return value class Object(Validator): @@ -215,9 +206,13 @@ class Object(Validator): 'type': 'Must be an object.', '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=None, required=None, **kwargs): + def __init__(self, properties=None, pattern_properties=None, additional_properties=True, min_properties=None, max_properties=None, required=None, **kwargs): super(Object, self).__init__(**kwargs) properties = {} if (properties is None) else dict_type(properties) @@ -229,10 +224,16 @@ def __init__(self, properties=None, pattern_properties=None, additional_properti 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 def validate(self, value, definitions=None): @@ -240,24 +241,39 @@ def validate(self, value, definitions=None): definitions = dict(self.definitions) definitions[''] = self - validated = dict_type() - try: - value = dict_type(value) - except TypeError: + if not isinstance(value, dict): self.error('type') + validated = dict_type() + value = dict_type(value) + # 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') + + # Requried 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(): try: item = value.pop(key) except KeyError: - if key in self.required: - errors[key] = self.error_message('required') + pass else: try: validated[key] = child_schema.validate(item, definitions=definitions) @@ -276,7 +292,12 @@ def validate(self, value, definitions=None): errors[key] = exc.detail # Additional properties - if self.additional_properties is not None: + if self.additional_properties is True: + validated.update(value) + elif self.additional_properties is False: + for key in value.keys(): + errors[key] = self.error_message('no_additional_properties') + elif self.additional_properties is not None: child_schema = self.additional_properties for key in list(value.keys()): item = value.pop(key) @@ -293,9 +314,12 @@ def validate(self, value, definitions=None): class Array(Validator): errors = { - 'type': 'Must be a list.', - 'min_items': 'Not enough items.', - 'max_items': 'Too many items.', + 'type': 'Must be an array.', + '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.', } @@ -321,22 +345,19 @@ def validate(self, value, definitions=None): definitions = dict(self.definitions) definitions[''] = self - validated = [] - try: - value = list(value) - except TypeError: + if not isinstance(value, list): self.error('type') - if isinstance(self.items, list) and len(self.items) > 1: - if len(value) < len(self.items): - self.error('min_items') - elif len(value) > len(self.items) and (self.additional_items is False): - self.error('max_items') + validated = [] 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 = {} diff --git a/tests/typesys/test_array.py b/tests/typesys/test_array.py new file mode 100644 index 0000000..50c0545 --- /dev/null +++ b/tests/typesys/test_array.py @@ -0,0 +1,76 @@ +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_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..bcb9bc5 --- /dev/null +++ b/tests/typesys/test_object.py @@ -0,0 +1,81 @@ +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_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.'} From a50e0e3deceb679a7ae527022018be0f69a275ab Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 21 Feb 2018 16:31:58 +0000 Subject: [PATCH 45/60] exact_items validation for Array --- coreapi/typesys.py | 2 ++ tests/typesys/test_array.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/coreapi/typesys.py b/coreapi/typesys.py index de53924..5a3b34e 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -350,6 +350,8 @@ def validate(self, value, definitions=None): 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') diff --git a/tests/typesys/test_array.py b/tests/typesys/test_array.py index 50c0545..f838e78 100644 --- a/tests/typesys/test_array.py +++ b/tests/typesys/test_array.py @@ -53,6 +53,14 @@ def test_array_empty(): assert exc.value.detail == 'Must not be empty.' +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] From b56b1d64fcebd765d458f546b3c38762fd65f042 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 21 Feb 2018 16:50:06 +0000 Subject: [PATCH 46/60] Add allow_null to typesys --- coreapi/typesys.py | 54 +++++++++++++++++++++++++++++------- tests/typesys/test_array.py | 10 +++++++ tests/typesys/test_object.py | 10 +++++++ 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/coreapi/typesys.py b/coreapi/typesys.py index 5a3b34e..b8e7a44 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -58,6 +58,7 @@ def has_default(self): 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.', @@ -67,7 +68,7 @@ class String(Validator): 'exact': 'Must be {exact}.' } - def __init__(self, max_length=None, min_length=None, pattern=None, enum=None, format=None, **kwargs): + 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) @@ -81,9 +82,14 @@ def __init__(self, max_length=None, min_length=None, pattern=None, enum=None, fo self.pattern = pattern self.enum = enum self.format = format + self.allow_null = allow_null def validate(self, value, definitions=None): - if not isinstance(value, string_types): + 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: @@ -117,6 +123,7 @@ class NumericType(Validator): numeric_type = None # type: type errors = { 'type': 'Must be a number.', + 'null': 'May not be null.', '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}.', @@ -124,7 +131,7 @@ class NumericType(Validator): '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, **kwargs): + 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, self.numeric_type) @@ -142,9 +149,14 @@ def __init__(self, minimum=None, maximum=None, exclusive_minimum=False, exclusiv self.multiple_of = multiple_of self.enum = enum self.format = format + self.allow_null = allow_null def validate(self, value, definitions=None): - if not isinstance(value, (int, float)) or isinstance(value, bool): + 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') value = self.numeric_type(value) @@ -192,11 +204,21 @@ class Integer(NumericType): class Boolean(Validator): errors = { - 'type': 'Must be a valid boolean.' + '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 not isinstance(value, bool): + 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 @@ -204,6 +226,7 @@ def validate(self, value, definitions=None): 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.', @@ -212,7 +235,7 @@ class Object(Validator): '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, **kwargs): + 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) @@ -235,13 +258,18 @@ def __init__(self, properties=None, pattern_properties=None, additional_properti 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 definitions is None: definitions = dict(self.definitions) definitions[''] = self - if not isinstance(value, dict): + 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') validated = dict_type() @@ -315,6 +343,7 @@ def validate(self, value, definitions=None): 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.', @@ -323,7 +352,7 @@ class Array(Validator): 'unique_items': 'This item is not unique.', } - def __init__(self, items=None, additional_items=None, min_items=None, max_items=None, unique_items=False, **kwargs): + 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 @@ -339,13 +368,18 @@ def __init__(self, items=None, additional_items=None, min_items=None, max_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 definitions is None: definitions = dict(self.definitions) definitions[''] = self - if not isinstance(value, list): + 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') validated = [] diff --git a/tests/typesys/test_array.py b/tests/typesys/test_array.py index f838e78..7da67f3 100644 --- a/tests/typesys/test_array.py +++ b/tests/typesys/test_array.py @@ -53,6 +53,16 @@ def test_array_empty(): 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] diff --git a/tests/typesys/test_object.py b/tests/typesys/test_object.py index bcb9bc5..b5539f1 100644 --- a/tests/typesys/test_object.py +++ b/tests/typesys/test_object.py @@ -57,6 +57,16 @@ def test_object_empty(): 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} From c58ad81f004657dcf91edb58770b0fbeb158b471 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 1 Mar 2018 08:54:25 +0000 Subject: [PATCH 47/60] Work on JSON Schema and typesys --- coreapi/codecs/jsonschema.py | 198 ++++++++++++++++++++++++----------- coreapi/typesys.py | 61 +++++++++-- 2 files changed, 189 insertions(+), 70 deletions(-) diff --git a/coreapi/codecs/jsonschema.py b/coreapi/codecs/jsonschema.py index 945944a..5387a6f 100644 --- a/coreapi/codecs/jsonschema.py +++ b/coreapi/codecs/jsonschema.py @@ -1,12 +1,144 @@ from coreapi import typesys from coreapi.codecs.base import BaseCodec -from coreapi.compat import COMPACT_SEPARATORS, VERBOSE_SEPARATORS, force_bytes +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' @@ -19,70 +151,10 @@ def decode(self, bytestring, **options): except ValueError as exc: raise ParseError('Malformed JSON. %s' % exc) jsonschema = JSONSchema.validate(data) - return self.decode_from_data_structure(jsonschema) + return decode(jsonschema) def decode_from_data_structure(self, struct): - attrs = {} - - # if '$ref' in struct: - # if struct['$ref'] == '#': - # return typesys.ref() - # name = struct['$ref'].split('/')[-1] - # return typesys.ref(name) - - if struct['type'] == '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 struct['type'] in ['number', 'integer']: - if 'minimum' in struct: - attrs['minimum'] = struct['minimum'] - if 'maximum' in struct: - attrs['maximum'] = struct['maximum'] - if 'exclusiveMinimum' in struct: - attrs['exclusiveMinimum'] = struct['exclusiveMinimum'] - if 'exclusiveMaximum' in struct: - attrs['exclusiveMaximum'] = struct['exclusiveMaximum'] - if 'multipleOf' in struct: - attrs['multipleOf'] = struct['multipleOf'] - if 'format' in struct: - attrs['format'] = struct['format'] - if struct['type'] == 'integer': - return typesys.Integer(**attrs) - return typesys.Number(**attrs) - - if struct['type'] == 'boolean': - return typesys.Boolean() - - if struct['type'] == 'object': - if 'properties' in struct: - attrs['properties'] = { - key: self.decode_from_data_structure(value) - for key, value in struct['properties'].items() - } - if 'required' in struct: - attrs['required'] = struct['required'] - return typesys.Object(**attrs) - - if struct['type'] == 'array': - if 'items' in struct: - attrs['items'] = self.decode_from_data_structure(struct['items']) - if 'additionalItems' in struct: - attrs['additional_items'] = 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) + return decode(struct) def encode(self, item, **options): struct = self.encode_to_data_structure(item) diff --git a/coreapi/typesys.py b/coreapi/typesys.py index b8e7a44..ba8662b 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -25,6 +25,18 @@ class NoDefault(object): pass +def unbool(element, true=object(), false=object()): + # https://github.com/Julian/jsonschema/blob/master/jsonschema/_utils.py + """ + A hack to treat True and False as distinct from 1 and 0 for uniqueness checks. + """ + if element is True: + return true + elif element is False: + return false + return element + + class Validator(object): errors = {} @@ -41,9 +53,16 @@ def __init__(self, title='', description='', default=NoDefault, definitions=None self.default = default self.definitions = definitions - def validate(value, definitions=None): + def validate(self, value, definitions=None): raise NotImplementedError() + def is_valid(self, value): + try: + self.validate(value) + except ValidationError: + return False + return True + def error(self, code): message = self.error_message(code) raise ValidationError(message) @@ -123,6 +142,7 @@ class NumericType(Validator): numeric_type = None # type: type errors = { 'type': 'Must be a number.', + 'integer': 'Must be an integer.', 'null': 'May not be null.', 'minimum': 'Must be greater than or equal to {minimum}.', 'exclusive_minimum': 'Must be greater than {minimum}.', @@ -134,11 +154,11 @@ class NumericType(Validator): 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, self.numeric_type) - assert maximum is None or isinstance(maximum, self.numeric_type) + 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, self.numeric_type) + 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) @@ -158,6 +178,8 @@ def validate(self, value, definitions=None): self.error('null') elif not isinstance(value, (int, float)) or isinstance(value, bool): self.error('type') + elif self.numeric_type is int and isinstance(value, float) and not value.is_integer(): + self.error('integer') value = self.numeric_type(value) @@ -398,7 +420,7 @@ def validate(self, value, definitions=None): # Ensure all items are of the right type. errors = {} if self.unique_items: - seen_items = set() + seen_items = [] for pos, item in enumerate(value): try: @@ -411,10 +433,10 @@ def validate(self, value, definitions=None): item = self.items.validate(item, definitions=definitions) if self.unique_items: - if item in seen_items: + if unbool(item) in seen_items: self.error('unique_items') else: - seen_items.add(item) + seen_items.append(unbool(item)) validated.append(item) except ValidationError as exc: @@ -432,6 +454,31 @@ def validate(self, value, definitions=None): 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) + except ValidationError: + pass + self.error('union') + + class Ref(Validator): def __init__(self, ref='', **kwargs): super(Ref, self).__init__(**kwargs) From 15f24dc6e7a13457a2aa1ef7c66597edc1ce8875 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 1 Mar 2018 16:46:12 +0000 Subject: [PATCH 48/60] Work on Union and JSONSchema --- coreapi/codecs/jsonschema.py | 3 +- coreapi/compat.py | 7 +++++ coreapi/schemas/jsonschema.py | 15 ++++++---- coreapi/typesys.py | 55 +++++++++++++++++++++++------------ 4 files changed, 56 insertions(+), 24 deletions(-) diff --git a/coreapi/codecs/jsonschema.py b/coreapi/codecs/jsonschema.py index 5387a6f..38df4a0 100644 --- a/coreapi/codecs/jsonschema.py +++ b/coreapi/codecs/jsonschema.py @@ -154,7 +154,8 @@ def decode(self, bytestring, **options): return decode(jsonschema) def decode_from_data_structure(self, struct): - return decode(struct) + jsonschema = JSONSchema.validate(struct) + return decode(jsonschema) def encode(self, item, **options): struct = self.encode_to_data_structure(item) diff --git a/coreapi/compat.py b/coreapi/compat.py index 0d3c46f..a8d5d44 100644 --- a/coreapi/compat.py +++ b/coreapi/compat.py @@ -15,6 +15,7 @@ # Python 2 import urlparse import cookielib as cookiejar + import math string_types = (basestring,) text_type = unicode @@ -25,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 diff --git a/coreapi/schemas/jsonschema.py b/coreapi/schemas/jsonschema.py index fb80edb..c210d9b 100644 --- a/coreapi/schemas/jsonschema.py +++ b/coreapi/schemas/jsonschema.py @@ -4,8 +4,9 @@ JSONSchema = typesys.Object( properties=[ ('$ref', typesys.String()), - ('type', typesys.String()), - ('enum', typesys.Any()), + ('type', typesys.String() | typesys.Array(items=typesys.String())), + ('enum', typesys.Array(unique_items=True, min_items=1)), + ('definitions', typesys.Object(additional_properties=typesys.Ref())), # String ('minLength', typesys.Integer(minimum=0, default=0)), @@ -22,12 +23,16 @@ # Object ('properties', typesys.Ref()), + ('minProperties', typesys.Integer(minimum=0, default=0)), + ('maxProperties', typesys.Integer(minimum=0)), + ('patternProperties', typesys.Object(additional_properties=typesys.Ref())), + ('additionalProperties', typesys.Ref() | typesys.Boolean()), ('required', typesys.Array(items=typesys.String(), min_items=1, unique_items=True)), # Array - ('items', typesys.Ref()), - ('additionalItems', typesys.Boolean()), - ('minItems', typesys.Integer(minimum=0)), + ('items', typesys.Ref() | typesys.Array(items=typesys.Ref(), min_items=1)), + ('additionalItems', typesys.Ref() | typesys.Boolean()), + ('minItems', typesys.Integer(minimum=0, default=9)), ('maxItems', typesys.Integer(minimum=0)), ('uniqueItems', typesys.Boolean()), ] diff --git a/coreapi/typesys.py b/coreapi/typesys.py index ba8662b..0c68eb7 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -1,4 +1,4 @@ -from coreapi.compat import dict_type, string_types +from coreapi.compat import dict_type, isfinite, string_types import re @@ -73,6 +73,19 @@ def error_message(self, code): def has_default(self): return self.default is not NoDefault + 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 = { @@ -142,8 +155,9 @@ class NumericType(Validator): numeric_type = None # type: type errors = { 'type': 'Must be a number.', - 'integer': 'Must be an integer.', '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}.', @@ -178,6 +192,8 @@ def validate(self, value, definitions=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') @@ -295,7 +311,6 @@ def validate(self, value, definitions=None): self.error('type') validated = dict_type() - value = dict_type(value) # Ensure all property keys are strings. errors = {} @@ -313,44 +328,48 @@ def validate(self, value, definitions=None): if len(value) > self.max_properties: self.error('max_properties') - # Requried 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: - item = value.pop(key) - except KeyError: - pass - else: - try: - validated[key] = child_schema.validate(item, definitions=definitions) - except ValidationError as exc: - errors[key] = exc.detail + 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.pop(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: - validated.update(value) + for key in remaining: + validated[key] = value[key] elif self.additional_properties is False: - for key in value.keys(): + 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 list(value.keys()): - item = value.pop(key) + for key in remaining: + item = value[key] try: validated[key] = child_schema.validate(item, definitions=definitions) except ValidationError as exc: @@ -473,7 +492,7 @@ def validate(self, value, definitions=None): for item in self.items: try: - return item.validate(value) + return item.validate(value, definitions=definitions) except ValidationError: pass self.error('union') From cb8e273fdfff7c635a196ad12fb791df12a85e2f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 1 Mar 2018 17:22:02 +0000 Subject: [PATCH 49/60] Cleanups on typesys --- coreapi/schemas/jsonschema.py | 2 +- coreapi/typesys.py | 32 ++++++++++---------------------- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/coreapi/schemas/jsonschema.py b/coreapi/schemas/jsonschema.py index c210d9b..55535ea 100644 --- a/coreapi/schemas/jsonschema.py +++ b/coreapi/schemas/jsonschema.py @@ -22,7 +22,7 @@ ('multipleOf', typesys.Number(minimum=0.0, exclusive_minimum=True)), # Object - ('properties', typesys.Ref()), + ('properties', typesys.Object(additional_properties=typesys.Ref())), ('minProperties', typesys.Integer(minimum=0, default=0)), ('maxProperties', typesys.Integer(minimum=0)), ('patternProperties', typesys.Object(additional_properties=typesys.Ref())), diff --git a/coreapi/typesys.py b/coreapi/typesys.py index 0c68eb7..30a0839 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -2,16 +2,16 @@ import re -# TODO: Error on unknown attributes -# TODO: allow_blank? -# TODO: format (check type at start and allow, coerce, .native) -# TODO: default=empty -# TODO: check 'required' exists in 'properties' -# TODO: smarter ordering -# TODO: extra_properties=False by default -# TODO: inf, -inf, nan -# TODO: Overriding errors -# TODO: Blank booleans as False? +def unbool(element, true=object(), false=object()): + # https://github.com/Julian/jsonschema/blob/master/jsonschema/_utils.py + """ + A hack to treat True and False as distinct from 1 and 0 for uniqueness checks. + """ + if element is True: + return true + elif element is False: + return false + return element class ValidationError(Exception): @@ -25,18 +25,6 @@ class NoDefault(object): pass -def unbool(element, true=object(), false=object()): - # https://github.com/Julian/jsonschema/blob/master/jsonschema/_utils.py - """ - A hack to treat True and False as distinct from 1 and 0 for uniqueness checks. - """ - if element is True: - return true - elif element is False: - return false - return element - - class Validator(object): errors = {} From f19c680dc45449abca72c63a74bbaa2454074ccc Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 5 Mar 2018 11:20:47 +0000 Subject: [PATCH 50/60] Uniqueness checking always that always uses set() --- coreapi/typesys.py | 51 +++++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/coreapi/typesys.py b/coreapi/typesys.py index 30a0839..2536926 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -2,15 +2,28 @@ import re -def unbool(element, true=object(), false=object()): - # https://github.com/Julian/jsonschema/blob/master/jsonschema/_utils.py - """ - A hack to treat True and False as distinct from 1 and 0 for uniqueness checks. - """ +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 + return TRUE # Need to make `True` distinct from `1`. elif element is False: - return 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 @@ -21,26 +34,25 @@ def __init__(self, detail): super(ValidationError, self).__init__(detail) -class NoDefault(object): - pass - - class Validator(object): errors = {} - def __init__(self, title='', description='', default=NoDefault, definitions=None): + def __init__(self, title='', description='', default=NO_DEFAULT, definitions=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.default = default self.definitions = definitions + if default is not NO_DEFAULT: + self.default = default + def validate(self, value, definitions=None): raise NotImplementedError() @@ -51,6 +63,9 @@ def is_valid(self, value): return False return True + def has_default(self): + return hasattr(self, 'default') + def error(self, code): message = self.error_message(code) raise ValidationError(message) @@ -58,9 +73,6 @@ def error(self, code): def error_message(self, code): return self.errors[code].format(**self.__dict__) - def has_default(self): - return self.default is not NoDefault - def __or__(self, other): if isinstance(self, Union): items = self.items @@ -427,7 +439,7 @@ def validate(self, value, definitions=None): # Ensure all items are of the right type. errors = {} if self.unique_items: - seen_items = [] + seen_items = set() for pos, item in enumerate(value): try: @@ -440,10 +452,11 @@ def validate(self, value, definitions=None): item = self.items.validate(item, definitions=definitions) if self.unique_items: - if unbool(item) in seen_items: + hashable_item = hashable(item) + if hashable_item in seen_items: self.error('unique_items') else: - seen_items.append(unbool(item)) + seen_items.add(hashable_item) validated.append(item) except ValidationError as exc: From a87f66a087d6c6b7690ab6cf7d14a2425257b7f2 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 5 Mar 2018 13:52:32 +0000 Subject: [PATCH 51/60] Add Section, Add Link.id --- coreapi/codecs/openapi.py | 34 ++++++++++++++++------------------ coreapi/document.py | 25 +++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index ee360f4..f3bff08 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -1,6 +1,6 @@ from coreapi.codecs import BaseCodec, JSONSchemaCodec from coreapi.compat import VERBOSE_SEPARATORS, dict_type, force_bytes, urlparse -from coreapi.document import Document, Link, Field +from coreapi.document import Document, Link, Field, Section from coreapi.exceptions import ParseError from coreapi.schemas import OpenAPI import json @@ -49,14 +49,14 @@ def decode(self, bytestring, **options): description = lookup(openapi, ['info', 'description']) version = lookup(openapi, ['info', 'version']) base_url = lookup(openapi, ['servers', 0, 'url']) - content = self.get_links(openapi, base_url) - return Document(title=title, description=description, version=version, url=base_url, content=content) + sections = self.get_sections(openapi, base_url) + return Document(title=title, description=description, version=version, url=base_url, sections=sections) - def get_links(self, openapi, base_url): + def get_sections(self, openapi, base_url): """ Return all the links in the document, layed out by tag and operationId. """ - content = {} + links = dict_type() for path, path_info in openapi.get('paths', {}).items(): operations = { @@ -64,26 +64,23 @@ def get_links(self, openapi, base_url): if key in METHODS } for operation, operation_info in operations.items(): - operationId = operation_info.get('operationId') - tag = lookup(operation_info, ['tags', 0]) - if not operationId: - continue - + tag = lookup(operation_info, ['tags', 0], default='') link = self.get_link(base_url, path, path_info, operation, operation_info) - if tag is None: - content[operationId] = link - else: - if tag in content: - content[tag][operationId] = link - else: - content[tag] = {operationId: link} - return content + if tag not in links: + links[tag] = [] + links[tag].append(link) + + return [ + Section(id=key, title=key, links=value) + for key, value in links.items() + ] def get_link(self, base_url, path, path_info, operation, operation_info): """ Return a single link in the document. """ + id = operation_info.get('operationId') title = operation_info.get('summary') description = operation_info.get('description') @@ -101,6 +98,7 @@ def get_link(self, base_url, path, path_info, operation, operation_info): ] return Link( + id=id, url=urlparse.urljoin(base_url, path), method=operation, title=title, diff --git a/coreapi/document.py b/coreapi/document.py index 4dd537e..e4212c9 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -48,7 +48,7 @@ class Document(Mapping): 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, version=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): @@ -71,6 +71,18 @@ def __init__(self, url=None, title=None, description=None, version=None, media_t 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 + + 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): @@ -133,6 +145,14 @@ def links(self): ]) +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. @@ -178,7 +198,7 @@ class Link(object): """ Links represent the actions that a client may perform. """ - def __init__(self, url=None, method=None, encoding=None, title=None, description=None, fields=None, action=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 @@ -200,6 +220,7 @@ def __init__(self, url=None, method=None, encoding=None, title=None, description ]): raise TypeError("Argument 'fields' must be a list of strings or fields.") + self.id = id self._url = '' if (url is None) else url self._method = '' if (method is None) else method self._encoding = '' if (encoding is None) else encoding From 382eef832dcb526edff842d3150d86dd3998af36 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 5 Mar 2018 13:56:42 +0000 Subject: [PATCH 52/60] Slugify tag names to get section ids --- coreapi/codecs/openapi.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index f3bff08..bdff499 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -4,6 +4,7 @@ from coreapi.exceptions import ParseError from coreapi.schemas import OpenAPI import json +import re METHODS = [ @@ -34,6 +35,13 @@ def _relative_url(base_url, url): 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' @@ -72,7 +80,7 @@ def get_sections(self, openapi, base_url): links[tag].append(link) return [ - Section(id=key, title=key, links=value) + Section(id=_simple_slugify(key), title=key, links=value) for key, value in links.items() ] From 4a5993a0e70255988e0cf2e2a8d67972f59907ce Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 6 Mar 2018 10:55:30 +0000 Subject: [PATCH 53/60] Add path_links and query_links to Section --- coreapi/document.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/coreapi/document.py b/coreapi/document.py index e4212c9..f06d476 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -152,6 +152,12 @@ def __init__(self, id=None, title=None, description=None, links=None): self.description = description self.links = links + def path_links(self): + return [link for link in self.links if link.location == 'path'] + + def query_links(self): + return [link for link in self.links if link.location == 'query'] + class Object(Mapping): """ From 5e3de6cc90b5e025b9668e10d673288971c904c8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 6 Mar 2018 10:58:54 +0000 Subject: [PATCH 54/60] Add path_fields and query_fields to Link --- coreapi/document.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/coreapi/document.py b/coreapi/document.py index f06d476..5128cf4 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -152,12 +152,6 @@ def __init__(self, id=None, title=None, description=None, links=None): self.description = description self.links = links - def path_links(self): - return [link for link in self.links if link.location == 'path'] - - def query_links(self): - return [link for link in self.links if link.location == 'query'] - class Object(Mapping): """ @@ -266,6 +260,12 @@ 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 __eq__(self, other): return ( isinstance(other, Link) and From 1d7a121561f3f2b7c5c23e5e35d8319b5e1e1f1f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 6 Mar 2018 11:12:25 +0000 Subject: [PATCH 55/60] Tweak --- coreapi/codecs/openapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index bdff499..46adc59 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -80,7 +80,7 @@ def get_sections(self, openapi, base_url): links[tag].append(link) return [ - Section(id=_simple_slugify(key), title=key, links=value) + Section(id=_simple_slugify(key), title=key.title(), links=value) for key, value in links.items() ] From 58ec296c0f6deb66a09f98e77436affb72e6275c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 6 Mar 2018 11:45:26 +0000 Subject: [PATCH 56/60] Fix 'security' in OpenAPI --- coreapi/schemas/openapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coreapi/schemas/openapi.py b/coreapi/schemas/openapi.py index 8ed7c27..e5b77b4 100644 --- a/coreapi/schemas/openapi.py +++ b/coreapi/schemas/openapi.py @@ -90,7 +90,7 @@ # TODO: 'responses' # TODO: 'callbacks' ('deprecated', typesys.Boolean()), - ('security', typesys.Ref('SecurityRequirement')), + ('security', typesys.Array(typesys.Ref('SecurityRequirement'))), ('servers', typesys.Array(items=typesys.Ref('Server'))) ] ), From 947e03bdc75d4f20ea77f083460db454fba4c83e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 6 Mar 2018 12:09:22 +0000 Subject: [PATCH 57/60] If no operationId then use summary for Link.id --- coreapi/codecs/openapi.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index 46adc59..0d5f439 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -74,6 +74,8 @@ def get_sections(self, openapi, base_url): 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) + if link is None: + continue if tag not in links: links[tag] = [] @@ -92,6 +94,11 @@ def get_link(self, base_url, path, path_info, operation, operation_info): 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) From 7486932af20ce241a42b4f3f2b13e8787a9f6c3f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 6 Mar 2018 13:27:11 +0000 Subject: [PATCH 58/60] Building out OpenAPI v3 spec --- coreapi/schemas/openapi.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/coreapi/schemas/openapi.py b/coreapi/schemas/openapi.py index e5b77b4..292a888 100644 --- a/coreapi/schemas/openapi.py +++ b/coreapi/schemas/openapi.py @@ -9,7 +9,7 @@ ('info', typesys.Ref('Info')), ('servers', typesys.Array(items=typesys.Ref('Server'))), ('paths', typesys.Ref('Paths')), - # TODO: 'components': ..., + ('components', typesys.Ref('Components')), ('security', typesys.Ref('SecurityRequirement')), ('tags', typesys.Array(items=typesys.Ref('Tag'))), ('externalDocs', typesys.Ref('ExternalDocumentation')) @@ -86,7 +86,7 @@ ('externalDocs', typesys.Ref('ExternalDocumentation')), ('operationId', typesys.String()), ('parameters', typesys.Array(items=typesys.Ref('Parameter'))), # TODO: Parameter | ReferenceObject - # TODO: 'requestBody' + ('requestBody', typesys.Ref('RequestBody')), # TODO: RequestBody | ReferenceObject # TODO: 'responses' # TODO: 'callbacks' ('deprecated', typesys.Boolean()), @@ -109,12 +109,31 @@ ('required', typesys.Boolean()), ('deprecated', typesys.Boolean()), ('allowEmptyValue', typesys.Boolean()), - ('schema', JSONSchema), + ('schema', JSONSchema), # TODO: | RefString ('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), # TODO: | RefString + ('example': typesys.Any()) + # TODO 'examples', 'encoding' + ] + ), + 'Components': typesys.Object( + properties: [ + ('schemas', typesys.Object(additional_properties=JSONSchema)), + ] + ), 'Tag': typesys.Object( properties=[ ('name', typesys.String()), From 69a5a6980657c9fe91c8bbc20b9b00795719814c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 6 Mar 2018 14:25:51 +0000 Subject: [PATCH 59/60] First pass at parsing OpenAPI requestBody schemas --- coreapi/codecs/openapi.py | 41 ++++++++++++++++++++++++++++------- coreapi/schemas/jsonschema.py | 13 ++++++----- coreapi/schemas/openapi.py | 12 ++++++---- coreapi/typesys.py | 27 ++++++++++++++--------- 4 files changed, 65 insertions(+), 28 deletions(-) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index 0d5f439..93098f9 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -1,5 +1,6 @@ +from coreapi import typesys from coreapi.codecs import BaseCodec, JSONSchemaCodec -from coreapi.compat import VERBOSE_SEPARATORS, dict_type, force_bytes, urlparse +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 @@ -57,10 +58,18 @@ def decode(self, bytestring, **options): description = lookup(openapi, ['info', 'description']) version = lookup(openapi, ['info', 'version']) base_url = lookup(openapi, ['servers', 0, 'url']) - sections = self.get_sections(openapi, base_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_sections(self, openapi, base_url): + 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. """ @@ -73,7 +82,7 @@ def get_sections(self, openapi, base_url): } 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) + link = self.get_link(base_url, path, path_info, operation, operation_info, schema_definitions) if link is None: continue @@ -86,7 +95,7 @@ def get_sections(self, openapi, base_url): for key, value in links.items() ] - def get_link(self, base_url, path, path_info, operation, operation_info): + def get_link(self, base_url, path, path_info, operation, operation_info, schema_definitions): """ Return a single link in the document. """ @@ -108,10 +117,22 @@ def get_link(self, base_url, path, path_info, operation, operation_info): parameters += operation_info.get('parameters', []) fields = [ - self.get_field(parameter) + 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']) + if body_schema: + if isinstance(body_schema, string_types): + ref = body_schema[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: + fields += [Field(name=key, location='form', schema=value)] + return Link( id=id, url=urlparse.urljoin(base_url, path), @@ -121,7 +142,7 @@ def get_link(self, base_url, path, path_info, operation, operation_info): fields=fields ) - def get_field(self, parameter): + def get_field(self, parameter, schema_definitions): """ Return a single field in a link. """ @@ -133,7 +154,11 @@ def get_field(self, parameter): example = parameter.get('example') if schema is not None: - schema = JSONSchemaCodec().decode_from_data_structure(schema) + if isinstance(schema, string_types): + ref = schema[len('^#/components/schemas/'):] + schema = schema_definitions.get(ref) + else: + schema = JSONSchemaCodec().decode_from_data_structure(schema) return Field( name=name, diff --git a/coreapi/schemas/jsonschema.py b/coreapi/schemas/jsonschema.py index 55535ea..d9e62c6 100644 --- a/coreapi/schemas/jsonschema.py +++ b/coreapi/schemas/jsonschema.py @@ -2,11 +2,12 @@ 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())), + ('definitions', typesys.Object(additional_properties=typesys.Ref('JSONSchema'))), # String ('minLength', typesys.Integer(minimum=0, default=0)), @@ -22,16 +23,16 @@ ('multipleOf', typesys.Number(minimum=0.0, exclusive_minimum=True)), # Object - ('properties', typesys.Object(additional_properties=typesys.Ref())), + ('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())), - ('additionalProperties', typesys.Ref() | typesys.Boolean()), + ('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() | typesys.Array(items=typesys.Ref(), min_items=1)), - ('additionalItems', typesys.Ref() | typesys.Boolean()), + ('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 index 292a888..4ff1e78 100644 --- a/coreapi/schemas/openapi.py +++ b/coreapi/schemas/openapi.py @@ -2,7 +2,11 @@ from coreapi.schemas import JSONSchema +SchemaRefString = typesys.String(pattern='^#/components/schemas/') + + OpenAPI = typesys.Object( + self_ref='OpenAPI', title='OpenAPI', properties=[ ('openapi', typesys.String()), @@ -109,7 +113,7 @@ ('required', typesys.Boolean()), ('deprecated', typesys.Boolean()), ('allowEmptyValue', typesys.Boolean()), - ('schema', JSONSchema), # TODO: | RefString + ('schema', JSONSchema | SchemaRefString), ('example', typesys.Any()) # TODO: Other fields ], @@ -124,13 +128,13 @@ ), 'MediaType': typesys.Object( properties=[ - ('schema': JSONSchema), # TODO: | RefString - ('example': typesys.Any()) + ('schema', JSONSchema | SchemaRefString), + ('example', typesys.Any()) # TODO 'examples', 'encoding' ] ), 'Components': typesys.Object( - properties: [ + properties=[ ('schemas', typesys.Object(additional_properties=JSONSchema)), ] ), diff --git a/coreapi/typesys.py b/coreapi/typesys.py index 2536926..b1468aa 100644 --- a/coreapi/typesys.py +++ b/coreapi/typesys.py @@ -37,7 +37,7 @@ def __init__(self, detail): class Validator(object): errors = {} - def __init__(self, title='', description='', default=NO_DEFAULT, definitions=None): + 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) @@ -49,6 +49,7 @@ def __init__(self, title='', description='', default=NO_DEFAULT, definitions=Non self.title = title self.description = description self.definitions = definitions + self.self_ref = self_ref if default is not NO_DEFAULT: self.default = default @@ -73,6 +74,18 @@ def error(self, code): 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 @@ -299,10 +312,6 @@ def __init__(self, properties=None, pattern_properties=None, additional_properti self.allow_null = allow_null def validate(self, value, definitions=None): - if definitions is None: - definitions = dict(self.definitions) - definitions[''] = self - if value is None and self.allow_null: return None elif value is None: @@ -310,6 +319,7 @@ def validate(self, value, definitions=None): elif not isinstance(value, dict): self.error('type') + definitions = self.get_definitions(definitions) validated = dict_type() # Ensure all property keys are strings. @@ -412,10 +422,6 @@ def __init__(self, items=None, additional_items=None, min_items=None, max_items= self.allow_null = allow_null def validate(self, value, definitions=None): - if definitions is None: - definitions = dict(self.definitions) - definitions[''] = self - if value is None and self.allow_null: return None elif value is None: @@ -423,6 +429,7 @@ def validate(self, value, definitions=None): 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: @@ -500,7 +507,7 @@ def validate(self, value, definitions=None): class Ref(Validator): - def __init__(self, ref='', **kwargs): + def __init__(self, ref, **kwargs): super(Ref, self).__init__(**kwargs) assert isinstance(ref, string_types) self.ref = ref From 3a793b97a4b1300370a5f90f87eac63e0012105e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 6 Mar 2018 14:54:14 +0000 Subject: [PATCH 60/60] Add initial requestBody schema support --- coreapi/codecs/openapi.py | 16 ++++++++++------ coreapi/document.py | 3 +++ coreapi/schemas/openapi.py | 8 +++++--- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/coreapi/codecs/openapi.py b/coreapi/codecs/openapi.py index 93098f9..902190a 100644 --- a/coreapi/codecs/openapi.py +++ b/coreapi/codecs/openapi.py @@ -123,14 +123,17 @@ def get_link(self, base_url, path, path_info, operation, operation_info, schema_ # TODO: Handle media type generically here... body_schema = lookup(operation_info, ['requestBody', 'content', 'application/json', 'schema']) + + encoding = None if body_schema: - if isinstance(body_schema, string_types): - ref = body_schema[len('^#/components/schemas/'):] + 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: + for key, value in schema.properties.items(): fields += [Field(name=key, location='form', schema=value)] return Link( @@ -139,7 +142,8 @@ def get_link(self, base_url, path, path_info, operation, operation_info, schema_ method=operation, title=title, description=description, - fields=fields + fields=fields, + encoding=encoding ) def get_field(self, parameter, schema_definitions): @@ -154,8 +158,8 @@ def get_field(self, parameter, schema_definitions): example = parameter.get('example') if schema is not None: - if isinstance(schema, string_types): - ref = schema[len('^#/components/schemas/'):] + if '$ref' in schema: + ref = schema['$ref'][len('#/components/schemas/'):] schema = schema_definitions.get(ref) else: schema = JSONSchemaCodec().decode_from_data_structure(schema) diff --git a/coreapi/document.py b/coreapi/document.py index 5128cf4..2136931 100644 --- a/coreapi/document.py +++ b/coreapi/document.py @@ -266,6 +266,9 @@ def path_fields(self): 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 diff --git a/coreapi/schemas/openapi.py b/coreapi/schemas/openapi.py index 4ff1e78..a395f51 100644 --- a/coreapi/schemas/openapi.py +++ b/coreapi/schemas/openapi.py @@ -2,7 +2,9 @@ from coreapi.schemas import JSONSchema -SchemaRefString = typesys.String(pattern='^#/components/schemas/') +SchemaRef = typesys.Object( + properties={'$ref': typesys.String(pattern='^#/components/schemas/')} +) OpenAPI = typesys.Object( @@ -113,7 +115,7 @@ ('required', typesys.Boolean()), ('deprecated', typesys.Boolean()), ('allowEmptyValue', typesys.Boolean()), - ('schema', JSONSchema | SchemaRefString), + ('schema', JSONSchema | SchemaRef), ('example', typesys.Any()) # TODO: Other fields ], @@ -128,7 +130,7 @@ ), 'MediaType': typesys.Object( properties=[ - ('schema', JSONSchema | SchemaRefString), + ('schema', JSONSchema | SchemaRef), ('example', typesys.Any()) # TODO 'examples', 'encoding' ]