njtransit 1.1.5 → 1.2.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: 69df919e7bfe02c784bee638a76b22efa444c696c40b713fb2ec926361293e11
4
- data.tar.gz: e14c18acff1a9d7be02cbc7ac7a4ca27623adb6e94e080bf1b593d3d61500010
3
+ metadata.gz: 034142d76cecb98cbd1b456d75052081a766217e684d249e2f4a2541db35ff07
4
+ data.tar.gz: b9a8c8209fbb6602bf6b10daa4594a8c59ba1a843c48aa8ea63ee79104fdf286
5
5
  SHA512:
6
- metadata.gz: 9f08ab2ede45ac14dfa743f7a04a7fb6808f3195be20688b257c51569669f665662b0afd7bfdb74f9b0da67e44b8fe4fb7813a8ace53ff4b4ec6ba7a299811a8
7
- data.tar.gz: 034410070b489ceb1e220310f72e21d7f1f32f42890cda2e73355a0ba965257d52fc91890e367f14d3a4a0f0eaf7358fd146c1b984368aa1515e9dd3efe6abec
6
+ metadata.gz: 3114f42fa2517def6d6eba2b1f58059a9d21825d6993a730804899538f2627384e118b391e46988e5e0dee7120b2eede76adabb69f495c2c6d172e47427e48d2
7
+ data.tar.gz: 85929dbf8bfb5d3f5bf879973d1aca00e9e8f82e6953be6d6853f4bfaba83db2f2ed43b8de3198c70e078d791942b68706a7c0367450d1a2c3862c1b2afe197a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.2.0] - 2026-11-14
8
+
9
+ ### Added
10
+
11
+ - Pluggable auth token store (`config.token_store` / `Client.new(token_store:)`): any object with `read`, `write` and `delete`, including `Rails.cache`. Lets long-running processes reuse a token across restarts instead of hitting NJ Transit's daily token cap
12
+ - `NJTransit::TokenStores::Memory` (the default, shared per process) and `NJTransit::TokenStores::File` (JSON file, mode 0600, atomic writes under an exclusive `flock`, safe across processes)
13
+ - Assigning a `token_store` without `read`/`write`/`delete` raises `ArgumentError`
14
+ - `Client#token_key` and `Client.default_token_store`
15
+
16
+ ### Changed
17
+
18
+ - Tokens are keyed by API host and username (`njtransit:token:<base_url>:<username>`), not host alone
19
+ - Raw GTFS-RT requests now detect an expired token (a JSON `Invalid token.` body, or a 401/403), re-authenticate and retry once, as JSON requests already did. A JSON error body on a feed now raises `APIError` instead of being returned as feed bytes
20
+ - Clearing an expired token deletes the stored copy only if it is still the stale one, so clients sharing a store don't discard each other's fresh tokens
21
+ - A failing token store always warns on stderr (regardless of `log_level`) and is bypassed instead of raising
22
+
23
+ ### Deprecated
24
+
25
+ - `Client.token_cache`: use `Client.default_token_store` or configure `token_store`
26
+
7
27
  ## [1.1.5] - 2026-11-14
8
28
 
9
29
  ### Added
data/CLAUDE.md CHANGED
@@ -56,6 +56,7 @@ SQLite-backed offline schedule data. Import from GTFS zip, then query via `NJTra
56
56
  - **`enrich` flag**: Bus API methods default to `enrich: true`, which joins GTFS static data (lat/lon, route names). Use `enrich: false` if GTFS isn't imported or you don't need it.
57
57
  - **`mode` parameter**: Bus methods default to `mode: "BUS"`. Pass `"HBLR"`, `"NLR"`, `"RL"`, or `"ALL"` for light rail.
58
58
  - **`stops_nearby` radius**: The radius parameter is in **feet**, not miles.
59
+ - **Token store**: Auth tokens go through a pluggable `token_store` (`lib/njtransit/token_stores.rb`): any object with `read`/`write`/`delete`, such as `TokenStores::File` or `Rails.cache`. The default is one shared `TokenStores::Memory` per process. Expired tokens are deleted from the store and the request is retried once, on both the JSON and raw (GTFS-RT) paths.
59
60
  - **Raw responses**: GTFS-RT methods (`bus_gtfs`, `rail_gtfs`) return binary protobuf data via `post_form_raw`.
60
61
 
61
62
  ## Testing
data/README.md CHANGED
@@ -61,6 +61,24 @@ client.bus.stops_nearby(lat: 40.878, lon: -74.221, radius: 2000, enrich: false)
61
61
  client.bus.routes(mode: "HBLR") # Hudson-Bergen Light Rail
62
62
  ```
