8000 Merge pull request #61 from core-api/hyperschema · core-api/python-client@dc08895 · GitHub
[go: up one dir, main page]

Skip to content
This repository was archived by the owner on Mar 18, 2019. It is now read-only.

Commit dc08895

Browse files
committed
Merge pull request #61 from core-api/hyperschema
JSON Hyper-Schema
2 parents a31b020 + 3207073 commit dc08895

File tree

15 files changed

+387
-183
lines changed

15 files changed

+387
-183
lines changed

coreapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from coreapi import codecs, history, transports
77

88

9-
__version__ = '1.12.0'
9+
__version__ = '1.13.0'
1010
__all__ = [
1111
'Array', 'Document', 'Link', 'Object', 'Error', 'Field',
1212
'ParseError', 'NotAcceptable', 'TransportError', 'ErrorMessage',

coreapi/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def _lookup_link(document, keys):
3333
for idx, key in enumerate(keys):
3434
try:
3535
node = node[key]
36-
except (KeyError, IndexError):
36+
except (KeyError, IndexError, TypeError):
3737
index_string = ''.join('[%s]' % repr(key).strip('u') for key in keys)
3838
msg = 'Index %s did not reference a link. Key %s was not found.'
3939
raise LinkLookupError(msg % (index_string, repr(key).strip('u')))

coreapi/codecs/__init__.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
11
# coding: utf-8
22
from coreapi.codecs.base import BaseCodec
3+
from coreapi.codecs.corehtml import CoreHTMLCodec
34
from coreapi.codecs.corejson import CoreJSONCodec
5+
from coreapi.codecs.coretext import CoreTextCodec
46
from coreapi.codecs.hal import HALCodec
5-
from coreapi.codecs.html import HTMLCodec
7+
from coreapi.codecs.hyperschema import HyperschemaCodec
8+
from coreapi.codecs.jsondata import JSONCodec
69
from coreapi.codecs.plaintext import PlainTextCodec
710
from coreapi.codecs.python import PythonCodec
811
from coreapi.exceptions import NotAcceptable, UnsupportedContentType
912
import itypes
1013

1114

1215
__all__ = [
13-
'BaseCodec', 'CoreJSONCodec', 'HALCodec', 'HTMLCodec', 'PlainTextCodec', 'PythonCodec',
16+
'BaseCodec', 'CoreHTMLCodec', 'CoreJSONCodec', 'CoreTextCodec', 'HALCodec',
17+
'HyperschemaCodec', 'JSONCodec', 'PlainTextCodec', 'PythonCodec',
1418
]
1519

16-
default_decoders = itypes.List([CoreJSONCodec(), HALCodec()])
17-
default_encoders = itypes.List([CoreJSONCodec(), HALCodec(), HTMLCodec(), PlainTextCodec()])
20+
# Default set of decoders for clients to accept.
21+
default_decoders = itypes.List([
22+
CoreJSONCodec(), HALCodec(), HyperschemaCodec(), # Document decoders.
23+
JSONCodec(), PlainTextCodec() # Data decoders.
24+
])
25+
26+
# Default set of encoders for servers to respond with.
27+
default_encoders = itypes.List([
28+
CoreJSONCodec(), HALCodec(), CoreHTMLCodec()
29+
])
1830

1931

2032
def negotiate_decoder(content_type=None, decoders=None):

coreapi/codecs/base.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import itypes
33

44

5-
# Helper functions to get an expected type from a dictionary,
5+
# Helper functions to get an expected type from a dictionary.
66

77
def _get_string(item, key):
88
value = item.get(key)
@@ -24,6 +24,12 @@ def _get_bool(item, key, default=False):
2424
return value if isinstance(value, bool) else default
2525

2626

27+
# Helper functions to get an expected type from a list.
28+
29+
def get_dicts(item):
30+
return [value for value in item if isinstance(value, dict)]
31+
32+
2733
class BaseCodec(itypes.Object):
2834
media_type = None
2935

coreapi/codecs/html.py renamed to coreapi/codecs/corehtml.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def _render_html(node, url=None, key=None, path=''):
4040
return template.render(node=node, render=_render_html, url=url, key=key, path=path)
4141

4242

43-
class HTMLCodec(BaseCodec):
43+
class CoreHTMLCodec(BaseCodec):
4444
media_type = 'text/html'
4545

4646
def dump(self, document, extra_css=None, **kwargs):

coreapi/codecs/coretext.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from __future__ import unicode_literals
2+
from coreapi.codecs.base import BaseCodec
3+
from coreapi.compat import string_types
4+
from coreapi.document import Document, Link, Array, Object, Error
5+
import click
6+
import json
7+
8+
9+
def _colorize_document(text):
10+
return click.style(text, fg='green') # pragma: nocover
11+
12+
13+
def _colorize_error(text):
14+
return click.style(text, fg='red') # pragma: nocover
15+
16+
17+
def _colorize_keys(text):
18+
return click.style(text, fg='cyan') # pragma: nocover
19+
20+
21+
def _to_plaintext(node, indent=0, base_url=None, colorize=False, extra_offset=None):
22+
colorize_document = _colorize_document if colorize else lambda x: x
23+
colorize_error = _colorize_error if colorize else lambda x: x
24+
colorize_keys = _colorize_keys if colorize else lambda x: x
25+
26+
if isinstance(node, Document):
27+
head_indent = ' ' * indent
28+
body_indent = ' ' * (indent + 1)
29+
30+
body = '\n'.join([
31+
body_indent + colorize_keys(str(key) + ': ') +
32+
_to_plaintext(value, indent + 1, base_url=base_url, colorize=colorize, extra_offset=len(str(key)))
33+
for key, value in node.data.items()
34+
] + [
35+
body_indent + colorize_keys(str(key) + '(') +
36+
_fields_to_plaintext(value, colorize=colorize) + colorize_keys(')')
37+
for key, value in node.links.items()
38+
])
39+
40+
head = colorize_document('<%s %s>' % (
41+
node.title.strip() or 'Document',
42+
json.dumps(node.url)
43+
))
44+
45+
return head if (not body) else head + '\n' + body
46+
47+
elif isinstance(node, Object):
48+
head_indent = ' ' * indent
49+
body_indent = ' ' * (indent + 1)
50+
51+
body = '\n'.join([
52+
body_indent + colorize_keys(str(key)) + ': ' +
53+
_to_plaintext(value, indent + 1, base_url=base_url, colorize=colorize, extra_offset=len(str(key)))
54+
for key, value in node.data.items()
55+
] + [
56+
body_indent + colorize_keys(str(key) + '(') +
57+
_fields_to_plaintext(value, colorize=colorize) + colorize_keys(')')
58+
for key, value in node.links.items()
59+
])
60+
61+
return '{}' if (not body) else '{\n' + body + '\n' + head_indent + '}'
62+
63+
if isinstance(node, Error):
64+
head_indent = ' ' * indent
65+
body_indent = ' ' * (indent + 1)
66+
67+
body = '\n'.join([
68+
body_indent + colorize_keys(str(key) + ': ') +
69+
_to_plaintext(value, indent + 1, base_url=base_url, colorize=colorize, extra_offset=len(str(key)))
70+
for key, value in node.items()
71+
])
72+
73+
head = colorize_error('<Error: %s>' % node.title.strip() if node.title else '<Error>')
74+
75+
return head if (not body) else head + '\n' + body
76+
77+
elif isinstance(node, Array):
78+
head_indent = ' ' * indent
79+
body_indent = ' ' * (indent + 1)
80+
81+
body = ',\n'.join([
82+
body_indent + _to_plaintext(value, indent + 1, base_url=base_url, colorize=colorize)
83+
for value in node
84+
])
85+
86+
return '[]' if (not body) else '[\n' + body + '\n' + head_indent + ']'
87+
88+
elif isinstance(node, Link):
89+
return (
90+
colorize_keys('link(') +
91+
_fields_to_plaintext(node, colorize=colorize) +
92+
colorize_keys(')')
93+
)
94+
95+
if isinstance(node, string_types) and (extra_offset is not None) and ('\n' in node):
96+
# Display newlines in strings gracefully.
97+
text = json.dumps(node)
98+
spacing = (' ' * indent) + (' ' * extra_offset) + ' '
99+
return text.replace('\\n', '\n' + spacing)
100+
101+
return json.dumps(node)
102+
103+
104+
def _fields_to_plaintext(link, colorize=False):
105+
colorize_keys = _colorize_keys if colorize else lambda x: x
106+
107+
return colorize_keys(', ').join([
108+
field.name for field in link.fields if field.required
109+
] + [
110+
'[%s]' % field.name for field in link.fields if not field.required
111+
])
112+
113+
114+
class CoreTextCodec(BaseCodec):
115+
"""
116+
A plaintext representation of a Document, intended for readability.
117+
"""
118+
media_type = 'text/plain'
119+
120+
def dump(self, node, colorize=False, **kwargs):
121+
return _to_plaintext(node, colorize=colorize)

coreapi/codecs/hal.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,6 @@ def load(self, bytes, base_url=None):
200200

201201
doc = _parse_document(data, base_url)
202202
if not isinstance(doc, Document):
203-
raise ParseError('Top level node must be a document message.')
203+
raise ParseError('Top level node must be a document.')
204204

205205
return doc

coreapi/codecs/hyperschema.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# coding: utf-8
2+
from coreapi.codecs.base import BaseCodec, _get_string, _get_list, _get_dict, get_dicts
3+
from coreapi.compat import urlparse
4+
from coreapi.document import Document, Link, Field
5+
from coreapi.exceptions import ParseError
6+
import json
7+
import uritemplate
8+
import urllib
9+
10+
11+
def _dereference(value, ref):
12+
keys = value.strip('#/').split('/')
13+
node = ref
14+
for key in keys:
15+
node = _get_dict(node, key)
16+
return node
17+
18+
19+
def _get_content(data, base_url, ref):
20+
content = {}
21+
links = _get_list(data, 'links')
22+
properties = _get_dict(data, 'properties')
23+
24+
if properties:
25+
for key, value in properties.items():
26+
if not isinstance(value, dict):
27+
continue
28+
if list(value.keys()) == ['$ref']:
29+
value = _dereference(value['$ref'], ref)
30+
sub_content = _get_content(value, base_url, ref)
31+
if sub_content:
32+
content[key] = sub_content
33+
if links:
34+
for link in get_dicts(links):
35+
rel = _get_string(link, 'rel')
36+
if rel:
37+
href = _get_string(link, 'href')
38+
method = _get_string(link, 'method')
39+
schema = _get_dict(link, 'schema')
40+
schema_type = _get_list(schema, 'type')
41+
schema_properties = _get_dict(schema, 'properties')
42+
schema_required = _get_list(schema, 'required')
43+
44+
fields = []
45+
url = urlparse.urljoin(base_url, href)
46+
templated = uritemplate.variables(url)
47+
for item in templated:
48+
orig = item
49+
if item.startswith('(') and item.endswith(')'):
50+
item = urllib.unquote(item.strip('(').rstrip(')'))
51+
if item.startswith('#/'):
52+
components = [
53+
component for component in item.strip('#/').split('/')
54+
if component != 'definitions'
55+
]
56+
item = '_'.join(components).replace('-', '_')
57+
url = url.replace(orig, item)
58+
fields.append(Field(name=item, location='path', required=True))
59+
60+
if schema_type == ['object'] and schema_properties:
61+
fields += [
62+
Field(name=key, required=key in schema_required)
63+
for key in schema_properties.keys()
64+
]
65+
if rel == 'self':
66+
rel = 'read'
67+
content[rel] = Link(url=url, action=method, fields=fields)
68+
69+
return content
70+
71+
72+
def _primative_to_document(data, base_url):
73+
url = base_url
74+
75+
# Determine if the document contains a self URL.
76+
links = _get_list(data, 'links')
77+
for link in get_dicts(links):
78+
href = _get_string(link, 'href')
79+
rel = _get_string(link, 'rel')
80+
if rel == 'self' and href:
81+
url = urlparse.urljoin(url, href)
82+
83+
# Load the document content.
84+
title = _get_string(data, 'title')
85+
content = _get_content(data, url, ref=data)
86+
return Document(title=title, url=url, content=content)
87+
88+
89+
class HyperschemaCodec(BaseCodec):
90+
"""
91+
JSON Hyper-Schema.
92+
"""
93+
media_type = 'application/schema+json'
94+
95+
def load(self, bytes, base_url=None):
96+
"""
97+
Takes a bytestring and returns a document.
98+
"""
99+
try:
100+
data = json.loads(bytes.decode('utf-8'))
101+
except ValueError as exc:
102+
raise ParseError('Malformed JSON. %s' % exc)
103+
104+
doc = _primative_to_document(data, base_url)
105+
if not (isinstance(doc, Document)):
106+
raise ParseError('Top level node must be a document.')
107+
108+
return doc

coreapi/codecs/jsondata.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# coding: utf-8
2+
from coreapi.codecs.base import BaseCodec
3+
from coreapi.exceptions import ParseError
4+
import json
5+
6+
7+
class JSONCodec(BaseCodec):
8+
media_type = 'application/json'
9+
10+
def load(self, bytes, base_url=None):
11+
"""
12+
Return raw JSON data.
13+
"""
14+
try:
15+
return json.loads(bytes.decode('utf-8'))
16+
except ValueError as exc:
17+
raise ParseError('Malformed JSON. %s' % exc)

0 commit comments

Comments
 (0)
0