toc_doc 1.7.0 → 1.8.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: a839823e2a82c7dd112617d6b969f275d7d88826e58f53b9ec9a5265701f277e
4
- data.tar.gz: 6a3c3d24b598f0d2f85b2c0bd5f8faad939eeba847578692b072f0070fb5d3bb
3
+ metadata.gz: 36c32de39cc1eed0396372360e37b86d7df01e342926c32ab03aa450db500ff9
4
+ data.tar.gz: 578e0b0c633708c5c8adc7f3e8b0212e563ca7eb07c9e7b73b52d72196b7f652
5
5
  SHA512:
6
- metadata.gz: 2580cf022a39c4f8e8d73c3b795927bf249ed3d4c9e6b36d051d326680d071ec4569e6f8da07143270584c7f65519f9df9c9efe824c7b623959d7be73f10f27f
7
- data.tar.gz: 19ab224e5729cb04789e7739d1c0a6d9de16b02b2e32d850c1bc6fe3024c58b2c01d4ac9c1b7ffbfed45fb2d7e3498c537d169f9c77733b54d01da009b692047
6
+ metadata.gz: b120c55bf6b52c77b78c609867385150647fb0163d32abd449da8a53a4851939f0053caa3983784b80826be6bf4a6097da7084b0580f1d30d63a6066c5c61c7e
7
+ data.tar.gz: b463ad3fbc58cbd14101e5d8c1bffa37bacfda4373fd7a5c821ae2666d9b0ce839506e5ca97a2926389398387e900e26aac5cc977e036162d55e497d46de27d1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.8.0] - 2026-04-05
4
+
5
+ ### Added
6
+
7
+ - **`TocDoc::RateLimiter::TokenBucket`** — new thread-safe token-bucket rate limiter using a monotonic clock; tokens are refilled at a fixed `rate` per `interval`; `#acquire` blocks until a token is available; rate values below 1 are clamped with a warning
8
+ - **`TocDoc::Middleware::RateLimiter`** — new Faraday middleware that enforces client-side rate limiting via a `TokenBucket`; inserted between the retry and JSON middleware so each retry attempt is individually rate-limited; enabled via the new `rate_limit` config key (accepts a `Numeric` for requests-per-second, a `Hash` of `TokenBucket` kwargs, or `nil` to disable)
9
+ - **`TocDoc::Cache::MemoryStore`** — new thread-safe, in-memory response cache with per-entry TTL; lazy expiration (eviction happens on `#read`, not via a background sweep); compatible with the `ActiveSupport::Cache::Store` interface (`read`, `write`, `delete`, `clear`)
10
+ - **`TocDoc::Middleware::Cache`** — new Faraday middleware that caches successful GET responses; cache hits bypass all downstream middleware; cache key is built from the full URL with query parameters sorted for determinism; only GET 200 responses are cached; enabled via the new `cache` config key (`:memory` for a default `MemoryStore`, a `Hash` with `:store`/`:ttl` keys, any AS-compatible store object, or `nil` to disable)
11
+ - **`pagination_depth` config key** — new module-level and per-client option controlling how many `next_slot` hops `Availability.where` follows automatically; defaults to `1`; configurable via the `TOCDOC_PAGINATION_DEPTH` environment variable; negative values are clamped to 0 with a warning; `Default::PAGINATION_DEPTH` constant added
12
+ - **`TocDoc::Availability::Collection#more?`** — returns `true` when the API response indicates further pages exist (`next_slot` present)
13
+ - **`TocDoc::Availability::Collection#fetch_next_page`** — fetches the next page of availabilities and merges it into the collection; raises `StopIteration` when `#more?` is false; raises `TocDoc::Error` when no client was provided at construction time
14
+
15
+ ### Changed
16
+
17
+ - **`TocDoc::Default#build_middleware`** — now accepts `rate_limit:` and `cache:` keyword arguments and injects the corresponding middleware into the Faraday stack; stack order is now: `RaiseError > [Cache] > [Logging] > Retry > [RateLimiter] > JSON > Adapter`
18
+ - **`TocDoc::Default#options`** — now includes `pagination_depth:`, `rate_limit: nil`, and `cache: nil` in the defaults hash
19
+ - **`TocDoc::Availability.where`** — now respects `pagination_depth` by following up to that many `next_slot` hops before returning; the constructed `Collection` now carries the client instance to enable `#fetch_next_page`
20
+
3
21
  ## [1.7.0] - 2026-04-04
