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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +21 -0
- data/README.md +275 -0
- data/lib/pocketbase/auth_store.rb +224 -0
- data/lib/pocketbase/client.rb +390 -0
- data/lib/pocketbase/client_response_error.rb +96 -0
- data/lib/pocketbase/multipart.rb +139 -0
- data/lib/pocketbase/services/backup_service.rb +54 -0
- data/lib/pocketbase/services/base_service.rb +49 -0
- data/lib/pocketbase/services/batch_service.rb +143 -0
- data/lib/pocketbase/services/collection_service.rb +52 -0
- data/lib/pocketbase/services/cron_service.rb +21 -0
- data/lib/pocketbase/services/crud_service.rb +113 -0
- data/lib/pocketbase/services/file_service.rb +39 -0
- data/lib/pocketbase/services/health_service.rb +14 -0
- data/lib/pocketbase/services/log_service.rb +42 -0
- data/lib/pocketbase/services/realtime_service.rb +379 -0
- data/lib/pocketbase/services/record_service.rb +367 -0
- data/lib/pocketbase/services/settings_service.rb +50 -0
- data/lib/pocketbase/services/sql_service.rb +14 -0
- data/lib/pocketbase/utils.rb +146 -0
- data/lib/pocketbase/version.rb +5 -0
- data/lib/pocketbase.rb +35 -0
- metadata +72 -0
|
@@ -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
|