bucket_store 0.4.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e251b7658b3f9e155f3e9e329d2022e6f4791e502de43bba2b8a0563634ba499
4
- data.tar.gz: f66fbec21d4a07e2ff0feb0ad45383eccb71daa13d733cc92f2a0ae54d6cd9f4
3
+ metadata.gz: 4a5f88aa8903c932c2de1c7ed531bf78f58f72e9785ba8732a4cca7e44605549
4
+ data.tar.gz: f2d8e01ad1f54acf7c44a71df268576131df013e656dfced91f700b80927f036
5
5
  SHA512:
6
- metadata.gz: cb3aeab11c33767d349fb4c7efe744000c9a129131cfff3054c308de7971da12e1ea3c32fafeaa8722ddf07049d4e2f0e1cf6ba85b24d6c997ac1f3fe4eb14af
7
- data.tar.gz: 5f2d1e70e8f0bc27ccfbf998ec3c4e1c89fb9ed0e179b8b2a808a7345bf4cda38dcc45fbadc842a142a0eb42d51fdb44247159c1fe58f5717cf302a6145ecff3
6
+ metadata.gz: 22ba164e42115a064b4aa6f7833d3d5c86931fe7c35d2da873725fa2488ab6849ce2481bf09bc9d93f7eab085a0132d957e1d81fad8855e89060663895aa5428
7
+ data.tar.gz: 9c88e6bf881ecab6686255cabfa416be09985a8f48b200726f89e7589e5925dd5ab13700a865aa69c49b4050d654447c09e802b71852b62ef331c5e80568b369
data/README.md CHANGED
@@ -135,18 +135,34 @@ in mind:
135
135
 
136
136
  ## Examples
137
137
 
138
- ### Uploading a file to a bucket
138
+ ### Uploading a string to a bucket
139
139
  ```ruby
140
140
  BucketStore.for("inmemory://bucket/path/file.xml").upload!("hello world")
141
141
  => "inmemory://bucket/path/file.xml"
142
142
  ```
143
143
 
144
- ### Accessing a file in a bucket
144
+ ### Accessing a string in a bucket
145
145
  ```ruby
146
146
  BucketStore.for("inmemory://bucket/path/file.xml").download
147
147
  => {:bucket=>"bucket", :key=>"path/file.xml", :content=>"hello world"}
148
148
  ```
149
149
 
150
+ ### Uploading a file-like object to a bucket
151
+ ```ruby
152
+ buffer = StringIO.new("This could also be an actual file")
153
+ BucketStore.for("inmemory://bucket/path/file.xml").stream.upload!(file: buffer)
154
+ => "inmemory://bucket/path/file.xml"
155
+ ```
156
+
157
+ ### Downloading to a file-like object from a bucket
158
+ ```ruby
159
+ buffer = StringIO.new
160
+ BucketStore.for("inmemory://bucket/path/file.xml").stream.download(file: buffer)
161
+ => {:bucket=>"bucket", :key=>"path/file.xml", :file=>buffer}
162
+ buffer.string
163
+ => "This could also be an actual file"
164
+ ```
165
+
150
166
  ### Listing all keys under a prefix
151
167
  ```ruby
152
168
  BucketStore.for("inmemory://bucket/path/").list
@@ -159,6 +175,19 @@ BucketStore.for("inmemory://bucket/path/file.xml").delete!
159
175
  => true
160
176
  ```
161
177
 
178
+ ## Development
179
+
180
+ ### Running tests
181
+ BucketStore comes with both unit and integration tests. While unit tests can be run by simply
182
+ executing `bundle exec rspec`, integration tests require running minio locally. We provide a
183
+ docker-compose file that spins up pre-configured simulator instances for S3 and GCS with
184
+ test buckets. Running the integration tests is as easy as:
185
+
186
+ ```
187
+ docker-compose up
188
+ bundle exec rspec --tag integration
189
+ ```
190
+
162
191
  ## License & Contributing
163
192
 
164
193
  * BucketStore is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -13,23 +13,22 @@ module BucketStore
13
13
  @base_dir = File.expand_path(base_dir)
14
14
  end
15
15
 
16
- def upload!(bucket:, key:, content:)
17
- File.open(key_path(bucket, key), "w") do |file|
18
- file.write(content)
16
+ def upload!(bucket:, key:, file:)
17
+ File.open(key_path(bucket, key), "w") do |output_file|
18
+ output_file.write(file.read)
19
+ output_file.rewind
19
20
  end
