jwt_auth_client 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,63 +1,126 @@
1
- require 'faraday'
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require_relative "errors"
5
+ require_relative "json_decoder"
6
+ require_relative "token_issuer"
2
7
 
3
8
  module JwtAuthClient
4
- # HttpClient is a thin wrapper around Faraday that automatically issues a JWT
5
- # for the given user/scopes and injects it into the Authorization header
6
- # for secure inter-service communication.
9
+ # A thin wrapper around Faraday that issues a JWT for the given user/scopes
10
+ # and injects it as a Bearer token on every outgoing request.
11
+ #
12
+ # The returned connection is safe to keep and reuse for the lifetime of the
13
+ # process: the token is (re)issued lazily per request, so a long-lived
14
+ # connection never sends an expired token.
7
15
  class HttpClient
8
- # The public entry point for making requests.
16
+ # Tokens are re-issued when they are this close to expiring, even when
17
+ # token reuse is enabled.
18
+ REFRESH_MARGIN_SECONDS = 30
19
+
20
+ # Builds an authenticated Faraday connection.
9
21
  #
10
- # @param user_id [String] The ID of the user to impersonate for this request.
11
- # @param target_service [String] The specific backend service the token is intended for (used for 'aud' claim).
12
- # @param scopes [Array<String>] The permissions required for the request.
13
- # @param base_url [String] The base URL of the service to call (overrides global config if provided).
14
- # @return [Faraday::Response] The response object from the HTTP request.
15
- def self.call(user_id:, target_service:, scopes: [], base_url: nil)
16
- new(user_id, target_service, scopes, base_url).connection
22
+ # @param user_id [String] the subject the token is issued for. Required.
23
+ # @param target_service [Symbol, String] the audience; must be a key of
24
+ # `configuration.service_urls` unless `base_url` is given. Required.
25
+ # @param scopes [Array<String>] permissions to embed in the token.
26
+ # @param base_url [String, nil] overrides the configured URL for `target_service`
27
+ # (e.g. a canary host). It does not change the `aud` claim.
28
+ # @yieldparam conn [Faraday::Connection] to add middleware (logging,
29
+ # instrumentation, ...) before the adapter is set.
30
+ # @return [Faraday::Connection]
31
+ def self.call(user_id:, target_service:, scopes: [], base_url: nil, &customize)
32
+ new(user_id, target_service, scopes, base_url, &customize).connection
17
33
  end
18
34
 
19
35
  attr_reader :user_id, :target_service, :scopes, :base_url
20
36
 
21
- # Initializes the client instance.
22
- def initialize(user_id, target_service, scopes, base_url = nil)
37
+ def initialize(user_id, target_service, scopes, base_url = nil, &customize)
38
+ raise ArgumentError, "user_id is required" if user_id.nil? || user_id.to_s.empty?
39
+ raise ArgumentError, "target_service is required" if target_service.nil? || target_service.to_s.empty?
40
+
23
41
  @user_id = user_id
24
42
  @target_service = target_service
25
- @scopes = scopes
43
+ @scopes = Array(scopes)
26
44
  @base_url = base_url
45
+ @customize = customize
27
46
  @config = JwtAuthClient.configuration
47
+ @token_mutex = Mutex.new
28
48
  end
29
49
 
30
- # Builds and memoizes the Faraday connection object.
31
- # The JWT is issued and included in a request header during this connection setup.
32
- #
33
- # @return [Faraday::Connection] The pre-configured Faraday connection.
50
+ # @return [Faraday::Connection] the memoised, pre-configured connection.
34
51
  def connection
