fileboy 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8733a37ff97e6459ba45a32878b7daad2dae4c24036aa2c259fe2f0c794b937c
4
- data.tar.gz: 2afd08a3e2d9e9690fdead7989696bf4ece30922fa0e7ffd8df77416f4126262
3
+ metadata.gz: f032a5828ce3db69a2ac9a2952774a2d5797906ee46c144af088f5bba1188a1e
4
+ data.tar.gz: b625e71b7d308b922f4fd5ed9bd9821058d4a992732baab9cd64b2cfc8d5e245
5
5
  SHA512:
6
- metadata.gz: 291cbb77ae1c0a7cff35c6e839df34a6575f857877634828fb9e377a8bd8d17eaee2d26d4ab0ae09ecfd0fee04b04d207bc1c960e609d9563bf458e8aecddd69
7
- data.tar.gz: f7f30b41249df4b3d3dbf9d24d8b0ab0f70fcf6996524b3840606b55c0dfc40f9301ffb888659985d94f8eb49d66b2739ae6d49ac089c6686816854c797458f8
6
+ metadata.gz: 35732881e4adc1331d672f708e30618fc112e5f306093eec6cf27f0edc779a29617ee7124da5d2c7ff4f7abd431673757a85b585c0a1f4662de1e5f148af0ed9
7
+ data.tar.gz: fb4739be49a85cd9645a20d6bed26e674b02e50c6e8e53c71594ec4bdbd102799e1c106d98e53d9d327ccf36efe91e2087038cb5c761f1583893b7b01cba2870
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - New: manage your app from Ruby. List, change and delete files, videos and labels, and read your usage. These calls need the private key and raise `Fileboy::ConfigurationError` before any request if the client has none.
6
+ - `list_files` and `list_videos` return a `Fileboy::Page` with `items` and `next_cursor`. `each_file` and `each_video` follow every page for you and can be used lazily.
7
+ - `update_file` changes a file's name, type, metadata (merged by default, `replace_metadata: true` to replace) and labels. `delete_files` removes up to 100 files at once.
8
+ - `Fileboy::FileData` has a new `labels` list, filled by the management calls.
9
+ - Errors from these calls use the existing classes (`NotFoundError`, `AuthenticationError`, `PermissionError`, `BadRequestError`, `ServerError`) and keep the API's `code`, message and HTTP `status`.
10
+
3
11
  ## 0.1.1
4
12
 
5
13
  - Fix: the gem failed to load on Ruby 3.4 because it required the `base64` gem, which is no longer a default gem. It now uses only the standard library.
data/README.md CHANGED
@@ -78,6 +78,54 @@ created in the dashboard.
78
78
 
79
79
  You can also pass a hash of `name => value`, such as `{ resize: "300x_", format: "webp", quality: 75 }`, or a ready-made string.
80
80
 