4
22
 
5
23
  ### Added
data/README.md CHANGED
@@ -140,12 +140,15 @@ client.get('/availabilities.json', query: { visit_motive_ids: '123', agenda_ids:
140
140
  | Option | Default | Description |
141
141
  |---|---|---|
142
142
  | `api_endpoint` | `https://www.doctolib.fr` | Base URL. Change to `.de` / `.it` for other countries. |
143
- | `user_agent` | `TocDoc Ruby Gem 1.7.0` | `User-Agent` header sent with every request. |
143
+ | `user_agent` | `TocDoc Ruby Gem 1.8.0` | `User-Agent` header sent with every request. |
144
144
  | `default_media_type` | `application/json` | `Accept` and `Content-Type` headers. |
145
145
  | `per_page` | `15` | Default number of availability dates per request (capped at `15`). Emits a warning if the value exceeds the cap. |
146
146
  | `connect_timeout` | `5` | TCP connect timeout in seconds, passed to Faraday as `open_timeout`. Override via `TOCDOC_CONNECT_TIMEOUT`. |
147
147
  | `read_timeout` | `10` | Response read timeout in seconds, passed to Faraday as `timeout`. Override via `TOCDOC_READ_TIMEOUT`. |
148
148
  | `logger` | `nil` | Logger-compatible object (e.g. `Logger.new($stdout)`). When set, `TocDoc::Middleware::Logging` logs each request URL and response status. `nil` disables logging. |
149
+ | `pagination_depth` | `1` | Number of `next_slot` hops `Availability.where` follows automatically. `0` disables automatic pagination. Override via `TOCDOC_PAGINATION_DEPTH`. Negative values are clamped to `0` with a warning. |
150
+ | `rate_limit` | `nil` | Client-side rate limit. Pass a `Numeric` for requests-per-second (e.g. `5`), a `Hash` of `TokenBucket` kwargs (e.g. `{ rate: 5, interval: 2.0 }`), or `nil` to disable. Values below `1` are clamped. |
151
+ | `cache` | `nil` | Response cache for GET requests. `:memory` uses a built-in `MemoryStore` (TTL 300s); a `Hash` with `:store` and `:ttl` keys accepts any AS-compatible store; `nil` disables caching. |
149
152
  | `middleware` | Retry + RaiseError + JSON + adapter | Full Faraday middleware stack. Override to customise completely. |
150
153
  | `connection_options` | `{}` | Options passed directly to `Faraday.new`. |
151
154
 
@@ -162,6 +165,7 @@ All primary options can be set via environment variables before the gem is loade
162
165
  | `TOCDOC_CONNECT_TIMEOUT` | `connect_timeout` (default `5`) |
163
166
  | `TOCDOC_READ_TIMEOUT` | `read_timeout` (default `10`) |
164
167
  | `TOCDOC_RETRY_MAX` | Maximum Faraday retry attempts (default `3`) |
168
+ | `TOCDOC_PAGINATION_DEPTH` | `pagination_depth` (default `1`) |
165
169
 
166
170
  ---
167
171
 
@@ -304,6 +308,8 @@ Implements `Enumerable`, yielding `TocDoc::Availability` instances that have at
304
308
  | `#next_slot` | `String \| nil` | ISO 8601 datetime of the nearest available slot. `nil` when none remain. |
305
309
  | `#each` | — | Yields each `TocDoc::Availability` that has at least one slot (excludes empty-slot dates). |
306
310
  | `#raw_availabilities` | `Array<TocDoc::Availability>` | All date entries, including those with no slots. |
311
+ | `#more?` | `Boolean` | `true` when the API has indicated further pages exist (`next_slot` is present). |
312
+ | `#fetch_next_page` | `self` | Fetches the next page and merges it into this collection. Raises `StopIteration` when `#more?` is `false`. Requires a client (i.e. built via `Availability.where`). |
307
313
  | `#to_h` | `Hash` | Plain-hash representation (only dates with slots in the `availabilities` key). |
308
314
 
309
315
  ### `TocDoc::Availability`
@@ -447,15 +453,40 @@ The Doctolib availability endpoint is window-based: each request returns up to
447
453
 
448
454
  ### Automatic next-slot resolution
449
455
 
450
- `TocDoc::Availability.where` automatically follows `next_slot` once: if the
451
- first API response contains a `next_slot` key (indicating no available slots in
452
- the requested window), a second request is issued transparently from that date
453
- before the collection is returned.
456
+ `TocDoc::Availability.where` automatically follows `next_slot` up to
457
+ `pagination_depth` times (default: `1`). Each hop issues a follow-up request
458
+ from the `next_slot` date returned by the previous response, transparently
459
+ merging the pages before returning the collection. Set `pagination_depth: 0` to
460
+ disable automatic pagination entirely.
461
+
462
+ ```ruby
463
+ TocDoc.configure { |c| c.pagination_depth = 3 }
464
+ # → up to 3 next_slot hops per Availability.where call
465
+ ```
466
+
467
+ ### On-demand pagination with `#fetch_next_page`
468
+
469
+ After the initial call, check `#more?` and call `#fetch_next_page` to load
470
+ additional pages one at a time:
471
+
472
+ ```ruby
473
+ collection = TocDoc::Availability.where(
474
+ visit_motive_ids: 7_767_829,
475
+ agenda_ids: 1_101_600,
476
+ start_date: Date.today
477
+ )
478
+
479
+ while collection.more?
480
+ collection.fetch_next_page
481
+ end
482
+
483
+ collection.total # slots across all fetched pages
484
+ ```
454
485
 
455
486
  ### Manual window advancement
456
487
 
457
- To fetch additional date windows, call `TocDoc::Availability.where` again with a
458
- later `start_date`:
488
+ To fetch a completely independent next window, call `TocDoc::Availability.where`
489
+ again with a later `start_date`:
459
490
 
460
491
  ```ruby
461
492
  first_page = TocDoc::Availability.where(
data/TODO.md CHANGED
@@ -3,12 +3,6 @@
3
3
  [POTENTIAL_ENDPOINTS][POTENTIAL_ENDPOINTS.md]
4
4
 
5
5
 
6
- ## 1.8 — HTTP Layer Robustness
7
-
8
- - [ ] Configurable availability pagination depth + `Collection#more?` / `#fetch_next_page`
9
- - [ ] Client-side rate limiter (token-bucket middleware)
10
- - [ ] Optional response caching (memory or ActiveSupport-compatible store)
11
-
12
6
  ## 1.9 — Service Layer rework
13
7
 
14
8
  - [ ] `TocDoc::Services::Availabilities` — extract from `Availability.where`
@@ -33,6 +27,12 @@
33
27
 
34
28
  # DONE & RELEASED
35
29
 
30
+ ## 1.8
31
+
32
+ - [x] Configurable availability pagination depth + `Collection#more?` / `#fetch_next_page`
33
+ - [x] Client-side rate limiter (token-bucket middleware)
34
+ - [x] Optional response caching (memory or ActiveSupport-compatible store)
35
+
36
36
  ## 1.7
37
37
 
38
38
  - [x] Logging middleware (`:logger` config key)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+
5
+ module TocDoc
6
+ module Cache
7
+ # A thread-safe, in-memory response cache with per-entry TTL.
8
+ #
9
+ # Uses lazy expiration: expired entries are evicted on +#read+, not via a
10
+ # background sweep. The key space is bounded by the number of distinct API
11
+ # URLs, so the overhead is negligible for typical gem usage.
12
+ #
13
+ # Compatible with the ActiveSupport::Cache::Store interface for the methods
14
+ # it implements (+read+, +write+, +delete+, +clear+).
15
+ #
16
+ # @example
17
+ # store = TocDoc::Cache::MemoryStore.new(default_ttl: 60)
18
+ # store.write('key', 'value', expires_in: 30)
19
+ # store.read('key') #=> 'value'
20
+ class MemoryStore
21
+ # @param default_ttl [Numeric] default TTL in seconds (default: 300)
22
+ def initialize(default_ttl: 300)
23
+ @default_ttl = default_ttl
24
+ @store = {}
25
+ @monitor = Monitor.new
26
+ end
27
+
28
+ # Reads the value stored under +key+.
29
+ #
30
+ # Returns +nil+ when the key is absent or has expired (and evicts the
31
+ # entry in the latter case).
32
+ #
33
+ # @param key [String]
34
+ # @return [Object, nil]
35
+ def read(key)
36
+ @monitor.synchronize do
37
+ entry = @store[key]
38
+ return nil unless entry
39
+
40
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > entry[:expires_at]
41
+ @store.delete(key)
42
+ return nil
43
+ end
44
+
45
+ entry[:value]
46
+ end
47
+ end
48
+
49
+ # Stores +value+ under +key+ with the given TTL.
50
+ #
51
+ # @param key [String]
52
+ # @param value [Object]
53
+ # @param expires_in [Numeric] TTL in seconds; falls back to +default_ttl+
54
+ # @return [Object] the stored value
55
+ def write(key, value, expires_in: @default_ttl)
56
+ @monitor.synchronize do
57
+ @store[key] = {
58
+ value: value,
59
+ expires_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + expires_in
60
+ }
61
+ value
62
+ end
63
+ end
64
+
65
+ # Deletes the entry for +key+.
66
+ #
67
+ # @param key [String]
68
+ # @return [void]
69
+ def delete(key)
70
+ @monitor.synchronize { @store.delete(key) }
71
+ end
72
+
73
+ # Removes all entries from the store.
74
+ #
75
+ # @return [void]
76
+ def clear
77
+ @monitor.synchronize { @store.clear }
78
+ end
79
+ end
80
+ end
81
+ end
@@ -32,6 +32,9 @@ module TocDoc
32
32
  connect_timeout
33
33
  read_timeout
34
34
  logger
35
+ pagination_depth
36
+ rate_limit
37
+ cache
35
38
  ].freeze
36
39
 
37
40
  # @!attribute [rw] api_endpoint
@@ -50,6 +53,12 @@ module TocDoc
50
53
  # @return [Integer] read (response) timeout in seconds
51
54
  # @!attribute [rw] logger
52
55
  # @return [Logger, :stdout, nil] logger for HTTP request logging; +nil+ disables logging
56
+ # @!attribute [rw] pagination_depth
57
+ # @return [Integer] number of +next_slot+ hops to follow automatically (0 disables)
58
+ # @!attribute [rw] rate_limit
59
+ # @return [Numeric, Hash, nil] client-side rate limit config; +nil+ disables
60
+ # @!attribute [rw] cache
61
+ # @return [Symbol, Hash, Object, nil] response cache config; +nil+ disables
53
62
  attr_accessor(*VALID_CONFIG_KEYS)
54
63
 
55
64
  # Set the number of results per page, clamped to
@@ -68,6 +77,23 @@ module TocDoc
68
77
  @per_page = [int, TocDoc::Default::MAX_PER_PAGE].min
69
78
  end
70
79
 
80
+ # Set the number of +next_slot+ hops to follow automatically, clamped to 0
81
+ # when a negative value is given.
82
+ #
83
+ # Emits a warning on +$stderr+ when +value+ is negative so callers are not
84
+ # silently surprised.
85
+ #
86
+ # @param value [Integer, #to_i] desired pagination depth
87
+ # @return [Integer] the effective depth after clamping
88
+ def pagination_depth=(value)
89
+ int = value.to_i
90
+ if int.negative?
91
+ warn "[TocDoc] pagination_depth #{int} is negative; clamped to 0."
92
+ int = 0
93
+ end
94
+ @pagination_depth = int
95
+ end
96
+
71
97
  # Returns the list of recognised configurable attribute names.
72
98
  #
73
99
  # @return [Array<Symbol>]
@@ -99,15 +99,15 @@ module TocDoc
99
99
 
100
100
  # Returns the appropriate middleware stack for this client.
101
101
  #
102
- # When a logger is configured, builds a fresh stack with the logger injected
103
- # so that the shared memoized default stack is never mutated.
104
- # Falls back to the memoized {TocDoc::Default.middleware} when no logger is
105
- # set.
102
+ # When a logger, rate limiter, or cache is configured, builds a fresh stack
103
+ # with those features injected so the shared memoized default stack is never
104
+ # mutated. Falls back to the memoized {TocDoc::Default.middleware} when
105
+ # none of those options are set.
106
106
  #
107
107
  # @return [Faraday::RackBuilder]
108
108
  def effective_middleware
109
- if logger
110
- TocDoc::Default.build_middleware(logger: logger)
109
+ if logger || rate_limit || cache
110
+ TocDoc::Default.build_middleware(logger: logger, rate_limit: rate_limit, cache: cache)
111
111
  else
112
112
  middleware
113
113
  end
@@ -5,6 +5,10 @@ require 'faraday/retry'
5
5
 
6
6
  require 'toc_doc/http/middleware/raise_error'
7
7
  require 'toc_doc/http/middleware/logging'
8
+ require 'toc_doc/http/middleware/rate_limiter'
9
+ require 'toc_doc/http/middleware/cache'
10
+ require 'toc_doc/http/rate_limiter/token_bucket'
11
+ require 'toc_doc/cache/memory_store'
8
12
 
9
13
  module TocDoc
10
14
  # Provides sensible default values for every configurable option.
@@ -26,6 +30,9 @@ module TocDoc
26
30
  # @return [Integer] the default number of results per page
27
31
  PER_PAGE = 15
28
32
 
33
+ # @return [Integer] the default number of +next_slot+ hops to follow
34
+ PAGINATION_DEPTH = 1
35
+
29
36
  # @return [Integer] the hard upper limit for per_page
30
37
  MAX_PER_PAGE = 15
31
38
 
@@ -46,7 +53,7 @@ module TocDoc
46
53
  def options
47
54
  { api_endpoint:, user_agent:, default_media_type:, per_page:,
48
55
  middleware:, connection_options:, connect_timeout:, read_timeout:,
49
- logger: nil }
56
+ logger: nil, pagination_depth:, rate_limit: nil, cache: nil }
50
57
  end
