nb_api_client 0.3.0 → 1.0.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 +15 -0
- data/README.md +61 -18
- data/lib/nb_api_client/request.rb +15 -4
- data/lib/nb_api_client/url_builder.rb +2 -1
- data/lib/nb_api_client/version.rb +1 -1
- data/lib/nb_api_client.rb +0 -1
- metadata +2 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8a34771eec831f76ebbcab4057adab19e3ed2f0f18a7ea1fefdf4f6cb9c75c8
|
|
4
|
+
data.tar.gz: d9c17c3d66fe7250dcb06c267530feda1255841509724b6ac061137c1584ff47
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e2215188c41fa8820c2b6372cb94e29efabbdd98717b7806b107d8e0e28820daa137118a9de1dfab8e0ee2bb0e5fe4a2a848a9a2a3e56f98cab34a001ce0fff2
|
|
7
|
+
data.tar.gz: 957fafb9f78368eeff8e0a42e9b01b8e1f5c0cfc8d7112bbcfb2247a62f4fad70632536c290d0f5af147740cccc48dba5165a64a00d430dc06d430cd3d9f7f71
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.0.0] - 2026-07-16
|
|
8
|
+
|
|
9
|
+
- **Breaking:** Removed the `oauth2` gem dependency. `NbApiClient::Request` now
|
|
10
|
+
raises `NbApiClient::Request::OAuthError` (a plain `StandardError` carrying
|
|
11
|
+
the failed `response`) instead of `OAuth2::Error` on token-refresh failure
|
|
12
|
+
or an unrecognized JSON error response. `OAuth2::Error` was only ever used
|
|
13
|
+
as a generic error wrapper here — this gem never used `OAuth2::Client` or
|
|
14
|
+
`OAuth2::AccessToken`. Update any `rescue OAuth2::Error` (or exception
|
|
15
|
+
allow-lists, e.g. Sidekiq retry/discard lists) around calls into this gem
|
|
16
|
+
to `rescue NbApiClient::Request::OAuthError` instead.
|
|
17
|
+
|
|
18
|
+
## [0.3.1] - 2026-07-16
|
|
19
|
+
|
|
20
|
+
- Updated `UrlBuilder` to build nation's URL based on it's slug, instead of requirng a separate `url` argument.
|
|
21
|
+
|
|
7
22
|
## [0.3.0] - 2026-07-16
|
|
8
23
|
|
|
9
24
|
- `NbApiClient::RateLimiter` now enforces an exact sliding window via a Redis
|
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,25 +25,59 @@ 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
|
-
| --------------------- |
|
|
37
|
-
| `slug` | A string uniquely identifying the account (used as a cache-key/log prefix).
|
|
38
|
-
| `active?` | Whether requests should be allowed (checked before every call, except `/oauth/token`).
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
42
|
-
|
|
39
|
+
| Method | Returns |
|
|
40
|
+
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
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"`). |
|
|
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
|
+
A typical `refresh_oauth_token`, using the [`oauth2`](https://github.com/oauth-xx/oauth2) gem (a separate dependency of your app, not of this gem) and an ActiveRecord model with `token`, `refresh_token`, and `token_expires_at` columns:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
def refresh_oauth_token
|
|
51
|
+
with_lock do
|
|
52
|
+
# Re-check after acquiring the lock in case another process already
|
|
53
|
+
# refreshed the token while this one was waiting.
|
|
54
|
+
return true if updated_at > 5.seconds.ago
|
|
55
|
+
|
|
56
|
+
client = OAuth2::Client.new(ENV["NB_CLIENT_ID"], ENV["NB_CLIENT_SECRET"], site: "https://#{slug}.nationbuilder.com")
|
|
57
|
+
old_token = OAuth2::AccessToken.new(client, token, refresh_token: refresh_token)
|
|
58
|
+
new_token = old_token.refresh!
|
|
59
|
+
|
|
60
|
+
update!(token: new_token.token,
|
|
61
|
+
refresh_token: new_token.refresh_token,
|
|
62
|
+
token_expires_at: new_token.expires_at)
|
|
63
|
+
end
|
|
64
|
+
rescue OAuth2::Error => e
|
|
65
|
+
Rails.logger.error("Failed to refresh OAuth token for #{slug}: #{e.message}")
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The `with_lock` (a `SELECT ... FOR UPDATE` row lock) and `updated_at` re-check guard against multiple concurrent requests for the same nation refreshing the token at once.
|
|
43
71
|
|
|
44
72
|
### Configuration
|
|
45
73
|
|
|
46
74
|
```ruby
|
|
75
|
+
NB_API_CLIENT_CACHE_STORE = ActiveSupport::Cache::RedisCacheStore.new(
|
|
76
|
+
url: ENV["REDIS_URL"],
|
|
77
|
+
pool: {size: ENV.fetch("RATE_LIMITER_POOL_SIZE", 6).to_i, timeout: 5},
|
|
78
|
+
namespace: "nb_api_client"
|
|
79
|
+
)
|
|
80
|
+
|
|
47
81
|
NbApiClient.configure do |config|
|
|
48
82
|
# Called after `nation.deauthorize` when a nation has failed authorization
|
|
49
83
|
# too many times in a row. Use this to notify the account owner or schedule
|
|
@@ -62,6 +96,13 @@ NbApiClient.configure do |config|
|
|
|
62
96
|
# respond to #increment(key, amount, options), #decrement, and #read.
|
|
63
97
|
config.cache_store = Rails.cache
|
|
64
98
|
|
|
99
|
+
# Example of a more advanced cache store, using its own Redis store with pooling.
|
|
100
|
+
# config.cache_store = ActiveSupport::Cache::RedisCacheStore.new(
|
|
101
|
+
# url: ENV["REDIS_URL"],
|
|
102
|
+
# pool: {size: ENV.fetch("RATE_LIMITER_POOL_SIZE", 6).to_i, timeout: 5},
|
|
103
|
+
# namespace: "nb_api_client"
|
|
104
|
+
# )
|
|
105
|
+
|
|
65
106
|
# Logger for rate-limit/retry warnings. Defaults to Rails.logger.
|
|
66
107
|
config.logger = Rails.logger
|
|
67
108
|
|
|
@@ -76,6 +117,8 @@ NbApiClient.configure do |config|
|
|
|
76
117
|
# cache_store to a raw Redis/ConnectionPool) and its client supports Lua
|
|
77
118
|
# scripts, this is enforced as an exact sliding window via a Lua script
|
|
78
119
|
# instead of the bucketed approximation used for other cache stores.
|
|
120
|
+
# Refer to https://support.nationbuilder.com/en/articles/9868960-api-rate-limit-policy
|
|
121
|
+
# for NationBuilder's rate limit policy.
|
|
79
122
|
config.rate_limit = 200 # requests
|
|
80
123
|
config.rate_limit_window_seconds = 10 # per this many seconds
|
|
81
124
|
config.rate_limit_max_wait_seconds = 120
|
|
@@ -88,13 +131,13 @@ All of the above have working defaults (matching NationBuilder's published limit
|
|
|
88
131
|
|
|
89
132
|
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:
|
|
90
133
|
|
|
91
|
-
| Method
|
|
92
|
-
|
|
|
93
|
-
| `parsed_response`
|
|
94
|
-
| `code`
|
|
95
|
-
| `headers`
|
|
96
|
-
| `body`
|
|
97
|
-
| `success?`
|
|
134
|
+
| Method | Returns |
|
|
135
|
+
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
136
|
+
| `parsed_response` | The JSON body parsed into a `Hash`/`Array`. |
|
|
137
|
+
| `code` | The HTTP status code, as an integer. |
|
|
138
|
+
| `headers` | An [`HTTParty::Response::Headers`](https://www.rubydoc.info/gems/httparty/HTTParty/Response/Headers) (delegates to `Net::HTTPHeader`). |
|
|
139
|
+
| `body` | The raw response body, as a string. |
|
|
140
|
+
| `success?` | Whether the response was a 2xx. |
|
|
98
141
|
|
|
99
142
|
`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.
|
|
100
143
|
|
|
@@ -108,7 +151,7 @@ Under `Rails.env.test?` (with the default `short_circuit_in_test`, see below), `
|
|
|
108
151
|
- `NbApiClient::Request::RateLimitedError` — NationBuilder returned 429 more than `max_rate_limit_retries` times in a row.
|
|
109
152
|
- `NbApiClient::Request::InvalidContentType` — the API returned a non-JSON error body.
|
|
110
153
|
- `NbApiClient::RateLimiter::RateLimitExhausted` — the local rate limiter couldn't get a slot within `rate_limit_max_wait_seconds`.
|
|
111
|
-
- `
|
|
154
|
+
- `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.)
|
|
112
155
|
|
|
113
156
|
These are ordinary Ruby exception classes, so they compose naturally with e.g. Sidekiq's `sidekiq_retry_in`/`sidekiq_retries_exhausted` hooks.
|
|
114
157
|
|
|
@@ -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
|
|
@@ -10,10 +10,11 @@ module NbApiClient
|
|
|
10
10
|
@path = path
|
|
11
11
|
|
|
12
12
|
raise ArgumentError, "Path cannot be empty" if @path.blank?
|
|
13
|
+
raise ArgumentError, "Path must start with /" unless @path.start_with?("/")
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def url
|
|
16
|
-
url_string =
|
|
17
|
+
url_string = "https://#{@nation.slug}.nationbuilder.com#{@path}"
|
|
17
18
|
|
|
18
19
|
uri = URI.parse(url_string)
|
|
19
20
|
new_query_ar = if @path.start_with?("/oauth/")
|
data/lib/nb_api_client.rb
CHANGED
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: 0.
|
|
4
|
+
version: 1.0.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
|