azure-blob 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.envrc +3 -0
- data/.rubocop.yml +25 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +7 -0
- data/Rakefile +8 -0
- data/azure-blob.gemspec +33 -0
- data/devenv.lock +242 -0
- data/devenv.nix +12 -0
- data/devenv.yaml +6 -0
- data/lib/azure_blob/blob.rb +41 -0
- data/lib/azure_blob/blob_list.rb +55 -0
- data/lib/azure_blob/block_list.rb +32 -0
- data/lib/azure_blob/canonicalized_headers.rb +19 -0
- data/lib/azure_blob/canonicalized_resource.rb +23 -0
- data/lib/azure_blob/client.rb +187 -0
- data/lib/azure_blob/const.rb +8 -0
- data/lib/azure_blob/errors.rb +3 -0
- data/lib/azure_blob/http.rb +111 -0
- data/lib/azure_blob/metadata.rb +12 -0
- data/lib/azure_blob/signer.rb +104 -0
- data/lib/azure_blob/version.rb +5 -0
- data/lib/azure_blob.rb +9 -0
- metadata +84 -0
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
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
data/CHANGELOG.md
ADDED
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
data/Rakefile
ADDED
data/azure-blob.gemspec
ADDED
@@ -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
data/devenv.yaml
ADDED
@@ -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,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,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
|
data/lib/azure_blob.rb
ADDED
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: []
|