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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f412b933b609da683b5885317338a0d6c09a86ae8614207a2eebd27a4aea6450
4
- data.tar.gz: 90778567fd7a94e88a3daf97b50320d8919b9436afb4ed0abb9f85146a4791f3
3
+ metadata.gz: fa4a409cda310073f64e30372fd23444e3785ac0a84bf75ec9a328c4ede4ea27
4
+ data.tar.gz: 5c7eecae6437924564604c56128a7a41a4a152a649cb51b94905becdeec00f20
5
5
  SHA512:
6
- metadata.gz: c53d7b630005c8ab3819ccc85194a865296fc1bb38707c36dc06d474aaa55536d350717428a25d2ef78f599777ba9db37710426a621801110567fbffac217978
7
- data.tar.gz: 3ee2477602ef28d7ca26d4432e1694ec33f148e2458c2476a42872c0f63c0cc62f1e11f3e16a2b5199485ee2b80e055df3bdae2ee0221e6c832fbd80b6b454e5
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
- # NbApiClient
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", "data": {"type": "signups", "attributes": {{"email": "email@example.com"}}})
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 | Returns |
91
- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
92
- | `parsed_response` | The JSON body parsed into a `Hash`/`Array`. |
93
- | `code` | The HTTP status code, as an integer. |
94
- | `headers` | An [`HTTParty::Response::Headers`](https://www.rubydoc.info/gems/httparty/HTTParty/Response/Headers) (delegates to `Net::HTTPHeader`). |
95
- | `body` | The raw response body, as a string. |
96
- | `success?` | Whether the response was a 2xx. |
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
- - `OAuth2::Error` — token refresh failed, or the API returned an unrecognized JSON error.
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 OAuth2::Error.new(@response) if (token_retries += 1) > config.max_token_retries
77
- raise OAuth2::Error.new(@response) unless @nation.refresh_oauth_token
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 OAuth2::Error.new(@response) if (token_retries += 1) > config.max_token_retries
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 OAuth2::Error.new(@response)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NbApiClient
4
- VERSION = "0.3.1"
4
+ VERSION = "1.1.0"
5
5
  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: 0.3.1
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-07-16 00:00:00.000000000 Z
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