├── .gitignore ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── swift_client.rb └── swift_client │ ├── null_cache.rb │ └── version.rb ├── swift_client.gemspec └── test ├── swift_client_test.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | .ruby-version 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | rvm: 3 | - 2.1.10 4 | - 2.2.5 5 | - 2.3.1 6 | 7 | install: 8 | - "travis_retry bundle install" 9 | 10 | script: "bundle exec rake test" 11 | 12 | sudo: false 13 | 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in swift_client.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Benjamin Vetter 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://secure.travis-ci.org/mrkamel/swift_client.png?branch=master)](http://travis-ci.org/mrkamel/swift_client) 2 | [![Code Climate](https://codeclimate.com/github/mrkamel/swift_client.png)](https://codeclimate.com/github/mrkamel/swift_client) 3 | [![Dependency Status](https://gemnasium.com/mrkamel/swift_client.png?travis)](https://gemnasium.com/mrkamel/swift_client) 4 | [![Gem Version](https://badge.fury.io/rb/swift_client.svg)](http://badge.fury.io/rb/swift_client) 5 | 6 | # SwiftClient 7 | 8 | Small but powerful client to interact with OpenStack Swift. 9 | 10 | ## Installation 11 | 12 | Add this line to your application's Gemfile: 13 | 14 | ```ruby 15 | gem 'swift_client' 16 | ``` 17 | 18 | And then execute: 19 | 20 | $ bundle 21 | 22 | Or install it yourself as: 23 | 24 | $ gem install swift_client 25 | 26 | ## Usage 27 | 28 | First, connect to a Swift cluster: 29 | 30 | ```ruby 31 | swift_client = SwiftClient.new( 32 | :auth_url => "https://example.com/auth/v1.0", 33 | :username => "account:username", 34 | :api_key => "api key", 35 | :temp_url_key => "temp url key", 36 | :storage_url => "https://example.com/v1/AUTH_account" 37 | ) 38 | ``` 39 | 40 | To connect via v2 you have to add version and method specific details: 41 | 42 | ```ruby 43 | swift_client = SwiftClient.new( 44 | :auth_url => "https://auth.example.com/v2.0", 45 | :storage_url => "https://storage.example.com/v1/AUTH_account", 46 | :tenant_name => "tenant", 47 | :username => "username", 48 | :password => "password" 49 | ) 50 | 51 | # OR 52 | 53 | swift_client = SwiftClient.new( 54 | :auth_url => "https://auth.example.com/v2.0", 55 | :storage_url => "https://storage.example.com/v1/AUTH_account", 56 | :tenant_name => "tenant", 57 | :access_key => "access key", 58 | :secret_key => "secret key" 59 | ) 60 | ``` 61 | 62 | To connect via v3: 63 | 64 | ```ruby 65 | swift_client = SwiftClient.new( 66 | :auth_url => "https://auth.example.com/v3", 67 | :storage_url => "https://storage.example.com/v1/AUTH_account", 68 | :username => "username", 69 | :password => "password", 70 | :user_domain => "example.com" # :user_domain_id => "..." is valid as well 71 | ) 72 | 73 | # OR 74 | 75 | # project scoped authentication 76 | 77 | swift_client = SwiftClient.new( 78 | :auth_url => "https://auth.example.com/v3", 79 | :username => "username", 80 | :password => "password", 81 | :user_domain => "example.com", # :user_domain_id => "..." is valid as well 82 | :project_id => "p-123456", # :project_name => "..." is valid as well 83 | :project_domain_id => "d-123456" # :project_domain_name => "..." is valid as well 84 | ) 85 | 86 | # OR 87 | 88 | # domain scoped authentication 89 | 90 | swift_client = SwiftClient.new( 91 | :auth_url => "https://auth.example.com/v3", 92 | :username => "username", 93 | :password => "password", 94 | :user_domain => "example.com", # :user_domain_id => "..." is valid as well 95 | :domain_id => "d-123456" # :domain_name => "..." is valid as well 96 | ) 97 | 98 | # OR 99 | 100 | swift_client = SwiftClient.new( 101 | :auth_url => "https://auth.example.com/v3", 102 | :storage_url => "https://storage.example.com/v1/AUTH_account", 103 | :user_id => "user id", 104 | :password => "password", 105 | :interface => "internal" 106 | ) 107 | 108 | # OR 109 | 110 | swift_client = SwiftClient.new( 111 | :auth_url => "https://auth.example.com/v3", 112 | :storage_url => "https://storage.example.com/v1/AUTH_account", 113 | :token => "token" 114 | ) 115 | ``` 116 | 117 | where `temp_url_key` and `storage_url` are optional. 118 | 119 | SwiftClient will automatically reconnect in case the endpoint responds with 401 120 | Unauthorized to one of your requests using the provided credentials. In case 121 | the endpoint does not respond with 2xx to any of SwiftClient's requests, 122 | SwiftClient will raise a `SwiftClient::ResponseError`. Otherwise, SwiftClient 123 | responds with an `HTTParty::Response` object, such that you can call `#headers` 124 | to access the response headers or `#body` as well as `#parsed_response` to 125 | access the response body and JSON response. Checkout the 126 | [HTTParty](https://github.com/jnunemaker/httparty) gem to learn more. 127 | 128 | SwiftClient offers the following requests: 129 | 130 | * `head_account(options = {}) # => HTTParty::Response` 131 | * `post_account(headers = {}, options = {}) # => HTTParty::Response` 132 | * `head_containers(options = {}) # => HTTParty::Response` 133 | * `get_containers(query = nil, options = {}) # => HTTParty::Response` 134 | * `paginate_containers(query = nil, options = {}) # => Enumerator` 135 | * `get_container(container_name, query = nil, options = {}) # => HTTParty::Response` 136 | * `paginate_container(container_name, query = nil, options = {}) # => Enumerator` 137 | * `head_container(container_name, options = {}) # => HTTParty::Response` 138 | * `put_container(container_name, headers = {}, options = {}) # => HTTParty::Response` 139 | * `post_container(container_name, headers = {}, options = {}) # => HTTParty::Response` 140 | * `delete_container(container_name, options = {}) # => HTTParty::Response` 141 | * `put_object(object_name, data_or_io, container_name, headers = {}, options = {}) # => HTTParty::Response` 142 | * `post_object(object_name, container_name, headers = {}, options = {}) # => HTTParty::Response` 143 | * `get_object(object_name, container_name, options = {}) -> HTTParty::Response` 144 | * `get_object(object_name, container_name, options = {}) { |chunk| save chunk } # => HTTParty::Response` 145 | * `head_object(object_name, container_name, options = {}) # => HTTParty::Response` 146 | * `delete_object(object_name, container_name, options = {}) # => HTTParty::Response` 147 | * `get_objects(container_name, query = nil, options = {}) # => HTTParty::Response` 148 | * `paginate_objects(container_name, query = nil, options = {}) # => Enumerator` 149 | * `public_url(object_name, container_name) # => HTTParty::Response` 150 | * `temp_url(object_name, container_name, options = {}) # => HTTParty::Response` 151 | * `bulk_delete(entries, options = {}) # => entries` 152 | * `post_head(object_name, container_name, _headers = {}, options = {}) # => HTTParty::Response` 153 | 154 | By default, the client instructs the Swift server to return JSON via an HTTP Accept header; to disable this pass `:json => false` in `options`. The rest of the `options` are passed directly to the internal [HTTParty](https://rubygems.org/gems/httparty) client. 155 | 156 | ### Getting large objects 157 | 158 | The `get_object` method with out a block is suitable for small objects that easily fit in memory. For larger objects, specify a block to process chunked data as it comes in. 159 | 160 | ```ruby 161 | File.open("/tmp/output", "wb") do |file_io| 162 | swift_client.get_object("/large/object", "container") do |chunk| 163 | file_io.write(chunk) 164 | end 165 | end 166 | ``` 167 | 168 | ## Re-Using/Sharing/Caching Auth Tokens 169 | 170 | Certain OpenStack/Swift providers have limits in place regarding token 171 | generation. To re-use auth tokens by caching them via memcached, install dalli 172 | 173 | `gem install dalli` 174 | 175 | and provide an instance of Dalli::Client to SwiftClient: 176 | 177 | ```ruby 178 | swift_client = SwiftClient.new( 179 | :auth_url => "https://example.com/auth/v1.0", 180 | ... 181 | :cache_store => Dalli::Client.new 182 | ) 183 | ``` 184 | 185 | The cache key used to store the auth token will include all neccessary details 186 | to ensure the auth token won't be used for a different swift account erroneously. 187 | 188 | The cache implementation of SwiftClient is not restricted to memcached. To use 189 | a different one, simply implement a driver for your favorite cache store. See 190 | [null_cache.rb](https://github.com/mrkamel/swift_client/blob/master/lib/swift_client/null_cache.rb) 191 | for more info. 192 | 193 | ## bulk_delete 194 | 195 | Takes an array containing container_name/object_name entries. 196 | Automatically slices and sends 1_000 items per request. 197 | 198 | ## Non-chunked uploads 199 | 200 | By default files are uploaded in chunks and using a `Transfer-Encoding: 201 | chunked` header. You can override this by passing a `Transfer-Encoding: 202 | identity` header: 203 | 204 | ```ruby 205 | put_object(object_name, data_or_io, container_name, "Transfer-Encoding" => "identity") 206 | ``` 207 | 208 | ## Contributing 209 | 210 | 1. Fork it ( https://github.com/mrkamel/swift_client/fork ) 211 | 2. Create your feature branch (`git checkout -b my-new-feature`) 212 | 3. Commit your changes (`git commit -am 'Add some feature'`) 213 | 4. Push to the branch (`git push origin my-new-feature`) 214 | 5. Create a new Pull Request 215 | 216 | ## Semantic Versioning 217 | 218 | Starting with version 0.2.0, SwiftClient uses Semantic Versioning: 219 | [SemVer](http://semver.org/) 220 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "lib" 6 | t.pattern = "test/**/*_test.rb" 7 | t.verbose = true 8 | end 9 | 10 | -------------------------------------------------------------------------------- /lib/swift_client.rb: -------------------------------------------------------------------------------- 1 | 2 | require "swift_client/version" 3 | require "swift_client/null_cache" 4 | 5 | require "httparty" 6 | require "mime-types" 7 | require "openssl" 8 | require "stringio" 9 | 10 | class SwiftClient 11 | class AuthenticationError < StandardError; end 12 | class OptionError < StandardError; end 13 | class EmptyNameError < StandardError; end 14 | class TempUrlKeyMissing < StandardError; end 15 | 16 | class ResponseError < StandardError 17 | attr_accessor :code, :message 18 | 19 | def initialize(code, message) 20 | self.code = code 21 | self.message = message 22 | end 23 | 24 | def to_s 25 | "#{code} #{message}" 26 | end 27 | end 28 | 29 | attr_accessor :options, :auth_token, :storage_url, :cache_store 30 | 31 | def initialize(options = {}) 32 | raise(OptionError, "Setting expires_in connection wide is deprecated") if options[:expires_in] 33 | 34 | self.options = options 35 | self.cache_store = options[:cache_store] || SwiftClient::NullCache.new 36 | 37 | authenticate 38 | end 39 | 40 | def head_account(options = {}) 41 | request :head, "/", options 42 | end 43 | 44 | def post_account(headers = {}, options = {}) 45 | request :post, "/", options.merge(:headers => headers) 46 | end 47 | 48 | def head_containers(options = {}) 49 | request :head, "/", options 50 | end 51 | 52 | def get_containers(query = nil, options = {}) 53 | request :get, "/", options.merge(:query => query) 54 | end 55 | 56 | def paginate_containers(query = nil, options = {}, &block) 57 | paginate(:get_containers, query, options, &block) 58 | end 59 | 60 | def get_container(container_name, query = nil, options = {}) 61 | raise(EmptyNameError) if container_name.empty? 62 | 63 | request :get, "/#{container_name}", options.merge(:query => query) 64 | end 65 | 66 | def paginate_container(container_name, query = nil, options = {}, &block) 67 | paginate(:get_container, container_name, query, options, &block) 68 | end 69 | 70 | def head_container(container_name, options = {}) 71 | raise(EmptyNameError) if container_name.empty? 72 | 73 | request :head, "/#{container_name}", options 74 | end 75 | 76 | def put_container(container_name, headers = {}, options = {}) 77 | raise(EmptyNameError) if container_name.empty? 78 | 79 | request :put, "/#{container_name}", options.merge(:headers => headers) 80 | end 81 | 82 | def post_container(container_name, headers = {}, options = {}) 83 | raise(EmptyNameError) if container_name.empty? 84 | 85 | request :post, "/#{container_name}", options.merge(:headers => headers) 86 | end 87 | 88 | def delete_container(container_name, options = {}) 89 | raise(EmptyNameError) if container_name.empty? 90 | 91 | request :delete, "/#{container_name}", options 92 | end 93 | 94 | def put_object(object_name, data_or_io, container_name, headers = {}, options = {}) 95 | raise(EmptyNameError) if object_name.empty? || container_name.empty? 96 | 97 | mime_type = MIME::Types.of(object_name).first 98 | 99 | extended_headers = (headers || {}).dup 100 | 101 | unless find_header_key(extended_headers, "Content-Type") 102 | extended_headers["Content-Type"] = mime_type.content_type if mime_type 103 | extended_headers["Content-Type"] ||= "application/octet-stream" 104 | end 105 | 106 | if extended_headers["Transfer-Encoding"] == "identity" 107 | request :put, "/#{container_name}/#{object_name}", options.merge(:body => data_or_io.respond_to?(:read) ? data_or_io.read : data_or_io, :headers => extended_headers) 108 | else 109 | extended_headers["Transfer-Encoding"] = "chunked" 110 | request :put, "/#{container_name}/#{object_name}", options.merge(:body_stream => data_or_io.respond_to?(:read) ? data_or_io : StringIO.new(data_or_io), :headers => extended_headers) 111 | end 112 | end 113 | 114 | def post_object(object_name, container_name, headers = {}, options = {}) 115 | raise(EmptyNameError) if object_name.empty? || container_name.empty? 116 | 117 | request :post, "/#{container_name}/#{object_name}", options.merge(:headers => headers) 118 | end 119 | 120 | def get_object(object_name, container_name, options = {}, &block) 121 | raise(EmptyNameError) if object_name.empty? || container_name.empty? 122 | 123 | request(:get, "/#{container_name}/#{object_name}", options.merge(block ? { :stream_body => true } : {}), &block) 124 | end 125 | 126 | def head_object(object_name, container_name, options = {}) 127 | raise(EmptyNameError) if object_name.empty? || container_name.empty? 128 | 129 | request :head, "/#{container_name}/#{object_name}", options 130 | end 131 | 132 | def post_head(object_name, container_name, _headers = {}, options = {}) 133 | raise(EmptyNameError) if object_name.empty? || container_name.empty? 134 | 135 | request :post, "/#{container_name}/#{object_name}", options.merge(headers: _headers) 136 | end 137 | 138 | def delete_object(object_name, container_name, options = {}) 139 | raise(EmptyNameError) if object_name.empty? || container_name.empty? 140 | 141 | request :delete, "/#{container_name}/#{object_name}", options 142 | end 143 | 144 | def get_objects(container_name, query = nil, options = {}) 145 | raise(EmptyNameError) if container_name.empty? 146 | 147 | request :get, "/#{container_name}", options.merge(:query => query) 148 | end 149 | 150 | def paginate_objects(container_name, query = nil, options = {}, &block) 151 | paginate(:get_objects, container_name, query, options, &block) 152 | end 153 | 154 | def public_url(object_name, container_name) 155 | raise(EmptyNameError) if object_name.empty? || container_name.empty? 156 | 157 | "#{storage_url}/#{container_name}/#{object_name}" 158 | end 159 | 160 | def temp_url(object_name, container_name, opts = {}) 161 | raise(EmptyNameError) if object_name.empty? || container_name.empty? 162 | raise(TempUrlKeyMissing) unless options[:temp_url_key] 163 | 164 | expires = (Time.now + (opts[:expires_in] || 3600).to_i).to_i 165 | path = URI.parse("#{storage_url}/#{container_name}/#{object_name}").path 166 | 167 | signature = OpenSSL::HMAC.hexdigest("sha1", options[:temp_url_key], "GET\n#{expires}\n#{path}") 168 | 169 | "#{storage_url}/#{container_name}/#{object_name}?temp_url_sig=#{signature}&temp_url_expires=#{expires}" 170 | end 171 | 172 | def bulk_delete(items, options = {}) 173 | items.each_slice(1_000) do |slice| 174 | request :delete, "/?bulk-delete", options.merge(:body => slice.join("\n"), :headers => { "Content-Type" => "text/plain" }) 175 | end 176 | 177 | items 178 | end 179 | 180 | private 181 | 182 | def cache_key 183 | auth_keys = [:auth_url, :username, :access_key, :user_id, :user_domain, :user_domain_id, :domain_name, 184 | :domain_id, :token, :project_id, :project_name, :project_domain_name, :project_domain_id, :tenant_name] 185 | 186 | auth_key = auth_keys.collect { |key| options[key] }.inspect 187 | 188 | Digest::SHA1.hexdigest(auth_key) 189 | end 190 | 191 | def find_header_key(headers, key) 192 | headers.keys.detect { |k| k.downcase == key.downcase } 193 | end 194 | 195 | def request(method, path, opts = {}, &block) 196 | headers = (opts[:headers] || {}).dup 197 | headers["X-Auth-Token"] = auth_token 198 | headers["Accept"] ||= "application/json" unless opts.delete(:json) == false 199 | 200 | stream_pos = opts[:body_stream].pos if opts[:body_stream] 201 | 202 | response = HTTParty.send(method, "#{storage_url}#{path}", opts.merge(:headers => headers), &block) 203 | 204 | if response.code == 401 205 | authenticate 206 | 207 | opts[:body_stream].pos = stream_pos if opts[:body_stream] 208 | 209 | return request(method, path, opts, &block) 210 | end 211 | 212 | raise(ResponseError.new(response.code, response.message)) unless response.success? 213 | 214 | response 215 | end 216 | 217 | def authenticate 218 | return if authenticate_from_cache 219 | 220 | return authenticate_v3 if options[:auth_url] =~ /v3/ 221 | return authenticate_v2 if options[:auth_url] =~ /v2/ 222 | 223 | authenticate_v1 224 | end 225 | 226 | def authenticate_from_cache 227 | cached_auth_token = cache_store.get("swift_client:auth_token:#{cache_key}") 228 | cached_storage_url = cache_store.get("swift_client:storage_url:#{cache_key}") 229 | 230 | return false if cached_auth_token.nil? || cached_storage_url.nil? 231 | 232 | if cached_auth_token != auth_token || cached_storage_url != storage_url 233 | self.auth_token = cached_auth_token 234 | self.storage_url = cached_storage_url 235 | 236 | return true 237 | end 238 | 239 | false 240 | end 241 | 242 | def set_authentication_details(auth_token, storage_url) 243 | cache_store.set("swift_client:auth_token:#{cache_key}", auth_token) 244 | cache_store.set("swift_client:storage_url:#{cache_key}", storage_url) 245 | 246 | self.auth_token = auth_token 247 | self.storage_url = storage_url 248 | end 249 | 250 | def authenticate_v1 251 | [:auth_url, :username, :api_key].each do |key| 252 | raise(AuthenticationError, "#{key} missing") unless options[key] 253 | end 254 | 255 | response = HTTParty.get(options[:auth_url], :headers => { "X-Auth-User" => options[:username], "X-Auth-Key" => options[:api_key] }) 256 | 257 | raise(AuthenticationError, "#{response.code}: #{response.message}") unless response.success? 258 | 259 | set_authentication_details response.headers["X-Auth-Token"], options[:storage_url] || response.headers["X-Storage-Url"] 260 | end 261 | 262 | def authenticate_v2 263 | [:auth_url, :storage_url].each do |key| 264 | raise(AuthenticationError, "#{key} missing") unless options[key] 265 | end 266 | 267 | auth = { "auth" => {} } 268 | 269 | if options[:tenant_name] 270 | auth["auth"]["tenantName"] = options[:tenant_name] 271 | else 272 | raise AuthenticationError, "No tenant specified" 273 | end 274 | 275 | if options[:username] && options[:password] 276 | auth["auth"]["passwordCredentials"] = { "username" => options[:username], "password" => options[:password] } 277 | elsif options[:access_key] && options[:secret_key] 278 | auth["auth"]["apiAccessKeyCredentials"] = { "accessKey" => options[:access_key], "secretKey" => options[:secret_key] } 279 | else 280 | raise AuthenticationError, "Unknown authentication method" 281 | end 282 | 283 | response = HTTParty.post("#{options[:auth_url].gsub(/\/+$/, "")}/tokens", :body => JSON.dump(auth), :headers => { "Content-Type" => "application/json" }) 284 | 285 | raise(AuthenticationError, "#{response.code}: #{response.message}") unless response.success? 286 | 287 | set_authentication_details response.parsed_response["access"]["token"]["id"], options[:storage_url] 288 | end 289 | 290 | def authenticate_v3 291 | raise(AuthenticationError, "auth_url missing") unless options[:auth_url] 292 | raise(AuthenticationError, "username in combination with domain/domain_id is deprecated, please use user_domain/user_domain_id instead") if options[:username] && (options[:domain] || options[:domain_id]) && !options[:user_domain] && !options[:user_domain_id] 293 | 294 | auth = { "auth" => { "identity" => {} } } 295 | 296 | if options[:username] && options[:password] && (options[:user_domain] || options[:user_domain_id]) 297 | auth["auth"]["identity"]["methods"] = ["password"] 298 | auth["auth"]["identity"]["password"] = { "user" => { "name" => options[:username], "password" => options[:password] } } 299 | auth["auth"]["identity"]["password"]["user"]["domain"] = options[:user_domain] ? { "name" => options[:user_domain] } : { "id" => options[:user_domain_id] } 300 | elsif options[:user_id] && options[:password] 301 | auth["auth"]["identity"]["methods"] = ["password"] 302 | auth["auth"]["identity"]["password"] = { "user" => { "id" => options[:user_id], "password" => options[:password] } } 303 | elsif options[:token] 304 | auth["auth"]["identity"]["methods"] = ["token"] 305 | auth["auth"]["identity"]["token"] = { "id" => options[:token] } 306 | else 307 | raise AuthenticationError, "Unknown authentication method" 308 | end 309 | 310 | # handle project authentication scope 311 | 312 | if (options[:project_id] || options[:project_name]) && (options[:project_domain_name] || options[:project_domain_id]) 313 | auth["auth"]["scope"] = { "project" => { "domain" => {} } } 314 | auth["auth"]["scope"]["project"]["name"] = options[:project_name] if options[:project_name] 315 | auth["auth"]["scope"]["project"]["id"] = options[:project_id] if options[:project_id] 316 | auth["auth"]["scope"]["project"]["domain"]["name"] = options[:project_domain_name] if options[:project_domain_name] 317 | auth["auth"]["scope"]["project"]["domain"]["id"] = options[:project_domain_id] if options[:project_domain_id] 318 | end 319 | 320 | # handle domain authentication scope 321 | 322 | if options[:domain_name] || options[:domain_id] 323 | auth["auth"]["scope"] = { "domain" => {} } 324 | auth["auth"]["scope"]["domain"]["name"] = options[:domain_name] if options[:domain_name] 325 | auth["auth"]["scope"]["domain"]["id"] = options[:domain_id] if options[:domain_id] 326 | end 327 | 328 | response = HTTParty.post("#{options[:auth_url].gsub(/\/+$/, "")}/auth/tokens", :body => JSON.dump(auth), :headers => { "Content-Type" => "application/json" }) 329 | 330 | raise(AuthenticationError, "#{response.code}: #{response.message}") unless response.success? 331 | 332 | storage_url = options[:storage_url] || storage_url_from_v3_response(response) 333 | 334 | raise(AuthenticationError, "storage_url missing") unless storage_url 335 | 336 | set_authentication_details response.headers["X-Subject-Token"], storage_url 337 | end 338 | 339 | def storage_url_from_v3_response(response) 340 | swift_services = Array(response.parsed_response["token"]["catalog"]).select { |service| service["type"] == "object-store" } 341 | swift_service = swift_services.first 342 | 343 | return unless swift_services.size == 1 344 | 345 | interface = options[:interface] || "public" 346 | swift_endpoints = swift_service["endpoints"].select { |endpoint| endpoint["interface"] == interface } 347 | swift_endpoint = swift_endpoints.first 348 | 349 | return unless swift_endpoints.size == 1 350 | 351 | swift_endpoint["url"] 352 | end 353 | 354 | def paginate(method, *args, query, options) 355 | return enum_for(:paginate, method, *args, query, options) unless block_given? 356 | 357 | marker = nil 358 | 359 | loop do 360 | response = send(method, *args, marker ? (query || {}).merge(:marker => marker) : query, options) 361 | 362 | return if response.parsed_response.empty? 363 | 364 | yield response 365 | 366 | marker = response.parsed_response.last["name"] 367 | end 368 | end 369 | end 370 | -------------------------------------------------------------------------------- /lib/swift_client/null_cache.rb: -------------------------------------------------------------------------------- 1 | 2 | class SwiftClient::NullCache 3 | def get(key) 4 | nil 5 | end 6 | 7 | def set(key, value) 8 | true 9 | end 10 | end 11 | 12 | -------------------------------------------------------------------------------- /lib/swift_client/version.rb: -------------------------------------------------------------------------------- 1 | 2 | class SwiftClient 3 | VERSION = "0.3.0" 4 | end 5 | -------------------------------------------------------------------------------- /swift_client.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'swift_client/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "swift_client" 8 | spec.version = SwiftClient::VERSION 9 | spec.authors = ["Benjamin Vetter"] 10 | spec.email = ["vetter@plainpicture.de"] 11 | spec.summary = %q{Small but powerful client to interact with OpenStack Swift} 12 | spec.description = %q{Small but powerful client to interact with OpenStack Swift} 13 | spec.homepage = "https://github.com/mrkamel/swift_client" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency "httparty" 22 | spec.add_dependency "mime-types" 23 | 24 | spec.add_development_dependency "bundler" 25 | spec.add_development_dependency "rake" 26 | spec.add_development_dependency "minitest" 27 | spec.add_development_dependency "webmock" 28 | spec.add_development_dependency "mocha" 29 | end 30 | -------------------------------------------------------------------------------- /test/swift_client_test.rb: -------------------------------------------------------------------------------- 1 | 2 | require File.expand_path("../test_helper", __FILE__) 3 | 4 | class MemoryCache 5 | def initialize 6 | @cache = {} 7 | end 8 | 9 | def set(key, value) 10 | @cache[key] = value 11 | end 12 | 13 | def get(key) 14 | @cache[key] 15 | end 16 | end 17 | 18 | class SwiftClientTest < MiniTest::Test 19 | def setup 20 | stub_request(:get, "https://example.com/auth/v1.0").with(:headers => { "X-Auth-Key" => "secret", "X-Auth-User" => "account:username" }).to_return(:status => 200, :body => "", :headers => { "X-Auth-Token" => "Token", "X-Storage-Url" => "https://example.com/v1/AUTH_account" }) 21 | 22 | @swift_client = SwiftClient.new(:auth_url => "https://example.com/auth/v1.0", :username => "account:username", :api_key => "secret", :temp_url_key => "Temp url key") 23 | 24 | assert_equal "Token", @swift_client.auth_token 25 | assert_equal "https://example.com/v1/AUTH_account", @swift_client.storage_url 26 | end 27 | 28 | def test_authenticate_from_cache 29 | cache = MemoryCache.new 30 | cache.set("swift_client:auth_token:49f42f2927701ba93a5bf9750da8fedfa197fa82", "Cached token") 31 | cache.set("swift_client:storage_url:49f42f2927701ba93a5bf9750da8fedfa197fa82", "https://cache.example.com/v1/AUTH_account") 32 | 33 | @swift_client = SwiftClient.new(:auth_url => "https://example.com/auth/v1.0", :username => "account:username", :api_key => "secret", :temp_url_key => "Temp url key", :cache_store => cache) 34 | 35 | assert_equal "Cached token", @swift_client.auth_token 36 | assert_equal "https://cache.example.com/v1/AUTH_account", @swift_client.storage_url 37 | 38 | @swift_client.send(:authenticate) # Re-authenticate 39 | 40 | assert_equal "Token", @swift_client.auth_token 41 | assert_equal "https://example.com/v1/AUTH_account", @swift_client.storage_url 42 | end 43 | 44 | def test_v3_authentication_unscoped_with_password 45 | stub_request(:post, "https://auth.example.com/v3/auth/tokens").with(:body => JSON.dump("auth" => { "identity" => { "methods" => ["password"], "password" => { "user" => { "name" => "username", "password" => "secret", "domain" => { "name" => "example.com" }}}}})).to_return(:status => 200, :body => JSON.dump("token" => "..."), :headers => { "X-Subject-Token" => "Token", "Content-Type" => "application/json" }) 46 | 47 | @swift_client = SwiftClient.new(:storage_url => "https://example.com/v1/AUTH_account", :auth_url => "https://auth.example.com/v3", :username => "username", :user_domain => "example.com", :password => "secret") 48 | 49 | assert_equal "Token", @swift_client.auth_token 50 | assert_equal "https://example.com/v1/AUTH_account", @swift_client.storage_url 51 | end 52 | 53 | def test_v3_authentication_project_scoped_with_password 54 | stub_request(:post, "https://auth.example.com/v3/auth/tokens").with(:body => JSON.dump("auth" => { "identity" => { "methods" => ["password"], "password" => { "user" => { "name" => "username", "password" => "secret", "domain" => { "name" => "example.com" }}}}, "scope"=> { "project" => { "domain" => { "id" => "domain1" }, "id" => "project1" }}})).to_return(:status => 200, :body => JSON.dump("token" => "..."), :headers => { "X-Subject-Token" => "Token", "Content-Type" => "application/json" }) 55 | 56 | @swift_client = SwiftClient.new(:storage_url => "https://example.com/v1/AUTH_account", :auth_url => "https://auth.example.com/v3", :username => "username", :user_domain => "example.com", :password => "secret", :project_id => 'project1', :project_domain_id => 'domain1') 57 | 58 | assert_equal "Token", @swift_client.auth_token 59 | assert_equal "https://example.com/v1/AUTH_account", @swift_client.storage_url 60 | end 61 | 62 | def test_v3_authentication_domain_scoped_with_password 63 | stub_request(:post, "https://auth.example.com/v3/auth/tokens").with(:body => JSON.dump("auth" => { "identity" => { "methods" => ["password"], "password" => { "user" => { "name" => "username", "password" => "secret", "domain" => { "name" => "example.com" }}}}, "scope"=> { "domain" => { "id" => "domain1" }}})).to_return(:status => 200, :body => JSON.dump("token" => "..."), :headers => { "X-Subject-Token" => "Token", "Content-Type" => "application/json" }) 64 | 65 | @swift_client = SwiftClient.new(:storage_url => "https://example.com/v1/AUTH_account", :auth_url => "https://auth.example.com/v3", :username => "username", :user_domain => "example.com", :password => "secret", :domain_id => 'domain1') 66 | 67 | assert_equal "Token", @swift_client.auth_token 68 | assert_equal "https://example.com/v1/AUTH_account", @swift_client.storage_url 69 | end 70 | 71 | def test_v3_authentication_storage_url_from_catalog 72 | stub_request(:post, "https://auth.example.com/v3/auth/tokens").with(:body => JSON.dump("auth" => { "identity" => { "methods" => ["password"], "password" => { "user" => { "name" => "username", "password" => "secret", "domain" => { "name" => "example.com" }}}}})).to_return(:status => 200, :body => JSON.dump("token" => { "catalog"=> [{ "type" => "object-store", "endpoints" => [{ "interface"=>"public", "url"=> "https://example.com/v1/AUTH_account" }] }] }), :headers => { "X-Subject-Token" => "Token", "Content-Type" => "application/json" }) 73 | 74 | @swift_client = SwiftClient.new(:auth_url => "https://auth.example.com/v3", :username => "username", :user_domain => "example.com", :password => "secret") 75 | 76 | assert_equal "Token", @swift_client.auth_token 77 | assert_equal "https://example.com/v1/AUTH_account", @swift_client.storage_url 78 | end 79 | 80 | def test_v3_authentication_storage_url_from_catalog_with_multiple_endpoints 81 | stub_request(:post, "https://auth.example.com/v3/auth/tokens").with(:body => JSON.dump("auth" => { "identity" => { "methods" => ["password"], "password" => { "user" => { "name" => "username", "password" => "secret", "domain" => { "name" => "example.com" }}}}})).to_return(:status => 200, :body => JSON.dump("token" => { "catalog"=> [{ "type" => "object-store", "endpoints" => [{ "interface"=>"public", "url"=> "https://example.com/v1/AUTH_account" }, { "interface"=>"internal", "url"=> "https://example.com/v2/AUTH_account" }] }] }), :headers => { "X-Subject-Token" => "Token", "Content-Type" => "application/json" }) 82 | 83 | @swift_client = SwiftClient.new(:auth_url => "https://auth.example.com/v3", :username => "username", :user_domain => "example.com", :password => "secret", :interface => "internal") 84 | 85 | assert_equal "Token", @swift_client.auth_token 86 | assert_equal "https://example.com/v2/AUTH_account", @swift_client.storage_url 87 | end 88 | 89 | def test_v3_authentication_without_storage_url_and_multiple_swifts 90 | stub_request(:post, "https://auth.example.com/v3/auth/tokens").with(:body => JSON.dump("auth" => { "identity" => { "methods" => ["password"], "password" => { "user" => { "name" => "username", "password" => "secret", "domain" => { "name" => "example.com" }}}}})).to_return(:status => 200, :body => JSON.dump("token" => { "catalog"=> [{ "type" => "object-store", "endpoints" => [{ "interface"=>"public", "url"=> "https://example.com/v1/AUTH_account" }] }, { "type" => "object-store", "endpoints" => [{ "interface"=>"public", "url"=> "https://example.com/v1/AUTH_account" }] }] }), :headers => { "X-Subject-Token" => "Token", "Content-Type" => "application/json" }) 91 | 92 | assert_raises SwiftClient::AuthenticationError do 93 | SwiftClient.new :auth_url => "https://auth.example.com/v3", :username => "username", :user_domain => "example.com", :password => "secret" 94 | end 95 | end 96 | 97 | def test_v3_authentication_without_storage_url_and_multiple_endpoints 98 | stub_request(:post, "https://auth.example.com/v3/auth/tokens").with(:body => JSON.dump("auth" => { "identity" => { "methods" => ["password"], "password" => { "user" => { "name" => "username", "password" => "secret", "domain" => { "name" => "example.com" }}}}})).to_return(:status => 200, :body => JSON.dump("token" => { "catalog"=> [{ "type" => "object-store", "endpoints" => [{ "interface"=>"public", "url"=> "https://first.example.com/v1/AUTH_account" }, { "interface"=>"public", "url"=> "https://second.example.com/v1/AUTH_account" }] }] }), :headers => { "X-Subject-Token" => "Token", "Content-Type" => "application/json" }) 99 | 100 | assert_raises SwiftClient::AuthenticationError do 101 | SwiftClient.new :auth_url => "https://auth.example.com/v3", :username => "username", :user_domain => "example.com", :password => "secret" 102 | end 103 | end 104 | 105 | def test_v3_authentication_with_token 106 | stub_request(:post, "https://auth.example.com/v3/auth/tokens").with(:body => JSON.dump("auth" => { "identity" => { "methods" => ["token"], "token" => { "id" => "Token" }}})).to_return(:status => 200, :body => JSON.dump("token" => "..."), :headers => { "X-Subject-Token" => "Token", "Content-Type" => "application/json" }) 107 | 108 | @swift_client = SwiftClient.new(:storage_url => "https://example.com/v1/AUTH_account", :auth_url => "https://auth.example.com/v3", :token => "Token") 109 | 110 | assert_equal "Token", @swift_client.auth_token 111 | assert_equal "https://example.com/v1/AUTH_account", @swift_client.storage_url 112 | end 113 | 114 | def test_v3_authentication_username_domain_deprecation 115 | assert_raises SwiftClient::AuthenticationError do 116 | SwiftClient.new :auth_url => "https://auth.example.com/v3", :username => "username", :domain => "example.com", :password => "secret" 117 | end 118 | end 119 | 120 | def test_v2_authentication_with_password 121 | stub_request(:post, "https://auth.example.com/v2.0/tokens").with(:body => JSON.dump("auth" => { "tenantName" => "Tenant", "passwordCredentials" => { "username" => "Username", :password => "Password" }})).to_return(:status => 200, :body => JSON.dump("access" => { "token" => { "id" => "Token" }}), :headers => { "Content-Type" => "application/json" }) 122 | 123 | @swift_client = SwiftClient.new(:storage_url => "https://example.com/v1/AUTH_account", :auth_url => "https://auth.example.com/v2.0", :tenant_name => "Tenant", :username => "Username", :password => "Password") 124 | 125 | assert_equal "Token", @swift_client.auth_token 126 | assert_equal "https://example.com/v1/AUTH_account", @swift_client.storage_url 127 | end 128 | 129 | def test_v2_authentication_with_key 130 | stub_request(:post, "https://auth.example.com/v2.0/tokens").with(:body => JSON.dump("auth" => { "tenantName" => "Tenant", "apiAccessKeyCredentials" => { "accessKey" => "AccessKey", :secretKey => "SecretKey" }})).to_return(:status => 200, :body => JSON.dump("access" => { "token" => { "id" => "Token" }}), :headers => { "Content-Type" => "application/json" }) 131 | 132 | @swift_client = SwiftClient.new(:storage_url => "https://example.com/v1/AUTH_account", :auth_url => "https://auth.example.com/v2.0", :tenant_name => "Tenant", :access_key => "AccessKey", :secret_key => "SecretKey") 133 | 134 | assert_equal "Token", @swift_client.auth_token 135 | assert_equal "https://example.com/v1/AUTH_account", @swift_client.storage_url 136 | end 137 | 138 | def test_storage_url 139 | stub_request(:get, "https://example.com/auth/v1.0").with(:headers => { "X-Auth-Key" => "secret", "X-Auth-User" => "account:username" }).to_return(:status => 200, :body => "", :headers => { "X-Auth-Token" => "Token", "X-Storage-Url" => "https://example.com/v1/AUTH_account" }) 140 | 141 | @swift_client = SwiftClient.new(:auth_url => "https://example.com/auth/v1.0", :username => "account:username", :api_key => "secret", :storage_url => "https://storage-url.com/path") 142 | 143 | assert_equal "Token", @swift_client.auth_token 144 | assert_equal "https://storage-url.com/path", @swift_client.storage_url 145 | end 146 | 147 | def test_head_account 148 | stub_request(:head, "https://example.com/v1/AUTH_account/").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 204, :body => "", :headers => { "Content-Type" => "application/json" }) 149 | 150 | assert 204, @swift_client.head_account.code 151 | end 152 | 153 | def test_post_account 154 | stub_request(:post, "https://example.com/v1/AUTH_account/").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token", "X-Account-Meta-Test" => "Test" }).to_return(:status => 204, :body => "", :headers => { "Content-Type" => "application/json" }) 155 | 156 | assert_equal 204, @swift_client.post_account("X-Account-Meta-Test" => "Test").code 157 | end 158 | 159 | def test_head_containers 160 | stub_request(:head, "https://example.com/v1/AUTH_account/").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 204, :body => "", :headers => { "Content-Type" => "application/json" }) 161 | 162 | assert 204, @swift_client.head_containers.code 163 | end 164 | 165 | def test_get_containers 166 | containers = [ 167 | { "count" => 1, "bytes" => 1, "name" => "container-1" }, 168 | { "count" => 1, "bytes" => 1, "name" => "container-2" } 169 | ] 170 | 171 | stub_request(:get, "https://example.com/v1/AUTH_account/").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump(containers), :headers => { "Content-Type" => "application/json" }) 172 | 173 | assert_equal containers, @swift_client.get_containers.parsed_response 174 | end 175 | 176 | def test_get_containers_without_query 177 | containers = [ 178 | { "count" => 1, "bytes" => 1, "name" => "container-1" }, 179 | { "count" => 1, "bytes" => 1, "name" => "container-2" } 180 | ] 181 | 182 | stub_request(:get, "https://example.com/v1/AUTH_account/").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump(containers), :headers => { "Content-Type" => "application/json" }) 183 | 184 | assert_equal containers, @swift_client.get_containers.parsed_response 185 | end 186 | 187 | def test_bulk_delete 188 | objects = [ 189 | "container1/object1", 190 | "container1/object2", 191 | "container2/object1" 192 | ] 193 | 194 | stub_request(:delete, "https://example.com/v1/AUTH_account/?bulk-delete").with(:body => objects.join("\n"), :headers => { "Content-Type" => "text/plain", "X-Auth-Token" => "Token" }).to_return(:status => 200,:body => "", :headers => { "Content-Type" => "application/json" }) 195 | 196 | assert @swift_client.bulk_delete(objects) 197 | end 198 | 199 | def test_paginate_containers 200 | containers = [ 201 | { "count" => 1, "bytes" => 1, "name" => "container-1" }, 202 | { "count" => 1, "bytes" => 1, "name" => "container-2" }, 203 | { "count" => 1, "bytes" => 1, "name" => "container-3" } 204 | ] 205 | 206 | stub_request(:get, "https://example.com/v1/AUTH_account/?limit=2").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump(containers[0 .. 1]), :headers => { "Content-Type" => "application/json" }) 207 | stub_request(:get, "https://example.com/v1/AUTH_account/?limit=2&marker=container-2").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump(containers[2 .. 3]), :headers => { "Content-Type" => "application/json" }) 208 | stub_request(:get, "https://example.com/v1/AUTH_account/?limit=2&marker=container-3").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump([]), :headers => { "Content-Type" => "application/json" }) 209 | 210 | assert_equal containers, @swift_client.paginate_containers(:limit => 2).collect(&:parsed_response).flatten 211 | end 212 | 213 | def test_get_container 214 | objects = [ 215 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-2", "content_type" => "Content type" }, 216 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-3", "content_type" => "Content type" } 217 | ] 218 | 219 | stub_request(:get, "https://example.com/v1/AUTH_account/container-1?limit=2&marker=object-2").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump(objects), :headers => { "Content-Type" => "application/json" }) 220 | 221 | assert_equal objects, @swift_client.get_container("container-1", :limit => 2, :marker => "object-2").parsed_response 222 | end 223 | 224 | def test_paginate_container 225 | objects = [ 226 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-1", "content_type" => "Content type" }, 227 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-2", "content_type" => "Content type" }, 228 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-3", "content_type" => "Content type" }, 229 | ] 230 | 231 | stub_request(:get, "https://example.com/v1/AUTH_account/container-1?limit=2").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump(objects[0 .. 1]), :headers => { "Content-Type" => "application/json" }) 232 | stub_request(:get, "https://example.com/v1/AUTH_account/container-1?limit=2&marker=object-2").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump(objects[2 .. 3]), :headers => { "Content-Type" => "application/json" }) 233 | stub_request(:get, "https://example.com/v1/AUTH_account/container-1?limit=2&marker=object-3").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump([]), :headers => { "Content-Type" => "application/json" }) 234 | 235 | assert_equal objects, @swift_client.paginate_container("container-1", :limit => 2).collect(&:parsed_response).flatten 236 | end 237 | 238 | def test_head_container 239 | stub_request(:head, "https://example.com/v1/AUTH_account/container-1").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 204, :body => "", :headers => { "Content-Type" => "application/json" }) 240 | 241 | assert 204, @swift_client.head_container("container-1").code 242 | end 243 | 244 | def test_put_container 245 | stub_request(:put, "https://example.com/v1/AUTH_account/container").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token", "X-Container-Read" => ".r:*" }).to_return(:status => 201, :body => "", :headers => {}) 246 | 247 | assert_equal 201, @swift_client.put_container("container", "X-Container-Read" => ".r:*").code 248 | end 249 | 250 | def test_post_container 251 | stub_request(:post, "https://example.com/v1/AUTH_account/container").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token", "X-Container-Read" => ".r:*" }).to_return(:status => 204, :body => "", :headers => {}) 252 | 253 | assert_equal 204, @swift_client.post_container("container", "X-Container-Read" => ".r:*").code 254 | end 255 | 256 | def test_delete_container 257 | stub_request(:delete, "https://example.com/v1/AUTH_account/container").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 204, :body => "", :headers => {}) 258 | 259 | assert_equal 204, @swift_client.delete_container("container").code 260 | end 261 | 262 | def test_put_object 263 | stub_request(:put, "https://example.com/v1/AUTH_account/container/object").with(:body => "data", :headers => { "Transfer-Encoding" => "chunked", "Content-Type" => "application/octet-stream", "Accept" => "application/json", "X-Auth-Token" => "Token", "X-Object-Meta-Test" => "Test" }).to_return(:status => 201, :body => "", :headers => {}) 264 | 265 | assert_equal 201, @swift_client.put_object("object", "data", "container", "X-Object-Meta-Test" => "Test").code 266 | end 267 | 268 | def test_put_object_nonchunked 269 | stub_request(:put, "https://example.com/v1/AUTH_account/container/object").with(:body => "data", :headers => { "Transfer-Encoding" => "identity", "Content-Type" => "application/octet-stream", "Accept" => "application/json", "X-Auth-Token" => "Token", "X-Object-Meta-Test" => "Test" }).to_return(:status => 201, :body => "", :headers => {}) 270 | 271 | assert_equal 201, @swift_client.put_object("object", "data", "container", "X-Object-Meta-Test" => "Test", "Transfer-Encoding" => "identity").code 272 | end 273 | 274 | def test_put_object_with_renewed_authorization 275 | stub_request(:put, "https://example.com/v1/AUTH_account/container/object").with(:body => "data", :headers => { "Transfer-Encoding" => "chunked", "Content-Type" => "application/octet-stream", "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return({ :status => 401, :body => "", :headers => {}}, { :status => 201, :body => "", :headers => {}}) 276 | 277 | assert_equal 201, @swift_client.put_object("object", "data", "container").code 278 | end 279 | 280 | def test_put_object_without_mime_type 281 | stub_request(:put, "https://example.com/v1/AUTH_account/container/object.jpg").with(:body => "data", :headers => { "Transfer-Encoding" => "chunked", "Accept" => "application/json", "Content-Type" => "image/jpeg", "X-Auth-Token" => "Token", "X-Object-Meta-Test" => "Test" }).to_return(:status => 201, :body => "", :headers => {}) 282 | 283 | assert_equal 201, @swift_client.put_object("object.jpg", "data", "container", "X-Object-Meta-Test" => "Test").code 284 | end 285 | 286 | def test_put_object_with_mime_type 287 | stub_request(:put, "https://example.com/v1/AUTH_account/container/object.jpg").with(:body => "data", :headers => { "Transfer-Encoding" => "chunked", "Accept" => "application/json", "Content-Type" => "content/type", "X-Auth-Token" => "Token", "X-Object-Meta-Test" => "Test" }).to_return(:status => 201, :body => "", :headers => {}) 288 | 289 | assert_equal 201, @swift_client.put_object("object.jpg", "data", "container", "X-Object-Meta-Test" => "Test", "Content-Type" => "content/type").code 290 | end 291 | 292 | def test_put_object_with_io 293 | stub_request(:put, "https://example.com/v1/AUTH_account/container/object").with(:body => "data", :headers => { "Transfer-Encoding" => "chunked", "Accept" => "application/json", "X-Auth-Token" => "Token", "X-Object-Meta-Test" => "Test" }).to_return(:status => 201, :body => "", :headers => {}) 294 | 295 | assert_equal 201, @swift_client.put_object("object", StringIO.new("data"), "container", "X-Object-Meta-Test" => "Test").code 296 | end 297 | 298 | def test_put_object_with_io_nonchunked 299 | stub_request(:put, "https://example.com/v1/AUTH_account/container/object").with(:body => "data", :headers => { "Transfer-Encoding" => "identity", "Accept" => "application/json", "X-Auth-Token" => "Token", "X-Object-Meta-Test" => "Test" }).to_return(:status => 201, :body => "", :headers => {}) 300 | 301 | assert_equal 201, @swift_client.put_object("object", StringIO.new("data"), "container", "X-Object-Meta-Test" => "Test", "Transfer-Encoding" => "identity").code 302 | end 303 | 304 | def test_post_object 305 | stub_request(:post, "https://example.com/v1/AUTH_account/container/object").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token", "X-Object-Meta-Test" => "Test" }).to_return(:status => 201, :body => "", :headers => {}) 306 | 307 | assert_equal 201, @swift_client.post_object("object", "container", "X-Object-Meta-Test" => "Test").code 308 | end 309 | 310 | def test_post_head 311 | stub_request(:post, 'https://example.com/v1/AUTH_account/container/object').with(headers: { 'Accept' => 'application/json', 'X-Auth-Token' => 'Token', 'X-Delete-At' => '1553524860' }).to_return(status: 201, body: '', headers: {}) 312 | 313 | assert_equal 201, @swift_client.post_head('object', 'container', 'X-Delete-At' => '1553524860').code 314 | end 315 | 316 | def test_get_object 317 | stub_request(:get, "https://example.com/v1/AUTH_account/container/object").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => "Body", :headers => {}) 318 | block_res = 0 319 | 320 | large_body = "Body" * 16384 321 | stub_request(:get, "https://example.com/v1/AUTH_account/container/large_object").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => large_body, :headers => {}) 322 | 323 | assert_equal "Body", @swift_client.get_object("object", "container").body 324 | 325 | @swift_client.get_object("large_object", "container") do |chunk| 326 | block_res += chunk.length 327 | end 328 | 329 | assert_equal block_res, large_body.length 330 | end 331 | 332 | def test_head_object 333 | stub_request(:head, "https://example.com/v1/AUTH_account/container/object").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => "", :headers => {}) 334 | 335 | assert_equal 200, @swift_client.head_object("object", "container").code 336 | end 337 | 338 | def test_delete_object 339 | stub_request(:delete, "https://example.com/v1/AUTH_account/container/object").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 204, :body => "", :headers => {}) 340 | 341 | assert_equal 204, @swift_client.delete_object("object", "container").code 342 | end 343 | 344 | def test_get_objects 345 | objects = [ 346 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-2", "content_type" => "Content type" }, 347 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-3", "content_type" => "Content type" } 348 | ] 349 | 350 | stub_request(:get, "https://example.com/v1/AUTH_account/container-1?limit=2&marker=object-2").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump(objects), :headers => { "Content-Type" => "application/json" }) 351 | 352 | assert_equal objects, @swift_client.get_objects("container-1", :limit => 2, :marker => "object-2").parsed_response 353 | end 354 | 355 | def test_get_objects_no_json 356 | response = "object-2\nobject-3\n" 357 | 358 | stub_request(:get, "https://example.com/v1/AUTH_account/container-1?limit=2&marker=object-2").with(:headers => { "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => response, :headers => { "Content-Type" => "text/html" }) 359 | 360 | assert_equal response, @swift_client.get_objects("container-1", { :limit => 2, :marker => "object-2" }, :json => false).body 361 | end 362 | 363 | def test_paginate_objects 364 | objects = [ 365 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-1", "content_type" => "Content type" }, 366 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-2", "content_type" => "Content type" }, 367 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-3", "content_type" => "Content type" } 368 | ] 369 | 370 | stub_request(:get, "https://example.com/v1/AUTH_account/container-1?limit=2").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump(objects[0 .. 1]), :headers => { "Content-Type" => "application/json" }) 371 | stub_request(:get, "https://example.com/v1/AUTH_account/container-1?limit=2&marker=object-2").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump(objects[2 .. 3]), :headers => { "Content-Type" => "application/json" }) 372 | stub_request(:get, "https://example.com/v1/AUTH_account/container-1?limit=2&marker=object-3").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump([]), :headers => { "Content-Type" => "application/json" }) 373 | 374 | assert_equal objects, @swift_client.paginate_objects("container-1", :limit => 2).collect(&:parsed_response).flatten 375 | end 376 | 377 | def test_paginate_objects_no_limit 378 | objects = [ 379 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-1", "content_type" => "Content type" }, 380 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-2", "content_type" => "Content type" }, 381 | { "hash" => "Hash", "last_modified" => "Last modified", "bytes" => 1, "name" => "object-3", "content_type" => "Content type" } 382 | ] 383 | 384 | stub_request(:get, "https://example.com/v1/AUTH_account/container-1").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump(objects), :headers => { "Content-Type" => "application/json" }) 385 | stub_request(:get, "https://example.com/v1/AUTH_account/container-1?marker=object-3").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => 200, :body => JSON.dump([]), :headers => { "Content-Type" => "application/json" }) 386 | 387 | assert_equal objects, @swift_client.paginate_objects("container-1").collect(&:parsed_response).flatten 388 | end 389 | 390 | def test_not_found 391 | stub_request(:get, "https://example.com/v1/AUTH_account/container/object").with(:headers => { "Accept" => "application/json", "X-Auth-Token" => "Token" }).to_return(:status => [404, "Not Found"], :body => "", :headers => { "Content-Type" => "application/json" }) 392 | 393 | begin 394 | @swift_client.get_object("object", "container") 395 | rescue SwiftClient::ResponseError => e 396 | assert_equal 404, e.code 397 | assert_equal "Not Found", e.message 398 | end 399 | end 400 | 401 | def test_public_url 402 | assert_equal "https://example.com/v1/AUTH_account/container/object", @swift_client.public_url("object", "container") 403 | end 404 | 405 | def test_temp_url 406 | Time.expects(:now).at_least_once.returns(1_000_000) 407 | 408 | assert @swift_client.temp_url("object", "container") =~ %r{https://example.com/v1/AUTH_account/container/object\?temp_url_sig=[a-f0-9]{40}&temp_url_expires=1003600} 409 | assert @swift_client.temp_url("object", "container", :expires_in => 86400) =~ %r{https://example.com/v1/AUTH_account/container/object\?temp_url_sig=[a-f0-9]{40}&temp_url_expires=1086400} 410 | end 411 | end 412 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | 2 | require "swift_client" 3 | require "minitest" 4 | require "minitest/autorun" 5 | require "webmock/minitest" 6 | require "minitest/unit" 7 | require "mocha/minitest" 8 | --------------------------------------------------------------------------------