diff --git a/lib/remote_storage/riak.rb b/lib/remote_storage/riak.rb index 248da41..1add990 100644 --- a/lib/remote_storage/riak.rb +++ b/lib/remote_storage/riak.rb @@ -23,28 +23,35 @@ module RemoteStorage request_method = server.env["REQUEST_METHOD"] if directory.split("/").first == "public" - return true if request_method == "GET" && !listing + return true if ["GET", "HEAD"].include?(request_method) && !listing end authorizations = auth_bucket.get("#{user}:#{token}").data permission = directory_permission(authorizations, directory) - server.halt 403 unless permission + server.halt 401 unless permission if ["PUT", "DELETE"].include? request_method - server.halt 403 unless permission == "rw" + server.halt 401 unless permission == "rw" end rescue ::Riak::HTTPFailedRequest - server.halt 403 + server.halt 401 + end + + def get_head(user, directory, key) + object = data_bucket.get("#{user}:#{directory}:#{key}") + set_object_response_headers(object) + server.halt 200 + rescue ::Riak::HTTPFailedRequest + server.halt 404 end def get_data(user, directory, key) object = data_bucket.get("#{user}:#{directory}:#{key}") - server.headers["Content-Type"] = object.content_type - server.headers["Last-Modified"] = last_modified_date_for(object) - server.headers["ETag"] = object.etag + set_object_response_headers(object) - server.halt 304 if server.env["HTTP_IF_NONE_MATCH"] == object.etag + none_match = (server.env["HTTP_IF_NONE_MATCH"] || "").split(",").map(&:strip) + server.halt 304 if none_match.include? object.etag if binary_key = object.meta["binary_key"] object = cs_binary_bucket.files.get(binary_key[0]) @@ -67,16 +74,21 @@ module RemoteStorage server.halt 404 end + def get_head_directory_listing(user, directory) + directory_object = directory_bucket.get("#{user}:#{directory}") + set_directory_response_headers(directory_object) + server.halt 200 + rescue ::Riak::HTTPFailedRequest + server.halt 404 + end + def get_directory_listing(user, directory) directory_object = directory_bucket.get("#{user}:#{directory}") - timestamp = directory_object.data.to_i - timestamp /= 1000 if timestamp.to_s.length == 13 - server.headers["Content-Type"] = "application/json" - server.headers["Last-Modified"] = Time.at(timestamp).to_s(:rfc822) - server.headers["ETag"] = directory_object.etag + set_directory_response_headers(directory_object) - server.halt 304 if server.env["HTTP_IF_NONE_MATCH"] == directory_object.etag + none_match = (server.env["HTTP_IF_NONE_MATCH"] || "").split(",").map(&:strip) + server.halt 304 if none_match.include? directory_object.etag listing = directory_listing(user, directory) @@ -86,6 +98,8 @@ module RemoteStorage end def put_data(user, directory, key, data, content_type=nil) + server.halt 409 if has_name_collision?(user, directory, key) + object = build_data_object(user, directory, key, data, content_type) if required_match = server.env["HTTP_IF_MATCH"] @@ -116,7 +130,7 @@ module RemoteStorage update_all_directory_objects(user, directory, timestamp) server.headers["ETag"] = object.etag - server.halt 200 + server.halt object_exists ? 200 : 201 rescue ::Riak::HTTPFailedRequest server.halt 422 end @@ -144,14 +158,24 @@ module RemoteStorage timestamp = (Time.now.to_f * 1000).to_i delete_or_update_directory_objects(user, directory, timestamp) - server.headers["ETag"] = etag - server.halt riak_response[:code] + server.halt 200 rescue ::Riak::HTTPFailedRequest server.halt 404 end private + def set_object_response_headers(object) + server.headers["Content-Type"] = object.content_type + server.headers["ETag"] = object.etag + server.headers["Content-Length"] = object_size(object) + end + + def set_directory_response_headers(directory_object) + server.headers["Content-Type"] = "application/json" + server.headers["ETag"] = directory_object.etag + end + def extract_category(directory) if directory.match(/^public\//) "public/#{directory.split('/')[1]}" @@ -166,7 +190,7 @@ module RemoteStorage object.content_type = content_type || "text/plain; charset=utf-8" directory_index = directory == "" ? "/" : directory - object.indexes.merge!({:user_id_bin => [user], + object.indexes.merge!({:user_id_bin => [user], :directory_bin => [directory_index]}) object @@ -179,8 +203,8 @@ module RemoteStorage log_entry = opslog_bucket.new log_entry.content_type = "application/json" log_entry.data = { - "count" => count, - "size" => size, + "count" => count, + "size" => size, "category" => extract_category(directory) } log_entry.indexes.merge!({:user_id_bin => [user]}) @@ -210,14 +234,6 @@ module RemoteStorage ::Riak::Serializers[content_type[/^[^;\s]+/]] end - def last_modified_date_for(object) - timestamp = object.meta["timestamp"] - timestamp = (timestamp[0].to_i / 1000) if timestamp - last_modified = timestamp ? Time.at(timestamp) : object.last_modified - - last_modified.to_s(:rfc822) - end - def directory_permission(authorizations, directory) authorizations = authorizations.map do |auth| auth.index(":") ? auth.split(":") : [auth, "rw"] @@ -239,26 +255,31 @@ module RemoteStorage end def directory_listing(user, directory) - listing = {} + listing = { + "@context" => "http://remotestorage.io/spec/folder-description", + "items" => {} + } sub_directories(user, directory).each do |entry| directory_name = entry["name"].split("/").last - timestamp = entry["timestamp"].to_i - etag = entry["etag"] + etag = entry["etag"] - listing.merge!({ "#{directory_name}/" => etag }) + listing["items"].merge!({ "#{directory_name}/" => { "ETag" => etag }}) end directory_entries(user, directory).each do |entry| - entry_name = entry["name"] - timestamp = if entry["timestamp"] - entry["timestamp"].to_i - else - DateTime.rfc2822(entry["last_modified"]).to_time.to_i - end - etag = entry["etag"] + entry_name = entry["name"] + etag = entry["etag"] + content_type = entry["contentType"] + content_length = entry["contentLength"].to_i - listing.merge!({ entry_name => etag }) + listing["items"].merge!({ + entry_name => { + "ETag" => etag, + "Content-Type" => content_type, + "Content-Length" => content_length + } + }) end listing @@ -277,15 +298,15 @@ module RemoteStorage } var name = v.key.match(/^[^:]*:(.*)/)[1]; // strip username from key name = name.replace(dir_name + ':', ''); // strip directory from key - var last_modified_date = metadata['X-Riak-Last-Modified']; - var timestamp = metadata['X-Riak-Meta']['X-Riak-Meta-Timestamp']; var etag = metadata['X-Riak-VTag']; + var contentType = metadata['content-type']; + var contentLength = metadata['X-Riak-Meta']['X-Riak-Meta-Content_length'] || 0; return [{ - name: name, - last_modified: last_modified_date, - timestamp: timestamp, - etag: etag + name: name, + etag: etag, + contentType: contentType, + contentLength: contentLength }]; } EOH @@ -300,12 +321,10 @@ module RemoteStorage map_query = <<-EOH function(v){ var name = v.key.match(/^[^:]*:(.*)/)[1]; // strip username from key - var timestamp = v.values[0]['data']; var etag = v.values[0]['metadata']['X-Riak-VTag']; return [{ name: name, - timestamp: timestamp, etag: etag }]; } @@ -376,6 +395,8 @@ module RemoteStorage data = JSON.parse(data) end + object.meta["content_length"] = data.size + if serializer_for(object.content_type) object.data = data else @@ -392,7 +413,8 @@ module RemoteStorage :content_type => object.content_type ) - object.meta["binary_key"] = cs_binary_object.key + object.meta["binary_key"] = cs_binary_object.key + object.meta["content_length"] = cs_binary_object.content_length object.raw_data = "" end @@ -419,6 +441,32 @@ module RemoteStorage parent_directories << "" end + def has_name_collision?(user, directory, key) + parent_directories = parent_directories_for(directory).reverse + parent_directories.shift # remove root dir entry + + # check for existing documents with the same name as one of the parent directories + parent_directories.each do |dir| + begin + parts = dir.split("/") + document_key = parts.pop + directory_name = parts.join("/") + data_bucket.get("#{user}:#{directory_name}:#{document_key}") + return true + rescue ::Riak::HTTPFailedRequest + end + end + + # check for an existing directory with same name as document + begin + directory_bucket.get("#{user}:#{directory}/#{key}") + return true + rescue ::Riak::HTTPFailedRequest + end + + false + end + def client @client ||= ::Riak::Client.new(:host => settings['host'], :http_port => settings['http_port']) diff --git a/liquor-cabinet.rb b/liquor-cabinet.rb index 71eecd1..2e4601f 100644 --- a/liquor-cabinet.rb +++ b/liquor-cabinet.rb @@ -46,6 +46,7 @@ class LiquorCabinet < Sinatra::Base 'Access-Control-Expose-Headers' => 'ETag' headers['Access-Control-Allow-Origin'] = env["HTTP_ORIGIN"] if env["HTTP_ORIGIN"] headers['Cache-Control'] = 'no-cache' + headers['Expires'] = '0' @user, @key = params[:user], params[:key] @directory = params[:splat] && params[:splat].first || "" @@ -65,6 +66,10 @@ class LiquorCabinet < Sinatra::Base storage.get_data(@user, @directory, @key) end + head path do + storage.get_head(@user, @directory, @key) + end + put path do data = request.body.read @@ -86,6 +91,10 @@ class LiquorCabinet < Sinatra::Base get path do storage.get_directory_listing(@user, @directory) end + + head path do + storage.get_head_directory_listing(@user, @directory) + end end private diff --git a/spec/directories_spec.rb b/spec/directories_spec.rb index dbe8a3e..3dbb1d6 100644 --- a/spec/directories_spec.rb +++ b/spec/directories_spec.rb @@ -13,6 +13,46 @@ describe "Directories" do header "Authorization", "Bearer 123" end + describe "HEAD listing" do + before do + put "/jimmy/tasks/foo", "do the laundry" + put "/jimmy/tasks/http%3A%2F%2F5apps.com", "prettify design" + + head "/jimmy/tasks/" + end + + it "has an empty body" do + last_response.status.must_equal 200 + last_response.body.must_equal "" + end + + it "has an ETag header set" do + last_response.status.must_equal 200 + last_response.headers["ETag"].wont_be_nil + + # check that ETag stays the same + etag = last_response.headers["ETag"] + get "/jimmy/tasks/" + last_response.headers["ETag"].must_equal etag + end + + it "has CORS headers set" do + last_response.status.must_equal 200 + last_response.headers["Access-Control-Allow-Origin"].must_equal "*" + last_response.headers["Access-Control-Allow-Methods"].must_equal "GET, PUT, DELETE" + last_response.headers["Access-Control-Allow-Headers"].must_equal "Authorization, Content-Type, Origin, If-Match, If-None-Match" + last_response.headers["Access-Control-Expose-Headers"].must_equal "ETag" + end + + context "for an empty or absent directory" do + it "responds with 404" do + head "/jimmy/documents/" + + last_response.status.must_equal 404 + end + end + end + describe "GET listing" do before do put "/jimmy/tasks/foo", "do the laundry" @@ -20,7 +60,7 @@ describe "Directories" do put "/jimmy/tasks/%3A/foo%3Abar%40foo.org", "hello world" end - it "lists the objects with their version" do + it "lists the objects with version, length and content-type" do get "/jimmy/tasks/" last_response.status.must_equal 200 @@ -29,32 +69,12 @@ describe "Directories" do foo = data_bucket.get("jimmy:tasks:foo") content = JSON.parse(last_response.body) - content.must_include "http://5apps.com" - content.must_include ":/" - content.must_include "foo" - content["foo"].must_equal foo.etag.gsub(/"/, "") - end - - it "doesn't choke on colons in the directory name" do - get "/jimmy/tasks/%3A/" - - last_response.status.must_equal 200 - last_response.content_type.must_equal "application/json" - - content = JSON.parse(last_response.body) - content.must_include "foo:bar@foo.org" - end - - it "has a Last-Modifier header set" do - get "/jimmy/tasks/" - - last_response.status.must_equal 200 - last_response.headers["Last-Modified"].wont_be_nil - - now = Time.now - last_modified = DateTime.parse(last_response.headers["Last-Modified"]) - last_modified.year.must_equal now.year - last_modified.day.must_equal now.day + content["items"]["http://5apps.com"].wont_be_nil + content["items"][":/"].wont_be_nil + content["items"]["foo"].wont_be_nil + content["items"]["foo"]["ETag"].must_equal foo.etag.gsub(/"/, "") + content["items"]["foo"]["Content-Type"].must_equal "text/plain" + content["items"]["foo"]["Content-Length"].must_equal 14 end it "has an ETag header set" do @@ -79,6 +99,23 @@ describe "Directories" do last_response.headers["Access-Control-Expose-Headers"].must_equal "ETag" end + it "has caching headers set" do + get "/jimmy/tasks/" + + last_response.status.must_equal 200 + last_response.headers["Expires"].must_equal "0" + end + + it "doesn't choke on colons in the directory name" do + get "/jimmy/tasks/%3A/" + + last_response.status.must_equal 200 + last_response.content_type.must_equal "application/json" + + content = JSON.parse(last_response.body) + content["items"]["foo:bar@foo.org"].wont_be_nil + end + context "when If-None-Match header is set" do before do get "/jimmy/tasks/" @@ -104,6 +141,31 @@ describe "Directories" do end end + describe "when If-None-Match header is set with multiple revisions" do + before do + get "/jimmy/tasks/" + + @etag = last_response.headers["ETag"] + end + + it "responds with 'not modified' when it contains the current ETag" do + header "If-None-Match", "DEADBEEF,#{@etag} ,F00BA4" + get "/jimmy/tasks/" + + last_response.status.must_equal 304 + last_response.body.must_be_empty + last_response.headers["ETag"].must_equal @etag + end + + it "responds normally when it does not contain the current ETag" do + header "If-None-Match", "FOO,BAR" + get "/jimmy/tasks/" + + last_response.status.must_equal 200 + last_response.body.wont_be_empty + end + end + context "with sub-directories" do before do get "/jimmy/tasks/" @@ -120,10 +182,10 @@ describe "Directories" do home = directory_bucket.get("jimmy:tasks/home") content = JSON.parse(last_response.body) - content.must_include "foo" - content.must_include "http://5apps.com" - content.must_include "home/" - content["home/"].must_equal home.etag.gsub(/"/, "") + content["items"]["foo"].wont_be_nil + content["items"]["http://5apps.com"].wont_be_nil + content["items"]["home/"].wont_be_nil + content["items"]["home/"]["ETag"].must_equal home.etag.gsub(/"/, "") end it "updates the ETag of the parent directory" do @@ -150,10 +212,10 @@ describe "Directories" do last_response.status.must_equal 200 content = JSON.parse(last_response.body) - content.wont_include "/" - content.wont_include "tasks/" - content.wont_include "home/" - content.must_include "homework" + content["items"]["/"].must_be_nil + content["items"]["tasks/"].must_be_nil + content["items"]["home/"].must_be_nil + content["items"]["homework"].wont_be_nil end end @@ -167,8 +229,7 @@ describe "Directories" do projects = directory_bucket.get("jimmy:tasks/private/projects") content = JSON.parse(last_response.body) - content.must_include "projects/" - content["projects/"].must_equal projects.etag.gsub(/"/, "") + content["items"]["projects/"]["ETag"].must_equal projects.etag.gsub(/"/, "") end it "updates the timestamps of the existing directory objects" do @@ -203,8 +264,9 @@ describe "Directories" do jaypeg = data_bucket.get("jimmy:tasks:jaypeg.jpg") content = JSON.parse(last_response.body) - content.must_include "jaypeg.jpg" - content["jaypeg.jpg"].must_equal jaypeg.etag.gsub(/"/, "") + content["items"]["jaypeg.jpg"]["ETag"].must_equal jaypeg.etag.gsub(/"/, "") + content["items"]["jaypeg.jpg"]["Content-Type"].must_equal "image/jpeg" + content["items"]["jaypeg.jpg"]["Content-Length"].must_equal 16044 end end @@ -224,8 +286,9 @@ describe "Directories" do jaypeg = data_bucket.get("jimmy:tasks:jaypeg.jpg") content = JSON.parse(last_response.body) - content.must_include "jaypeg.jpg" - content["jaypeg.jpg"].must_equal jaypeg.etag.gsub(/"/, "") + content["items"]["jaypeg.jpg"]["ETag"].must_equal jaypeg.etag.gsub(/"/, "") + content["items"]["jaypeg.jpg"]["Content-Type"].must_equal "image/jpeg" + content["items"]["jaypeg.jpg"]["Content-Length"].must_equal 16044 end end end @@ -244,8 +307,7 @@ describe "Directories" do laundry = data_bucket.get("jimmy:tasks/home:laundry") content = JSON.parse(last_response.body) - content.must_include "laundry" - content["laundry"].must_equal laundry.etag.gsub(/"/, "") + content["items"]["laundry"]["ETag"].must_equal laundry.etag.gsub(/"/, "") end end @@ -268,7 +330,7 @@ describe "Directories" do last_response.status.must_equal 200 content = JSON.parse(last_response.body) - content.must_include "foo~bar/" + content["items"]["foo~bar/"].wont_be_nil end it "lists the containing objects" do @@ -277,7 +339,7 @@ describe "Directories" do last_response.status.must_equal 200 content = JSON.parse(last_response.body) - content.must_include "task1" + content["items"]["task1"].wont_be_nil end it "returns the requested object" do @@ -300,7 +362,7 @@ describe "Directories" do last_response.status.must_equal 200 content = JSON.parse(last_response.body) - content.must_include "bla~blub" + content["items"]["bla~blub"].wont_be_nil end end @@ -322,10 +384,10 @@ describe "Directories" do tasks = directory_bucket.get("jimmy:tasks") content = JSON.parse(last_response.body) - content.must_include "root-1" - content.must_include "root-2" - content.must_include "tasks/" - content["tasks/"].must_equal tasks.etag.gsub(/"/, "") + content["items"]["root-1"].wont_be_nil + content["items"]["root-2"].wont_be_nil + content["items"]["tasks/"].wont_be_nil + content["items"]["tasks/"]["ETag"].must_equal tasks.etag.gsub(/"/, "") end it "has an ETag header set" do @@ -352,7 +414,7 @@ describe "Directories" do last_response.status.must_equal 200 content = JSON.parse(last_response.body) - content.must_include "5apps" + content["items"]["5apps"].wont_be_nil end it "has an ETag header set" do @@ -376,7 +438,7 @@ describe "Directories" do last_response.status.must_equal 200 content = JSON.parse(last_response.body) - content.must_include "5apps" + content["items"]["5apps"].wont_be_nil end end @@ -388,13 +450,13 @@ describe "Directories" do it "does not allow a directory listing of the public root" do get "/jimmy/public/" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end it "does not allow a directory listing of a sub-directory" do get "/jimmy/public/bookmarks/" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end end end @@ -498,7 +560,7 @@ describe "Directories" do it "deletes the directory objects for all empty parent directories" do delete "/jimmy/tasks/home/trash" - last_response.status.must_equal 204 + last_response.status.must_equal 200 lambda { directory_bucket.get("jimmy:tasks/home") diff --git a/spec/permissions_spec.rb b/spec/permissions_spec.rb index 8f13a16..6652c85 100644 --- a/spec/permissions_spec.rb +++ b/spec/permissions_spec.rb @@ -26,8 +26,6 @@ describe "Permissions" do last_response.status.must_equal 200 last_response.body.must_equal "some text data" - - last_response.headers["Last-Modified"].wont_be_nil end it "returns the value from a sub-directory" do @@ -79,10 +77,10 @@ describe "Permissions" do end context "when not authorized" do - it "returns a 403 for a key in a top-level directory" do + it "returns a 401 for a key in a top-level directory" do get "/jimmy/confidential/bar" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end end end @@ -101,14 +99,14 @@ describe "Permissions" do it "saves the value when there are write permissions" do put "/jimmy/contacts/1", "John Doe" - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy:contacts:1").data.must_equal "John Doe" end - it "returns a 403 when there are read permissions only" do + it "returns a 401 when there are read permissions only" do put "/jimmy/documents/foo", "some text" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end end @@ -116,21 +114,21 @@ describe "Permissions" do it "saves the value when there are direct write permissions" do put "/jimmy/tasks/home/1", "take out the trash" - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy:tasks/home:1").data.must_equal "take out the trash" end it "saves the value when there are write permissions for a parent directory" do put "/jimmy/contacts/family/1", "Bobby Brother" - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy:contacts/family:1").data.must_equal "Bobby Brother" end - it "returns a 403 when there are read permissions only" do + it "returns a 401 when there are read permissions only" do put "/jimmy/documents/business/1", "some text" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end end @@ -139,23 +137,23 @@ describe "Permissions" do it "saves the value" do put "/jimmy/public/contacts/foo", "Foo Bar" - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy:public/contacts:foo").data.must_equal "Foo Bar" end it "saves the value to a sub-directory" do put "/jimmy/public/contacts/family/foo", "Foo Bar" - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy:public/contacts/family:foo").data.must_equal "Foo Bar" end end context "when not authorized for the corresponding category" do - it "returns a 403" do + it "returns a 401" do put "/jimmy/public/documents/foo", "Foo Bar" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end end end @@ -186,7 +184,7 @@ describe "Permissions" do it "removes the key from a top-level directory" do delete "/jimmy/tasks/1" - last_response.status.must_equal 204 + last_response.status.must_equal 200 lambda { data_bucket.get("jimmy:tasks:1") }.must_raise Riak::HTTPFailedRequest @@ -195,7 +193,7 @@ describe "Permissions" do it "removes the key from a top-level directory" do delete "/jimmy/tasks/home/1" - last_response.status.must_equal 204 + last_response.status.must_equal 200 lambda { data_bucket.get("jimmy:tasks/home:1") }.must_raise Riak::HTTPFailedRequest @@ -212,7 +210,7 @@ describe "Permissions" do it "removes the key" do delete "/jimmy/public/tasks/open" - last_response.status.must_equal 204 + last_response.status.must_equal 200 lambda { data_bucket.get("jimmy:public/tasks:open") }.must_raise Riak::HTTPFailedRequest @@ -233,16 +231,16 @@ describe "Permissions" do object.store end - it "returns a 403 for a key in a top-level directory" do + it "returns a 401 for a key in a top-level directory" do delete "/jimmy/documents/private" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end - it "returns a 403 for a key in a sub-directory" do + it "returns a 401 for a key in a sub-directory" do delete "/jimmy/documents/business/foo" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end context "public directory" do @@ -253,10 +251,10 @@ describe "Permissions" do object.store end - it "returns a 403" do + it "returns a 401" do delete "/jimmy/public/documents/foo" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end end end @@ -289,14 +287,14 @@ describe "Permissions" do it "allows PUT requests" do put "/jimmy/contacts/1", "John Doe" - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy:contacts:1").data.must_equal "John Doe" end it "allows DELETE requests" do delete "/jimmy/documents/very/interesting/text" - last_response.status.must_equal 204 + last_response.status.must_equal 200 lambda { data_bucket.get("jimmy:documents/very/interesting:text") }.must_raise Riak::HTTPFailedRequest @@ -320,14 +318,14 @@ describe "Permissions" do it "allows PUT requests" do put "/jimmy/1", "Gonna kick it root down" - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy::1").data.must_equal "Gonna kick it root down" end it "allows DELETE requests" do delete "/jimmy/root" - last_response.status.must_equal 204 + last_response.status.must_equal 200 lambda { data_bucket.get("jimmy::root") }.must_raise Riak::HTTPFailedRequest @@ -351,14 +349,14 @@ describe "Permissions" do it "allows PUT requests" do put "/jimmy/public/1", "Hello World" - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy:public:1").data.must_equal "Hello World" end it "allows DELETE requests" do delete "/jimmy/public/tasks/hello" - last_response.status.must_equal 204 + last_response.status.must_equal 200 lambda { data_bucket.get("jimmy:public/tasks:hello") }.must_raise Riak::HTTPFailedRequest @@ -385,13 +383,13 @@ describe "Permissions" do it "disallows PUT requests" do put "/jimmy/documents/foo", "some text" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end it "disallows DELETE requests" do delete "/jimmy/documents/very/interesting/text" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end context "public directory" do @@ -411,13 +409,13 @@ describe "Permissions" do it "disallows PUT requests" do put "/jimmy/public/tasks/foo", "some text" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end it "disallows DELETE requests" do delete "/jimmy/public/tasks/hello" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end end end diff --git a/spec/riak_spec.rb b/spec/riak_spec.rb index fef3553..e17a5a4 100644 --- a/spec/riak_spec.rb +++ b/spec/riak_spec.rb @@ -7,6 +7,32 @@ describe "App with Riak backend" do purge_all_buckets end + describe "HEAD public data" do + before do + object = data_bucket.new("jimmy:public:foo") + object.content_type = "text/plain" + object.data = "some text data" + object.store + + head "/jimmy/public/foo" + end + + it "returns an empty body" do + last_response.status.must_equal 200 + last_response.body.must_equal "" + end + + it "has an ETag header set" do + last_response.status.must_equal 200 + last_response.headers["ETag"].wont_be_nil + end + + it "has a Content-Length header set" do + last_response.status.must_equal 200 + last_response.headers["Content-Length"].must_equal 14 + end + end + describe "GET public data" do before do object = data_bucket.new("jimmy:public:foo") @@ -22,21 +48,20 @@ describe "App with Riak backend" do last_response.body.must_equal "some text data" end - # If this one fails, try restarting Riak - it "has a Last-Modified header set" do - last_response.status.must_equal 200 - last_response.headers["Last-Modified"].wont_be_nil - - now = Time.now - last_modified = DateTime.parse(last_response.headers["Last-Modified"]) - last_modified.year.must_equal now.year - last_modified.day.must_equal now.day - end - it "has an ETag header set" do last_response.status.must_equal 200 last_response.headers["ETag"].wont_be_nil end + + it "has a Content-Length header set" do + last_response.status.must_equal 200 + last_response.headers["Content-Length"].must_equal "14" + end + + it "has caching headers set" do + last_response.status.must_equal 200 + last_response.headers["Expires"].must_equal "0" + end end describe "GET data with custom content type" do @@ -70,6 +95,37 @@ describe "App with Riak backend" do auth.store end + describe "HEAD" do + before do + header "Authorization", "Bearer 123" + head "/jimmy/documents/foo" + end + + it "returns an empty body" do + last_response.status.must_equal 200 + last_response.body.must_equal "" + end + + it "has an ETag header set" do + last_response.status.must_equal 200 + last_response.headers["ETag"].wont_be_nil + end + + it "has a Content-Length header set" do + last_response.status.must_equal 200 + last_response.headers["Content-Length"].must_equal 22 + end + end + + describe "HEAD nonexisting key" do + it "returns a 404" do + header "Authorization", "Bearer 123" + head "/jimmy/documents/somestupidkey" + + last_response.status.must_equal 404 + end + end + describe "GET" do before do header "Authorization", "Bearer 123" @@ -100,6 +156,25 @@ describe "App with Riak backend" do last_response.body.must_equal "some private text data" end end + + describe "when If-None-Match header is set with multiple revisions" do + it "responds with 'not modified' when it contains the current ETag" do + header "If-None-Match", "DEADBEEF,#{@etag},F00BA4" + get "/jimmy/documents/foo" + + last_response.status.must_equal 304 + last_response.body.must_be_empty + last_response.headers["ETag"].must_equal @etag + end + + it "responds normally when it does not contain the current ETag" do + header "If-None-Match", "FOO,BAR" + get "/jimmy/documents/foo" + + last_response.status.must_equal 200 + last_response.body.must_equal "some private text data" + end + end end describe "GET nonexisting key" do @@ -122,7 +197,7 @@ describe "App with Riak backend" do end it "saves the value" do - last_response.status.must_equal 200 + last_response.status.must_equal 201 last_response.body.must_equal "" data_bucket.get("jimmy:documents:bar").data.must_equal "another text" end @@ -161,7 +236,7 @@ describe "App with Riak backend" do end it "saves the value (as JSON)" do - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy:documents:jason").data.must_be_kind_of Hash data_bucket.get("jimmy:documents:jason").data.must_equal({"foo" => "bar", "unhosted" => 1}) end @@ -186,7 +261,7 @@ describe "App with Riak backend" do end it "saves the value" do - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy:documents:magic").raw_data.must_equal "pure magic" end @@ -210,7 +285,7 @@ describe "App with Riak backend" do end it "saves the value (as JSON)" do - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy:documents:jason").data.must_be_kind_of Hash data_bucket.get("jimmy:documents:jason").data.must_equal({"foo" => "bar", "unhosted" => 1}) end @@ -227,6 +302,32 @@ describe "App with Riak backend" do end end + describe "naming collissions between documents and directories" do + before do + put "/jimmy/documents/archive/document", "lorem ipsum" + end + + it "responds with 409 when directory with same name already exists" do + put "/jimmy/documents/archive", "some awesome content" + + last_response.status.must_equal 409 + + lambda { + data_bucket.get("jimmy:documents/archive") + }.must_raise Riak::HTTPFailedRequest + end + + it "responds with 409 when there is an existing document with same name as one of the directories" do + put "/jimmy/documents/archive/document/subdir/doc", "some awesome content" + + last_response.status.must_equal 409 + + lambda { + data_bucket.get("jimmy:documents/archive/document/subdir/doc") + }.must_raise Riak::HTTPFailedRequest + end + end + describe "with existing content" do before do put "/jimmy/documents/archive/foo", "lorem ipsum" @@ -304,7 +405,7 @@ describe "App with Riak backend" do it "succeeds when the document does not exist" do put "/jimmy/documents/archive/bar", "my little content" - last_response.status.must_equal 200 + last_response.status.must_equal 201 end end end @@ -332,7 +433,7 @@ describe "App with Riak backend" do end it "saves the value" do - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy:public/documents/notes:foo").data.must_equal "note to self" end @@ -380,6 +481,12 @@ describe "App with Riak backend" do last_response.headers["ETag"].must_equal etag end + it "responds with a Content-Length header" do + get "/jimmy/documents/jaypeg" + + last_response.headers["Content-Length"].must_equal "16044" + end + it "changes the ETag when updating the file" do old_etag = last_response.headers["ETag"] put "/jimmy/documents/jaypeg", @image @@ -482,7 +589,7 @@ describe "App with Riak backend" do get "/jimmy/documents/bar:baz/" content = JSON.parse(last_response.body) - content.must_include "john@doe.com" + content["items"]["john@doe.com"].wont_be_nil end it "delivers the data correctly" do @@ -513,7 +620,7 @@ describe "App with Riak backend" do end it "saves an empty JSON object" do - last_response.status.must_equal 200 + last_response.status.must_equal 201 data_bucket.get("jimmy:documents:jason").data.must_be_kind_of Hash data_bucket.get("jimmy:documents:jason").data.must_equal({}) end @@ -543,7 +650,7 @@ describe "App with Riak backend" do end it "removes the key" do - last_response.status.must_equal 204 + last_response.status.must_equal 200 lambda { data_bucket.get("jimmy:documents:foo") }.must_raise Riak::HTTPFailedRequest @@ -558,10 +665,6 @@ describe "App with Riak backend" do log_entry.data["category"].must_equal "documents" log_entry.indexes["user_id_bin"].must_include "jimmy" end - - it "sets the ETag header" do - last_response.headers["ETag"].wont_be_nil - end end context "non-existing object" do @@ -587,7 +690,7 @@ describe "App with Riak backend" do header "If-Match", old_etag delete "/jimmy/documents/foo" - last_response.status.must_equal 204 + last_response.status.must_equal 200 get "/jimmy/documents/foo" last_response.status.must_equal 404 @@ -616,14 +719,14 @@ describe "App with Riak backend" do end it "removes the main object" do - last_response.status.must_equal 204 + last_response.status.must_equal 200 lambda { data_bucket.get("jimmy:documents:jaypeg") }.must_raise Riak::HTTPFailedRequest end it "removes the binary object" do - last_response.status.must_equal 204 + last_response.status.must_equal 200 binary = cs_binary_bucket.files.get("jimmy:documents:jaypeg") binary.must_be_nil @@ -651,26 +754,26 @@ describe "App with Riak backend" do end describe "GET" do - it "returns a 403" do + it "returns a 401" do get "/jimmy/documents/foo" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end end describe "PUT" do - it "returns a 403" do + it "returns a 401" do put "/jimmy/documents/foo", "some text" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end end describe "DELETE" do - it "returns a 403" do + it "returns a 401" do delete "/jimmy/documents/foo" - last_response.status.must_equal 403 + last_response.status.must_equal 401 end end end