21
+
20
22
  {
21
23
  bucket: bucket,
22
24
  key: key,
23
25
  }
24
26
  end
25
27
 
26
- def download(bucket:, key:)
27
- File.open(key_path(bucket, key), "r") do |file|
28
- {
29
- bucket: bucket,
30
- key: key,
31
- content: file.read,
32
- }
28
+ def download(bucket:, key:, file:)
29
+ File.open(key_path(bucket, key), "r") do |saved_file|
30
+ file.write(saved_file.read)
31
+ file.rewind
33
32
  end
34
33
  end
35
34
 
@@ -78,7 +77,7 @@ module BucketStore
78
77
  end
79
78
 
80
79
  def sanitize_filename(filename)
81
- filename.gsub(%r{[^0-9A-z.\-/]}, "_")
80
+ filename.gsub(%r{[^0-9A-z.\- /]}, "_")
82
81
  end
83
82
  end
84
83
  end
@@ -14,14 +14,27 @@ module BucketStore
14
14
  end
15
15
 
16
16
  def initialize(timeout_seconds)
17
- @storage = Google::Cloud::Storage.new(
17
+ # Ruby's GCS library does not natively support setting up a simulator, but it allows
18
+ # for a specific endpoint to be passed down which has the same effect. The simulator
19
+ # needs to be special cased as in that case we want to bypass authentication,
20
+ # which we can only do by accessing the `.anonymous` version of the Storage class.
21
+ simulator_endpoint = ENV["STORAGE_EMULATOR_HOST"]
22
+ is_simulator = !simulator_endpoint.nil?
23
+
24
+ args = {
25
+ endpoint: simulator_endpoint,
18
26
  timeout: timeout_seconds,
19
- )
27
+ }.compact
28
+
29
+ @storage = if is_simulator
30
+ Google::Cloud::Storage.anonymous(**args)
31
+ else
32
+ Google::Cloud::Storage.new(**args)
33
+ end
20
34
  end
21
35
 
22
- def upload!(bucket:, key:, content:)
23
- buffer = StringIO.new(content)
24
- get_bucket(bucket).create_file(buffer, key)
36
+ def upload!(bucket:, key:, file:)
37
+ get_bucket(bucket).create_file(file, key)
25
38
 
26
39
  {
27
40
  bucket: bucket,
@@ -29,17 +42,14 @@ module BucketStore
29
42
  }
30
43
  end
31
44
 
32
- def download(bucket:, key:)
33
- file = get_bucket(bucket).file(key)
34
-
35
- buffer = StringIO.new
36
- file.download(buffer)
45
+ def download(bucket:, key:, file:)
46
+ file.tap do |f|
47
+ get_bucket(bucket).
48
+ file(key).
49
+ download(f)
37
50
 
38
- {
39
- bucket: bucket,
40
- key: key,
41
- content: buffer.string,
42
- }
51
+ f.rewind
52
+ end
43
53
  end
44
54
 
45
55
  def list(bucket:, key:, page_size:)
@@ -24,8 +24,10 @@ module BucketStore
24
24
  @buckets = Hash.new { |hash, key| hash[key] = {} }
25
25
  end
26
26
 
27
- def upload!(bucket:, key:, content:)
28
- @buckets[bucket][key] = content
27
+ def upload!(bucket:, key:, file:)
28
+ file.tap do |f|
29
+ @buckets[bucket][key] = f.read
30
+ end
29
31
 
30
32
  {
31
33
  bucket: bucket,
@@ -33,12 +35,11 @@ module BucketStore
33
35
  }
34
36
  end
35
37
 
36
- def download(bucket:, key:)
37
- {
38
- bucket: bucket,
39
- key: key,
40
- content: @buckets[bucket].fetch(key),
41
- }
38
+ def download(bucket:, key:, file:)
39
+ file.tap do |f|
40
+ f.write(@buckets[bucket].fetch(key))
41
+ f.rewind
42
+ end
42
43
  end
43
44
 
44
45
  def list(bucket:, key:, page_size:)
@@ -19,18 +19,35 @@ module BucketStore
19
19
  end
20
20
 
21
21
  def self.parse(raw_key)
22
- uri = URI(raw_key)
22
+ uri = URI(escape(raw_key))
23
+
24
+ scheme = unescape(uri.scheme)
25
+ bucket = unescape(uri.host)
23
26
 
24
27
  # A key should never be `nil` but can be empty. Depending on the operation, this may