51
58
 
52
59
  # The base API endpoint URL.
@@ -91,6 +98,19 @@ module TocDoc
91
98
  PER_PAGE
92
99
  end
93
100
 
101
+ # Number of +next_slot+ hops to follow automatically.
102
+ #
103
+ # Falls back to the `TOCDOC_PAGINATION_DEPTH` environment variable, then
104
+ # {PAGINATION_DEPTH}. Negative ENV values are clamped to 0.
105
+ #
106
+ # @return [Integer]
107
+ def pagination_depth
108
+ depth = Integer(ENV.fetch('TOCDOC_PAGINATION_DEPTH', PAGINATION_DEPTH), 10)
109
+ [depth, 0].max
110
+ rescue ArgumentError
111
+ PAGINATION_DEPTH
112
+ end
113
+
94
114
  # The default (memoized) Faraday middleware stack, built without a logger.
95
115
  #
96
116
  # Stack order (outermost first): RaiseError, retry, JSON parsing, adapter.
@@ -102,27 +122,21 @@ module TocDoc
102
122
  @middleware ||= build_middleware
103
123
  end
104
124
 
105
- # Builds a Faraday middleware stack, optionally injecting a logger.
106
- #
107
- # When +logger+ is non-nil, {TocDoc::Middleware::Logging} is inserted
108
- # between {TocDoc::Middleware::RaiseError} and the retry middleware so
109
- # each logical request is logged exactly once after all retries are
110
- # exhausted.
125
+ # Builds a Faraday middleware stack, optionally injecting a logger,
126
+ # rate limiter, and response cache.
111
127
  #
