kompressr-sdk 0.1.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.
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+ require 'time'
6
+
7
+ module Kompressr
8
+ module Validation
9
+ MAX_SAFE = 9_007_199_254_740_991
10
+ MAX_TIMEOUT = 2_147_483.647
11
+ DELETION = %w[active deleting deleted legacy_deleted].freeze
12
+ PROCESSING = %w[pending processing processed failed unprocessable unknown].freeze
13
+ SCOPES = %w[upload status list private_url delete].freeze
14
+ module_function
15
+
16
+ def invalid(message) = raise(Error.new(message, kind: 'validation', code: 'INVALID_ARGUMENT'), cause: nil)
17
+ def bad(message = 'The API returned an invalid response.')
18
+ raise Error.new(message, kind: 'invalid_response', code: 'INVALID_RESPONSE'), cause: nil
19
+ end
20
+
21
+ def positive(value, name, maximum = MAX_SAFE)
22
+ invalid("#{name} must be an integer between 1 and #{maximum}.") unless value.instance_of?(Integer) && value.between?(1, maximum)
23
+ value
24
+ end
25
+
26
+ def seconds(value, name = 'timeout')
27
+ invalid("#{name} must be positive finite seconds, at most #{MAX_TIMEOUT}.") unless finite?(value) && value.positive? && value <= MAX_TIMEOUT
28
+ value.to_f
29
+ end
30
+
31
+ def finite?(value) = (value.instance_of?(Integer) || value.instance_of?(Float)) && value.finite?
32
+
33
+ def text(value, name, maximum, empty: false, trim: true)
34
+ utf8 = value.instance_of?(String) ? value.b.force_encoding(Encoding::UTF_8) : nil
35
+ valid = utf8 && value.encoding.ascii_compatible? && utf8.valid_encoding?
36
+ normalized = valid ? (trim ? utf8.strip : utf8) : ''
37
+ invalid("#{name} must contain #{empty ? '0' : '1'}–#{maximum} valid UTF-8 bytes.") unless valid && (empty || !normalized.empty?) && normalized.bytesize <= maximum
38
+ utf8
39
+ end
40
+
41
+ def uuid(value, name = 'ID')
42
+ invalid("#{name} must be a UUID.") unless value.instance_of?(String) && value.ascii_only? && /\A[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\z/.match?(value)
43
+ value
44
+ end
45
+
46
+ def enum(value, allowed, name)
47
+ invalid("Unsupported #{name}.") unless value.instance_of?(String) && allowed.include?(value)
48
+ value
49
+ end
50
+
51
+ def customer_data(value, maximum = 4096)
52
+ invalid('Customer data supports at most five scalar fields.') unless value.instance_of?(Hash) && value.size <= 5
53
+ result = {}
54
+ value.each do |raw_key, item|
55
+ key = text(raw_key, 'Data key', 64).strip
56
+ invalid('Data keys must be unique after trimming.') if result.key?(key)
57
+ if item.instance_of?(String)
58
+ item = text(item, 'Data value', 512, empty: true, trim: false)
59
+ elsif !item.nil? && item != true && item != false
60
+ invalid('Data values must be finite scalars.') unless finite?(item)
61
+ end
62
+ result[key] = item
63
+ end
64
+ invalid("Encoded customer data exceeds #{maximum} bytes.") if JSON.generate(result).bytesize > maximum
65
+ result
66
+ rescue JSON::GeneratorError, EncodingError
67
+ invalid('Customer data must contain valid UTF-8.')
68
+ end
69
+
70
+ def origin(value)
71
+ invalid('Base URL must be an HTTP(S) origin without credentials, path, query or fragment.') unless value.instance_of?(String) && !/[\x00-\x20\x7f]/.match?(value)
72
+ uri = URI.parse(value)
73
+ valid = %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo && !uri.query && !uri.fragment && ['', '/'].include?(uri.path)
74
+ valid &&= uri.port.between?(1, 65_535) && !uri.host.include?('%')
75
+ invalid('Base URL must be an HTTP(S) origin without credentials, path, query or fragment.') unless valid
76
+ suffix = uri.port == (uri.scheme == 'https' ? 443 : 80) ? '' : ":#{uri.port}"
77
+ "#{uri.scheme}://#{uri.host.downcase}#{suffix}".freeze
78
+ rescue URI::InvalidURIError, ArgumentError
79
+ invalid('Base URL must be an HTTP(S) origin without credentials, path, query or fragment.')
80
+ end
81
+
82
+ def filename(value)
83
+ value = text(value, 'Filename', 4096)
84
+ invalid('Filename must not contain control characters.') if /[\x00-\x1f\x7f]/.match?(value)
85
+ result = value.tr('\\', '/').split('/', -1).last
86
+ invalid('Supply a nonempty filename.') if result.nil? || result.strip.empty?
87
+ result
88
+ end
89
+
90
+ def record(value)
91
+ bad unless value.instance_of?(Hash)
92
+ value
93
+ end
94
+ def valid_json_strings?(value)
95
+ case value
96
+ when String then value.valid_encoding?
97
+ when Array then value.all? { |item| valid_json_strings?(item) }
98
+ when Hash then value.all? { |key, item| valid_json_strings?(key) && valid_json_strings?(item) }
99
+ else true
100
+ end
101
+ end
102
+ def string(value)
103
+ bad unless value.instance_of?(String) && !value.strip.empty?
104
+ value
105
+ end
106
+ def integer(value)
107
+ bad unless finite?(value) && value == value.to_i && value.abs <= MAX_SAFE
108
+ value.to_i
109
+ end
110
+ def nonnegative(value)
111
+ n = integer(value)
112
+ bad if n.negative?
113
+ n
114
+ end
115
+ def id(value)
116
+ n = integer(value)
117
+ bad unless n.positive?
118
+ n
119
+ end
120
+ def boolean(value)
121
+ bad unless value == true || value == false
122
+ value
123
+ end
124
+ def member(value, allowed)
125
+ bad unless value.instance_of?(String) && allowed.include?(value)
126
+ value
127
+ end
128
+ def optional_strings(value, names)
129
+ names.each { |name| bad if value.key?(name) && !value[name].instance_of?(String) }
130
+ end
131
+ def select(value, names) = value.select { |name, _| names.include?(name) }
132
+ def metadata_response(value, maximum = 8192)
133
+ customer_data(value, maximum)
134
+ rescue Error
135
+ bad('Invalid customer metadata response.')
136
+ end
137
+ def urls(value)
138
+ %w[cdn_url cdn_url_avif private_url].each do |name|
139
+ next unless value.key?(name)
140
+ url = string(value[name])
141
+ uri = URI.parse(url)
142
+ bad('Invalid delivery URL.') unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo && !/[\x00-\x20\x7f]/.match?(url) && uri.port.between?(1, 65_535)
143
+ end
144
+ rescue URI::InvalidURIError, ArgumentError
145
+ bad('Invalid delivery URL.')
146
+ end
147
+
148
+ def upload(value, accepted: false)
149
+ v = record(value)
150
+ result = select(v, %w[status visibility deletion_status original_name original_size asset_type deletion_requested_at external_id created_at input_format output_format private_url_expires_at cdn_url cdn_url_avif private_url data options quality warning])
151
+ result['id'] = id(v[accepted ? 'asset_id' : 'id'])
152
+ member(v['status'], PROCESSING)
153
+ member(v['visibility'], %w[public private])
154
+ member(v['deletion_status'], DELETION)
155
+ result['original_size'] = nonnegative(v['original_size'])
156
+ string(v['original_name']) if accepted
157
+ string(v['asset_type'])
158
+ optional_strings(v, %w[original_name deletion_requested_at external_id created_at input_format output_format private_url_expires_at warning])
159
+ urls(v)
160
+ bad('Private asset response contains a public URL.') if v['visibility'] == 'private' && %w[cdn_url cdn_url_avif].any? { |name| v.key?(name) }
161
+ bad('Deleted asset response contains a delivery URL.') if v['deletion_status'] != 'active' && %w[cdn_url cdn_url_avif private_url].any? { |name| v.key?(name) }
162
+ result['data'] = metadata_response(v['data']) if v.key?('data')
163
+ bad if v.key?('options') && record(v['options']).values.any? { |item| !item.instance_of?(String) }
164
+ member(v['quality'], %w[480p 720p 1080p 4k]) if v.key?('quality')
165
+ unless accepted
166
+ result['kompressd_size'] = nonnegative(v['kompressd_size'])
167
+ result['can_generate_private_url'] = boolean(v['can_generate_private_url'])
168
+ bad unless finite?(v['savings_percent'])
169
+ result['savings_percent'] = v['savings_percent']
170
+ end
171
+ result
172
+ end
173
+
174
+ def private_url(value)
175
+ v = record(value)
176
+ id(v['asset_id'])
177
+ member(v['variant'], %w[webp avif])
178
+ urls(v)
179
+ string(v['private_url'])
180
+ string(v['expires_at'])
181
+ bad unless integer(v['expires_in']).between?(60, 604_800)
182
+ select(v, %w[asset_id variant private_url expires_at expires_in])
183
+ end
184
+
185
+ def asset_state(value)
186
+ v = record(value)
187
+ id(v['id']); id(v['kind']); nonnegative(v['storage_credited_mb'])
188
+ string(v['project_id']); integer(v['processing_status'])
189
+ member(v['deletion_status'], DELETION); member(v['source'], %w[upload origin])
190
+ optional_strings(v, %w[deletion_requested_at])
191
+ member(v['accounting_status'], %w[pending settled unavailable]) if v.key?('accounting_status')
192
+ result = select(v, %w[id project_id kind source processing_status deletion_status storage_credited_mb deletion_requested_at accounting_status])
193
+ if v.key?('last_failure')
194
+ failure = record(v['last_failure'])
195
+ %w[code message at].each { |key| string(failure[key]) }
196
+ result['last_failure'] = select(failure, %w[code message at])
197
+ end
198
+ result
199
+ end
200
+
201
+ def page(value, key = 'data', &parse)
202
+ v = record(value)
203
+ bad unless v[key].instance_of?(Array)
204
+ result = { 'data' => v[key].map(&parse), 'page' => id(v['page']), 'limit' => id(v['limit']), 'total' => nonnegative(v['total']) }
205
+ bad if result['limit'] > 100 || result['data'].size > result['limit']
206
+ result
207
+ end
208
+
209
+ def bulk(value, expected)
210
+ v = record(value)
211
+ counts = record(v['counts'])
212
+ bad unless v['results'].instance_of?(Array) && v['results'].size == expected.size
213
+ accepted = 0
214
+ rows = v['results'].each_with_index.map do |item, index|
215
+ row = record(item)
216
+ bad unless id(row['id']) == expected[index]
217
+ ok = boolean(row['accepted']); status = integer(row['status'])
218
+ output = select(row, %w[id status accepted code error])
219
+ optional_strings(row, %w[code error])
220
+ if ok
221
+ accepted += 1
222
+ state = asset_state(row['state'])
223
+ bad unless [200, 202].include?(status) && state['id'] == row['id'] && state['deletion_status'] != 'active'
224
+ output['state'] = state
225
+ else
226
+ bad unless status >= 400
227
+ string(row['error'])
228
+ end
229
+ output
230
+ end
231
+ expected_counts = { 'total' => expected.size, 'accepted' => accepted, 'failed' => expected.size - accepted }
232
+ expected_counts.each { |key, n| bad unless integer(counts[key]) == n }
233
+ { 'results' => rows, 'counts' => expected_counts }
234
+ end
235
+
236
+ def project_user(value)
237
+ v = record(value)
238
+ %w[id project_id customer_user_id].each { |name| string(v[name]) }
239
+ member(v['status'], %w[active suspended])
240
+ optional_strings(v, %w[display_name created_at last_seen_at last_upload_at last_key_used_at])
241
+ numbers = %w[asset_count storage_bytes active_key_count bandwidth_bytes_current_period requests_current_period]
242
+ numbers.each { |name| nonnegative(v[name]) if v.key?(name) }
243
+ result = select(v, %w[id project_id customer_user_id status display_name created_at last_seen_at last_upload_at last_key_used_at] + numbers)
244
+ result['metadata'] = metadata_response(v['metadata']) if v.key?('metadata')
245
+ result
246
+ end
247
+
248
+ def upload_key(value)
249
+ v = record(value)
250
+ %w[id key_id project_id project_user_id name].each { |name| string(v[name]) }
251
+ bad unless v['scopes'].instance_of?(Array) && v['scopes'].all? { |s| SCOPES.include?(s) }
252
+ member(v['allowed_visibility'], %w[public private both])
253
+ if v.key?('allowed_asset_types')
254
+ bad unless v['allowed_asset_types'].instance_of?(Array)
255
+ v['allowed_asset_types'].each { |type| id(type) }
256
+ end
257
+ %w[expires_at revoked_at last_used_at].each { |key| bad if v.key?(key) && !v[key].nil? && !v[key].instance_of?(String) }
258
+ optional_strings(v, %w[created_at])
259
+ select(v, %w[id key_id project_id project_user_id name scopes allowed_visibility allowed_asset_types expires_at revoked_at last_used_at created_at])
260
+ end
261
+
262
+ def success(value)
263
+ v = record(value)
264
+ bad unless v['success'] == true
265
+ v
266
+ end
267
+ def data(value)
268
+ v = success(value)
269
+ bad unless v.key?('data')
270
+ v['data']
271
+ end
272
+ def issued(value)
273
+ v = success(value)
274
+ IssuedKey.new(id: string(v['id']), key_id: string(v['key_id']), api_key: string(v['api_key']),
275
+ upload_key: v.key?('upload_key') ? upload_key(v['upload_key']) : nil)
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kompressr
4
+ VERSION = '0.1.0'
5
+ end
data/lib/kompressr.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'kompressr/version'
4
+ require_relative 'kompressr/error'
5
+ require_relative 'kompressr/validation'
6
+ require_relative 'kompressr/transport'
7
+ require_relative 'kompressr/platform_client'
8
+ require_relative 'kompressr/client'
metadata ADDED
@@ -0,0 +1,179 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kompressr-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kompressr
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.7'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '2.7'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3'
32
+ - !ruby/object:Gem::Dependency
33
+ name: openssl
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '3.2'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '5'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.2'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '5'
52
+ - !ruby/object:Gem::Dependency
53
+ name: uri
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0.13'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0.13'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '2'
72
+ - !ruby/object:Gem::Dependency
73
+ name: time
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0.3'
79
+ - - "<"
80
+ - !ruby/object:Gem::Version
81
+ version: '1'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0.3'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '1'
92
+ - !ruby/object:Gem::Dependency
93
+ name: ipaddr
94
+ requirement: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: 1.2.5
99
+ - - "<"
100
+ - !ruby/object:Gem::Version
101
+ version: '2'
102
+ type: :runtime
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: 1.2.5
109
+ - - "<"
110
+ - !ruby/object:Gem::Version
111
+ version: '2'
112
+ - !ruby/object:Gem::Dependency
113
+ name: securerandom
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0.3'
119
+ - - "<"
120
+ - !ruby/object:Gem::Version
121
+ version: '1'
122
+ type: :runtime
123
+ prerelease: false
124
+ version_requirements: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '0.3'
129
+ - - "<"
130
+ - !ruby/object:Gem::Version
131
+ version: '1'
132
+ description: Upload, wait for processing, sign private links and manage scoped identities
133
+ using a framework-independent Ruby client.
134
+ executables: []
135
+ extensions: []
136
+ extra_rdoc_files: []
137
+ files:
138
+ - API.md
139
+ - CHANGELOG.md
140
+ - LICENSE
141
+ - PARITY.md
142
+ - README.md
143
+ - examples/private_upload.rb
144
+ - examples/rails_service.rb
145
+ - lib/kompressr.rb
146
+ - lib/kompressr/client.rb
147
+ - lib/kompressr/error.rb
148
+ - lib/kompressr/platform_client.rb
149
+ - lib/kompressr/transport.rb
150
+ - lib/kompressr/validation.rb
151
+ - lib/kompressr/version.rb
152
+ homepage: https://kompressr.com
153
+ licenses:
154
+ - MIT
155
+ metadata:
156
+ source_code_uri: https://github.com/kompressrcdn/clients/tree/main/sdk-ruby
157
+ documentation_uri: https://github.com/kompressrcdn/clients/blob/main/sdk-ruby/README.md
158
+ changelog_uri: https://github.com/kompressrcdn/clients/blob/main/sdk-ruby/CHANGELOG.md
159
+ bug_tracker_uri: https://github.com/kompressrcdn/clients/issues
160
+ allowed_push_host: https://rubygems.org
161
+ rubygems_mfa_required: 'true'
162
+ rdoc_options: []
163
+ require_paths:
164
+ - lib
165
+ required_ruby_version: !ruby/object:Gem::Requirement
166
+ requirements:
167
+ - - ">="
168
+ - !ruby/object:Gem::Version
169
+ version: '3.2'
170
+ required_rubygems_version: !ruby/object:Gem::Requirement
171
+ requirements:
172
+ - - ">="
173
+ - !ruby/object:Gem::Version
174
+ version: '0'
175
+ requirements: []
176
+ rubygems_version: 4.0.16
177
+ specification_version: 4
178
+ summary: Official Ruby SDK for Kompressr file and platform APIs.
179
+ test_files: []