35
- @connection ||= Faraday.new(url: determined_base_url) do |conn|
36
- # Inject the Authorization header with the JWT before every request
37
- conn.request :authorization, 'Bearer', jwt_token
38
-
39
- # Other standard middleware
40
- conn.request :json
41
- conn.response :json, content_type: /\bjson$/
42
- conn.response :raise_error # Raise exceptions on 4xx/5xx responses
43
- conn.adapter Faraday.default_adapter
52
+ @connection ||= begin
53
+ @config.validate!
54
+ Faraday.new(url: determined_base_url) do |conn|
55
+ configure_timeouts(conn)
56
+
57
+ # Evaluated on every request, so the token is always fresh.
58
+ conn.request :authorization, "Bearer", -> { bearer_token }
59
+ conn.request :json
60
+
61
+ # Response middleware runs innermost-first, so declaration order matters:
62
+ # raise_error - outermost: raises on 4xx/5xx once retries are exhausted,
63
+ # with the already-parsed JSON body attached to the error.
64
+ # retry - (opt-in) sees raw statuses, so `retry_statuses` works.
65
+ # json - innermost: parses the body before anything else sees it.
66
+ conn.response :raise_error
67
+ configure_retry(conn)
68
+ conn.response :json, parser_options: { decoder: [JsonDecoder, :parse] }
69
+
70
+ @customize&.call(conn)
71
+ conn.adapter Faraday.default_adapter
72
+ end
73
+ end
74
+ end
75
+
76
+ # Returns a valid token, issuing a new one when there is none, when the
77
+ # cached one is within REFRESH_MARGIN_SECONDS of expiry, or when
78
+ # `configuration.token_reuse_seconds` has elapsed since it was issued.
79
+ #
80
+ # @return [String]
81
+ def bearer_token
82
+ @token_mutex.synchronize do
83
+ issue_token! if token_stale?
84
+ @token
44
85
  end
45
86
  end
46
87
 
47
88
  private
48
89
 
49
- # Determines the base URL, preferring the local override if provided.
50
90
  def determined_base_url
51
91
  base_url || @config.base_url_for(target_service)
52
92
  end
53
93
 
54
- # Uses the TokenIssuer service to generate the authenticated token.
55
- def jwt_token
56
- @jwt_token ||= TokenIssuer.call(
57
- user_id: user_id,
58
- target_service: target_service,
59
- scopes: scopes
60
- )
94
+ def configure_timeouts(conn)
95
+ conn.options.open_timeout = @config.open_timeout
96
+ conn.options.read_timeout = @config.read_timeout
97
+ conn.options.write_timeout = @config.write_timeout
98
+ conn.options.timeout = @config.read_timeout # for adapters that only honour :timeout
99
+ end
100
+
101
+ def configure_retry(conn)
102
+ return unless @config.retry_options
103
+
104
+ begin
105
+ require "faraday/retry"
106
+ rescue LoadError
107
+ raise ConfigurationError, "retry_options is set but the faraday-retry gem is not available"
108
+ end
109
+ conn.request :retry, **@config.retry_options
110
+ end
111
+
112
+ def token_stale?
113
+ return true if @token.nil?
114
+
115
+ now = Time.now.to_i
116
+ now >= @token_expires_at - REFRESH_MARGIN_SECONDS ||
117
+ now >= @token_issued_at + @config.token_reuse_seconds
118
+ end
119
+
120
+ def issue_token!
121
+ @token_issued_at = Time.now.to_i
122
+ @token_expires_at = @token_issued_at + @config.default_expiry_seconds
123
+ @token = TokenIssuer.call(user_id: user_id, target_service: target_service, scopes: scopes)
61
124
  end
62
125
  end
63
126
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "token_issuer"
4
+
5
+ module JwtAuthClient
6
+ # Mixin for objects (typically a User model) that can be turned into a JWT,
7
+ # e.g. by an SSO hub handing a signed identity token to a client app.
8
+ #
9
+ # class User < ApplicationRecord
10
+ # include JwtAuthClient::Issuable
11
+ #
12
+ # def jwt_claims
13
+ # { user_id: sso_id, email: email }
14
+ # end
15
+ # end
16
+ #
17
+ # user.to_jwt # => "eyJhbGciOi..."
18
+ # user.to_jwt(expiry_seconds: 60)
19
+ #
20
+ # The registered claims (iss, sub, iat, nbf, exp, jti) are always set by the
21
+ # gem; `jwt_claims` cannot override them. `sub` comes from `jwt_subject`,
22
+ # which defaults to `jwt_claims[:user_id]`, falling back to `id`.
23
+ module Issuable
24
+ # Custom claims to embed in the token. Override in the including class.
25
+ #
26
+ # @return [Hash]
27
+ def jwt_claims
28
+ raise NotImplementedError, "#{self.class} must define #jwt_claims returning a Hash of claims"
29
+ end
30
+
31
+ # The `sub` claim. Override to use a different identifier.
32
+ #
33
+ # @return [String, Integer]
34
+ def jwt_subject
35
+ claims = jwt_claims
36
+ claims[:user_id] || claims["user_id"] || (respond_to?(:id) ? id : nil)
37
+ end
38
+
39
+ # @param expiry_seconds [Integer, nil] overrides the configured default expiry.
40
+ # @param target_service [Symbol, String, nil] optional `aud` claim.
41
+ # @param scopes [Array<String>] optional `scopes` claim.
42
+ # @return [String] the signed JWT.
43
+ def to_jwt(expiry_seconds: nil, target_service: nil, scopes: [])
44
+ TokenIssuer.call(
45
+ user_id: jwt_subject,
46
+ target_service: target_service,
47
+ scopes: scopes,
48
+ claims: jwt_claims,
49
+ expiry_seconds: expiry_seconds
50
+ )
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module JwtAuthClient
6
+ # Adapter handed to Faraday's :json response middleware.
7
+ #
8
+ # Faraday (<= 2.14) calls `decoder.parse(body, options_hash)` with a positional
9
+ # options hash, which `json` >= 3.0 no longer accepts. Forwarding the options as
10
+ # keywords keeps response parsing working on both json 2.x and 3.x.
11
+ module JsonDecoder
12
+ module_function
13
+
14
+ def parse(body, options = {})
15
+ ::JSON.parse(body, **options)
16
+ end
17
+ end
18
+ end
@@ -1,60 +1,72 @@
1
- require 'jwt'
2
- require 'securerandom'
1
+ # frozen_string_literal: true
2
+
3
+ require "jwt"
4
+ require "securerandom"
5
+ require_relative "errors"
3
6
 