112
- # Accepts +:stdout+ as a shorthand that writes to +$stdout+.
128
+ # Stack order (outermost first):
129
+ # RaiseError > [Cache] > [Logging] > Retry > [RateLimiter] > JSON > Adapter
113
130
  #
114
- # @param logger [Logger, :stdout, nil] the logger to inject; +nil+ omits
115
- # the logging middleware entirely
131
+ # @param logger [Logger, :stdout, nil] the logger to inject; +nil+ omits logging
132
+ # @param rate_limit [Numeric, Hash, nil] rate-limit config (see {.resolve_rate_limit})
133
+ # @param cache [Symbol, Hash, Object, nil] cache config (see {.resolve_cache})
116
134
  # @return [Faraday::RackBuilder]
117
- def build_middleware(logger: nil)
118
- resolved = resolve_logger(logger)
119
- Faraday::RackBuilder.new do |builder|
120
- builder.use TocDoc::Middleware::RaiseError
121
- builder.use TocDoc::Middleware::Logging, logger: resolved if resolved
122
- builder.request :retry, retry_options
123
- builder.response :json, content_type: /\bjson$/
124
- builder.adapter Faraday.default_adapter
125
- end
135
+ def build_middleware(logger: nil, rate_limit: nil, cache: nil)
136
+ logger_obj = resolve_logger(logger)
137
+ bucket = resolve_rate_limit(rate_limit)
138
+ cache_config = resolve_cache(cache)
139
+ Faraday::RackBuilder.new { |b| apply_middleware(b, logger_obj, bucket, cache_config) }
126
140
  end
