nb_api_client 0.3.1 → 1.1.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/CHANGELOG.md +21 -0
- data/README.md +52 -16
- data/lib/nb_api_client/configuration.rb +7 -0
- data/lib/nb_api_client/oauth_token_refresh.rb +39 -0
- data/lib/nb_api_client/request.rb +15 -4
- data/lib/nb_api_client/version.rb +1 -1
- data/lib/nb_api_client.rb +1 -1
- metadata +3 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa4a409cda310073f64e30372fd23444e3785ac0a84bf75ec9a328c4ede4ea27
|
|
4
|
+
data.tar.gz: 5c7eecae6437924564604c56128a7a41a4a152a649cb51b94905becdeec00f20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0732e459c9eddf51483e4718ea6b117a993db84b960bac2a2d2213fa30776fc1f576265e464c5cf9df5d44e7d1eb10ba6d2fef14a99d4edf99bfe274bdee28d9
|
|
7
|
+
data.tar.gz: 5958277d5e508c9b63f3c126e9ab5305a5f85a8100bc693d07b26f83f09f7639e62071a44e2cc6a2b9a7a2ff3bf29f1ff61e18b804494b8ee01f95ddf66795f5
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.1.0] - 2026-07-16
|
|
8
|
+
|
|
9
|
+
- Added `NbApiClient::OAuthTokenRefresh`, an optional mixin implementing
|
|
10
|
+
`refresh_oauth_token` for nations backed by an ActiveRecord-style model
|
|
11
|
+
(`token`/`refresh_token`/`token_expires_at` columns, `#with_lock`/
|
|
12
|
+
`#update!`). `include` it instead of writing your own; it POSTs to
|
|
13
|
+
NationBuilder's `/oauth/token` endpoint via HTTParty. Configured via new
|
|
14
|
+
`config.oauth_client_id`/`config.oauth_client_secret` (default from
|
|
15
|
+
`NB_CLIENT_ID`/`NB_CLIENT_SECRET` env vars).
|
|
16
|
+
|
|
17
|
+
## [1.0.0] - 2026-07-16
|
|
18
|
+
|
|
19
|
+
- **Breaking:** Removed the `oauth2` gem dependency. `NbApiClient::Request` now
|
|
20
|
+
raises `NbApiClient::Request::OAuthError` (a plain `StandardError` carrying
|
|
21
|
+
the failed `response`) instead of `OAuth2::Error` on token-refresh failure
|
|
22
|
+
or an unrecognized JSON error response. `OAuth2::Error` was only ever used
|
|
23
|
+
as a generic error wrapper here — this gem never used `OAuth2::Client` or
|
|
24
|
+
`OAuth2::AccessToken`. Update any `rescue OAuth2::Error` (or exception
|
|
25
|
+
allow-lists, e.g. Sidekiq retry/discard lists) around calls into this gem
|
|
26
|
+
to `rescue NbApiClient::Request::OAuthError` instead.
|
|
27
|
+
|
|
7
28
|
## [0.3.1] - 2026-07-16
|
|
8
29
|
|
|
9
30
|
- Updated `UrlBuilder` to build nation's URL based on it's slug, instead of requirng a separate `url` argument.
|
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# NationBuilder API Client
|
|
2
2
|
|
|
3
3
|
A rate-limited, token-refreshing HTTP client for the NationBuilder API, for use in Rails apps that integrate with NationBuilder on behalf of many OAuth-connected accounts ("nations").
|
|
4
4
|
|
|
@@ -25,24 +25,46 @@ gem "nb_api_client", path: "gems/nb_api_client"
|
|
|
25
25
|
|
|
26
26
|
```ruby
|
|
27
27
|
NbApiClient::Request.call(nation, :get, "/api/v2/signups/123")
|
|
28
|
-
NbApiClient::Request.call(nation, :post, "/api/v2/signups",
|
|
28
|
+
NbApiClient::Request.call(nation, :post, "/api/v2/signups", {
|
|
29
|
+
"data": {
|
|
30
|
+
"type": "signups", "attributes": {"email": "email@example.com"}
|
|
31
|
+
}
|
|
32
|
+
})
|
|
29
33
|
```
|
|
30
34
|
|
|
31
35
|
### The `nation` interface
|
|
32
36
|
|
|
33
37
|
`nation` can be any object — it does not need to be an ActiveRecord model — that responds to:
|
|
34
38
|
|
|
35
|
-
| Method | Returns
|
|
36
|
-
| --------------------- |
|
|
39
|
+
| Method | Returns |
|
|
40
|
+
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
37
41
|
| `slug` | A string uniquely identifying the account (used as a cache-key/log prefix, and to build the account's base URL, e.g. `"myorg"` becomes `"https://myorg.nationbuilder.com"`). |
|
|
38
|
-
| `active?` | Whether requests should be allowed (checked before every call, except `/oauth/token`).
|
|
39
|
-
| `token` | The current OAuth access token, appended as a query param on every request.
|
|
40
|
-
| `refresh_oauth_token` | Refreshes the OAuth token in place; returns truthy on success, falsy on failure.
|
|
41
|
-
| `deauthorize` | Called after too many consecutive "unauthorized" responses (see `unauthorized_error_threshold` below).
|
|
42
|
+
| `active?` | Whether requests should be allowed (checked before every call, except `/oauth/token`). |
|
|
43
|
+
| `token` | The current OAuth access token, appended as a query param on every request. |
|
|
44
|
+
| `refresh_oauth_token` | Refreshes the OAuth token in place; returns truthy on success, falsy on failure. |
|
|
45
|
+
| `deauthorize` | Called after too many consecutive "unauthorized" responses (see `unauthorized_error_threshold` below). |
|
|
46
|
+
|
|
47
|
+
If your model is an ActiveRecord model with `token`, `refresh_token`, and `token_expires_at` columns and supports `#with_lock`/`#update!`, just include the gem's mixin instead of writing `refresh_oauth_token` yourself:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class Nation < ApplicationRecord
|
|
51
|
+
include NbApiClient::OAuthTokenRefresh
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
It also needs `config.oauth_client_id`/`config.oauth_client_secret` set (below; default from `NB_CLIENT_ID`/`NB_CLIENT_SECRET` env vars). Internally it POSTs to NationBuilder's `/oauth/token` endpoint via HTTParty (no `oauth2` gem dependency), and uses `with_lock` plus an `updated_at` re-check to guard against multiple concurrent requests for the same nation refreshing the token at once.
|
|
56
|
+
|
|
57
|
+
If your model doesn't fit that shape (different column names, no `with_lock`, non-ActiveRecord storage), write your own `refresh_oauth_token` instead — it just needs to return truthy on success, falsy on failure.
|
|
42
58
|
|
|
43
59
|
### Configuration
|
|
44
60
|
|
|
45
61
|
```ruby
|
|
62
|
+
NB_API_CLIENT_CACHE_STORE = ActiveSupport::Cache::RedisCacheStore.new(
|
|
63
|
+
url: ENV["REDIS_URL"],
|
|
64
|
+
pool: {size: ENV.fetch("RATE_LIMITER_POOL_SIZE", 6).to_i, timeout: 5},
|
|
65
|
+
namespace: "nb_api_client"
|
|
66
|
+
)
|
|
67
|
+
|
|
46
68
|
NbApiClient.configure do |config|
|
|
47
69
|
# Called after `nation.deauthorize` when a nation has failed authorization
|
|
48
70
|
# too many times in a row. Use this to notify the account owner or schedule
|
|
@@ -56,11 +78,23 @@ NbApiClient.configure do |config|
|
|
|
56
78
|
config.unauthorized_error_threshold = 25
|
|
57
79
|
config.unauthorized_error_window = 5 * 60
|
|
58
80
|
|
|
81
|
+
# Only needed if you include NbApiClient::OAuthTokenRefresh. Default from
|
|
82
|
+
# NB_CLIENT_ID/NB_CLIENT_SECRET env vars.
|
|
83
|
+
config.oauth_client_id = ENV["NB_CLIENT_ID"]
|
|
84
|
+
config.oauth_client_secret = ENV["NB_CLIENT_SECRET"]
|
|
85
|
+
|
|
59
86
|
# Cache store used to count those unauthorized responses, and (below) to
|
|
60
87
|
# back the sliding-window rate limiter. Defaults to Rails.cache; must
|
|
61
88
|
# respond to #increment(key, amount, options), #decrement, and #read.
|
|
62
89
|
config.cache_store = Rails.cache
|
|
63
90
|
|
|
91
|
+
# Example of a more advanced cache store, using its own Redis store with pooling.
|
|
92
|
+
# config.cache_store = ActiveSupport::Cache::RedisCacheStore.new(
|
|
93
|
+
# url: ENV["REDIS_URL"],
|
|
94
|
+
# pool: {size: ENV.fetch("RATE_LIMITER_POOL_SIZE", 6).to_i, timeout: 5},
|
|
95
|
+
# namespace: "nb_api_client"
|
|
96
|
+
# )
|
|
97
|
+
|
|
64
98
|
# Logger for rate-limit/retry warnings. Defaults to Rails.logger.
|
|
65
99
|
config.logger = Rails.logger
|
|
66
100
|
|
|
@@ -75,6 +109,8 @@ NbApiClient.configure do |config|
|
|
|
75
109
|
# cache_store to a raw Redis/ConnectionPool) and its client supports Lua
|
|
76
110
|
# scripts, this is enforced as an exact sliding window via a Lua script
|
|
77
111
|
# instead of the bucketed approximation used for other cache stores.
|
|
112
|
+
# Refer to https://support.nationbuilder.com/en/articles/9868960-api-rate-limit-policy
|
|
113
|
+
# for NationBuilder's rate limit policy.
|
|
78
114
|
config.rate_limit = 200 # requests
|
|
79
115
|
config.rate_limit_window_seconds = 10 # per this many seconds
|
|
80
116
|
config.rate_limit_max_wait_seconds = 120
|
|
@@ -87,13 +123,13 @@ All of the above have working defaults (matching NationBuilder's published limit
|
|
|
87
123
|
|
|
88
124
|
On success, `NbApiClient::Request.call` returns the raw [`HTTParty::Response`](https://www.rubydoc.info/gems/httparty/HTTParty/Response) object from the underlying call — it is not parsed or unwrapped for you. Useful methods on it include:
|
|
89
125
|
|
|
90
|
-
| Method
|
|
91
|
-
|
|
|
92
|
-
| `parsed_response`
|
|
93
|
-
| `code`
|
|
94
|
-
| `headers`
|
|
95
|
-
| `body`
|
|
96
|
-
| `success?`
|
|
126
|
+
| Method | Returns |
|
|
127
|
+
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
128
|
+
| `parsed_response` | The JSON body parsed into a `Hash`/`Array`. |
|
|
129
|
+
| `code` | The HTTP status code, as an integer. |
|
|
130
|
+
| `headers` | An [`HTTParty::Response::Headers`](https://www.rubydoc.info/gems/httparty/HTTParty/Response/Headers) (delegates to `Net::HTTPHeader`). |
|
|
131
|
+
| `body` | The raw response body, as a string. |
|
|
132
|
+
| `success?` | Whether the response was a 2xx. |
|
|
97
133
|
|
|
98
134
|
`HTTParty::Response` also delegates most `Hash`/`Array` methods (`[]`, `fetch`, `each`, …) straight to `parsed_response`, so e.g. `response["data"]` or `response.fetch("code", "")` works directly on the response without calling `parsed_response` first. See the [HTTParty README](https://github.com/jnunemaker/httparty#readme) for the full API.
|
|
99
135
|
|
|
@@ -107,7 +143,7 @@ Under `Rails.env.test?` (with the default `short_circuit_in_test`, see below), `
|
|
|
107
143
|
- `NbApiClient::Request::RateLimitedError` — NationBuilder returned 429 more than `max_rate_limit_retries` times in a row.
|
|
108
144
|
- `NbApiClient::Request::InvalidContentType` — the API returned a non-JSON error body.
|
|
109
145
|
- `NbApiClient::RateLimiter::RateLimitExhausted` — the local rate limiter couldn't get a slot within `rate_limit_max_wait_seconds`.
|
|
110
|
-
- `
|
|
146
|
+
- `NbApiClient::Request::OAuthError` — token refresh failed, or the API returned an unrecognized JSON error. (In 0.x this was `OAuth2::Error`; the gem no longer depends on `oauth2` — see CHANGELOG.)
|
|
111
147
|
|
|
112
148
|
These are ordinary Ruby exception classes, so they compose naturally with e.g. Sidekiq's `sidekiq_retry_in`/`sidekiq_retries_exhausted` hooks.
|
|
113
149
|
|
|
@@ -30,6 +30,11 @@ module NbApiClient
|
|
|
30
30
|
attr_accessor :max_rate_limit_retries
|
|
31
31
|
attr_accessor :max_token_retries
|
|
32
32
|
|
|
33
|
+
# Client id/secret used by NbApiClient::OAuthTokenRefresh (only needed if
|
|
34
|
+
# you include that mixin instead of writing your own refresh_oauth_token).
|
|
35
|
+
attr_accessor :oauth_client_id
|
|
36
|
+
attr_accessor :oauth_client_secret
|
|
37
|
+
|
|
33
38
|
# Sliding-window rate limit: at most `rate_limit` requests per `rate_limit_window_seconds`,
|
|
34
39
|
# enforced across all processes via `cache_store` (above). If `cache_store` is nil,
|
|
35
40
|
# rate limiting is disabled entirely.
|
|
@@ -48,6 +53,8 @@ module NbApiClient
|
|
|
48
53
|
@short_circuit_in_test = true
|
|
49
54
|
@max_rate_limit_retries = 3
|
|
50
55
|
@max_token_retries = 2
|
|
56
|
+
@oauth_client_id = ENV["NB_CLIENT_ID"]
|
|
57
|
+
@oauth_client_secret = ENV["NB_CLIENT_SECRET"]
|
|
51
58
|
|
|
52
59
|
@rate_limit = ENV.fetch("API_RATE_LIMIT", 200).to_i
|
|
53
60
|
@rate_limit_window_seconds = ENV.fetch("API_RATE_WINDOW", 10).to_i
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NbApiClient
|
|
4
|
+
# Optional mixin implementing `refresh_oauth_token` (see README's "nation
|
|
5
|
+
# interface") for nations backed by an ActiveRecord-style model with
|
|
6
|
+
# `token`, `refresh_token`, `token_expires_at` columns, and `#with_lock`/
|
|
7
|
+
# `#update!`. `include NbApiClient::OAuthTokenRefresh` instead of writing
|
|
8
|
+
# your own refresh_oauth_token; write your own if your model differs
|
|
9
|
+
# (no `with_lock`, different column names, etc).
|
|
10
|
+
module OAuthTokenRefresh
|
|
11
|
+
def refresh_oauth_token
|
|
12
|
+
with_lock do
|
|
13
|
+
# Re-check after acquiring the lock in case another process already
|
|
14
|
+
# refreshed the token while this one was waiting.
|
|
15
|
+
return true if Time.now - updated_at < 5
|
|
16
|
+
|
|
17
|
+
response = HTTParty.post(
|
|
18
|
+
"https://#{slug}.nationbuilder.com/oauth/token",
|
|
19
|
+
body: {
|
|
20
|
+
grant_type: "refresh_token",
|
|
21
|
+
refresh_token: refresh_token,
|
|
22
|
+
client_id: NbApiClient.configuration.oauth_client_id,
|
|
23
|
+
client_secret: NbApiClient.configuration.oauth_client_secret
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
return false unless response.success?
|
|
27
|
+
|
|
28
|
+
update!(
|
|
29
|
+
token: response["access_token"],
|
|
30
|
+
refresh_token: response["refresh_token"],
|
|
31
|
+
token_expires_at: response["expires_in"] ? Time.now + response["expires_in"].to_i : nil
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
rescue => e
|
|
35
|
+
NbApiClient.configuration.logger&.error("[NbApiClient::OAuthTokenRefresh] Failed to refresh OAuth token for #{slug}: #{e.message}")
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -30,6 +30,17 @@ module NbApiClient
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# Replaces OAuth2::Error: this gem never talks OAuth2::Client/AccessToken,
|
|
34
|
+
# it only wraps an HTTParty response body as an error message.
|
|
35
|
+
class OAuthError < StandardError
|
|
36
|
+
attr_reader :response
|
|
37
|
+
|
|
38
|
+
def initialize(response)
|
|
39
|
+
@response = response
|
|
40
|
+
super(response.body)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
33
44
|
def self.call(...)
|
|
34
45
|
new(...).call
|
|
35
46
|
end
|
|
@@ -73,12 +84,12 @@ module NbApiClient
|
|
|
73
84
|
sleep wait_seconds
|
|
74
85
|
next
|
|
75
86
|
elsif expired_token_error?
|
|
76
|
-
raise
|
|
77
|
-
raise
|
|
87
|
+
raise OAuthError.new(@response) if (token_retries += 1) > config.max_token_retries
|
|
88
|
+
raise OAuthError.new(@response) unless @nation.refresh_oauth_token
|
|
78
89
|
|
|
79
90
|
next
|
|
80
91
|
elsif unauthorized_error?
|
|
81
|
-
raise
|
|
92
|
+
raise OAuthError.new(@response) if (token_retries += 1) > config.max_token_retries
|
|
82
93
|
|
|
83
94
|
if too_many_unauthorized_errors?(config)
|
|
84
95
|
@nation.deauthorize
|
|
@@ -89,7 +100,7 @@ module NbApiClient
|
|
|
89
100
|
@nation.refresh_oauth_token
|
|
90
101
|
next
|
|
91
102
|
elsif @response.content_type.in?(["application/json", "application/vnd.api+json"])
|
|
92
|
-
raise
|
|
103
|
+
raise OAuthError.new(@response)
|
|
93
104
|
else
|
|
94
105
|
raise InvalidContentType.new(nation: @nation.slug, body: @response.body, content_type: @response.content_type)
|
|
95
106
|
end
|
data/lib/nb_api_client.rb
CHANGED
|
@@ -9,13 +9,13 @@ require "active_support/core_ext/object/inclusion"
|
|
|
9
9
|
|
|
10
10
|
require "httparty"
|
|
11
11
|
require "addressable/uri"
|
|
12
|
-
require "oauth2"
|
|
13
12
|
|
|
14
13
|
require_relative "nb_api_client/version"
|
|
15
14
|
require_relative "nb_api_client/configuration"
|
|
16
15
|
require_relative "nb_api_client/url_builder"
|
|
17
16
|
require_relative "nb_api_client/rate_limiter"
|
|
18
17
|
require_relative "nb_api_client/request"
|
|
18
|
+
require_relative "nb_api_client/oauth_token_refresh"
|
|
19
19
|
|
|
20
20
|
module NbApiClient
|
|
21
21
|
class << self
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: nb_api_client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Flint
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -52,20 +52,6 @@ dependencies:
|
|
|
52
52
|
- - ">="
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
54
|
version: '2.8'
|
|
55
|
-
- !ruby/object:Gem::Dependency
|
|
56
|
-
name: oauth2
|
|
57
|
-
requirement: !ruby/object:Gem::Requirement
|
|
58
|
-
requirements:
|
|
59
|
-
- - ">="
|
|
60
|
-
- !ruby/object:Gem::Version
|
|
61
|
-
version: '2.0'
|
|
62
|
-
type: :runtime
|
|
63
|
-
prerelease: false
|
|
64
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
-
requirements:
|
|
66
|
-
- - ">="
|
|
67
|
-
- !ruby/object:Gem::Version
|
|
68
|
-
version: '2.0'
|
|
69
55
|
- !ruby/object:Gem::Dependency
|
|
70
56
|
name: rake
|
|
71
57
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -120,6 +106,7 @@ files:
|
|
|
120
106
|
- README.md
|
|
121
107
|
- lib/nb_api_client.rb
|
|
122
108
|
- lib/nb_api_client/configuration.rb
|
|
109
|
+
- lib/nb_api_client/oauth_token_refresh.rb
|
|
123
110
|
- lib/nb_api_client/rate_limiter.rb
|
|
124
111
|
- lib/nb_api_client/request.rb
|
|
125
112
|
- lib/nb_api_client/url_builder.rb
|