81
+ ## Managing files
82
+
83
+ List, change and delete what is stored in your app. These calls are signed, so build the client with
84
+ your private key. With only a public key they raise `Fileboy::ConfigurationError` before any request.
85
+
86
+ ```ruby
87
+ fileboy = Fileboy::Client.new(public_key: "PUBLIC", private_key: "PRIVATE")
88
+
89
+ # One page, with filters
90
+ page = fileboy.list_files(mime: "image/", metadata: { orderId: "42" }, limit: 100)
91
+ page.items.each { |file| puts "#{file.name} #{file.labels}" }
92
+ page.next_cursor # nil on the last page
93
+
94
+ # Every page, lazily
95
+ fileboy.each_file(mime: "image/").each { |file| puts file.name }
96
+ fileboy.each_file(label: "logo").first(5)
97
+
98
+ # Change a file. Metadata is merged and nil removes a key, replace_metadata: true replaces it.
99
+ fileboy.update_file(file.id, name: "new.png", metadata: { owner: "7", old: nil },
100
+ labels: { add: ["hero"], remove: ["draft"] })
101
+ fileboy.update_file("hero", name: "x") # a label name works too
102
+
103
+ fileboy.delete_file(file.id) # raises Fileboy::NotFoundError if it is gone
104
+ result = fileboy.delete_files([id1, id2]) # up to 100; result.deleted, .not_found, .failed
105
+
106
+ fileboy.list_videos(status: :ready).items
107
+ fileboy.update_video(video_id, name: "Launch.mp4")
108
+ fileboy.delete_video(video_id)
109
+
110
+ fileboy.list_labels # [Fileboy::Label]
111
+ fileboy.create_label("hero", file_id: file.id) # BadRequestError with code "label_taken" if it exists
112
+ fileboy.delete_label("hero") # the file is kept
113
+
114
+ usage = fileboy.fetch_usage
115
+ usage.files; usage.storage_bytes; usage.limits.storage_bytes # a nil limit means none is set
116
+ ```
117
+
118
+ Failures carry the API's `code`, message and HTTP `status`:
119
+
120
+ ```ruby
121
+ begin
122
+ fileboy.delete_file(id)
123
+ rescue Fileboy::NotFoundError => e
124
+ e.code # "file_not_found"
125
+ e.status # 404
126
+ end
127
+ ```
128
+
81
129
  ## Errors
82
130
 
