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.
@@ -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
@@ -1,319 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
- require "json"
5
- require "uri"
6
-
7
- require_relative "errors"
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
- DEFAULT_PLATFORM_URL = "https://leash.build"
15
-
16
- # Main client for accessing Leash platform integrations.
17
- #
18
- # @example
19
- # client = Leash::Integrations.new(auth_token: "your-jwt-token")
20
- # messages = client.gmail.list_messages(query: "is:unread")
21
- # events = client.calendar.list_events(time_min: "2026-04-10T00:00:00Z")
22
- # files = client.drive.list_files
23
- class Integrations
24
- # @param auth_token [String] the leash-auth JWT token
25
- # @param platform_url [String] base URL of the Leash platform API
26
- # @param api_key [String, nil] optional API key for server-to-server auth
27
- def initialize(auth_token:, platform_url: DEFAULT_PLATFORM_URL, api_key: nil)
28
- @auth_token = auth_token
29
- @platform_url = platform_url.chomp("/")
30
- @api_key = api_key || ENV["LEASH_API_KEY"]
31
- end
32
-
33
- # Gmail integration client.
34
- #
35
- # @return [GmailClient]
36
- def gmail
37
- @gmail ||= GmailClient.new(method(:call))
38
- end
39
-
40
- # Google Calendar integration client.
41
- #
42
- # @return [CalendarClient]
43
- def calendar
44
- @calendar ||= CalendarClient.new(method(:call))
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leash
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end