4
7
  module JwtAuthClient
8
+ # Issues short-lived, signed JWTs carrying the registered claims
9
+ # (iss, sub, iat, nbf, exp, jti) plus the optional `aud`, `scopes` and any
10
+ # caller-supplied custom claims.
5
11
  class TokenIssuer
6
- # This is the ONLY public class method API. It initializes and calls the public instance method.
7
- def self.call(user_id:, target_service: nil, scopes: [])
8
- new(user_id, target_service, scopes).issue
12
+ # Claims that are always set by the issuer and can never be overridden by
13
+ # caller-supplied custom claims.
14
+ REGISTERED_CLAIMS = %i[iss sub iat nbf exp jti].freeze
15
+
16
+ # @param user_id [String] the subject (`sub`) of the token. Required.
17
+ # @param target_service [Symbol, String, nil] the audience (`aud`).
18
+ # @param scopes [Array<String>, nil] permissions granted by the token.
19
+ # @param claims [Hash] extra custom claims (e.g. email); registered claims
20
+ # in this hash are ignored.
21
+ # @param expiry_seconds [Integer, nil] overrides `configuration.default_expiry_seconds`.
22
+ # @return [String] the signed JWT.
23
+ def self.call(user_id:, target_service: nil, scopes: [], claims: {}, expiry_seconds: nil)
24
+ new(user_id, target_service, scopes, claims: claims, expiry_seconds: expiry_seconds).issue
9
25
  end
10
26
 
11
- # initialize is public by default.
12
- def initialize(user_id, target_service, scopes)
27
+ def initialize(user_id, target_service, scopes, claims: {}, expiry_seconds: nil)
28
+ raise ArgumentError, "user_id is required" if user_id.nil? || user_id.to_s.empty?
29
+ raise ArgumentError, "claims must be a Hash" unless claims.is_a?(Hash)
30
+ unless expiry_seconds.nil? || (expiry_seconds.is_a?(Integer) && expiry_seconds.positive?)
31
+ raise ArgumentError, "expiry_seconds must be a positive Integer"
32
+ end
33
+
13
34
  @user_id = user_id
14
35
  @target_service = target_service
15
- @scopes = scopes
36
+ @scopes = Array(scopes)
37
+ @claims = claims
38
+ @expiry_seconds = expiry_seconds
16
39
  @config = JwtAuthClient.configuration
17
40
  end
18
41
 
19
- # --- Public Instance Method (Called by self.call) ---
42
+ # @return [String] the signed JWT.
43
+ # @raise [ConfigurationError] if the configuration is invalid.
44
+ # @raise [TokenError] if the jwt gem fails to sign the payload.
20
45
  def issue
21
- encode
46
+ @config.validate!
47
+ JWT.encode(build_payload, @config.shared_secret, @config.algorithm)
48
+ rescue JWT::EncodeError, JWT::DecodeError => e
49
+ # The jwt gem reports bad HMAC keys as DecodeError even on encode.
50
+ raise TokenError, "Failed to sign token: #{e.message}"
22
51
  end
23
52
 
24
- # --- Private Helper Methods ---
25
53
  private