63
63
 
64
+ ## Keeping Auth Tokens Across Restarts
65
+
66
+ NJ Transit caps how many auth tokens you can create per day (as low as 5–10 on some endpoints). By default the gem caches tokens in memory, so every new process authenticates again. A service that restarts often (deploys, crash loops, containers) can hit the cap and be locked out for the rest of the day.
67
+
68
+ Configure a `token_store` to reuse tokens across restarts:
69
+
70
+ ```ruby
71
+ NJTransit.configure do |config|
72
+ # A JSON file (created with mode 0600)
73
+ config.token_store = NJTransit::TokenStores::File.new("tmp/njtransit_tokens.json")
74
+
75
+ # ...or, in Rails, any ActiveSupport cache (persists if its backend does)
76
+ config.token_store = Rails.cache
77
+ end
78
+ ```
79
+
80
+ A token store is any object with `read(key)`, `write(key, token)` and `delete(key)`; anything else raises `ArgumentError` when you assign it. Tokens are keyed by API host and username, so clients and processes sharing a store share one token per account. When the API reports an expired token (a JSON `Invalid token.` error, or a 401/403 on a GTFS-RT feed), the gem deletes it from the store (unless another client already replaced it), authenticates, stores the new one and retries the request once. If the store itself fails, the gem warns on stderr and falls back to authenticating, so a broken cache never breaks requests. `TokenStores::File` locks a `<path>.lock` file around each write, so several processes can share one file.
81
+
64
82
  ## Two Clients, One Gem
65
83
 
66
84
  NJ Transit splits its API across two hosts. The gem handles this with two clients:
@@ -15,11 +15,12 @@ module NJTransit
15
15
  class Client
16
16
  DEFAULT_AUTH_PATH = "/api/BUSDV2/authenticateUser"
17
17
 
18
- attr_reader :username, :password, :log_level, :base_url, :timeout, :auth_path
18
+ attr_reader :username, :password, :log_level, :base_url, :timeout, :auth_path, :token_store
19
19
 
20
20
  def initialize(username:, password:, log_level: "silent", base_url: Configuration::DEFAULT_BASE_URL,
21
- timeout: Configuration::DEFAULT_TIMEOUT, auth_path: DEFAULT_AUTH_PATH)
21
+ timeout: Configuration::DEFAULT_TIMEOUT, auth_path: DEFAULT_AUTH_PATH, token_store: nil)
22
22
  @username = username
23
+ @token_store = token_store ? TokenStores.validate!(token_store) : self.class.default_token_store
23
24
  @password = password
24
25
  @log_level = log_level
25
26
  @base_url = base_url
@@ -77,7 +78,7 @@ module NJTransit
77
78
  end
78
79
 
79
80
  def authenticate!
80
- cached = self.class.token_cache[base_url]
81
+ cached = read_stored_token
81
82
  if cached
82
83
  @token = cached
83
84
  return @token
@@ -95,7 +96,7 @@ module NJTransit
95
96
  end
96
97
 
97
98
  @token = result["UserToken"]
98
- self.class.token_cache[base_url] = @token
99
+ write_stored_token(@token)
99
100
  @token
100
101
  end
101
102
 
@@ -104,17 +105,32 @@ module NJTransit
104
105
  @token
105
106
  end
106
107
 
108
+ # Forget this client's token. The stored copy is deleted only if it is still the
109
+ # token this client held, so a fresh token another client just stored survives.
107
110
  def clear_token!
111
+ stale = @token
108
112
  @token = nil
109
- self.class.token_cache.delete(base_url)
113
+ store_call(:delete, token_key) if stale.nil? || read_stored_token == stale
110
114
  end
111
115
 
116
+ # Store key for this client's token: one per API host and username.
117
+ def token_key
118
+ "njtransit:token:#{base_url}:#{username}"
119
+ end
120
+
121
+ def self.default_token_store
122
+ @default_token_store ||= TokenStores::Memory.new
123
+ end
124
+
125
+ # Deprecated: use default_token_store, or configure a token_store.
112
126
  def self.token_cache
