toc_doc 1.0.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.
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'toc_doc/core/default'
4
+
5
+ module TocDoc
6
+ # Mixin providing shared configuration behaviour for both the top-level
7
+ # {TocDoc} module and individual {TocDoc::Client} instances.
8
+ #
9
+ # Include this module to gain attribute accessors for every configurable key,
10
+ # a block-based {#configure} helper, and a {#reset!} method to restore
11
+ # defaults from {TocDoc::Default}.
12
+ #
13
+ # @example Module-level configuration
14
+ # TocDoc.configure do |config|
15
+ # config.api_endpoint = 'https://www.doctolib.de'
16
+ # config.per_page = 10
17
+ # end
18
+ #
19
+ # @example Per-client configuration
20
+ # client = TocDoc::Client.new(api_endpoint: 'https://www.doctolib.it')
21
+ #
22
+ # @see TocDoc::Default
23
+ module Configurable
24
+ # @return [Array<Symbol>] all recognised configuration keys
25
+ VALID_CONFIG_KEYS = %i[
26
+ api_endpoint
27
+ user_agent
28
+ middleware
29
+ connection_options
30
+ default_media_type
31
+ per_page
32
+ auto_paginate
33
+ ].freeze
34
+
35
+ # @!attribute [rw] api_endpoint
36
+ # @return [String] the base URL for API requests
37
+ # @!attribute [rw] user_agent
38
+ # @return [String] the User-Agent header value
39
+ # @!attribute [rw] middleware
40
+ # @return [Faraday::RackBuilder, nil] custom Faraday middleware stack
41
+ # @!attribute [rw] connection_options
42
+ # @return [Hash] additional Faraday connection options
43
+ # @!attribute [rw] default_media_type
44
+ # @return [String] the Accept / Content-Type header value
45
+ # @!attribute [rw] auto_paginate
46
+ # @return [Boolean] whether to follow pagination automatically
47
+ attr_accessor(*VALID_CONFIG_KEYS)
48
+
49
+ # Set the number of results per page, clamped to
50
+ # {TocDoc::Default::MAX_PER_PAGE}.
51
+ #
52
+ # @param value [Integer, #to_i] desired page size
53
+ # @return [Integer] the effective page size after clamping
54
+ def per_page=(value)
55
+ @per_page = [value.to_i, TocDoc::Default::MAX_PER_PAGE].min
56
+ end
57
+
58
+ # Returns the list of recognised configurable attribute names.
59
+ #
60
+ # @return [Array<Symbol>]
61
+ def self.keys
62
+ VALID_CONFIG_KEYS
63
+ end
64
+
65
+ # Yields +self+ so callers can set options in a block.
66
+ #
67
+ # @yield [config] the object being configured
68
+ # @yieldparam config [TocDoc::Configurable] self
69
+ # @return [self]
70
+ #
71
+ # @example
72
+ # TocDoc.configure do |config|
73
+ # config.api_endpoint = 'https://www.doctolib.de'
74
+ # end
75
+ def configure
76
+ yield self
77
+ self
78
+ end
79
+
80
+ # Reset all configuration options to their {TocDoc::Default} values.
81
+ #
82
+ # @return [self]
83
+ def reset!
84
+ TocDoc::Default.options.each do |key, value|
85
+ public_send("#{key}=", value)
86
+ end
87
+ self
88
+ end
89
+
90
+ # Returns a frozen snapshot of the current configuration as a Hash.
91
+ #
92
+ # @return [Hash{Symbol => Object}]
93
+ def options
94
+ Configurable.keys.to_h { |key| [key, public_send(key)] }
95
+ end
96
+
97
+ # Compares the given options to the current configuration.
98
+ #
99
+ # Used internally for memoising the {TocDoc.client} instance — a new
100
+ # client is created only when the options have actually changed.
101
+ #
102
+ # @param other_options [Hash{Symbol => Object}] options to compare
103
+ # @return [Boolean] +true+ when both option sets are equal
104
+ def same_options?(other_options)
105
+ candidate =
106
+ if other_options.respond_to?(:to_hash)
107
+ other_options.to_hash.transform_keys!(&:to_sym)
108
+ else
109
+ other_options
110
+ end
111
+
112
+ candidate == options
113
+ end
114
+
115
+ # @!visibility private
116
+ # When extended (e.g., by the TocDoc module), initialize with defaults.
117
+ def self.extended(base)
118
+ base.reset!
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module TocDoc
6
+ # Faraday-based HTTP connection and request helpers.
7
+ #
8
+ # Included into {TocDoc::Client} to provide low-level HTTP verbs
9
+ # (`get`, `post`, `put`, `patch`, `delete`, `head`), a memoised
10
+ # Faraday connection, pagination support, and response tracking.
11
+ #
12
+ # All HTTP methods are **private** — callers interact with them through
13
+ # higher-level endpoint modules (e.g. {TocDoc::Client::Availabilities}).
14
+ #
15
+ # @see TocDoc::Client
16
+ module Connection
17
+ # The most-recent raw Faraday response, set after every request.
18
+ #
19
+ # @return [Faraday::Response, nil]
20
+ attr_accessor :last_response
21
+
22
+ private
23
+
24
+ # Perform a GET request.
25
+ #
26
+ # @param path [String] API path (relative to {Configurable#api_endpoint})
27
+ # @param options [Hash] query / header options forwarded to {#request}
28
+ # @return [Object] parsed response body
29
+ def get(path, options = {})
30
+ request(:get, path, nil, options)
31
+ end
32
+
33
+ # Perform a POST request.
34
+ #
35
+ # @param path [String] API path
36
+ # @param data [Object, nil] request body
37
+ # @param options [Hash] query / header options
38
+ # @return [Object] parsed response body
39
+ def post(path, data = nil, options = {})
40
+ request(:post, path, data, options)
41
+ end
42
+
43
+ # Perform a PUT request.
44
+ #
45
+ # @param path [String] API path
46
+ # @param data [Object, nil] request body
47
+ # @param options [Hash] query / header options
48
+ # @return [Object] parsed response body
49
+ def put(path, data = nil, options = {})
50
+ request(:put, path, data, options)
51
+ end
52
+
53
+ # Perform a PATCH request.
54
+ #
55
+ # @param path [String] API path
56
+ # @param data [Object, nil] request body
57
+ # @param options [Hash] query / header options
58
+ # @return [Object] parsed response body
59
+ def patch(path, data = nil, options = {})
60
+ request(:patch, path, data, options)
61
+ end
62
+
63
+ # Perform a DELETE request.
64
+ #
65
+ # @param path [String] API path
66
+ # @param options [Hash] query / header options
67
+ # @return [Object] parsed response body
68
+ def delete(path, options = {})
69
+ request(:delete, path, nil, options)
70
+ end
71
+
72
+ # Perform a HEAD request.
73
+ #
74
+ # @param path [String] API path
75
+ # @param options [Hash] query / header options
76
+ # @return [Object] parsed response body
77
+ def head(path, options = {})
78
+ request(:head, path, nil, options)
79
+ end
80
+
81
+ # Memoised Faraday connection configured from the current
82
+ # {Configurable} options.
83
+ #
84
+ # @return [Faraday::Connection]
85
+ def agent
86
+ @agent ||= Faraday.new(api_endpoint, faraday_options) do |conn|
87
+ configure_faraday_headers(conn)
88
+ end
89
+ end
90
+
91
+ # @return [Hash] merged Faraday connection options
92
+ def faraday_options
93
+ opts = connection_options.dup
94
+ opts[:builder] = middleware if middleware
95
+ opts
96
+ end
97
+
98
+ # Sets default HTTP headers on a Faraday connection.
99
+ #
100
+ # @param conn [Faraday::Connection]
101
+ # @return [void]
102
+ def configure_faraday_headers(conn)
103
+ conn.headers['Accept'] = default_media_type
104
+ conn.headers['Content-Type'] = default_media_type
105
+ conn.headers['User-Agent'] = user_agent
106
+ end
107
+
108
+ # Performs a paginated GET, accumulating results across pages.
109
+ #
110
+ # When {Configurable#auto_paginate} is disabled or no block is given,
111
+ # behaves exactly like {#get}.
112
+ #
113
+ # When +auto_paginate+ is +true+ **and** a block is provided, the block is
114
+ # yielded after every page fetch — including the first — with
115
+ # +(accumulator, last_response)+. The block must:
116
+ #
117
+ # 1. Detect whether it is a continuation call by comparing object identity:
118
+ # `acc.equal?(last_response.body)` is `true` only on the first yield,
119
+ # when the accumulator *is* the first-page body. On subsequent yields
120
+ # the block should merge `last_response.body` into `acc`.
121
+ # 2. Return a Hash of options to pass to the next {#get} call (pagination
122
+ # continues), or `nil` / `false` to halt.
123
+ #
124
+ # @param path [String] the API path
125
+ # @param options [Hash] query / header options forwarded to every request
126
+ # @yieldparam acc [Object] the growing accumulator (first-page body initially)
127
+ # @yieldparam last_response [Faraday::Response] the most-recent raw response
128
+ # @yieldreturn [Hash, nil] next-page options, or +nil+/+false+ to halt
129
+ # @return [Object] the fully-accumulated response body
130
+ def paginate(path, options = {}, &)
131
+ data = get(path, options)
132
+ return data unless block_given? && auto_paginate
133
+
134
+ loop do
135
+ next_options = yield(data, last_response)
136
+ break unless next_options
137
+
138
+ get(path, next_options)
139
+ end
140
+
141
+ data
142
+ end
143
+
144
+ # Returns a boolean based on the HTTP response status of the given request.
145
+ #
146
+ # @param method [Symbol] HTTP verb (e.g. +:get+, +:head+)
147
+ # @param path [String] API path
148
+ # @param options [Hash] query / header options
149
+ # @return [Boolean] +true+ when the response is 2xx
150
+ def boolean_from_response?(method, path, options = {})
151
+ request(method, path, nil, options)
152
+ status = last_response&.status
153
+
154
+ (200..299).cover?(status)
155
+ end
156
+
157
+ # Core request helper used by all HTTP verb methods.
158
+ #
159
+ # @param method [Symbol] HTTP verb
160
+ # @param path [String] API path
161
+ # @param data [Object, nil] optional request body
162
+ # @param options [Hash] query / header options
163
+ # @return [Object] parsed response body
164
+ def request(method, path, data = nil, options = {})
165
+ query, headers = parse_query_and_convenience_headers(options)
166
+
167
+ response = agent.public_send(method) do |req|
168
+ req.url(path, query)
169
+ req.headers.update(headers) unless headers.empty?
170
+ req.body = data if data
171
+ end
172
+
173
+ self.last_response = response
174
+ response.body
175
+ end
176
+
177
+ # Splits a generic options hash into query params and headers.
178
+ #
179
+ # Supports explicit `:query` and `:headers` keys; otherwise treats
180
+ # remaining keys as query params.
181
+ #
182
+ # @param options [Hash] combined query/header options
183
+ # @return [Array(Hash, Hash)] a two-element array of `[query, headers]`
184
+ def parse_query_and_convenience_headers(options)
185
+ return [{}, {}] if options.nil? || options.empty?
186
+
187
+ opts = options.dup
188
+ explicit_query = opts.delete(:query)
189
+ explicit_headers = opts.delete(:headers) || {}
190
+
191
+ query = explicit_query || opts
192
+
193
+ [query || {}, explicit_headers]
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/retry'
5
+
6
+ require 'toc_doc/http/middleware/raise_error'
7
+
8
+ module TocDoc
9
+ # Provides sensible default values for every configurable option.
10
+ #
11
+ # Each value can be overridden per-environment via the corresponding `TOCDOC_*`
12
+ # environment variable (see individual methods).
13
+ #
14
+ # @see TocDoc::Configurable
15
+ module Default
16
+ # @return [String] the default API base URL
17
+ API_ENDPOINT = 'https://www.doctolib.fr'
18
+
19
+ # @return [String] the default User-Agent header
20
+ USER_AGENT = "TocDoc Ruby Gem #{TocDoc::VERSION}".freeze
21
+
22
+ # @return [String] the default Accept / Content-Type media type
23
+ MEDIA_TYPE = 'application/json'
24
+
25
+ # @return [Integer] the default number of results per page
26
+ PER_PAGE = 15
27
+
28
+ # @return [Integer] the hard upper limit for per_page
29
+ MAX_PER_PAGE = 15
30
+
31
+ # @return [Boolean] whether to auto-paginate by default
32
+ AUTO_PAGINATE = false
33
+
34
+ # @return [Integer] the default maximum number of retries
35
+ MAX_RETRY = 3
36
+
37
+ class << self
38
+ # Returns a hash of all default configuration values, suitable for
39
+ # passing to {TocDoc::Configurable#reset!}.
40
+ #
41
+ # @return [Hash{Symbol => Object}]
42
+ def options
43
+ {
44
+ api_endpoint: api_endpoint,
45
+ user_agent: user_agent,
46
+ default_media_type: default_media_type,
47
+ per_page: per_page,
48
+ auto_paginate: auto_paginate,
49
+ middleware: middleware,
50
+ connection_options: connection_options
51
+ }
52
+ end
53
+
54
+ # The base API endpoint URL.
55
+ #
56
+ # Falls back to the `TOCDOC_API_ENDPOINT` environment variable, then
57
+ # {API_ENDPOINT}.
58
+ #
59
+ # @return [String]
60
+ def api_endpoint
61
+ ENV.fetch('TOCDOC_API_ENDPOINT', API_ENDPOINT)
62
+ end
63
+
64
+ # The User-Agent header sent with every request.
65
+ #
66
+ # Falls back to the `TOCDOC_USER_AGENT` environment variable, then
67
+ # {USER_AGENT}.
68
+ #
69
+ # @return [String]
70
+ def user_agent
71
+ ENV.fetch('TOCDOC_USER_AGENT', USER_AGENT)
72
+ end
73
+
74
+ # The Accept / Content-Type media type.
75
+ #
76
+ # Falls back to the `TOCDOC_MEDIA_TYPE` environment variable, then
77
+ # {MEDIA_TYPE}.
78
+ #
79
+ # @return [String]
80
+ def default_media_type
81
+ ENV.fetch('TOCDOC_MEDIA_TYPE', MEDIA_TYPE)
82
+ end
83
+
84
+ # Number of results per page, clamped to {MAX_PER_PAGE}.
85
+ #
86
+ # Falls back to the `TOCDOC_PER_PAGE` environment variable, then
87
+ # {PER_PAGE}.
88
+ #
89
+ # @return [Integer]
90
+ def per_page
91
+ [Integer(ENV.fetch('TOCDOC_PER_PAGE', PER_PAGE), 10), MAX_PER_PAGE].min
92
+ rescue ArgumentError
93
+ PER_PAGE
94
+ end
95
+
96
+ # Whether to follow pagination automatically.
97
+ #
98
+ # Falls back to the `TOCDOC_AUTO_PAGINATE` environment variable (set to
99
+ # `"true"` to enable), then {AUTO_PAGINATE}.
100
+ #
101
+ # @return [Boolean]
102
+ def auto_paginate
103
+ env_val = ENV.fetch('TOCDOC_AUTO_PAGINATE', nil)
104
+ return AUTO_PAGINATE if env_val.nil?
105
+
106
+ env_val.casecmp('true').zero?
107
+ end
108
+
109
+ # The default Faraday middleware stack.
110
+ #
111
+ # Includes retry logic, error raising, JSON parsing, and the default
112
+ # adapter.
113
+ #
114
+ # @return [Faraday::RackBuilder]
115
+ def middleware
116
+ @middleware ||= build_middleware
117
+ end
118
+
119
+ # Default Faraday connection options (empty by default).
120
+ #
121
+ # @return [Hash]
122
+ def connection_options
123
+ @connection_options ||= {}
124
+ end
125
+
126
+ private
127
+
128
+ def build_middleware
129
+ Faraday::RackBuilder.new do |builder|
130
+ builder.request :retry, retry_options
131
+ builder.use TocDoc::Middleware::RaiseError
132
+ builder.response :raise_error
133
+ builder.response :json, content_type: /\bjson$/
134
+ builder.adapter Faraday.default_adapter
135
+ end
136
+ end
137
+
138
+ def retry_options
139
+ {
140
+ max: Integer(ENV.fetch('TOCDOC_RETRY_MAX', MAX_RETRY), 10),
141
+ interval: 0.5,
142
+ interval_randomness: 0.5,
143
+ backoff_factor: 2,
144
+ retry_statuses: [429, 500, 502, 503, 504],
145
+ methods: %i[get head options]
146
+ }
147
+ rescue ArgumentError
148
+ retry_options_fallback
149
+ end
150
+
151
+ def retry_options_fallback
152
+ {
153
+ max: MAX_RETRY,
154
+ interval: 0.5,
155
+ interval_randomness: 0.5,
156
+ backoff_factor: 2,
157
+ retry_statuses: [429, 500, 502, 503, 504],
158
+ methods: %i[get head options]
159
+ }
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TocDoc
4
+ # Base error class for all TocDoc errors.
5
+ #
6
+ # Inherits from +StandardError+ so consumers can rescue `TocDoc::Error`
7
+ # without any knowledge of Faraday or other internal HTTP details.
8
+ #
9
+ # @example Rescuing TocDoc errors
10
+ # begin
11
+ # TocDoc.availabilities(visit_motive_ids: 0, agenda_ids: 0)
12
+ # rescue TocDoc::Error => e
13
+ # puts e.message
14
+ # end
15
+ class Error < StandardError; end
16
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TocDoc
4
+ # URL building helpers for Doctolib API parameters.
5
+ #
6
+ # Doctolib expects certain ID list parameters to be dash-joined strings
7
+ # rather than standard repeated/bracket array notation. Include this module
8
+ # into endpoint modules and call +dashed_ids+ explicitly for each such param:
9
+ #
10
+ # module TocDoc::Client::Availabilities
11
+ # include TocDoc::UriUtils
12
+ #
13
+ # def availabilities(visit_motive_ids:, agenda_ids:, **opts)
14
+ # get('/availabilities.json', query: {
15
+ # visit_motive_ids: dashed_ids(visit_motive_ids),
16
+ # agenda_ids: dashed_ids(agenda_ids),
17
+ # **opts
18
+ # })
19
+ # end
20
+ # end
21
+ module UriUtils
22
+ # Joins one or many IDs into the dash-separated format expected by Doctolib.
23
+ #
24
+ # @param ids [Integer, String, Array] one or more IDs
25
+ # @return [String] e.g. "1234-5678-9012"
26
+ def dashed_ids(ids)
27
+ Array(ids).flatten.compact.map(&:to_s).reject(&:empty?).join('-')
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TocDoc
4
+ # The current version of the TocDoc gem.
5
+ #
6
+ # @return [String]
7
+ VERSION = '1.0.0'
8
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module TocDoc
6
+ # @api private
7
+ module Middleware
8
+ # Faraday middleware that translates Faraday HTTP errors into
9
+ # {TocDoc::Error}, keeping Faraday as an internal implementation detail.
10
+ #
11
+ # Registered in the default middleware stack built by {TocDoc::Default}.
12
+ #
13
+ # @see TocDoc::Error
14
+ class RaiseError < Faraday::Middleware
15
+ # Executes the request and re-raises any +Faraday::Error+ as a
16
+ # {TocDoc::Error}.
17
+ #
18
+ # @param env [Faraday::Env] the Faraday request environment
19
+ # @return [Faraday::Env] the response environment on success
20
+ # @raise [TocDoc::Error] when Faraday raises an HTTP error
21
+ def call(env)
22
+ @app.call(env)
23
+ rescue Faraday::Error => e
24
+ raise TocDoc::Error, e.message
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module TocDoc
6
+ # @api private
7
+ module Response
8
+ # Abstract base class for TocDoc Faraday response middleware.
9
+ #
10
+ # Subclasses **must** override {#on_complete} to inspect or transform
11
+ # the response environment.
12
+ #
13
+ # @abstract
14
+ class BaseMiddleware < Faraday::Middleware
15
+ # Called by Faraday after the response has been received.
16
+ #
17
+ # @param env [Faraday::Env] the Faraday response environment
18
+ # @return [void]
19
+ # @raise [NotImplementedError] always — subclasses must override
20
+ def on_complete(env)
21
+ raise NotImplementedError, "#{self.class} must implement #on_complete"
22
+ end
23
+ end
24
+ end
25
+ end
File without changes
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TocDoc
4
+ # Represents a single availability date entry returned by the Doctolib API.
5
+ #
6
+ # @example
7
+ # avail = TocDoc::Availability.new('date' => '2026-02-28', 'slots' => ['2026-02-28T10:00:00.000+01:00'])
8
+ # avail.date #=> "2026-02-28"
9
+ # avail.slots #=> ["2026-02-28T10:00:00.000+01:00"]
10
+ class Availability < Resource
11
+ # @return [String] date in ISO 8601 format (e.g. "2026-02-28")
12
+ def date
13
+ @attrs['date']
14
+ end
15
+
16
+ # @return [Array<String>] ISO 8601 datetime strings for each available slot
17
+ def slots
18
+ @attrs['slots'] || []
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TocDoc
4
+ # A lightweight wrapper providing dot-notation access to response fields.
5
+ # Backed by a Hash, with +method_missing+ for attribute access and +#to_h+ for
6
+ # round-tripping back to a plain Hash.
7
+ #
8
+ # Unlike Sawyer, TocDoc does not use hypermedia relations, so this wrapper
9
+ # intentionally stays minimal.
10
+ #
11
+ # @example
12
+ # resource = TocDoc::Resource.new('date' => '2026-02-28', 'slots' => [])
13
+ # resource.date #=> "2026-02-28"
14
+ # resource[:date] #=> "2026-02-28"
15
+ # resource.to_h #=> { "date" => "2026-02-28", "slots" => [] }
16
+ class Resource
17
+ # @param attrs [Hash] the raw attribute hash (string or symbol keys)
18
+ def initialize(attrs = {})
19
+ @attrs = attrs.transform_keys(&:to_s)
20
+ end
21
+
22
+ # Read an attribute by name.
23
+ #
24
+ # @param key [String, Symbol] attribute name
25
+ # @return [Object, nil] the attribute value, or +nil+ if not present
26
+ #
27
+ # @example
28
+ # resource[:date] #=> "2026-02-28"
29
+ def [](key)
30
+ @attrs[key.to_s]
31
+ end
32
+
33
+ # Write an attribute by name.
34
+ #
35
+ # @param key [String, Symbol] attribute name
36
+ # @param value [Object] the value to set
37
+ # @return [Object] the value
38
+ def []=(key, value)
39
+ @attrs[key.to_s] = value
40
+ end
41
+
42
+ # Return a plain Hash representation (shallow copy).
43
+ #
44
+ # @return [Hash{String => Object}]
45
+ def to_h
46
+ @attrs.dup
47
+ end
48
+
49
+ # Equality comparison.
50
+ #
51
+ # Two {Resource} instances are equal when their attribute hashes match.
52
+ # A {Resource} is also equal to a plain +Hash+ with equivalent keys.
53
+ #
54
+ # @param other [Resource, Hash, Object]
55
+ # @return [Boolean]
56
+ def ==(other)
57
+ case other
58
+ when Resource then @attrs == other.to_h
59
+ when Hash then @attrs == other.transform_keys(&:to_s)
60
+ else false
61
+ end
62
+ end
63
+
64
+ # @!visibility private
65
+ def respond_to_missing?(method_name, include_private = false)
66
+ @attrs.key?(method_name.to_s) || super
67
+ end
68
+
69
+ # Provides dot-notation access to response fields.
70
+ #
71
+ # Any method call whose name matches a key in the underlying attribute
72
+ # hash is dispatched here.
73
+ #
74
+ # @param method_name [Symbol] the method name
75
+ # @return [Object] the attribute value
76
+ # @raise [NoMethodError] when the key does not exist
77
+ def method_missing(method_name, *_args)
78
+ key = method_name.to_s
79
+ @attrs.key?(key) ? @attrs[key] : super
80
+ end
81
+ end
82
+ end