26
-
27
- def encode
28
- payload = build_payload
29
-
30
- # Use the JWT gem to encode the token
31
- JWT.encode(payload, @config.shared_secret, @config.algorithm)
32
- end
33
54
 
34
- # Uses conditional merging to ensure keys for optional claims (aud, scopes)
35
- # are only included if they have a non-nil/non-empty value.
36
55
  def build_payload
37
- # Standard JWT Claims (Must include these for security/verification)
38
56
  issued_at = Time.now.to_i
39
- expiry = issued_at + @config.default_expiry_seconds
40
-
41
- # 1. Start with Required Claims
42
- payload = {
43
- iss: @config.issuer, # Issuer (e.g., main_sso_app)
44
- sub: @user_id, # Subject (The user being impersonated)
45
- iat: issued_at, # Issued At Time
46
- exp: expiry, # Expiration Time (CRITICAL: short-lived)
47
- jti: SecureRandom.uuid, # JWT ID (For optional replay attack prevention)
48
- }
49
-
50
- # 2. Conditionally merge Optional Claims
51
- # Audience is nil by default
52
- payload[:aud] = @target_service if @target_service
53
-
54
- # Scopes are [] by default, so check if the array is not empty
57
+
58
+ payload = @claims.transform_keys(&:to_sym).reject { |key, _| REGISTERED_CLAIMS.include?(key) }
59
+ payload[:aud] = @target_service.to_s unless @target_service.nil? || @target_service.to_s.empty?
55
60
  payload[:scopes] = @scopes unless @scopes.empty?
56
61
 
57
- payload
62
+ payload.merge!(
63
+ iss: @config.issuer,
64
+ sub: @user_id,
65
+ iat: issued_at,
66
+ nbf: issued_at,
67
+ exp: issued_at + (@expiry_seconds || @config.default_expiry_seconds),
68
+ jti: SecureRandom.uuid
69
+ )
58
70
  end
59
71
  end
60
72
  end
@@ -1,3 +1,3 @@
1
1
  module JwtAuthClient
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -1,16 +1,11 @@
1
- require 'jwt' # Core dependency
2
- require 'active_support/core_ext/numeric/time' # For N.minutes.from_now logic
3
- require 'securerandom' # Added for SecureRandom.uuid dependency
4
- require 'jwt_auth_client/version'
5
- require 'jwt_auth_client/configuration'
6
- require 'jwt_auth_client/token_issuer'
7
- require 'jwt_auth_client/http_client'
8
- require 'jwt_auth_client/billing_client'
1
+ # frozen_string_literal: true
9
2
 
10
- module JwtAuthClient
11
- # Main module definition
12
- end
3
+ require "jwt_auth_client/version"
4
+ require "jwt_auth_client/errors"
5
+ require "jwt_auth_client/configuration"
6
+ require "jwt_auth_client/token_issuer"
7
+ require "jwt_auth_client/issuable"
8
+ require "jwt_auth_client/http_client"
13
9
 
14
10
  module JwtAuthClient
15
- # Main module definition
16
- end
11
+ end
metadata CHANGED
@@ -1,136 +1,140 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jwt_auth_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniele Frisanco
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-10-25 00:00:00.000000000 Z
11
+ date: 2026-09-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: jwt
14
+ name: faraday
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '2.8'
19
+ version: '2.9'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '2.8'
26
+ version: '2.9'
27
27
  - !ruby/object:Gem::Dependency
28
- name: faraday
28
+ name: jwt
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '2.9'
33
+ version: '2.8'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '2.9'
40
+ version: '2.8'
41
41
  - !ruby/object:Gem::Dependency
42
- name: activesupport
42
+ name: faraday-retry
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - ">="
45
+ - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '6.0'
48
- type: :runtime
47
+ version: '2.0'
48
+ type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - ">="
52
+ - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '6.0'
54
+ version: '2.0'
55
55
  - !ruby/object:Gem::Dependency
56
- name: bundler
56
+ name: rake
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '2.0'
61
+ version: '13.0'
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '2.0'
68
+ version: '13.0'
69
69
  - !ruby/object:Gem::Dependency
70
- name: rake
70
+ name: rspec
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - "~>"
74
74
  - !ruby/object:Gem::Version
75
- version: '13.0'
75
+ version: '3.12'
76
76
  type: :development
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
- version: '13.0'
82
+ version: '3.12'
83
83
  - !ruby/object:Gem::Dependency
