azure-blob 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5ff6c7e8f9d7ec11e85bcb1ea543b9344a9db958fea0d9451221c5be161e0d8b
4
+ data.tar.gz: fc1a13d7c8f39303c5985de80b7790a378f6a1901b22e4f8cce1eb85789aaeb4
5
+ SHA512:
6
+ metadata.gz: eabce34cd075764436fc473b550451fadf2f764a731464345ba2542a7e1ca5f5e5d44286b94b19d11eda554fde4e4a17faaf40eb8ad3ed2e9d8bab798ba8e2bb
7
+ data.tar.gz: 793a8c163e2b7d489bfd8d4a643ba6c466d043652e5cce3f6ee2798142bfb36ce41e0881028712b57ab45eb357704af3c64d47e66c53d631ee949a50d22c310f
data/.envrc ADDED
@@ -0,0 +1,3 @@
1
+ source_url "https://raw.githubusercontent.com/cachix/devenv/d1f7b48e35e6dee421cfd0f51481d17f77586997/direnvrc" "sha256-YBzqskFZxmNb3kYVoKD9ZixoPXJh1C9ZvTLGFRkauZ0="
2
+
3
+ use devenv
data/.rubocop.yml ADDED
@@ -0,0 +1,25 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1
3
+
4
+ # Omakase Ruby styling for Rails
5
+ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
6
+
7
+ # Overwrite or add rules to create your own house style
8
+ #
9
+ # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
10
+ # Layout/SpaceInsideArrayLiteralBrackets:
11
+ # Enabled: false
12
+
13
+ Style/TrailingCommaInArrayLiteral:
14
+ Enabled: true
15
+ EnforcedStyleForMultiline: consistent_comma
16
+
17
+ Style/TrailingCommaInHashLiteral:
18
+ Enabled: true
19
+ EnforcedStyleForMultiline: consistent_comma
20
+
21
+
22
+ Rails/AssertNot:
23
+ Enabled: false
24
+ Rails/RefuteMethods:
25
+ Enabled: false
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/standardrb/standard
3
+ ruby_version: 3.1
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-04-05
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Joé Dupuis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # AzureBlob
2
+
3
+ WIP This gem is built as a replacement of azure-storage-blob (deprecated) in Active Storage
4
+
5
+ ## License
6
+
7
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: %i[test]
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/azure_blob/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "azure-blob"
7
+ spec.version = AzureBlob::VERSION
8
+ spec.authors = [ "Joé Dupuis" ]
9
+ spec.email = [ "joe@dupuis.io" ]
10
+
11
+ spec.summary = "Azure blob client"
12
+ spec.homepage = "https://github.com/JoeDupuis/azure-blob"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 3.1"
15
+
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = spec.homepage
18
+ spec.metadata["changelog_uri"] = "https://github.com/JoeDupuis/azure-blob/blob/main/CHANGELOG.md"
19
+
20
+ spec.add_dependency "rexml"
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ (File.expand_path(f) == __FILE__) ||
27
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
28
+ end
29
+ end
30
+ spec.bindir = "exe"
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = [ "lib" ]
33
+ end
data/devenv.lock ADDED
@@ -0,0 +1,242 @@
1
+ {
2
+ "nodes": {
3
+ "devenv": {
4
+ "locked": {
5
+ "dir": "src/modules",
6
+ "lastModified": 1715593316,
7
+ "narHash": "sha256-S7XatU9uV3q9bVBcg/ER0VMQcnPZprrVlN209ne7LDw=",
8
+ "owner": "cachix",
9
+ "repo": "devenv",
10
+ "rev": "725c90407ef53cc2a1b53701c6d2d0745cf2484f",
11
+ "type": "github"
12
+ },
13
+ "original": {
14
+ "dir": "src/modules",
15
+ "owner": "cachix",
16
+ "repo": "devenv",
17
+ "type": "github"
18
+ }
19
+ },
20
+ "flake-compat": {
21
+ "flake": false,
22
+ "locked": {
23
+ "lastModified": 1696426674,
24
+ "owner": "edolstra",
25
+ "repo": "flake-compat",
26
+ "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
27
+ "treeHash": "2addb7b71a20a25ea74feeaf5c2f6a6b30898ecb",
28
+ "type": "github"
29
+ },
30
+ "original": {
31
+ "owner": "edolstra",
32
+ "repo": "flake-compat",
33
+ "type": "github"
34
+ }
35
+ },
36
+ "flake-compat_2": {
37
+ "flake": false,
38
+ "locked": {
39
+ "lastModified": 1696426674,
40
+ "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
41
+ "owner": "edolstra",
42
+ "repo": "flake-compat",
43
+ "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
44
+ "type": "github"
45
+ },
46
+ "original": {
47
+ "owner": "edolstra",
48
+ "repo": "flake-compat",
49
+ "type": "github"
50
+ }
51
+ },
52
+ "flake-utils": {
53
+ "inputs": {
54
+ "systems": "systems"
55
+ },
56
+ "locked": {
57
+ "lastModified": 1710146030,
58
+ "owner": "numtide",
59
+ "repo": "flake-utils",
60
+ "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
61
+ "treeHash": "bd263f021e345cb4a39d80c126ab650bebc3c10c",
62
+ "type": "github"
63
+ },
64
+ "original": {
65
+ "owner": "numtide",
66
+ "repo": "flake-utils",
67
+ "type": "github"
68
+ }
69
+ },
70
+ "flake-utils_2": {
71
+ "inputs": {
72
+ "systems": "systems_2"
73
+ },
74
+ "locked": {
75
+ "lastModified": 1710146030,
76
+ "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
77
+ "owner": "numtide",
78
+ "repo": "flake-utils",
79
+ "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
80
+ "type": "github"
81
+ },
82
+ "original": {
83
+ "owner": "numtide",
84
+ "repo": "flake-utils",
85
+ "type": "github"
86
+ }
87
+ },
88
+ "gitignore": {
89
+ "inputs": {
90
+ "nixpkgs": [
91
+ "pre-commit-hooks",
92
+ "nixpkgs"
93
+ ]
94
+ },
95
+ "locked": {
96
+ "lastModified": 1709087332,
97
+ "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
98
+ "owner": "hercules-ci",
99
+ "repo": "gitignore.nix",
100
+ "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
101
+ "type": "github"
102
+ },
103
+ "original": {
104
+ "owner": "hercules-ci",
105
+ "repo": "gitignore.nix",
106
+ "type": "github"
107
+ }
108
+ },
109
+ "nixpkgs": {
110
+ "locked": {
111
+ "lastModified": 1715542476,
112
+ "owner": "NixOS",
113
+ "repo": "nixpkgs",
114
+ "rev": "44072e24566c5bcc0b7aa9178a0104f4cfffab19",
115
+ "treeHash": "3f9021e4c33de6fe59b88ac8c3019fc49136dc2a",
116
+ "type": "github"
117
+ },
118
+ "original": {
119
+ "owner": "NixOS",
120
+ "ref": "nixos-23.11",
121
+ "repo": "nixpkgs",
122
+ "type": "github"
123
+ }
124
+ },
125
+ "nixpkgs-ruby": {
126
+ "inputs": {
127
+ "flake-compat": "flake-compat",
128
+ "flake-utils": "flake-utils",
129
+ "nixpkgs": "nixpkgs_2"
130
+ },
131
+ "locked": {
132
+ "lastModified": 1713939467,
133
+ "owner": "bobvanderlinden",
134
+ "repo": "nixpkgs-ruby",
135
+ "rev": "c1ba161adf31119cfdbb24489766a7bcd4dbe881",
136
+ "treeHash": "0d32620317b29f94d6718684f030dd2fc2f30cb2",
137
+ "type": "github"
138
+ },
139
+ "original": {
140
+ "owner": "bobvanderlinden",
141
+ "repo": "nixpkgs-ruby",
142
+ "type": "github"
143
+ }
144
+ },
145
+ "nixpkgs-stable": {
146
+ "locked": {
147
+ "lastModified": 1710695816,
148
+ "narHash": "sha256-3Eh7fhEID17pv9ZxrPwCLfqXnYP006RKzSs0JptsN84=",
149
+ "owner": "NixOS",
150
+ "repo": "nixpkgs",
151
+ "rev": "614b4613980a522ba49f0d194531beddbb7220d3",
152
+ "type": "github"
153
+ },
154
+ "original": {
155
+ "owner": "NixOS",
156
+ "ref": "nixos-23.11",
157
+ "repo": "nixpkgs",
158
+ "type": "github"
159
+ }
160
+ },
161
+ "nixpkgs_2": {
162
+ "locked": {
163
+ "lastModified": 1715542476,
164
+ "owner": "NixOS",
165
+ "repo": "nixpkgs",
166
+ "rev": "44072e24566c5bcc0b7aa9178a0104f4cfffab19",
167
+ "treeHash": "3f9021e4c33de6fe59b88ac8c3019fc49136dc2a",
168
+ "type": "github"
169
+ },
170
+ "original": {
171
+ "owner": "NixOS",
172
+ "ref": "nixos-23.11",
173
+ "repo": "nixpkgs",
174
+ "type": "github"
175
+ }
176
+ },
177
+ "pre-commit-hooks": {
178
+ "inputs": {
179
+ "flake-compat": "flake-compat_2",
180
+ "flake-utils": "flake-utils_2",
181
+ "gitignore": "gitignore",
182
+ "nixpkgs": [
183
+ "nixpkgs"
184
+ ],
185
+ "nixpkgs-stable": "nixpkgs-stable"
186
+ },
187
+ "locked": {
188
+ "lastModified": 1715609711,
189
+ "narHash": "sha256-/5u29K0c+4jyQ8x7dUIEUWlz2BoTSZWUP2quPwFCE7M=",
190
+ "owner": "cachix",
191
+ "repo": "pre-commit-hooks.nix",
192
+ "rev": "c182c876690380f8d3b9557c4609472ebfa1b141",
193
+ "type": "github"
194
+ },
195
+ "original": {
196
+ "owner": "cachix",
197
+ "repo": "pre-commit-hooks.nix",
198
+ "type": "github"
199
+ }
200
+ },
201
+ "root": {
202
+ "inputs": {
203
+ "devenv": "devenv",
204
+ "nixpkgs": "nixpkgs",
205
+ "nixpkgs-ruby": "nixpkgs-ruby",
206
+ "pre-commit-hooks": "pre-commit-hooks"
207
+ }
208
+ },
209
+ "systems": {
210
+ "locked": {
211
+ "lastModified": 1681028828,
212
+ "owner": "nix-systems",
213
+ "repo": "default",
214
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
215
+ "treeHash": "cce81f2a0f0743b2eb61bc2eb6c7adbe2f2c6beb",
216
+ "type": "github"
217
+ },
218
+ "original": {
219
+ "owner": "nix-systems",
220
+ "repo": "default",
221
+ "type": "github"
222
+ }
223
+ },
224
+ "systems_2": {
225
+ "locked": {
226
+ "lastModified": 1681028828,
227
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
228
+ "owner": "nix-systems",
229
+ "repo": "default",
230
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
231
+ "type": "github"
232
+ },
233
+ "original": {
234
+ "owner": "nix-systems",
235
+ "repo": "default",
236
+ "type": "github"
237
+ }
238
+ }
239
+ },
240
+ "root": "root",
241
+ "version": 7
242
+ }
data/devenv.nix ADDED
@@ -0,0 +1,12 @@
1
+ { pkgs, ... }:
2
+
3
+ {
4
+ packages = with pkgs; [
5
+ git
6
+ libyaml
7
+ ];
8
+
9
+
10
+ languages.ruby.enable = true;
11
+ languages.ruby.version = "3.1.5";
12
+ }
data/devenv.yaml ADDED
@@ -0,0 +1,6 @@
1
+ allowUnfree: true
2
+ inputs:
3
+ nixpkgs:
4
+ url: github:NixOS/nixpkgs/nixos-23.11
5
+ nixpkgs-ruby:
6
+ url: github:bobvanderlinden/nixpkgs-ruby
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AzureBlob
4
+ class Blob
5
+ def initialize(response)
6
+ @response = response
7
+ end
8
+
9
+ def content_type
10
+ response.content_type
11
+ end
12
+
13
+ def content_disposition
14
+ response["content-disposition"]
15
+ end
16
+
17
+ def checksum
18
+ response["content-md5"]
19
+ end
20
+
21
+ def size
22
+ response.content_length
23
+ end
24
+
25
+ def present?
26
+ response.code == "200"
27
+ end
28
+
29
+ def metadata
30
+ @metadata || response
31
+ .to_hash
32
+ .select { |key, _| key.start_with?("x-ms-meta") }
33
+ .transform_values(&:first)
34
+ .transform_keys { |key| key.delete_prefix("x-ms-meta-").to_sym }
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :response
40
+ end
41
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml"
4
+
5
+ module AzureBlob
6
+ class BlobList
7
+ include REXML
8
+ include Enumerable
9
+
10
+ def initialize(fetcher)
11
+ @fetcher = fetcher
12
+ end
13
+
14
+ def size
15
+ to_a.size
16
+ end
17
+
18
+ def each
19
+ loop do
20
+ fetch
21
+ current_page.each do |key|
22
+ yield key
23
+ end
24
+
25
+ break unless marker
26
+ end
27
+ end
28
+
29
+ def to_s
30
+ to_a.to_s
31
+ end
32
+
33
+ def inspect
34
+ to_a.inspect
35
+ end
36
+
37
+ private
38
+
39
+ def marker
40
+ document && document.get_elements("//EnumerationResults/NextMarker").first.get_text()&.to_s
41
+ end
42
+
43
+ def current_page
44
+ document
45
+ .get_elements("//EnumerationResults/Blobs/Blob/Name")
46
+ .map { |element| element.get_text.to_s }
47
+ end
48
+
49
+ def fetch
50
+ @document = Document.new(fetcher.call(marker))
51
+ end
52
+
53
+ attr_reader :document, :fetcher
54
+ end
55
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml"
4
+
5
+ module AzureBlob
6
+ class BlockList
7
+ include REXML
8
+ def initialize(blocks)
9
+ @blocks = blocks
10
+ @document = build_document
11
+ end
12
+
13
+ def to_s
14
+ document.to_s
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :blocks, :document
20
+
21
+ def build_document
22
+ document = Document.new
23
+ document.add(XMLDecl.new("1.0", "utf-8"))
24
+ block_list = document.add_element(Element.new("BlockList"))
25
+ blocks.each do |block_id|
26
+ block = block_list.add_element(Element.new("Latest"))
27
+ block.text = block_id
28
+ end
29
+ document
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,19 @@
1
+ module AzureBlob
2
+ class CanonicalizedHeaders
3
+ STANDARD_HEADERS = [
4
+ :"x-ms-version",
5
+ ]
6
+
7
+ def initialize(headers)
8
+ @cannonicalized_headers = headers
9
+ .transform_keys(&:downcase)
10
+ .select { |key, value| key.start_with? "x-ms-" }
11
+ .sort
12
+ .map { |header, value| "#{header}:#{value}" }
13
+ end
14
+
15
+ def to_s
16
+ @cannonicalized_headers.join("\n")
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,23 @@
1
+ require "cgi"
2
+
3
+ module AzureBlob
4
+ class CanonicalizedResource
5
+ def initialize(uri, account_name, service_name: nil, url_safe: true)
6
+ # This next line is needed because CanonicalizedResource
7
+ # need to be escaped for auhthorization headers, but not SAS tokens
8
+ path = url_safe ? uri.path : URI::DEFAULT_PARSER.unescape(uri.path)
9
+ resource = "/#{account_name}#{path.empty? ? "/" : path}"
10
+ resource = "/#{service_name}#{resource}" if service_name
11
+ params = CGI.parse(uri.query.to_s)
12
+ .transform_keys(&:downcase)
13
+ .sort
14
+ .map { |param, value| "#{param}:#{value.map(&:strip).sort.join(",")}" }
15
+
16
+ @canonicalized_resource = [ resource, *params ].join("\n")
17
+ end
18
+
19
+ def to_s
20
+ @canonicalized_resource
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "signer"
4
+ require_relative "block_list"
5
+ require_relative "blob_list"
6
+ require_relative "blob"
7
+ require_relative "http"
8
+ require "time"
9
+ require "base64"
10
+
11
+ module AzureBlob
12
+ class Client
13
+ def initialize(account_name:, access_key:, container:)
14
+ @account_name = account_name
15
+ @container = container
16
+ @signer = Signer.new(account_name:, access_key:)
17
+ end
18
+
19
+ def create_block_blob(key, content, options = {})
20
+ if content.size > (options[:block_size] || DEFAULT_BLOCK_SIZE)
21
+ put_blob_multiple(key, content, **options)
22
+ else
23
+ put_blob_single(key, content, **options)
24
+ end
25
+ end
26
+
27
+ def get_blob(key, options = {})
28
+ uri = generate_uri("#{container}/#{key}")
29
+
30
+ headers = {
31
+ "x-ms-range": options[:start] && "bytes=#{options[:start]}-#{options[:end]}",
32
+ }
33
+
34
+ Http.new(uri, headers, signer:).get
35
+ end
36
+
37
+ def delete_blob(key, options = {})
38
+ uri = generate_uri("#{container}/#{key}")
39
+
40
+ headers = {
41
+ "x-ms-delete-snapshots": options[:delete_snapshots] || "include",
42
+ }
43
+
44
+ Http.new(uri, headers, signer:).delete
45
+ end
46
+
47
+ def delete_prefix(prefix, options = {})
48
+ results = list_blobs(prefix:)
49
+ results.each { |key| delete_blob(key) }
50
+ end
51
+
52
+ def list_blobs(options = {})
53
+ uri = generate_uri(container)
54
+ query = {
55
+ comp: "list",
56
+ restype: "container",
57
+ prefix: options[:prefix].to_s.gsub(/\\/, "/"),
58
+ }
59
+ query[:maxresults] = options[:max_results] if options[:max_results]
60
+ uri.query = URI.encode_www_form(**query)
61
+
62
+ fetcher = ->(marker) do
63
+ query[:marker] = marker
64
+ query.reject! { |key, value| value.to_s.empty? }
65
+ uri.query = URI.encode_www_form(**query)
66
+ response = Http.new(uri, signer:).get
67
+ end
68
+
69
+ BlobList.new(fetcher)
70
+ end
71
+
72
+ def get_blob_properties(key, options = {})
73
+ uri = generate_uri("#{container}/#{key}")
74
+
75
+ response = Http.new(uri, signer:).head
76
+
77
+ Blob.new(response)
78
+ end
79
+
80
+ def generate_uri(path)
81
+ URI.parse(URI::DEFAULT_PARSER.escape(File.join(host, path)))
82
+ end
83
+
84
+ def signed_uri(key, permissions:, expiry:, **options)
85
+ uri = generate_uri("#{container}/#{key}")
86
+ uri.query = signer.sas_token(uri, permissions:, expiry:, **options)
87
+ uri
88
+ end
89
+
90
+ def create_append_blob(key, options = {})
91
+ uri = generate_uri("#{container}/#{key}")
92
+
93
+ headers = {
94
+ "x-ms-blob-type": "AppendBlob",
95
+ "Content-Length": 0,
96
+ "Content-Type": options[:content_type],
97
+ "Content-MD5": options[:content_md5],
98
+ "x-ms-blob-content-disposition": options[:content_disposition],
99
+ }
100
+
101
+ Http.new(uri, headers, metadata: options[:metadata], signer:).put(nil)
102
+ end
103
+
104
+ def append_blob_block(key, content, options = {})
105
+ uri = generate_uri("#{container}/#{key}")
106
+ uri.query = URI.encode_www_form(comp: "appendblock")
107
+
108
+ headers = {
109
+ "Content-Length": content.size,
110
+ "Content-Type": options[:content_type],
111
+ "Content-MD5": options[:content_md5],
112
+ }
113
+
114
+ Http.new(uri, headers, signer:).put(content)
115
+ end
116
+
117
+ def put_blob_block(key, index, content, options = {})
118
+ block_id = generate_block_id(index)
119
+ uri = generate_uri("#{container}/#{key}")
120
+ uri.query = URI.encode_www_form(comp: "block", blockid: block_id)
121
+
122
+ headers = {
123
+ "Content-Length": content.size,
124
+ "Content-Type": options[:content_type],
125
+ "Content-MD5": options[:content_md5],
126
+ }
127
+
128
+ Http.new(uri, headers, signer:).put(content)
129
+
130
+ block_id
131
+ end
132
+
133
+ def commit_blob_blocks(key, block_ids, options = {})
134
+ block_list = BlockList.new(block_ids)
135
+ content = block_list.to_s
136
+ uri = generate_uri("#{container}/#{key}")
137
+ uri.query = URI.encode_www_form(comp: "blocklist")
138
+
139
+ headers = {
140
+ "Content-Length": content.size,
141
+ "Content-Type": options[:content_type],
142
+ "Content-MD5": options[:content_md5],
143
+ "x-ms-blob-content-disposition": options[:content_disposition],
144
+ }
145
+
146
+ Http.new(uri, headers, metadata: options[:metadata], signer:).put(content)
147
+ end
148
+
149
+ private
150
+
151
+ def generate_block_id(index)
152
+ Base64.urlsafe_encode64(index.to_s.rjust(6, "0"))
153
+ end
154
+
155
+ def put_blob_multiple(key, content, options = {})
156
+ content = StringIO.new(content) if content.is_a? String
157
+ block_size = options[:block_size] || DEFAULT_BLOCK_SIZE
158
+ block_count = (content.size.to_f / block_size).ceil
159
+ block_ids = block_count.times.map do |i|
160
+ put_blob_block(key, i, content.read(block_size))
161
+ end
162
+
163
+ commit_blob_blocks(key, block_ids, options)
164
+ end
165
+
166
+ def put_blob_single(key, content, options = {})
167
+ content = StringIO.new(content) if content.is_a? String
168
+ uri = generate_uri("#{container}/#{key}")
169
+
170
+ headers = {
171
+ "x-ms-blob-type": "BlockBlob",
172
+ "Content-Length": content.size,
173
+ "Content-Type": options[:content_type],
174
+ "Content-MD5": options[:content_md5],
175
+ "x-ms-blob-content-disposition": options[:content_disposition],
176
+ }
177
+
178
+ Http.new(uri, headers, metadata: options[:metadata], signer:).put(content.read)
179
+ end
180
+
181
+ attr_reader :account_name, :signer, :container, :http
182
+
183
+ def host
184
+ "https://#{account_name}.blob.core.windows.net"
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AzureBlob
4
+ API_VERSION = "2024-05-04"
5
+ MAX_UPLOAD_SIZE = 256 * 1024 * 1024
6
+ DEFAULT_BLOCK_SIZE = 128 * 1024 * 1024
7
+ BLOB_SERVICE = "b"
8
+ end
@@ -0,0 +1,3 @@
1
+ module AzureBlob
2
+ class Error < StandardError; end
3
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "metadata"
5
+ require "net/http"
6
+ require "rexml"
7
+
8
+ module AzureBlob
9
+ class Http
10
+ class Error < AzureBlob::Error; end
11
+ class FileNotFoundError < Error; end
12
+ class ForbidenError < Error; end
13
+ class IntegrityError < Error; end
14
+
15
+ include REXML
16
+
17
+ def initialize(uri, headers = {}, signer: nil, metadata: {}, debug: false)
18
+ @date = Time.now.httpdate
19
+ @uri = uri
20
+ @signer = signer
21
+ @headers = headers.merge(Metadata.new(metadata).headers)
22
+ sanitize_headers
23
+
24
+ @http = Net::HTTP.new(uri.hostname, uri.port)
25
+ @http.use_ssl = uri.port == 443
26
+ @http.set_debug_output($stdout) if debug
27
+ end
28
+
29
+ def get
30
+ sign_request("GET") if signer
31
+ @response = http.start do |http|
32
+ http.get(uri, headers)
33
+ end
34
+ raise_error unless success?
35
+ response.body
36
+ end
37
+
38
+ def put(content)
39
+ sign_request("PUT") if signer
40
+ @response = http.start do |http|
41
+ http.put(uri, content, headers)
42
+ end
43
+ raise_error unless success?
44
+ true
45
+ end
46
+
47
+ def head
48
+ sign_request("HEAD") if signer
49
+ @response = http.start do |http|
50
+ http.head(uri, headers)
51
+ end
52
+ raise_error unless success?
53
+ response
54
+ end
55
+
56
+ def delete
57
+ sign_request("DELETE") if signer
58
+ @response = http.start do |http|
59
+ http.delete(uri, headers)
60
+ end
61
+ raise_error unless success?
62
+ response.body
63
+ end
64
+
65
+ def success?
66
+ status < Net::HTTPSuccess
67
+ end
68
+
69
+ private
70
+
71
+ ERROR_MAPPINGS = {
72
+ Net::HTTPNotFound => FileNotFoundError,
73
+ Net::HTTPForbidden => ForbidenError,
74
+ }
75
+
76
+ ERROR_CODE_MAPPINGS = {
77
+ "Md5Mismatch" => IntegrityError,
78
+ }
79
+
80
+ def sanitize_headers
81
+ headers[:"x-ms-version"] = API_VERSION
82
+ headers[:"x-ms-date"] = date
83
+ headers[:"Content-Type"] = headers[:"Content-Type"].to_s
84
+ headers[:"Content-Length"] = headers[:"Content-Length"]&.to_s
85
+ headers[:"Content-MD5"] = nil if headers[:"Content-MD5"]&.empty?
86
+ headers.reject! { |_, value| value.nil? }
87
+ end
88
+
89
+ def sign_request(method)
90
+ headers[:Authorization] = signer.authorization_header(uri:, verb: method, headers:)
91
+ end
92
+
93
+ def raise_error
94
+ raise error_from_response.new(@response.body)
95
+ end
96
+
97
+ def status
98
+ @status ||= Net::HTTPResponse::CODE_TO_OBJ[response.code]
99
+ end
100
+
101
+ def azure_error_code
102
+ Document.new(response.body).get_elements("//Error/Code").first.get_text.to_s
103
+ end
104
+
105
+ def error_from_response
106
+ ERROR_MAPPINGS[status] || ERROR_CODE_MAPPINGS[azure_error_code] || Error
107
+ end
108
+
109
+ attr_accessor :host, :http, :signer, :response, :headers, :uri, :date
110
+ end
111
+ end
@@ -0,0 +1,12 @@
1
+ module AzureBlob
2
+ class Metadata
3
+ def initialize(metadata = nil)
4
+ @metadata = metadata || {}
5
+ @headers = @metadata.map do |key, value|
6
+ [ :"x-ms-meta-#{key}", value.to_s ]
7
+ end.to_h
8
+ end
9
+
10
+ attr_reader :headers
11
+ end
12
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+ require_relative "canonicalized_headers"
6
+ require_relative "canonicalized_resource"
7
+
8
+ module AzureBlob
9
+ class Signer
10
+ def initialize(account_name:, access_key:)
11
+ @account_name = account_name
12
+ @access_key = Base64.decode64(access_key)
13
+ end
14
+
15
+ def authorization_header(uri:, verb:, headers: {})
16
+ canonicalized_headers = CanonicalizedHeaders.new(headers)
17
+ canonicalized_resource = CanonicalizedResource.new(uri, account_name)
18
+
19
+ to_sign = [
20
+ verb,
21
+ *sanitize_headers(headers).fetch_values(
22
+ :"Content-Encoding",
23
+ :"Content-Language",
24
+ :"Content-Length",
25
+ :"Content-MD5",
26
+ :"Content-Type",
27
+ :"Date",
28
+ :"If-Modified-Since",
29
+ :"If-Match",
30
+ :"If-None-Match",
31
+ :"If-Unmodified-Since",
32
+ :"Range"
33
+ ) { nil },
34
+ canonicalized_headers,
35
+ canonicalized_resource,
36
+ ].join("\n")
37
+
38
+ "SharedKey #{account_name}:#{sign(to_sign)}"
39
+ end
40
+
41
+ def sas_token(uri, options = {})
42
+ to_sign = [
43
+ options[:permissions],
44
+ options[:start],
45
+ options[:expiry],
46
+ CanonicalizedResource.new(uri, account_name, url_safe: false, service_name: :blob),
47
+ options[:identifier],
48
+ options[:ip],
49
+ options[:protocol],
50
+ SAS::Version,
51
+ SAS::Resources::Blob,
52
+ nil,
53
+ nil,
54
+ nil,
55
+ options[:content_disposition],
56
+ nil,
57
+ nil,
58
+ options[:content_type],
59
+ ].join("\n")
60
+
61
+ query = {
62
+ SAS::Fields::Permissions => options[:permissions],
63
+ SAS::Fields::Version => SAS::Version,
64
+ SAS::Fields::Expiry => options[:expiry],
65
+ SAS::Fields::Resource => SAS::Resources::Blob,
66
+ SAS::Fields::Disposition => options[:content_disposition],
67
+ SAS::Fields::Type => options[:content_type],
68
+ SAS::Fields::Signature => sign(to_sign),
69
+ }.reject { |_, value| value.nil? }
70
+
71
+ URI.encode_www_form(**query)
72
+ end
73
+
74
+ def sign(body)
75
+ Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", access_key, body))
76
+ end
77
+
78
+ private
79
+
80
+ def sanitize_headers(headers)
81
+ headers = headers.dup
82
+ headers[:"Content-Length"] = nil if headers[:"Content-Length"].to_i == 0
83
+ headers
84
+ end
85
+
86
+ module SAS
87
+ Version = "2024-05-04"
88
+ module Fields
89
+ Permissions = :sp
90
+ Version = :sv
91
+ Expiry = :se
92
+ Resource = :sr
93
+ Signature = :sig
94
+ Disposition = :rscd
95
+ Type = :rsct
96
+ end
97
+ module Resources
98
+ Blob = :b
99
+ end
100
+ end
101
+
102
+ attr_reader :access_key, :account_name
103
+ end
104
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AzureBlob
4
+ VERSION = "0.3.0"
5
+ end
data/lib/azure_blob.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "azure_blob/version"
4
+ require_relative "azure_blob/client"
5
+ require_relative "azure_blob/const"
6
+ require_relative "azure_blob/errors"
7
+
8
+ module AzureBlob
9
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: azure-blob
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Joé Dupuis
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 1980-01-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rexml
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description:
28
+ email:
29
+ - joe@dupuis.io
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".envrc"
35
+ - ".rubocop.yml"
36
+ - ".standard.yml"
37
+ - CHANGELOG.md
38
+ - LICENSE.txt
39
+ - README.md
40
+ - Rakefile
41
+ - azure-blob.gemspec
42
+ - devenv.lock
43
+ - devenv.nix
44
+ - devenv.yaml
45
+ - lib/azure_blob.rb
46
+ - lib/azure_blob/blob.rb
47
+ - lib/azure_blob/blob_list.rb
48
+ - lib/azure_blob/block_list.rb
49
+ - lib/azure_blob/canonicalized_headers.rb
50
+ - lib/azure_blob/canonicalized_resource.rb
51
+ - lib/azure_blob/client.rb
52
+ - lib/azure_blob/const.rb
53
+ - lib/azure_blob/errors.rb
54
+ - lib/azure_blob/http.rb
55
+ - lib/azure_blob/metadata.rb
56
+ - lib/azure_blob/signer.rb
57
+ - lib/azure_blob/version.rb
58
+ homepage: https://github.com/JoeDupuis/azure-blob
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ homepage_uri: https://github.com/JoeDupuis/azure-blob
63
+ source_code_uri: https://github.com/JoeDupuis/azure-blob
64
+ changelog_uri: https://github.com/JoeDupuis/azure-blob/blob/main/CHANGELOG.md
65
+ post_install_message:
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '3.1'
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 3.3.27
81
+ signing_key:
82
+ specification_version: 4
83
+ summary: Azure blob client
84
+ test_files: []