25
28
  # or may not be a valid configuration (e.g. an empty key is likely valid on a
26
29
  # `list`, but not during a `download`).
27
- key = uri.path.sub!(%r{/}, "") || ""
30
+ key = unescape(uri.path).sub!(%r{/}, "") || ""
28
31
 
29
- raise KeyParseException if [uri.scheme, uri.host, key].map(&:nil?).any?
32
+ raise KeyParseException if [scheme, bucket, key].map(&:nil?).any?
30
33
 
31
- KeyContext.new(adapter: uri.scheme,
32
- bucket: uri.host,
34
+ KeyContext.new(adapter: scheme,
35
+ bucket: bucket,
33
36
  key: key)
34
37
  end
38
+
39
+ def self.escape(key)
40
+ return key if key.nil?
41
+
42
+ URI::DEFAULT_PARSER.escape(key)
43
+ end
44
+ private_class_method :escape
45
+
46
+ def self.unescape(key)
47
+ return key if key.nil?
48
+
49
+ URI::DEFAULT_PARSER.unescape(key)
50
+ end
51
+ private_class_method :unescape
35
52
  end
36
53
  end
@@ -15,6 +15,90 @@ module BucketStore
15
15
  disk: Disk,
16
16
  }.freeze
17
17
 
18
+ # Defines a streaming interface for download and upload operations.
19
+ #
20
+ # Note that individual adapters may require additional configuration for the correct
21
+ # behavior of the streaming interface.
22
+ class KeyStreamer
23
+ attr_reader :bucket, :key, :adapter_type
24
+
25
+ def initialize(adapter:, adapter_type:, bucket:, key:)
26
+ @adapter = adapter
27
+ @adapter_type = adapter_type
28
+ @bucket = bucket
29
+ @key = key
30
+ end
31
+
32
+ # Streams the content of the reference key into a file-like object
33
+ # @param [IO] file a writeable IO instance, or a file-like object such as `StringIO`
34
+ # @return hash containing the bucket, the key and file like object passed in as input
35
+ #
36
+ # @see KeyStorage#download
37
+ # @example Download a key
38
+ # buffer = StringIO.new
39
+ # BucketStore.for("inmemory://bucket/file.xml").stream.download(file: buffer)
40
+ # buffer.string == "Imagine I'm a 2GB file"
41
+ def download(file:)
42
+ BucketStore.logger.info(event: "key_storage.download_started")
43
+
44
+ start = BucketStore::Timing.monotonic_now
45
+ adapter.download(
46
+ bucket: bucket,
47
+ key: key,
48
+ file: file,
49
+ )
50
+
51
+ BucketStore.logger.info(event: "key_storage.download_finished",
52
+ duration: BucketStore::Timing.monotonic_now - start)
53
+
54
+ {
55
+ bucket: bucket,
56
+ key: key,
57
+ file: file,
58
+ }
59
+ end
60
+
61
+ # Performs a streaming upload to the backing object store
62
+ # @param [IO] file a readable IO instance, or a file-like object such as `StringIO`
63
+ # @return the generated key for the new object
64
+ #
65
+ # @see KeyStorage#upload!
66
+ # @example Upload a key
67
+ # buffer = StringIO.new("Imagine I'm a 2GB file")
68
+ # BucketStore.for("inmemory://bucket/file.xml").stream.upload!(file: buffer)
69
+ def upload!(file:)
70
+ raise ArgumentError, "Key cannot be empty" if key.empty?
71
+
72
+ BucketStore.logger.info(event: "key_storage.upload_started",
73
+ **log_context)
74
+
75
+ start = BucketStore::Timing.monotonic_now
76
+ adapter.upload!(
77
+ bucket: bucket,
78
+ key: key,
79
+ file: file,
80
+ )
81
+
82
+ BucketStore.logger.info(event: "key_storage.upload_finished",
83
+ duration: BucketStore::Timing.monotonic_now - start,
84
+ **log_context)
85
+
86
+ "#{adapter_type}://#{bucket}/#{key}"
87
+ end
88
+
89
+ private
90
+
91
+ attr_reader :adapter
92
+
93
+ def log_context
94
+ {
95
+ bucket: bucket,
96
+ key: key,
97
+ adapter_type: adapter_type,
98
+ }.compact
99
+ end
100
+ end
101
+
18
102
  attr_reader :bucket, :key, :adapter_type
19
103
 