84
- name: rspec
84
+ name: timecop
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: '3.0'
89
+ version: '0.9'
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: '3.0'
96
+ version: '0.9'
97
97
  - !ruby/object:Gem::Dependency
98
- name: pry
98
+ name: webmock
99
99
  requirement: !ruby/object:Gem::Requirement
100
100
  requirements:
101
- - - ">="
101
+ - - "~>"
102
102
  - !ruby/object:Gem::Version
103
- version: '0'
103
+ version: '3.19'
104
104
  type: :development
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
- - - ">="
108
+ - - "~>"
109
109
  - !ruby/object:Gem::Version
110
- version: '0'
111
- description: Generates short-lived, signed JWTs for internal API calls authenticated
112
- via a shared secret.
110
+ version: '3.19'
111
+ description: Generates short-lived, signed JWTs for internal API calls and injects
112
+ them into Faraday requests as Bearer tokens.
113
113
  email:
114
114
  - daniele.frisanco@gmail.com
115
115
  executables: []
116
116
  extensions: []
117
117
  extra_rdoc_files: []
118
118
  files:
119
- - Gemfile
120
- - Gemfile.lock
119
+ - CHANGELOG.md
120
+ - LICENSE
121
121
  - README.md
122
- - Rakefile
123
122
  - lib/jwt_auth_client.rb
124
- - lib/jwt_auth_client/billing_client.rb
125
123
  - lib/jwt_auth_client/configuration.rb
124
+ - lib/jwt_auth_client/errors.rb
126
125
  - lib/jwt_auth_client/http_client.rb
126
+ - lib/jwt_auth_client/issuable.rb
127
+ - lib/jwt_auth_client/json_decoder.rb
127
128
  - lib/jwt_auth_client/token_issuer.rb
128
129
  - lib/jwt_auth_client/version.rb
129
- - pkg/jwt_auth_client-0.1.0.gem
130
130
  homepage: https://github.com/danielefrisanco/jwt_auth_client
131
131
  licenses:
132
132
  - MIT
133
- metadata: {}
133
+ metadata:
134
+ homepage_uri: https://github.com/danielefrisanco/jwt_auth_client
135
+ source_code_uri: https://github.com/danielefrisanco/jwt_auth_client
136
+ changelog_uri: https://github.com/danielefrisanco/jwt_auth_client/blob/main/CHANGELOG.md
137
+ rubygems_mfa_required: 'true'
134
138
  post_install_message:
135
139
  rdoc_options: []
136
140
  require_paths:
@@ -139,14 +143,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
139
143
  requirements:
140
144
  - - ">="
141
145
  - !ruby/object:Gem::Version
142
- version: '0'
146
+ version: '3.1'
143
147
  required_rubygems_version: !ruby/object:Gem::Requirement
144
148
  requirements:
145
149
  - - ">="
146
150
  - !ruby/object:Gem::Version
147
151
  version: '0'
148
152
  requirements: []
149
- rubygems_version: 3.4.10
153
+ rubygems_version: 3.3.26
150
154
  signing_key:
151
155
  specification_version: 4
152
156
  summary: Secure client for generating and sending internal service-to-service JWTs.
data/Gemfile DELETED
@@ -1,27 +0,0 @@
1
- source "https://rubygems.org"
2
- gemspec
3
- group :development, :test do
4
- gem 'pry', '~> 0.14'
5
-
6
- # Core HTTP Client
7
- gem 'faraday', '~> 2.7'
8
-
9
- # For conn.request :retry (now typically part of the faraday-excon or faraday-net_http gems)
10
- # gem 'faraday-retry' # You might need this explicitly if on an older version of Faraday 2+
11
-
12
- gem 'webmock', '~> 3.19' # For HTTP stubbing/testing
13
- gem 'timecop', '~> 0.9' # For time-based testing
14
- # Testing tools
15
- gem 'rspec', '~> 3.0'
16
-
17
- # Faraday and required middleware
18
- # The core faraday gem is already included via gemspec
19
- # We must explicitly add the middleware that replaced faraday-middleware and faraday-json
20
- gem 'faraday-typhoeus' # A robust adapter
21
- gem 'faraday-retry', '~> 2.0' # Explicit gem for the :retry middleware
22
-
23
- # Note: JSON encoding/decoding middleware is often implicitly handled
24
- # by faraday v2, or included in faraday-typhoeus or a different gem
25
- # depending on the setup. Explicitly adding :json requests/responses
26
- # in the HttpClient often just needs the `json` gem itself.
27
- end