implement HTML JSON form style per spec

This commit is contained in:
S. Andrew Sheppard 2015-03-12 16:13:42 -05:00
parent 67ddd54a89
commit 7fa40cfa57
3 changed files with 240 additions and 72 deletions

View File

@ -375,7 +375,7 @@ class Serializer(BaseSerializer):
# We override the default field access in order to support # We override the default field access in order to support
# nested HTML forms. # nested HTML forms.
if html.is_html_input(dictionary): if html.is_html_input(dictionary):
return html.parse_html_dict(dictionary, prefix=self.field_name) or empty return html.parse_json_form(dictionary, prefix=self.field_name) or empty
return dictionary.get(self.field_name, empty) return dictionary.get(self.field_name, empty)
def run_validation(self, data=empty): def run_validation(self, data=empty):
@ -525,7 +525,7 @@ class ListSerializer(BaseSerializer):
# We override the default field access in order to support # We override the default field access in order to support
# lists in HTML forms. # lists in HTML forms.
if html.is_html_input(dictionary): if html.is_html_input(dictionary):
return html.parse_html_list(dictionary, prefix=self.field_name) return html.parse_json_form(dictionary, prefix=self.field_name)
return dictionary.get(self.field_name, empty) return dictionary.get(self.field_name, empty)
def run_validation(self, data=empty): def run_validation(self, data=empty):
@ -553,7 +553,7 @@ class ListSerializer(BaseSerializer):
List of dicts of native values <- List of dicts of primitive datatypes. List of dicts of native values <- List of dicts of primitive datatypes.
""" """
if html.is_html_input(data): if html.is_html_input(data):
data = html.parse_html_list(data) data = html.parse_json_form(data)
if not isinstance(data, list): if not isinstance(data, list):
message = self.error_messages['not_a_list'].format( message = self.error_messages['not_a_list'].format(

View File

@ -3,8 +3,6 @@ Helpers for dealing with HTML input.
""" """
import re import re
from django.utils.datastructures import MultiValueDict
def is_html_input(dictionary): def is_html_input(dictionary):
# MultiDict type datastructures are used to represent HTML form input, # MultiDict type datastructures are used to represent HTML form input,
@ -12,78 +10,248 @@ def is_html_input(dictionary):
return hasattr(dictionary, 'getlist') return hasattr(dictionary, 'getlist')
def parse_html_list(dictionary, prefix=''): def parse_json_form(dictionary, prefix=''):
""" """
Used to suport list values in HTML forms. Parse an HTML JSON form submission as per the W3C Draft spec
Supports lists of primitives and/or dictionaries. An implementation of "The application/json encoding algorithm"
http://www.w3.org/TR/html-json-forms/
"""
# Step 1: Initialize output object
output = {}
for name, value in dictionary.items():
# TODO: implement is_file flag
* List of primitives. # Step 2: Compute steps array
steps = parse_json_path(name)
{ # Step 3: Initialize context
'[0]': 'abc', context = output
'[1]': 'def',
'[2]': 'hij' # Step 4: Iterate through steps
} for step in steps:
--> # Step 4.1 Retrieve current value from context
[ current_value = get_value(context, step.key, Undefined())
'abc',
'def', # Steps 4.2, 4.3: Set JSON value on context
'hij' context = set_json_value(
context=context,
step=step,
current_value=current_value,
entry_value=value,
is_file=False,
)
# Account for DRF prefix (not part of JSON form spec)
result = get_value(output, prefix, Undefined())
if isinstance(result, Undefined):
return output
else:
return result
def parse_json_path(path):
"""
Parse a string as a JSON path
An implementation of "steps to parse a JSON encoding path"
http://www.w3.org/TR/html-json-forms/#dfn-steps-to-parse-a-json-encoding-path
"""
# Steps 1, 2, 3
original_path = path
steps = []
# Step 11 (Failure)
failed = [
JsonStep(
type="object",
key=original_path,
last=True,
failed=True,
)
] ]
* List of dictionaries. # Other variables for later use
digit_re = re.compile(r'^\[([0-9]+)\]')
key_re = re.compile(r'^\[([^\]]+)\]')
{ # Step 4 - Find characters before first [ (if any)
'[0]foo': 'abc', parts = path.split("[")
'[0]bar': 'def', first_key = parts[0]
'[1]foo': 'hij', if parts[1:]:
'[1]bar': 'klm', path = "[" + "[".join(parts[1:])
} else:
--> path = ""
[
{'foo': 'abc', 'bar': 'def'}, # Step 5 - According to spec, keys cannot start with [
{'foo': 'hij', 'bar': 'klm'} # NOTE: This was allowed in older DRF versions, so disabling rule for now
] # if not first_key:
""" # return failed
ret = {}
regex = re.compile(r'^%s\[([0-9]+)\](.*)$' % re.escape(prefix)) # Step 6 - Save initial step
for field, value in dictionary.items(): steps.append(JsonStep(
match = regex.match(field) type="object",
if not match: key=first_key,
))
# Step 7 - Simple single-step case (no [ found)
if not path:
steps[-1].last = True
return steps
# Step 8 - Loop
while path:
# Step 8.1 - Check for single-item array
if path[:1] == "[]":
steps[-1].append = True
path = path[2:]
if path:
return failed
continue continue
index, key = match.groups()
index = int(index) # Step 8.2 - Check for array[index]
if not key: digit_match = digit_re.match(path)
ret[index] = value if digit_match:
elif isinstance(ret.get(index), dict): path = digit_re.sub("", path)
ret[index][key] = value steps.append(JsonStep(
type="array",
key=int(digit_match.group(1)),
))
continue
# Step 8.3 - Check for object[key]
key_match = key_re.match(path)
if key_match:
path = key_re.sub("", path)
steps.append(JsonStep(
type="object",
key=key_match.group(1),
))
continue
# Step 8.4 - Invalid key format
return failed
# Step 9
next_step = None
for step in reversed(steps):
if next_step:
step.next_type = next_step.type
else: else:
ret[index] = MultiValueDict({key: [value]}) step.last = True
return [ret[item] for item in sorted(ret.keys())] next_step = step
return steps
def parse_html_dict(dictionary, prefix=''): def set_json_value(context, step, current_value, entry_value, is_file):
""" """
Used to support dictionary values in HTML forms. Apply a JSON value to a context object
An implementation of "steps to set a JSON encoding value"
{ http://www.w3.org/TR/html-json-forms/#dfn-steps-to-set-a-json-encoding-value
'profile.username': 'example',
'profile.email': 'example@example.com',
}
-->
{
'profile': {
'username': 'example',
'email': 'example@example.com'
}
}
""" """
ret = {}
regex = re.compile(r'^%s\.(.+)$' % re.escape(prefix)) # TODO: handle is_file
for field, value in dictionary.items():
match = regex.match(field) # Add empty values to array so indexing works like JavaScript
if not match: # TODO: According to spec, these should turn into nulls if they are never
continue # overriden in a later step.
key = match.groups()[0] if isinstance(context, list) and isinstance(step.key, int):
ret[key] = value while len(context) <= step.key:
return ret context.append(Undefined())
# Step 7: Handle last step
if step.last:
if isinstance(current_value, Undefined):
# Step 7.1: No existing value
if step.append:
context[step.key] = [entry_value]
else:
context[step.key] = entry_value
elif isinstance(current_value, list):
# Step 7.2: Existing value is an Array
context[step.key].append(entry_value)
elif isinstance(current_value, dict) and not is_file:
# Step 7.3: Existing value is an Object
return set_json_value(
context=current_value,
step=JsonStep(type="object", key="", last=True),
current_value=current_value.get("", Undefined()),
entry_value=entry_value,
is_file=is_file,
)
else:
# Step 7.4: Existing value is a scalar; preserve both values
context[step.key] = [current_value, entry_value]
# Step 7.5
return context
# Step 8: Handle intermediate steps
if isinstance(current_value, Undefined):
# 8.1: No existing value
if step.next_type == "array":
context[step.key] = []
else:
context[step.key] = {}
return context[step.key]
elif isinstance(current_value, dict):
# Step 7.2: Existing value is an Object
return get_value(context, step.key, Undefined())
elif isinstance(current_value, list):
# Step 7.3: Existing value is an Array
if step.next_type == "array":
return current_value
obj = {}
for i, item in enumerate(current_value):
if not isinstance(item, Undefined):
obj[i] = item
context[step.key] = obj
return obj
else:
# 7.4: Existing value is a scalar; convert to Object, preserving
# current value via an empty key
obj = {'': current_value}
context[step.key] = obj
return obj
def get_value(obj, key, default=None):
"""
Mimic JavaScript Object/Array behavior by allowing access to nonexistent
indexes.
"""
if isinstance(obj, dict):
return obj.get(key, default)
elif isinstance(obj, list):
try:
return obj[key]
except IndexError:
return default
class Undefined(object):
"""
Use Undefined() rather than None to distinguish from null.
"""
pass
class JsonStep(object):
"""
Struct to represent "step" as described in HTML JSON form algorithm
"""
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def __repr__(self):
vals = ",".join(
"%s=%s" % (key, val) for key, val in self.__dict__.items()
)
return "JsonStep(%s)" % vals
type = None
next_type = None
key = None
append = None
last = None
failed = None

View File

@ -125,10 +125,10 @@ class TestListSerializerContainingNestedSerializer:
style prefixes. style prefixes.
""" """
input_data = MultiValueDict({ input_data = MultiValueDict({
"[0]integer": ["123"], "[0][integer]": ["123"],
"[0]boolean": ["true"], "[0][boolean]": ["true"],
"[1]integer": ["456"], "[1][integer]": ["456"],
"[1]boolean": ["false"] "[1][boolean]": ["false"]
}) })
expected_output = [ expected_output = [
{"integer": 123, "boolean": True}, {"integer": 123, "boolean": True},