113
- @token_cache ||= {}
127
+ warn "[njtransit] Client.token_cache is deprecated; use Client.default_token_store " \
128
+ "or configure token_store. Keys are now \"njtransit:token:<base_url>:<username>\"."
129
+ default_token_store.to_h
114
130
  end
115
131
 
116
132
  def self.clear_token_cache!
117
- @token_cache = {}
133
+ default_token_store.clear
118
134
  end
119
135
 
120
136
  private
@@ -159,15 +175,22 @@ module NJTransit
159
175
  raise ConnectionError, e.message
160
176
  end
161
177
 
162
- def request_form_raw(method, path, params = {})
178
+ def request_form_raw(method, path, params = {}, retry_auth: true)
163
179
  params[:token] = token
164
180
  response = raw_connection.public_send(method) do |req|
165
181
  req.url(path)
166
182
  req.body = params
167
183
  end
168
184
 
185
+ if retry_auth && raw_auth_failure?(response)
186
+ clear_token!
187
+ return request_form_raw(method, path, params, retry_auth: false)
188
+ end
189
+
169
190
  raise error_for_status(response.status).new(error_message(response), response: response) unless response.success?
170
191
 
192
+ # Feeds are protobuf, which never starts with "{": a JSON body is an API error.
193
+ check_api_error!(parse_body(response.body)) if json_body?(response.body)
171
194
  response.body
172
195
  rescue Faraday::TimeoutError => e
173
196
  raise TimeoutError, e.message
@@ -179,6 +202,34 @@ module NJTransit
179
202
  result.is_a?(Hash) && result["errorMessage"] == "Invalid token."
180
203
  end
181
204
 
205
+ # An expired token on a feed: a JSON "Invalid token." error, or a 401/403.
206
+ def raw_auth_failure?(response)
207
+ return true if [401, 403].include?(response.status)
208
+
209
+ response.success? && json_body?(response.body) && token_expired?(parse_body(response.body))
210
+ end
211
+
212
+ def json_body?(body)
213
+ body.is_a?(String) && body.start_with?("{")
214
+ end
215
+
216
+ def read_stored_token
217
+ store_call(:read, token_key)
218
+ end
219
+
220
+ def write_stored_token(token)
221
+ store_call(:write, token_key, token)
222
+ end
223
+
224
+ # A broken store must not break requests: fall back to authenticating, but always
225
+ # warn (whatever log_level is), since each fallback spends a capped token.
226
+ def store_call(method, *)
227
+ token_store.public_send(method, *)
228
+ rescue StandardError => e
229
+ warn "[njtransit] token store #{method} failed: #{e.class}: #{e.message}"
230
+ nil
231
+ end
232
+
182
233
  def check_api_error!(result)
183
234
  return unless result.is_a?(Hash) && result["errorMessage"]
184
235
 
@@ -8,7 +8,7 @@ module NJTransit
8
8
  DEFAULT_TIMEOUT = 30
9
9
 
10
10
  attr_accessor :username, :password, :base_url, :timeout, :gtfs_database_path
11
- attr_reader :log_level
11
+ attr_reader :log_level, :token_store
12
12
 
13
13
  def initialize
14
14
  @username = ENV.fetch("NJTRANSIT_USERNAME", nil)
@@ -29,13 +29,19 @@ module NJTransit
29
29
  @log_level = level
30
30
  end
31
31
 
32
+ # Any object with read, write and delete (e.g. NJTransit::TokenStores::File, Rails.cache); nil for in-memory.
33
+ def token_store=(store)
34
+ @token_store = store.nil? ? nil : TokenStores.validate!(store)
35
+ end
36
+
32
37
  def to_h
33
38
  {
34
39
  username: username,
35
40
  password: password,
36
41
  log_level: log_level,
37
42
  base_url: base_url,
38
- timeout: timeout
43
+ timeout: timeout,
44
+ token_store: token_store
39
45
  }
40
46
  end