83
131
  Every error is a `Fileboy::Error` with `status`, `code` (the API's error code) and `body`.
@@ -97,7 +145,7 @@ Every error is a `Fileboy::Error` with `status`, `code` (the API's error code) a
97
145
 
98
146
  * File, image and video reads need no authorization, so URL builders never sign anything.
99
147
  * The shared `demo` key only accepts small images and cannot upload video.
100
- * There is no endpoint yet to update, delete or list files, so the client has no such methods.
148
+ * Managing files, videos and labels needs the private key. See below.
101
149
 
102
150
  ## Development
103
151
 
@@ -16,8 +16,12 @@ module Fileboy
16
16
  #
17
17
  # Reading a file (GET /files/get, /images/get, video data) needs no authorization, so URL
18
18
  # builders never sign anything. Uploads send your public key, or a signed token when you also
19
- # pass a private key.
19
+ # pass a private key. Listing, changing and deleting files, videos and labels needs the private key.
20
20
  class Client
21
+ HTTP_METHODS = {
22
+ get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch, delete: Net::HTTP::Delete
23
+ }.freeze
24
+
21
25
  attr_reader :public_key, :api_url
22
26
 
23
27
  # @param public_key [String] your app's public key
@@ -184,6 +188,165 @@ module Fileboy
184
188
  request(:get, "/videos/#{video_id}/poster", raw: true)
185
189
  end
186
190
 
191
+ # --- Management (needs the private key) ----------------------------------------------------
192
+ #
193
+ # These list, change and delete what is stored in your app. They are signed with the private
194
+ # key, so build the client with `private_key:`. Without one they raise
195
+ # {Fileboy::ConfigurationError} before any request is made. Failures raise {Fileboy::NotFoundError},
196
+ # {Fileboy::AuthenticationError}, {Fileboy::PermissionError}, {Fileboy::BadRequestError} (invalid
197
+ # input, a label name that is taken) or {Fileboy::ServerError}, each with `status`, `code` and
198
+ # the API's message.
199
+
200
+ # One page of your files.
201
+ #
202
+ # page = fileboy.list_files(mime: "image/", limit: 100)
203
+ # page.items.each { |file| puts file.name }
204
+ # fileboy.list_files(cursor: page.next_cursor) if page.next_cursor
205
+ #
206
+ # @param limit [Integer, nil] page size, 1 to 100 (the API default is 50)
207
+ # @param cursor [String, nil] the `next_cursor` of the previous page
208
+ # @param sort [Symbol, String, nil] :created_at (default), :name or :file_size
209
+ # @param order [Symbol, String, nil] :asc or :desc
210
+ # @param label [String, nil] only the file this label points at
211
+ # @param mime [String, nil] only types starting with this, like "image/"
212
+ # @param name [String, nil] only names containing this text
213
+ # @param hash [String, nil] only files with this SHA1 content hash
214
+ # @param metadata [Hash, nil] only files whose metadata has all of these pairs
215
+ # @param created_before [Time, String, nil]
216
+ # @param created_after [Time, String, nil]
217
+ # @return [Fileboy::Page] of {Fileboy::FileData}
218
+ def list_files(limit: nil, cursor: nil, sort: nil, order: nil, label: nil, mime: nil, name: nil, hash: nil,
219
+ metadata: nil, created_before: nil, created_after: nil)
220
+ query = list_query(
221
+ limit: limit, cursor: cursor, sort: camelize(sort), order: order, label: label, mime: mime, name: name,
222
+ hash: hash, metadata: metadata, createdBefore: created_before, createdAfter: created_after
223
+ )
224
+ Page.from_api(manage(:get, "/files", query: query), FileData)
225
+ end
226
+
227
+ # Every file matching the filters, following every page. Without a block it returns an
228
+ # Enumerator, so it can be lazy: `fileboy.each_file(mime: "image/").first(10)`.
229
+ #
230
+ # Takes the same filters as {#list_files}.
231
+ def each_file(**filters, &block)
232
+ return enum_for(:each_file, **filters) unless block
233
+
234
+ each_in_pages(:list_files, filters, &block)
235
+ end
236
+
237
+ # Change a file. Only the fields you pass change. `metadata` is merged into the existing
238
+ # metadata and a nil value removes that key; pass `replace_metadata: true` to replace it all.
239
+ #
240
+ # fileboy.update_file(id, name: "new.png", metadata: { owner: "7", old: nil },
241
+ # labels: { add: ["hero"], remove: ["draft"] })
242
+ #
243
+ # @param id_or_label [String] a file id, or a label name
244
+ # @param labels [Hash, nil] `{ add: [...], remove: [...] }`. A label points at one file, so
245
+ # adding one that is on another file moves it here.
246
+ # @return [Fileboy::FileData]
247
+ def update_file(id_or_label, name: nil, mime: nil, metadata: nil, replace_metadata: nil, labels: nil)
248
+ body = {}
249
+ body[:name] = name unless name.nil?
250
+ body[:mime] = mime unless mime.nil?
251
+ unless metadata.nil?
252
+ body[:metadata] = metadata.to_h.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v.nil? ? nil : v.to_s }
253
+ end
254
+ body[:replaceMetadata] = replace_metadata unless replace_metadata.nil?
255
+ unless labels.nil?
256
+ body[:labels] = {}
257
+ add = labels[:add] || labels["add"]
258
+ remove = labels[:remove] || labels["remove"]
259
+ body[:labels][:add] = Array(add).map(&:to_s) if add
260
+ body[:labels][:remove] = Array(remove).map(&:to_s) if remove
261
+ end
262
+ FileData.from_api(manage(:patch, "/files/#{file_ref(id_or_label)}", json: body).fetch("data"))
263
+ end
264
+
265
+ # Delete a file and its stored data. Raises {Fileboy::NotFoundError} when there is no such
266
+ # file, which includes one that is already deleted. Returns true.
267
+ def delete_file(id_or_label)
268
+ manage(:delete, "/files/#{file_ref(id_or_label)}")
269
+ true
270
+ end
271
+
272
+ # Delete up to 100 files at once. Unknown ids come back in `not_found` instead of raising, so
273
+ # it is safe to repeat. Ids in `failed` are still there because storage was unreachable, send
274
+ # them again.
275
+ #
276
+ # @param ids [Array<String>]
277
+ # @return [Fileboy::BulkDeleteResult]
278
+ def delete_files(ids)
279
+ ids = Array(ids).map(&:to_s)
280
+ raise InvalidArgumentError, "No ids given" if ids.empty?
281
+
282
+ BulkDeleteResult.from_api(manage(:post, "/files/delete", json: { ids: ids }).fetch("data"))
283
+ end
284
+
285
+ # One page of your videos. `status` is :pending, :processing, :ready or :failed, `sort` is
286
+ # :created_at or :name. Other options are as in {#list_files}.
287
+ #
288
+ # @return [Fileboy::Page] of {Fileboy::ManagedVideo}
289
+ def list_videos(limit: nil, cursor: nil, sort: nil, order: nil, name: nil, status: nil, created_before: nil,
290
+ created_after: nil)
291
+ query = list_query(
292
+ limit: limit, cursor: cursor, sort: camelize(sort), order: order, name: name, status: status,
293
+ createdBefore: created_before, createdAfter: created_after
294
+ )
295
+ Page.from_api(manage(:get, "/videos", query: query), ManagedVideo)
296
+ end
297
+
298
+ # Every video matching the filters, following every page. See {#each_file}.
299
+ def each_video(**filters, &block)
300
+ return enum_for(:each_video, **filters) unless block
301
+
302
+ each_in_pages(:list_videos, filters, &block)
303
+ end
304
+
305
+ # Rename a video.
306
+ #
307
+ # @return [Fileboy::ManagedVideo]
308
+ def update_video(video_id, name:)
309
+ ManagedVideo.from_api(manage(:patch, "/videos/#{path_id(video_id)}", json: { name: name }).fetch("data"))
310
+ end
311
+
312
+ # Delete a video with its original, renditions, posters and transcripts. Returns true.
313
+ def delete_video(video_id)
314
+ manage(:delete, "/videos/#{path_id(video_id)}")
315
+ true
316
+ end
317
+
318
+ # All labels of your app.
319
+ #
320
+ # @return [Array<Fileboy::Label>]
321
+ def list_labels
322
+ manage(:get, "/labels").fetch("data").map { |data| Label.from_api(data) }
323
+ end
324
+
325
+ # Create a label, optionally pointing at a file. Raises {Fileboy::BadRequestError} with code
326
+ # "label_taken" when the name exists.
327
+ #
328
+ # @return [Fileboy::Label]
329
+ def create_label(name, file_id: nil)
330
+ raise InvalidArgumentError, "No name given" if name.to_s.empty?
331
+
332
+ body = { name: name }
333
+ body[:fileId] = file_id if file_id
334
+ Label.from_api(manage(:post, "/labels", json: body).fetch("data"))
335
+ end
336
+
337
+ # Delete a label. The file it points at is kept. Returns true.
338
+ def delete_label(name)
339
+ manage(:delete, "/labels/#{path_id(name, "name")}")
340
+ true
341
+ end
342
+
343
+ # How many files and videos the app has and how much they store, with the plan limits.
344
+ #
345
+ # @return [Fileboy::Usage]
346
+ def fetch_usage
347
+ Usage.from_api(manage(:get, "/usage").fetch("data"))
348
+ end
349
+
187
350
  # --- Tokens --------------------------------------------------------------------------------
188
351
 
189
352
  # A short-lived signed token for handing a browser limited access. Needs a private key.
@@ -207,6 +370,63 @@ module Fileboy
207
370
  "#{@public_key}+#{ERB::Util.url_encode(value)}"
208
371
  end
209
372
 
373
+ # Management calls are signed, so they need the private key.
374
+ def manage(method, path, query: nil, json: nil)
375
+ unless @private_key
376
+ raise ConfigurationError, "This needs the app's private key. Create the client with private_key:"
377
+ end
378
+
379
+ path += "?#{query}" if query && !query.empty?
380
+ request(method, path, json: json)
381
+ end
382
+
383
+ # Query string from a hash. Skips nils, formats times as ISO 8601, expands metadata.
384
+ def list_query(options)
385
+ params = []
386
+ options.each do |key, value|
387
+ next if value.nil?
388
+
389
+ if key == :metadata
390
+ value.each { |k, v| params << ["metadata[#{k}]", v.to_s] }
391
+ elsif value.respond_to?(:utc)
392
+ params << [key.to_s, value.utc.iso8601]
393
+ else
394
+ params << [key.to_s, value.to_s]
395
+ end
396
+ end
397
+ params.map { |k, v| "#{ERB::Util.url_encode(k)}=#{ERB::Util.url_encode(v)}" }.join("&")
398
+ end
399
+
400
+ def camelize(value)
401
+ value&.to_s&.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
402
+ end
403
+
404
+ def each_in_pages(method, filters)
405
+ cursor = filters.delete(:cursor)
406
+ loop do
407
+ page = public_send(method, **filters, cursor: cursor)
408
+ page.items.each { |item| yield item }
409
+ cursor = page.next_cursor
410
+ break if cursor.nil? || cursor.empty?
411
+ end
412
+ end
413
+
414
+ # A file id or label for a management path. Labels become PUBLIC_KEY%2BLABEL.
415
+ def file_ref(id_or_label)
416
+ value = id_or_label.to_s
417
+ raise InvalidArgumentError, "No id given" if value.empty?
418
+
419
+ # Build the raw reference first so it is percent-encoded exactly once
420
+ value = "#{@public_key}+#{value}" unless value.match?(UUID) || value.include?("+")
421
+ ERB::Util.url_encode(value)
422
+ end
423
+
424
+ def path_id(value, what = "id")
425
+ raise InvalidArgumentError, "No #{what} given" if value.to_s.empty?
426
+
427
+ ERB::Util.url_encode(value.to_s)
428
+ end
429
+
210
430
  def stringify(metadata)
211
431
  metadata.to_h.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v.to_s }
