wefunder 0.1.0.beta1 → 0.1.0.beta3
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/README.md +17 -4
- data/lib/wefunder/client.rb +42 -14
- data/lib/wefunder/oauth.rb +11 -8
- data/lib/wefunder/token_manager.rb +64 -8
- data/lib/wefunder/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3ac10a62a698ee6cb163f3ec6e100248d461dc6c55bf97caeb1bac349b44af6
|
|
4
|
+
data.tar.gz: 26d0e08433a88d288942da627f8d509ffa196e6e2ed3cc347bae43beadaf84bf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ee97ef8052e3e7a9476a8b0243b52a706bfd2c6a5911e1b9362c07e5d5d6290529615ed6daace3d953769d126a492440692ebef5191cd7846c418a70f6a54b5
|
|
7
|
+
data.tar.gz: 535800baceafa25b8fcb744d2a1fc16a0596e14d80db5fba6f0c83772ee371c16a361335f82819b5a5e664bf016d17b008d3ff46a3f07f527eab491d8aa36bdd
|
data/README.md
CHANGED
|
@@ -84,6 +84,12 @@ class TokenStore
|
|
|
84
84
|
end
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
The rotated set is saved **before** any thread can use it. If `store.save` or `on_token_refresh` raises, the SDK raises
|
|
88
|
+
`Wefunder::TokenPersistenceError` and keeps the rotated set *pending*: no request is made with it, the
|
|
89
|
+
consumed refresh token is never reused, and the next call retries the save (or persist
|
|
90
|
+
`error.tokens` yourself and call `wf.token_manager.mark_persisted!(error.tokens)`; a stale acknowledgment after another rotation is a no-op). Alert on this error; a process
|
|
91
|
+
that keeps failing to save cannot reconnect after a restart.
|
|
92
|
+
|
|
87
93
|
Concurrent requests that hit a 401 at the same time share one refresh (the client is thread-safe).
|
|
88
94
|
If several application instances can use the same OAuth connection, serialize refreshes for that
|
|
89
95
|
connection yourself.
|
|
@@ -98,7 +104,7 @@ portfolio = wf.portfolio.get
|
|
|
98
104
|
|
|
99
105
|
Namespaces: `users`, `offerings`, `investments`, `portfolio`, `campaigns`, `syndicates`, `intents`,
|
|
100
106
|
`attribution`, and `webhook_endpoints`. Query parameters are keyword arguments; enum-typed ones take
|
|
101
|
-
plain strings.
|
|
107
|
+
plain strings, and `Time` / `DateTime` / `Date` values are sent as ISO 8601.
|
|
102
108
|
|
|
103
109
|
`wf.investments` is the Investment Delta API. `list` without a cursor bootstraps; pass `updated_since:`
|
|
104
110
|
or the `meta.next_cursor` you saved from your last page to receive only records that changed since then.
|
|
@@ -136,7 +142,11 @@ end
|
|
|
136
142
|
|
|
137
143
|
The SDK retries idempotent `GET` requests after transient network errors, `5xx` responses, and rate
|
|
138
144
|
limits (honouring `X-RateLimit-Reset`). Write requests are not retried automatically, except once after
|
|
139
|
-
a `401` has been recovered.
|
|
145
|
+
a `401` has been recovered. Exhausted network failures raise `Wefunder::Error` with `status` 0 and
|
|
146
|
+
`type` `"network_error"`, from namespaces and `request` alike.
|
|
147
|
+
|
|
148
|
+
`timeout:` (seconds, default 30) applies to every request the client makes: API calls, `request`, and
|
|
149
|
+
the OAuth token round-trips for refresh and re-mint.
|
|
140
150
|
|
|
141
151
|
## Webhooks
|
|
142
152
|
|
|
@@ -220,10 +230,13 @@ sends a fully-wrapped request and returns the decoded JSON.
|
|
|
220
230
|
```bash
|
|
221
231
|
bundle install
|
|
222
232
|
bundle exec rubocop
|
|
223
|
-
bundle exec rspec
|
|
224
|
-
bundle exec rspec spec/e2e
|
|
233
|
+
bundle exec rspec # hermetic — live examples are tagged :e2e and filtered out
|
|
234
|
+
WEFUNDER_E2E=1 bundle exec rspec spec/e2e # live sandbox; also needs WEFUNDER_CLIENT_ID / WEFUNDER_CLIENT_SECRET
|
|
225
235
|
```
|
|
226
236
|
|
|
237
|
+
The live group is opt-in by tag, not by path: without `WEFUNDER_E2E=1` it never runs, even when sandbox
|
|
238
|
+
credentials happen to be exported in your shell.
|
|
239
|
+
|
|
227
240
|
Generated files in `lib/wefunder_generated/` come from `openapi/openapi.yaml` via `script/generate`
|
|
228
241
|
(openapi-generator in Docker; no Java needed) and are never edited by hand.
|
|
229
242
|
|
data/lib/wefunder/client.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
require "uri"
|
|
5
6
|
require "faraday"
|
|
6
7
|
|
|
@@ -36,11 +37,12 @@ module Wefunder
|
|
|
36
37
|
|
|
37
38
|
_ = authorize_base_url # only /authorize uses it; see OAuth.create_authorization_url
|
|
38
39
|
@mode = Wefunder.mode_for_token(token_set.access_token)
|
|
40
|
+
@timeout = timeout
|
|
39
41
|
token_base = OAuth.resolve_token_base(token_base_url: token_base_url, oauth_base_url: oauth_base_url)
|
|
40
|
-
re_mint = build_re_mint(client_credentials, client_id, client_secret, token_base, faraday, now)
|
|
42
|
+
re_mint = build_re_mint(client_credentials, client_id, client_secret, token_base, faraday, now, timeout)
|
|
41
43
|
@token_manager = TokenManager.new(token_set, client_id: client_id, client_secret: client_secret, re_mint: re_mint,
|
|
42
44
|
on_token_refresh: on_token_refresh, store: store, faraday: faraday, now: now,
|
|
43
|
-
token_base_url: token_base)
|
|
45
|
+
token_base_url: token_base, timeout: timeout)
|
|
44
46
|
@api_client = build_api_client(base_url, api_version, retry_options, faraday, now, sleep, random, timeout)
|
|
45
47
|
@raw = Raw.new(@api_client)
|
|
46
48
|
@users = Users.new(self)
|
|
@@ -59,7 +61,8 @@ module Wefunder
|
|
|
59
61
|
def self.from_client_credentials(client_id:, client_secret:, scopes: nil, **opts)
|
|
60
62
|
tokens = OAuth.client_credentials_grant(
|
|
61
63
|
client_id: client_id, client_secret: client_secret, scopes: scopes,
|
|
62
|
-
token_base_url: opts[:token_base_url], oauth_base_url: opts[:oauth_base_url], faraday: opts[:faraday], now: opts[:now]
|
|
64
|
+
token_base_url: opts[:token_base_url], oauth_base_url: opts[:oauth_base_url], faraday: opts[:faraday], now: opts[:now],
|
|
65
|
+
timeout: opts.fetch(:timeout, 30)
|
|
63
66
|
)
|
|
64
67
|
new(tokens: tokens, client_id: client_id, client_secret: client_secret, client_credentials: { scopes: scopes }, **opts)
|
|
65
68
|
end
|
|
@@ -67,6 +70,9 @@ module Wefunder
|
|
|
67
70
|
# The current token set (rotated refresh token included).
|
|
68
71
|
def tokens = @token_manager.current
|
|
69
72
|
|
|
73
|
+
# The TokenManager (for +mark_persisted!+ after handling a TokenPersistenceError).
|
|
74
|
+
attr_reader :token_manager
|
|
75
|
+
|
|
70
76
|
# The generated APIs, one accessor per tag (+wf.raw.syndicate_members.list_syndicate_members(id)+).
|
|
71
77
|
# Wrap calls in +wrap+ to get typed errors.
|
|
72
78
|
attr_reader :raw
|
|
@@ -88,7 +94,8 @@ module Wefunder
|
|
|
88
94
|
payload = body.nil? || body.is_a?(String) ? body : JSON.generate(body)
|
|
89
95
|
hdrs = @api_client.default_headers.merge(headers || {})
|
|
90
96
|
response = conn.run_request(method.to_s.downcase.to_sym, path, payload, hdrs) do |req|
|
|
91
|
-
req.
|
|
97
|
+
req.options.timeout = @timeout if @timeout # same timeout as the generated calls
|
|
98
|
+
req.params.update(Client.normalize_params((query || {}).compact).transform_keys(&:to_s))
|
|
92
99
|
end
|
|
93
100
|
if response.status >= 400
|
|
94
101
|
raise Error.from_response(response.status, response.headers, response.body,
|
|
@@ -96,6 +103,22 @@ module Wefunder
|
|
|
96
103
|
end
|
|
97
104
|
|
|
98
105
|
response.body.to_s.empty? ? nil : JSON.parse(response.body)
|
|
106
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError => e
|
|
107
|
+
# Same shape the generated calls produce through `wrap` (ApiError with no code).
|
|
108
|
+
raise Error.new(status: 0, type: "network_error", message: e.message.to_s)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Query values the API wants as ISO 8601: Time / DateTime → +iso8601+, Date → +YYYY-MM-DD+.
|
|
112
|
+
# Faraday would otherwise serialize a Time with +to_s+ ("2026-09-01 00:00:00 UTC"), which
|
|
113
|
+
# the server rejects with 400.
|
|
114
|
+
def self.normalize_params(opts)
|
|
115
|
+
opts.transform_values do |v|
|
|
116
|
+
case v
|
|
117
|
+
when Date then v.iso8601 # Date and DateTime (DateTime < Date)
|
|
118
|
+
when Time then v.utc.iso8601
|
|
119
|
+
else v
|
|
120
|
+
end
|
|
121
|
+
end
|
|
99
122
|
end
|
|
100
123
|
|
|
101
124
|
# Single-resource envelopes are +{data: Entity}+; return the entity for ergonomics.
|
|
@@ -107,13 +130,13 @@ module Wefunder
|
|
|
107
130
|
|
|
108
131
|
private
|
|
109
132
|
|
|
110
|
-
def build_re_mint(client_credentials, client_id, client_secret, token_base, faraday, now)
|
|
133
|
+
def build_re_mint(client_credentials, client_id, client_secret, token_base, faraday, now, timeout)
|
|
111
134
|
return nil unless client_credentials && client_id && client_secret
|
|
112
135
|
|
|
113
136
|
scopes = client_credentials[:scopes] || client_credentials["scopes"]
|
|
114
137
|
lambda do
|
|
115
138
|
OAuth.client_credentials_grant(client_id: client_id, client_secret: client_secret, scopes: scopes,
|
|
116
|
-
token_base_url: token_base, faraday: faraday, now: now)
|
|
139
|
+
token_base_url: token_base, faraday: faraday, now: now, timeout: timeout)
|
|
117
140
|
end
|
|
118
141
|
end
|
|
119
142
|
|
|
@@ -172,9 +195,11 @@ module Wefunder
|
|
|
172
195
|
def wrap(&) = @client.wrap(&)
|
|
173
196
|
def raw = @client.raw
|
|
174
197
|
def data_of(env) = Client.data_of(env)
|
|
198
|
+
def norm(opts) = Client.normalize_params(opts)
|
|
175
199
|
|
|
176
200
|
# Auto-paginate a list method, keeping +opts+ on every page and threading the cursor.
|
|
177
201
|
def pages(opts, &fetch)
|
|
202
|
+
opts = norm(opts)
|
|
178
203
|
Wefunder.paginate { |cursor| wrap { fetch.call(cursor.nil? ? opts : opts.merge(cursor: cursor)) } }
|
|
179
204
|
end
|
|
180
205
|
end
|
|
@@ -186,7 +211,7 @@ module Wefunder
|
|
|
186
211
|
|
|
187
212
|
class Offerings < Namespace
|
|
188
213
|
# One page of GET /explore (+sort:+, +cursor:+, filters). Returns the envelope.
|
|
189
|
-
def list(**opts) = wrap { raw.explore.list_offerings(opts) }
|
|
214
|
+
def list(**opts) = wrap { raw.explore.list_offerings(norm(opts)) }
|
|
190
215
|
# Every offering, lazily, +opts+ preserved across pages.
|
|
191
216
|
def all(**opts) = pages(opts) { |o| raw.explore.list_offerings(o) }
|
|
192
217
|
def get(offering_id) = data_of(wrap { raw.explore.get_offering(offering_id) })
|
|
@@ -196,7 +221,7 @@ module Wefunder
|
|
|
196
221
|
# The Investment Delta API. +list+ without a cursor bootstraps; +next_cursor+ is always
|
|
197
222
|
# present (persist it after every sync) and +has_more+ terminates.
|
|
198
223
|
class Investments < Namespace
|
|
199
|
-
def list(**opts) = wrap { raw.investments.list_investments(opts) }
|
|
224
|
+
def list(**opts) = wrap { raw.investments.list_investments(norm(opts)) }
|
|
200
225
|
def all(**opts) = pages(opts) { |o| raw.investments.list_investments(o) }
|
|
201
226
|
def collect(**) = all(**).to_a
|
|
202
227
|
def get(investment_id) = data_of(wrap { raw.investments.get_investment(investment_id) })
|
|
@@ -204,7 +229,7 @@ module Wefunder
|
|
|
204
229
|
|
|
205
230
|
class Portfolio < Namespace
|
|
206
231
|
Positions = Class.new(Namespace) do
|
|
207
|
-
def list(**opts) = wrap { raw.portfolio.list_portfolio_positions(opts) }
|
|
232
|
+
def list(**opts) = wrap { raw.portfolio.list_portfolio_positions(norm(opts)) }
|
|
208
233
|
def all(**opts) = pages(opts) { |o| raw.portfolio.list_portfolio_positions(o) }
|
|
209
234
|
end
|
|
210
235
|
|
|
@@ -216,19 +241,22 @@ module Wefunder
|
|
|
216
241
|
end
|
|
217
242
|
|
|
218
243
|
# The portfolio summary (+status:+, +company:+ filters).
|
|
219
|
-
def get(**opts) = data_of(wrap { raw.portfolio.get_portfolio(opts) })
|
|
244
|
+
def get(**opts) = data_of(wrap { raw.portfolio.get_portfolio(norm(opts)) })
|
|
220
245
|
end
|
|
221
246
|
|
|
222
247
|
class Campaigns < Namespace
|
|
223
|
-
def list(**opts) = wrap { raw.campaigns.list_campaigns(opts) }
|
|
248
|
+
def list(**opts) = wrap { raw.campaigns.list_campaigns(norm(opts)) }
|
|
224
249
|
def all(**opts) = pages(opts) { |o| raw.campaigns.list_campaigns(o) }
|
|
225
250
|
end
|
|
226
251
|
|
|
227
252
|
class Syndicates < Namespace
|
|
228
|
-
def list(**opts) = wrap { raw.syndicates.list_syndicates(opts) }
|
|
253
|
+
def list(**opts) = wrap { raw.syndicates.list_syndicates(norm(opts)) }
|
|
229
254
|
def all(**opts) = pages(opts) { |o| raw.syndicates.list_syndicates(o) }
|
|
230
255
|
def get(syndicate_id) = data_of(wrap { raw.syndicates.get_syndicate(syndicate_id) })
|
|
231
|
-
|
|
256
|
+
|
|
257
|
+
def portfolio(syndicate_id, **opts)
|
|
258
|
+
data_of(wrap { raw.syndicate_portfolio.get_syndicate_portfolio(syndicate_id, norm(opts)) })
|
|
259
|
+
end
|
|
232
260
|
|
|
233
261
|
def portfolio_positions(syndicate_id, **opts)
|
|
234
262
|
pages(opts) { |o| raw.syndicate_portfolio.list_syndicate_portfolio_positions(syndicate_id, o) }
|
|
@@ -236,7 +264,7 @@ module Wefunder
|
|
|
236
264
|
end
|
|
237
265
|
|
|
238
266
|
class Intents < Namespace
|
|
239
|
-
def list(**opts) = wrap { raw.intents.list_intents(opts) }
|
|
267
|
+
def list(**opts) = wrap { raw.intents.list_intents(norm(opts)) }
|
|
240
268
|
def all(**opts) = pages(opts) { |o| raw.intents.list_intents(o) }
|
|
241
269
|
def get(intent_id) = data_of(wrap { raw.intents.get_intent(intent_id) })
|
|
242
270
|
def create(body) = data_of(wrap { raw.intents.create_intent(WefunderGenerated::CreateIntentRequest.new(body)) })
|
data/lib/wefunder/oauth.rb
CHANGED
|
@@ -93,32 +93,35 @@ module Wefunder
|
|
|
93
93
|
|
|
94
94
|
# Exchange an authorization code (+ PKCE verifier) for a token set. Public (PKCE) clients
|
|
95
95
|
# omit +client_secret+; confidential clients pass it.
|
|
96
|
-
def exchange_code(client_id:, code:, redirect_uri:, code_verifier:, client_secret: nil, faraday: nil, now: nil,
|
|
96
|
+
def exchange_code(client_id:, code:, redirect_uri:, code_verifier:, client_secret: nil, faraday: nil, now: nil, timeout: nil,
|
|
97
|
+
**hosts)
|
|
97
98
|
params = { grant_type: "authorization_code", client_id: client_id, code: code, redirect_uri: redirect_uri,
|
|
98
99
|
code_verifier: code_verifier }
|
|
99
100
|
params[:client_secret] = client_secret if client_secret
|
|
100
|
-
post_token(resolve_token_base(**hosts), params, faraday, now)
|
|
101
|
+
post_token(resolve_token_base(**hosts), params, faraday, now, timeout)
|
|
101
102
|
end
|
|
102
103
|
|
|
103
104
|
# Mint an application token (server-to-server). cc tokens carry no refresh token.
|
|
104
|
-
def client_credentials_grant(client_id:, client_secret:, scopes: nil, faraday: nil, now: nil, **hosts)
|
|
105
|
+
def client_credentials_grant(client_id:, client_secret:, scopes: nil, faraday: nil, now: nil, timeout: nil, **hosts)
|
|
105
106
|
params = { grant_type: "client_credentials", client_id: client_id, client_secret: client_secret }
|
|
106
107
|
params[:scope] = Array(scopes).join(" ") if scopes && !Array(scopes).empty?
|
|
107
|
-
post_token(resolve_token_base(**hosts), params, faraday, now)
|
|
108
|
+
post_token(resolve_token_base(**hosts), params, faraday, now, timeout)
|
|
108
109
|
end
|
|
109
110
|
|
|
110
111
|
# Refresh an access token. CRITICAL: the result carries a NEW refresh token (rotation) —
|
|
111
112
|
# persist it. Reusing the old one after rotation is a permanent 401.
|
|
112
|
-
def refresh_token(client_id:, refresh_token:, client_secret: nil, faraday: nil, now: nil, **hosts)
|
|
113
|
+
def refresh_token(client_id:, refresh_token:, client_secret: nil, faraday: nil, now: nil, timeout: nil, **hosts)
|
|
113
114
|
params = { grant_type: "refresh_token", client_id: client_id, refresh_token: refresh_token }
|
|
114
115
|
params[:client_secret] = client_secret if client_secret
|
|
115
|
-
post_token(resolve_token_base(**hosts), params, faraday, now)
|
|
116
|
+
post_token(resolve_token_base(**hosts), params, faraday, now, timeout)
|
|
116
117
|
end
|
|
117
118
|
|
|
118
119
|
# +faraday+ is an optional callable that configures the connection (tests inject an
|
|
119
|
-
# adapter: +->(conn) { conn.adapter :test, stubs }+).
|
|
120
|
-
|
|
120
|
+
# adapter: +->(conn) { conn.adapter :test, stubs }+). +timeout+ (seconds) covers the token
|
|
121
|
+
# round-trip so refreshes honour the client's timeout like every other call.
|
|
122
|
+
def post_token(base, params, faraday, now, timeout = nil)
|
|
121
123
|
conn = Faraday.new do |c|
|
|
124
|
+
c.options.timeout = timeout if timeout
|
|
122
125
|
faraday&.call(c)
|
|
123
126
|
c.adapter Faraday.default_adapter unless faraday
|
|
124
127
|
end
|
|
@@ -8,14 +8,29 @@ module Wefunder
|
|
|
8
8
|
# manager already holds a different one, another caller recovered in the meantime and the
|
|
9
9
|
# current set is returned without a round-trip. The rotated set is persisted (store +
|
|
10
10
|
# callback) BEFORE the retried request can use it. Thread-safe (Mutex).
|
|
11
|
+
# Raised when the token store failed to save a rotated token set. +tokens+ is the rotated
|
|
12
|
+
# set that is NOT yet durable and NOT yet in use; the manager keeps it pending and retries
|
|
13
|
+
# the save on the next call (or persist it yourself and call +mark_persisted!(tokens)+). Until it is
|
|
14
|
+
# saved no request is made with it — the consumed refresh token is never reused either.
|
|
15
|
+
class TokenPersistenceError < StandardError
|
|
16
|
+
attr_reader :tokens
|
|
17
|
+
|
|
18
|
+
def initialize(tokens, cause)
|
|
19
|
+
super("Token store failed to save the rotated token set: #{cause.message}")
|
|
20
|
+
@tokens = tokens
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
11
24
|
class TokenManager
|
|
12
25
|
DEFAULT_EXPIRY_LEEWAY_SECONDS = 30.0
|
|
13
26
|
|
|
14
|
-
|
|
27
|
+
# The durable, in-use token set. A rotated set that could not be persisted is held in
|
|
28
|
+
# +pending_tokens+ instead until a save succeeds.
|
|
29
|
+
attr_reader :current, :pending_tokens
|
|
15
30
|
|
|
16
31
|
def initialize(tokens, client_id: nil, client_secret: nil, re_mint: nil, on_token_refresh: nil, store: nil,
|
|
17
32
|
faraday: nil, now: nil, expiry_leeway_seconds: DEFAULT_EXPIRY_LEEWAY_SECONDS,
|
|
18
|
-
token_base_url: nil, oauth_base_url: nil)
|
|
33
|
+
token_base_url: nil, oauth_base_url: nil, timeout: nil)
|
|
19
34
|
@current = tokens
|
|
20
35
|
@client_id = client_id
|
|
21
36
|
@client_secret = client_secret
|
|
@@ -23,6 +38,7 @@ module Wefunder
|
|
|
23
38
|
@on_token_refresh = on_token_refresh
|
|
24
39
|
@store = store
|
|
25
40
|
@faraday = faraday
|
|
41
|
+
@timeout = timeout
|
|
26
42
|
@now = now || -> { Time.now.to_f }
|
|
27
43
|
@leeway = expiry_leeway_seconds
|
|
28
44
|
@token_base_url = OAuth.resolve_token_base(token_base_url: token_base_url, oauth_base_url: oauth_base_url)
|
|
@@ -34,23 +50,42 @@ module Wefunder
|
|
|
34
50
|
can_rotate? || !@re_mint.nil?
|
|
35
51
|
end
|
|
36
52
|
|
|
37
|
-
# A valid access token, refreshing proactively if expired / within the leeway.
|
|
53
|
+
# A valid access token, refreshing proactively if expired / within the leeway. If a rotated
|
|
54
|
+
# set is pending persistence, the save is retried first — no request uses an undurable token.
|
|
38
55
|
def access_token
|
|
56
|
+
@lock.synchronize { publish_pending! if @pending_tokens } if @pending_tokens # re-check under the lock
|
|
39
57
|
refresh(stale_token: @current.access_token) if near_expiry? && can_refresh?
|
|
40
58
|
@current.access_token
|
|
41
59
|
end
|
|
42
60
|
|
|
61
|
+
# Tell the manager you persisted +tokens+ (the set from a TokenPersistenceError) yourself.
|
|
62
|
+
# Publishes it only if it is still the pending set; a stale acknowledgment (the manager has
|
|
63
|
+
# since rotated again) is a no-op and returns false, so an older save can never publish a
|
|
64
|
+
# newer, unsaved set.
|
|
65
|
+
def mark_persisted!(tokens)
|
|
66
|
+
@lock.synchronize do
|
|
67
|
+
return false unless @pending_tokens && same_token_set?(@pending_tokens, tokens)
|
|
68
|
+
|
|
69
|
+
@current = @pending_tokens
|
|
70
|
+
@pending_tokens = nil
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
43
75
|
# Recover the token (after a 401 or proactively). If +stale_token+ is no longer the current
|
|
44
76
|
# token, someone else already recovered and the current set is returned.
|
|
45
77
|
def refresh(stale_token: nil)
|
|
46
78
|
@lock.synchronize do
|
|
79
|
+
# A rotated set awaiting persistence: retry the save rather than rotating again (the
|
|
80
|
+
# old refresh token was consumed by that rotation).
|
|
81
|
+
return publish_pending! if @pending_tokens
|
|
47
82
|
return @current if stale_token && @current.access_token != stale_token
|
|
48
83
|
|
|
49
84
|
previous_refresh = @current.refresh_token
|
|
50
85
|
nxt =
|
|
51
86
|
if can_rotate?
|
|
52
87
|
set = OAuth.refresh_token(client_id: @client_id, client_secret: @client_secret, refresh_token: previous_refresh,
|
|
53
|
-
token_base_url: @token_base_url, faraday: @faraday, now: @now)
|
|
88
|
+
token_base_url: @token_base_url, faraday: @faraday, now: @now, timeout: @timeout)
|
|
54
89
|
# Some servers omit a fresh refresh_token on rotation-disabled flows; keep the old one.
|
|
55
90
|
set.refresh_token ||= previous_refresh
|
|
56
91
|
set
|
|
@@ -59,15 +94,36 @@ module Wefunder
|
|
|
59
94
|
else
|
|
60
95
|
raise AuthError, "Access token expired and no refresh token / re-mint capability is configured."
|
|
61
96
|
end
|
|
62
|
-
@
|
|
63
|
-
|
|
64
|
-
@on_token_refresh&.call(nxt)
|
|
65
|
-
nxt
|
|
97
|
+
@pending_tokens = nxt
|
|
98
|
+
publish_pending!
|
|
66
99
|
end
|
|
67
100
|
end
|
|
68
101
|
|
|
69
102
|
private
|
|
70
103
|
|
|
104
|
+
# Persist BEFORE publishing (caller holds the lock): no thread may use the rotated token
|
|
105
|
+
# until it is durable, and a failed save must not leave the process working in memory but
|
|
106
|
+
# unable to reconnect after a restart. On failure the set stays in +pending_tokens+ and
|
|
107
|
+
# TokenPersistenceError is raised; the next call retries the save.
|
|
108
|
+
def publish_pending!
|
|
109
|
+
tokens = @pending_tokens
|
|
110
|
+
begin
|
|
111
|
+
@store&.save(tokens)
|
|
112
|
+
# on_token_refresh is a persistence path too, so it runs BEFORE publication and a failure
|
|
113
|
+
# keeps the set pending exactly like a store failure.
|
|
114
|
+
@on_token_refresh&.call(tokens)
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
raise TokenPersistenceError.new(tokens, e)
|
|
117
|
+
end
|
|
118
|
+
@pending_tokens = nil
|
|
119
|
+
@current = tokens
|
|
120
|
+
tokens
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def same_token_set?(a, b)
|
|
124
|
+
a.access_token == b.access_token && a.refresh_token == b.refresh_token
|
|
125
|
+
end
|
|
126
|
+
|
|
71
127
|
def can_rotate?
|
|
72
128
|
!@current.refresh_token.nil? && !@current.refresh_token.empty? && !@client_id.nil?
|
|
73
129
|
end
|
data/lib/wefunder/version.rb
CHANGED