s4 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. data/Rakefile +1 -1
  2. data/lib/s4.rb +120 -39
  3. data/s4.gemspec +7 -7
  4. data/test/s4_test.rb +151 -0
  5. metadata +4 -6
  6. data/test/s3_test.rb +0 -108
data/Rakefile CHANGED
@@ -1,5 +1,5 @@
1
1
  task :default => :test
2
2
 
3
3
  task :test do
4
- system "cutest test/*.rb"
4
+ system "ruby test/*.rb"
5
5
  end
data/lib/s4.rb CHANGED
@@ -5,7 +5,7 @@ require "time"
5
5
 
6
6
  # Simpler AWS S3 library
7
7
  class S4
8
- VERSION = "0.0.1"
8
+ VERSION = "0.0.2"
9
9
 
10
10
  # sub-resource names which may appear in the query string and also must be
11
11
  # signed against.
@@ -16,8 +16,42 @@ class S4
16
16
  HeaderValues = %w( response-content-type response-content-language response-expires reponse-cache-control response-content-disposition response-content-encoding )
17
17
 
18
18
  attr_reader :connection, :access_key_id, :secret_access_key, :bucket, :host
19
-
20
- def initialize(s3_url = ENV["S3_URL"])
19
+
20
+ class << self
21
+ # Connect to an S3 bucket.
22
+ #
23
+ # Pass your S3 connection parameters as URL, or read from ENV["S3_URL"] if
24
+ # none is passed.
25
+ #
26
+ # S3_URL format is s3://<access key id>:<secret access key>@s3.amazonaws.com/<bucket>
27
+ #
28
+ # i.e.
29
+ # bucket = S4.connect #=> Connects to ENV["S3_URL"]
30
+ # bucket = S4.connect("s3://0PN5J17HBGZHT7JJ3X82:k3nL7gH3+PadhTEVn5EXAMPLE@s3.amazonaws.com/bucket")
31
+ def connect(s3_url=ENV["S3_URL"])
32
+ new(s3_url).tap do |s4|
33
+ s4.connect
34
+ end
35
+ end
36
+
37
+ # Create an S3 bucket.
38
+ #
39
+ # See #connect for S3_URL parameters.
40
+ #
41
+ # Will create the bucket on S3 and connect to it, or just connect if the
42
+ # bucket already exists and is owned by you.
43
+ #
44
+ # i.e.
45
+ # bucket = S4.create
46
+ def create(s3_url=ENV["S3_URL"])
47
+ new(s3_url).tap do |s4|
48
+ s4.create
49
+ end
50
+ end
51
+ end
52
+
53
+ # Initialize a new S3 bucket connection.
54
+ def initialize(s3_url=ENV["S3_URL"])
21
55
  raise ArgumentError, "No S3 URL provided. You can set ENV['S3_URL'], too." if s3_url.nil? || s3_url.empty?
22
56
 
23
57
  begin
@@ -27,23 +61,38 @@ class S4
27
61
  raise e
28
62
  end
29
63
 
30
- @access_key_id = url.user
64
+ @access_key_id = url.user
31
65
  @secret_access_key = URI.unescape(url.password || "")
32
- @host = url.host
33
- @bucket = url.path[1..-1]
66
+ @host = url.host
67
+ @bucket = url.path[1..-1]
34
68
  end
35
-
36
- def connection
37
- @connection ||= Net::HTTP::Persistent.new("aws-s3/#{bucket}")
69
+
70
+ # Connect to the S3 bucket.
71
+ #
72
+ # Since S3 doesn't really require a persistent connection this really just
73
+ # makes sure that it *can* connect (i.e. the bucket exists and you own it).
74
+ def connect
75
+ raise NoSuchBucket.new(bucket) if request(uri("/", query: "location")).nil?
76
+ end
77
+
78
+ # Create the S3 bucket.
79
+ def create
80
+ uri = URI::HTTP.build(host: host, path: "/#{bucket}")
81
+ request uri, Net::HTTP::Put.new(uri.request_uri)
38
82
  end
