Repository: ckan/ckan

Issue:
lists not allowed as resource extras
### CKAN Version if known (or site URL)
all

### Please describe the expected behaviour
Resource extras are very liberal, almost any key may be used and complex nested JSON is supported as values natively.

### Please describe the actual behaviour
Resource extras with almost any JSON type are supported, but list values are silently removed .

### What steps can be taken to reproduce the issue? 
Pass an extra with a list value to the `resource_create` API.


Discussion:

Resolving patch:
diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py
--- a/ckan/lib/dictization/model_save.py
+++ b/ckan/lib/dictization/model_save.py
@@ -41,8 +41,6 @@ def resource_dict_save(res_dict, context):
     new_extras = {}
     has_changed = False
     for key, value in six.iteritems(res_dict):
-        if isinstance(value, list):
-            continue
         if key in ('extras', 'revision_timestamp', 'tracking_summary'):
             continue
         if key in fields:
diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py
--- a/ckan/logic/schema.py
+++ b/ckan/logic/schema.py
@@ -29,7 +29,7 @@ def wrapper():
 def default_resource_schema(
         ignore_empty, unicode_safe, ignore, ignore_missing,
         remove_whitespace, if_empty_guess_format, clean_format, isodate,
-        int_validator, extras_unicode_convert, keep_extras):
+        int_validator, extras_valid_json, keep_extras):
     return {
         'id': [ignore_empty, unicode_safe],
         'package_id': [ignore],
@@ -52,7 +52,7 @@ def default_resource_schema(
         'cache_last_updated': [ignore_missing, isodate],
         'tracking_summary': [ignore_missing],
         'datastore_active': [ignore_missing],
-        '__extras': [ignore_missing, extras_unicode_convert, keep_extras],
+        '__extras': [ignore_missing, extras_valid_json, keep_extras],
     }
 
 
diff --git a/ckan/logic/validators.py b/ckan/logic/validators.py
--- a/ckan/logic/validators.py
+++ b/ckan/logic/validators.py
@@ -9,7 +9,7 @@
 import string
 import json
 
-from six import string_types
+from six import string_types, iteritems
 from six.moves.urllib.parse import urlparse
 
 import ckan.lib.navl.dictization_functions as df
@@ -902,3 +902,13 @@ def json_object(value):
         raise Invalid(_('Could not parse the value as a valid JSON object'))
 
     return value
+
+
+def extras_valid_json(extras, context):
+    try:
+        for extra, value in iteritems(extras):
+            json.dumps(value)
+    except ValueError as e:
+        raise Invalid(_(u'Could not parse extra \'{name}\' as valid JSON').
+                format(name=extra))
+    return extras


Test patch:
diff --git a/ckan/tests/logic/action/test_create.py b/ckan/tests/logic/action/test_create.py
--- a/ckan/tests/logic/action/test_create.py
+++ b/ckan/tests/logic/action/test_create.py
@@ -630,6 +630,8 @@ def test_extras(self):
             package_id=dataset["id"],
             somekey="somevalue",  # this is how to do resource extras
             extras={u"someotherkey": u"alt234"},  # this isnt
+            subobject={u'hello': u'there'},  # JSON objects supported
+            sublist=[1, 2, 3],  # JSON lists suppoted
             format=u"plain text",
             url=u"http://datahub.io/download/",
         )
@@ -637,12 +639,16 @@ def test_extras(self):
         assert resource["somekey"] == "somevalue"
         assert "extras" not in resource
         assert "someotherkey" not in resource
+        assert resource["subobject"] == {u"hello": u"there"}
+        assert resource["sublist"] == [1, 2, 3]
         resource = helpers.call_action("package_show", id=dataset["id"])[
             "resources"
         ][0]
         assert resource["somekey"] == "somevalue"
         assert "extras" not in resource
         assert "someotherkey" not in resource
+        assert resource["subobject"] == {u"hello": u"there"}
+        assert resource["sublist"] == [1, 2, 3]
 
     @freeze_time('2020-02-25 12:00:00')
     def test_metadata_modified_is_set_to_utcnow_when_created(self):