wefunder 0.1.0.beta1 → 0.1.0.beta2
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 +11 -2
- data/lib/wefunder/client.rb +39 -14
- data/lib/wefunder/oauth.rb +11 -8
- data/lib/wefunder/token_manager.rb +27 -3
- 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: 946ede1e59f03277730fa442f9df079e44bac9532db1587306f488d1d8c0a616
|
|
4
|
+
data.tar.gz: 53ca111474c48a0e954a690caa7001b34cad1c28cd9ba2e275d2489e5f3a235c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 96cafc3351d195aae306c22a512d0ae1ea7613b2a0d7ffdc843cfc2c37614006effae50197c38c175020ecee6145140e2271f5c26355b39a53eac7a2636626c4
|
|
7
|
+
data.tar.gz: 381a50ca34d2084e923b4336e4d39b7d6403ad6f2c15feb03602493d0ab1ad3ebf88123bf5bf86cb75bab1a718f7709f00e4b7d8d79b1ac34973e7d22fe1ef3e
|
data/README.md
CHANGED
|
@@ -84,6 +84,11 @@ class TokenStore
|
|
|
84
84
|
end
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
The rotated set is saved **before** any thread can use it. If `store.save` raises, the SDK still
|
|
88
|
+
switches to the new set in memory (the old refresh token is already dead) and raises
|
|
89
|
+
`Wefunder::TokenPersistenceError`, whose `tokens` is the set that is live but not durable. Alert on it
|
|
90
|
+
and retry the save; do not swallow it, or a restart will not be able to reconnect.
|
|
91
|
+
|
|
87
92
|
Concurrent requests that hit a 401 at the same time share one refresh (the client is thread-safe).
|
|
88
93
|
If several application instances can use the same OAuth connection, serialize refreshes for that
|
|
89
94
|
connection yourself.
|
|
@@ -98,7 +103,7 @@ portfolio = wf.portfolio.get
|
|
|
98
103
|
|
|
99
104
|
Namespaces: `users`, `offerings`, `investments`, `portfolio`, `campaigns`, `syndicates`, `intents`,
|
|
100
105
|
`attribution`, and `webhook_endpoints`. Query parameters are keyword arguments; enum-typed ones take
|
|
101
|
-
plain strings.
|
|
106
|
+
plain strings, and `Time` / `DateTime` / `Date` values are sent as ISO 8601.
|
|
102
107
|
|
|
103
108
|
`wf.investments` is the Investment Delta API. `list` without a cursor bootstraps; pass `updated_since:`
|
|
104
109
|
or the `meta.next_cursor` you saved from your last page to receive only records that changed since then.
|
|
@@ -136,7 +141,11 @@ end
|
|
|
136
141
|
|
|
137
142
|
The SDK retries idempotent `GET` requests after transient network errors, `5xx` responses, and rate
|
|
138
143
|
limits (honouring `X-RateLimit-Reset`). Write requests are not retried automatically, except once after
|
|
139
|
-
a `401` has been recovered.
|
|
144
|
+
a `401` has been recovered. Exhausted network failures raise `Wefunder::Error` with `status` 0 and
|
|
145
|
+
`type` `"network_error"`, from namespaces and `request` alike.
|
|
146
|
+
|
|
147
|
+
`timeout:` (seconds, default 30) applies to every request the client makes: API calls, `request`, and
|
|
148
|
+
the OAuth token round-trips for refresh and re-mint.
|
|
140
149
|
|
|
141
150
|
## Webhooks
|
|
142
151
|
|
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
|
|
@@ -88,7 +91,8 @@ module Wefunder
|
|
|
88
91
|
payload = body.nil? || body.is_a?(String) ? body : JSON.generate(body)
|
|
89
92
|
hdrs = @api_client.default_headers.merge(headers || {})
|
|
90
93
|
response = conn.run_request(method.to_s.downcase.to_sym, path, payload, hdrs) do |req|
|
|
91
|
-
req.
|
|
94
|
+
req.options.timeout = @timeout if @timeout # same timeout as the generated calls
|
|
95
|
+
req.params.update(Client.normalize_params((query || {}).compact).transform_keys(&:to_s))
|
|
92
96
|
end
|
|
93
97
|
if response.status >= 400
|
|
94
98
|
raise Error.from_response(response.status, response.headers, response.body,
|
|
@@ -96,6 +100,22 @@ module Wefunder
|
|
|
96
100
|
end
|
|
97
101
|
|
|
98
102
|
response.body.to_s.empty? ? nil : JSON.parse(response.body)
|
|
103
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError => e
|
|
104
|
+
# Same shape the generated calls produce through `wrap` (ApiError with no code).
|
|
105
|
+
raise Error.new(status: 0, type: "network_error", message: e.message.to_s)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Query values the API wants as ISO 8601: Time / DateTime → +iso8601+, Date → +YYYY-MM-DD+.
|
|
109
|
+
# Faraday would otherwise serialize a Time with +to_s+ ("2026-09-01 00:00:00 UTC"), which
|
|
110
|
+
# the server rejects with 400.
|
|
111
|
+
def self.normalize_params(opts)
|
|
112
|
+
opts.transform_values do |v|
|
|
113
|
+
case v
|
|
114
|
+
when Date then v.iso8601 # Date and DateTime (DateTime < Date)
|
|
115
|
+
when Time then v.utc.iso8601
|
|
116
|
+
else v
|
|
117
|
+
end
|
|
118
|
+
end
|
|
99
119
|
end
|
|
100
120
|
|
|
101
121
|
# Single-resource envelopes are +{data: Entity}+; return the entity for ergonomics.
|
|
@@ -107,13 +127,13 @@ module Wefunder
|
|
|
107
127
|
|
|
108
128
|
private
|
|
109
129
|
|
|
110
|
-
def build_re_mint(client_credentials, client_id, client_secret, token_base, faraday, now)
|
|
130
|
+
def build_re_mint(client_credentials, client_id, client_secret, token_base, faraday, now, timeout)
|
|
111
131
|
return nil unless client_credentials && client_id && client_secret
|
|
112
132
|
|
|
113
133
|
scopes = client_credentials[:scopes] || client_credentials["scopes"]
|
|
114
134
|
lambda do
|
|
115
135
|
OAuth.client_credentials_grant(client_id: client_id, client_secret: client_secret, scopes: scopes,
|
|
116
|
-
token_base_url: token_base, faraday: faraday, now: now)
|
|
136
|
+
token_base_url: token_base, faraday: faraday, now: now, timeout: timeout)
|
|
117
137
|
end
|
|
118
138
|
end
|
|
119
139
|
|
|
@@ -172,9 +192,11 @@ module Wefunder
|
|
|
172
192
|
def wrap(&) = @client.wrap(&)
|
|
173
193
|
def raw = @client.raw
|
|
174
194
|
def data_of(env) = Client.data_of(env)
|
|
195
|
+
def norm(opts) = Client.normalize_params(opts)
|
|
175
196
|
|
|
176
197
|
# Auto-paginate a list method, keeping +opts+ on every page and threading the cursor.
|
|
177
198
|
def pages(opts, &fetch)
|
|
199
|
+
opts = norm(opts)
|
|
178
200
|
Wefunder.paginate { |cursor| wrap { fetch.call(cursor.nil? ? opts : opts.merge(cursor: cursor)) } }
|
|
179
201
|
end
|
|
180
202
|
end
|
|
@@ -186,7 +208,7 @@ module Wefunder
|
|
|
186
208
|
|
|
187
209
|
class Offerings < Namespace
|
|
188
210
|
# One page of GET /explore (+sort:+, +cursor:+, filters). Returns the envelope.
|
|
189
|
-
def list(**opts) = wrap { raw.explore.list_offerings(opts) }
|
|
211
|
+
def list(**opts) = wrap { raw.explore.list_offerings(norm(opts)) }
|
|
190
212
|
# Every offering, lazily, +opts+ preserved across pages.
|
|
191
213
|
def all(**opts) = pages(opts) { |o| raw.explore.list_offerings(o) }
|
|
192
214
|
def get(offering_id) = data_of(wrap { raw.explore.get_offering(offering_id) })
|
|
@@ -196,7 +218,7 @@ module Wefunder
|
|
|
196
218
|
# The Investment Delta API. +list+ without a cursor bootstraps; +next_cursor+ is always
|
|
197
219
|
# present (persist it after every sync) and +has_more+ terminates.
|
|
198
220
|
class Investments < Namespace
|
|
199
|
-
def list(**opts) = wrap { raw.investments.list_investments(opts) }
|
|
221
|
+
def list(**opts) = wrap { raw.investments.list_investments(norm(opts)) }
|
|
200
222
|
def all(**opts) = pages(opts) { |o| raw.investments.list_investments(o) }
|
|
201
223
|
def collect(**) = all(**).to_a
|
|
202
224
|
def get(investment_id) = data_of(wrap { raw.investments.get_investment(investment_id) })
|
|
@@ -204,7 +226,7 @@ module Wefunder
|
|
|
204
226
|
|
|
205
227
|
class Portfolio < Namespace
|
|
206
228
|
Positions = Class.new(Namespace) do
|
|
207
|
-
def list(**opts) = wrap { raw.portfolio.list_portfolio_positions(opts) }
|
|
229
|
+
def list(**opts) = wrap { raw.portfolio.list_portfolio_positions(norm(opts)) }
|
|
208
230
|
def all(**opts) = pages(opts) { |o| raw.portfolio.list_portfolio_positions(o) }
|
|
209
231
|
end
|
|
210
232
|
|
|
@@ -216,19 +238,22 @@ module Wefunder
|
|
|
216
238
|
end
|
|
217
239
|
|
|
218
240
|
# The portfolio summary (+status:+, +company:+ filters).
|
|
219
|
-
def get(**opts) = data_of(wrap { raw.portfolio.get_portfolio(opts) })
|
|
241
|
+
def get(**opts) = data_of(wrap { raw.portfolio.get_portfolio(norm(opts)) })
|
|
220
242
|
end
|
|
221
243
|
|
|
222
244
|
class Campaigns < Namespace
|
|
223
|
-
def list(**opts) = wrap { raw.campaigns.list_campaigns(opts) }
|
|
245
|
+
def list(**opts) = wrap { raw.campaigns.list_campaigns(norm(opts)) }
|
|
224
246
|
def all(**opts) = pages(opts) { |o| raw.campaigns.list_campaigns(o) }
|
|
225
247
|
end
|
|
226
248
|
|
|
227
249
|
class Syndicates < Namespace
|
|
228
|
-
def list(**opts) = wrap { raw.syndicates.list_syndicates(opts) }
|
|
250
|
+
def list(**opts) = wrap { raw.syndicates.list_syndicates(norm(opts)) }
|
|
229
251
|
def all(**opts) = pages(opts) { |o| raw.syndicates.list_syndicates(o) }
|
|
230
252
|
def get(syndicate_id) = data_of(wrap { raw.syndicates.get_syndicate(syndicate_id) })
|
|
231
|
-
|
|
253
|
+
|
|
254
|
+
def portfolio(syndicate_id, **opts)
|
|
255
|
+
data_of(wrap { raw.syndicate_portfolio.get_syndicate_portfolio(syndicate_id, norm(opts)) })
|
|
256
|
+
end
|
|
232
257
|
|
|
233
258
|
def portfolio_positions(syndicate_id, **opts)
|
|
234
259
|
pages(opts) { |o| raw.syndicate_portfolio.list_syndicate_portfolio_positions(syndicate_id, o) }
|
|
@@ -236,7 +261,7 @@ module Wefunder
|
|
|
236
261
|
end
|
|
237
262
|
|
|
238
263
|
class Intents < Namespace
|
|
239
|
-
def list(**opts) = wrap { raw.intents.list_intents(opts) }
|
|
264
|
+
def list(**opts) = wrap { raw.intents.list_intents(norm(opts)) }
|
|
240
265
|
def all(**opts) = pages(opts) { |o| raw.intents.list_intents(o) }
|
|
241
266
|
def get(intent_id) = data_of(wrap { raw.intents.get_intent(intent_id) })
|
|
242
267
|
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,6 +8,17 @@ 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 set that
|
|
12
|
+
# is now live in memory but NOT durable; +cause+ is the store's exception.
|
|
13
|
+
class TokenPersistenceError < StandardError
|
|
14
|
+
attr_reader :tokens
|
|
15
|
+
|
|
16
|
+
def initialize(tokens, cause)
|
|
17
|
+
super("Token store failed to save the rotated token set: #{cause.message}")
|
|
18
|
+
@tokens = tokens
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
11
22
|
class TokenManager
|
|
12
23
|
DEFAULT_EXPIRY_LEEWAY_SECONDS = 30.0
|
|
13
24
|
|
|
@@ -15,7 +26,7 @@ module Wefunder
|
|
|
15
26
|
|
|
16
27
|
def initialize(tokens, client_id: nil, client_secret: nil, re_mint: nil, on_token_refresh: nil, store: nil,
|
|
17
28
|
faraday: nil, now: nil, expiry_leeway_seconds: DEFAULT_EXPIRY_LEEWAY_SECONDS,
|
|
18
|
-
token_base_url: nil, oauth_base_url: nil)
|
|
29
|
+
token_base_url: nil, oauth_base_url: nil, timeout: nil)
|
|
19
30
|
@current = tokens
|
|
20
31
|
@client_id = client_id
|
|
21
32
|
@client_secret = client_secret
|
|
@@ -23,6 +34,7 @@ module Wefunder
|
|
|
23
34
|
@on_token_refresh = on_token_refresh
|
|
24
35
|
@store = store
|
|
25
36
|
@faraday = faraday
|
|
37
|
+
@timeout = timeout
|
|
26
38
|
@now = now || -> { Time.now.to_f }
|
|
27
39
|
@leeway = expiry_leeway_seconds
|
|
28
40
|
@token_base_url = OAuth.resolve_token_base(token_base_url: token_base_url, oauth_base_url: oauth_base_url)
|
|
@@ -50,7 +62,7 @@ module Wefunder
|
|
|
50
62
|
nxt =
|
|
51
63
|
if can_rotate?
|
|
52
64
|
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)
|
|
65
|
+
token_base_url: @token_base_url, faraday: @faraday, now: @now, timeout: @timeout)
|
|
54
66
|
# Some servers omit a fresh refresh_token on rotation-disabled flows; keep the old one.
|
|
55
67
|
set.refresh_token ||= previous_refresh
|
|
56
68
|
set
|
|
@@ -59,13 +71,25 @@ module Wefunder
|
|
|
59
71
|
else
|
|
60
72
|
raise AuthError, "Access token expired and no refresh token / re-mint capability is configured."
|
|
61
73
|
end
|
|
74
|
+
persist(nxt)
|
|
62
75
|
@current = nxt
|
|
63
|
-
@store&.save(nxt)
|
|
64
76
|
@on_token_refresh&.call(nxt)
|
|
65
77
|
nxt
|
|
66
78
|
end
|
|
67
79
|
end
|
|
68
80
|
|
|
81
|
+
# Persist BEFORE publishing: no other thread may use the rotated token until it is durable,
|
|
82
|
+
# and a failed save must not leave the process working in memory but unable to reconnect
|
|
83
|
+
# after a restart. If the store raises we still have the only copy of the rotated refresh
|
|
84
|
+
# token (the old one is dead), so it is published anyway and the failure is surfaced as
|
|
85
|
+
# TokenPersistenceError — handle it (alert, retry the save with +error.tokens+), don't swallow it.
|
|
86
|
+
def persist(tokens)
|
|
87
|
+
@store&.save(tokens)
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
@current = tokens
|
|
90
|
+
raise TokenPersistenceError.new(tokens, e)
|
|
91
|
+
end
|
|
92
|
+
|
|
69
93
|
private
|
|
70
94
|
|
|
71
95
|
def can_rotate?
|
data/lib/wefunder/version.rb
CHANGED