diff --git a/app/controllers/custom_data_controller.rb b/app/controllers/custom_data_controller.rb new file mode 100644 index 00000000000..18530c2e69a --- /dev/null +++ b/app/controllers/custom_data_controller.rb @@ -0,0 +1,411 @@ +# +# Copyright (C) 2014 Instructure, Inc. +# +# This file is part of Canvas. +# +# Canvas is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, version 3 of the License. +# +# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# + +# @API Users +class CustomDataController < ApplicationController + before_filter :require_namespace, :get_scope, :get_context + before_filter :require_custom_data, :except => :set_data + + # @API Store custom data + # @beta + # Store arbitrary user data as JSON. + # + # Arbitrary JSON data can be stored for a User. + # A typical scenario would be an external site/service that registers users in Canvas + # and wants to capture additional info about them. The part of the URL that follows + # +/custom_data/+ defines the scope of the request, and it reflects the structure of + # the JSON data to be stored or retrieved. + # + # A namespace parameter, +ns+, is used to prevent custom_data collisions between + # different apps. This parameter is required for all custom_data requests. + # + # A request with Content-Type multipart/form-data or Content-Type + # application/x-www-form-urlencoded can only be used to store strings. + # + # Example PUT with multipart/form-data data: + # curl 'https:///api/v1/users//custom_data/telephone' \ + # -X PUT \ + # -F 'ns=com.my-organization.canvas-app' \ + # -F 'data=555-1234' \ + # -H 'Authorization: Bearer ' + # + # Response: + # !!!javascript + # { + # "data": "555-1234" + # } + # + # Subscopes (or, generated scopes) can also be specified by passing values to + # data[]. + # + # Example PUT specifying subscopes: + # curl 'https:///api/v1/users//custom_data/body/measurements' \ + # -X PUT \ + # -F 'ns=com.my-organization.canvas-app' \ + # -F 'data[waist]=32in' \ + # -F 'data[inseam]=34in' \ + # -F 'data[chest]=40in' \ + # -H 'Authorization: Bearer ' + # + # Response: + # !!!javascript + # { + # "data": { + # "chest": "40in", + # "waist": "32in", + # "inseam": "34in" + # } + # } + # + # Following such a request, subsets of the stored data to be retrieved directly from a subscope. + # + # Example {api:UsersController#get_custom_data GET} from a generated scope + # curl 'https:///api/v1/users//custom_data/body/measurements/chest' \ + # -X GET \ + # -F 'ns=com.my-organization.canvas-app' \ + # -H 'Authorization: Bearer ' + # + # Response: + # !!!javascript + # { + # "data": "40in" + # } + # + # If you want to store more than just strings (i.e. numbers, arrays, hashes, true, false, + # and/or null), you must make a request with Content-Type application/json as in the following + # example. + # + # Example PUT with JSON data: + # curl 'https:///api/v1/users//custom_data' \ + # -H 'Content-Type: application/json' \ + # -X PUT \ + # -d '{ + # "ns": "com.my-organization.canvas-app", + # "data": { + # "a-number": 6.02e23, + # "a-bool": true, + # "a-string": "true", + # "a-hash": {"a": {"b": "ohai"}}, + # "an-array": [1, "two", null, false] + # } + # }' \ + # -H 'Authorization: Bearer ' + # + # Response: + # !!!javascript + # { + # "data": { + # "a-number": 6.02e+23, + # "a-bool": true, + # "a-string": "true", + # "a-hash": { + # "a": { + # "b": "ohai" + # } + # }, + # "an-array": [1, "two", null, false] + # } + # } + # + # If the data is an Object (as it is in the above example), then subsets of the data can + # be accessed by including the object's (possibly nested) keys in the scope of a GET request. + # + # Example {api:UsersController#get_custom_data GET} with a generated scope: + # curl 'https:///api/v1/users//custom_data/a-hash/a/b' \ + # -X GET \ + # -F 'ns=com.my-organization.canvas-app' \ + # -H 'Authorization: Bearer ' + # + # Response: + # !!!javascript + # { + # "data": "ohai" + # } + # + # + # On success, this endpoint returns an object containing the data that was stored. + # + # Responds with status code 200 if the scope already contained data, and it was overwritten + # by the data specified in the request. + # + # Responds with status code 201 if the scope was previously empty, and the data specified + # in the request was successfully stored there. + # + # Responds with status code 400 if the namespace parameter, +ns+, is missing or invalid, or if + # the +data+ parameter is missing. + # + # Responds with status code 409 if the requested scope caused a conflict and data was not stored. + # This happens when storing data at the requested scope would cause data at an outer scope + # to be lost. e.g., if /custom_data currently was {"fashion_app": {"hair": "blonde"}}, but + # you tried to PUT /custom_data/fashion_app/hair/style -F 'data=buzz', then for the request + # to succeed,the value of /custom_data/fashion_app/hair would have to become a hash, and its + # old string value would be lost. In this situation, an error object is returned with the + # following format: + # + # !!!javascript + # { + # "message": "write conflict for custom_data hash", + # "conflict_scope": "fashion_app/hair", + # "type_at_conflict": "String", + # "value_at_conflict": "blonde" + # } + # + # @argument ns [String] + # The namespace under which to store the data. This should be something other + # Canvas API apps aren't likely to use, such as a reverse DNS for your organization. + # + # @argument data [JSON] + # The data you want to store for the user, at the specified scope. If the data is + # composed of (possibly nested) JSON objects, scopes will be generated for the (nested) + # keys (see examples). + # + # @example_request + # curl 'https:///api/v1/users//custom_data/food_app' \ + # -X PUT \ + # -F 'ns=com.my-organization.canvas-app' \ + # -F 'data[weight]=81kg' \ + # -F 'data[favorites][meat]=pork belly' \ + # -F 'data[favorites][dessert]=pistachio ice cream' \ + # -H 'Authorization: Bearer ' + # + # @example_response + # { + # "data": { + # "weight": "81kg", + # "favorites": { + # "meat": "pork belly", + # "dessert": "pistachio ice cream" + # } + # } + # } + def set_data + return unless authorized_action(@context, @current_user, [:manage, :manage_user_details]) + + cd = CustomData.where(user_id: @context.id, namespace: @namespace).first + cd = CustomData.new(user_id: @context.id, namespace: @namespace) unless cd + + data = params[:data] + render(json: {message: 'no data specified'}, status: :bad_request) and return if data.nil? + + begin + overwrite = cd.set_data(@scope, data) + rescue CustomData::WriteConflict => wc + render(json: wc.as_json.merge({message: wc.message}), status: :conflict) and return + end + if cd.save + render(json: {data: cd.get_data(@scope)}, + status: (overwrite ? :ok : :created)) + else + render(json: cd.errors, status: :bad_request) + end + end + + # @API Load custom data + # @beta + # Load custom user data. + # + # Arbitrary JSON data can be stored for a User. This API call + # retrieves that data for a (optional) given scope. + # See {api:UsersController#set_custom_data Store Custom Data} for details and + # examples. + # + # On success, this endpoint returns an object containing the data that was requested. + # + # Responds with status code 400 if the namespace parameter, +ns+, is missing or invalid, + # or if the specified scope does not contain any data. + # + # @argument ns [String] + # The namespace from which to retrieve the data. This should be something other + # Canvas API apps aren't likely to use, such as a reverse DNS for your organization. + # + # @example_request + # curl 'https:///api/v1/users//custom_data/food_app/favorites/dessert' \ + # -X GET \ + # -F 'ns=com.my-organization.canvas-app' \ + # -H 'Authorization: Bearer ' + # + # @example_response + # { + # "data": "pistachio ice cream" + # } + def get_data + return unless authorized_action(@context, @current_user, :read) + + begin + data = @cd.get_data @scope + rescue ArgumentError => e + render(json: {message: e.message}, status: :bad_request) and return + end + render(json: {data: data}) + end + + # @API Delete custom data + # @beta + # Delete custom user data. + # + # Arbitrary JSON data can be stored for a User. This API call + # deletes that data for a given scope. Without a scope, all custom_data is deleted. + # See {api:UsersController#set_custom_data Store Custom Data} for details and + # examples of storage and retrieval. + # + # As an example, we'll store some data, then delete a subset of it. + # + # Example {api:UsersController#set_custom_data PUT} with valid JSON data: + # curl 'https:///api/v1/users//custom_data' \ + # -X PUT \ + # -F 'ns=com.my-organization.canvas-app' \ + # -F 'data[fruit][apple]=so tasty' \ + # -F 'data[fruit][kiwi]=a bit sour' \ + # -F 'data[veggies][root][onion]=tear-jerking' \ + # -H 'Authorization: Bearer ' + # + # Response: + # !!!javascript + # { + # "data": { + # "fruit": { + # "apple": "so tasty", + # "kiwi": "a bit sour" + # }, + # "veggies": { + # "root": { + # "onion": "tear-jerking" + # } + # } + # } + # } + # + # Example DELETE: + # curl 'https:///api/v1/users//custom_data/fruit/kiwi' \ + # -X DELETE \ + # -F 'ns=com.my-organization.canvas-app' \ + # -H 'Authorization: Bearer ' + # + # Response: + # !!!javascript + # { + # "data": "a bit sour" + # } + # + # Example {api:UsersController#get_custom_data GET} following the above DELETE: + # curl 'https:///api/v1/users//custom_data' \ + # -X GET \ + # -F 'ns=com.my-organization.canvas-app' \ + # -H 'Authorization: Bearer ' + # + # Response: + # !!!javascript + # { + # "data": { + # "fruit": { + # "apple": "so tasty" + # }, + # "veggies": { + # "root": { + # "onion": "tear-jerking" + # } + # } + # } + # } + # + # Note that hashes left empty after a DELETE will get removed from the custom_data store. + # For example, following the previous commands, if we delete /custom_data/veggies/root/onion, + # then the entire /custom_data/veggies scope will be removed. + # + # Example DELETE that empties a parent scope: + # curl 'https:///api/v1/users//custom_data/veggies/root/onion' \ + # -X DELETE \ + # -F 'ns=com.my-organization.canvas-app' \ + # -H 'Authorization: Bearer ' + # + # Response: + # !!!javascript + # { + # "data": "tear-jerking" + # } + # + # Example {api:UsersController#get_custom_data GET} following the above DELETE: + # curl 'https:///api/v1/users//custom_data' \ + # -X GET \ + # -F 'ns=com.my-organization.canvas-app' \ + # -H 'Authorization: Bearer ' + # + # Response: + # !!!javascript + # { + # "data": { + # "fruit": { + # "apple": "so tasty" + # } + # } + # } + # + # On success, this endpoint returns an object containing the data that was deleted. + # + # Responds with status code 400 if the namespace parameter, +ns+, is missing or invalid, + # or if the specified scope does not contain any data. + # + # @argument ns [String] + # The namespace from which to delete the data. This should be something other + # Canvas API apps aren't likely to use, such as a reverse DNS for your organization. + # + # @example_request + # curl 'https:///api/v1/users//custom_data/fruit/kiwi' \ + # -X DELETE \ + # -F 'ns=com.my-organization.canvas-app' \ + # -H 'Authorization: Bearer ' + # + # @example_response + # !!!javascript + # { + # "data": "a bit sour" + # } + def delete_data + return unless authorized_action(@context, @current_user, [:manage, :manage_user_details]) + + begin + ret = @cd.delete_data(@scope) + rescue ArgumentError => e + render(json: {message: e.message}, status: :bad_request) and return + end + + if @cd.destroyed? || @cd.save + render(json: {data: ret}) + else + render(json: @cd.errors, status: :bad_request) + end + end + + private + + def require_namespace + @namespace = params[:ns] + render(json: { message: "invalid namespace" }, status: :bad_request) and return if @namespace.blank? + end + + def get_scope + @scope = params[:scope] + @scope = @scope.join('/') if CANVAS_RAILS2 + end + + def require_custom_data + @cd = CustomData.where(user_id: @context.id, namespace: @namespace).first or + render(json: { message: "no data for scope" }, status: :bad_request) + end + +end \ No newline at end of file diff --git a/app/models/custom_data.rb b/app/models/custom_data.rb new file mode 100644 index 00000000000..4dd9792cb71 --- /dev/null +++ b/app/models/custom_data.rb @@ -0,0 +1,115 @@ +# +# Copyright (C) 2014 Instructure, Inc. +# +# This file is part of Canvas. +# +# Canvas is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, version 3 of the License. +# +# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# + +class CustomData < ActiveRecord::Base + class WriteConflict < Exception + attr_accessor :conflict_scope, :type_at_conflict, :value_at_conflict + + def initialize(opts = {}) + opts.each do |k,v| + instance_variable_set("@#{k}", v) + end + super 'write conflict for custom_data hash' + end + end + + if CANVAS_RAILS2 + set_table_name 'custom_data' + else + self.table_name = 'custom_data' + end + + belongs_to :user + + serialize :data, Hash + attr_protected :data + + validates_presence_of :user, :namespace + + def get_data(scope) + hash_data_from_scope(data_frd, "d/#{scope}") + end + + def set_data(scope, val) + set_hash_data_from_scope(data_frd, "d/#{scope}", val) + end + + def delete_data(scope) + delete_hash_data_from_scope(data_frd, "d/#{scope}") + end + + private + + def hash_data_from_scope(hash, scope) + keys = scope.split('/') + keys.inject(hash) do |hash, k| + raise ArgumentError, 'invalid scope for hash' unless hash.is_a? Hash + hash[k] + end + end + + def set_hash_data_from_scope(hash, scope, data) + keys = scope.split('/') + last = keys.pop + + traverse = ->(hsh, key_idx) do + return hsh if key_idx == keys.length + k = keys[key_idx] + h = hsh[k] + if h.nil? + hsh[k] = {} + elsif !h.is_a? Hash + raise WriteConflict.new({ + conflict_scope: keys.slice(1..key_idx).join('/'), + type_at_conflict: h.class, + value_at_conflict: h + }) + end + traverse.call(hsh[k], key_idx+1) + end + + h = traverse.call(hash, 0) + overwrite = !h[last].nil? + h[last] = data + overwrite + end + + def delete_hash_data_from_scope(hash, scope) + keys = scope.split('/') + del_frd = ->(hash) do + k = keys.shift + if keys.empty? + raise ArgumentError, 'invalid scope for hash' unless hash.has_key? k + hash.delete k + else + h = hash[k] + raise ArgumentError, 'invalid scope for hash' if h.nil? + ret = del_frd.call(h) + hash.delete k if h.empty? + ret + end + end + ret = del_frd.call(hash) + self.destroy if hash.empty? + ret + end + + def data_frd + read_attribute(:data) || write_attribute(:data, {}) + end +end diff --git a/config/routes.rb b/config/routes.rb index 1f4ad5ec2b5..6e7d4ec308b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1054,6 +1054,13 @@ routes.draw do put 'users/:id/merge_into/accounts/:destination_account_id/users/:destination_user_id', controller: 'users', action: 'merge_into' end + scope(:controller => :custom_data) do + glob = CANVAS_RAILS2 ? '/*scope' : '(/*scope)' + get "users/:user_id/custom_data#{glob}", action: 'get_data' + put "users/:user_id/custom_data#{glob}", action: 'set_data' + delete "users/:user_id/custom_data#{glob}", action: 'delete_data' + end + scope(:controller => :pseudonyms) do get 'accounts/:account_id/logins', :action => :index, :path_name => 'account_pseudonyms' get 'users/:user_id/logins', :action => :index, :path_name => 'user_pseudonyms' diff --git a/db/migrate/20140312232054_create_custom_data.rb b/db/migrate/20140312232054_create_custom_data.rb new file mode 100644 index 00000000000..680e9086d7d --- /dev/null +++ b/db/migrate/20140312232054_create_custom_data.rb @@ -0,0 +1,16 @@ +class CreateCustomData < ActiveRecord::Migration + tag :predeploy + + def self.up + create_table :custom_data do |t| + t.text :data + t.string :namespace + t.references :user, :limit => 8 + t.timestamps + end + end + + def self.down + drop_table :custom_data + end +end diff --git a/db/migrate/20140319223606_add_unique_index_on_custom_data.rb b/db/migrate/20140319223606_add_unique_index_on_custom_data.rb new file mode 100644 index 00000000000..cd553c0526c --- /dev/null +++ b/db/migrate/20140319223606_add_unique_index_on_custom_data.rb @@ -0,0 +1,13 @@ +class AddUniqueIndexOnCustomData < ActiveRecord::Migration + tag :predeploy + + def self.up + add_index :custom_data, [:user_id, :namespace], + name: 'index_custom_data_on_user_id_and_namespace', + unique: true + end + + def self.down + remove_index :custom_data, name: 'index_custom_data_on_user_id_and_namespace' + end +end diff --git a/spec/apis/v1/users_api_spec.rb b/spec/apis/v1/users_api_spec.rb index 504aa19b9d0..3b8048b2cc3 100644 --- a/spec/apis/v1/users_api_spec.rb +++ b/spec/apis/v1/users_api_spec.rb @@ -709,6 +709,92 @@ describe "Users API", type: :request do end end + describe "user custom_data" do + let(:namespace_a) { 'com.awesome-developer.mobile' } + let(:namespace_b) { 'org.charitable-developer.generosity' } + let(:scope) { 'nice/scope' } + let(:scope2) { 'something-different' } + let(:path) { "/api/v1/users/#{@student.to_param}/custom_data/#{scope}" } + let(:path2) { "/api/v1/users/#{@student.to_param}/custom_data/#{scope2}" } + let(:path_opts_put) { {controller: 'custom_data', + action: 'set_data', + format: 'json', + user_id: @student.to_param, + scope: scope} } + let(:path_opts_get) { path_opts_put.merge({action: 'get_data'}) } + let(:path_opts_del) { path_opts_put.merge({action: 'delete_data'}) } + let(:path_opts_put2) { path_opts_put.merge({scope: scope2}) } + let(:path_opts_get2) { path_opts_put2.merge({action: 'get_data'}) } + + it "scopes storage by namespace and a *scope glob" do + data = 'boom shaka-laka' + other_data = 'whoop there it is' + data2 = 'whatevs' + other_data2 = 'totes' + api_call(:put, path, path_opts_put, {ns: namespace_a, data: data}) + api_call(:put, path2, path_opts_put2, {ns: namespace_a, data: data2}) + api_call(:put, path, path_opts_put, {ns: namespace_b, data: other_data}) + api_call(:put, path2, path_opts_put2, {ns: namespace_b, data: other_data2}) + + body = api_call(:get, path, path_opts_get, {ns: namespace_a}) + body.should == {'data'=>data} + + body = api_call(:get, path, path_opts_get, {ns: namespace_b}) + body.should == {'data'=>other_data} + + body = api_call(:get, path2, path_opts_get2, {ns: namespace_a}) + body.should == {'data'=>data2} + + body = api_call(:get, path2, path_opts_get2, {ns: namespace_b}) + body.should == {'data'=>other_data2} + end + + it "turns JSON hashes into scopes" do + data = JSON.parse '{"a":"nice JSON","b":"dont you think?"}' + get_path = path + '/b' + get_scope = scope + '/b' + api_call(:put, path, path_opts_put, {ns: namespace_a, data: data}) + body = api_call(:get, get_path, path_opts_get.merge({scope: get_scope}), {ns: namespace_a}) + body.should == {'data'=>'dont you think?'} + end + + it "is deleteable" do + data = JSON.parse '{"a":"nice JSON","b":"dont you think?"}' + del_path = path + '/b' + del_scope = scope + '/b' + api_call(:put, path, path_opts_put, {ns: namespace_a, data: data}) + body = api_call(:delete, del_path, path_opts_del.merge({scope: del_scope}), {ns: namespace_a}) + body.should == {'data'=>'dont you think?'} + + body = api_call(:get, path, path_opts_get, {ns: namespace_a}) + body.should == {'data'=>{'a'=>'nice JSON'}} + end + + context "without a namespace" do + it "responds 400 to GET" do + api_call(:get, path, path_opts_get, {}, {}, {expected_status: 400}) + end + + it "responds 400 to PUT" do + api_call(:put, path, path_opts_put, {data: 'whatevs'}, {}, {expected_status: 400}) + end + + it "responds 400 to DELETE" do + api_call(:delete, path, path_opts_del, {}, {}, {expected_status: 400}) + end + end + + context "PUT" do + it "responds 409 when the requested scope is invalid" do + deeper_path = path + '/whoa' + deeper_scope = scope + '/whoa' + api_call(:put, path, path_opts_put, {ns: namespace_a, data: 'ohai!'}) + raw_api_call(:put, deeper_path, path_opts_put.merge({scope: deeper_scope}), {ns: namespace_a, data: 'dood'}) + response.code.should eql '409' + end + end + end + describe "user deletion" do before do @admin = account_admin_user diff --git a/spec/factories/custom_data_factory.rb b/spec/factories/custom_data_factory.rb new file mode 100644 index 00000000000..6c3d4e69fc2 --- /dev/null +++ b/spec/factories/custom_data_factory.rb @@ -0,0 +1,28 @@ +# +# Copyright (C) 2011 Instructure, Inc. +# +# This file is part of Canvas. +# +# Canvas is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, version 3 of the License. +# +# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# + +def custom_data_model(opts={}) + @custom_data = factory_with_protected_attributes(CustomData, valid_custom_data_attributes.merge(opts)) +end + +def valid_custom_data_attributes + { + user: user_model, + namespace: 'value for namespace' + } +end \ No newline at end of file diff --git a/spec/models/custom_data_spec.rb b/spec/models/custom_data_spec.rb new file mode 100644 index 00000000000..7606399a885 --- /dev/null +++ b/spec/models/custom_data_spec.rb @@ -0,0 +1,87 @@ +# +# Copyright (C) 2011 Instructure, Inc. +# +# This file is part of Canvas. +# +# Canvas is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, version 3 of the License. +# +# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# + +require File.expand_path(File.dirname(__FILE__) + '/../spec_helper.rb') + +describe CustomData do + before do + custom_data_model + end + + context "#get_data" do + it "returns the same data that was set" do + str = 'lol hi' + @custom_data.set_data(nil, str) + @custom_data.get_data(nil).should eql str + + hsh = {'lol'=>'hi', 'wut'=>'bye'} + @custom_data.set_data(nil, hsh) + @custom_data.get_data(nil).should eql hsh + + @custom_data.set_data('kewl/skope', str) + @custom_data.get_data('kewl/skope').should eql str + end + + it "recognizes sub-scopes of previously-set data" do + @custom_data.set_data('kewl/skope', {'lol'=> {'wut'=>'ohai'} }) + @custom_data.get_data('kewl/skope/lol/wut').should eql 'ohai' + end + + it "returns sub-scopes when a wide scope is requested" do + @custom_data.set_data('kewl/skope', {'lol'=> {'wut'=>'ohai'} }) + @custom_data.get_data('kewl').should eql({'skope'=> {'lol'=> {'wut'=>'ohai'} } }) + end + + it "raises ArgumentError for non-existing scopes" do + @custom_data.set_data('kewl/skope', {'lol'=> {'wut'=>'ohai'} }) + lambda { @custom_data.get_data('no/data/here') }.should raise_error(ArgumentError) + end + end + + context "#set_data" do + it "raises a WriteConflict when the requested scope is invalid" do + @custom_data.set_data('kewl/skope', 'ohai') + lambda { @custom_data.set_data('kewl/skope/plus/more', 'bad idea dood') }.should raise_error(CustomData::WriteConflict) + end + end + + context "#delete_data" do + it "deletes values" do + @custom_data.set_data(nil, {'a'=>1, 'b'=>2, 'c'=>3}) + @custom_data.delete_data('a').should eql 1 + @custom_data.get_data(nil).should eql({'b'=>2, 'c'=>3}) + end + + it "cleans up empty JSON Objects if they result from value removal" do + @custom_data.set_data(nil, {'a'=> {'b'=> {'c'=>'bonjour!'}}, 'croissant'=>'merci!'}) + @custom_data.delete_data('a/b/c').should eql 'bonjour!' + @custom_data.get_data(nil).should eql({'croissant'=>'merci!'}) + end + + it "destroys the entire record if all of its data is removed" do + @custom_data.set_data(nil, {'a'=> {'b'=> {'c'=>'bonjour!'}}}) + @custom_data.delete_data('a/b/c').should eql 'bonjour!' + @custom_data.destroyed?.should be_true + end + + it "raises ArgumentError for non-existing scopes" do + @custom_data.set_data(nil, {'a'=>1, 'b'=>2, 'c'=>3}) + lambda { @custom_data.delete_data('d') }.should raise_error(ArgumentError) + end + end +end