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,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ require_relative "auth"
6
+ require_relative "postgrest"
7
+ require_relative "storage"
8
+ require_relative "functions"
9
+ require_relative "realtime"
10
+
11
+ module Supabase
12
+ # Top-level client that combines every sub-library behind one object, mirroring
13
+ # supabase-py's `supabase.create_client()`.
14
+ #
15
+ # client = Supabase.create_client(
16
+ # supabase_url: "https://project.supabase.co",
17
+ # supabase_key: ENV["SUPABASE_ANON_KEY"]
18
+ # )
19
+ #
20
+ # client.auth.sign_in_with_password(email:, password:)
21
+ # users = client.from("users").select("*").execute
22
+ # client.storage.from("avatars").upload("a.png", bytes)
23
+ # client.functions.invoke("hello-world", body: { name: "Ada" })
24
+ # ch = client.realtime.channel("realtime:public:users")
25
+ #
26
+ # Sub-clients are built lazily and memoized. Pass `async: true` to swap in the
27
+ # async-http-faraday variants for Auth / Postgrest / Storage / Functions; the
28
+ # Realtime client is transport-agnostic and ships sync regardless (a real WS
29
+ # transport is wired in by the caller — see lib/supabase/realtime/socket.rb).
30
+ class Client
31
+ attr_reader :supabase_url, :supabase_key, :options, :headers
32
+
33
+ def initialize(supabase_url:, supabase_key:, options: {}, async: false)
34
+ # Use Supabase::SupabaseException once defined; fall back to ArgumentError
35
+ # during early require cycles. Matches supabase-py's contract.
36
+ err = defined?(Supabase::SupabaseException) ? Supabase::SupabaseException : ArgumentError
37
+ raise err, "supabase_url is required" if supabase_url.to_s.empty?
38
+ raise err, "supabase_key is required" if supabase_key.to_s.empty?
39
+ raise err, "Invalid URL" unless supabase_url.to_s.match?(%r{^https?://.+})
40
+
41
+ @supabase_url = supabase_url.to_s.chomp("/")
42
+ @supabase_key = supabase_key
43
+ @options = options || {}
44
+ @async = async
45
+
46
+ configured_headers =
47
+ if @options.is_a?(Supabase::ClientOptions)
48
+ @options.headers
49
+ else
50
+ @options[:global]&.dig(:headers) || @options.dig("global", "headers") || {}
51
+ end
52
+
53
+ @headers = {
54
+ "apikey" => @supabase_key,
55
+ "Authorization" => "Bearer #{@supabase_key}"
56
+ }.merge(configured_headers || {})
57
+ end
58
+
59
+ def async?
60
+ @async
61
+ end
62
+
63
+ # --- Sub-clients ---------------------------------------------------------
64
+
65
+ def auth
66
+ @auth ||= auth_class.new(url: rest_url_for("auth/v1"), headers: @headers, **sub_options(:auth))
67
+ end
68
+
69
+ def storage
70
+ @storage ||= storage_class.new(base_url: rest_url_for("storage/v1"), headers: @headers,
71
+ **sub_options(:storage))
72
+ end
73
+
74
+ def functions
75
+ @functions ||= functions_class.new(base_url: rest_url_for("functions/v1"), headers: @headers,
76
+ **sub_options(:functions))
77
+ end
78
+
79
+ def realtime
80
+ @realtime ||= Realtime::Client.new(
81
+ url: realtime_url,
82
+ params: { "apikey" => @supabase_key, "access_token" => @supabase_key },
83
+ **sub_options(:realtime)
84
+ )
85
+ end
86
+
87
+ # PostgREST is the only sub-library where the public API is reached via a
88
+ # bare method on the umbrella (`client.from('users')`) rather than a named
89
+ # accessor. We expose both for explicitness.
90
+ def postgrest
91
+ @postgrest ||= postgrest_class.new(base_url: rest_url_for("rest/v1"), headers: @headers,
92
+ **sub_options(:postgrest))
93
+ end
94
+
95
+ def from(table)
96
+ postgrest.from(table)
97
+ end
98
+
99
+ def rpc(func, params = {}, **opts)
100
+ postgrest.rpc(func, params, **opts)
101
+ end
102
+
103
+ def schema(name)
104
+ @postgrest = postgrest.schema(name)
105
+ self
106
+ end
107
+
108
+ # --- Shared auth context -------------------------------------------------
109
+
110
+ # Update the Authorization header used by every sub-client. Useful after
111
+ # auth.sign_in returns a fresh JWT — the apikey stays the same but the
112
+ # bearer token becomes the user's access token.
113
+ def set_auth(token)
114
+ @headers["Authorization"] = "Bearer #{token || @supabase_key}"
115
+ # Reset memoized sub-clients so they pick up the new header on next access.
116
+ # Realtime gets its own pathway (set_auth pushes access_token frames).
117
+ @auth = nil
118
+ @storage = nil
119
+ @functions = nil
120
+ @postgrest = nil
121
+ @realtime&.set_auth(token)
122
+ self
123
+ end
124
+
125
+ private
126
+
127
+ def auth_class
128
+ @async ? require_async_class("auth", "Async::Client") : Auth::Client
129
+ end
130
+
131
+ def postgrest_class
132
+ @async ? require_async_class("postgrest", "Async::Client") : Postgrest::Client
133
+ end
134
+
135
+ def storage_class
136
+ @async ? require_async_class("storage", "Async::Client") : Storage::Client
137
+ end
138
+
139
+ def functions_class
140
+ @async ? require_async_class("functions", "Async::Client") : Functions::Client
141
+ end
142
+
143
+ # Loads e.g. "supabase/postgrest/async" only when async: true so sync users
144
+ # never pull in async-http-faraday.
145
+ def require_async_class(sub, class_name)
146
+ require "supabase/#{sub}/async"
147
+ mod = const_get_from_string("Supabase::#{sub.capitalize}::#{class_name}")
148
+ mod
149
+ end
150
+
151
+ def const_get_from_string(path)
152
+ path.split("::").reduce(Object) { |m, name| m.const_get(name) }
153
+ end
154
+
155
+ def rest_url_for(suffix)
156
+ "#{@supabase_url}/#{suffix}"
157
+ end
158
+
159
+ # Realtime uses wss:// against the project host. The realtime path is /realtime/v1.
160
+ def realtime_url
161
+ uri = URI.parse(@supabase_url)
162
+ scheme = uri.scheme == "https" ? "wss" : "ws"
163
+ port = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
164
+ "#{scheme}://#{uri.host}#{port}/realtime/v1"
165
+ end
166
+
167
+ def sub_options(key)
168
+ return options_from_struct(key) if @options.is_a?(Supabase::ClientOptions)
169
+
170
+ (@options[key] || @options[key.to_s] || {}).transform_keys(&:to_sym)
171
+ end
172
+
173
+ # Translate a ClientOptions struct into the per-sub kwargs each sub-client
174
+ # accepts. We don't try to surface every field — only the ones the existing
175
+ # sub-clients actually take today.
176
+ def options_from_struct(key)
177
+ o = @options
178
+ case key
179
+ when :auth
180
+ { auto_refresh_token: o.auto_refresh_token, persist_session: o.persist_session,
181
+ storage: o.storage, flow_type: o.flow_type }.compact
182
+ when :postgrest
183
+ { schema: o.schema, timeout: o.postgrest_client_timeout, http_client: o.http_client }.compact
184
+ when :storage
185
+ { timeout: o.storage_client_timeout, http_client: o.http_client }.compact
186
+ when :functions
187
+ { timeout: o.function_client_timeout, http_client: o.http_client }.compact
188
+ when :realtime
189
+ o.realtime.is_a?(Hash) ? o.realtime.transform_keys(&:to_sym) : {}
190
+ else
191
+ {}
192
+ end
193
+ end
194
+ end
195
+
196
+ # Factory that matches supabase-py's `supabase.create_client()` signature.
197
+ def self.create_client(supabase_url:, supabase_key:, options: {}, async: false)
198
+ Client.new(supabase_url: supabase_url, supabase_key: supabase_key, options: options, async: async)
199
+ end
200
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+
5
+ module Supabase
6
+ # Structured options passed to {Supabase.create_client} / {Supabase::Client}.
7
+ # Mirrors supabase-py's `SyncClientOptions` / `AsyncClientOptions`. The Ruby
8
+ # port uses one class because the sync/async split is decided at runtime via
9
+ # the `async:` flag on the umbrella client.
10
+ #
11
+ # opts = Supabase::ClientOptions.new(
12
+ # schema: "public",
13
+ # headers: { "X-Tenant" => "acme" },
14
+ # auto_refresh_token: true,
15
+ # persist_session: true,
16
+ # postgrest_client_timeout: 30,
17
+ # storage_client_timeout: 20,
18
+ # function_client_timeout: 10,
19
+ # flow_type: "pkce"
20
+ # )
21
+ #
22
+ # Supabase.create_client(supabase_url: url, supabase_key: key, options: opts)
23
+ class ClientOptions
24
+ DEFAULT_POSTGREST_TIMEOUT = 120
25
+ DEFAULT_STORAGE_TIMEOUT = 20
26
+ DEFAULT_FUNCTIONS_TIMEOUT = 60
27
+
28
+ DEFAULT_HEADERS = { "X-Client-Info" => "supabase-rb/#{Supabase::VERSION}" }.freeze
29
+
30
+ attr_accessor :schema, :headers, :auto_refresh_token, :persist_session,
31
+ :realtime, :postgrest_client_timeout, :storage_client_timeout,
32
+ :function_client_timeout, :flow_type, :storage, :http_client
33
+
34
+ def initialize(schema: "public",
35
+ headers: nil,
36
+ auto_refresh_token: true,
37
+ persist_session: true,
38
+ realtime: nil,
39
+ postgrest_client_timeout: DEFAULT_POSTGREST_TIMEOUT,
40
+ storage_client_timeout: DEFAULT_STORAGE_TIMEOUT,
41
+ function_client_timeout: DEFAULT_FUNCTIONS_TIMEOUT,
42
+ flow_type: "pkce",
43
+ storage: nil,
44
+ http_client: nil)
45
+ @schema = schema
46
+ @headers = DEFAULT_HEADERS.merge(headers || {})
47
+ @auto_refresh_token = auto_refresh_token
48
+ @persist_session = persist_session
49
+ @realtime = realtime
50
+ @postgrest_client_timeout = postgrest_client_timeout
51
+ @storage_client_timeout = storage_client_timeout
52
+ @function_client_timeout = function_client_timeout
53
+ @flow_type = flow_type
54
+ @storage = storage
55
+ @http_client = http_client
56
+ end
57
+
58
+ # Returns a new ClientOptions with the given fields overridden. Mirrors
59
+ # supabase-py's `replace()` — handy because dataclasses there are frozen
60
+ # at the field-level and we want the same "build then derive" ergonomics.
61
+ def replace(**overrides)
62
+ attrs = to_h.merge(overrides)
63
+ self.class.new(**attrs)
64
+ end
65
+
66
+ def to_h
67
+ {
68
+ schema: @schema,
69
+ headers: @headers,
70
+ auto_refresh_token: @auto_refresh_token,
71
+ persist_session: @persist_session,
72
+ realtime: @realtime,
73
+ postgrest_client_timeout: @postgrest_client_timeout,
74
+ storage_client_timeout: @storage_client_timeout,
75
+ function_client_timeout: @function_client_timeout,
76
+ flow_type: @flow_type,
77
+ storage: @storage,
78
+ http_client: @http_client
79
+ }
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,71 @@
1
+ # `supabase-functions`
2
+
3
+ Ruby client for [Supabase Edge Functions](https://supabase.com/docs/guides/functions).
4
+ Per-call control over body, headers, HTTP method, region routing, and
5
+ response parsing. Mirrors the public surface of
6
+ [`supabase_functions`](https://github.com/supabase/supabase-py/tree/main/src/functions)
7
+ in Python.
8
+
9
+ - Source: [github.com/supabase-rb/client](https://github.com/supabase-rb/client)
10
+
11
+ ## Installation
12
+
13
+ ```ruby
14
+ gem "supabase-functions"
15
+ ```
16
+
17
+ Then `bundle install`. (Requires Ruby >= 3.0.)
18
+
19
+ ## Usage
20
+
21
+ ```ruby
22
+ require "supabase/functions"
23
+
24
+ functions = Supabase::Functions::Client.new(
25
+ base_url: "https://your-project.supabase.co/functions/v1",
26
+ headers: { "Authorization" => "Bearer #{key}" }
27
+ )
28
+
29
+ # Simple invoke (POST + JSON body)
30
+ response = functions.invoke("hello", body: { name: "Ada" })
31
+ response.data # => parsed JSON or raw bytes
32
+ response.status
33
+ response.headers
34
+ ```
35
+
36
+ ### Custom method / headers / query / region
37
+
38
+ ```ruby
39
+ functions.invoke(
40
+ "ingest",
41
+ method: "PUT",
42
+ headers: { "X-Trace-Id" => "abc" },
43
+ query: { tenant: "x" },
44
+ region: Supabase::Functions::Types::FunctionRegion::US_EAST_1,
45
+ body: payload_hash
46
+ )
47
+ ```
48
+
49
+ `response.data` is auto-parsed when the response `Content-Type` is JSON,
50
+ otherwise the raw body. Force parsing with `response_type: :json`.
51
+
52
+ ### Errors
53
+
54
+ `FunctionsHttpError` is raised on a function-side error response.
55
+ `FunctionsRelayError` is raised when the server's `x-relay-header` signals a
56
+ relay failure.
57
+
58
+ ## Async variant
59
+
60
+ ```ruby
61
+ require "supabase/functions/async"
62
+
63
+ async = Supabase::Functions::Async::Client.new(
64
+ base_url: ENV["SUPABASE_URL"] + "/functions/v1",
65
+ headers: { "Authorization" => "Bearer #{key}" }
66
+ )
67
+
68
+ Async do
69
+ response = async.invoke("hello", body: { name: "Ada" })
70
+ end
71
+ ```
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/http/faraday"
4
+ require_relative "../client"
5
+
6
+ module Supabase
7
+ module Functions
8
+ module Async
9
+ # Async counterpart to {Supabase::Functions::Client}.
10
+ #
11
+ # Inherits the full public surface (invoke, set_auth) and rewires only the
12
+ # Faraday adapter to async-http-faraday so HTTP I/O yields back to the
13
+ # {::Async} reactor instead of blocking the thread.
14
+ #
15
+ # Call sites must run inside an `Async do ... end` block; outside one, the
16
+ # adapter still works but loses the concurrency win.
17
+ #
18
+ # require "supabase/functions/async"
19
+ # require "async"
20
+ #
21
+ # functions = Supabase::Functions::Async::Client.new(
22
+ # base_url: "https://project.supabase.co/functions/v1",
23
+ # headers: { "Authorization" => "Bearer #{key}" }
24
+ # )
25
+ #
26
+ # Async do |task|
27
+ # calls = function_names.map do |name|
28
+ # task.async { functions.invoke(name, body: { id: 1 }) }
29
+ # end
30
+ # calls.map(&:wait)
31
+ # end
32
+ class Client < Supabase::Functions::Client
33
+ private
34
+
35
+ def build_session
36
+ Faraday.new(url: @base_url, ssl: { verify: @verify }, proxy: @proxy) do |f|
37
+ f.options.timeout = @timeout
38
+ f.options.open_timeout = @timeout
39
+ f.adapter :async_http
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Convenience requirer for the async tree, mirroring lib/supabase/{auth,postgrest,storage}/async.rb.
4
+ # Plain `require "supabase/functions"` stays free of async-http-faraday so sync-only
5
+ # consumers don't pay for the fiber stack.
6
+
7
+ require_relative "../functions"
8
+ require_relative "async/client"
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "uri"
6
+
7
+ require_relative "errors"
8
+ require_relative "types"
9
+ require_relative "version"
10
+
11
+ module Supabase
12
+ module Functions
13
+ # Sync Edge Functions client. Constructed once per project; reused across invocations.
14
+ #
15
+ # functions = Supabase::Functions::Client.new(
16
+ # base_url: "https://project.supabase.co/functions/v1",
17
+ # headers: { "Authorization" => "Bearer #{key}" }
18
+ # )
19
+ #
20
+ # response = functions.invoke("hello-world", body: { name: "Ada" })
21
+ # response.data # => parsed JSON or raw bytes
22
+ # response.status # => 200
23
+ # response.headers # => { "content-type" => "application/json", ... }
24
+ class Client
25
+ VALID_METHODS = %w[GET OPTIONS HEAD POST PUT PATCH DELETE].freeze
26
+
27
+ attr_reader :base_url, :headers
28
+
29
+ # @param base_url [String] full URL to the Edge Functions endpoint
30
+ # @param headers [Hash] static headers attached to every invocation
31
+ # @param http_client [Faraday::Connection, nil] inject a pre-built Faraday for tests
32
+ # @param verify [Boolean] TLS cert verification
33
+ # @param proxy [String, nil]
34
+ # @param timeout [Numeric, nil] per-request timeout (seconds), default 60
35
+ def initialize(base_url:, headers: {}, http_client: nil, verify: true, proxy: nil, timeout: nil)
36
+ raise ArgumentError, "base_url must be an http(s) URL" unless http_url?(base_url)
37
+
38
+ @base_url = base_url.chomp("/")
39
+ @verify = verify
40
+ @proxy = proxy
41
+ @timeout = timeout || 60
42
+
43
+ @headers = {
44
+ "X-Client-Info" => "supabase-rb/functions-rb v#{VERSION}"
45
+ }.merge(headers)
46
+
47
+ @session = http_client || build_session
48
+ end
49
+
50
+ # Replace the Authorization header (e.g. when a user signs in / out).
51
+ def set_auth(token)
52
+ @headers["Authorization"] = "Bearer #{token}"
53
+ end
54
+
55
+ # Invoke an Edge Function by name.
56
+ #
57
+ # @param function_name [String]
58
+ # @param body [Hash, String, nil] JSON-encoded if Hash, sent as-is if String
59
+ # @param headers [Hash] per-invocation headers (merged over the client defaults)
60
+ # @param method [String, Symbol] HTTP method, defaults to "POST"
61
+ # @param region [String, nil] one of {Types::FunctionRegion}::ALL
62
+ # @param response_type [Symbol, String] :json to parse JSON, anything else returns raw bytes
63
+ # @param query [Hash, nil] extra query-string params
64
+ # @return [Types::Response]
65
+ def invoke(function_name, body: nil, headers: {}, method: "POST", region: nil, response_type: :text, query: nil)
66
+ validate_function_name!(function_name)
67
+
68
+ http_method = method.to_s.upcase
69
+ unless VALID_METHODS.include?(http_method)
70
+ raise ArgumentError, "method must be one of #{VALID_METHODS.join(', ')}"
71
+ end
72
+
73
+ merged_headers = @headers.merge(headers)
74
+ merged_query = (query || {}).transform_keys(&:to_s)
75
+
76
+ if region && region != Types::FunctionRegion::ANY
77
+ merged_headers["x-region"] = region
78
+ merged_query["forceFunctionRegion"] = region
79
+ end
80
+
81
+ encoded_body =
82
+ case body
83
+ when nil
84
+ nil
85
+ when String
86
+ merged_headers["Content-Type"] ||= "text/plain"
87
+ body
88
+ when Hash, Array
89
+ merged_headers["Content-Type"] ||= "application/json"
90
+ JSON.generate(body)
91
+ else
92
+ raise ArgumentError, "body must be a String, Hash, Array, or nil (got #{body.class})"
93
+ end
94
+
95
+ response = @session.run_request(
96
+ http_method.downcase.to_sym,
97
+ "#{@base_url}/#{function_name}",
98
+ encoded_body,
99
+ merged_headers
100
+ ) do |req|
101
+ req.params.update(merged_query) unless merged_query.empty?
102
+ end
103
+
104
+ raise_for_relay!(response)
105
+ raise_for_status!(response)
106
+
107
+ Types::Response.new(
108
+ data: parse_body(response, response_type),
109
+ status: response.status,
110
+ headers: response.headers
111
+ )
112
+ end
113
+
114
+ private
115
+
116
+ def build_session
117
+ Faraday.new(url: @base_url, ssl: { verify: @verify }, proxy: @proxy) do |f|
118
+ f.options.timeout = @timeout
119
+ f.options.open_timeout = @timeout
120
+ f.adapter Faraday.default_adapter
121
+ end
122
+ end
123
+
124
+ def http_url?(url)
125
+ scheme = URI.parse(url.to_s).scheme
126
+ %w[http https].include?(scheme)
127
+ rescue URI::InvalidURIError
128
+ false
129
+ end
130
+
131
+ def validate_function_name!(name)
132
+ return if name.is_a?(String) && !name.strip.empty?
133
+
134
+ raise ArgumentError, "function_name must be a non-empty String"
135
+ end
136
+
137
+ def raise_for_relay!(response)
138
+ # The relay layer signals its own errors via this response header (set to
139
+ # "true"). The function itself doesn't set this — only the relay.
140
+ relay = response.headers["x-relay-header"] || response.headers["X-Relay-Header"]
141
+ return unless relay == "true"
142
+
143
+ parsed = parse_json_safe(response.body) || {}
144
+ raise Errors::FunctionsRelayError.new(parsed["error"] || "Relay error", status: response.status)
145
+ end
146
+
147
+ def raise_for_status!(response)
148
+ return if (200..299).include?(response.status)
149
+
150
+ parsed = parse_json_safe(response.body) || {}
151
+ message = parsed["error"] || "An error occurred while requesting the edge function"
152
+ raise Errors::FunctionsHttpError.new(message, status: response.status)
153
+ end
154
+
155
+ def parse_body(response, response_type)
156
+ return response.body if response.body.nil? || response.body.empty?
157
+
158
+ if response_type.to_s == "json"
159
+ parse_json_safe(response.body) || response.body
160
+ else
161
+ # Auto-detect JSON: if the server says application/json, parse it.
162
+ content_type = response.headers["content-type"] || response.headers["Content-Type"] || ""
163
+ content_type.include?("application/json") ? (parse_json_safe(response.body) || response.body) : response.body
164
+ end
165
+ end
166
+
167
+ def parse_json_safe(body)
168
+ JSON.parse(body) if body && !body.empty?
169
+ rescue JSON::ParserError
170
+ nil
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Functions
5
+ module Errors
6
+ # Base class — rescue this to catch any Functions-API failure.
7
+ class FunctionsError < StandardError
8
+ attr_reader :name, :status
9
+
10
+ def initialize(message, name:, status:)
11
+ @name = name
12
+ @status = status
13
+ super(message.to_s)
14
+ end
15
+
16
+ def to_h
17
+ { "name" => @name, "message" => message, "status" => @status }
18
+ end
19
+ end
20
+
21
+ # Raised when the edge function returns a non-2xx HTTP response.
22
+ class FunctionsHttpError < FunctionsError
23
+ def initialize(message, status: nil)
24
+ super(message, name: "FunctionsHttpError", status: status || 400)
25
+ end
26
+ end
27
+
28
+ # Raised when the Supabase relay (the layer in front of the function) reports
29
+ # an error — distinguishable from a function-side error via the x-relay-header
30
+ # response header.
31
+ class FunctionsRelayError < FunctionsError
32
+ def initialize(message, status: nil)
33
+ super(message, name: "FunctionsRelayError", status: status || 400)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Functions
5
+ module Types
6
+ # Returned by Client#invoke. `data` is parsed JSON when response_type: :json
7
+ # (or auto-detected from a JSON Content-Type), otherwise the raw response body.
8
+ Response = Struct.new(:data, :status, :headers, keyword_init: true)
9
+
10
+ # Supabase Edge Function regions. Use FunctionRegion::US_EAST_1 etc., or pass
11
+ # the bare string ("us-east-1") to Client#invoke — both are accepted.
12
+ module FunctionRegion
13
+ ANY = "any"
14
+ AP_NORTHEAST_1 = "ap-northeast-1"
15
+ AP_NORTHEAST_2 = "ap-northeast-2"
16
+ AP_SOUTH_1 = "ap-south-1"
17
+ AP_SOUTHEAST_1 = "ap-southeast-1"
18
+ AP_SOUTHEAST_2 = "ap-southeast-2"
19
+ CA_CENTRAL_1 = "ca-central-1"
20
+ EU_CENTRAL_1 = "eu-central-1"
21
+ EU_WEST_1 = "eu-west-1"
22
+ EU_WEST_2 = "eu-west-2"
23
+ EU_WEST_3 = "eu-west-3"
24
+ SA_EAST_1 = "sa-east-1"
25
+ US_EAST_1 = "us-east-1"
26
+ US_WEST_1 = "us-west-1"
27
+ US_WEST_2 = "us-west-2"
28
+
29
+ ALL = [
30
+ ANY, AP_NORTHEAST_1, AP_NORTHEAST_2, AP_SOUTH_1, AP_SOUTHEAST_1, AP_SOUTHEAST_2,
31
+ CA_CENTRAL_1, EU_CENTRAL_1, EU_WEST_1, EU_WEST_2, EU_WEST_3,
32
+ SA_EAST_1, US_EAST_1, US_WEST_1, US_WEST_2
33
+ ].freeze
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Functions
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "functions/version"
4
+ require_relative "functions/errors"
5
+ require_relative "functions/types"
6
+ require_relative "functions/client"
7
+
8
+ module Supabase
9
+ module Functions
10
+ end
11
+ end