127
141
 
128
142
  # Default Faraday connection options (empty by default).
@@ -170,6 +184,22 @@ module TocDoc
170
184
 
171
185
  private
172
186
 
187
+ def apply_middleware(builder, logger, bucket, cache_config)
188
+ builder.use TocDoc::Middleware::RaiseError
189
+ insert_cache(builder, cache_config)
190
+ builder.use TocDoc::Middleware::Logging, logger: logger if logger
191
+ builder.request :retry, retry_options
192
+ builder.use TocDoc::Middleware::RateLimiter, bucket: bucket if bucket
193
+ builder.response :json, content_type: /\bjson$/
194
+ builder.adapter Faraday.default_adapter
195
+ end
196
+
197
+ def insert_cache(builder, cache_config)
198
+ return unless cache_config
199
+
200
+ builder.use TocDoc::Middleware::Cache, store: cache_config[:store], ttl: cache_config[:ttl]
201
+ end
202
+
173
203
  def resolve_logger(logger)
174
204
  case logger
175
205
  when :stdout
@@ -182,6 +212,32 @@ module TocDoc
182
212
  end
183
213
  end
184
214
 
215
+ def resolve_rate_limit(config)
216
+ case config
217
+ when nil, false
218
+ nil
219
+ when Numeric
220
+ TocDoc::RateLimiter::TokenBucket.new(rate: config, interval: 1.0)
221
+ when Hash
222
+ TocDoc::RateLimiter::TokenBucket.new(**config)
223
+ end
224
+ end
225
+
226
+ def resolve_cache(config)
227
+ case config
228
+ when nil, false then nil
229
+ when :memory then { store: TocDoc::Cache::MemoryStore.new, ttl: 300 }
230
+ when Hash then resolve_cache_hash(config)
231
+ else { store: config, ttl: 300 }
232
+ end
233
+ end
234
+
235
+ def resolve_cache_hash(config)
236
+ store = config[:store]
237
+ store = TocDoc::Cache::MemoryStore.new if store.nil? || store == :memory
238
+ { store: store, ttl: config.fetch(:ttl, 300) }
239
+ end
240
+
185
241
  def retry_options
