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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +38 -7
- data/TODO.md +6 -6
- data/lib/toc_doc/cache/memory_store.rb +81 -0
- data/lib/toc_doc/core/configurable.rb +26 -0
- data/lib/toc_doc/core/connection.rb +6 -6
- data/lib/toc_doc/core/default.rb +75 -19
- data/lib/toc_doc/core/version.rb +1 -1
- data/lib/toc_doc/http/middleware/cache.rb +72 -0
- data/lib/toc_doc/http/middleware/rate_limiter.rb +34 -0
- data/lib/toc_doc/http/rate_limiter/token_bucket.rb +63 -0
- data/lib/toc_doc/models/availability/collection.rb +34 -1
- data/lib/toc_doc/models/availability.rb +7 -2
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36c32de39cc1eed0396372360e37b86d7df01e342926c32ab03aa450db500ff9
|
|
4
|
+
data.tar.gz: 578e0b0c633708c5c8adc7f3e8b0212e563ca7eb07c9e7b73b52d72196b7f652
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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`
|
|
451
|
-
|
|
452
|
-
the
|
|
453
|
-
before the collection
|
|
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
|
|
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
|
|
103
|
-
# so
|
|
104
|
-
# Falls back to the memoized {TocDoc::Default.middleware} when
|
|
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
|
data/lib/toc_doc/core/default.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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,
|
data/lib/toc_doc/core/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
62
|
+
depth.times do
|
|
63
|
+
break unless data['next_slot']
|
|
62
64
|
|
|
63
|
-
|
|
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.
|
|
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
|