azure-blob 0.3.0

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.
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: []