186
242
  {
187
243
  max: retry_max,
@@ -4,5 +4,5 @@ module TocDoc
4
4
  # The current version of the TocDoc gem.
5
5
  #
6
6
  # @return [String]
7
- VERSION = '1.7.0'
7
+ VERSION = '1.8.0'
8
8
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'toc_doc/cache/memory_store'
5
+
6
+ module TocDoc
7
+ module Middleware
8
+ # Faraday middleware that caches successful GET responses.
9
+ #
10
+ # Cache hits bypass all downstream middleware (retry, rate-limiting, JSON
11
+ # parsing, and the HTTP adapter). The cached body is the already-parsed
12
+ # object returned by the JSON middleware on the original miss.
13
+ #
14
+ # Only GET requests with 200 responses are cached. All other verbs and
15
+ # non-200 responses flow through normally.
16
+ #
17
+ # The cache key is built from the full URL with query parameters sorted for
18
+ # determinism.
19
+ #
20
+ # Stack position (outermost first): RaiseError > Cache > Logging > Retry >
21
+ # RateLimiter > JSON > Adapter
22
+ #
23
+ # @example
24
+ # store = TocDoc::Cache::MemoryStore.new
25
+ # builder.use TocDoc::Middleware::Cache, store: store, ttl: 300
26
+ class Cache < Faraday::Middleware
27
+ # @param app [#call] the next middleware in the stack
28
+ # @param store [#read, #write] a cache store (MemoryStore or AS-compatible)
29
+ # @param ttl [Numeric] TTL in seconds for cached responses (default: 300)
30
+ def initialize(app, store:, ttl: 300)
31
+ super(app)
32
+ @store = store
33
+ @ttl = ttl
34
+ end
35
+
36
+ # Serves from cache on hit; fetches and caches on miss.
37
+ #
38
+ # @param env [Faraday::Env] the request environment
39
+ # @return [Faraday::Response]
40
+ def call(env)
41
+ return @app.call(env) unless env[:method] == :get
42
+
43
+ cache_key = build_key(env)
44
+ cached = @store.read(cache_key)
45
+ return cached_response(env, cached) if cached
46
+
47
+ @app.call(env).on_complete do |response_env|
48
+ next unless response_env.status == 200
49
+
50
+ @store.write(cache_key, response_env.body, expires_in: @ttl)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def build_key(env)
57
+ uri = env.url.dup
58
+ if uri.query
59
+ sorted_query = URI.decode_www_form(uri.query).sort.map { |k, v| "#{k}=#{v}" }.join('&')
60
+ uri.query = sorted_query
61
+ end
62
+ "tocdoc:get:#{uri}"
63
+ end
64
+
65
+ def cached_response(env, body)
66
+ env.status = 200
67
+ env.body = body
68
+ Faraday::Response.new(env)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'toc_doc/http/rate_limiter/token_bucket'
4
+
5
+ module TocDoc
6
+ module Middleware
7
+ # Faraday middleware that enforces a client-side rate limit via a
8
+ # {TocDoc::RateLimiter::TokenBucket}.
9
+ #
10
+ # Placed between the retry middleware and the JSON middleware so each retry
11
+ # attempt is individually rate-limited.
12
+ #
13
+ # @example
14
+ # bucket = TocDoc::RateLimiter::TokenBucket.new(rate: 5)
15
+ # builder.use TocDoc::Middleware::RateLimiter, bucket: bucket
16
+ class RateLimiter < Faraday::Middleware
17
+ # @param app [#call] the next middleware in the stack
18
+ # @param bucket [TocDoc::RateLimiter::TokenBucket] the rate-limiting token bucket
19
+ def initialize(app, bucket:)
20
+ super(app)
21
+ @bucket = bucket
22
+ end
23
+
24
+ # Acquires a token before forwarding the request downstream.
25
+ #
26
+ # @param env [Faraday::Env] the request environment
27
+ # @return [Faraday::Response]
28
+ def call(env)
29
+ @bucket.acquire
30
+ @app.call(env)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TocDoc
4
+ module RateLimiter
5
+ # A thread-safe token-bucket rate limiter using a monotonic clock.
6
+ #
7
+ # Tokens are refilled at a fixed rate; when the bucket is empty, {#acquire}
8
+ # sleeps until the next token is available.
9
+ #
10
+ # @example Allow 5 requests per second
11
+ # bucket = TocDoc::RateLimiter::TokenBucket.new(rate: 5)
12
+ # bucket.acquire # returns immediately while tokens remain
13
+ #
14
+ # @example 2 requests per 2 seconds
15
+ # bucket = TocDoc::RateLimiter::TokenBucket.new(rate: 2, interval: 2.0)
16
+ class TokenBucket
17
+ MIN_RATE = 1.0
18
+
19
+ # @param rate [Numeric] maximum burst capacity and refill amount per +interval+;
20
+ # clamped to a minimum of +1+
21
+ # @param interval [Float] refill period in seconds (default: 1.0)
22
+ def initialize(rate:, interval: 1.0)
23
+ raw = rate.to_f
24
+ if raw < MIN_RATE
25
+ warn "[TocDoc] rate_limit #{raw} is below minimum; clamped to #{MIN_RATE}."
26
+ raw = MIN_RATE
27
+ end
28
+ @rate = raw
29
+ @interval = interval.to_f
30
+ @tokens = @rate
31
+ @last_refill = Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
+ @mutex = Mutex.new
33
+ end
34
+
35
+ # Blocks until a token is available, then consumes it.
36
+ #
37
+ # The mutex is released while sleeping so other threads can proceed
38
+ # concurrently.
39
+ #
40
+ # @return [void]
41
+ def acquire
42
+ @mutex.synchronize do
43
+ refill
44
+ while @tokens < 1
45
+ sleep_time = @interval / @rate
46
+ @mutex.sleep(sleep_time)
47
+ refill
48
+ end
49
+ @tokens -= 1
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def refill
56
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
57
+ elapsed = now - @last_refill
58
+ @tokens = [@tokens + ((elapsed / @interval) * @rate), @rate].min
59
+ @last_refill = now
60
+ end
61
+ end
62
+ end
63
+ end
@@ -23,10 +23,13 @@ module TocDoc
23
23
  # @param data [Hash] parsed first-page response body
24
24
  # @param query [Hash] original query params (used to build next-page requests)
25
25
  # @param path [String] API path for subsequent requests
26
- def initialize(data, query: {}, path: '/availabilities.json')
26
+ # @param client [TocDoc::Client, nil] client used to fetch additional pages
27
+ # via {#fetch_next_page}; +nil+ disables {#fetch_next_page}
28
+ def initialize(data, query: {}, path: '/availabilities.json', client: nil)
27
29
  @data = data.dup
28
30
  @query = query
29
31
  @path = path
32
+ @client = client
30
33
  end
31
34
 
32
35
  # Iterates over {TocDoc::Availability} instances that have at least one slot.
@@ -62,6 +65,36 @@ module TocDoc
62
65
  nil
63
66
  end
64
67
 
68
+ # Returns +true+ when the API has indicated that more results exist beyond
69
+ # the currently loaded pages.
70
+ #
71
+ # @return [Boolean]
72
+ def more?
73
+ !!@data['next_slot']
74
+ end
75
+
76
+ # Fetches the next page of availabilities and merges it into this collection.
77
+ #
78
+ # Uses the +next_slot+ date from the API response as the +start_date+ for
79
+ # the follow-up request.
80
+ #
81
+ # @raise [TocDoc::Error] if no client was provided at construction time
82
+ # @raise [StopIteration] if {#more?} is +false+
83
+ # @return [self]
84
+ #
85
+ # @example
86
+ # collection.fetch_next_page if collection.more?
87
+ def fetch_next_page
88
+ raise TocDoc::Error, 'No client available for pagination' unless @client
89
+ raise StopIteration, 'No more pages available' unless more?
90
+
91
+ next_date = Date.parse(@data['next_slot']).to_s
92
+ next_page = @client.get(@path, query: @query.merge(start_date: next_date))
93
+ @data.delete('next_slot')
94
+ @data['next_slot'] = next_page['next_slot'] if next_page.key?('next_slot')
95
+ merge_page!(next_page)
96
+ end
97
+
65
98
  # All date entries — including those with no slots — as {TocDoc::Availability}
66
99
  # objects.
67
100
  #
@@ -55,12 +55,17 @@ module TocDoc
55
55
  def where(visit_motive_ids:, agenda_ids:, start_date: Date.today,
56
56
  limit: TocDoc.per_page, **options)
57
57
  client = TocDoc.client
58
+ depth = TocDoc.pagination_depth
58
59
  query = build_query(visit_motive_ids, agenda_ids, start_date, limit, options)
59
60
  data = client.get(PATH, query: query)
60
61
 
61
- merge_next_page(client, query, data) if data['next_slot']
62
+ depth.times do
63
+ break unless data['next_slot']
62
64
 
63
- Collection.new(data, query: query, path: PATH)
65
+ merge_next_page(client, query, data)
66
+ end
67
+
68
+ Collection.new(data, query: query, path: PATH, client: client)
64
69
  end
65
70
 
66
71
  private
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toc_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - 01max
@@ -62,6 +62,7 @@ files:
62
62
  - Rakefile
63
63
  - TODO.md
64
64
  - lib/toc_doc.rb
65
+ - lib/toc_doc/cache/memory_store.rb
65
66
  - lib/toc_doc/client.rb
66
67
  - lib/toc_doc/core/authentication.rb
67
68
  - lib/toc_doc/core/configurable.rb
@@ -70,8 +71,11 @@ files:
70
71
  - lib/toc_doc/core/error.rb
71
72
  - lib/toc_doc/core/uri_utils.rb
72
73
  - lib/toc_doc/core/version.rb
74
+ - lib/toc_doc/http/middleware/cache.rb
73
75
  - lib/toc_doc/http/middleware/logging.rb
74
76
  - lib/toc_doc/http/middleware/raise_error.rb
77
+ - lib/toc_doc/http/middleware/rate_limiter.rb
78
+ - lib/toc_doc/http/rate_limiter/token_bucket.rb
75
79
  - lib/toc_doc/middleware/.keep
76
80
  - lib/toc_doc/models.rb
77
81
  - lib/toc_doc/models/agenda.rb