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,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
|