39
83
 
40
84
  # Lower level object get which just yields the successful S3 response to the
41
85
  # block. See #download if you want to simply copy a file from S3 to local.
42
86
  def get(name, &block)
43
87
  request(uri(name), &block)
88
+ rescue S4::Error => e
89
+ raise e if e.status != "404"
44
90
  end
45
91
 
46
92
  # Download the file with the given filename to the given destination.
93
+ #
94
+ # i.e.
95
+ # bucket.download("images/palm_trees.jpg", "./palm_trees.jpg")
47
96
  def download(name, destination=nil)
48
97
  get(name) do |response|
49
98
  File.open(destination || File.join(Dir.pwd, File.basename(name)), "wb") do |io|
@@ -59,13 +108,18 @@ class S4
59
108
  request(uri = uri(name), Net::HTTP::Delete.new(uri.request_uri))
60
109
  end
61
110
 
62
- # Upload the file with the given filename to the given destination in your
63
- # S3 bucket. If no destination is given then uploads it with the same
64
- # filename to the root of your bucket.
111
+ # Upload the file with the given filename to the given destination in your S3
112
+ # bucket.
113
+ #
114
+ # If no destination is given then uploads it with the same filename to the
115
+ # root of your bucket.
116
+ #
117
+ # i.e.
118
+ # bucket.upload("./images/1996_animated_explosion.gif", "website_background.gif")
65
119
  def upload(name, destination=nil)
66
120
  put File.open(name, "rb"), destination || File.basename(name)
67
121
  end
68
-
122
+
69
123
  def put(io, name)
70
124
  uri = uri(name)
71
125
  req = Net::HTTP::Put.new(uri.request_uri)
@@ -77,38 +131,40 @@ class S4
77
131
  request(URI::HTTP.build(host: host, path: "/#{bucket}/#{name}"), req)
78
132
  end
79
133
 
80
- # List bucket contents
134
+ # List bucket contents.
135
+ #
136
+ # Optionally pass a prefix to list from (useful for paths).
137
+ #
138
+ # i.e.
139
+ # bucket.list("images/") #=> [ "birds.jpg", "bees.jpg" ]
81
140
  def list(prefix = "")
82
141
  REXML::Document.new(request(uri("", query: "prefix=#{prefix}"))).elements.collect("//Key", &:text)
83
142
  end
84
-
143
+
85
144
  private
86
-
145
+
146
+ def connection
147
+ @connection ||= Net::HTTP::Persistent.new("aws-s3/#{bucket}")
148
+ end
149
+
87
150
  def uri(path, options={})
88
- URI::HTTP.build(options.merge(host: host, path: "/#{bucket}/#{CGI.escape(path.sub(/^\//, ""))}"))
151
+ URI::HTTP.build(options.merge(host: host, path: "/#{bucket}/#{URI.escape(path.sub(/^\//, ""))}"))
89
152
  end
90
153
 
91
154
  # Makes a request to the S3 API.
92
- def request(uri, request = nil)
93
- # TODO: Possibly use SAX parsing for large request bodies (?)
94
-
155
+ def request(uri, request=nil)
95
156
  request ||= Net::HTTP::Get.new(uri.request_uri)
96
-
157
+
97
158
  connection.request(uri, sign(uri, request)) do |response|
98
159
  case response
99
- when Net::HTTPNotFound
100
- return nil
101
-
102
- when Net::HTTPSuccess
103
- if block_given?
104
- yield(response)
105
- else
106
- return response.body
107
- end
108
-
160
+ when Net::HTTPSuccess
161
+ if block_given?
162
+ yield(response)
109
163
  else
110
- raise Error.from_xml(response.body)
111
-
164
+ return response.body
165
+ end
166
+ else
167
+ raise Error.from_response(response)
112
168
  end
