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.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/lib/supabase/README.md +90 -0
  3. data/lib/supabase/auth/README.md +172 -0
  4. data/lib/supabase/auth/admin_api.rb +218 -0
  5. data/lib/supabase/auth/admin_oauth_api.rb +51 -0
  6. data/lib/supabase/auth/api.rb +125 -0
  7. data/lib/supabase/auth/async/admin_api.rb +36 -0
  8. data/lib/supabase/auth/async/admin_oauth_api.rb +15 -0
  9. data/lib/supabase/auth/async/api.rb +32 -0
  10. data/lib/supabase/auth/async/client.rb +33 -0
  11. data/lib/supabase/auth/async.rb +14 -0
  12. data/lib/supabase/auth/client.rb +1217 -0
  13. data/lib/supabase/auth/constants.rb +32 -0
  14. data/lib/supabase/auth/errors.rb +207 -0
  15. data/lib/supabase/auth/helpers.rb +222 -0
  16. data/lib/supabase/auth/memory_storage.rb +25 -0
  17. data/lib/supabase/auth/storage.rb +19 -0
  18. data/lib/supabase/auth/timer.rb +40 -0
  19. data/lib/supabase/auth/types.rb +517 -0
  20. data/lib/supabase/auth/version.rb +7 -0
  21. data/lib/supabase/auth.rb +19 -0
  22. data/lib/supabase/client.rb +200 -0
  23. data/lib/supabase/client_options.rb +82 -0
  24. data/lib/supabase/functions/README.md +71 -0
  25. data/lib/supabase/functions/async/client.rb +45 -0
  26. data/lib/supabase/functions/async.rb +8 -0
  27. data/lib/supabase/functions/client.rb +174 -0
  28. data/lib/supabase/functions/errors.rb +38 -0
  29. data/lib/supabase/functions/types.rb +37 -0
  30. data/lib/supabase/functions/version.rb +7 -0
  31. data/lib/supabase/functions.rb +11 -0
  32. data/lib/supabase/postgrest/README.md +84 -0
  33. data/lib/supabase/postgrest/async/client.rb +50 -0
  34. data/lib/supabase/postgrest/async.rb +8 -0
  35. data/lib/supabase/postgrest/client.rb +136 -0
  36. data/lib/supabase/postgrest/errors.rb +49 -0
  37. data/lib/supabase/postgrest/request_builder.rb +657 -0
  38. data/lib/supabase/postgrest/types.rb +60 -0
  39. data/lib/supabase/postgrest/utils.rb +24 -0
  40. data/lib/supabase/postgrest/version.rb +7 -0
  41. data/lib/supabase/postgrest.rb +13 -0
  42. data/lib/supabase/realtime/README.md +90 -0
  43. data/lib/supabase/realtime/channel.rb +274 -0
  44. data/lib/supabase/realtime/client.rb +182 -0
  45. data/lib/supabase/realtime/errors.rb +19 -0
  46. data/lib/supabase/realtime/message.rb +38 -0
  47. data/lib/supabase/realtime/presence.rb +136 -0
  48. data/lib/supabase/realtime/push.rb +48 -0
  49. data/lib/supabase/realtime/socket.rb +40 -0
  50. data/lib/supabase/realtime/sockets/async_websocket.rb +175 -0
  51. data/lib/supabase/realtime/sockets/websocket_client_simple.rb +94 -0
  52. data/lib/supabase/realtime/test_socket.rb +65 -0
  53. data/lib/supabase/realtime/transformers.rb +26 -0
  54. data/lib/supabase/realtime/types.rb +70 -0
  55. data/lib/supabase/realtime/version.rb +7 -0
  56. data/lib/supabase/realtime.rb +18 -0
  57. data/lib/supabase/storage/README.md +108 -0
  58. data/lib/supabase/storage/analytics.rb +69 -0
  59. data/lib/supabase/storage/async/client.rb +52 -0
  60. data/lib/supabase/storage/async.rb +8 -0
  61. data/lib/supabase/storage/bucket_api.rb +65 -0
  62. data/lib/supabase/storage/client.rb +80 -0
  63. data/lib/supabase/storage/errors.rb +32 -0
  64. data/lib/supabase/storage/file_api.rb +281 -0
  65. data/lib/supabase/storage/request.rb +63 -0
  66. data/lib/supabase/storage/types.rb +236 -0
  67. data/lib/supabase/storage/utils.rb +35 -0
  68. data/lib/supabase/storage/vectors.rb +189 -0
  69. data/lib/supabase/storage/version.rb +7 -0
  70. data/lib/supabase/storage.rb +17 -0
  71. data/lib/supabase/version.rb +5 -0
  72. data/lib/supabase-auth.rb +3 -0
  73. data/lib/supabase.rb +63 -0
  74. 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