Update frontend to use `allowNull` feature of `PUT /item/<id>/metadata` endpoint
Now that we are able to store actual `null` values in metadata, we should allow it to happen via the frontend.

What tasks would this entail? I believe this list will cover it:
- set `allowNull` to true when the metadata update call happens (i.e., when the "save" button is clicked)
- exclusively use the new `DELETE /item/<id>/metadata` endpoint to remove metadata entries that disappear from the metadata record when the "save" button is clicked
diff --git a/clients/web/src/models/MetadataMixin.js b/clients/web/src/models/MetadataMixin.js
index 1aa4e95ea..ddf204c0a 100644
--- a/clients/web/src/models/MetadataMixin.js
+++ b/clients/web/src/models/MetadataMixin.js
@@ -7,7 +7,7 @@ var MetadataMixin = {
         opts = opts || {};
         restRequest({
             path: opts.path ||
-                ((this.altUrl || this.resourceName) + '/' + this.get('_id') + '/metadata'),
+                ((this.altUrl || this.resourceName) + `/${this.id}/metadata?allowNull=true`),
             contentType: 'application/json',
             data: JSON.stringify(metadata),
             type: 'PUT',
@@ -40,9 +40,27 @@ var MetadataMixin = {
     },
 
     removeMetadata: function (key, successCallback, errorCallback, opts) {
-        var datum = {};
-        datum[key] = null;
-        this._sendMetadata(datum, successCallback, errorCallback, opts);
+        if (!_.isArray(key)) {
+            key = [key];
+        }
+        restRequest({
+            path: opts.path ||
+                ((this.altUrl || this.resourceName) + `/${this.id}/metadata`),
+            contentType: 'application/json',
+            data: JSON.stringify(key),
+            type: 'DELETE',
+            error: null
+        }).done(resp => {
+            this.set(opts.field || 'meta', resp.meta);
+            if (_.isFunction(successCallback)) {
+                successCallback();
+            }
+        }).error(err => {
+            err.message = err.responseJSON.message;
+            if (_.isFunction(errorCallback)) {
+                errorCallback(err);
+            }
+        });
     },
 
     editMetadata: function (newKey, oldKey, value, successCallback, errorCallback, opts) {
diff --git a/girder/api/v1/folder.py b/girder/api/v1/folder.py
index c51712a87..8f0fbd848 100644
--- a/girder/api/v1/folder.py
+++ b/girder/api/v1/folder.py
@@ -43,6 +43,7 @@ class Folder(Resource):
         self.route('PUT', (':id', 'access'), self.updateFolderAccess)
         self.route('POST', (':id', 'copy'), self.copyFolder)
         self.route('PUT', (':id', 'metadata'), self.setMetadata)
+        self.route('DELETE', (':id', 'metadata'), self.deleteMetadata)
 
     @access.public(scope=TokenScope.DATA_READ)
     @filtermodel(model='folder')
@@ -278,20 +279,16 @@ class Folder(Resource):
         .notes('Set metadata fields to null in order to delete them.')
         .modelParam('id', model='folder', level=AccessType.WRITE)
         .jsonParam('metadata', 'A JSON object containing the metadata keys to add',
-                   paramType='body')
+                   paramType='body', requireObject=True)
+        .param('allowNull', 'Whether "null" is allowed as a metadata value.', required=False,
+               dataType='boolean', default=False)
         .errorResponse(('ID was invalid.',
                         'Invalid JSON passed in request body.',
                         'Metadata key name was invalid.'))
         .errorResponse('Write access was denied for the folder.', 403)
     )
-    def setMetadata(self, folder, metadata, params):
-        # Make sure we let user know if we can't accept a metadata key
-        for k in metadata:
-            if '.' in k or k[0] == '$':
-                raise RestException('The key name %s must not contain a '
-                                    'period or begin with a dollar sign.' % k)
-
-        return self.model('folder').setMetadata(folder, metadata)
+    def setMetadata(self, folder, metadata, allowNull, params):
+        return self.model('folder').setMetadata(folder, metadata, allowNull=allowNull)
 
     @access.user(scope=TokenScope.DATA_WRITE)
     @filtermodel(model='folder')
@@ -355,3 +352,26 @@ class Folder(Resource):
                 ctx.update(total=self.model('folder').subtreeCount(folder) - 1)
             self.model('folder').clean(folder, progress=ctx)
         return {'message': 'Cleaned folder %s.' % folder['name']}
+
+    @access.user(scope=TokenScope.DATA_WRITE)
+    @filtermodel('folder')
+    @autoDescribeRoute(
+        Description('Delete metadata fields on a folder.')
+        .responseClass('Folder')
+        .modelParam('id', model='folder', level=AccessType.WRITE)
+        .jsonParam(
+            'fields', 'A JSON list containing the metadata fields to delete',
+            paramType='body', schema={
+                'type': 'array',
+                'items': {
+                    'type': 'string'
+                }
+            }
+        )
+        .errorResponse(('ID was invalid.',
+                        'Invalid JSON passed in request body.',
+                        'Metadata key name was invalid.'))
+        .errorResponse('Write access was denied for the folder.', 403)
+    )
+    def deleteMetadata(self, folder, fields, params):
+        return self.model('folder').deleteMetadata(folder, fields)
diff --git a/girder/api/v1/item.py b/girder/api/v1/item.py
index f6a9840e2..6f1c419dd 100644
--- a/girder/api/v1/item.py
+++ b/girder/api/v1/item.py
@@ -24,16 +24,6 @@ from girder.constants import AccessType, TokenScope
 from girder.api import access
 
 
-def _validateFields(fields):
-    for f in fields:
-        if not f:
-            raise RestException('Key names must not be empty.')
-        if '.' in f:
-            raise RestException('Invalid key %s: keys must not contain the "." character.' % f)
-        if f[0] == '$':
-            raise RestException('Invalid key %s: keys must not start with the "$" character.' % f)
-
-
 class Item(Resource):
 
     def __init__(self):
@@ -165,7 +155,7 @@ class Item(Resource):
         .notes('Set metadata fields to null in order to delete them.')
         .modelParam('id', model='item', level=AccessType.WRITE)
         .jsonParam('metadata', 'A JSON object containing the metadata keys to add',
-                   paramType='body')
+                   paramType='body', requireObject=True)
         .param('allowNull', 'Whether "null" is allowed as a metadata value.', required=False,
                dataType='boolean', default=False)
         .errorResponse(('ID was invalid.',
@@ -174,25 +164,29 @@ class Item(Resource):
         .errorResponse('Write access was denied for the item.', 403)
     )
     def setMetadata(self, item, metadata, allowNull, params):
-        # Make sure we let user know if we can't accept a metadata key
-        _validateFields(metadata)
-
-        return self.model('item').setMetadata(item, metadata, allowNull)
+        return self.model('item').setMetadata(item, metadata, allowNull=allowNull)
 
+    @access.user(scope=TokenScope.DATA_WRITE)
+    @filtermodel('item')
     @autoDescribeRoute(
         Description('Delete metadata fields on an item.')
         .responseClass('Item')
         .modelParam('id', model='item', level=AccessType.WRITE)
-        .jsonParam('fields', 'A JSON list containing the metadata fields to delete',
-                   paramType='body', requireArray=True)
+        .jsonParam(
+            'fields', 'A JSON list containing the metadata fields to delete',
+            paramType='body', schema={
+                'type': 'array',
+                'items': {
+                    'type': 'string'
+                }
+            }
+        )
         .errorResponse(('ID was invalid.',
                         'Invalid JSON passed in request body.',
                         'Metadata key name was invalid.'))
-        .errorResponse('Write acess was denied for the item.', 403)
+        .errorResponse('Write access was denied for the item.', 403)
     )
     def deleteMetadata(self, item, fields, params):
-        _validateFields(fields)
-
         return self.model('item').deleteMetadata(item, fields)
 
     def _downloadMultifileItem(self, item, user):
diff --git a/girder/models/folder.py b/girder/models/folder.py
index 4ad220aa5..7014465d0 100644
--- a/girder/models/folder.py
+++ b/girder/models/folder.py
@@ -156,7 +156,7 @@ class Folder(AccessControlledModel):
 
         return size
 
-    def setMetadata(self, folder, metadata):
+    def setMetadata(self, folder, metadata, allowNull=False):
         """
         Set metadata on a folder.  A `ValidationException` is thrown in the
         cases where the metadata JSON object is badly formed, or if any of the
@@ -167,6 +167,9 @@ class Folder(AccessControlledModel):
         :param metadata: A dictionary containing key-value pairs to add to
                      the folder's meta field
         :type metadata: dict
+        :param allowNull: Whether to allow `null` values to be set in the item's
+            metadata. If set to `False` or omitted, a `null` value will cause that
+            metadata field to be deleted.
         :returns: the folder document
         """
         if 'meta' not in folder:
@@ -176,15 +179,43 @@ class Folder(AccessControlledModel):
         folder['meta'].update(six.viewitems(metadata))
 
         # Remove metadata fields that were set to null (use items in py3)
-        folder['meta'] = {k: v
-                          for k, v in six.viewitems(folder['meta'])
-                          if v is not None}
+        if not allowNull:
+            toDelete = [k for k, v in six.viewitems(metadata) if v is None]
+            for key in toDelete:
+                del folder['meta'][key]
 
         folder['updated'] = datetime.datetime.utcnow()
 
+        self.validateKeys(folder['meta'])
+
         # Validate and save the item
         return self.save(folder)
 
+    def deleteMetadata(self, folder, fields):
+        """
+        Delete metadata on a folder. A `ValidationException` is thrown if the
+        metadata field names contain a period ('.') or begin with a dollar sign
+        ('$').
+
+        :param folder: The folder to delete metadata from.
+        :type folder: dict
+        :param fields: An array containing the field names to delete from the
+            folder's meta field
+        :type field: list
+        :returns: the folder document
+        """
+        self.validateKeys(fields)
+
+        if 'meta' not in folder:
+            folder['meta'] = {}
+
+        for field in fields:
+            folder['meta'].pop(field, None)
+
+        folder['updated'] = datetime.datetime.utcnow()
+
+        return self.save(folder)
+
     def _updateDescendants(self, folderId, updateQuery):
         """
         This helper is used to update all items and folders underneath a
diff --git a/girder/models/item.py b/girder/models/item.py
index a5f2bdc7c..09015d07c 100644
--- a/girder/models/item.py
+++ b/girder/models/item.py
@@ -312,6 +312,8 @@ class Item(acl_mixin.AccessControlMixin, Model):
             for key in toDelete:
                 del item['meta'][key]
 
+        self.validateKeys(item['meta'])
+
         item['updated'] = datetime.datetime.utcnow()
 
         # Validate and save the item
@@ -325,11 +327,13 @@ class Item(acl_mixin.AccessControlMixin, Model):
 
         :param item: The item to delete metadata from.
         :type item: dict
-        :param fields: An array containing the field names to deleete from the
-                   item's meta field
+        :param fields: An array containing the field names to delete from the
+            item's meta field
         :type field: list
         :returns: the item document
         """
+        self.validateKeys(fields)
+
         if 'meta' not in item:
             item['meta'] = {}
 
@@ -338,7 +342,6 @@ class Item(acl_mixin.AccessControlMixin, Model):
 
         item['updated'] = datetime.datetime.utcnow()
 
-        # Validate and save the item
         return self.save(item)
 
     def parentsToRoot(self, item, user=None, force=False):
diff --git a/girder/models/model_base.py b/girder/models/model_base.py
index b45c42213..b17e610de 100644
--- a/girder/models/model_base.py
+++ b/girder/models/model_base.py
@@ -206,6 +206,26 @@ class Model(ModelImporter):
         raise Exception('Must override validate() in %s model.'
                         % self.__class__.__name__)  # pragma: no cover
 
+    def validateKeys(self, keys):
+        """
+        Validate a set of keys to make sure they are able to be used in the
+        database. This enforces MongoDB rules about key names.
+        @TODO Add recurse=True argument if ``keys`` is a dict.
+
+        :param keys: An iterable of keys to validate.
+        :type keys: iterable
+        :raises: ValidationException
+        """
+        for k in keys:
+            if not k:
+                raise ValidationException('Key names must not be empty.')
+            if '.' in k:
+                raise ValidationException(
+                    'Invalid key %s: keys must not contain the "." character.' % k)
+            if k[0] == '$':
+                raise ValidationException(
+                    'Invalid key %s: keys must not start with the "$" character.' % k)
+
     def initialize(self):
         """
         Subclasses should override this and set the name of the collection as
diff --git a/tests/cases/folder_test.py b/tests/cases/folder_test.py
index 592f4a3f2..fd865235c 100644
--- a/tests/cases/folder_test.py
+++ b/tests/cases/folder_test.py
@@ -296,12 +296,10 @@ class FolderTestCase(base.TestCase):
                             method='PUT', user=self.admin,
                             body=json.dumps(metadata), type='application/json')
         self.assertStatus(resp, 400)
-        self.assertEqual(resp.json['message'],
-                         'The key name foo.bar must not contain a period' +
-                         ' or begin with a dollar sign.')
+        self.assertEqual(
+            resp.json['message'], 'Invalid key foo.bar: keys must not contain the "." character.')
 
-        # Make sure metadata cannot be added if the key begins with a
-        # dollar sign
+        # Make sure metadata cannot be added if the key begins with a $
         metadata = {
             '$foobar': 'alsonotallowed'
         }
@@ -309,9 +307,26 @@ class FolderTestCase(base.TestCase):
                             method='PUT', user=self.admin,
                             body=json.dumps(metadata), type='application/json')
         self.assertStatus(resp, 400)
-        self.assertEqual(resp.json['message'],
-                         'The key name $foobar must not contain a period' +
-                         ' or begin with a dollar sign.')
+        self.assertEqual(
+            resp.json['message'],
+            'Invalid key $foobar: keys must not start with the "$" character.')
+
+        # Test allowNull
+        metadata = {
+            'foo': None
+        }
+        resp = self.request(
+            path='/folder/%s/metadata' % folder['_id'], params={'allowNull': True},
+            user=self.admin, method='PUT', body=json.dumps(metadata), type='application/json')
+        self.assertStatusOk(resp)
+        self.assertEqual(resp.json['meta'], metadata)
+
+        # Test delete metadata endpoint
+        resp = self.request(
+            path='/folder/%s/metadata' % folder['_id'], user=self.admin, method='DELETE',
+            body=json.dumps(['foo']), type='application/json')
+        self.assertStatusOk(resp)
+        self.assertEqual(resp.json['meta'], {})
 
     def testDeleteFolder(self):
         cbInfo = {}
['girder/models/folder.py', 'tests/cases/folder_test.py', 'girder/api/v1/item.py', 'girder/models/item.py', 'clients/web/src/models/MetadataMixin.js', 'girder/api/v1/folder.py', 'girder/models/model_base.py']