113
169
  end
114
170
  end
@@ -123,22 +179,47 @@ class S4
123
179
  end
124
180
 
125
181
  def signature(uri, request)
182
+ query = signed_params(uri.query) if uri.query
183
+
126
184
  Base64.encode64(
127
185
  OpenSSL::HMAC.digest(
128
186
  OpenSSL::Digest::Digest.new("sha1"),
129
187
  secret_access_key,
130
- "#{request.class::METHOD}\n\n#{request["Content-Type"]}\n#{request.fetch("Date")}\n#{uri.path}"
188
+ "#{request.class::METHOD}\n\n#{request["Content-Type"]}\n#{request.fetch("Date")}\n" + uri.path + (query ? "?#{query}" : "")
131
189
  )
132
190
  ).chomp
133
191
  end
134
-
192
+
193
+ # Returns the given query string consisting only of query parameters which
194
+ # need to be signed against, or nil if there are none in the query string.
195
+ def signed_params(query)
196
+ signed = query.
197
+ split("&").
198
+ collect{ |param| param.split("=") }.
199
+ reject{ |pair| !SubResources.include?(pair[0]) }.
200
+ collect{ |pair| pair.join("=") }.
201
+ join("&")
202
+
203
+ signed unless signed.empty?
204
+ end
205
+
135
206
  # Base class of all S3 Errors
136
207
  class Error < ::RuntimeError
137
- def self.from_xml(xml)
138
- doc = REXML::Document.new(xml).elements["//Error"]
208
+ attr_reader :code, :status
209
+
210
+ def self.from_response(response)
211
+ doc = REXML::Document.new(response.body).elements["//Error"]
139
212
  code = doc.elements["Code"].text
140
213
  message = doc.elements["Message"].text
141
- new("#{code}: #{message}")
214
+
215
+ new response.code, code, message
142
216
  end
143
- end
217
+
218
+ def initialize(status, code, message)
219
+ @status = status
220
+ @code = code
221
+
222
+ super "#{@code}: #{message}"
223
+ end
224
+ end
144
225
  end
data/s4.gemspec CHANGED
@@ -1,13 +1,13 @@
1
1
  require "./lib/s4"
2
2
 
3
3
  Gem::Specification.new do |s|
4
- s.name = "s4"
5
- s.version = S4::VERSION
6
- s.summary = "Simple API for AWS S3"
7
- s.description = "Simple API for AWS S3"
8
- s.authors = ["Ben Alavi"]
9
- s.email = ["ben.alavi@citrusbyte.com"]
10
- s.homepage = "http://github.com/benalavi/s4"
4
+ s.name = "s4"
5
+ s.version = S4::VERSION
6
+ s.summary = "Simple API for AWS S3"
7
+ s.description = "Simple API for AWS S3"
8
+ s.authors = ["Ben Alavi"]
9
+ s.email = ["ben.alavi@citrusbyte.com"]
10
+ s.homepage = "http://github.com/benalavi/s4"
11
11
 