41
47
 
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module NJTransit
8
+ # Where clients keep NJ Transit auth tokens. NJ Transit caps token creation
9
+ # (as low as 5-10 a day on some endpoints), so a long-running process that
10
+ # restarts should reuse its token instead of authenticating on every boot.
11
+ #
12
+ # A token store is any object that responds to:
13
+ #
14
+ # read(key) -> token String or nil
15
+ # write(key, token)
16
+ # delete(key)
17
+ #
18
+ # That is the ActiveSupport::Cache::Store interface, so `Rails.cache`
19
+ # works as-is (it persists only if its backend does).
20
+ module TokenStores
21
+ INTERFACE = %i[read write delete].freeze
22
+
23
+ def self.validate!(store)
24
+ missing = INTERFACE.reject { |m| store.respond_to?(m) }
25
+ return store if missing.empty?
26
+
27
+ raise ArgumentError, "token_store must respond to #{INTERFACE.join(", ")} " \
28
+ "(missing #{missing.join(", ")}): got #{store.inspect}"
29
+ end
30
+
31
+ # Process-local store: tokens are lost when the process exits.
32
+ # Clients share one of these unless configured otherwise.
33
+ class Memory
34
+ def initialize
35
+ @tokens = {}
36
+ @mutex = Mutex.new
37
+ end
38
+
39
+ def read(key)
40
+ @mutex.synchronize { @tokens[key] }
41
+ end
42
+
43
+ def write(key, token)
44
+ @mutex.synchronize { @tokens[key] = token }
45
+ end
46
+
47
+ def delete(key)
48
+ @mutex.synchronize { @tokens.delete(key) }
49
+ end
50
+
51
+ def clear
52
+ @mutex.synchronize { @tokens.clear }
53
+ end
54
+
55
+ # The live hash behind the store; only for the deprecated Client.token_cache.
56
+ def to_h
57
+ @tokens
58
+ end
59
+ end
60
+
61
+ # JSON file store: tokens survive process restarts. Writes take an
62
+ # exclusive flock on "<path>.lock" (so concurrent processes and instances
63
+ # don't lose each other's keys) and replace the file atomically, mode 0600.
64
+ class File
65
+ attr_reader :path
66
+
67
+ def initialize(path)
68
+ @path = path
69
+ @mutex = Mutex.new
70
+ end
71
+
72
+ def read(key)
73
+ load[key]
74
+ end
75
+
76
+ def write(key, token)
77
+ update { |tokens| tokens.merge(key => token) }
78
+ end
79
+
80
+ def delete(key)
81
+ update { |tokens| tokens.key?(key) ? tokens.except(key) : nil }
82
+ end
83
+
84
+ private
85
+
86
+ # Read-modify-write under the lock; the block returns the new hash, or nil for no change.
87
+ def update
88
+ @mutex.synchronize do
89
+ FileUtils.mkdir_p(::File.dirname(path))
90
+ ::File.open("#{path}.lock", ::File::RDWR | ::File::CREAT, 0o600) do |lock|
91
+ lock.flock(::File::LOCK_EX)
92
+ tokens = yield(load)
93
+ save(tokens) if tokens
94
+ end
95
+ end
96
+ end
97
+
98
+ def load
99
+ return {} unless ::File.exist?(path)
100
+
101
+ data = JSON.parse(::File.read(path))
102
+ data.is_a?(Hash) ? data : {}
103
+ rescue JSON::ParserError
104
+ {}
105
+ end
106
+
107
+ def save(tokens)
108
+ tmp = "#{path}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp"
109
+ ::File.open(tmp, ::File::WRONLY | ::File::CREAT | ::File::EXCL, 0o600) { |f| f.write(JSON.generate(tokens)) }
110
+ ::File.rename(tmp, path)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NJTransit
4
- VERSION = "1.1.5"
4
+ VERSION = "1.2.0"
5
5
  end
data/lib/njtransit.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "njtransit/version"
4
4
  require_relative "njtransit/configuration"
5
5
  require_relative "njtransit/error"
6
+ require_relative "njtransit/token_stores"
6
7
  require_relative "njtransit/client"
7
8
  require_relative "njtransit/gtfs"
8
9
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: njtransit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.5
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jay Ravaliya
@@ -163,6 +163,7 @@ files:
163
163
  - lib/njtransit/resources/rail_gtfs.rb
164
164
  - lib/njtransit/skill.md
165
165
  - lib/njtransit/tasks.rb
166
+ - lib/njtransit/token_stores.rb
166
167
  - lib/njtransit/version.rb
167
168
  - sig/njtransit.rbs
168
169
  homepage: https://github.com/jayrav13/njtransit