supabase-rb 2.0.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 +7 -0
- data/lib/supabase/README.md +90 -0
- data/lib/supabase/auth/README.md +172 -0
- data/lib/supabase/auth/admin_api.rb +218 -0
- data/lib/supabase/auth/admin_oauth_api.rb +51 -0
- data/lib/supabase/auth/api.rb +125 -0
- data/lib/supabase/auth/async/admin_api.rb +36 -0
- data/lib/supabase/auth/async/admin_oauth_api.rb +15 -0
- data/lib/supabase/auth/async/api.rb +32 -0
- data/lib/supabase/auth/async/client.rb +33 -0
- data/lib/supabase/auth/async.rb +14 -0
- data/lib/supabase/auth/client.rb +1217 -0
- data/lib/supabase/auth/constants.rb +32 -0
- data/lib/supabase/auth/errors.rb +207 -0
- data/lib/supabase/auth/helpers.rb +222 -0
- data/lib/supabase/auth/memory_storage.rb +25 -0
- data/lib/supabase/auth/storage.rb +19 -0
- data/lib/supabase/auth/timer.rb +40 -0
- data/lib/supabase/auth/types.rb +517 -0
- data/lib/supabase/auth/version.rb +7 -0
- data/lib/supabase/auth.rb +19 -0
- data/lib/supabase/client.rb +200 -0
- data/lib/supabase/client_options.rb +82 -0
- data/lib/supabase/functions/README.md +71 -0
- data/lib/supabase/functions/async/client.rb +45 -0
- data/lib/supabase/functions/async.rb +8 -0
- data/lib/supabase/functions/client.rb +174 -0
- data/lib/supabase/functions/errors.rb +38 -0
- data/lib/supabase/functions/types.rb +37 -0
- data/lib/supabase/functions/version.rb +7 -0
- data/lib/supabase/functions.rb +11 -0
- data/lib/supabase/postgrest/README.md +84 -0
- data/lib/supabase/postgrest/async/client.rb +50 -0
- data/lib/supabase/postgrest/async.rb +8 -0
- data/lib/supabase/postgrest/client.rb +136 -0
- data/lib/supabase/postgrest/errors.rb +49 -0
- data/lib/supabase/postgrest/request_builder.rb +657 -0
- data/lib/supabase/postgrest/types.rb +60 -0
- data/lib/supabase/postgrest/utils.rb +24 -0
- data/lib/supabase/postgrest/version.rb +7 -0
- data/lib/supabase/postgrest.rb +13 -0
- data/lib/supabase/realtime/README.md +90 -0
- data/lib/supabase/realtime/channel.rb +274 -0
- data/lib/supabase/realtime/client.rb +182 -0
- data/lib/supabase/realtime/errors.rb +19 -0
- data/lib/supabase/realtime/message.rb +38 -0
- data/lib/supabase/realtime/presence.rb +136 -0
- data/lib/supabase/realtime/push.rb +48 -0
- data/lib/supabase/realtime/socket.rb +40 -0
- data/lib/supabase/realtime/sockets/async_websocket.rb +175 -0
- data/lib/supabase/realtime/sockets/websocket_client_simple.rb +94 -0
- data/lib/supabase/realtime/test_socket.rb +65 -0
- data/lib/supabase/realtime/transformers.rb +26 -0
- data/lib/supabase/realtime/types.rb +70 -0
- data/lib/supabase/realtime/version.rb +7 -0
- data/lib/supabase/realtime.rb +18 -0
- data/lib/supabase/storage/README.md +108 -0
- data/lib/supabase/storage/analytics.rb +69 -0
- data/lib/supabase/storage/async/client.rb +52 -0
- data/lib/supabase/storage/async.rb +8 -0
- data/lib/supabase/storage/bucket_api.rb +65 -0
- data/lib/supabase/storage/client.rb +80 -0
- data/lib/supabase/storage/errors.rb +32 -0
- data/lib/supabase/storage/file_api.rb +281 -0
- data/lib/supabase/storage/request.rb +63 -0
- data/lib/supabase/storage/types.rb +236 -0
- data/lib/supabase/storage/utils.rb +35 -0
- data/lib/supabase/storage/vectors.rb +189 -0
- data/lib/supabase/storage/version.rb +7 -0
- data/lib/supabase/storage.rb +17 -0
- data/lib/supabase/version.rb +5 -0
- data/lib/supabase-auth.rb +3 -0
- data/lib/supabase.rb +63 -0
- metadata +272 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/multipart"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require_relative "request"
|
|
9
|
+
require_relative "types"
|
|
10
|
+
require_relative "utils"
|
|
11
|
+
|
|
12
|
+
module Supabase
|
|
13
|
+
module Storage
|
|
14
|
+
# All file-level operations scoped to one bucket — upload / download / list /
|
|
15
|
+
# remove / move / copy / info / exists, plus signed/public URL helpers.
|
|
16
|
+
# Mirrors storage3's SyncBucketActionsMixin + SyncBucketProxy.
|
|
17
|
+
#
|
|
18
|
+
# Constructed via {Client#from(bucket_id)}.
|
|
19
|
+
class FileApi
|
|
20
|
+
include Request
|
|
21
|
+
|
|
22
|
+
attr_reader :id
|
|
23
|
+
|
|
24
|
+
def initialize(id, base_url, headers, session)
|
|
25
|
+
@id = id
|
|
26
|
+
@base_url = base_url.end_with?("/") ? base_url : "#{base_url}/"
|
|
27
|
+
@headers = headers
|
|
28
|
+
@session = session
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# ----- Upload -----
|
|
32
|
+
|
|
33
|
+
# @param path [String] destination path within the bucket (e.g. "folder/avatar.png")
|
|
34
|
+
# @param file [String, IO, Pathname] bytes / file-like object / path on disk
|
|
35
|
+
# @param content_type [String, nil]
|
|
36
|
+
# @param cache_control [String, Integer, nil] becomes "max-age=<n>"
|
|
37
|
+
# @param upsert [Boolean]
|
|
38
|
+
# @param metadata [Hash, nil] base64-encoded into the x-metadata header
|
|
39
|
+
# @param headers [Hash, nil] extra HTTP headers to send
|
|
40
|
+
# @return [Types::UploadResponse]
|
|
41
|
+
def upload(path, file, content_type: nil, cache_control: nil, upsert: false, metadata: nil, headers: nil)
|
|
42
|
+
upload_or_update(:post, path, file,
|
|
43
|
+
content_type: content_type, cache_control: cache_control,
|
|
44
|
+
upsert: upsert, metadata: metadata, headers: headers)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def update(path, file, content_type: nil, cache_control: nil, metadata: nil, headers: nil)
|
|
48
|
+
# Per Python: PUT never sends x-upsert.
|
|
49
|
+
upload_or_update(:put, path, file,
|
|
50
|
+
content_type: content_type, cache_control: cache_control,
|
|
51
|
+
upsert: false, metadata: metadata, headers: headers, omit_upsert: true)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# ----- Download -----
|
|
55
|
+
|
|
56
|
+
def download(path)
|
|
57
|
+
parts = Utils.relative_path_to_parts(path)
|
|
58
|
+
response = _request(:get, ["object", @id, *parts], raw_response: true)
|
|
59
|
+
response.body
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# ----- List -----
|
|
63
|
+
|
|
64
|
+
# @param prefix [String, nil] folder path to list under
|
|
65
|
+
# @param limit [Integer]
|
|
66
|
+
# @param offset [Integer]
|
|
67
|
+
# @param sort_by [Hash] {column:, order:}
|
|
68
|
+
# @param search [String, nil]
|
|
69
|
+
def list(prefix = nil, limit: nil, offset: nil, sort_by: nil, search: nil)
|
|
70
|
+
body = Types::DEFAULT_SEARCH_OPTIONS.dup
|
|
71
|
+
body["limit"] = limit unless limit.nil?
|
|
72
|
+
body["offset"] = offset unless offset.nil?
|
|
73
|
+
body["sortBy"] = stringify_sort_by(sort_by) if sort_by
|
|
74
|
+
body["search"] = search unless search.nil?
|
|
75
|
+
body["prefix"] = prefix || ""
|
|
76
|
+
_request(:post, ["object", "list", @id], json: body, headers: { "Content-Type" => "application/json" })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Cursor-paginated list. Mirrors storage3's list_v2 — only sends the keys the
|
|
80
|
+
# caller passed (no DEFAULT_SEARCH_OPTIONS merge).
|
|
81
|
+
# @param prefix [String, nil]
|
|
82
|
+
# @param limit [Integer, nil]
|
|
83
|
+
# @param cursor [String, nil] opaque cursor returned by a previous call
|
|
84
|
+
# @param with_delimiter [Boolean, nil] if true, server groups by "/" into folders
|
|
85
|
+
# @param sort_by [Hash, nil] {column:, order:}
|
|
86
|
+
# @return [Types::SearchV2Result]
|
|
87
|
+
def list_v2(prefix: nil, limit: nil, cursor: nil, with_delimiter: nil, sort_by: nil)
|
|
88
|
+
body = {}
|
|
89
|
+
body["prefix"] = prefix unless prefix.nil?
|
|
90
|
+
body["limit"] = limit unless limit.nil?
|
|
91
|
+
body["cursor"] = cursor unless cursor.nil?
|
|
92
|
+
body["with_delimiter"] = with_delimiter unless with_delimiter.nil?
|
|
93
|
+
body["sortBy"] = stringify_sort_by(sort_by) if sort_by
|
|
94
|
+
data = _request(:post, ["object", "list-v2", @id], json: body,
|
|
95
|
+
headers: { "Content-Type" => "application/json" })
|
|
96
|
+
Types::SearchV2Result.from_hash(data)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ----- Remove / Move / Copy / Info / Exists -----
|
|
100
|
+
|
|
101
|
+
def remove(paths)
|
|
102
|
+
_request(:delete, ["object", @id], json: { "prefixes" => Array(paths) })
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def move(from_path, to_path)
|
|
106
|
+
_request(:post, ["object", "move"],
|
|
107
|
+
json: { "bucketId" => @id, "sourceKey" => from_path, "destinationKey" => to_path })
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def copy(from_path, to_path)
|
|
111
|
+
_request(:post, ["object", "copy"],
|
|
112
|
+
json: { "bucketId" => @id, "sourceKey" => from_path, "destinationKey" => to_path })
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def info(path)
|
|
116
|
+
parts = Utils.relative_path_to_parts(path)
|
|
117
|
+
_request(:get, ["object", "info", @id, *parts])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def exists?(path)
|
|
121
|
+
parts = Utils.relative_path_to_parts(path)
|
|
122
|
+
response = _request(:head, ["object", @id, *parts], raw_response: true)
|
|
123
|
+
response.status == 200
|
|
124
|
+
rescue Errors::StorageApiError
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ----- Signed URLs -----
|
|
129
|
+
|
|
130
|
+
# @param expires_in [Integer] seconds until the URL expires
|
|
131
|
+
# @param download [Boolean, String, nil] `true` to force browser download with the
|
|
132
|
+
# original filename, a String to override the filename, or nil to leave inline
|
|
133
|
+
# @return [Hash] { "signedURL" => "...", "signedUrl" => "..." }
|
|
134
|
+
def create_signed_url(path, expires_in:, download: nil, transform: nil)
|
|
135
|
+
json = { "expiresIn" => expires_in.to_s }
|
|
136
|
+
download_query = {}
|
|
137
|
+
if download
|
|
138
|
+
json["download"] = download
|
|
139
|
+
download_query["download"] = download == true ? "" : download
|
|
140
|
+
end
|
|
141
|
+
json["transform"] = transform if transform
|
|
142
|
+
|
|
143
|
+
parts = Utils.relative_path_to_parts(path)
|
|
144
|
+
body = _request(:post, ["object", "sign", @id, *parts], json: json)
|
|
145
|
+
wrap_signed_url(body["signedURL"] || body["signedUrl"], download_query)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def create_signed_urls(paths, expires_in:, download: nil)
|
|
149
|
+
json = { "paths" => Array(paths), "expiresIn" => expires_in.to_s }
|
|
150
|
+
download_query = {}
|
|
151
|
+
if download
|
|
152
|
+
json["download"] = download
|
|
153
|
+
download_query["download"] = download == true ? "" : download
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
items = _request(:post, ["object", "sign", @id], json: json)
|
|
157
|
+
Array(items).map do |item|
|
|
158
|
+
wrapped = wrap_signed_url(item["signedURL"] || item["signedUrl"], download_query)
|
|
159
|
+
{
|
|
160
|
+
"error" => item["error"],
|
|
161
|
+
"path" => item["path"],
|
|
162
|
+
"signedURL" => wrapped["signedURL"],
|
|
163
|
+
"signedUrl" => wrapped["signedURL"]
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def get_public_url(path, download: nil, transform: nil)
|
|
169
|
+
download_query = {}
|
|
170
|
+
if download
|
|
171
|
+
download_query["download"] = download == true ? "" : download
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
render_path = transform ? %w[render image] : %w[object]
|
|
175
|
+
transform_query = transform ? transform.transform_keys(&:to_s).transform_values(&:to_s) : {}
|
|
176
|
+
query = download_query.merge(transform_query)
|
|
177
|
+
|
|
178
|
+
parts = Utils.relative_path_to_parts(path)
|
|
179
|
+
Utils.join_url(@base_url, [*render_path, "public", @id, *parts], query)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def create_signed_upload_url(path, upsert: nil)
|
|
183
|
+
headers = upsert.nil? ? {} : { "x-upsert" => upsert.to_s }
|
|
184
|
+
parts = Utils.relative_path_to_parts(path)
|
|
185
|
+
body = _request(:post, ["object", "upload", "sign", @id, *parts], headers: headers)
|
|
186
|
+
|
|
187
|
+
signed_url = "#{@base_url.chomp('/')}#{body['url']}"
|
|
188
|
+
token = URI.decode_www_form(URI.parse(signed_url).query.to_s).to_h["token"]
|
|
189
|
+
raise Errors::StorageError, "No token sent by the API" if token.nil? || token.empty?
|
|
190
|
+
|
|
191
|
+
Types::SignedUploadURL.new(signed_url: signed_url, token: token, path: path)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def upload_to_signed_url(path, token:, file:, content_type: nil, cache_control: nil, metadata: nil, headers: nil)
|
|
195
|
+
parts = Utils.relative_path_to_parts(path)
|
|
196
|
+
send_multipart(:put, ["object", "upload", "sign", @id, *parts],
|
|
197
|
+
file: file, filename: parts.last, content_type: content_type,
|
|
198
|
+
cache_control: cache_control, upsert: nil, metadata: metadata,
|
|
199
|
+
extra_headers: headers, query: { "token" => token })
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def upload_or_update(method, path, file, content_type:, cache_control:, upsert:, metadata:, headers:, omit_upsert: false)
|
|
205
|
+
parts = Utils.relative_path_to_parts(path)
|
|
206
|
+
send_multipart(method, ["object", @id, *parts],
|
|
207
|
+
file: file, filename: parts.last,
|
|
208
|
+
content_type: content_type, cache_control: cache_control,
|
|
209
|
+
upsert: omit_upsert ? nil : upsert,
|
|
210
|
+
metadata: metadata, extra_headers: headers)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def send_multipart(method, segments, file:, filename:, content_type:, cache_control:, upsert:, metadata:, extra_headers:, query: nil)
|
|
214
|
+
request_headers = {}
|
|
215
|
+
request_headers["cache-control"] = "max-age=#{cache_control}" if cache_control
|
|
216
|
+
request_headers["x-upsert"] = upsert.to_s unless upsert.nil?
|
|
217
|
+
request_headers.merge!(extra_headers) if extra_headers
|
|
218
|
+
|
|
219
|
+
if metadata
|
|
220
|
+
metadata_json = JSON.generate(metadata)
|
|
221
|
+
request_headers["x-metadata"] = [metadata_json].pack("m0")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
ctype = content_type || Types::DEFAULT_FILE_OPTIONS["content-type"]
|
|
225
|
+
upload_io = build_upload_io(file, filename, ctype)
|
|
226
|
+
|
|
227
|
+
url = Utils.join_url(@base_url, segments, query)
|
|
228
|
+
merged_headers = @headers.merge(request_headers)
|
|
229
|
+
# Faraday Multipart writes its own multipart Content-Type with boundary; drop ours
|
|
230
|
+
# so it can be regenerated.
|
|
231
|
+
merged_headers.delete("Content-Type")
|
|
232
|
+
merged_headers.delete("content-type")
|
|
233
|
+
|
|
234
|
+
form = { file: upload_io }
|
|
235
|
+
form[:cacheControl] = cache_control.to_s if cache_control
|
|
236
|
+
form[:metadata] = JSON.generate(metadata) if metadata
|
|
237
|
+
|
|
238
|
+
# @session is built by Client with `f.request :multipart`, so handing it a
|
|
239
|
+
# Hash body lets the middleware multipart-encode and add the boundary header.
|
|
240
|
+
response = @session.run_request(method, url, form, merged_headers)
|
|
241
|
+
raise_for_status(response)
|
|
242
|
+
parsed = parse_json(response.body) || {}
|
|
243
|
+
Types::UploadResponse.from_hash(path: segments[2..].join("/"), key: parsed["Key"])
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def build_upload_io(file, filename, content_type)
|
|
247
|
+
case file
|
|
248
|
+
when String
|
|
249
|
+
# Treat as raw bytes/text, not a path — call sites that want path semantics
|
|
250
|
+
# pass a Pathname or open the File themselves (matches storage3's bytes/IO contract).
|
|
251
|
+
Faraday::Multipart::FilePart.new(StringIO.new(file), content_type, filename)
|
|
252
|
+
when Pathname
|
|
253
|
+
Faraday::Multipart::FilePart.new(file.to_s, content_type, filename)
|
|
254
|
+
when IO, StringIO
|
|
255
|
+
Faraday::Multipart::FilePart.new(file, content_type, filename)
|
|
256
|
+
else
|
|
257
|
+
if file.respond_to?(:read)
|
|
258
|
+
Faraday::Multipart::FilePart.new(file, content_type, filename)
|
|
259
|
+
else
|
|
260
|
+
raise ArgumentError, "upload `file` must be a String, IO, or Pathname (got #{file.class})"
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def wrap_signed_url(signed_url, download_query)
|
|
266
|
+
return { "signedURL" => nil, "signedUrl" => nil } if signed_url.nil?
|
|
267
|
+
|
|
268
|
+
# storage3 strips the leading "/" before joining; we mirror that.
|
|
269
|
+
cleaned = signed_url.sub(%r{^/}, "")
|
|
270
|
+
full = "#{@base_url.chomp('/')}/#{cleaned}"
|
|
271
|
+
full = "#{full}#{full.include?('?') ? '&' : '?'}#{URI.encode_www_form(download_query)}" unless download_query.empty?
|
|
272
|
+
{ "signedURL" => full, "signedUrl" => full }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def stringify_sort_by(sort_by)
|
|
276
|
+
h = sort_by.transform_keys(&:to_s)
|
|
277
|
+
{ "column" => h["column"], "order" => h["order"] }
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "faraday"
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Supabase
|
|
9
|
+
module Storage
|
|
10
|
+
# Mixin used by BucketApi and FileApi. Holds the shared HTTP wiring: build a Faraday
|
|
11
|
+
# request, raise StorageApiError on non-2xx, and parse JSON bodies.
|
|
12
|
+
#
|
|
13
|
+
# Including classes must expose @session (Faraday::Connection), @base_url (String
|
|
14
|
+
# ending in "/"), and @headers (Hash).
|
|
15
|
+
module Request
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def _request(method, segments, json: nil, headers: nil, query: nil, body: nil, raw_response: false)
|
|
19
|
+
url = Utils.join_url(@base_url, segments, query)
|
|
20
|
+
merged_headers = @headers.merge(headers || {})
|
|
21
|
+
|
|
22
|
+
response = @session.run_request(method.to_s.downcase.to_sym, url, nil, merged_headers) do |req|
|
|
23
|
+
if json
|
|
24
|
+
req.headers["Content-Type"] ||= "application/json"
|
|
25
|
+
req.body = JSON.generate(json)
|
|
26
|
+
elsif body
|
|
27
|
+
req.body = body
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
raise_for_status(response)
|
|
32
|
+
return response if raw_response
|
|
33
|
+
|
|
34
|
+
parse_json(response.body)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def raise_for_status(response)
|
|
38
|
+
return if (200..299).include?(response.status)
|
|
39
|
+
|
|
40
|
+
parsed = parse_json_safe(response.body) || {}
|
|
41
|
+
raise Errors::StorageApiError.new(
|
|
42
|
+
parsed["message"] || "HTTP #{response.status}",
|
|
43
|
+
code: parsed["error"] || "InternalError",
|
|
44
|
+
status: parsed["statusCode"] || response.status
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_json(body)
|
|
49
|
+
return nil if body.nil? || body.empty?
|
|
50
|
+
|
|
51
|
+
JSON.parse(body)
|
|
52
|
+
rescue JSON::ParserError
|
|
53
|
+
body
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse_json_safe(body)
|
|
57
|
+
JSON.parse(body) if body && !body.empty?
|
|
58
|
+
rescue JSON::ParserError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Storage
|
|
5
|
+
module Types
|
|
6
|
+
# Matches storage3's DEFAULT_SEARCH_OPTIONS — sent in the list() body when the
|
|
7
|
+
# caller doesn't override individual fields.
|
|
8
|
+
DEFAULT_SEARCH_OPTIONS = {
|
|
9
|
+
"limit" => 100,
|
|
10
|
+
"offset" => 0,
|
|
11
|
+
"sortBy" => { "column" => "name", "order" => "asc" }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
# Matches storage3's DEFAULT_FILE_OPTIONS — base headers/form fields for upload.
|
|
15
|
+
DEFAULT_FILE_OPTIONS = {
|
|
16
|
+
"cache-control" => "3600",
|
|
17
|
+
"content-type" => "text/plain;charset=UTF-8",
|
|
18
|
+
"x-upsert" => "false"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Returned by list_buckets / get_bucket. Mirrors storage3's BaseBucket fields.
|
|
22
|
+
Bucket = Struct.new(
|
|
23
|
+
:id, :name, :owner, :public, :file_size_limit, :allowed_mime_types,
|
|
24
|
+
:created_at, :updated_at, :type,
|
|
25
|
+
keyword_init: true
|
|
26
|
+
) do
|
|
27
|
+
def self.from_hash(hash)
|
|
28
|
+
return nil if hash.nil?
|
|
29
|
+
|
|
30
|
+
h = hash.transform_keys(&:to_s)
|
|
31
|
+
new(
|
|
32
|
+
id: h["id"],
|
|
33
|
+
name: h["name"],
|
|
34
|
+
owner: h["owner"],
|
|
35
|
+
public: h["public"],
|
|
36
|
+
file_size_limit: h["file_size_limit"],
|
|
37
|
+
allowed_mime_types: h["allowed_mime_types"],
|
|
38
|
+
created_at: h["created_at"],
|
|
39
|
+
updated_at: h["updated_at"],
|
|
40
|
+
type: h["type"]
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returned by upload/update. Python exposes :path/:full_path/:fullPath; we keep
|
|
46
|
+
# both snake_case and camelCase aliases so call sites that follow Python docs work.
|
|
47
|
+
UploadResponse = Struct.new(:path, :full_path, :key, keyword_init: true) do
|
|
48
|
+
alias_method :fullPath, :full_path # rubocop:disable Naming/MethodName
|
|
49
|
+
|
|
50
|
+
def self.from_hash(path:, key:)
|
|
51
|
+
new(path: path, full_path: key, key: key)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returned by create_signed_upload_url.
|
|
56
|
+
SignedUploadURL = Struct.new(:signed_url, :token, :path, keyword_init: true) do
|
|
57
|
+
alias_method :signedUrl, :signed_url # rubocop:disable Naming/MethodName
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# --- list_v2 -----------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
SearchV2Object = Struct.new(:id, :name, :updated_at, :created_at, :metadata, :key, keyword_init: true) do
|
|
63
|
+
def self.from_hash(hash)
|
|
64
|
+
return nil if hash.nil?
|
|
65
|
+
|
|
66
|
+
h = hash.transform_keys(&:to_s)
|
|
67
|
+
new(id: h["id"], name: h["name"],
|
|
68
|
+
updated_at: h["updated_at"], created_at: h["created_at"],
|
|
69
|
+
metadata: h["metadata"], key: h["key"])
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
SearchV2Folder = Struct.new(:key, :name, :created_at, :updated_at, keyword_init: true) do
|
|
74
|
+
def self.from_hash(hash)
|
|
75
|
+
return nil if hash.nil?
|
|
76
|
+
|
|
77
|
+
h = hash.transform_keys(&:to_s)
|
|
78
|
+
new(key: h["key"], name: h["name"],
|
|
79
|
+
created_at: h["created_at"], updated_at: h["updated_at"])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
SearchV2Result = Struct.new(:has_next, :folders, :objects, :next_cursor, keyword_init: true) do
|
|
84
|
+
alias_method :hasNext, :has_next # rubocop:disable Naming/MethodName
|
|
85
|
+
alias_method :nextCursor, :next_cursor # rubocop:disable Naming/MethodName
|
|
86
|
+
|
|
87
|
+
def self.from_hash(hash)
|
|
88
|
+
return nil if hash.nil?
|
|
89
|
+
|
|
90
|
+
h = hash.transform_keys(&:to_s)
|
|
91
|
+
new(
|
|
92
|
+
has_next: h["hasNext"],
|
|
93
|
+
folders: Array(h["folders"]).map { |f| SearchV2Folder.from_hash(f) },
|
|
94
|
+
objects: Array(h["objects"]).map { |o| SearchV2Object.from_hash(o) },
|
|
95
|
+
next_cursor: h["nextCursor"]
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# --- Analytics ---------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
# Returned by analytics.create / analytics.list. Mirrors storage3's
|
|
103
|
+
# `AnalyticsBucket` pydantic model.
|
|
104
|
+
AnalyticsBucket = Struct.new(:name, :type, :format, :created_at, :updated_at, keyword_init: true) do
|
|
105
|
+
def self.from_hash(hash)
|
|
106
|
+
return nil if hash.nil?
|
|
107
|
+
|
|
108
|
+
h = hash.transform_keys(&:to_s)
|
|
109
|
+
new(name: h["name"], type: h["type"], format: h["format"],
|
|
110
|
+
created_at: h["created_at"], updated_at: h["updated_at"])
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
AnalyticsBucketDeleteResponse = Struct.new(:message, keyword_init: true) do
|
|
115
|
+
def self.from_hash(hash)
|
|
116
|
+
return nil if hash.nil?
|
|
117
|
+
|
|
118
|
+
new(message: hash.transform_keys(&:to_s)["message"])
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# --- Vectors -----------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
VectorBucketEncryptionConfiguration = Struct.new(:kms_key_arn, :sse_type, keyword_init: true) do
|
|
125
|
+
def self.from_hash(hash)
|
|
126
|
+
return nil if hash.nil?
|
|
127
|
+
|
|
128
|
+
h = hash.transform_keys(&:to_s)
|
|
129
|
+
new(kms_key_arn: h["kmsKeyArn"], sse_type: h["sseType"])
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
VectorBucket = Struct.new(:vector_bucket_name, :creation_time, :encryption_configuration, keyword_init: true) do
|
|
134
|
+
def self.from_hash(hash)
|
|
135
|
+
return nil if hash.nil?
|
|
136
|
+
|
|
137
|
+
h = hash.transform_keys(&:to_s)
|
|
138
|
+
new(
|
|
139
|
+
vector_bucket_name: h["vectorBucketName"],
|
|
140
|
+
creation_time: h["creationTime"],
|
|
141
|
+
encryption_configuration: VectorBucketEncryptionConfiguration.from_hash(h["encryptionConfiguration"])
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
GetVectorBucketResponse = Struct.new(:vector_bucket, keyword_init: true) do
|
|
147
|
+
def self.from_hash(hash)
|
|
148
|
+
return nil if hash.nil?
|
|
149
|
+
|
|
150
|
+
new(vector_bucket: VectorBucket.from_hash(hash.transform_keys(&:to_s)["vectorBucket"]))
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
ListVectorBucketsResponse = Struct.new(:vector_buckets, :next_token, keyword_init: true) do
|
|
155
|
+
def self.from_hash(hash)
|
|
156
|
+
return nil if hash.nil?
|
|
157
|
+
|
|
158
|
+
h = hash.transform_keys(&:to_s)
|
|
159
|
+
items = Array(h["vectorBuckets"]).map { |b| { name: b["vectorBucketName"] } }
|
|
160
|
+
new(vector_buckets: items, next_token: h["nextToken"])
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
VectorIndex = Struct.new(:index_name, :bucket_name, :data_type, :dimension,
|
|
165
|
+
:distance_metric, :metadata, :creation_time, keyword_init: true) do
|
|
166
|
+
def self.from_hash(hash)
|
|
167
|
+
return nil if hash.nil?
|
|
168
|
+
|
|
169
|
+
h = hash.transform_keys(&:to_s)
|
|
170
|
+
new(
|
|
171
|
+
index_name: h["indexName"],
|
|
172
|
+
bucket_name: h["vectorBucketName"],
|
|
173
|
+
data_type: h["dataType"],
|
|
174
|
+
dimension: h["dimension"],
|
|
175
|
+
distance_metric: h["distanceMetric"],
|
|
176
|
+
metadata: h["metadataConfiguration"],
|
|
177
|
+
creation_time: h["creationTime"]
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
GetVectorIndexResponse = Struct.new(:index, keyword_init: true) do
|
|
183
|
+
def self.from_hash(hash)
|
|
184
|
+
return nil if hash.nil?
|
|
185
|
+
|
|
186
|
+
new(index: VectorIndex.from_hash(hash.transform_keys(&:to_s)["index"]))
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
ListVectorIndexesResponse = Struct.new(:indexes, :next_token, keyword_init: true) do
|
|
191
|
+
def self.from_hash(hash)
|
|
192
|
+
return nil if hash.nil?
|
|
193
|
+
|
|
194
|
+
h = hash.transform_keys(&:to_s)
|
|
195
|
+
indexes = Array(h["indexes"]).map { |i| { index_name: i["indexName"] } }
|
|
196
|
+
new(indexes: indexes, next_token: h["nextToken"])
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Matched/returned vector. Mirrors `VectorMatch` in storage3.
|
|
201
|
+
VectorMatch = Struct.new(:key, :data, :distance, :metadata, keyword_init: true) do
|
|
202
|
+
def self.from_hash(hash)
|
|
203
|
+
return nil if hash.nil?
|
|
204
|
+
|
|
205
|
+
h = hash.transform_keys(&:to_s)
|
|
206
|
+
new(key: h["key"], data: h["data"], distance: h["distance"], metadata: h["metadata"])
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
GetVectorsResponse = Struct.new(:vectors, keyword_init: true) do
|
|
211
|
+
def self.from_hash(hash)
|
|
212
|
+
return nil if hash.nil?
|
|
213
|
+
|
|
214
|
+
new(vectors: Array(hash.transform_keys(&:to_s)["vectors"]).map { |v| VectorMatch.from_hash(v) })
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
ListVectorsResponse = Struct.new(:vectors, :next_token, keyword_init: true) do
|
|
219
|
+
def self.from_hash(hash)
|
|
220
|
+
return nil if hash.nil?
|
|
221
|
+
|
|
222
|
+
h = hash.transform_keys(&:to_s)
|
|
223
|
+
new(vectors: Array(h["vectors"]).map { |v| VectorMatch.from_hash(v) }, next_token: h["nextToken"])
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
QueryVectorsResponse = Struct.new(:vectors, keyword_init: true) do
|
|
228
|
+
def self.from_hash(hash)
|
|
229
|
+
return nil if hash.nil?
|
|
230
|
+
|
|
231
|
+
new(vectors: Array(hash.transform_keys(&:to_s)["vectors"]).map { |v| VectorMatch.from_hash(v) })
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Supabase
|
|
6
|
+
module Storage
|
|
7
|
+
module Utils
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Splits a relative storage path into its path segments, dropping a leading
|
|
11
|
+
# `/` if the caller supplied one. Mirrors storage3.utils.relative_path_to_parts.
|
|
12
|
+
#
|
|
13
|
+
# relative_path_to_parts("folder/avatar.png") # => ["folder", "avatar.png"]
|
|
14
|
+
# relative_path_to_parts("/folder/x.png") # => ["folder", "x.png"]
|
|
15
|
+
def relative_path_to_parts(path)
|
|
16
|
+
path.to_s.split("/").reject(&:empty?)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# URL-encode each path segment so user-supplied filenames don't break the URL.
|
|
20
|
+
def encode_segments(parts)
|
|
21
|
+
parts.map { |p| URI.encode_www_form_component(p) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Join the (already-trailing-slashed) base URL with the given path segments and
|
|
25
|
+
# an optional query Hash. Used so `_request` never has to concat strings by hand.
|
|
26
|
+
def join_url(base_url, segments, query = nil)
|
|
27
|
+
path = encode_segments(segments).join("/")
|
|
28
|
+
url = "#{base_url.chomp('/')}/#{path}"
|
|
29
|
+
return url if query.nil? || query.empty?
|
|
30
|
+
|
|
31
|
+
"#{url}?#{URI.encode_www_form(query)}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|