20
104
  def initialize(adapter:, bucket:, key:)
@@ -40,45 +124,32 @@ module BucketStore
40
124
  # @example Download a key
41
125
  # BucketStore.for("inmemory://bucket/file.xml").download
42
126
  def download
43
- raise ArgumentError, "Key cannot be empty" if key.empty?
44
-
45
- BucketStore.logger.info(event: "key_storage.download_started")
46
-
47
- start = BucketStore::Timing.monotonic_now
48
- result = adapter.download(bucket: bucket, key: key)
49
-
50
- BucketStore.logger.info(event: "key_storage.download_finished",
51
- duration: BucketStore::Timing.monotonic_now - start)
52
-
53
- result
127
+ buffer = StringIO.new
128
+ stream.download(file: buffer).tap do |result|
129
+ result.delete(:file)
130
+ result[:content] = buffer.string
131
+ end
54
132
  end
55
133
 
56
- # Uploads the given content to the reference key location.
134
+ # Uploads the given file to the reference key location.
57
135
  #
58
136
  # If the `key` already exists, its content will be replaced by the one in input.
59
137
  #
60
- # @param [String] content The content to upload
138
+ # @param [String] content Contents of the file
61
139
  # @return [String] The final `key` where the content has been uploaded
62
140
  # @example Upload a file
63
- # BucketStore.for("inmemory://bucket/file.xml").upload("hello world")
141
+ # BucketStore.for("inmemory://bucket/file.xml").upload!("hello world")
64
142
  def upload!(content)
65
- raise ArgumentError, "Key cannot be empty" if key.empty?
66
-
67
- BucketStore.logger.info(event: "key_storage.upload_started",
68
- **log_context)
69
-
70
- start = BucketStore::Timing.monotonic_now
71
- result = adapter.upload!(
72
- bucket: bucket,
73
- key: key,
74
- content: content,
75
- )
143
+ stream.upload!(file: StringIO.new(content))
144
+ end
76
145
 
77
- BucketStore.logger.info(event: "key_storage.upload_finished",
78
- duration: BucketStore::Timing.monotonic_now - start,
79
- **log_context)
146
+ # Returns an interface for streaming operations
147
+ #
148
+ # @return [KeyStreamer] An interface for streaming operations
149
+ def stream
150
+ raise ArgumentError, "Key cannot be empty" if key.empty?
80
151
 
81
- "#{adapter_type}://#{result[:bucket]}/#{result[:key]}"
152
+ KeyStreamer.new(adapter: adapter, adapter_type: adapter_type, bucket: bucket, key: key)
82
153
  end
83
154
 
84
155
  # Lists all keys for the current adapter that have the reference key as prefix
@@ -153,19 +224,11 @@ module BucketStore
153
224
  #
154
225
  # @return [bool] `true` if the given key exists, `false` if not
155
226
  def exists?
156
- list.first == "#{adapter_type}://#{bucket}/#{key}"
227
+ list(page_size: 1).first == "#{adapter_type}://#{bucket}/#{key}"
157
228
  end
158
229
 
159
230
  private
160
231
 
161
232
  attr_reader :adapter
162
-
163
- def log_context
164
- {
165
- bucket: bucket,
166
- key: key,
167
- adapter_type: adapter_type,
168
- }.compact
169
- end
170
233
  end
171
234
  end
@@ -20,11 +20,11 @@ module BucketStore
20
20
  )
21
21
  end
22
22
 
23
- def upload!(bucket:, key:, content:)
23
+ def upload!(bucket:, key:, file:)
24
24
  storage.put_object(
25
25
  bucket: bucket,
26
26
  key: key,
27
- body: content,
27
+ body: file,
28
28
  )
29
29
 
30
30
  {
@@ -33,17 +33,12 @@ module BucketStore
33
33
  }
34
34
  end
35
35
 
