leash-sdk 0.3.1 → 0.4.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 +4 -4
- data/Gemfile +1 -0
- data/README.md +156 -39
- data/leash-sdk.gemspec +5 -3
- data/lib/leash/auth.rb +216 -70
- data/lib/leash/client.rb +138 -0
- data/lib/leash/env.rb +165 -0
- data/lib/leash/errors.rb +107 -15
- data/lib/leash/integrations/base.rb +46 -0
- data/lib/leash/integrations/calendar.rb +64 -0
- data/lib/leash/integrations/drive.rb +53 -0
- data/lib/leash/integrations/gmail.rb +60 -0
- data/lib/leash/integrations/linear.rb +88 -0
- data/lib/leash/integrations.rb +36 -312
- data/lib/leash/transport.rb +188 -0
- data/lib/leash/types.rb +42 -0
- data/lib/leash/version.rb +1 -1
- data/lib/leash.rb +6 -1
- metadata +32 -14
- data/lib/leash/calendar.rb +0 -73
- data/lib/leash/custom_integration.rb +0 -32
- data/lib/leash/drive.rb +0 -48
- data/lib/leash/gmail.rb +0 -70
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Leash
|
|
6
|
+
module Integrations
|
|
7
|
+
# `leash.integrations.linear` — mirrors TS `leash.integrations.linear`.
|
|
8
|
+
#
|
|
9
|
+
# The Linear MCP uses underscored action names on the wire (`list_issues`,
|
|
10
|
+
# `get_issue`, …) — preserved here. List responses can come back as either
|
|
11
|
+
# a bare array or an envelope hash; the SDK tolerates both shapes.
|
|
12
|
+
class Linear < Base
|
|
13
|
+
PROVIDER = "linear"
|
|
14
|
+
|
|
15
|
+
def list_issues(team_id: nil, assignee_id: nil, state_type: nil,
|
|
16
|
+
limit: nil, cursor: nil)
|
|
17
|
+
params = compact_params(
|
|
18
|
+
"teamId" => team_id,
|
|
19
|
+
"assigneeId" => assignee_id,
|
|
20
|
+
"stateType" => state_type,
|
|
21
|
+
"limit" => limit,
|
|
22
|
+
"cursor" => cursor
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
raw = call("list_issues", params)
|
|
26
|
+
case raw
|
|
27
|
+
when Array
|
|
28
|
+
{ "issues" => raw }
|
|
29
|
+
when Hash
|
|
30
|
+
out = { "issues" => raw["issues"] || [] }
|
|
31
|
+
out["cursor"] = raw["cursor"] if raw["cursor"]
|
|
32
|
+
out
|
|
33
|
+
else
|
|
34
|
+
{ "issues" => [] }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def get_issue(id)
|
|
39
|
+
call("get_issue", { "id" => id })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def create_issue(team_id:, title:, description: nil, assignee_id: nil,
|
|
43
|
+
priority: nil, label_ids: nil)
|
|
44
|
+
params = { "teamId" => team_id, "title" => title }
|
|
45
|
+
params["description"] = description unless description.nil?
|
|
46
|
+
params["assigneeId"] = assignee_id unless assignee_id.nil?
|
|
47
|
+
params["priority"] = priority unless priority.nil?
|
|
48
|
+
params["labelIds"] = label_ids unless label_ids.nil?
|
|
49
|
+
call("create_issue", params)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def update_issue(id, title: nil, description: nil, assignee_id: nil,
|
|
53
|
+
priority: nil, label_ids: nil, team_id: nil)
|
|
54
|
+
params = { "id" => id }
|
|
55
|
+
params["title"] = title unless title.nil?
|
|
56
|
+
params["description"] = description unless description.nil?
|
|
57
|
+
params["assigneeId"] = assignee_id unless assignee_id.nil?
|
|
58
|
+
params["priority"] = priority unless priority.nil?
|
|
59
|
+
params["labelIds"] = label_ids unless label_ids.nil?
|
|
60
|
+
params["teamId"] = team_id unless team_id.nil?
|
|
61
|
+
call("update_issue", params)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def add_comment(issue_id, body)
|
|
65
|
+
call("add_comment", { "issueId" => issue_id, "body" => body })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def list_teams
|
|
69
|
+
raw = call("list_teams", {})
|
|
70
|
+
case raw
|
|
71
|
+
when Array then raw
|
|
72
|
+
when Hash then raw["teams"] || []
|
|
73
|
+
else []
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def list_projects(team_id: nil)
|
|
78
|
+
params = compact_params("teamId" => team_id)
|
|
79
|
+
raw = call("list_projects", params)
|
|
80
|
+
case raw
|
|
81
|
+
when Array then raw
|
|
82
|
+
when Hash then raw["projects"] || []
|
|
83
|
+
else []
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/leash/integrations.rb
CHANGED
|
@@ -1,319 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
require_relative "
|
|
8
|
-
require_relative "custom_integration"
|
|
9
|
-
require_relative "gmail"
|
|
10
|
-
require_relative "calendar"
|
|
11
|
-
require_relative "drive"
|
|
3
|
+
require_relative "integrations/base"
|
|
4
|
+
require_relative "integrations/gmail"
|
|
5
|
+
require_relative "integrations/calendar"
|
|
6
|
+
require_relative "integrations/drive"
|
|
7
|
+
require_relative "integrations/linear"
|
|
12
8
|
|
|
13
9
|
module Leash
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Google Drive integration client.
|
|
48
|
-
#
|
|
49
|
-
# @return [DriveClient]
|
|
50
|
-
def drive
|
|
51
|
-
@drive ||= DriveClient.new(method(:call))
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Access a custom integration by name. Returns an untyped client.
|
|
55
|
-
#
|
|
56
|
-
# @param name [String] the custom integration name
|
|
57
|
-
# @return [CustomIntegration]
|
|
58
|
-
#
|
|
59
|
-
# @example
|
|
60
|
-
# stripe = client.integration("stripe")
|
|
61
|
-
# charges = stripe.call("/v1/charges", method: "GET")
|
|
62
|
-
def integration(name)
|
|
63
|
-
CustomIntegration.new(name, method(:call_custom))
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Generic proxy call for any provider action.
|
|
67
|
-
#
|
|
68
|
-
# @param provider [String] integration provider name (e.g. "gmail")
|
|
69
|
-
# @param action [String] action to perform (e.g. "list-messages")
|
|
70
|
-
# @param params [Hash, nil] optional request body parameters
|
|
71
|
-
# @return [Object] the "data" field from the platform response
|
|
72
|
-
# @raise [Leash::NotConnectedError] if the provider is not connected
|
|
73
|
-
# @raise [Leash::TokenExpiredError] if the OAuth token has expired
|
|
74
|
-
# @raise [Leash::Error] if the platform returns a non-success response
|
|
75
|
-
def call(provider, action, params = nil)
|
|
76
|
-
uri = URI("#{@platform_url}/api/integrations/#{provider}/#{action}")
|
|
77
|
-
|
|
78
|
-
request = Net::HTTP::Post.new(uri)
|
|
79
|
-
request["Content-Type"] = "application/json"
|
|
80
|
-
request["Authorization"] = "Bearer #{@auth_token}"
|
|
81
|
-
request["X-API-Key"] = @api_key if @api_key
|
|
82
|
-
request.body = (params || {}).to_json
|
|
83
|
-
|
|
84
|
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
85
|
-
http.request(request)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
data = JSON.parse(response.body)
|
|
89
|
-
|
|
90
|
-
unless data["success"]
|
|
91
|
-
raise_error(data)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
data["data"]
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Check if a provider is connected for the current user.
|
|
98
|
-
#
|
|
99
|
-
# @param provider_id [String] the provider identifier (e.g. "gmail")
|
|
100
|
-
# @return [Boolean]
|
|
101
|
-
def connected?(provider_id)
|
|
102
|
-
conn = connections.find { |c| c["providerId"] == provider_id }
|
|
103
|
-
conn&.dig("status") == "active"
|
|
104
|
-
rescue StandardError
|
|
105
|
-
false
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Get connection status for all providers.
|
|
109
|
-
#
|
|
110
|
-
# @return [Array<Hash>] list of connection status hashes
|
|
111
|
-
def connections
|
|
112
|
-
uri = URI("#{@platform_url}/api/integrations/connections")
|
|
113
|
-
|
|
114
|
-
request = Net::HTTP::Get.new(uri)
|
|
115
|
-
request["Authorization"] = "Bearer #{@auth_token}"
|
|
116
|
-
request["X-API-Key"] = @api_key if @api_key
|
|
117
|
-
|
|
118
|
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
119
|
-
http.request(request)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
data = JSON.parse(response.body)
|
|
123
|
-
|
|
124
|
-
unless data["success"]
|
|
125
|
-
raise_error(data)
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
data["data"] || []
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# Get the URL to connect a provider (for UI buttons).
|
|
132
|
-
#
|
|
133
|
-
# @param provider_id [String] the provider identifier
|
|
134
|
-
# @param return_url [String, nil] optional URL to redirect back to after connecting
|
|
135
|
-
# @return [String] the full URL to initiate the OAuth connection flow
|
|
136
|
-
def connect_url(provider_id, return_url: nil)
|
|
137
|
-
url = "#{@platform_url}/api/integrations/connect/#{provider_id}"
|
|
138
|
-
url += "?return_url=#{URI.encode_www_form_component(return_url)}" if return_url
|
|
139
|
-
url
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
# Get the user's current access token for a provider -- built-in or
|
|
143
|
-
# org-registered (LEA-142). Lets you call third-party APIs directly
|
|
144
|
-
# without proxying every request through Leash. Refresh-on-expiry
|
|
145
|
-
# happens transparently on the platform side.
|
|
146
|
-
#
|
|
147
|
-
# @param provider [String] the provider slug (e.g. "slack", "gmail")
|
|
148
|
-
# @return [String] the access token
|
|
149
|
-
# @raise [Leash::NotConnectedError] if the user hasn't completed the OAuth flow
|
|
150
|
-
# @raise [Leash::TokenExpiredError] if the token is expired and cannot be refreshed
|
|
151
|
-
# @raise [Leash::Error] if the platform returns a non-success response
|
|
152
|
-
def get_access_token(provider)
|
|
153
|
-
uri = URI("#{@platform_url}/api/integrations/token")
|
|
154
|
-
|
|
155
|
-
request = Net::HTTP::Post.new(uri)
|
|
156
|
-
request["Content-Type"] = "application/json"
|
|
157
|
-
request["Authorization"] = "Bearer #{@auth_token}" if @auth_token
|
|
158
|
-
request["X-API-Key"] = @api_key if @api_key
|
|
159
|
-
request.body = { provider: provider }.to_json
|
|
160
|
-
|
|
161
|
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
162
|
-
http.request(request)
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
data = JSON.parse(response.body)
|
|
166
|
-
|
|
167
|
-
unless data["success"]
|
|
168
|
-
raise_error(data)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
data["data"]["accessToken"]
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# Get the resolved config for a customer-registered MCP server (LEA-143).
|
|
175
|
-
# Returns the customer's MCP URL plus auth headers (e.g. +Authorization:
|
|
176
|
-
# Bearer ...+ for bearer-auth servers) -- feed this directly into your
|
|
177
|
-
# MCP client. Leash isn't on the MCP request path.
|
|
178
|
-
#
|
|
179
|
-
# @param slug [String] the MCP server slug
|
|
180
|
-
# @return [Hash] hash with "slug", "displayName", "url", and "headers"
|
|
181
|
-
# @raise [Leash::Error] if the platform returns a non-success response
|
|
182
|
-
# (e.g. code +unknown_mcp_server+)
|
|
183
|
-
def get_custom_mcp_config(slug)
|
|
184
|
-
uri = URI("#{@platform_url}/api/integrations/mcp-config/#{URI.encode_www_form_component(slug)}")
|
|
185
|
-
|
|
186
|
-
request = Net::HTTP::Get.new(uri)
|
|
187
|
-
request["Authorization"] = "Bearer #{@auth_token}" if @auth_token
|
|
188
|
-
request["X-API-Key"] = @api_key if @api_key
|
|
189
|
-
|
|
190
|
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
191
|
-
http.request(request)
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
data = JSON.parse(response.body)
|
|
195
|
-
|
|
196
|
-
unless data["success"]
|
|
197
|
-
raise_error(data)
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
data["data"]
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
# Call any MCP server tool directly.
|
|
204
|
-
#
|
|
205
|
-
# @param package_name [String] the npm package name of the MCP server
|
|
206
|
-
# @param tool [String] the tool name to invoke
|
|
207
|
-
# @param args [Hash] optional arguments to pass to the tool
|
|
208
|
-
# @return [Object] the "data" field from the platform response
|
|
209
|
-
# @raise [Leash::Error] if the platform returns a non-success response
|
|
210
|
-
def mcp(package_name, tool, args = {})
|
|
211
|
-
uri = URI("#{@platform_url}/api/mcp/run")
|
|
212
|
-
|
|
213
|
-
request = Net::HTTP::Post.new(uri)
|
|
214
|
-
request["Content-Type"] = "application/json"
|
|
215
|
-
request["Authorization"] = "Bearer #{@auth_token}" if @auth_token
|
|
216
|
-
request["X-API-Key"] = @api_key if @api_key
|
|
217
|
-
|
|
218
|
-
payload = { package: package_name, tool: tool }
|
|
219
|
-
payload[:args] = args unless args.nil? || args.empty?
|
|
220
|
-
request.body = payload.to_json
|
|
221
|
-
|
|
222
|
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
223
|
-
http.request(request)
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
data = JSON.parse(response.body)
|
|
227
|
-
|
|
228
|
-
unless data["success"]
|
|
229
|
-
raise_error(data)
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
data["data"]
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
# Fetch env vars from the platform. Cached after first call.
|
|
236
|
-
#
|
|
237
|
-
# @param key [String, nil] optional key to look up
|
|
238
|
-
# @return [Hash, String, nil] all env vars as a Hash, or a single value if key given
|
|
239
|
-
# @raise [Leash::Error] if the platform returns a non-success response
|
|
240
|
-
def get_env(key = nil)
|
|
241
|
-
@env_cache ||= begin
|
|
242
|
-
uri = URI("#{@platform_url}/api/apps/env")
|
|
243
|
-
|
|
244
|
-
request = Net::HTTP::Get.new(uri)
|
|
245
|
-
request["Authorization"] = "Bearer #{@auth_token}" if @auth_token
|
|
246
|
-
request["X-API-Key"] = @api_key if @api_key
|
|
247
|
-
|
|
248
|
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
249
|
-
http.request(request)
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
data = JSON.parse(response.body)
|
|
253
|
-
|
|
254
|
-
unless data["success"]
|
|
255
|
-
raise_error(data)
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
data["data"] || {}
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
key ? @env_cache[key] : @env_cache
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
private
|
|
265
|
-
|
|
266
|
-
# Call the custom integration proxy endpoint.
|
|
267
|
-
#
|
|
268
|
-
# @param name [String] the custom integration name
|
|
269
|
-
# @param path [String] the endpoint path to forward
|
|
270
|
-
# @param method [String] HTTP method
|
|
271
|
-
# @param body [Hash, nil] optional JSON body to forward
|
|
272
|
-
# @param headers [Hash, nil] optional extra headers to forward
|
|
273
|
-
# @return [Object] the "data" field from the platform response
|
|
274
|
-
# @raise [Leash::Error] if the platform returns a non-success response
|
|
275
|
-
def call_custom(name, path, method = "GET", body = nil, headers = nil)
|
|
276
|
-
uri = URI("#{@platform_url}/api/integrations/custom/#{name}")
|
|
277
|
-
|
|
278
|
-
payload = { path: path, method: method }
|
|
279
|
-
payload[:body] = body if body
|
|
280
|
-
payload[:headers] = headers if headers
|
|
281
|
-
|
|
282
|
-
request = Net::HTTP::Post.new(uri)
|
|
283
|
-
request["Content-Type"] = "application/json"
|
|
284
|
-
request["Authorization"] = "Bearer #{@auth_token}"
|
|
285
|
-
request["X-API-Key"] = @api_key if @api_key
|
|
286
|
-
request.body = payload.to_json
|
|
287
|
-
|
|
288
|
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
289
|
-
http.request(request)
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
data = JSON.parse(response.body)
|
|
293
|
-
|
|
294
|
-
unless data["success"]
|
|
295
|
-
raise_error(data)
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
data["data"]
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
# Map error codes to specific exception classes.
|
|
302
|
-
#
|
|
303
|
-
# @param data [Hash] the parsed error response
|
|
304
|
-
# @raise [Leash::NotConnectedError, Leash::TokenExpiredError, Leash::Error]
|
|
305
|
-
def raise_error(data)
|
|
306
|
-
message = data["error"] || "Unknown error"
|
|
307
|
-
code = data["code"]
|
|
308
|
-
connect_url = data["connectUrl"]
|
|
309
|
-
|
|
310
|
-
case code
|
|
311
|
-
when "not_connected"
|
|
312
|
-
raise NotConnectedError.new(message, connect_url: connect_url)
|
|
313
|
-
when "token_expired"
|
|
314
|
-
raise TokenExpiredError.new(message, connect_url: connect_url)
|
|
315
|
-
else
|
|
316
|
-
raise Error.new(message, code: code, connect_url: connect_url)
|
|
10
|
+
module Integrations
|
|
11
|
+
# `leash.integrations` — typed provider namespaces plus a generic
|
|
12
|
+
# `.provider(name)` escape hatch for un-typed providers (Slack, GitHub,
|
|
13
|
+
# HubSpot, Jira, …). Mirrors the TS `leash.integrations` namespace and
|
|
14
|
+
# the Python `IntegrationsNamespace`.
|
|
15
|
+
#
|
|
16
|
+
# Method aliases keep the TS provider names addressable:
|
|
17
|
+
#
|
|
18
|
+
# leash.integrations.calendar # canonical (matches TS shape)
|
|
19
|
+
# leash.integrations.google_calendar # alias for calendar
|
|
20
|
+
# leash.integrations.drive # canonical
|
|
21
|
+
# leash.integrations.google_drive # alias for drive
|
|
22
|
+
class Namespace
|
|
23
|
+
def initialize(transport)
|
|
24
|
+
@transport = transport
|
|
25
|
+
@gmail = Gmail.new(transport)
|
|
26
|
+
@calendar = Calendar.new(transport)
|
|
27
|
+
@drive = Drive.new(transport)
|
|
28
|
+
@linear = Linear.new(transport)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :gmail, :calendar, :drive, :linear
|
|
32
|
+
|
|
33
|
+
# Aliases matching the Google-prefixed provider IDs (and platform
|
|
34
|
+
# docs that use them). Same instances as `calendar` / `drive`.
|
|
35
|
+
alias google_calendar calendar
|
|
36
|
+
alias google_drive drive
|
|
37
|
+
|
|
38
|
+
# Generic escape hatch — `leash.integrations.provider('slack').call(...)`.
|
|
39
|
+
def provider(name)
|
|
40
|
+
Caller.new(@transport, name)
|
|
317
41
|
end
|
|
318
42
|
end
|
|
319
43
|
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module Leash
|
|
10
|
+
# @api private
|
|
11
|
+
#
|
|
12
|
+
# Shared HTTP transport used by every integration POST. Mirrors the
|
|
13
|
+
# `_call` / `_post` private methods on the TS `Leash` class and the
|
|
14
|
+
# Python `_Transport`.
|
|
15
|
+
#
|
|
16
|
+
# Critical platform contract (Critical #1 in the 0.4 plan):
|
|
17
|
+
#
|
|
18
|
+
# * `X-API-Key` carries the app key (`LEASH_API_KEY`)
|
|
19
|
+
# * `Cookie: leash-auth=…` forwards the browser session
|
|
20
|
+
#
|
|
21
|
+
# The user JWT extracted from an inbound `Authorization: Bearer …` header is
|
|
22
|
+
# intentionally NOT forwarded — the TS SDK warns that the JWT path causes
|
|
23
|
+
# the platform's `verifyToken()` to reject before the X-API-Key check runs,
|
|
24
|
+
# producing a misleading 401 on every integration call. Bearer tokens are
|
|
25
|
+
# still accepted by `Leash` for other code paths (env.get fallback, future
|
|
26
|
+
# CLI flows) but never sent on integration POSTs. A negative-assertion test
|
|
27
|
+
# covers this.
|
|
28
|
+
class Transport
|
|
29
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
30
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
31
|
+
|
|
32
|
+
attr_reader :platform_url, :api_key, :cookie_value
|
|
33
|
+
|
|
34
|
+
def initialize(platform_url:, api_key: nil, cookie_value: nil,
|
|
35
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
36
|
+
read_timeout: DEFAULT_READ_TIMEOUT,
|
|
37
|
+
http_runner: nil)
|
|
38
|
+
@platform_url = platform_url.to_s.sub(%r{/+\z}, "")
|
|
39
|
+
@api_key = api_key
|
|
40
|
+
@cookie_value = cookie_value
|
|
41
|
+
@open_timeout = open_timeout
|
|
42
|
+
@read_timeout = read_timeout
|
|
43
|
+
# Test seam — allow injecting a custom HTTP runner block.
|
|
44
|
+
# If nil, calls go through `Net::HTTP.start`.
|
|
45
|
+
@http_runner = http_runner
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# POST to `/api/integrations/{provider}/{action}` and return the parsed
|
|
49
|
+
# response body (after unwrapping `{success, data}`).
|
|
50
|
+
def call(provider, action, params = nil)
|
|
51
|
+
url = "#{@platform_url}/api/integrations/#{provider}/#{action}"
|
|
52
|
+
docs_url = "https://leash.build/docs/integrations/#{provider}"
|
|
53
|
+
post_json(url, params, docs_url: docs_url)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @api private — used by env.rb for GET /api/apps/me/secrets/<key>.
|
|
57
|
+
def get_json(url, headers: {})
|
|
58
|
+
run_request(url, method: :get, headers: headers, body: nil)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @api private — used by env.rb for arbitrary URLs.
|
|
62
|
+
def post_json(url, params, docs_url:)
|
|
63
|
+
headers = { "Content-Type" => "application/json" }
|
|
64
|
+
headers["X-API-Key"] = @api_key if @api_key
|
|
65
|
+
headers["Cookie"] = "leash-auth=#{@cookie_value}" if @cookie_value
|
|
66
|
+
|
|
67
|
+
body = (params.nil? ? {} : params).to_json
|
|
68
|
+
response = run_request(url, method: :post, headers: headers, body: body)
|
|
69
|
+
|
|
70
|
+
handle_response(response, docs_url: docs_url)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @api private
|
|
74
|
+
def run_request(url, method:, headers:, body:)
|
|
75
|
+
uri = URI.parse(url)
|
|
76
|
+
request = build_request(uri, method: method, headers: headers, body: body)
|
|
77
|
+
|
|
78
|
+
if @http_runner
|
|
79
|
+
return @http_runner.call(uri, request)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
use_ssl = uri.scheme == "https"
|
|
83
|
+
opts = { use_ssl: use_ssl, open_timeout: @open_timeout, read_timeout: @read_timeout }
|
|
84
|
+
|
|
85
|
+
Net::HTTP.start(uri.host, uri.port, **opts) do |http|
|
|
86
|
+
http.request(request)
|
|
87
|
+
end
|
|
88
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
89
|
+
raise NetworkError.new("Timed out reaching the Leash platform: #{e.message}",
|
|
90
|
+
action: "Check your network connection and that the Leash platform is reachable.",
|
|
91
|
+
see_also: "https://leash.build/docs/sdk",
|
|
92
|
+
cause: e)
|
|
93
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, IOError => e
|
|
94
|
+
raise NetworkError.new(e.message,
|
|
95
|
+
action: "Check your network connection and that the Leash platform is reachable.",
|
|
96
|
+
see_also: "https://leash.build/docs/sdk",
|
|
97
|
+
cause: e)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @api private
|
|
101
|
+
def build_request(uri, method:, headers:, body:)
|
|
102
|
+
request =
|
|
103
|
+
case method
|
|
104
|
+
when :post then Net::HTTP::Post.new(uri.request_uri)
|
|
105
|
+
when :get then Net::HTTP::Get.new(uri.request_uri)
|
|
106
|
+
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
headers.each { |k, v| request[k] = v }
|
|
110
|
+
request.body = body if body
|
|
111
|
+
|
|
112
|
+
request
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @api private
|
|
116
|
+
def handle_response(response, docs_url:)
|
|
117
|
+
status = response.respond_to?(:code) ? response.code.to_i : 0
|
|
118
|
+
body_raw = response.respond_to?(:body) ? response.body : nil
|
|
119
|
+
|
|
120
|
+
parsed = parse_json(body_raw)
|
|
121
|
+
|
|
122
|
+
if status >= 400
|
|
123
|
+
raise_for_status(status, parsed, docs_url: docs_url)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if parsed.is_a?(Hash)
|
|
127
|
+
# Platform contract: { success, data } envelope OR raw shape.
|
|
128
|
+
if parsed["success"] == false
|
|
129
|
+
err_message = parsed["error"].is_a?(String) ? parsed["error"] : "Integration error"
|
|
130
|
+
err_code = parsed["code"].is_a?(String) ? parsed["code"] : "INTEGRATION_ERROR"
|
|
131
|
+
raise Error.new(err_message,
|
|
132
|
+
code: err_code,
|
|
133
|
+
action: "Check your integration configuration and try again.",
|
|
134
|
+
see_also: docs_url,
|
|
135
|
+
status: status,
|
|
136
|
+
connect_url: parsed["connectUrl"])
|
|
137
|
+
end
|
|
138
|
+
return parsed["data"] if parsed.key?("data")
|
|
139
|
+
|
|
140
|
+
return parsed
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
parsed
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @api private
|
|
147
|
+
def parse_json(body)
|
|
148
|
+
return nil if body.nil? || body.empty?
|
|
149
|
+
|
|
150
|
+
JSON.parse(body)
|
|
151
|
+
rescue JSON::ParserError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @api private
|
|
156
|
+
def raise_for_status(status, body, docs_url:)
|
|
157
|
+
message = "HTTP #{status}"
|
|
158
|
+
if body.is_a?(Hash) && body["error"].is_a?(String)
|
|
159
|
+
message = body["error"]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
case status
|
|
163
|
+
when 401
|
|
164
|
+
raise UnauthorizedError.new(message,
|
|
165
|
+
action: "Ensure the leash-auth cookie is present, or open your app from the Leash dashboard to get a valid session.",
|
|
166
|
+
see_also: "https://leash.build/docs/sdk",
|
|
167
|
+
status: status)
|
|
168
|
+
when 402
|
|
169
|
+
msg = (body.is_a?(Hash) && body["message"].is_a?(String) ? body["message"] : message)
|
|
170
|
+
raise UpgradeRequiredError.new(msg,
|
|
171
|
+
action: "Upgrade your plan at https://leash.build/dashboard/billing.",
|
|
172
|
+
see_also: "https://leash.build/pricing",
|
|
173
|
+
status: status)
|
|
174
|
+
when 403
|
|
175
|
+
raise ConnectionRequiredError.new(message,
|
|
176
|
+
action: "Connect the integration at /dashboard/integrations and make sure this app is on the allow-list.",
|
|
177
|
+
see_also: "https://leash.build/dashboard/integrations",
|
|
178
|
+
status: status)
|
|
179
|
+
else
|
|
180
|
+
raise Error.new(message,
|
|
181
|
+
code: "INTEGRATION_ERROR",
|
|
182
|
+
action: "Check your integration configuration and try again — the upstream provider returned an error.",
|
|
183
|
+
see_also: docs_url,
|
|
184
|
+
status: status)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
data/lib/leash/types.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Leash
|
|
4
|
+
# Authenticated Leash user.
|
|
5
|
+
#
|
|
6
|
+
# Mirrors the TS `LeashUser` interface — JSON fields use camelCase on the
|
|
7
|
+
# wire (`id`, `email`, `name`, `picture`), and on the Ruby side `picture` is
|
|
8
|
+
# optional. Two `LeashUser`s are equal when every attribute matches.
|
|
9
|
+
class User
|
|
10
|
+
attr_reader :id, :email, :name, :picture
|
|
11
|
+
|
|
12
|
+
def initialize(id:, email:, name: nil, picture: nil)
|
|
13
|
+
@id = id
|
|
14
|
+
@email = email
|
|
15
|
+
@name = name
|
|
16
|
+
@picture = picture
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ==(other)
|
|
20
|
+
other.is_a?(User) &&
|
|
21
|
+
id == other.id &&
|
|
22
|
+
email == other.email &&
|
|
23
|
+
name == other.name &&
|
|
24
|
+
picture == other.picture
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
alias eql? ==
|
|
28
|
+
|
|
29
|
+
def hash
|
|
30
|
+
[self.class, id, email, name, picture].hash
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
h = { id: id, email: email, name: name }
|
|
35
|
+
h[:picture] = picture if picture
|
|
36
|
+
h
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Older alias — readers reaching for `Leash::LeashUser` get the same class.
|
|
41
|
+
LeashUser = User
|
|
42
|
+
end
|
data/lib/leash/version.rb
CHANGED