12
12
  s.files = Dir[
13
13
  "LICENSE",
data/test/s4_test.rb ADDED
@@ -0,0 +1,151 @@
1
+ require "contest"
2
+ require "timecop"
3
+ require "fileutils"
4
+
5
+ begin
6
+ require "ruby-debug"
7
+ rescue LoadError
8
+ end
9
+
10
+ require File.expand_path("../lib/s4", File.dirname(__FILE__))
11
+
12
+ def fixture(filename="")
13
+ File.join(File.dirname(__FILE__), "fixtures", filename)
14
+ end
15
+
16
+ def output(filename="")
17
+ File.join(File.dirname(__FILE__), "output", filename)
18
+ end
19
+
20
+ NewBucket = "s4-bucketthatdoesntexist"
21
+ TestBucket = "s4-test-bucket"
22
+
23
+ class S4Test < Test::Unit::TestCase
24
+ setup do
25
+ FileUtils.rm_rf(output)
26
+ FileUtils.mkdir_p(output)
27
+ end
28
+
29
+ context "connecting to S3" do
30
+ should "return connected bucket if can connect" do
31
+ s4 = S4.connect
32
+ end
33
+
34
+ should "raise error if cannot connect" do
35
+ `s3cmd rb 's3://#{NewBucket}' 2>&1`
36
+
37
+ assert_raise(S4::Error) do
38
+ S4.connect ENV["S3_URL"].sub(TestBucket, NewBucket)
39
+ end
40
+ end
41
+ end
42
+
43
+ context "when S3 errors occur" do
44
+ # foo is taken bucket, will cause 409 Conflict on create
45
+
46
+ should "raise on S3 errors" do
47
+ assert_raise(S4::Error) do
48
+ S4.create(ENV["S3_URL"].sub(TestBucket, "foo"))
49
+ end
50
+ end
51
+
52
+ should "capture code of S3 error" do
53
+ begin
54
+ S4.create(ENV["S3_URL"].sub(TestBucket, "foo"))
55
+ rescue S4::Error => e
56
+ assert_equal "409", e.status
57
+ end
58
+ end
59
+ end
60
+
61
+ context "creating a bucket" do
62
+ setup do
63
+ `s3cmd rb 's3://#{NewBucket}' 2>&1`
64
+ end
65
+
66
+ should "create a bucket" do
67
+ assert_equal "ERROR: Bucket '#{NewBucket}' does not exist", `s3cmd ls 's3://#{NewBucket}' 2>&1`.chomp
68
+ s4 = S4.create ENV["S3_URL"].sub(TestBucket, NewBucket)
69
+ assert_equal "", `s3cmd ls 's3://#{NewBucket}' 2>&1`.chomp
70
+ end
71
+ end
72
+
73
+ context "when connected" do
74
+ setup do
75
+ @s4 = S4.connect
76
+ end
77
+
78
+ should "download foo.txt" do
79
+ `s3cmd put #{fixture("foo.txt")} s3://#{@s4.bucket}/foo.txt`
80
+ @s4.download("foo.txt", output("foo.txt"))
81
+
82
+ assert_equal "abc123", File.read(output("foo.txt"))
83
+ end
84
+
85
+ should "not download non-existent files" do
86
+ `s3cmd del 's3://#{@s4.bucket}/foo.txt'`
87
+ @s4.download("foo.txt", output("foo.txt"))
88
+
89
+ assert !File.exists?(output("foo.txt"))
90
+ end
91
+
92
+ should "return false when downloading non-existent files" do
93
+ `s3cmd del 's3://#{@s4.bucket}/foo.txt'`
94
+ assert_equal nil, @s4.download("foo.txt", output("foo.txt"))
95
+ end
96
+
97
+ should "yield raw response from get of foo.txt" do
98
+ `s3cmd put #{fixture("foo.txt")} s3://#{@s4.bucket}/foo.txt`
99
+
100
+ @s4.get("foo.txt") do |response|
101
+ assert_equal "abc123", response.body
102
+ end
103
+ end
104
+
105
+ should "delete object" do
106
+ `s3cmd put #{fixture("foo.txt")} s3://#{@s4.bucket}/foo.txt`
107
+ @s4.get("foo.txt") { |response| assert_equal "abc123", response.body }
108
+ @s4.delete("foo.txt")
109
+
110
+ assert_equal nil, @s4.get("foo.txt")
111
+ end
112
+
113
+ should "return list of items in bucket" do
114
+ `s3cmd del 's3://#{@s4.bucket}/abc/*'`
115
+ `s3cmd del 's3://#{@s4.bucket}/boom.txt'`
116
+ `s3cmd del 's3://#{@s4.bucket}/foo\ bar+baz.txt'`
117
+ `s3cmd put #{fixture("foo.txt")} s3://#{@s4.bucket}/foo.txt`
118
+ `s3cmd put #{fixture("foo.txt")} s3://#{@s4.bucket}/bar.txt`
119
+ `s3cmd put #{fixture("foo.txt")} s3://#{@s4.bucket}/baz.txt`
120
+
121
+ assert_equal %w( bar.txt baz.txt foo.txt ), @s4.list
122
+ end
123
+
124
+ should "return list of keys starting with prefix" do
125
+ `s3cmd put #{fixture("foo.txt")} s3://#{@s4.bucket}/abc/bing.txt`
126
+ `s3cmd put #{fixture("foo.txt")} s3://#{@s4.bucket}/abc/bang.txt`
127
+ `s3cmd put #{fixture("foo.txt")} s3://#{@s4.bucket}/boom.txt`
128
+
129
+ assert_equal %w( abc/bang.txt abc/bing.txt ), @s4.list("abc/")
130
+ end
131
+
132
+ should "get content with special chars in it" do
133
+ `s3cmd put #{fixture("foo.txt")} 's3://#{@s4.bucket}/foo bar+baz.txt'`
134
+
135
+ @s4.get("foo bar+baz.txt") { |response| assert_equal "abc123", response.body }
136
+ end
137
+
138
+ should "upload foo.txt" do
139
+ `s3cmd del 's3://#{@s4.bucket}/foo.txt'`
140
+ @s4.upload(fixture("foo.txt"))
141
+ @s4.get("foo.txt") { |response| assert_equal "abc123", response.body }
142
+ end
143
+
144
+ should "bark when no URL is provided" do
145
+ assert_raise(ArgumentError) { S4.connect("") }
146
+ assert_raise(ArgumentError) { S4.connect(nil) }
147
+
148
+ assert_raise(URI::InvalidURIError) { S4.connect("s3://foo:bar/baz") }
149
+ end
150
+ end
151
+ end
metadata CHANGED
@@ -2,7 +2,7 @@
2
2
  name: s4
3
3
  version: !ruby/object:Gem::Version
4
4
  prerelease:
5
- version: 0.0.1
5
+ version: 0.0.2
6
6
  platform: ruby
7
7
  authors:
8
8
  - Ben Alavi
@@ -10,8 +10,7 @@ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
12
 
13
- date: 2011-05-20 00:00:00 -03:00
14
- default_executable:
13
+ date: 2011-09-01 00:00:00 Z
15
14
  dependencies:
16
15
  - !ruby/object:Gem::Dependency
17
16
  name: net-http-persistent
@@ -59,8 +58,7 @@ files:
59
58
  - Rakefile
60
59
  - lib/s4.rb
61
60
  - s4.gemspec
62
- - test/s3_test.rb
63
- has_rdoc: true
61
+ - test/s4_test.rb
64
62
  homepage: http://github.com/benalavi/s4
65
63
  licenses: []
66
64
 
@@ -84,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
84
82
  requirements: []
85
83
 
86
84
  rubyforge_project:
87
- rubygems_version: 1.6.2
85
+ rubygems_version: 1.8.10
88
86
  signing_key:
89
87
  specification_version: 3
90
88
  summary: Simple API for AWS S3
data/test/s3_test.rb DELETED
@@ -1,108 +0,0 @@
1
- require "cutest"
2
- require "timecop"
3
- require "fileutils"
4
-
5
- begin
6
- require "ruby-debug"
7
- rescue LoadError
8
- end
9
-
10
- require File.expand_path("../lib/s3", File.dirname(__FILE__))
11
-
12
- def fixture(filename="")
13
- File.join(File.dirname(__FILE__), "fixtures", filename)
14
- end
15
-
16
- def output(filename="")
17
- File.join(File.dirname(__FILE__), "output", filename)
18
- end
19
-
20
- Bucket = URI(ENV["S3_URL"]).path[1..-1]
21
-
22
- scope do
23
- setup do
24
- FileUtils.rm_rf(output)
25
- FileUtils.mkdir_p(output)
26
- @s3 = S3.new
27
- end
28
-
29
- test "should download foo.txt" do
30
- `s3cmd put #{fixture("foo.txt")} s3://#{Bucket}/foo.txt`
31
- @s3.download("foo.txt", output("foo.txt"))
32
-
33
- assert_equal "abc123", File.read(output("foo.txt"))
34
- end
35
-
36
- test "should not download non-existent files" do
37
- `s3cmd del 's3://#{Bucket}/foo.txt'`
38
- @s3.download("foo.txt", output("foo.txt"))
39
-
40
- assert !File.exists?(output("foo.txt"))
41
- end
42
-
43
- test "should yield raw response from get of foo.txt" do
44
- `s3cmd put #{fixture("foo.txt")} s3://#{Bucket}/foo.txt`
45
-
46
- @s3.get("foo.txt") do |response|
47
- assert_equal "abc123", response.body
48
- end
49
- end
50
-
51
- test "should delete object" do
52
- `s3cmd put #{fixture("foo.txt")} s3://#{Bucket}/foo.txt`
53
- @s3.get("foo.txt") { |response| assert_equal "abc123", response.body }
54
- @s3.delete("foo.txt")
55
-
56
- assert_equal nil, @s3.get("foo.txt")
57
- end
58
-
59
- test "should return list of items in bucket" do
60
- `s3cmd del 's3://#{Bucket}/abc/*'`
61
- `s3cmd del 's3://#{Bucket}/boom.txt'`
62
- `s3cmd del 's3://#{Bucket}/foo\ bar+baz.txt'`
63
- `s3cmd put #{fixture("foo.txt")} s3://#{Bucket}/foo.txt`
64
- `s3cmd put #{fixture("foo.txt")} s3://#{Bucket}/bar.txt`
65
- `s3cmd put #{fixture("foo.txt")} s3://#{Bucket}/baz.txt`
66
-
67
- assert_equal %w( bar.txt baz.txt foo.txt ), @s3.list
68
- end
69
-
70
- test "should return list of keys starting with prefix" do
71
- `s3cmd put #{fixture("foo.txt")} s3://#{Bucket}/abc/bing.txt`
72
- `s3cmd put #{fixture("foo.txt")} s3://#{Bucket}/abc/bang.txt`
73
- `s3cmd put #{fixture("foo.txt")} s3://#{Bucket}/boom.txt`
74
-
75
- assert_equal %w( abc/bang.txt abc/bing.txt ), @s3.list("abc/")
76
- end
77
-
78
- test "should get content with special chars in it" do
79
- `s3cmd put #{fixture("foo.txt")} 's3://#{Bucket}/foo bar+baz.txt'`
80
-
81
- @s3.get("foo bar+baz.txt") { |response| assert_equal "abc123", response.body }
82
- end
83
-
84
- test "should upload foo.txt" do
85
- `s3cmd del 's3://#{Bucket}/foo.txt'`
86
-
87
- s3 = S3.new
88
-
89
- s3.upload(fixture("foo.txt"))
90
-
91
- s3.get("foo.txt") { |response| assert_equal "abc123", response.body }
92
- end
93
-
94
- test "barks when no URL is provided" do
95
- assert_raise(ArgumentError) { S3.new("") }
96
- assert_raise(ArgumentError) { S3.new(nil) }
97
-
98
- assert_raise(URI::InvalidURIError) { S3.new("s3://foo:bar/baz") }
99
- end
100
-
101
- test "raises on S3 errors" do
102
- s3 = S3.new(ENV["S3_URL"].sub(/.@/, "@")) # Break the password
103
-
104
- assert_raise(S3::Error) do
105
- s3.upload(fixture("foo.txt"))
106
- end
107
- end
108
- end