212
432
  end
@@ -243,7 +463,7 @@ module Fileboy
243
463
 
244
464
  def request(method, path, json: nil, form: nil, raw: false, range: nil)
245
465
  uri = URI.parse("#{@api_url}#{path}")
246
- req = (method == :post ? Net::HTTP::Post : Net::HTTP::Get).new(uri)
466
+ req = HTTP_METHODS.fetch(method).new(uri)
247
467
  req["Authorization"] = auth_header
248
468
  req["Accept"] = "application/json" unless raw
249
469
  req["User-Agent"] = "fileboy-ruby/#{VERSION}"
@@ -262,6 +482,7 @@ module Fileboy
262
482
 
263
483
  raise_for_status(response) unless response.is_a?(Net::HTTPSuccess)
264
484
  return response.body if raw
485
+ return nil if response.code.to_i == 204 || response.body.to_s.empty?
265
486
 
266
487
  begin
267
488
  JSON.parse(response.body)
@@ -1,10 +1,11 @@
1
1
  require "time"
2
2
 
3
3
  module Fileboy
4
- # The details of one stored file, as returned by an upload or `fetch_file_data`.
4
+ # The details of one stored file, as returned by an upload, `fetch_file_data` or the management calls.
5
+ # `labels` lists the labels pointing at the file and is only filled by the management calls.
5
6
  FileData = Struct.new(
6
7
  :id, :name, :original_filename, :mime, :metadata, :file_size, :content_hash, :signed,
7
- :dies_at, :created_at,
8
+ :dies_at, :created_at, :labels,
8
9
  keyword_init: true
9
10
  ) do
