client-api-builder 0.10.0 → 0.11.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: d24a1d62d5ce8f7ace55ad6e7505fddb3663c51d032df031f4e058bb632b6f5e
4
- data.tar.gz: a40b56b3f7ff04ecbe3507ea7ef9a7e5bbd8bc0aeb34ef9a0255038b62574948
3
+ metadata.gz: c5a8b4e5fcad31bffb2d4234fb2d73205ae0e3aed33ba75ec32defa778ed456f
4
+ data.tar.gz: ff349350509add94a7108af7dddf9416b98265752735a510d46d35bc97897849
5
5
  SHA512:
6
- metadata.gz: '08cbffeca686d5299d7e294a25a750dc5ea7542ba017c3dbda817159b395bc51af51802f09326594b20968ac8f5628bf29d0e798bec84fddf4ff18c2e2613759'
7
- data.tar.gz: 7a8476834697f1c8daf6498eeccd0a9fb256fd93b820e5fd407e586a49bdd3564459f005536b7eb0f29eba27c4925ffd8197289a77293df60310495f2c8c93a1
6
+ metadata.gz: cea030b2bb67c167c3dc4f4a798cadd2e4a8c873fe71d19bed944b4263446f0befb3cbdbc6dccbd9741814aeedb52f0b0b7b2aff155f7800c221601d52383fbd
7
+ data.tar.gz: 62d3ccbfc3ad52500a29070c6bad1961ba06539e131fc53e5eb775616c8ca179d91c8bd4f0e3bfa150cb3c40a2f8897f3e8bb957d4ae867400c7ec6a872db42e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.0](https://github.com/dougyouch/client-api-builder/compare/v0.10.0...v0.11.0) (2026-10-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * **thread_connections:** add per-thread persistent connections ([3ad347d](https://github.com/dougyouch/client-api-builder/commit/3ad347d7a022b60dfb9e0d36de5260dd37f456be))
9
+ * **thread_connections:** add per-thread persistent connections ([4f61cd9](https://github.com/dougyouch/client-api-builder/commit/4f61cd94731ac81c0993bfbee8acf6c9c4328ce2))
10
+
3
11
  ## [0.10.0](https://github.com/dougyouch/client-api-builder/compare/v0.9.0...v0.10.0) (2026-10-04)
4
12
 
5
13
 
data/README.md CHANGED
@@ -16,6 +16,7 @@ A Ruby gem for building robust, secure API clients through declarative configura
16
16
  - **Nested Routing** - Organize complex APIs with hierarchical route structures
17
17
  - **Retry Logic** - Configurable automatic retries for transient network failures
18
18
  - **Connection Pooling** - Opt-in persistent connections shared across threads, with no extra gems
19
+ - **Per-Thread Connections** - Opt-in persistent connections dedicated to each thread, with no pool to size
19
20
  - **HTTP/2** - Opt-in HTTP/2 over TLS, multiplexing concurrent requests on one connection per host (with the `http-2` gem)
20
21
  - **Streaming Support** - Handle large payloads efficiently with streaming to files or IO
21
22
  - **ActiveSupport Integration** - Optional logging and instrumentation
@@ -400,9 +401,41 @@ The pools live on the class, like ActiveRecord's, so every instance shares them:
400
401
 
401
402
  Pooling uses only `Net::HTTP` from the standard library.
402
403
 
404
+ ### Per-Thread Connections
405
+
406
+ When a program runs a fixed set of worker threads, each making its own requests, include `ClientApiBuilder::ThreadConnections` (after `Router`, instead of `ConnectionPools`) to give every thread its own persistent connections:
407
+
408
+ ```ruby
409
+ class MyApiClient
410
+ include ClientApiBuilder::Router
411
+ include ClientApiBuilder::ThreadConnections
412
+
413
+ base_url 'https://api.example.com'
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
419
+
420
+ workers = Array.new(20) do
421
+ Thread.new do
422
+ client = MyApiClient.new
423
+ user_ids.each { |id| client.get_user(id: id) } # always over this thread's connection
424
+ end
425
+ end
426
+ ```
427
+
428
+ 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
+
430
+ - 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
+ - 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 `ConnectionPools`.
433
+ - 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.
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.
435
+
403
436
  ### HTTP/2
404
437
 
405
- Include `ClientApiBuilder::HTTP2` (after `Router`, and after `ConnectionPools` if you use both) to send https requests over HTTP/2. It needs the [`http-2`](https://rubygems.org/gems/http-2) gem, which isn't installed with this one:
438
+ Include `ClientApiBuilder::HTTP2` (after `Router`, and after `ConnectionPools` or `ThreadConnections` if you use either) to send https requests over HTTP/2. It needs the [`http-2`](https://rubygems.org/gems/http-2) gem, which isn't installed with this one:
406
439
 
407
440
  ```ruby
408
441
  # Gemfile
@@ -421,13 +454,13 @@ end
421
454
 
422
455
  Every instance of the class 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.
423
456
 
424
- - 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 `Net::HTTP`, or the connection pools if the class includes `ConnectionPools`. `http://` URLs always use HTTP/1.1.
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 `Net::HTTP`, or the pooled or per-thread connections if the class includes `ConnectionPools` or `ThreadConnections`. `http://` URLs always use HTTP/1.1.
425
458
  - 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.
426
459
  - When the server's limit on concurrent streams is reached, a request waits up to `read_timeout` for a stream to free up.
427
460
  - Gzip and deflate responses are inflated, as `Net::HTTP` does, unless you set `Accept-Encoding` yourself.
428
461
  - 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`.
429
462
  - Response data is acknowledged to the server as it arrives, so a streaming consumer slower than the server buffers the difference in memory.
430
- - Sections use their root client's connections. After a fork, the child opens its own. `MyApiClient.close_connections` closes the HTTP/2 connections (and the pools).
463
+ - Sections use their root client's connections. After a fork, the child opens its own. `MyApiClient.close_connections` closes the HTTP/2 connections (and the pooled or per-thread ones).
431
464
 
432
465
  **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):
433
466
 
@@ -437,7 +470,7 @@ Every instance of the class shares one connection per host (and connection optio
437
470
  | 50 threads, 20ms delay, one pooled connection per thread | 628 req/s | 1,904 req/s | 1,765 req/s |
438
471
  | 50 threads, 20ms delay, default pool of 5 | 640 req/s | 208 req/s | 1,706 req/s |
439
472
 
440
- 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 the pools a connection per thread, they're usually as fast or faster.
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 `ThreadConnections`), HTTP/1.1 is usually as fast or faster.
441
474
 
442
475
  ### Retry Configuration
443
476
 
@@ -759,7 +792,8 @@ end
759
792
  | `configure_retries(max_attempts, sleep = 0.05, backoff: 1, max_sleep: nil, jitter: false)` | Configure retry behavior |
760
793
  | `configure_exponential_retries(attempts:, initial: 0.1, max: 10, multiplier: 2, jitter: false)` | Configure retries with exponential backoff |
761
794
  | `connection_pool(**settings)` | Configure persistent connection pools (requires `include ClientApiBuilder::ConnectionPools`) |
762
- | `close_connections` | Close the class's pooled connections and its sections' (with `ConnectionPools`) |
795
+ | `connection_per_thread(**settings)` | Configure persistent per-thread connections (requires `include ClientApiBuilder::ThreadConnections`) |
796
+ | `close_connections` | Close the class's pooled or per-thread connections and its sections' (with `ConnectionPools` or `ThreadConnections`) |
763
797
  | `section_routers` | Section router classes by name |
764
798
  | `route(name, path, options)` | Define an API endpoint |
765
799
  | `section(name, options, &block)` | Define nested routes; `inherit:` opts into the root client's `:headers`, `:query_params` and/or `:connection_options` |
@@ -783,7 +817,7 @@ Define these in your client to change default behavior:
783
817
  | Method | Default |
784
818
  |--------|---------|
785
819
  | `retry_request?(exception, options)` | `true` for the network errors listed under Retry Configuration |
786
- | `with_http_connection(uri, connection_options, &block)` | Yields a started `Net::HTTP`: a new connection per request, or a pooled one with `ConnectionPools` |
820
+ | `with_http_connection(uri, connection_options, &block)` | Yields a started `Net::HTTP`: a new connection per request, a pooled one with `ConnectionPools`, or the thread's own with `ThreadConnections` |
787
821
  | `escape_path(value)` | Percent-encodes path values (`ERB::Util.url_encode`) |
788
822
  | `parse_response(response, options)` | Parses the body as JSON, `nil` when empty |
789
823
  | `handle_response(response, options, &block)` | Applies `return:`, parsing and the response block |
@@ -31,6 +31,7 @@ module ClientApiBuilder
31
31
  autoload :RouteValueValidator, 'client_api_builder/route_value_validator'
32
32
  autoload :Router, 'client_api_builder/router'
33
33
  autoload :Section, 'client_api_builder/section'
34
+ autoload :ThreadConnections, 'client_api_builder/thread_connections'
34
35
 
35
36
  module NetHTTP
36
37
  autoload :Request, 'client_api_builder/net_http_request'
@@ -26,6 +26,9 @@ module ClientApiBuilder
26
26
  unless base.include?(::ClientApiBuilder::Router)
27
27
  raise ArgumentError, 'include ClientApiBuilder::Router before ClientApiBuilder::ConnectionPools'
28
28
  end
29
+ if base.include?(::ClientApiBuilder::ThreadConnections)
30
+ raise ArgumentError, 'include either ClientApiBuilder::ConnectionPools or ClientApiBuilder::ThreadConnections'
31
+ end
29
32
  # HTTP2 falls back to the pools through super, so it must come after them
30
33
  if base.ancestors.any? { |mod| mod.name == 'ClientApiBuilder::HTTP2' }
31
34
  raise ArgumentError, 'include ClientApiBuilder::ConnectionPools before ClientApiBuilder::HTTP2'
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Purpose: opt-in HTTP/2 for https requests, using the http-2 gem (add gem 'http-2' to your
4
4
  # Gemfile; it isn't a runtime dependency). Include after ClientApiBuilder::Router, and after
5
- # ClientApiBuilder::ConnectionPools when using both:
5
+ # ClientApiBuilder::ConnectionPools or ClientApiBuilder::ThreadConnections when using either:
6
6
  #
7
7
  # class MyClient
8
8
  # include ClientApiBuilder::Router
@@ -13,8 +13,9 @@
13
13
  #
14
14
  # Every instance of the class shares one connection per origin, carrying concurrent requests
15
15
  # as streams. TLS negotiates the protocol (ALPN): when the server picks HTTP/1.1, the origin is
16
- # remembered and its requests go through Net::HTTP (or the connection pools) instead. http
17
- # URLs always use HTTP/1.1. Responses are Net::HTTPResponse objects with http_version '2.0'.
16
+ # remembered and its requests go through Net::HTTP (or the pooled or per-thread connections)
17
+ # instead. http URLs always use HTTP/1.1. Responses are Net::HTTPResponse objects with
18
+ # http_version '2.0'.
18
19
  module ClientApiBuilder
19
20
  module HTTP2
20
21
  autoload :Connection, 'client_api_builder/http2/connection'
@@ -54,7 +55,8 @@ module ClientApiBuilder
54
55
  end
55
56
 
56
57
  module ClassMethods
57
- # Closes this class's HTTP/2 connections, and its connection pools when it has them
58
+ # Closes this class's HTTP/2 connections, and its pooled or per-thread connections when it
59
+ # has them
58
60
  def close_connections
59
61
  http2_connections.close
60
62
  super if defined?(super)
@@ -53,8 +53,16 @@ module ClientApiBuilder
53
53
  connection_pool(**settings)
54
54
  end
55
55
 
56
- # Closes the pools of any nested sections that have their own; a section with its own
57
- # pools uses ConnectionPools.close_connections, which closes those as well
56
+ # Gives this section its own per-thread connections (see
57
+ # ThreadConnections.connection_per_thread), the same way connection_pool does
58
+ def self.connection_per_thread(**settings)
59
+ include ::ClientApiBuilder::ThreadConnections
60
+
61
+ connection_per_thread(**settings)
62
+ end
63
+
64
+ # Closes the connections of any nested sections that have their own; a section with its
65
+ # own uses ConnectionPools or ThreadConnections.close_connections, which close those as well
58
66
  def self.close_connections
59
67
  section_routers.each_value(&:close_connections)
60
68
  end
@@ -79,7 +87,8 @@ module ClientApiBuilder
79
87
  root_router.handle_response(response, options, &)
80
88
  end
81
89
 
82
- # Uses the root client's connections unless this section calls connection_pool
90
+ # Uses the root client's connections unless this section calls connection_pool or
91
+ # connection_per_thread
83
92
  def with_http_connection(uri, connection_options, &)
84
93
  root_router.with_http_connection(uri, connection_options, &)
85
94
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClientApiBuilder
4
+ module ThreadConnections
5
+ # A client class's connections, kept per thread: one per scheme, host, port and connection
6
+ # options, opened on the thread's first request and reused by its later ones.
7
+ #
8
+ # A connection is taken out of its thread's slot while a request uses it, so a request made
9
+ # before it's back (from a streaming block, or another fiber on the thread) opens one of its
10
+ # own; whichever finishes second is closed rather than kept. A connection that outlives the
11
+ # ttl, was opened before close, or whose request raised is closed instead of reused. The
12
+ # connections of threads that have died are closed when a thread makes its first request.
13
+ # After a fork the child starts with none; the parent's are dropped without being closed,
14
+ # since closing a shared TLS socket would disturb the parent.
15
+ class ConnectionSet
16
+ MONOTONIC_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
17
+
18
+ attr_reader :settings
19
+
20
+ def initialize(settings, clock: MONOTONIC_CLOCK)
21
+ @settings = settings
22
+ @clock = clock
23
+ reset
24
+ end
25
+
26
+ # Yields the current thread's Net::HTTP session and returns the block's result
27
+ def with_connection(uri, connection_options)
28
+ key = [uri.scheme, uri.hostname, uri.port, connection_options.dup.freeze].freeze
29
+ connection = checkout(key) || open_connection(uri, connection_options)
30
+ returned = false
31
+ begin
32
+ result = yield connection.http
33
+ checkin(key, connection)
34
+ returned = true
35
+ result
36
+ ensure
37
+ connection.close unless returned
38
+ end
39
+ end
40
+
41
+ # Closes idle connections now; connections in use are closed when their request ends
42
+ def close
43
+ idle = @mutex.synchronize do
44
+ @closed_at = now
45
+ remove_threads(@threads.keys)
46
+ end
47
+ idle.each(&:close)
48
+ end
49
+
50
+ # Idle connections, across all threads
51
+ def size
52
+ @mutex.synchronize { @threads.each_value.sum(&:size) }
53
+ end
54
+
55
+ private
56
+
57
+ def reset
58
+ @pid = Process.pid
59
+ @mutex = Mutex.new
60
+ @threads = {}
61
+ @closed_at = nil
62
+ end
63
+
64
+ def forked?
65
+ @pid != Process.pid
66
+ end
67
+
68
+ def now
69
+ @clock.call
70
+ end
71
+
72
+ # Returns the thread's connection for key, or nil when it has none to reuse. Retired
73
+ # connections are closed after the lock is released.
74
+ def checkout(key)
75
+ retired = []
76
+ reset if forked?
77
+ @mutex.synchronize do
78
+ retired.concat(remove_threads(dead_threads)) unless @threads.key?(Thread.current)
79
+ take(key, retired)
80
+ end
81
+ ensure
82
+ retired.each(&:close)
83
+ end
84
+
85
+ def take(key, retired)
86
+ connection = @threads[Thread.current]&.delete(key)
87
+ return connection unless connection && retire?(connection)
88
+
89
+ retired << connection
90
+ nil
91
+ end
92
+
93
+ def open_connection(uri, connection_options)
94
+ ConnectionPools::Connection.open(uri.hostname, uri.port,
95
+ { keep_alive_timeout: settings.idle_timeout }.merge(connection_options), now)
96
+ end
97
+
98
+ def checkin(key, connection)
99
+ connection.used(now)
100
+ kept = @mutex.synchronize { !retire?(connection) && keep?(key, connection) }
101
+ connection.close unless kept
102
+ end
103
+
104
+ # Keeps the connection for the thread's next request, unless a request that ran while
105
+ # this one did has already left one there
106
+ def keep?(key, connection)
107
+ connections = (@threads[Thread.current] ||= {})
108
+ return false if connections.key?(key)
109
+
110
+ connections[key] = connection
111
+ true
112
+ end
113
+
114
+ def dead_threads
115
+ @threads.keys.reject(&:alive?)
116
+ end
117
+
118
+ # Forgets the threads and returns their idle connections
119
+ def remove_threads(threads)
120
+ threads.flat_map { |thread| @threads.delete(thread).values }
121
+ end
122
+
123
+ def retire?(connection)
124
+ connection.expired?(settings.ttl, now) || closed_since_opened?(connection)
125
+ end
126
+
127
+ def closed_since_opened?(connection)
128
+ !@closed_at.nil? && connection.opened_at <= @closed_at
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClientApiBuilder
4
+ module ThreadConnections
5
+ # ttl: seconds a connection may live before it's closed and replaced
6
+ # idle_timeout: seconds a connection may sit unused before Net::HTTP reconnects it
7
+ # (Net::HTTP's keep_alive_timeout; a connection option overrides it)
8
+ Settings = Data.define(:ttl, :idle_timeout) do
9
+ def initialize(ttl: 30, idle_timeout: 2)
10
+ { ttl: ttl, idle_timeout: idle_timeout }.each do |name, value|
11
+ next if value.is_a?(Numeric) && value.positive?
12
+
13
+ raise ArgumentError, "#{name} must be a positive number of seconds, got #{value.inspect}"
14
+ end
15
+ super
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Purpose: opt-in persistent HTTP connections dedicated to each thread. Include after
4
+ # ClientApiBuilder::Router, instead of ClientApiBuilder::ConnectionPools:
5
+ #
6
+ # class MyClient
7
+ # include ClientApiBuilder::Router
8
+ # include ClientApiBuilder::ThreadConnections
9
+ #
10
+ # connection_per_thread ttl: 60
11
+ # end
12
+ #
13
+ # Each thread opens its own connection per host on its first request and reuses it for its
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.
16
+ module ClientApiBuilder
17
+ module ThreadConnections
18
+ autoload :ConnectionSet, 'client_api_builder/thread_connections/connection_set'
19
+ autoload :Settings, 'client_api_builder/thread_connections/settings'
20
+
21
+ def self.included(base)
22
+ unless base.include?(::ClientApiBuilder::Router)
23
+ raise ArgumentError, 'include ClientApiBuilder::Router before ClientApiBuilder::ThreadConnections'
24
+ end
25
+ if base.include?(::ClientApiBuilder::ConnectionPools)
26
+ raise ArgumentError, 'include either ClientApiBuilder::ConnectionPools or ClientApiBuilder::ThreadConnections'
27
+ end
28
+ # HTTP2 falls back to these connections through super, so it must come after them
29
+ if base.ancestors.any? { |mod| mod.name == 'ClientApiBuilder::HTTP2' }
30
+ raise ArgumentError, 'include ClientApiBuilder::ThreadConnections before ClientApiBuilder::HTTP2'
31
+ end
32
+
33
+ base.extend ClassMethods
34
+ base.connection_per_thread
35
+ end
36
+
37
+ module ClassMethods
38
+ # Configures this class's per-thread connections, replacing any it already had.
39
+ # Subclasses share their parent's unless they call connection_per_thread themselves.
40
+ def connection_per_thread(**settings)
41
+ redefine_class_method(:thread_connections, ConnectionSet.new(Settings.new(**settings)))
42
+ end
43
+
44
+ # Closes every thread's idle connections now and in-use ones when their request ends,
45
+ # including those of sections that have their own
46
+ def close_connections
47
+ thread_connections.close
48
+ section_routers.each_value(&:close_connections)
49
+ end
50
+ end
51
+
52
+ def with_http_connection(uri, connection_options, &)
53
+ self.class.thread_connections.with_connection(uri, connection_options, &)
54
+ end
55
+ end
56
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ClientApiBuilder
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.10.0'
5
+ VERSION = '0.11.0'
6
6
  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: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch
@@ -59,6 +59,9 @@ files:
59
59
  - lib/client_api_builder/route_value_validator.rb
60
60
  - lib/client_api_builder/router.rb
61
61
  - lib/client_api_builder/section.rb
62
+ - lib/client_api_builder/thread_connections.rb
63
+ - lib/client_api_builder/thread_connections/connection_set.rb
64
+ - lib/client_api_builder/thread_connections/settings.rb
62
65
  - lib/client_api_builder/version.rb
63
66
  homepage: https://github.com/dougyouch/client-api-builder
64
67
  licenses: