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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8074f860a3e500b08ddab7a4fa3f7ebecb0ec937926aa52884e666243c310715
4
- data.tar.gz: 22dfeff5e30c9aaa4e1def2805eab0c33795d53b69140518b364ed878b257d5c
3
+ metadata.gz: 946ede1e59f03277730fa442f9df079e44bac9532db1587306f488d1d8c0a616
4
+ data.tar.gz: 53ca111474c48a0e954a690caa7001b34cad1c28cd9ba2e275d2489e5f3a235c
5
5
  SHA512:
6
- metadata.gz: ad2bc61f75b11d9d1164b81e4d5232f82478a28557ba018f6a7c922f4af5a451e690c05fdcbc9537a1013ea43e9f1328476252ff2f1e14ab3abcde0d48969ccc
7
- data.tar.gz: 7dfd26d68fd440c078f8a0f2c5db20fe8fd44c523e85230b86f4bb83eec571f9217185fd61b70a3560f8a6d1c86a1c2fd4de3e58368e0310a32769e108fa3672
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
 
@@ -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.params.update((query || {}).compact.transform_keys(&:to_s))
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
- def portfolio(syndicate_id, **opts) = data_of(wrap { raw.syndicate_portfolio.get_syndicate_portfolio(syndicate_id, opts) })
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)) })
@@ -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, **hosts)
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
- def post_token(base, params, faraday, now)
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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wefunder
4
- VERSION = "0.1.0.beta1"
4
+ VERSION = "0.1.0.beta2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wefunder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.beta1
4
+ version: 0.1.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wefunder