10
11
  # Build from the `data` object of an API response.
@@ -19,7 +20,8 @@ module Fileboy
19
20
  content_hash: data["hash"],
20
21
  signed: data["signed"],
21
22
  dies_at: parse_time(data["diesAt"]),
22
- created_at: parse_time(data["createdAt"])
23
+ created_at: parse_time(data["createdAt"]),
24
+ labels: data["labels"] || []
23
25
  )
24
26
  end
25
27
 
@@ -34,4 +36,83 @@ module Fileboy
34
36
  new(id: data["id"], name: data["name"], raw_file_id: data["rawFileId"])
35
37
  end
36
38
  end
39
+
40
+ # One page of a list. Enumerable over its items; `next_cursor` is nil on the last page.
41
+ class Page
42
+ include Enumerable
43
+
44
+ attr_reader :items, :next_cursor
45
+
46
+ def self.from_api(body, type)
47
+ new(items: body.fetch("data").map { |data| type.from_api(data) }, next_cursor: body["nextCursor"])
48
+ end
49
+
50
+ def initialize(items:, next_cursor: nil)
51
+ @items = items
52
+ @next_cursor = next_cursor
53
+ end
54
+
55
+ def each(&block)
56
+ return items.each unless block
57
+
58
+ items.each(&block)
59
+ self
60
+ end
61
+
62
+ def last_page?
63
+ next_cursor.nil?
64
+ end
65
+ end
66
+
67
+ # A video as the management API returns it. `status` is "pending", "processing", "ready" or
68
+ # "failed". `urls` is nil until ready, else a hash with :manifest, :hls, :dash and :poster.
69
+ ManagedVideo = Struct.new(
70
+ :id, :name, :created_at, :status, :status_message, :duration, :width, :height, :play_count,
71
+ :last_played_at, :urls,
72
+ keyword_init: true
73
+ ) do
74
+ def self.from_api(data)
75
+ urls = data["urls"]
76
+ new(
77
+ id: data["id"], name: data["name"], created_at: FileData.parse_time(data["createdAt"]),
78
+ status: data["status"], status_message: data["statusMessage"], duration: data["duration"],
79
+ width: data["width"], height: data["height"], play_count: data["playCount"],
80
+ last_played_at: FileData.parse_time(data["lastPlayedAt"]),
81
+ urls: urls && urls.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
82
+ )
83
+ end
84
+ end
85
+
86
+ # A label. `file_id` is the file it points at, or nil.
87
+ Label = Struct.new(:id, :name, :created_at, :file_id, keyword_init: true) do
88
+ def self.from_api(data)
89
+ new(id: data["id"], name: data["name"], created_at: FileData.parse_time(data["createdAt"]),
90
+ file_id: data["fileId"])
91
+ end
92
+ end
93
+
94
+ # The result of `delete_files`. `failed` is a list of {FailedDelete}.
95
+ BulkDeleteResult = Struct.new(:deleted, :not_found, :failed, keyword_init: true) do
96
+ def self.from_api(data)
97
+ new(
98
+ deleted: data["deleted"] || [], not_found: data["notFound"] || [],
99
+ failed: (data["failed"] || []).map { |f| FailedDelete.new(id: f["id"], message: f["message"]) }
100
+ )
101
+ end
102
+ end
103
+ FailedDelete = Struct.new(:id, :message, keyword_init: true)
104
+
105
+ # Plan limits. A nil limit means none is set.
106
+ UsageLimits = Struct.new(:files, :videos, :storage_bytes, keyword_init: true)
107
+
108
+ # What the app stores. `storage_bytes` counts files, image copies, video renditions and posters.
109
+ Usage = Struct.new(:files, :videos, :storage_bytes, :limits, keyword_init: true) do
110
+ def self.from_api(data)
111
+ limits = data["limits"] || {}
112
+ new(
113
+ files: data["files"], videos: data["videos"], storage_bytes: data["storageBytes"],
114
+ limits: UsageLimits.new(files: limits["files"], videos: limits["videos"], storage_bytes: limits["storageBytes"])
115
+ )
116
+ end
117
+ end
37
118
  end
@@ -1,3 +1,3 @@
1
1
  module Fileboy
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fileboy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - CD2 Solutions