36
- def download(bucket:, key:)
37
- file = storage.get_object(
36
+ def download(bucket:, key:, file:)
37
+ storage.get_object(
38
+ response_target: file,
38
39
  bucket: bucket,
39
40
  key: key,
40
41
  )
41
-
42
- {
43
- bucket: bucket,
44
- key: key,
45
- content: file.body.read,
46
- }
47
42
  end
48
43
 
49
44
  def list(bucket:, key:, page_size:)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BucketStore
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/bucket_store.rb CHANGED
@@ -4,6 +4,7 @@ require "bucket_store/version"
4
4
  require "bucket_store/configuration"
5
5
  require "bucket_store/key_context"
6
6
  require "bucket_store/key_storage"
7
+ require "bucket_store/uri_builder"
7
8
 
8
9
  # An abstraction layer on the top of file cloud storage systems such as Google Cloud
9
10
  # Storage or S3. This module exposes a generic interface that allows interoperability
@@ -41,8 +42,8 @@ module BucketStore
41
42
  # Given a `key` in the format of `adapter://bucket/key` returns the corresponding
42
43
  # adapter that will allow to manipulate (e.g. download, upload or list) such key.
43
44
  #
44
- # Currently supported adapters are `gs` (Google Cloud Storage), `inmemory` (an
45
- # in-memory key-value storage) and `disk` (a disk-backed key-value store).
45
+ # Currently supported adapters are `gs` (Google Cloud Storage), `s3` (AWS S3),
46
+ # `inmemory` (an in-memory key-value storage) and `disk` (a disk-backed key-value store).
46
47
  #
47
48
  # @param [String] key The reference key
48
49
  # @return [KeyStorage] An interface to the adapter that can handle requests on the given key
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bucket_store
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GoCardless Engineering
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-10-29 00:00:00.000000000 Z
11
+ date: 2023-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-s3
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '2.29'
47
+ version: 4.3.0
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '2.29'
54
+ version: 4.3.0
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: pry-byebug
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -81,61 +81,33 @@ dependencies:
81
81
  - !ruby/object:Gem::Version
82
82
  version: '3.10'
83
83
  - !ruby/object:Gem::Dependency
84
- name: rspec_junit_formatter
84
+ name: rspec-github
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: 0.4.1
89
+ version: 2.4.0
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: 0.4.1
96
+ version: 2.4.0
97
97
  - !ruby/object:Gem::Dependency
98
98
  name: rubocop
99
99
  requirement: !ruby/object:Gem::Requirement
100
100
  requirements:
101
- - - "~>"
102
- - !ruby/object:Gem::Version
103
- version: '1.22'
104
- type: :development
105
- prerelease: false
106
- version_requirements: !ruby/object:Gem::Requirement
107
- requirements:
108
- - - "~>"
109
- - !ruby/object:Gem::Version
110
- version: '1.22'
111
- - !ruby/object:Gem::Dependency
112
- name: rubocop-performance
113
- requirement: !ruby/object:Gem::Requirement
114
- requirements:
115
- - - "~>"
116
- - !ruby/object:Gem::Version
117
- version: '1.11'
118
- type: :development
119
- prerelease: false
120
- version_requirements: !ruby/object:Gem::Requirement
121
- requirements:
122
- - - "~>"
123
- - !ruby/object:Gem::Version
124
- version: '1.11'
125
- - !ruby/object:Gem::Dependency
126
- name: rubocop-rspec
127
- requirement: !ruby/object:Gem::Requirement
128
- requirements:
129
- - - "~>"
101
+ - - ">="
130
102
  - !ruby/object:Gem::Version
131
- version: '2.5'
103
+ version: '1.49'
132
104
  type: :development
133
105
  prerelease: false
134
106
  version_requirements: !ruby/object:Gem::Requirement
135
107
  requirements:
136
- - - "~>"
108
+ - - ">="
137
109
  - !ruby/object:Gem::Version
138
- version: '2.5'
110
+ version: '1.49'
139
111
  description: " A helper library to access cloud storage services such as Google
140
112
  Cloud Storage or S3.\n"
141
113
  email:
@@ -159,7 +131,8 @@ files:
159
131
  homepage: https://github.com/gocardless/bucket-store
160
132
  licenses:
161
133
  - MIT
162
- metadata: {}
134
+ metadata:
135
+ rubygems_mfa_required: 'true'
163
136
  post_install_message:
164
137
  rdoc_options: []
165
138
  require_paths:
@@ -168,14 +141,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
168
141
  requirements:
169
142
  - - ">="
170
143
  - !ruby/object:Gem::Version
171
- version: '2.6'
144
+ version: '2.7'
172
145
  required_rubygems_version: !ruby/object:Gem::Requirement
173
146
  requirements:
174
147
  - - ">="
175
148
  - !ruby/object:Gem::Version
176
149
  version: '0'
177
150
  requirements: []
178
- rubygems_version: 3.2.22
151
+ rubygems_version: 3.3.3
179
152
  signing_key:
180
153
  specification_version: 4
181
154
  summary: A helper library to access cloud storage services