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
data/lib/leash/client.rb
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "auth"
|
|
4
|
+
require_relative "env"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "integrations"
|
|
7
|
+
require_relative "transport"
|
|
8
|
+
require_relative "types"
|
|
9
|
+
|
|
10
|
+
module Leash
|
|
11
|
+
DEFAULT_PLATFORM_URL = "https://leash.build"
|
|
12
|
+
|
|
13
|
+
# Unified Leash client — namespaces for `auth`, `env`, `integrations`.
|
|
14
|
+
# Ruby mirror of `leash-sdk-ts/src/leash.ts` and `leash-sdk-python/leash/client.py`.
|
|
15
|
+
#
|
|
16
|
+
# Construction is server-only in 0.4 and requires a request object:
|
|
17
|
+
#
|
|
18
|
+
# leash = Leash.new(request: request) # Rails / Sinatra / Hanami / Rack hash
|
|
19
|
+
# user = leash.auth.user # Leash::User or nil
|
|
20
|
+
# key = leash.env.get("OPENAI_API_KEY") # String or nil
|
|
21
|
+
# msgs = leash.integrations.gmail.list_messages(max_results: 5)
|
|
22
|
+
#
|
|
23
|
+
# Authentication precedence (mirror TS / Python / Go exactly):
|
|
24
|
+
#
|
|
25
|
+
# 1. `LEASH_API_KEY` env var (or explicit `api_key:` constructor arg)
|
|
26
|
+
# 2. `Authorization: Bearer <jwt>` header on the request — used for
|
|
27
|
+
# `auth.user` AND as an env-read fallback (when no API key), but
|
|
28
|
+
# NEVER forwarded on integration POSTs
|
|
29
|
+
# 3. `leash-auth` cookie from the request
|
|
30
|
+
#
|
|
31
|
+
# The Bearer fallback for env reads is documented here per the Go-reviewer
|
|
32
|
+
# callout: when the constructor sees no `LEASH_API_KEY` but does see an
|
|
33
|
+
# inbound `Authorization: Bearer …`, the bearer JWT is used as the API key
|
|
34
|
+
# for the platform's `/api/apps/me/secrets/[key]` endpoint. The bearer is
|
|
35
|
+
# still suppressed on integration POSTs (Critical #1 in the 0.4 plan).
|
|
36
|
+
class Client
|
|
37
|
+
# @return [Leash::Integrations::Namespace]
|
|
38
|
+
attr_reader :integrations
|
|
39
|
+
|
|
40
|
+
# @return [Leash::Env]
|
|
41
|
+
attr_reader :env
|
|
42
|
+
|
|
43
|
+
# @return [Auth]
|
|
44
|
+
attr_reader :auth
|
|
45
|
+
|
|
46
|
+
# @return [String]
|
|
47
|
+
attr_reader :platform_url
|
|
48
|
+
|
|
49
|
+
# @param request [Object, Hash] any Rack-conforming request (Rails
|
|
50
|
+
# `ActionDispatch::Request`, Sinatra `Sinatra::Request`, Hanami,
|
|
51
|
+
# a plain Rack `env` hash, or anything quacking with `.cookies` / `.env`).
|
|
52
|
+
# @param platform_url [String, nil] override the platform base URL.
|
|
53
|
+
# Defaults to `LEASH_PLATFORM_URL` env var or `https://leash.build`.
|
|
54
|
+
# @param api_key [String, nil] explicit `LEASH_API_KEY` override.
|
|
55
|
+
# @param transport [Leash::Transport, nil] inject a transport (handy for
|
|
56
|
+
# tests). When omitted, a default `Net::HTTP`-backed transport is built.
|
|
57
|
+
def initialize(request:, platform_url: nil, api_key: nil, transport: nil)
|
|
58
|
+
if request.nil?
|
|
59
|
+
raise Error.new(
|
|
60
|
+
"Leash requires a request object in server environments.",
|
|
61
|
+
code: "NO_REQUEST_SERVER_CONSTRUCT",
|
|
62
|
+
action: "Pass request: req to Leash.new in your route handler.",
|
|
63
|
+
see_also: "https://leash.build/docs/sdk"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@request = request
|
|
68
|
+
@platform_url = (platform_url || ENV["LEASH_PLATFORM_URL"] || DEFAULT_PLATFORM_URL)
|
|
69
|
+
.to_s.sub(%r{/+\z}, "")
|
|
70
|
+
|
|
71
|
+
# Auth precedence: explicit api_key > LEASH_API_KEY env > bearer header > cookie.
|
|
72
|
+
env_api_key = ENV["LEASH_API_KEY"]
|
|
73
|
+
env_api_key = nil if env_api_key && env_api_key.empty?
|
|
74
|
+
@explicit_api_key = api_key || env_api_key
|
|
75
|
+
|
|
76
|
+
@bearer_token = Auth.extract_bearer_token(request)
|
|
77
|
+
@cookie_value = Auth.extract_cookie(request)
|
|
78
|
+
|
|
79
|
+
# Bearer→env fallback: when no app key is present, the inbound user
|
|
80
|
+
# JWT is used to authenticate env reads to the platform. NEVER used
|
|
81
|
+
# as `X-API-Key` on integration POSTs (see Transport docstring).
|
|
82
|
+
@env_api_key = @explicit_api_key || @bearer_token
|
|
83
|
+
|
|
84
|
+
@transport = transport || Transport.new(
|
|
85
|
+
platform_url: @platform_url,
|
|
86
|
+
api_key: @explicit_api_key,
|
|
87
|
+
cookie_value: @cookie_value
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@auth = AuthFacade.new(request)
|
|
91
|
+
@env = Env.new(
|
|
92
|
+
platform_url: @platform_url,
|
|
93
|
+
api_key: @env_api_key,
|
|
94
|
+
transport: @transport
|
|
95
|
+
)
|
|
96
|
+
@integrations = Integrations::Namespace.new(@transport)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @api private — exposed so tests can introspect resolved credentials.
|
|
100
|
+
attr_reader :bearer_token, :cookie_value, :explicit_api_key
|
|
101
|
+
|
|
102
|
+
# `leash.auth` — sync, non-throwing wrapper around {Leash::Auth.get_user}.
|
|
103
|
+
class AuthFacade
|
|
104
|
+
def initialize(request)
|
|
105
|
+
@request = request
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Leash::User, nil] the authenticated user, or `nil` when not
|
|
109
|
+
# authenticated. Never raises — swallows decode errors so handlers can
|
|
110
|
+
# branch with a clean `if user.nil?`.
|
|
111
|
+
#
|
|
112
|
+
# Note: this method reads ONLY the `leash-auth` cookie from the request.
|
|
113
|
+
# `Authorization: Bearer <jwt>` headers are deliberately NOT used for
|
|
114
|
+
# identity resolution here — they're reserved for env-fetch fallback +
|
|
115
|
+
# CLI/agent flows. To get the user from a Bearer token, you'd hit
|
|
116
|
+
# `/api/auth/me` directly with that token.
|
|
117
|
+
def user
|
|
118
|
+
Leash::Auth.get_user(@request)
|
|
119
|
+
rescue Leash::AuthError
|
|
120
|
+
nil
|
|
121
|
+
rescue StandardError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [Boolean]
|
|
126
|
+
def authenticated?
|
|
127
|
+
!user.nil?
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# `Leash.new(...)` is the idiomatic Ruby entry point. We delegate to
|
|
133
|
+
# `Client.new` so the module-level constant `Leash` stays a module
|
|
134
|
+
# (necessary because `Leash::Auth`, `Leash::Error`, etc. all live under it).
|
|
135
|
+
def self.new(**kwargs)
|
|
136
|
+
Client.new(**kwargs)
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/leash/env.rb
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module Leash
|
|
8
|
+
# `leash.env` namespace — runtime env-var fetcher with a per-instance
|
|
9
|
+
# 60 s TTL cache. Mirrors the TS `leash.env.get` / `leash.env.getMany`
|
|
10
|
+
# surface and the Python `EnvNamespace` behaviour (`Optional[str]` — Ruby
|
|
11
|
+
# returns `nil` for HTTP 404 instead of raising, so callers can branch
|
|
12
|
+
# naturally).
|
|
13
|
+
class Env
|
|
14
|
+
CACHE_TTL_S = 60
|
|
15
|
+
|
|
16
|
+
attr_reader :platform_url
|
|
17
|
+
|
|
18
|
+
def initialize(platform_url:, api_key:, transport:)
|
|
19
|
+
@platform_url = platform_url.to_s.sub(%r{/+\z}, "")
|
|
20
|
+
@api_key = api_key
|
|
21
|
+
@transport = transport
|
|
22
|
+
@cache = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Resolve a single env-var value.
|
|
26
|
+
#
|
|
27
|
+
# @param key [String] the env-var name (uppercase by convention)
|
|
28
|
+
# @param fresh [Boolean] skip the TTL cache for this call; the freshly
|
|
29
|
+
# fetched value is still written back to the cache
|
|
30
|
+
# @return [String, nil] the value, or `nil` when the platform reports the
|
|
31
|
+
# key as not declared / not found
|
|
32
|
+
# @raise [Leash::Error] for auth / invalid-key / plan / platform errors
|
|
33
|
+
def get(key, fresh: false)
|
|
34
|
+
now = monotonic_now
|
|
35
|
+
unless fresh
|
|
36
|
+
entry = @cache[key]
|
|
37
|
+
return entry[:value] if entry && entry[:expires_at] > now
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
value = fetch_one(key)
|
|
41
|
+
@cache[key] = { value: value, expires_at: now + CACHE_TTL_S }
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Bulk variant — resolve multiple keys sequentially against the shared
|
|
46
|
+
# TTL cache.
|
|
47
|
+
#
|
|
48
|
+
# @return [Hash{String => String, nil}]
|
|
49
|
+
def get_many(keys)
|
|
50
|
+
result = {}
|
|
51
|
+
keys.each { |k| result[k] = get(k) }
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @api private — for tests / introspection.
|
|
56
|
+
def clear_cache!
|
|
57
|
+
@cache.clear
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def monotonic_now
|
|
63
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fetch_one(key)
|
|
67
|
+
raise NoApiKeyError unless @api_key
|
|
68
|
+
|
|
69
|
+
# Use path-style percent-encoding (TS `encodeURIComponent` semantics):
|
|
70
|
+
# spaces become %20, not + (CGI.escape's form-encoding default).
|
|
71
|
+
encoded = CGI.escape(key).gsub("+", "%20")
|
|
72
|
+
url = "#{@platform_url}/api/apps/me/secrets/#{encoded}"
|
|
73
|
+
|
|
74
|
+
# Env reads use `Authorization: Bearer <api_key>` — matches the platform
|
|
75
|
+
# contract for /api/apps/me/secrets/[key] (see leash.ts line 299 + 303).
|
|
76
|
+
response = @transport.get_json(url, headers: {
|
|
77
|
+
"Authorization" => "Bearer #{@api_key}"
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
status = response.respond_to?(:code) ? response.code.to_i : 0
|
|
81
|
+
body = parse_json(response.respond_to?(:body) ? response.body : nil)
|
|
82
|
+
|
|
83
|
+
case status
|
|
84
|
+
when 400
|
|
85
|
+
raise Error.new(
|
|
86
|
+
"Invalid env-var key: '#{key}'.",
|
|
87
|
+
code: "INVALID_KEY",
|
|
88
|
+
action: "Env-var names must match /^[A-Za-z_][A-Za-z0-9_]*$/ and be no longer than 100 characters.",
|
|
89
|
+
see_also: "https://leash.build/docs/sdk",
|
|
90
|
+
status: 400
|
|
91
|
+
)
|
|
92
|
+
when 401
|
|
93
|
+
raise UnauthorizedError.new(
|
|
94
|
+
"Missing or invalid LEASH_API_KEY.",
|
|
95
|
+
action: "Mint a fresh API key at /dashboard/organization.",
|
|
96
|
+
see_also: "https://leash.build/dashboard/organization",
|
|
97
|
+
status: 401
|
|
98
|
+
)
|
|
99
|
+
when 402
|
|
100
|
+
required_plan = body.is_a?(Hash) ? body["requiredPlan"] : nil
|
|
101
|
+
suffix = required_plan ? " (requiredPlan: #{required_plan})" : ""
|
|
102
|
+
raise UpgradeRequiredError.new(
|
|
103
|
+
"leash.env.get requires the Growth plan or above#{suffix}.",
|
|
104
|
+
action: "Upgrade at https://leash.build/dashboard/billing.",
|
|
105
|
+
see_also: "https://leash.build/dashboard/billing",
|
|
106
|
+
status: 402
|
|
107
|
+
)
|
|
108
|
+
when 404
|
|
109
|
+
# Ruby idiom: return nil for missing keys instead of raising so
|
|
110
|
+
# callers can branch with `if value.nil?`. Mirrors Python.
|
|
111
|
+
return nil
|
|
112
|
+
when 502
|
|
113
|
+
platform_error = body.is_a?(Hash) ? body["error"] : nil
|
|
114
|
+
raise Error.new(
|
|
115
|
+
platform_error || "Secret source resync failed on the platform side.",
|
|
116
|
+
code: "SOURCE_RESYNC_FAILED",
|
|
117
|
+
action: "Check your secret source configuration in the Leash dashboard.",
|
|
118
|
+
see_also: "https://leash.build/dashboard",
|
|
119
|
+
status: 502
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if status >= 400
|
|
124
|
+
raise Error.new(
|
|
125
|
+
"Unexpected response from platform: HTTP #{status}",
|
|
126
|
+
code: "ENV_FETCH_ERROR",
|
|
127
|
+
action: "Check the Leash platform status and your configuration.",
|
|
128
|
+
see_also: "https://leash.build/docs/sdk",
|
|
129
|
+
status: status
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
value = body.is_a?(Hash) ? body["value"] : nil
|
|
134
|
+
unless value.is_a?(String)
|
|
135
|
+
raise Error.new(
|
|
136
|
+
"Platform returned an unexpected response shape for key '#{key}'.",
|
|
137
|
+
code: "ENV_FETCH_ERROR",
|
|
138
|
+
action: "Check the Leash platform status and your configuration.",
|
|
139
|
+
see_also: "https://leash.build/docs/sdk",
|
|
140
|
+
status: status
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
value
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def parse_json(body)
|
|
147
|
+
return nil if body.nil? || body.empty?
|
|
148
|
+
|
|
149
|
+
JSON.parse(body)
|
|
150
|
+
rescue JSON::ParserError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
class NoApiKeyError < Error
|
|
155
|
+
def initialize
|
|
156
|
+
super(
|
|
157
|
+
"LEASH_API_KEY is required to call leash.env.get().",
|
|
158
|
+
code: "NO_API_KEY",
|
|
159
|
+
action: "Set LEASH_API_KEY in your environment or pass api_key: ... to Leash.new.",
|
|
160
|
+
see_also: "https://leash.build/dashboard/organization"
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
data/lib/leash/errors.rb
CHANGED
|
@@ -1,34 +1,126 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Leash
|
|
4
|
-
#
|
|
4
|
+
# Structured error type raised by every Leash SDK call site.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
6
|
+
# Mirrors `leash-sdk-ts/src/errors.ts` and Python's `leash.errors.LeashError`.
|
|
7
|
+
# The `code` field is the stable machine-readable identifier consumers should
|
|
8
|
+
# switch on; `message` is the human-readable line; `action` and `see_also`
|
|
9
|
+
# are optional remediation hints.
|
|
10
|
+
#
|
|
11
|
+
# Known codes (kept in sync with leash-sdk-ts):
|
|
12
|
+
# - NO_API_KEY
|
|
13
|
+
# - NO_REQUEST_SERVER_CONSTRUCT
|
|
14
|
+
# - BROWSER_MODE_UNSUPPORTED
|
|
15
|
+
# - UNAUTHORIZED
|
|
16
|
+
# - NO_AUTH_CONTEXT
|
|
17
|
+
# - INTEGRATION_NOT_ENABLED
|
|
18
|
+
# - INTEGRATION_ERROR
|
|
19
|
+
# - UPGRADE_REQUIRED
|
|
20
|
+
# - PLAN_BLOCK
|
|
21
|
+
# - CONNECTION_REQUIRED
|
|
22
|
+
# - NETWORK_ERROR
|
|
23
|
+
# - KEY_NOT_DECLARED
|
|
24
|
+
# - INVALID_KEY
|
|
25
|
+
# - SOURCE_RESYNC_FAILED
|
|
26
|
+
# - ENV_FETCH_ERROR
|
|
8
27
|
class Error < StandardError
|
|
9
|
-
attr_reader :code, :
|
|
28
|
+
attr_reader :code, :action, :see_also, :status, :cause
|
|
10
29
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
# @param connect_url [String, nil] URL to initiate OAuth connection
|
|
14
|
-
def initialize(message, code: nil, connect_url: nil)
|
|
30
|
+
def initialize(message, code: nil, action: nil, see_also: nil, status: nil,
|
|
31
|
+
cause: nil, connect_url: nil)
|
|
15
32
|
super(message)
|
|
33
|
+
@message = message
|
|
16
34
|
@code = code
|
|
35
|
+
@action = action
|
|
36
|
+
@see_also = see_also
|
|
37
|
+
@status = status
|
|
38
|
+
@cause = cause
|
|
17
39
|
@connect_url = connect_url
|
|
18
40
|
end
|
|
41
|
+
|
|
42
|
+
# Compatibility shim — the 0.3 surface exposed `connect_url`.
|
|
43
|
+
def connect_url
|
|
44
|
+
@connect_url
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Override `message` to return our stored value — avoids Exception#message
|
|
48
|
+
# falling back to `to_s` and recursing infinitely.
|
|
49
|
+
def message
|
|
50
|
+
@message
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_s
|
|
54
|
+
out = +"x #{@message}"
|
|
55
|
+
out << "\n Fix: #{@action}" if @action
|
|
56
|
+
out << "\n See: #{@see_also}" if @see_also
|
|
57
|
+
out
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 402 from the platform — feature requires a higher plan.
|
|
62
|
+
class UpgradeRequiredError < Error
|
|
63
|
+
def initialize(message = "This feature requires a higher plan.", **opts)
|
|
64
|
+
opts[:code] ||= "UPGRADE_REQUIRED"
|
|
65
|
+
super(message, **opts)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Alias kept for the surface contract described in the 0.4 plan
|
|
70
|
+
# (`Leash::PlanBlockError`). Same class as `UpgradeRequiredError`.
|
|
71
|
+
PlanBlockError = UpgradeRequiredError
|
|
72
|
+
|
|
73
|
+
# 403 from the platform — provider not connected for the current user.
|
|
74
|
+
class ConnectionRequiredError < Error
|
|
75
|
+
def initialize(message = "Integration not connected.", **opts)
|
|
76
|
+
opts[:code] ||= "INTEGRATION_NOT_ENABLED"
|
|
77
|
+
super(message, **opts)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# 404 on env.get — env-var key isn't declared / not found.
|
|
82
|
+
class KeyNotDeclaredError < Error
|
|
83
|
+
def initialize(message = "Key is not declared.", **opts)
|
|
84
|
+
opts[:code] ||= "KEY_NOT_DECLARED"
|
|
85
|
+
super(message, **opts)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# 401 from the platform — missing / invalid credentials.
|
|
90
|
+
class UnauthorizedError < Error
|
|
91
|
+
def initialize(message = "Unauthorized.", **opts)
|
|
92
|
+
opts[:code] ||= "UNAUTHORIZED"
|
|
93
|
+
super(message, **opts)
|
|
94
|
+
end
|
|
19
95
|
end
|
|
20
96
|
|
|
21
|
-
#
|
|
22
|
-
class
|
|
23
|
-
def initialize(message = "
|
|
24
|
-
|
|
97
|
+
# Transport-level failure (DNS, refused connection, timeout, …).
|
|
98
|
+
class NetworkError < Error
|
|
99
|
+
def initialize(message = "Failed to reach the Leash platform.", **opts)
|
|
100
|
+
opts[:code] ||= "NETWORK_ERROR"
|
|
101
|
+
super(message, **opts)
|
|
25
102
|
end
|
|
26
103
|
end
|
|
27
104
|
|
|
28
|
-
#
|
|
105
|
+
# Backwards-compat alias for the 0.3 surface — kept so existing user code
|
|
106
|
+
# `rescue Leash::NotConnectedError` keeps working.
|
|
107
|
+
NotConnectedError = ConnectionRequiredError
|
|
108
|
+
|
|
29
109
|
class TokenExpiredError < Error
|
|
30
|
-
def initialize(message = "Token expired",
|
|
31
|
-
|
|
110
|
+
def initialize(message = "Token expired.", **opts)
|
|
111
|
+
# Legacy code casing — kept as snake_case to preserve 0.3 behavior for
|
|
112
|
+
# callers matching on err.code. New errors use SCREAMING_SNAKE.
|
|
113
|
+
opts[:code] ||= "token_expired"
|
|
114
|
+
super(message, **opts)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Raised by `Leash::Auth.get_user` when the leash-auth cookie is missing /
|
|
119
|
+
# invalid. `Leash#auth.user` returns `nil` instead so it never raises.
|
|
120
|
+
class AuthError < Error
|
|
121
|
+
def initialize(message = "Authentication failed", **opts)
|
|
122
|
+
opts[:code] ||= "NO_AUTH_CONTEXT"
|
|
123
|
+
super(message, **opts)
|
|
32
124
|
end
|
|
33
125
|
end
|
|
34
126
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Leash
|
|
4
|
+
module Integrations
|
|
5
|
+
# @api private
|
|
6
|
+
# Internal base class — wraps a {Leash::Transport} bound to a provider id.
|
|
7
|
+
class Base
|
|
8
|
+
PROVIDER = nil
|
|
9
|
+
|
|
10
|
+
def initialize(transport)
|
|
11
|
+
@transport = transport
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def call(action, params = nil)
|
|
17
|
+
@transport.call(self.class::PROVIDER, action, params)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Drop nil values from a hash so we never send `{"foo":null}` payloads
|
|
21
|
+
# — mirrors the Python provider behaviour. Uses Hash#compact (Ruby 2.4+).
|
|
22
|
+
def compact_params(hash)
|
|
23
|
+
hash.compact
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Generic escape hatch — call any provider action without a typed wrapper.
|
|
28
|
+
# Returned by `leash.integrations.provider(name)`.
|
|
29
|
+
class Caller
|
|
30
|
+
def initialize(transport, name)
|
|
31
|
+
@transport = transport
|
|
32
|
+
@name = name
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Invoke `<provider>/<action>` with an optional JSON body.
|
|
36
|
+
#
|
|
37
|
+
# @param action [String] the wire action (e.g. `'post_message'`)
|
|
38
|
+
# @param body [Hash, nil] JSON body forwarded to the platform
|
|
39
|
+
def call(action, body: nil)
|
|
40
|
+
@transport.call(@name, action, body)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
attr_reader :name
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Leash
|
|
6
|
+
module Integrations
|
|
7
|
+
# `leash.integrations.calendar` — mirrors TS `leash.integrations.calendar`.
|
|
8
|
+
# Wire provider id is `google_calendar` (also aliased as
|
|
9
|
+
# `leash.integrations.google_calendar`).
|
|
10
|
+
class Calendar < Base
|
|
11
|
+
PROVIDER = "google_calendar"
|
|
12
|
+
|
|
13
|
+
def list_calendars
|
|
14
|
+
call("list-calendars")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param calendar_id [String, nil]
|
|
18
|
+
# @param time_min [String, nil] ISO 8601 timestamp
|
|
19
|
+
# @param time_max [String, nil] ISO 8601 timestamp
|
|
20
|
+
# @param max_results [Integer, nil]
|
|
21
|
+
# @param query [String, nil]
|
|
22
|
+
# @param single_events [Boolean, nil]
|
|
23
|
+
# @param order_by [String, nil]
|
|
24
|
+
def list_events(calendar_id: nil, time_min: nil, time_max: nil,
|
|
25
|
+
max_results: nil, query: nil, single_events: nil,
|
|
26
|
+
order_by: nil)
|
|
27
|
+
params = compact_params(
|
|
28
|
+
"calendarId" => calendar_id,
|
|
29
|
+
"timeMin" => time_min,
|
|
30
|
+
"timeMax" => time_max,
|
|
31
|
+
"maxResults" => max_results,
|
|
32
|
+
"query" => query,
|
|
33
|
+
"singleEvents" => single_events,
|
|
34
|
+
"orderBy" => order_by
|
|
35
|
+
)
|
|
36
|
+
call("list-events", params.empty? ? nil : params)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `end_time:` keyword is used here (not `end:`) to avoid clashing with
|
|
40
|
+
# the Ruby `end` keyword — but the wire payload uses `"end"`.
|
|
41
|
+
def create_event(summary:, start:, end_time: nil, **rest)
|
|
42
|
+
# Backward-compat: accept `:end` if callers pass it via a hash splat.
|
|
43
|
+
end_time = rest.delete(:end) if end_time.nil? && rest.key?(:end)
|
|
44
|
+
|
|
45
|
+
params = {
|
|
46
|
+
"summary" => summary,
|
|
47
|
+
"start" => start,
|
|
48
|
+
"end" => end_time
|
|
49
|
+
}
|
|
50
|
+
params["calendarId"] = rest[:calendar_id] if rest[:calendar_id]
|
|
51
|
+
params["description"] = rest[:description] if rest[:description]
|
|
52
|
+
params["location"] = rest[:location] if rest[:location]
|
|
53
|
+
params["attendees"] = rest[:attendees] if rest[:attendees]
|
|
54
|
+
call("create-event", params)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def get_event(event_id, calendar_id: nil)
|
|
58
|
+
params = { "eventId" => event_id }
|
|
59
|
+
params["calendarId"] = calendar_id unless calendar_id.nil?
|
|
60
|
+
call("get-event", params)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Leash
|
|
6
|
+
module Integrations
|
|
7
|
+
# `leash.integrations.drive` — mirrors TS `leash.integrations.drive`.
|
|
8
|
+
# Wire provider id is `google_drive` (also aliased as
|
|
9
|
+
# `leash.integrations.google_drive`).
|
|
10
|
+
class Drive < Base
|
|
11
|
+
PROVIDER = "google_drive"
|
|
12
|
+
|
|
13
|
+
def list_files(query: nil, max_results: nil, folder_id: nil)
|
|
14
|
+
params = compact_params(
|
|
15
|
+
"query" => query,
|
|
16
|
+
"maxResults" => max_results,
|
|
17
|
+
"folderId" => folder_id
|
|
18
|
+
)
|
|
19
|
+
call("list-files", params.empty? ? nil : params)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def get_file(file_id)
|
|
23
|
+
call("get-file", { "fileId" => file_id })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def download_file(file_id)
|
|
27
|
+
call("download-file", { "fileId" => file_id })
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create_folder(name, parent_id: nil)
|
|
31
|
+
params = { "name" => name }
|
|
32
|
+
params["parentId"] = parent_id unless parent_id.nil?
|
|
33
|
+
call("create-folder", params)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def upload_file(name:, content:, mime_type:, parent_id: nil)
|
|
37
|
+
params = { "name" => name, "content" => content, "mimeType" => mime_type }
|
|
38
|
+
params["parentId"] = parent_id unless parent_id.nil?
|
|
39
|
+
call("upload-file", params)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def delete_file(file_id)
|
|
43
|
+
call("delete-file", { "fileId" => file_id })
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def search_files(query, max_results: nil)
|
|
47
|
+
params = { "query" => query }
|
|
48
|
+
params["maxResults"] = max_results unless max_results.nil?
|
|
49
|
+
call("search-files", params)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Leash
|
|
6
|
+
module Integrations
|
|
7
|
+
# `leash.integrations.gmail` — mirrors TS `leash.integrations.gmail`.
|
|
8
|
+
class Gmail < Base
|
|
9
|
+
PROVIDER = "gmail"
|
|
10
|
+
|
|
11
|
+
# @param query [String, nil] Gmail search query
|
|
12
|
+
# @param max_results [Integer, nil]
|
|
13
|
+
# @param label_ids [Array<String>, nil]
|
|
14
|
+
# @param page_token [String, nil]
|
|
15
|
+
def list_messages(query: nil, max_results: nil, label_ids: nil, page_token: nil)
|
|
16
|
+
params = compact_params(
|
|
17
|
+
"query" => query,
|
|
18
|
+
"maxResults" => max_results,
|
|
19
|
+
"labelIds" => label_ids,
|
|
20
|
+
"pageToken" => page_token
|
|
21
|
+
)
|
|
22
|
+
call("list-messages", params.empty? ? nil : params)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param message_id [String]
|
|
26
|
+
# @param format ['full','metadata','minimal','raw']
|
|
27
|
+
def get_message(message_id, format: "full")
|
|
28
|
+
call("get-message", { "messageId" => message_id, "format" => format })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param to [String]
|
|
32
|
+
# @param subject [String]
|
|
33
|
+
# @param body [String]
|
|
34
|
+
# @param cc [String, nil]
|
|
35
|
+
# @param bcc [String, nil]
|
|
36
|
+
def send_message(to:, subject:, body:, cc: nil, bcc: nil)
|
|
37
|
+
params = compact_params(
|
|
38
|
+
"to" => to, "subject" => subject, "body" => body, "cc" => cc, "bcc" => bcc
|
|
39
|
+
)
|
|
40
|
+
call("send-message", params)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param query [String]
|
|
44
|
+
# @param max_results [Integer, nil]
|
|
45
|
+
def search_messages(query, max_results: nil)
|
|
46
|
+
params = { "query" => query }
|
|
47
|
+
params["maxResults"] = max_results unless max_results.nil?
|
|
48
|
+
call("search-messages", params)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def list_labels
|
|
52
|
+
call("list-labels")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get_profile
|
|
56
|
+
call("get-profile")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|