pocketbase 0.1.1

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,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module PocketBase
8
+ # PocketBase API client (port of the js-sdk Client).
9
+ #
10
+ # pb = PocketBase::Client.new("http://127.0.0.1:8090")
11
+ # pb.collection("users").auth_with_password("test@example.com", "1234567890")
12
+ class Client
13
+ # Options that are treated as send options; anything else passed at the
14
+ # top level of an options hash is moved into the query string
15
+ # (mirrors js-sdk normalizeUnknownQueryParams).
16
+ KNOWN_SEND_OPTIONS = %i[
17
+ method headers body query params request_key timeout
18
+ fetch signal
19
+ ].freeze
20
+
21
+ attr_accessor :base_url, :lang, :auth_store, :before_send, :after_send
22
+ attr_reader :settings, :collections, :files, :logs, :realtime,
23
+ :health, :backups, :crons, :sql
24
+
25
+ def initialize(base_url = "/", auth_store: nil, lang: "en-US")
26
+ @base_url = base_url.to_s
27
+ @lang = lang
28
+ @auth_store = auth_store || AuthStore.new
29
+ @before_send = nil
30
+ @after_send = nil
31
+ @record_services = {}
32
+ @enable_auto_cancellation = true
33
+
34
+ @settings = Services::SettingsService.new(self)
35
+ @collections = Services::CollectionService.new(self)
36
+ @files = Services::FileService.new(self)
37
+ @logs = Services::LogService.new(self)
38
+ @realtime = Services::RealtimeService.new(self)
39
+ @health = Services::HealthService.new(self)
40
+ @backups = Services::BackupService.new(self)
41
+ @crons = Services::CronService.new(self)
42
+ @sql = Services::SQLService.new(self)
43
+ end
44
+
45
+ # Returns the RecordService for a collection (cached per idOrName).
46
+ def collection(id_or_name)
47
+ key = id_or_name.to_s
48
+ @record_services[key] ||= Services::RecordService.new(self, key)
49
+ end
50
+
51
+ # Creates a new batch handler for transactional multi-requests.
52
+ #
53
+ # batch = pb.create_batch
54
+ # batch.collection("posts").create(title: "hello")
55
+ # batch.collection("posts").update("RECORD_ID", title: "hi")
56
+ # batch.send
57
+ def create_batch
58
+ Services::BatchService.new(self)
59
+ end
60
+
61
+ # Globally enables/disables auto cancellation of duplicated requests.
62
+ # Note: requests are synchronous in the Ruby client, so there is nothing
63
+ # to cancel; the flag is accepted for API compatibility.
64
+ def auto_cancellation(enable)
65
+ @enable_auto_cancellation = !!enable
66
+ self
67
+ end
68
+
69
+ # No-op in the synchronous Ruby client (kept for API compatibility).
70
+ def cancel_request(_request_key)
71
+ self
72
+ end
73
+
74
+ # No-op in the synchronous Ruby client (kept for API compatibility).
75
+ def cancel_all_requests
76
+ self
77
+ end
78
+
79
+ # Builds a filter expression with `{:param}` placeholders populated
80
+ # from params (see Utils.build_filter).
81
+ def filter(raw, params = nil)
82
+ Utils.build_filter(raw, params)
83
+ end
84
+
85
+ # Builds a full client URL by safely concatenating the given path.
86
+ def build_url(path = nil)
87
+ url = @base_url.dup
88
+ if path && !path.to_s.empty?
89
+ url += "/" unless url.end_with?("/")
90
+ p = path.to_s
91
+ p = p[1..] if p.start_with?("/")
92
+ url += p
93
+ end
94
+ url
95
+ end
96
+
97
+ # Sends an API HTTP request.
98
+ #
99
+ # Options:
100
+ # method: "GET" (default), "POST", "PATCH", "PUT", "DELETE", ...
101
+ # headers: Hash of request headers
102
+ # body: Hash (sent as JSON, or as multipart when it has file values),
103
+ # a PocketBase::MultipartBody, or a raw String
104
+ # query: Hash of query params (merged with legacy :params)
105
+ # timeout: request timeout in seconds
106
+ # Any other top-level option is moved into the query string.
107
+ #
108
+ # The before_send hook receives (url, options) and may return a Hash with
109
+ # symbol or string keys for url/options; nil preserves current options.
110
+ # The after_send hook receives (ResponseInfo, parsed_data, options), and
111
+ # its return value becomes the result, including the API error payload.
112
+ # Redirects drop credentials across origins. POST becomes GET on 301/302;
113
+ # non-GET/HEAD becomes GET on 303. Other redirects retain method and body.
114
+ #
115
+ # @param path [String] API path relative to base_url
116
+ # @param options [Hash] Request options and extra query parameters
117
+ # @return [Hash, Array, nil] Parsed JSON, nil for an empty body, or hook result
118
+ # @raise [ClientResponseError] On transport errors or HTTP status >= 400
119
+ # @example Request health with a timeout
120
+ # client.send_request("/api/health", timeout: 10)
121
+ #
122
+ # Raises PocketBase::ClientResponseError on transport or API errors.
123
+ def send_request(path, options = {})
124
+ options = init_send_options(options)
125
+ url = build_url(path)
126
+
127
+ if @before_send
128
+ result = @before_send.call(url, options)
129
+ result = normalize_hook_result(result)
130
+ url = result[:url] if result[:url]
131
+ options = result[:options] if result[:options]
132
+ end
133
+
134
+ query = options.delete(:query)
135
+ if query && !query.empty?
136
+ serialized = Utils.serialize_query_params(query)
137
+ url += (url.include?("?") ? "&" : "?") + serialized unless serialized.empty?
138
+ end
139
+
140
+ method = (options[:method] || "GET").to_s.upcase
141
+ headers = options[:headers] || {}
142
+ body, content_type = prepare_body(options[:body], headers)
143
+ headers = headers.dup
144
+ headers["Content-Type"] = content_type if content_type && !header_present?(headers, "Content-Type")
145
+ timeout = options[:timeout]
146
+
147
+ final_url = url
148
+ response = perform_request(url, method, headers, body, timeout) do |u|
149
+ final_url = u
150
+ end
151
+
152
+ data = parse_response_body(response)
153
+
154
+ if @after_send
155
+ data = @after_send.call(ResponseInfo.new(response, final_url), data, options)
156
+ end
157
+
158
+ if response.code.to_i >= 400
159
+ raise ClientResponseError.new(
160
+ url: final_url,
161
+ status: response.code.to_i,
162
+ response: data.is_a?(Hash) ? data : {},
163
+ original_error: nil
164
+ )
165
+ end
166
+
167
+ data
168
+ rescue ClientResponseError
169
+ raise
170
+ rescue StandardError => e
171
+ raise ClientResponseError.wrap(e, url: url.to_s)
172
+ end
173
+
174
+ # Registers token auto-refresh for superuser sessions, mirroring the
175
+ # js-sdk autoRefreshThreshold behavior: before each request the token is
176
+ # refreshed when it expires within `threshold` seconds, falling back to
177
+ # re-authentication when refresh fails.
178
+ def register_auto_refresh(threshold, refresh_func, reauthenticate_func)
179
+ reset_auto_refresh
180
+
181
+ old_before_send = @before_send
182
+ old_record = @auth_store.record
183
+
184
+ unsub = @auth_store.on_change do |new_token, model|
185
+ if new_token.to_s.empty? || model_id(model) != model_id(old_record) ||
186
+ (collection_id_of(model) || collection_id_of(old_record)) &&
187
+ collection_id_of(model) != collection_id_of(old_record)
188
+ reset_auto_refresh
189
+ end
190
+ end
191
+
192
+ reset_fn = lambda do
193
+ unsub.call
194
+ @before_send = old_before_send
195
+ remove_instance_variable(:@_reset_auto_refresh) if defined?(@_reset_auto_refresh)
196
+ end
197
+ @_reset_auto_refresh = reset_fn
198
+
199
+ @before_send = lambda do |url, send_options|
200
+ old_token = @auth_store.token
201
+ query = send_options[:query] || {}
202
+
203
+ unless query[:autoRefresh] || query["autoRefresh"]
204
+ is_valid = @auth_store.valid?
205
+ if is_valid && Utils.token_expired?(@auth_store.token, threshold)
206
+ begin
207
+ refresh_func.call
208
+ rescue StandardError
209
+ is_valid = false
210
+ end
211
+ end
212
+ reauthenticate_func.call unless is_valid
213
+
214
+ headers = (send_options[:headers] || {}).dup
215
+ headers.each do |k, v|
216
+ if k.to_s.downcase == "authorization" && v == old_token && !@auth_store.token.empty?
217
+ headers[k] = @auth_store.token
218
+ break
219
+ end
220
+ end
221
+ send_options = send_options.merge(headers: headers)
222
+ end
223
+
224
+ if old_before_send
225
+ old_before_send.call(url, send_options)
226
+ else
227
+ { url: url, options: send_options }
228
+ end
229
+ end
230
+ self
231
+ end
232
+
233
+ def reset_auto_refresh
234
+ @_reset_auto_refresh&.call
235
+ self
236
+ end
237
+
238
+ private
239
+
240
+ def model_id(model)
241
+ model.is_a?(Hash) ? (model["id"] || model[:id]) : nil
242
+ end
243
+
244
+ def collection_id_of(model)
245
+ model.is_a?(Hash) ? (model["collectionId"] || model[:collectionId]) : nil
246
+ end
247
+
248
+ def normalize_hook_result(result)
249
+ return {} if result.nil?
250
+ if result.is_a?(Hash) && (result.key?(:url) || result.key?("url") ||
251
+ result.key?(:options) || result.key?("options"))
252
+ return { url: result[:url] || result["url"], options: result[:options] || result["options"] }
253
+ end
254
+
255
+ # legacy behavior: the whole result replaces the options
256
+ { options: result }
257
+ end
258
+
259
+ def init_send_options(options)
260
+ normalized = {}
261
+ (options || {}).each { |k, v| normalized[k.to_sym] = v }
262
+ options = { method: "GET" }.merge(normalized)
263
+
264
+ # merge legacy :params into :query
265
+ query = {}
266
+ query.merge!(options.delete(:params)) if options[:params]
267
+ query.merge!(options.delete(:query)) if options[:query]
268
+
269
+ # move unknown send options into the query (js-sdk compatibility)
270
+ options.keys.each do |key|
271
+ next if KNOWN_SEND_OPTIONS.include?(key.to_sym)
272
+
273
+ query[key] = options.delete(key)
274
+ end
275
+ options[:query] = query
276
+
277
+ headers = {}
278
+ (options[:headers] || {}).each { |k, v| headers[k.to_s] = v }
279
+
280
+ # default json content type unless the body needs multipart
281
+ body = options[:body]
282
+ unless header_present?(headers, "Content-Type") || body.is_a?(MultipartBody) ||
283
+ (body.is_a?(Hash) && Multipart.has_file_field?(body))
284
+ headers["Content-Type"] = "application/json"
285
+ end
286
+
287
+ headers["Accept-Language"] = @lang unless header_present?(headers, "Accept-Language")
288
+
289
+ if @auth_store.token && !@auth_store.token.empty? && !header_present?(headers, "Authorization")
290
+ headers["Authorization"] = @auth_store.token
291
+ end
292
+
293
+ options[:headers] = headers
294
+ options
295
+ end
296
+
297
+ def header_present?(headers, name)
298
+ headers.keys.any? { |k| k.to_s.downcase == name.downcase }
299
+ end
300
+
301
+ def prepare_body(body, headers)
302
+ return [nil, nil] if body.nil?
303
+
304
+ if body.is_a?(MultipartBody)
305
+ return [body.to_s, body.content_type]
306
+ end
307
+
308
+ if body.is_a?(Hash) && Multipart.has_file_field?(body)
309
+ form = Multipart.build(body)
310
+ return [form.to_s, form.content_type]
311
+ end
312
+
313
+ ct = nil
314
+ headers.each { |k, v| ct = v if k.to_s.downcase == "content-type" }
315
+ if (ct.nil? || ct.include?("application/json")) && !body.is_a?(String)
316
+ return [JSON.generate(body), "application/json"]
317
+ end
318
+
319
+ [body.to_s, nil]
320
+ end
321
+
322
+ def parse_response_body(response)
323
+ raw = response.body
324
+ return nil if raw.nil? || raw.empty?
325
+
326
+ JSON.parse(raw)
327
+ rescue StandardError
328
+ {}
329
+ end
330
+
331
+ def perform_request(url, method, headers, body, timeout, redirects = 5, &url_tracker)
332
+ uri = URI.parse(url)
333
+ http = Net::HTTP.new(uri.host, uri.port)
334
+ http.use_ssl = uri.scheme == "https"
335
+ if timeout
336
+ http.open_timeout = timeout
337
+ http.read_timeout = timeout
338
+ end
339
+
340
+ request_class = {
341
+ "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post,
342
+ "PUT" => Net::HTTP::Put, "PATCH" => Net::HTTP::Patch,
343
+ "DELETE" => Net::HTTP::Delete, "HEAD" => Net::HTTP::Head,
344
+ "OPTIONS" => Net::HTTP::Options,
345
+ }[method] || Net::HTTP::Get
346
+
347
+ req = request_class.new(uri.request_uri)
348
+ headers.each { |k, v| req[k.to_s] = v.to_s }
349
+ req.body = body if body
350
+
351
+ response = http.request(req)
352
+
353
+ case response
354
+ when Net::HTTPRedirection
355
+ raise ClientResponseError.new(url: url, status: response.code.to_i, response: {}) if redirects <= 0
356
+
357
+ location = response["location"]
358
+ target = URI.join(url, location)
359
+ new_url = target.to_s
360
+ if [uri.scheme, uri.host, uri.port] != [target.scheme, target.host, target.port]
361
+ headers = headers.reject { |key, _| %w[authorization cookie proxy-authorization].include?(key.to_s.downcase) }
362
+ end
363
+ url_tracker&.call(new_url)
364
+ switch_to_get = (%w[301 302].include?(response.code) && method == "POST") ||
365
+ (response.code == "303" && !%w[GET HEAD].include?(method))
366
+ new_method = switch_to_get ? "GET" : method
367
+ if switch_to_get
368
+ body = nil
369
+ headers = headers.reject { |key, _| %w[content-type content-length transfer-encoding].include?(key.to_s.downcase) }
370
+ end
371
+ perform_request(new_url, new_method, headers, body,
372
+ timeout, redirects - 1, &url_tracker)
373
+ else
374
+ response
375
+ end
376
+ end
377
+ end
378
+
379
+ # Lightweight response view passed to the after_send hook.
380
+ class ResponseInfo
381
+ attr_reader :status, :headers, :url
382
+
383
+ def initialize(net_response, url)
384
+ @status = net_response.code.to_i
385
+ @headers = {}
386
+ net_response.each_header { |k, v| @headers[k] = v }
387
+ @url = url
388
+ end
389
+ end
390
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module PocketBase
6
+ # Wraps and normalizes any error raised by Client#send_request,
7
+ # mirroring the js-sdk ClientResponseError.
8
+ class ClientResponseError < StandardError
9
+ attr_reader :url, :status, :response, :original_error
10
+ attr_accessor :is_abort
11
+
12
+ def initialize(err_data = nil)
13
+ @url = ""
14
+ @status = 0
15
+ @response = {}
16
+ @is_abort = false
17
+ @original_error = nil
18
+
19
+ if err_data.is_a?(Hash)
20
+ @original_error = err_data[:original_error] || err_data["originalError"] || err_data["original_error"]
21
+ @url = err_data[:url] || err_data["url"] || ""
22
+ @status = err_data[:status] || err_data["status"] || 0
23
+ @is_abort = !!(err_data[:is_abort] || err_data["isAbort"] || err_data["is_abort"])
24
+ resp = err_data[:response] || err_data["response"] ||
25
+ err_data[:data] || err_data["data"]
26
+ @response = resp.is_a?(Hash) ? resp : {}
27
+ elsif err_data.is_a?(ClientResponseError)
28
+ @original_error = err_data.original_error
29
+ @url = err_data.url
30
+ @status = err_data.status
31
+ @response = err_data.response
32
+ @is_abort = err_data.is_abort
33
+ elsif !err_data.nil?
34
+ @original_error = err_data
35
+ end
36
+
37
+ @original_error ||= err_data unless err_data.is_a?(ClientResponseError) || err_data.is_a?(Hash)
38
+
39
+ msg = @response["message"] || @response[:message]
40
+ if msg.nil? && err_data.is_a?(Hash)
41
+ msg = err_data[:message] || err_data["message"]
42
+ end
43
+ msg ||= if @is_abort
44
+ "The request was aborted."
45
+ elsif @original_error.is_a?(SystemCallError) || connection_refused?(@original_error)
46
+ "Failed to connect to the PocketBase server."
47
+ else
48
+ "Something went wrong."
49
+ end
50
+
51
+ super(msg.to_s)
52
+ end
53
+
54
+ # Alias for #response (backward compatibility with the js-sdk `.data`).
55
+ def data
56
+ @response
57
+ end
58
+
59
+ def to_h
60
+ {
61
+ "url" => @url,
62
+ "status" => @status,
63
+ "response" => @response,
64
+ "isAbort" => @is_abort,
65
+ "message" => message,
66
+ }
67
+ end
68
+
69
+ # Wraps any raised error into a ClientResponseError (no-op if already one).
70
+ def self.wrap(err, url: "", status: 0)
71
+ return err if err.is_a?(ClientResponseError)
72
+
73
+ data = {}
74
+ unless connection_error?(err)
75
+ data = { "message" => err.message } if err.respond_to?(:message) && err.message && !err.message.empty?
76
+ end
77
+ new({ url: url, status: status, response: data, original_error: err })
78
+ end
79
+
80
+ # Loose check for transport-level connection failures.
81
+ def self.connection_error?(err)
82
+ return true if err.is_a?(SystemCallError)
83
+ return true if err.is_a?(Net::OpenTimeout) || err.is_a?(Net::ReadTimeout)
84
+
85
+ msg = err.respond_to?(:message) ? err.message.to_s : ""
86
+ msg.include?("ECONNREFUSED") || msg.include?("connection refused") ||
87
+ msg.include?("Failed to open TCP connection")
88
+ end
89
+
90
+ private
91
+
92
+ def connection_refused?(err)
93
+ self.class.connection_error?(err)
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module PocketBase
6
+ # Minimal multipart/form-data builder (stdlib only).
7
+ class MultipartBody
8
+ attr_reader :boundary
9
+
10
+ def initialize(boundary = nil)
11
+ @boundary = boundary || "----PocketBaseRuby#{SecureRandom.hex(16)}"
12
+ @parts = [] # each entry: [headers_array, body_string]
13
+ end
14
+
15
+ def add_field(name, value)
16
+ @parts << [
17
+ [["Content-Disposition", %(form-data; name="#{name}")]],
18
+ value.to_s,
19
+ ]
20
+ self
21
+ end
22
+
23
+ def add_json_payload(hash)
24
+ add_field("@jsonPayload", JSON.generate(hash))
25
+ end
26
+
27
+ def add_file(name, filename, content_type, data)
28
+ @parts << [
29
+ [
30
+ ["Content-Disposition", %(form-data; name="#{name}"; filename="#{filename}")],
31
+ ["Content-Type", content_type || "application/octet-stream"],
32
+ ],
33
+ data.to_s.b,
34
+ ]
35
+ self
36
+ end
37
+
38
+ def content_type
39
+ "multipart/form-data; boundary=#{@boundary}"
40
+ end
41
+
42
+ def to_s
43
+ # binary accumulator so arbitrary file bytes never raise
44
+ # Encoding::CompatibilityError when appended
45
+ out = "".b
46
+ @parts.each do |headers, body|
47
+ out << "--#{@boundary}\r\n".b
48
+ headers.each { |k, v| out << "#{k}: #{v}\r\n".b }
49
+ out << "\r\n".b
50
+ out << body.to_s.b
51
+ out << "\r\n".b
52
+ end
53
+ out << "--#{@boundary}--\r\n".b
54
+ out
55
+ end
56
+ end
57
+
58
+ module Multipart
59
+ module_function
60
+
61
+ # A value counts as a file when it is a File/IO object or a
62
+ # { filename:, data: } / { "filename" =>, "data" => } style hash.
63
+ def file_value?(value)
64
+ return true if value.is_a?(File)
65
+ return true if value.is_a?(Hash) && (value.key?(:filename) || value.key?("filename") ||
66
+ value.key?(:data) || value.key?("data"))
67
+ return true if value.respond_to?(:read) && !value.is_a?(String)
68
+
69
+ false
70
+ end
71
+
72
+ def has_file_field?(body)
73
+ return false unless body.is_a?(Hash)
74
+
75
+ body.each_value do |val|
76
+ values = val.is_a?(Array) ? val : [val]
77
+ return true if values.any? { |v| file_value?(v) }
78
+ end
79
+ false
80
+ end
81
+
82
+ # Mirrors js-sdk convertToFormDataIfNeeded: plain Hash/Array values are
83
+ # sent via @jsonPayload parts to avoid implicit string normalization,
84
+ # files become regular multipart file parts.
85
+ def build(body)
86
+ form = MultipartBody.new
87
+ (body || {}).each do |key, val|
88
+ next if val.nil?
89
+
90
+ key = key.to_s
91
+ if val.is_a?(Hash) && !file_value?(val)
92
+ form.add_json_payload({ key => val })
93
+ elsif val.is_a?(Array) && !val.any? { |v| file_value?(v) } && val.any? { |v| v.is_a?(Hash) || v.is_a?(Array) }
94
+ form.add_json_payload({ key => val })
95
+ elsif val.is_a?(Array)
96
+ val.each { |v| append_value(form, key, v) }
97
+ else
98
+ append_value(form, key, val)
99
+ end
100
+ end
101
+ form
102
+ end
103
+
104
+ def append_value(form, key, val)
105
+ if file_value?(val)
106
+ info = file_info(val, key)
107
+ form.add_file(key, info[:filename], info[:content_type], info[:data])
108
+ elsif val.is_a?(Hash) || val.is_a?(Array)
109
+ form.add_json_payload({ key => val })
110
+ else
111
+ form.add_field(key, val)
112
+ end
113
+ end
114
+
115
+ def file_info(value, default_name = "file")
116
+ case value
117
+ when File
118
+ value.binmode
119
+ {
120
+ filename: File.basename(value.path),
121
+ content_type: nil,
122
+ data: value.read,
123
+ }
124
+ when Hash
125
+ {
126
+ filename: value[:filename] || value["filename"] || default_name,
127
+ content_type: value[:content_type] || value["content_type"],
128
+ data: value[:data] || value["data"],
129
+ }
130
+ else # IO-like (StringIO, Tempfile, ...)
131
+ {
132
+ filename: (value.respond_to?(:path) && value.path ? File.basename(value.path.to_s) : default_name),
133
+ content_type: nil,
134
+ data: value.read,
135
+ }
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketBase
4
+ module Services
5
+ # Backup service (port of js-sdk BackupService).
6
+ class BackupService < BaseService
7
+ # Returns the list of all available backup files.
8
+ def get_full_list(options = {})
9
+ opts = send_options({ method: "GET" }, options)
10
+ @client.send_request("/api/backups", opts)
11
+ end
12
+
13
+ # Initializes a new backup. Returns true.
14
+ def create(basename, options = {})
15
+ opts = send_options({ method: "POST", body: { "name" => basename } }, options)
16
+ @client.send_request("/api/backups", opts)
17
+ true
18
+ end
19
+
20
+ # Uploads an existing backup file.
21
+ #
22
+ # pb.backups.upload(file: File.open("backup.zip"))
23
+ # pb.backups.upload("file" => { filename: "b.zip", data: "..." })
24
+ def upload(body_params = nil, options = {})
25
+ opts = send_options({ method: "POST", body: body_params }, options)
26
+ @client.send_request("/api/backups/upload", opts)
27
+ true
28
+ end
29
+
30
+ # Deletes a single backup file. Returns true.
31
+ def delete(key, options = {})
32
+ opts = send_options({ method: "DELETE" }, options)
33
+ @client.send_request("/api/backups/#{Utils.encode_uri_component(key.to_s)}", opts)
34
+ true
35
+ end
36
+
37
+ # Initializes an app data restore from an existing backup. Returns true.
38
+ def restore(key, options = {})
39
+ opts = send_options({ method: "POST" }, options)
40
+ @client.send_request("/api/backups/#{Utils.encode_uri_component(key.to_s)}/restore", opts)
41
+ true
42
+ end
43
+
44
+ # Builds a download URL for a backup using a file token
45
+ # (generate one via pb.files.get_token).
46
+ def get_download_url(token, key)
47
+ @client.build_url(
48
+ "/api/backups/#{Utils.encode_uri_component(key.to_s)}" \
49
+ "?token=#{Utils.encode_uri_component(token.to_s)}"
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end