client-api-builder 1.0.0 → 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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +97 -33
- data/UPGRADING.md +139 -0
- data/lib/client-api-builder.rb +18 -0
- data/lib/client_api_builder/connection_pools.rb +9 -5
- data/lib/client_api_builder/declared_transport.rb +24 -0
- data/lib/client_api_builder/http2.rb +9 -8
- data/lib/client_api_builder/nested_router.rb +4 -4
- data/lib/client_api_builder/net_http_request.rb +12 -3
- data/lib/client_api_builder/thread_connections.rb +9 -6
- data/lib/client_api_builder/transports/http2.rb +36 -0
- data/lib/client_api_builder/transports/one_shot.rb +18 -0
- data/lib/client_api_builder/transports/pooled.rb +30 -0
- data/lib/client_api_builder/transports/registry.rb +80 -0
- data/lib/client_api_builder/transports/thread_local.rb +31 -0
- data/lib/client_api_builder/transports.rb +20 -0
- data/lib/client_api_builder/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d553ae987e278ac4a41b6185531e02292cc2f3b57cd4af1332552d827ad74b5
|
|
4
|
+
data.tar.gz: 50cd54be44249bf9a366929c73abe839bab6c2594bf299bc6a0ebb5db4278db3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8dfbfc94f744e4d7f91386d550d6d33b98049c872b4324f3dffd9a49e3d42d52ea805d93c2e9cd0c97c556826b6a9aaa2415689d019a49790be14af677adc936
|
|
7
|
+
data.tar.gz: cd6170e890b619dbac6ec5fe7be55fb9f092fcde346597a201390ee351204a51f8d483a627e26ae3bffae0d50329d63d2e93f81192047b7d56f897c1bc187711
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.1.0](https://github.com/dougyouch/client-api-builder/compare/v1.0.0...v1.1.0) (2026-10-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **transports:** choose how requests are sent per environment ([2193768](https://github.com/dougyouch/client-api-builder/commit/2193768ce8b916ffb9064ccedbd09b87b47def5e))
|
|
9
|
+
|
|
3
10
|
## [1.0.0](https://github.com/dougyouch/client-api-builder/compare/v0.11.0...v1.0.0) (2026-10-05)
|
|
4
11
|
|
|
5
12
|
|
data/README.md
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
[](https://github.com/dougyouch/client-api-builder/actions/workflows/ci.yml)
|
|
6
6
|
[](https://github.com/dougyouch/client-api-builder/actions/workflows/ci.yml)
|
|
7
7
|
|
|
8
|
+
[Changelog](CHANGELOG.md) · [Upgrading to 1.0](UPGRADING.md)
|
|
9
|
+
|
|
8
10
|
A Ruby gem for building robust, secure API clients through declarative configuration. Define your API endpoints and their behavior with minimal boilerplate while benefiting from built-in security features, automatic retries, and comprehensive error handling.
|
|
9
11
|
|
|
10
12
|
## Features
|
|
@@ -15,6 +17,7 @@ A Ruby gem for building robust, secure API clients through declarative configura
|
|
|
15
17
|
- **Flexible Request Building** - Support for JSON, query params, and custom body builders
|
|
16
18
|
- **Nested Routing** - Organize complex APIs with hierarchical route structures
|
|
17
19
|
- **Retry Logic** - Configurable automatic retries for transient network failures
|
|
20
|
+
- **Transports** - Choose how requests reach the network per environment at boot, so one client class can open a connection per request in a web server and keep persistent connections in a worker
|
|
18
21
|
- **Connection Pooling** - Opt-in persistent connections shared across threads, with no extra gems
|
|
19
22
|
- **Per-Thread Connections** - Opt-in persistent connections dedicated to each thread, with no pool to size
|
|
20
23
|
- **HTTP/2** - Opt-in HTTP/2 over TLS, multiplexing concurrent requests on one connection per host (with the `http-2` gem)
|
|
@@ -336,7 +339,7 @@ section :users, inherit: %i[headers query_params connection_options] do
|
|
|
336
339
|
end
|
|
337
340
|
```
|
|
338
341
|
|
|
339
|
-
Inherited settings are the root client's class-level ones, read on every request (so ones declared after the section, or in a subclass of the client, apply too). The section's own settings override them, and per-request options override both; a per-request `nil` header still drops an inherited one. Retries and body/query builders are always the section's own. Any other options passed to `section` are available to it as `nested_router_options`.
|
|
342
|
+
Inherited settings are the root client's class-level ones, read on every request (so ones declared after the section, or in a subclass of the client, apply too). The section's own settings override them, and per-request options override both; a per-request `nil` header still drops an inherited one. Retries and body/query builders are always the section's own. Any other options passed to `section` are available to it as `nested_router_options`. Get a section's class with `<name>_router` (`MyApiClient.users_router`) rather than its constant name; see [UPGRADING.md](UPGRADING.md#section-class-names-are-no-longer-singularized).
|
|
340
343
|
|
|
341
344
|
Symbol and block values given to `header` and `query_param`, `{name}` path values, and response blocks are evaluated on the root client, so they can use its methods and state.
|
|
342
345
|
|
|
@@ -361,9 +364,67 @@ end
|
|
|
361
364
|
|
|
362
365
|
Any `Net::HTTP.start` option can be set this way. Your options are applied over the secure HTTPS defaults, so setting `verify_mode` yourself replaces `VERIFY_PEER`.
|
|
363
366
|
|
|
367
|
+
### Transports
|
|
368
|
+
|
|
369
|
+
How requests reach the network is up to a transport, chosen for the environment at boot rather than in the client class. The same `GitHubClient` can open a connection per request inside a web server and keep per-thread connections inside a background worker:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
# config/puma.rb, or any web server's boot code: the default, shown for clarity
|
|
373
|
+
ClientApiBuilder.use_transport(ClientApiBuilder::Transports::OneShot.new)
|
|
374
|
+
|
|
375
|
+
# config/initializers/sidekiq.rb
|
|
376
|
+
Sidekiq.configure_server do |config|
|
|
377
|
+
ClientApiBuilder.use_transport(ClientApiBuilder::Transports::ThreadLocal.new(ttl: 30))
|
|
378
|
+
config.on(:shutdown) { ClientApiBuilder.close_transports }
|
|
379
|
+
end
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
| Transport | Connections | Suits |
|
|
383
|
+
|---|---|---|
|
|
384
|
+
| `Transports::OneShot.new` (default) | A new one per request, closed once the response is read | Web servers, forking process managers, low request rates |
|
|
385
|
+
| `Transports::Pooled.new(max_connections:, ttl:, checkout_timeout:, idle_timeout:)` | Pools shared by every thread, one per scheme, host, port and connection options | Many threads, including short-lived ones ([details](#persistent-connections)) |
|
|
386
|
+
| `Transports::ThreadLocal.new(ttl:, idle_timeout:)` | One per thread and host, reused by that thread's requests | A fixed set of worker threads ([details](#per-thread-connections)) |
|
|
387
|
+
| `Transports::HTTP2.new(fallback: Transports::OneShot.new)` | One HTTP/2 connection per origin, carrying concurrent requests as streams | Many threads waiting on one slow https API ([details](#http2)) |
|
|
388
|
+
|
|
389
|
+
Register a transport for every client, for one client class (and its subclasses), or for one client instance:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
ClientApiBuilder.use_transport(ClientApiBuilder::Transports::ThreadLocal.new) # every client
|
|
393
|
+
ClientApiBuilder.use_transport(ClientApiBuilder::Transports::Pooled.new(max_connections: 10), GitHubClient)
|
|
394
|
+
client.transport = ClientApiBuilder::Transports::OneShot.new # this instance only
|
|
395
|
+
|
|
396
|
+
ClientApiBuilder.close_transports # closes every registered transport's connections, e.g. at shutdown
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
A request uses the first transport it finds:
|
|
400
|
+
|
|
401
|
+
1. The one set on the client instance with `transport=`.
|
|
402
|
+
2. The one registered with `use_transport` for the client's class or its nearest ancestor (a module works too, for every class including it).
|
|
403
|
+
3. The one the class itself includes (`ConnectionPools`, `ThreadConnections` or `HTTP2`, below), or its nearest ancestor's.
|
|
404
|
+
4. For a section, its root client's transport.
|
|
405
|
+
5. The process default set with `use_transport(transport)`, else `OneShot`.
|
|
406
|
+
|
|
407
|
+
So a class can include a sensible default for its API, and the environment still decides: `use_transport` for the class wins whether it runs before or after the class is loaded. Sections use their root client's transport, so they share its connections, unless a transport is set or registered for the section, or it calls `connection_pool` or `connection_per_thread`.
|
|
408
|
+
|
|
409
|
+
Every transport is fork-safe (after a fork, the child opens its own connections and leaves the parent's alone) and closes a connection whose request raised rather than reusing it.
|
|
410
|
+
|
|
411
|
+
A transport is any object with `with_connection(uri, connection_options) { |http| ... }`, which yields something answering `request(net_request) { |response| ... }` like a started `Net::HTTP`, and `close`. `connection_options` are the `Net::HTTP.start` options, with the [secure HTTPS defaults](#connection-options) applied.
|
|
412
|
+
|
|
364
413
|
### Persistent Connections
|
|
365
414
|
|
|
366
|
-
|
|
415
|
+
`Transports::Pooled` keeps connections open and reuses them:
|
|
416
|
+
|
|
417
|
+
```ruby
|
|
418
|
+
# Optional settings; these are the defaults
|
|
419
|
+
ClientApiBuilder.use_transport(
|
|
420
|
+
ClientApiBuilder::Transports::Pooled.new(max_connections: 5, # connections per host
|
|
421
|
+
ttl: 30, # seconds before a connection is closed and replaced
|
|
422
|
+
checkout_timeout: 5, # seconds to wait for a free connection
|
|
423
|
+
idle_timeout: 2) # seconds idle before Net::HTTP reconnects (keep_alive_timeout)
|
|
424
|
+
)
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Or give a client class its own pools, as its default, by including `ClientApiBuilder::ConnectionPools` (after `Router`):
|
|
367
428
|
|
|
368
429
|
```ruby
|
|
369
430
|
class MyApiClient
|
|
@@ -372,21 +433,17 @@ class MyApiClient
|
|
|
372
433
|
|
|
373
434
|
base_url 'https://api.example.com'
|
|
374
435
|
|
|
375
|
-
#
|
|
376
|
-
connection_pool max_connections: 5, # connections per host
|
|
377
|
-
ttl: 30, # seconds before a connection is closed and replaced
|
|
378
|
-
checkout_timeout: 5, # seconds to wait for a free connection
|
|
379
|
-
idle_timeout: 2 # seconds idle before Net::HTTP reconnects (keep_alive_timeout)
|
|
436
|
+
connection_pool max_connections: 10, ttl: 60 # optional; takes the same settings
|
|
380
437
|
end
|
|
381
438
|
```
|
|
382
439
|
|
|
383
|
-
|
|
440
|
+
Every client using the transport shares its pools: keep one client instance per thread and the threads share the connections. A connection is checked out for one request (or one streamed response) and returned straight after, so `max_connections` can be lower than your thread count. There is one pool per scheme, host, port and connection options.
|
|
384
441
|
|
|
385
442
|
- Sockets the server has closed, or that sat idle past `idle_timeout`, are reopened automatically.
|
|
386
443
|
- A connection whose request raised is closed rather than reused.
|
|
387
444
|
- When no connection frees up within `checkout_timeout`, the request raises `ClientApiBuilder::ConnectionPools::TimeoutError`.
|
|
388
|
-
-
|
|
389
|
-
- A section can call `connection_pool` in its block to get its own pools and settings. This works
|
|
445
|
+
- With `ConnectionPools`, sections use their root client's pools, getting a separate pool for any other host in their `base_url`. Subclasses share their parent's pools unless they call `connection_pool` themselves.
|
|
446
|
+
- A section can call `connection_pool` in its block to get its own pools and settings. This works whatever transport the root client uses:
|
|
390
447
|
|
|
391
448
|
```ruby
|
|
392
449
|
section :uploads do
|
|
@@ -397,25 +454,20 @@ The pools live on the class, like ActiveRecord's, so every instance shares them:
|
|
|
397
454
|
|
|
398
455
|
Sections nested inside it still fall back to the root client, as they do for `base_url`.
|
|
399
456
|
- After a fork (Puma, Sidekiq, Resque), the child process opens its own connections.
|
|
400
|
-
- `MyApiClient.close_connections` closes idle connections now and in-use ones when they're returned
|
|
457
|
+
- `transport.close` (or `MyApiClient.close_connections`, with `ConnectionPools`) closes idle connections now and in-use ones when they're returned. `close_connections` also closes the pools of sections that have their own.
|
|
401
458
|
|
|
402
459
|
Pooling uses only `Net::HTTP` from the standard library.
|
|
403
460
|
|
|
404
461
|
### Per-Thread Connections
|
|
405
462
|
|
|
406
|
-
When a program runs a fixed set of worker threads, each making its own requests,
|
|
463
|
+
When a program runs a fixed set of worker threads, each making its own requests, `Transports::ThreadLocal` gives every thread its own persistent connections:
|
|
407
464
|
|
|
408
465
|
```ruby
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
# Optional; these are the defaults
|
|
416
|
-
connection_per_thread ttl: 30, # seconds before a connection is closed and replaced
|
|
417
|
-
idle_timeout: 2 # seconds idle before Net::HTTP reconnects (keep_alive_timeout)
|
|
418
|
-
end
|
|
466
|
+
# Optional settings; these are the defaults
|
|
467
|
+
ClientApiBuilder.use_transport(
|
|
468
|
+
ClientApiBuilder::Transports::ThreadLocal.new(ttl: 30, # seconds before a connection is closed and replaced
|
|
469
|
+
idle_timeout: 2) # seconds idle before Net::HTTP reconnects (keep_alive_timeout)
|
|
470
|
+
)
|
|
419
471
|
|
|
420
472
|
workers = Array.new(20) do
|
|
421
473
|
Thread.new do
|
|
@@ -425,23 +477,35 @@ workers = Array.new(20) do
|
|
|
425
477
|
end
|
|
426
478
|
```
|
|
427
479
|
|
|
480
|
+
To make it a client class's default, include `ClientApiBuilder::ThreadConnections` (after `Router`, instead of `ConnectionPools`) and optionally call `connection_per_thread ttl: 30, idle_timeout: 2` in the class.
|
|
481
|
+
|
|
428
482
|
A thread opens a connection per host (scheme, port and connection options) on its first request and reuses it for every later one. There's no pool size to keep in step with the thread count, and no thread ever waits for another's connection.
|
|
429
483
|
|
|
430
484
|
- Sockets the server has closed, or that sat idle past `idle_timeout`, are reopened automatically. A connection whose request raised is closed rather than reused.
|
|
431
485
|
- A request made while the thread's connection is busy (from inside a streaming block, or from another fiber on the thread) opens a connection of its own; the thread keeps one of the two afterwards.
|
|
432
|
-
- Connections of threads that have finished are closed when a thread makes its first request. With short-lived threads, prefer `
|
|
433
|
-
-
|
|
434
|
-
- After a fork, the child process opens its own connections. `MyApiClient.close_connections` closes every thread's idle connections now and in-use ones when their request ends.
|
|
486
|
+
- Connections of threads that have finished are closed when a thread makes its first request. With short-lived threads, prefer `Transports::Pooled`.
|
|
487
|
+
- With `ThreadConnections`, sections and subclasses share the class's connections the same way they share pools, and a section can call `connection_per_thread` in its block to get its own.
|
|
488
|
+
- After a fork, the child process opens its own connections. `transport.close` (or `MyApiClient.close_connections`) closes every thread's idle connections now and in-use ones when their request ends.
|
|
435
489
|
|
|
436
490
|
### HTTP/2
|
|
437
491
|
|
|
438
|
-
|
|
492
|
+
`Transports::HTTP2` sends https requests over HTTP/2. It needs the [`http-2`](https://rubygems.org/gems/http-2) gem, which isn't installed with this one:
|
|
439
493
|
|
|
440
494
|
```ruby
|
|
441
495
|
# Gemfile
|
|
442
496
|
gem 'http-2'
|
|
443
497
|
```
|
|
444
498
|
|
|
499
|
+
```ruby
|
|
500
|
+
# fallback: is used for http:// URLs and servers that only speak HTTP/1.1 (default: OneShot)
|
|
501
|
+
ClientApiBuilder.use_transport(
|
|
502
|
+
ClientApiBuilder::Transports::HTTP2.new(fallback: ClientApiBuilder::Transports::Pooled.new),
|
|
503
|
+
MyApiClient
|
|
504
|
+
)
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Or make it a client class's default by including `ClientApiBuilder::HTTP2` (after `Router`, and after `ConnectionPools` or `ThreadConnections` to use them as the fallback):
|
|
508
|
+
|
|
445
509
|
```ruby
|
|
446
510
|
class MyApiClient
|
|
447
511
|
include ClientApiBuilder::Router
|
|
@@ -452,15 +516,15 @@ class MyApiClient
|
|
|
452
516
|
end
|
|
453
517
|
```
|
|
454
518
|
|
|
455
|
-
Every
|
|
519
|
+
Every client using the transport shares one connection per host (and connection options), and concurrent requests from different threads travel over it as separate streams. Responses are ordinary `Net::HTTPResponse` objects with `http_version` `'2.0'`, so `handle_response`, `UnexpectedResponse#response` and streaming routes work unchanged.
|
|
456
520
|
|
|
457
|
-
- The protocol is negotiated during the TLS handshake (ALPN). When a server picks HTTP/1.1, that host is remembered and its requests go through
|
|
521
|
+
- The protocol is negotiated during the TLS handshake (ALPN). When a server picks HTTP/1.1, that host is remembered and its requests go through the fallback transport. `http://` URLs always use the fallback.
|
|
458
522
|
- The usual connection options apply: `open_timeout`, `read_timeout` (per response header and body chunk), and the SSL options (`verify_mode`, `ca_file`, `cert_store`, `cert`, `key`, ...). Proxies aren't supported.
|
|
459
523
|
- When the server's limit on concurrent streams is reached, a request waits up to `read_timeout` for a stream to free up.
|
|
460
524
|
- Gzip and deflate responses are inflated, as `Net::HTTP` does, unless you set `Accept-Encoding` yourself.
|
|
461
525
|
- A stream the server refused, or never processed before closing the connection (GOAWAY), raises `ClientApiBuilder::HTTP2::StreamRefused`; a dropped connection raises `ClientApiBuilder::HTTP2::ConnectionLost`. Both are retried by `configure_retries`. A stream the server reset raises `ClientApiBuilder::HTTP2::StreamError`.
|
|
462
526
|
- Response data is acknowledged to the server as it arrives, so a streaming consumer slower than the server buffers the difference in memory.
|
|
463
|
-
-
|
|
527
|
+
- After a fork, the child opens its own connections. `transport.close` (or `MyApiClient.close_connections`, with `include ClientApiBuilder::HTTP2`) closes the HTTP/2 connections and the fallback's.
|
|
464
528
|
|
|
465
529
|
**When it's faster.** `script/benchmark_http2.rb` compares a new connection per request, `ConnectionPools` and HTTP/2 against a local TLS server. On one machine (Ruby 4.0.7, http-2 1.2.3):
|
|
466
530
|
|
|
@@ -470,7 +534,7 @@ Every instance of the class shares one connection per host (and connection optio
|
|
|
470
534
|
| 50 threads, 20ms delay, one pooled connection per thread | 628 req/s | 1,904 req/s | 1,765 req/s |
|
|
471
535
|
| 50 threads, 20ms delay, default pool of 5 | 640 req/s | 208 req/s | 1,706 req/s |
|
|
472
536
|
|
|
473
|
-
HTTP/2 avoids a TLS handshake per request, like the pools, and isn't limited by a connection count: many threads waiting on a slow API share one connection. Per request, it costs more client CPU than pooled HTTP/1.1 (the protocol layer is pure Ruby), which shows most with large bodies. If you can give each thread its own connection (a pool as large as the thread count, or `
|
|
537
|
+
HTTP/2 avoids a TLS handshake per request, like the pools, and isn't limited by a connection count: many threads waiting on a slow API share one connection. Per request, it costs more client CPU than pooled HTTP/1.1 (the protocol layer is pure Ruby), which shows most with large bodies. If you can give each thread its own connection (a pool as large as the thread count, or `Transports::ThreadLocal`), HTTP/1.1 is usually as fast or faster.
|
|
474
538
|
|
|
475
539
|
### Retry Configuration
|
|
476
540
|
|
|
@@ -756,7 +820,7 @@ Only safe file modes are allowed for streaming to files: `w`, `wb`, `a`, `ab`, `
|
|
|
756
820
|
|
|
757
821
|
## Thread Safety
|
|
758
822
|
|
|
759
|
-
Client instances are **not thread-safe**. Create a separate client instance per thread. To share connections between those instances, see [
|
|
823
|
+
Client instances are **not thread-safe**. Create a separate client instance per thread. To share connections between those instances, see [Transports](#transports).
|
|
760
824
|
|
|
761
825
|
```ruby
|
|
762
826
|
# Correct: Create a new client for each thread
|
|
@@ -817,7 +881,7 @@ Define these in your client to change default behavior:
|
|
|
817
881
|
| Method | Default |
|
|
818
882
|
|--------|---------|
|
|
819
883
|
| `retry_request?(exception, options)` | `true` for the network errors listed under Retry Configuration |
|
|
820
|
-
| `with_http_connection(uri, connection_options, &block)` | Yields a started `Net::HTTP
|
|
884
|
+
| `with_http_connection(uri, connection_options, &block)` | Yields a started `Net::HTTP` (or an HTTP/2 connection) from the client's [transport](#transports) |
|
|
821
885
|
| `escape_path(value)` | Percent-encodes path values (`ERB::Util.url_encode`) |
|
|
822
886
|
| `parse_response(response, options)` | Parses the body as JSON, `nil` when empty |
|
|
823
887
|
| `handle_response(response, options, &block)` | Applies `return:`, parsing and the response block |
|
|
@@ -828,7 +892,7 @@ Define these in your client to change default behavior:
|
|
|
828
892
|
## Requirements
|
|
829
893
|
|
|
830
894
|
- Ruby 3.3+
|
|
831
|
-
- `inheritance-helper` gem (
|
|
895
|
+
- `inheritance-helper` gem (1.x)
|
|
832
896
|
- `activesupport` (optional) for `to_query` builders and instrumentation
|
|
833
897
|
|
|
834
898
|
## Contributing
|
data/UPGRADING.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Upgrading
|
|
2
|
+
|
|
3
|
+
## From 1.0 to 1.1: transports (optional)
|
|
4
|
+
|
|
5
|
+
1.1 has no breaking changes. Clients that include `ClientApiBuilder::ConnectionPools`, `ThreadConnections` or `HTTP2`
|
|
6
|
+
keep working as before, with the same `connection_pool`, `connection_per_thread` and `close_connections` class methods.
|
|
7
|
+
|
|
8
|
+
What's new is that how requests reach the network can be chosen per environment at boot, with
|
|
9
|
+
[transports](README.md#transports), instead of only in the client class. Move to them when one client class needs
|
|
10
|
+
different connection handling in different processes, for example a connection per request in a web server and
|
|
11
|
+
persistent connections in a background worker.
|
|
12
|
+
|
|
13
|
+
Before, the class decided for every process:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
class GitHubClient
|
|
17
|
+
include ClientApiBuilder::Router
|
|
18
|
+
include ClientApiBuilder::ThreadConnections
|
|
19
|
+
|
|
20
|
+
base_url 'https://api.github.com'
|
|
21
|
+
connection_per_thread ttl: 30
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
After, the class describes the API and each process picks its transport:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
class GitHubClient
|
|
29
|
+
include ClientApiBuilder::Router
|
|
30
|
+
|
|
31
|
+
base_url 'https://api.github.com'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Sidekiq boot code; the web server keeps the default, a new connection per request
|
|
35
|
+
ClientApiBuilder.use_transport(ClientApiBuilder::Transports::ThreadLocal.new(ttl: 30), GitHubClient)
|
|
36
|
+
at_exit { ClientApiBuilder.close_transports }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
You can also keep the `include` as the class's default and override it only where needed: a transport registered for
|
|
40
|
+
the class with `use_transport` wins over the one it includes, whether it's registered before or after the class loads.
|
|
41
|
+
|
|
42
|
+
| Include style | Transport |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `include ClientApiBuilder::ConnectionPools` + `connection_pool(**settings)` | `Transports::Pooled.new(**settings)` |
|
|
45
|
+
| `include ClientApiBuilder::ThreadConnections` + `connection_per_thread(**settings)` | `Transports::ThreadLocal.new(**settings)` |
|
|
46
|
+
| `include ClientApiBuilder::HTTP2` | `Transports::HTTP2.new` |
|
|
47
|
+
| `include ClientApiBuilder::ConnectionPools` + `include ClientApiBuilder::HTTP2` | `Transports::HTTP2.new(fallback: Transports::Pooled.new)` |
|
|
48
|
+
| `MyClient.close_connections` | `transport.close`, or `ClientApiBuilder.close_transports` for all of them |
|
|
49
|
+
|
|
50
|
+
One behaviour to know: if your client overrides `with_http_connection`, the override still runs and still takes
|
|
51
|
+
precedence over every transport.
|
|
52
|
+
|
|
53
|
+
## From 0.11 to 1.0
|
|
54
|
+
|
|
55
|
+
1.0 has two breaking changes. Most clients need no code changes; check whether these apply to you.
|
|
56
|
+
|
|
57
|
+
| Change | Who is affected |
|
|
58
|
+
|---|---|
|
|
59
|
+
| [Ruby 3.3 or newer is required](#ruby-33-or-newer-is-required) | Apps on Ruby 3.2 |
|
|
60
|
+
| [Section class names are no longer singularized](#section-class-names-are-no-longer-singularized) | Apps with ActiveSupport loaded (any Rails app) that refer to a section's class by its constant name |
|
|
61
|
+
|
|
62
|
+
1.0 also requires [inheritance-helper 1.x](https://github.com/dougyouch/inheritance-helper/blob/master/UPGRADING.md).
|
|
63
|
+
If your own code uses inheritance-helper directly, check its upgrade guide too.
|
|
64
|
+
|
|
65
|
+
### Ruby 3.3 or newer is required
|
|
66
|
+
|
|
67
|
+
Ruby 3.2 reached end of life in March 2026. On Ruby 3.2, Bundler and RubyGems keep resolving to 0.11.0.
|
|
68
|
+
|
|
69
|
+
### Section class names are no longer singularized
|
|
70
|
+
|
|
71
|
+
Each `section` creates a router class and assigns it to a constant on the client class. The name came from
|
|
72
|
+
inheritance-helper, which used `String#classify` when ActiveSupport was loaded, and `classify` singularizes. 1.0
|
|
73
|
+
requires inheritance-helper 1.x, which builds the name the same way everywhere:
|
|
74
|
+
|
|
75
|
+
| Section | 0.11 with ActiveSupport | 0.11 without ActiveSupport | 1.0 |
|
|
76
|
+
|---|---|---|---|
|
|
77
|
+
| `section :users` | `UserNestedRouter` | `UsersNestedRouter` | `UsersNestedRouter` |
|
|
78
|
+
| `section :news_items` | `NewsItemNestedRouter` | `NewsItemsNestedRouter` | `NewsItemsNestedRouter` |
|
|
79
|
+
| `section :user` | `UserNestedRouter` | `UserNestedRouter` | `UserNestedRouter` |
|
|
80
|
+
|
|
81
|
+
**What doesn't change:** `client.users`, `MyClient.users_router` and `MyClient.section_routers` return the same
|
|
82
|
+
classes as before. Without ActiveSupport, nothing changes at all.
|
|
83
|
+
|
|
84
|
+
**What changes:** with ActiveSupport loaded, code that names the constant, such as `MyClient::UserNestedRouter`
|
|
85
|
+
for `section :users`, gets a `NameError` (uninitialized constant).
|
|
86
|
+
|
|
87
|
+
**Watch for a singular and a plural section in the same client.** On 0.11 with ActiveSupport, `section :user`
|
|
88
|
+
and `section :users` both became `UserNestedRouter`, and whichever was declared last replaced the other (with an
|
|
89
|
+
"already initialized constant" warning). On 1.0 each gets its own class:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
class MyClient
|
|
93
|
+
include ClientApiBuilder::Router
|
|
94
|
+
|
|
95
|
+
section(:user) { route :me, '/me' }
|
|
96
|
+
section(:users) { route :list_users, '/users' }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
MyClient::UserNestedRouter # 0.11: the :users section (declared last) 1.0: the :user section
|
|
100
|
+
MyClient::UsersNestedRouter # 0.11: NameError 1.0: the :users section
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Code that used `UserNestedRouter` to mean the `:users` section doesn't fail on 1.0. It gets the `:user` section
|
|
104
|
+
instead, so check for this case.
|
|
105
|
+
|
|
106
|
+
#### Finding affected code
|
|
107
|
+
|
|
108
|
+
Search for the generated constants:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
grep -rnE '[A-Za-z]+NestedRouter\b' app lib spec | grep -v 'ClientApiBuilder::NestedRouter'
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
#### Fixing it
|
|
115
|
+
|
|
116
|
+
Use the `<name>_router` class method, which is the same on every version:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# before
|
|
120
|
+
MyClient::UserNestedRouter
|
|
121
|
+
# after
|
|
122
|
+
MyClient.users_router
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
If you can't change every reference at once, alias the old name to the section's class after it's defined:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
class MyClient
|
|
129
|
+
include ClientApiBuilder::Router
|
|
130
|
+
|
|
131
|
+
section(:users) { route :list_users, '/users' }
|
|
132
|
+
|
|
133
|
+
# temporary: keeps 0.11's name working until callers use users_router
|
|
134
|
+
UserNestedRouter = users_router
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Don't add an alias that clashes with a section of that name. In the example above with both `:user` and
|
|
139
|
+
`:users`, `UserNestedRouter` already belongs to `:user`.
|
data/lib/client-api-builder.rb
CHANGED
|
@@ -20,11 +20,26 @@ module ClientApiBuilder
|
|
|
20
20
|
|
|
21
21
|
class << self
|
|
22
22
|
attr_accessor :logger
|
|
23
|
+
|
|
24
|
+
# The process-wide Transports::Registry
|
|
25
|
+
attr_reader :transports
|
|
26
|
+
|
|
27
|
+
# Sends requests through transport: for every client, or for client_class and its
|
|
28
|
+
# subclasses when given. Meant to be called once at boot for the environment.
|
|
29
|
+
def use_transport(transport, client_class = nil)
|
|
30
|
+
transports.use(transport, client_class)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Closes the connections of every registered transport, e.g. at shutdown
|
|
34
|
+
def close_transports
|
|
35
|
+
transports.close_all
|
|
36
|
+
end
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
autoload :ActiveSupportNotifications, 'client_api_builder/active_support_notifications'
|
|
26
40
|
autoload :ActiveSupportLogSubscriber, 'client_api_builder/active_support_log_subscriber'
|
|
27
41
|
autoload :ConnectionPools, 'client_api_builder/connection_pools'
|
|
42
|
+
autoload :DeclaredTransport, 'client_api_builder/declared_transport'
|
|
28
43
|
autoload :HTTP2, 'client_api_builder/http2'
|
|
29
44
|
autoload :NestedRouter, 'client_api_builder/nested_router'
|
|
30
45
|
autoload :QueryParams, 'client_api_builder/query_params'
|
|
@@ -32,8 +47,11 @@ module ClientApiBuilder
|
|
|
32
47
|
autoload :Router, 'client_api_builder/router'
|
|
33
48
|
autoload :Section, 'client_api_builder/section'
|
|
34
49
|
autoload :ThreadConnections, 'client_api_builder/thread_connections'
|
|
50
|
+
autoload :Transports, 'client_api_builder/transports'
|
|
35
51
|
|
|
36
52
|
module NetHTTP
|
|
37
53
|
autoload :Request, 'client_api_builder/net_http_request'
|
|
38
54
|
end
|
|
55
|
+
|
|
56
|
+
@transports = Transports::Registry.new
|
|
39
57
|
end
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
#
|
|
13
13
|
# Each thread uses its own client instance; the instances check connections out of the
|
|
14
14
|
# class's pools (one pool per host and connection options) for the length of one request.
|
|
15
|
+
# The pools are a Transports::Pooled declared as the class's transport, so
|
|
16
|
+
# ClientApiBuilder.use_transport can still pick another one for the class at boot.
|
|
15
17
|
module ClientApiBuilder
|
|
16
18
|
module ConnectionPools
|
|
17
19
|
autoload :Connection, 'client_api_builder/connection_pools/connection'
|
|
@@ -34,6 +36,7 @@ module ClientApiBuilder
|
|
|
34
36
|
raise ArgumentError, 'include ClientApiBuilder::ConnectionPools before ClientApiBuilder::HTTP2'
|
|
35
37
|
end
|
|
36
38
|
|
|
39
|
+
base.extend ::ClientApiBuilder::DeclaredTransport
|
|
37
40
|
base.extend ClassMethods
|
|
38
41
|
base.connection_pool
|
|
39
42
|
end
|
|
@@ -42,7 +45,12 @@ module ClientApiBuilder
|
|
|
42
45
|
# Configures this class's pools, replacing any it already had. Subclasses share their
|
|
43
46
|
# parent's pools unless they call connection_pool themselves.
|
|
44
47
|
def connection_pool(**settings)
|
|
45
|
-
redefine_class_method(:
|
|
48
|
+
redefine_class_method(:http1_transport, ::ClientApiBuilder::Transports::Pooled.new(**settings))
|
|
49
|
+
declare_transport
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def connection_pools
|
|
53
|
+
http1_transport.pool_set
|
|
46
54
|
end
|
|
47
55
|
|
|
48
56
|
# Closes idle connections now and in-use ones when they are returned, including the
|
|
@@ -52,9 +60,5 @@ module ClientApiBuilder
|
|
|
52
60
|
section_routers.each_value(&:close_connections)
|
|
53
61
|
end
|
|
54
62
|
end
|
|
55
|
-
|
|
56
|
-
def with_http_connection(uri, connection_options, &)
|
|
57
|
-
self.class.connection_pools.with_connection(uri, connection_options, &)
|
|
58
|
-
end
|
|
59
63
|
end
|
|
60
64
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClientApiBuilder
|
|
4
|
+
# Class methods shared by the ConnectionPools, ThreadConnections and HTTP2 mixins. Each
|
|
5
|
+
# stores its transport on the class with redefine_class_method, so subclasses share it,
|
|
6
|
+
# and declare_transport registers their combination as the class's declared transport
|
|
7
|
+
# (see Transports::Registry): the HTTP/2 transport falling back to the HTTP/1.1 one.
|
|
8
|
+
module DeclaredTransport
|
|
9
|
+
# The transport for http URLs, and https origins without HTTP/2
|
|
10
|
+
def http1_transport
|
|
11
|
+
Transports::OneShot.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The class's HTTP/2 transport, when it includes HTTP2
|
|
15
|
+
def http2_transport
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def declare_transport
|
|
20
|
+
transport = http2_transport ? http2_transport.with_fallback(http1_transport) : http1_transport
|
|
21
|
+
ClientApiBuilder.transports.declare(transport, self)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
# as streams. TLS negotiates the protocol (ALPN): when the server picks HTTP/1.1, the origin is
|
|
16
16
|
# remembered and its requests go through Net::HTTP (or the pooled or per-thread connections)
|
|
17
17
|
# instead. http URLs always use HTTP/1.1. Responses are Net::HTTPResponse objects with
|
|
18
|
-
# http_version '2.0'.
|
|
18
|
+
# http_version '2.0'. The class's transport becomes a Transports::HTTP2 falling back to its
|
|
19
|
+
# HTTP/1.1 transport; ClientApiBuilder.use_transport can still pick another one at boot.
|
|
19
20
|
module ClientApiBuilder
|
|
20
21
|
module HTTP2
|
|
21
22
|
autoload :Connection, 'client_api_builder/http2/connection'
|
|
@@ -43,9 +44,10 @@ module ClientApiBuilder
|
|
|
43
44
|
raise ArgumentError, 'include ClientApiBuilder::Router before ClientApiBuilder::HTTP2'
|
|
44
45
|
end
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
base.extend ::ClientApiBuilder::DeclaredTransport
|
|
47
48
|
base.extend ClassMethods
|
|
48
|
-
base.redefine_class_method(:
|
|
49
|
+
base.redefine_class_method(:http2_transport, ::ClientApiBuilder::Transports::HTTP2.new)
|
|
50
|
+
base.declare_transport
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
def self.require_http2_gem
|
|
@@ -55,6 +57,10 @@ module ClientApiBuilder
|
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
module ClassMethods
|
|
60
|
+
def http2_connections
|
|
61
|
+
http2_transport.connections
|
|
62
|
+
end
|
|
63
|
+
|
|
58
64
|
# Closes this class's HTTP/2 connections, and its pooled or per-thread connections when it
|
|
59
65
|
# has them
|
|
60
66
|
def close_connections
|
|
@@ -62,10 +68,5 @@ module ClientApiBuilder
|
|
|
62
68
|
super if defined?(super)
|
|
63
69
|
end
|
|
64
70
|
end
|
|
65
|
-
|
|
66
|
-
def with_http_connection(uri, connection_options, &)
|
|
67
|
-
connection = uri.scheme == 'https' && self.class.http2_connections.connection_for(uri, connection_options)
|
|
68
|
-
connection ? yield(connection) : super
|
|
69
|
-
end
|
|
70
71
|
end
|
|
71
72
|
end
|
|
@@ -87,10 +87,10 @@ module ClientApiBuilder
|
|
|
87
87
|
root_router.handle_response(response, options, &)
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
# Uses the root client's
|
|
91
|
-
# connection_per_thread
|
|
92
|
-
def
|
|
93
|
-
|
|
90
|
+
# Uses the root client's transport unless one is set on this section, registered for its
|
|
91
|
+
# class, or declared by connection_pool or connection_per_thread
|
|
92
|
+
def transport
|
|
93
|
+
@transport || ClientApiBuilder.transports.registered_for(self.class) || root_router.transport
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
private
|
|
@@ -50,10 +50,19 @@ module ClientApiBuilder
|
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
#
|
|
54
|
-
|
|
53
|
+
# Sends this client's requests through transport instead of its class's
|
|
54
|
+
attr_writer :transport
|
|
55
|
+
|
|
56
|
+
# The transport set on this client, else the one registered for its class (see
|
|
57
|
+
# Transports::Registry)
|
|
58
|
+
def transport
|
|
59
|
+
@transport || ClientApiBuilder.transports.for_class(self.class)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Yields a started Net::HTTP session (or something answering request like one) for one
|
|
63
|
+
# request, from the client's transport
|
|
55
64
|
def with_http_connection(uri, connection_options, &)
|
|
56
|
-
|
|
65
|
+
transport.with_connection(uri, connection_options, &)
|
|
57
66
|
end
|
|
58
67
|
|
|
59
68
|
# validate_response, when given, is called with the response before its body is streamed
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
#
|
|
13
13
|
# Each thread opens its own connection per host on its first request and reuses it for its
|
|
14
14
|
# later ones, so there's no pool size to match to the thread count and no waiting for a
|
|
15
|
-
# connection to free up.
|
|
15
|
+
# connection to free up. The connections are a Transports::ThreadLocal declared as the class's
|
|
16
|
+
# transport, so ClientApiBuilder.use_transport can still pick another one for the class at boot.
|
|
16
17
|
module ClientApiBuilder
|
|
17
18
|
module ThreadConnections
|
|
18
19
|
autoload :ConnectionSet, 'client_api_builder/thread_connections/connection_set'
|
|
@@ -30,6 +31,7 @@ module ClientApiBuilder
|
|
|
30
31
|
raise ArgumentError, 'include ClientApiBuilder::ThreadConnections before ClientApiBuilder::HTTP2'
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
base.extend ::ClientApiBuilder::DeclaredTransport
|
|
33
35
|
base.extend ClassMethods
|
|
34
36
|
base.connection_per_thread
|
|
35
37
|
end
|
|
@@ -38,7 +40,12 @@ module ClientApiBuilder
|
|
|
38
40
|
# Configures this class's per-thread connections, replacing any it already had.
|
|
39
41
|
# Subclasses share their parent's unless they call connection_per_thread themselves.
|
|
40
42
|
def connection_per_thread(**settings)
|
|
41
|
-
redefine_class_method(:
|
|
43
|
+
redefine_class_method(:http1_transport, ::ClientApiBuilder::Transports::ThreadLocal.new(**settings))
|
|
44
|
+
declare_transport
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def thread_connections
|
|
48
|
+
http1_transport.connection_set
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
# Closes every thread's idle connections now and in-use ones when their request ends,
|
|
@@ -48,9 +55,5 @@ module ClientApiBuilder
|
|
|
48
55
|
section_routers.each_value(&:close_connections)
|
|
49
56
|
end
|
|
50
57
|
end
|
|
51
|
-
|
|
52
|
-
def with_http_connection(uri, connection_options, &)
|
|
53
|
-
self.class.thread_connections.with_connection(uri, connection_options, &)
|
|
54
|
-
end
|
|
55
58
|
end
|
|
56
59
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClientApiBuilder
|
|
4
|
+
module Transports
|
|
5
|
+
# HTTP/2 for https, one connection per origin and connection options carrying concurrent
|
|
6
|
+
# requests as streams (needs the http-2 gem). http URLs, and origins whose server chose
|
|
7
|
+
# HTTP/1.1 during the TLS handshake, go through the fallback transport.
|
|
8
|
+
#
|
|
9
|
+
# connections: shares the HTTP/2 connections of another transport (see with_fallback).
|
|
10
|
+
class HTTP2
|
|
11
|
+
attr_reader :fallback, :connections
|
|
12
|
+
|
|
13
|
+
def initialize(fallback: OneShot.new, connections: nil)
|
|
14
|
+
::ClientApiBuilder::HTTP2.require_http2_gem
|
|
15
|
+
@fallback = fallback
|
|
16
|
+
@connections = connections || ::ClientApiBuilder::HTTP2::ConnectionSet.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A transport over the same HTTP/2 connections with a different fallback
|
|
20
|
+
def with_fallback(fallback)
|
|
21
|
+
self.class.new(fallback: fallback, connections: connections)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with_connection(uri, connection_options, &)
|
|
25
|
+
connection = uri.scheme == 'https' && connections.connection_for(uri, connection_options)
|
|
26
|
+
connection ? yield(connection) : fallback.with_connection(uri, connection_options, &)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Closes the HTTP/2 connections and the fallback's
|
|
30
|
+
def close
|
|
31
|
+
connections.close
|
|
32
|
+
fallback.close
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module ClientApiBuilder
|
|
6
|
+
module Transports
|
|
7
|
+
# A new connection for every request, closed once the response is read. Nothing outlives
|
|
8
|
+
# a request, so it suits web servers and forking process managers. This is the default.
|
|
9
|
+
class OneShot
|
|
10
|
+
def with_connection(uri, connection_options, &)
|
|
11
|
+
Net::HTTP.start(uri.hostname, uri.port, connection_options, &)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Nothing stays open between requests
|
|
15
|
+
def close; end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClientApiBuilder
|
|
4
|
+
module Transports
|
|
5
|
+
# Persistent connections shared by every thread, checked out of a pool per scheme, host,
|
|
6
|
+
# port and connection options for the length of one request. Takes the
|
|
7
|
+
# ConnectionPools::Settings options (max_connections, ttl, checkout_timeout, idle_timeout).
|
|
8
|
+
# A connection whose request raised is closed, and after a fork the child opens its own.
|
|
9
|
+
class Pooled
|
|
10
|
+
attr_reader :pool_set
|
|
11
|
+
|
|
12
|
+
def initialize(**settings)
|
|
13
|
+
@pool_set = ConnectionPools::PoolSet.new(ConnectionPools::Settings.new(**settings))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def settings
|
|
17
|
+
pool_set.settings
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def with_connection(uri, connection_options, &)
|
|
21
|
+
pool_set.with_connection(uri, connection_options, &)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Closes idle connections now and in-use ones when they're returned
|
|
25
|
+
def close
|
|
26
|
+
pool_set.close
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClientApiBuilder
|
|
4
|
+
module Transports
|
|
5
|
+
# Which transport each client class uses. A class (or module) can have two kinds of entry:
|
|
6
|
+
# one registered with use, normally at boot for the environment, and one declared by its
|
|
7
|
+
# class body through ConnectionPools, ThreadConnections or HTTP2. A class uses the
|
|
8
|
+
# registered transport of the nearest class in its ancestry, else the declared one of the
|
|
9
|
+
# nearest, else the process default (OneShot unless use set another), so the environment's
|
|
10
|
+
# choice wins whichever loads first.
|
|
11
|
+
#
|
|
12
|
+
# Entries are held weakly by class, so anonymous client classes can still be collected.
|
|
13
|
+
class Registry
|
|
14
|
+
def initialize(default_factory: -> { OneShot.new })
|
|
15
|
+
@default_factory = default_factory
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@default = nil
|
|
18
|
+
@registered = ObjectSpace::WeakKeyMap.new
|
|
19
|
+
@declared = ObjectSpace::WeakKeyMap.new
|
|
20
|
+
@transports = ObjectSpace::WeakMap.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Sends requests through transport: for every client, or for client_class and its
|
|
24
|
+
# subclasses (and the classes including it, for a module) when given
|
|
25
|
+
def use(transport, client_class = nil)
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
client_class ? @registered[client_class] = transport : @default = transport
|
|
28
|
+
@transports[transport] = true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The transport a client class's own body chose; use takes precedence over it
|
|
33
|
+
def declare(transport, client_class)
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
@declared[client_class] = transport
|
|
36
|
+
@transports[transport] = true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The transport registered or declared for the class or its nearest ancestor, or nil
|
|
41
|
+
def registered_for(client_class)
|
|
42
|
+
ancestors = client_class.ancestors
|
|
43
|
+
@mutex.synchronize { nearest(@registered, ancestors) || nearest(@declared, ancestors) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def for_class(client_class)
|
|
47
|
+
registered_for(client_class) || default
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def default
|
|
51
|
+
@mutex.synchronize { @default ||= @default_factory.call }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Closes every transport registered or declared, and the default
|
|
55
|
+
def close_all
|
|
56
|
+
transports = @mutex.synchronize { [*@transports.keys, @default].compact.uniq }
|
|
57
|
+
transports.each(&:close)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Forgets the transports set with use, and the default, closing nothing; declarations
|
|
61
|
+
# belong to class bodies and stay. Meant for specs.
|
|
62
|
+
def reset
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
@registered = ObjectSpace::WeakKeyMap.new
|
|
65
|
+
@default = nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def nearest(entries, ancestors)
|
|
72
|
+
ancestors.each do |ancestor|
|
|
73
|
+
transport = entries[ancestor]
|
|
74
|
+
return transport if transport
|
|
75
|
+
end
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClientApiBuilder
|
|
4
|
+
module Transports
|
|
5
|
+
# Persistent connections dedicated to each thread: one per scheme, host, port and
|
|
6
|
+
# connection options, opened on the thread's first request. Takes the
|
|
7
|
+
# ThreadConnections::Settings options (ttl, idle_timeout). Suits a fixed set of worker
|
|
8
|
+
# threads; with many short-lived threads, prefer Pooled. A connection whose request
|
|
9
|
+
# raised is closed, and after a fork the child opens its own.
|
|
10
|
+
class ThreadLocal
|
|
11
|
+
attr_reader :connection_set
|
|
12
|
+
|
|
13
|
+
def initialize(**settings)
|
|
14
|
+
@connection_set = ThreadConnections::ConnectionSet.new(ThreadConnections::Settings.new(**settings))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def settings
|
|
18
|
+
connection_set.settings
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def with_connection(uri, connection_options, &)
|
|
22
|
+
connection_set.with_connection(uri, connection_options, &)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Closes every thread's idle connections now and in-use ones when their request ends
|
|
26
|
+
def close
|
|
27
|
+
connection_set.close
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Purpose: objects that own connections and send requests, chosen per environment at boot
|
|
4
|
+
# rather than baked into a client class:
|
|
5
|
+
#
|
|
6
|
+
# ClientApiBuilder.use_transport(ClientApiBuilder::Transports::ThreadLocal.new) # every client
|
|
7
|
+
# ClientApiBuilder.use_transport(ClientApiBuilder::Transports::Pooled.new, GitHubClient) # one class
|
|
8
|
+
#
|
|
9
|
+
# A transport answers with_connection(uri, connection_options) { |http| ... }, yielding
|
|
10
|
+
# something that responds to request(net_request) { |response| } like a started Net::HTTP,
|
|
11
|
+
# and close, which closes its connections.
|
|
12
|
+
module ClientApiBuilder
|
|
13
|
+
module Transports
|
|
14
|
+
autoload :HTTP2, 'client_api_builder/transports/http2'
|
|
15
|
+
autoload :OneShot, 'client_api_builder/transports/one_shot'
|
|
16
|
+
autoload :Pooled, 'client_api_builder/transports/pooled'
|
|
17
|
+
autoload :Registry, 'client_api_builder/transports/registry'
|
|
18
|
+
autoload :ThreadLocal, 'client_api_builder/transports/thread_local'
|
|
19
|
+
end
|
|
20
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: client-api-builder
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Doug Youch
|
|
@@ -43,6 +43,7 @@ files:
|
|
|
43
43
|
- CHANGELOG.md
|
|
44
44
|
- LICENSE
|
|
45
45
|
- README.md
|
|
46
|
+
- UPGRADING.md
|
|
46
47
|
- lib/client-api-builder.rb
|
|
47
48
|
- lib/client_api_builder/active_support_log_subscriber.rb
|
|
48
49
|
- lib/client_api_builder/active_support_notifications.rb
|
|
@@ -51,6 +52,7 @@ files:
|
|
|
51
52
|
- lib/client_api_builder/connection_pools/pool.rb
|
|
52
53
|
- lib/client_api_builder/connection_pools/pool_set.rb
|
|
53
54
|
- lib/client_api_builder/connection_pools/settings.rb
|
|
55
|
+
- lib/client_api_builder/declared_transport.rb
|
|
54
56
|
- lib/client_api_builder/http2.rb
|
|
55
57
|
- lib/client_api_builder/http2/connection.rb
|
|
56
58
|
- lib/client_api_builder/http2/connection_set.rb
|
|
@@ -68,6 +70,12 @@ files:
|
|
|
68
70
|
- lib/client_api_builder/thread_connections.rb
|
|
69
71
|
- lib/client_api_builder/thread_connections/connection_set.rb
|
|
70
72
|
- lib/client_api_builder/thread_connections/settings.rb
|
|
73
|
+
- lib/client_api_builder/transports.rb
|
|
74
|
+
- lib/client_api_builder/transports/http2.rb
|
|
75
|
+
- lib/client_api_builder/transports/one_shot.rb
|
|
76
|
+
- lib/client_api_builder/transports/pooled.rb
|
|
77
|
+
- lib/client_api_builder/transports/registry.rb
|
|
78
|
+
- lib/client_api_builder/transports/thread_local.rb
|
|
71
79
|
- lib/client_api_builder/version.rb
|
|
72
80
|
homepage: https://github.com/dougyouch/client-api-builder
|
|
73
81
|
licenses:
|