toc_doc 1.6.0 → 1.7.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: 50818c97b021143ebf2d502a867fb5f20a90299a822ec800b7d02ae4d20999eb
4
- data.tar.gz: d4ef919a143957c2685fba1f28675786743922387b0703c521a6d5c0b9ab76c3
3
+ metadata.gz: a839823e2a82c7dd112617d6b969f275d7d88826e58f53b9ec9a5265701f277e
4
+ data.tar.gz: 6a3c3d24b598f0d2f85b2c0bd5f8faad939eeba847578692b072f0070fb5d3bb
5
5
  SHA512:
6
- metadata.gz: 9aa3dd68984a7cec38789a3a718db4f7c9db9a0335ae8e47744f24990dccb73de12e41903aee8806e06f579288769c13a5b9f82327a031dd3c2052d096ae33e5
7
- data.tar.gz: 10725265d2117c4d93720c89f5c06360ea567377c6d21f9a848e26151de7039876ac9a7b1da936801f0eaf9a7b8141689e8389b5a2917f3d550a01847aa52767
6
+ metadata.gz: 2580cf022a39c4f8e8d73c3b795927bf249ed3d4c9e6b36d051d326680d071ec4569e6f8da07143270584c7f65519f9df9c9efe824c7b623959d7be73f10f27f
7
+ data.tar.gz: 19ab224e5729cb04789e7739d1c0a6d9de16b02b2e32d850c1bc6fe3024c58b2c01d4ac9c1b7ffbfed45fb2d7e3498c537d169f9c77733b54d01da009b692047
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.7.0] - 2026-04-04
4
+
5
+ ### Added
6
+
7
+ - **`TocDoc::Middleware::Logging`** — new Faraday middleware that logs every outgoing request URL and the response status; plugged in automatically between the retry and raise-error layers; enabled via the new `logger` config key (accepts any Logger-compatible object; `nil` disables logging)
8
+ - **`logger` config key** — module-level and per-client option; also available as a `TocDoc::Default::LOGGER` constant (defaults to `nil`); each client instance carries its own logger, so multiple clients can log to different destinations simultaneously
9
+ - **`TocDoc::Resource#attribute_names`** — instance method returning the sorted list of attribute keys present on a resource instance
10
+ - **`TocDoc::Resource` singleton method definition on first access** — reader methods are now defined as true singleton methods on the instance the first time an attribute is accessed, eliminating repeated `respond_to?` / `method_missing` dispatch on subsequent calls
11
+
12
+ ### Changed
13
+
14
+ - **`TocDoc::Resource#to_h`** — now performs a deep conversion: nested `Resource` objects and arrays of `Resource` objects are recursively converted; previously returned a shallow hash with raw attribute values
15
+ - **`TocDoc::Resource#to_json`** — delegates to the new deep `to_h`, so JSON output is fully recursive
16
+ - **`TocDoc::BookingInfo#to_h`** and **`#to_json`** — override `Resource`'s implementation to include all typed sub-objects (`profile`, `specialities`, `visit_motives`, `agendas`, `places`, `practitioners`) as deeply converted hashes
17
+ - **`TocDoc::BookingInfo#agendas`** — visit-motive resolution changed from O(n*m) nested iteration to O(n+m) hash lookup; no change to the public interface
18
+ - **`TocDoc::Availability::Collection#filtered_entries`** — result is now memoized; cache is invalidated automatically when `#merge_page!` appends a new page, preventing redundant filtering on repeated `#each` / `#to_a` calls
19
+ - **`#inspect`** for `TocDoc::Availability`, `TocDoc::Place`, `TocDoc::Speciality`, and `TocDoc::Profile::Organization`** — `main_attrs` declared on each class so inspect output stays concise; `TocDoc::Profile` and `TocDoc::Resource` inspect also improved
20
+
3
21
  ## [1.6.0] - 2026-03-30
4
22
 
5
23
  ### Added
data/README.md CHANGED
@@ -140,11 +140,12 @@ client.get('/availabilities.json', query: { visit_motive_ids: '123', agenda_ids:
140
140
  | Option | Default | Description |
141
141
  |---|---|---|
142
142
  | `api_endpoint` | `https://www.doctolib.fr` | Base URL. Change to `.de` / `.it` for other countries. |
143
- | `user_agent` | `TocDoc Ruby Gem 1.6.0` | `User-Agent` header sent with every request. |
143
+ | `user_agent` | `TocDoc Ruby Gem 1.7.0` | `User-Agent` header sent with every request. |
144
144
  | `default_media_type` | `application/json` | `Accept` and `Content-Type` headers. |
145
145
  | `per_page` | `15` | Default number of availability dates per request (capped at `15`). Emits a warning if the value exceeds the cap. |
146
146
  | `connect_timeout` | `5` | TCP connect timeout in seconds, passed to Faraday as `open_timeout`. Override via `TOCDOC_CONNECT_TIMEOUT`. |
147
147
  | `read_timeout` | `10` | Response read timeout in seconds, passed to Faraday as `timeout`. Override via `TOCDOC_READ_TIMEOUT`. |
148
+ | `logger` | `nil` | Logger-compatible object (e.g. `Logger.new($stdout)`). When set, `TocDoc::Middleware::Logging` logs each request URL and response status. `nil` disables logging. |
148
149
  | `middleware` | Retry + RaiseError + JSON + adapter | Full Faraday middleware stack. Override to customise completely. |
149
150
  | `connection_options` | `{}` | Options passed directly to `Faraday.new`. |
150
151
 
data/TODO.md CHANGED
@@ -3,14 +3,6 @@
3
3
  [POTENTIAL_ENDPOINTS][POTENTIAL_ENDPOINTS.md]
4
4
 
5
5
 
6
- ## 1.7 — DevX
7
-
8
- - [ ] Logging middleware (`:logger` config key)
9
- - [ ] Resource: `define_singleton_method` on first access + `#attribute_names`
10
- - [ ] Deep `to_h` and `to_json` on `Resource` and `BookingInfo`
11
- - [ ] `Collection#filtered_entries` memoization
12
- - [ ] `BookingInfo#agendas` O(n*m) → hash lookup
13
-
14
6
  ## 1.8 — HTTP Layer Robustness
15
7
 
16
8
  - [ ] Configurable availability pagination depth + `Collection#more?` / `#fetch_next_page`
@@ -41,6 +33,15 @@
41
33
 
42
34
  # DONE & RELEASED
43
35
 
36
+ ## 1.7
37
+
38
+ - [x] Logging middleware (`:logger` config key)
39
+ - [x] Resource: `define_singleton_method` on first access + `#attribute_names`
40
+ - [x] Deep `to_h` and `to_json` on `Resource` and `BookingInfo`
41
+ - [x] `Collection#filtered_entries` memoization
42
+ - [x] `BookingInfo#agendas` O(n*m) → hash lookup
43
+ - [x] Improve `#inspect` for `Availability`, `Place`, `Speciality`, `Profile::Organization`
44
+
44
45
  ## 1.6
45
46
 
46
47
  ### Default connection timeouts
@@ -31,6 +31,7 @@ module TocDoc
31
31
  per_page
32
32
  connect_timeout
33
33
  read_timeout
34
+ logger
34
35
  ].freeze
35
36
 
36
37
  # @!attribute [rw] api_endpoint
@@ -47,6 +48,8 @@ module TocDoc
47
48
  # @return [Integer] TCP connect timeout in seconds
48
49
  # @!attribute [rw] read_timeout
49
50
  # @return [Integer] read (response) timeout in seconds
51
+ # @!attribute [rw] logger
52
+ # @return [Logger, :stdout, nil] logger for HTTP request logging; +nil+ disables logging
50
53
  attr_accessor(*VALID_CONFIG_KEYS)
51
54
 
52
55
  # Set the number of results per page, clamped to
@@ -92,11 +92,27 @@ module TocDoc
92
92
  # @return [Hash] merged Faraday connection options
93
93
  def faraday_options
94
94
  opts = connection_options.dup
95
- opts[:builder] = middleware if middleware
95
+ opts[:builder] = effective_middleware
96
96
  opts[:request] = { timeout: read_timeout, open_timeout: connect_timeout }
97
97
  opts
98
98
  end
99
99
 
100
+ # Returns the appropriate middleware stack for this client.
101
+ #
102
+ # When a logger is configured, builds a fresh stack with the logger injected
103
+ # so that the shared memoized default stack is never mutated.
104
+ # Falls back to the memoized {TocDoc::Default.middleware} when no logger is
105
+ # set.
106
+ #
107
+ # @return [Faraday::RackBuilder]
108
+ def effective_middleware
109
+ if logger
110
+ TocDoc::Default.build_middleware(logger: logger)
111
+ else
112
+ middleware
113
+ end
114
+ end
115
+
100
116
  # Sets default HTTP headers on a Faraday connection.
101
117
  #
102
118
  # @param conn [Faraday::Connection]
@@ -4,6 +4,7 @@ require 'faraday'
4
4
  require 'faraday/retry'
5
5
 
6
6
  require 'toc_doc/http/middleware/raise_error'
7
+ require 'toc_doc/http/middleware/logging'
7
8
 
8
9
  module TocDoc
9
10
  # Provides sensible default values for every configurable option.
@@ -43,16 +44,9 @@ module TocDoc
43
44
  #
44
45
  # @return [Hash{Symbol => Object}]
45
46
  def options
46
- {
47
- api_endpoint: api_endpoint,
48
- user_agent: user_agent,
49
- default_media_type: default_media_type,
50
- per_page: per_page,
51
- middleware: middleware,
52
- connection_options: connection_options,
53
- connect_timeout: connect_timeout,
54
- read_timeout: read_timeout
55
- }
47
+ { api_endpoint:, user_agent:, default_media_type:, per_page:,
48
+ middleware:, connection_options:, connect_timeout:, read_timeout:,
49
+ logger: nil }
56
50
  end
57
51
 
58
52
  # The base API endpoint URL.
@@ -97,7 +91,7 @@ module TocDoc
97
91
  PER_PAGE
98
92
  end
99
93
 
100
- # The default Faraday middleware stack.
94
+ # The default (memoized) Faraday middleware stack, built without a logger.
101
95
  #
102
96
  # Stack order (outermost first): RaiseError, retry, JSON parsing, adapter.
103
97
  # RaiseError is outermost so it wraps retry and maps the final response or
@@ -108,6 +102,29 @@ module TocDoc
108
102
  @middleware ||= build_middleware
109
103
  end
110
104
 
105
+ # Builds a Faraday middleware stack, optionally injecting a logger.
106
+ #
107
+ # When +logger+ is non-nil, {TocDoc::Middleware::Logging} is inserted
108
+ # between {TocDoc::Middleware::RaiseError} and the retry middleware so
109
+ # each logical request is logged exactly once after all retries are
110
+ # exhausted.
111
+ #
112
+ # Accepts +:stdout+ as a shorthand that writes to +$stdout+.
113
+ #
114
+ # @param logger [Logger, :stdout, nil] the logger to inject; +nil+ omits
115
+ # the logging middleware entirely
116
+ # @return [Faraday::RackBuilder]
117
+ def build_middleware(logger: nil)
118
+ resolved = resolve_logger(logger)
119
+ Faraday::RackBuilder.new do |builder|
120
+ builder.use TocDoc::Middleware::RaiseError
121
+ builder.use TocDoc::Middleware::Logging, logger: resolved if resolved
122
+ builder.request :retry, retry_options
123
+ builder.response :json, content_type: /\bjson$/
124
+ builder.adapter Faraday.default_adapter
125
+ end
126
+ end
127
+
111
128
  # Default Faraday connection options (empty by default).
112
129
  #
113
130
  # @return [Hash]
@@ -153,12 +170,15 @@ module TocDoc
153
170
 
154
171
  private
155
172
 
156
- def build_middleware
157
- Faraday::RackBuilder.new do |builder|
158
- builder.use TocDoc::Middleware::RaiseError
159
- builder.request :retry, retry_options
160
- builder.response :json, content_type: /\bjson$/
161
- builder.adapter Faraday.default_adapter
173
+ def resolve_logger(logger)
174
+ case logger
175
+ when :stdout
176
+ require 'logger'
177
+ Logger.new($stdout, progname: 'TocDoc')
178
+ when nil, false
179
+ nil
180
+ else
181
+ logger
162
182
  end
163
183
  end
164
184
 
@@ -4,5 +4,5 @@ module TocDoc
4
4
  # The current version of the TocDoc gem.
5
5
  #
6
6
  # @return [String]
7
- VERSION = '1.6.0'
7
+ VERSION = '1.7.0'
8
8
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module TocDoc
6
+ module Middleware
7
+ # Faraday middleware that logs the outcome of every HTTP request.
8
+ #
9
+ # Placed between {RaiseError} (outermost) and the retry middleware so that
10
+ # each logical request is logged exactly once — after all retry attempts have
11
+ # been exhausted — rather than once per attempt.
12
+ #
13
+ # Stack order (outermost first):
14
+ # RaiseError > Logging > retry > JSON parse > adapter
15
+ #
16
+ # Log format:
17
+ # TocDoc: GET /path.json -> 200 (42ms) # success → logger.info
18
+ # TocDoc: GET /path.json -> error: Msg (42ms) # failure → logger.warn
19
+ #
20
+ # When no logger is provided the middleware is a no-op.
21
+ #
22
+ # @example Attach a custom logger
23
+ # TocDoc.configure { |c| c.logger = Logger.new($stdout) }
24
+ #
25
+ # @example Use the :stdout shorthand
26
+ # TocDoc.configure { |c| c.logger = :stdout }
27
+ class Logging < Faraday::Middleware
28
+ # @param app [#call] the next middleware in the stack
29
+ # @param logger [Logger, nil] the logger to write to; +nil+ disables logging
30
+ def initialize(app, logger: nil)
31
+ super(app)
32
+ @logger = logger
33
+ end
34
+
35
+ # Calls the next middleware, measures elapsed time, and logs the outcome.
36
+ #
37
+ # @param env [Faraday::Env] the Faraday request environment
38
+ # @return [Faraday::Response] the response on success
39
+ # @raise [StandardError] re-raises any exception after logging it
40
+ def call(env)
41
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+ response = @app.call(env)
43
+ duration = duration_ms(start)
44
+ log_request(env, response.status, duration)
45
+ response
46
+ rescue StandardError => e
47
+ duration = duration_ms(start)
48
+ log_error(env, e, duration)
49
+ raise
50
+ end
51
+
52
+ private
53
+
54
+ # @param start [Float] monotonic clock time at request start
55
+ # @return [Integer] elapsed time in milliseconds
56
+ def duration_ms(start)
57
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
58
+ end
59
+
60
+ # Logs a successful HTTP response at +info+ level.
61
+ #
62
+ # @param env [Faraday::Env] the Faraday request environment
63
+ # @param status [Integer] the HTTP response status code
64
+ # @param duration [Integer] elapsed time in milliseconds
65
+ # @return [void]
66
+ def log_request(env, status, duration)
67
+ return unless @logger
68
+
69
+ @logger.info("TocDoc: #{env.method.to_s.upcase} #{env.url.path} -> #{status} (#{duration}ms)")
70
+ end
71
+
72
+ # Logs a failed request at +warn+ level.
73
+ #
74
+ # @param env [Faraday::Env] the Faraday request environment
75
+ # @param error [StandardError] the exception that was raised
76
+ # @param duration [Integer] elapsed time in milliseconds
77
+ # @return [void]
78
+ def log_error(env, error, duration)
79
+ return unless @logger
80
+
81
+ @logger.warn("TocDoc: #{env.method.to_s.upcase} #{env.url.path} -> error: #{error.message} (#{duration}ms)")
82
+ end
83
+ end
84
+ end
85
+ end
@@ -88,15 +88,16 @@ module TocDoc
88
88
  def merge_page!(page_data)
89
89
  @data['availabilities'] = @data.fetch('availabilities', []) + page_data.fetch('availabilities', [])
90
90
  @data['total'] = @data.fetch('total', 0) + page_data.fetch('total', 0)
91
+ @filtered_entries = nil
91
92
  self
92
93
  end
93
94
 
94
95
  private
95
96
 
96
97
  def filtered_entries
97
- Array(@data['availabilities'])
98
- .select { |entry| Array(entry['slots']).any? }
99
- .map { |entry| TocDoc::Availability.new(entry) }
98
+ @filtered_entries ||= Array(@data['availabilities'])
99
+ .select { |entry| Array(entry['slots']).any? }
100
+ .map { |entry| TocDoc::Availability.new(entry) }
100
101
  end
101
102
  end
102
103
  end
@@ -9,13 +9,19 @@ module TocDoc
9
9
  # @example
10
10
  # avail = TocDoc::Availability.new('date' => '2026-02-28', 'slots' => ['2026-02-28T10:00:00.000+01:00'])
11
11
  # avail.date #=> #<Date: 2026-02-28>
12
- # avail.raw_date #=> "2026-02-28"
12
+ # avail['date'] #=> "2026-02-28"
13
13
  # avail.slots #=> [#<DateTime: 2026-02-28T10:00:00.000+01:00>]
14
- # avail.raw_slots #=> ["2026-02-28T10:00:00.000+01:00"]
14
+ # avail['slots'] #=> ["2026-02-28T10:00:00.000+01:00"]
15
15
  class Availability < Resource
16
+ main_attrs :date, :slots
17
+
16
18
  extend TocDoc::UriUtils
17
19
 
18
- attr_reader :date, :slots
20
+ # @return [Date, nil] the parsed availability date
21
+ attr_reader :date
22
+
23
+ # @return [Array<DateTime>] the parsed slot datetimes
24
+ attr_reader :slots
19
25
 
20
26
  # API path for the availabilities endpoint.
21
27
  # @return [String]
@@ -96,6 +102,11 @@ module TocDoc
96
102
 
97
103
  private
98
104
 
105
+ # Extracts the +date+ and +slots+ keys from the raw attribute hash,
106
+ # providing an empty array fallback for missing slots.
107
+ #
108
+ # @param attrs [Hash{String => Object}] normalized attribute hash
109
+ # @return [Hash{String => Object}]
99
110
  def build_raw(attrs)
100
111
  {
101
112
  'date' => attrs['date'],
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
3
4
  require 'toc_doc/models/profile'
4
5
  require 'toc_doc/models/speciality'
5
6
  require 'toc_doc/models/place'
@@ -78,14 +79,19 @@ module TocDoc
78
79
 
79
80
  # All agendas for this booking context.
80
81
  #
82
+ # Visit motives are resolved via a hash index keyed by ID, so lookup is O(1)
83
+ # per motive rather than O(n) per agenda. Unknown visit_motive_ids are
84
+ # silently dropped.
85
+ #
81
86
  # @return [Array<TocDoc::Agenda>]
82
87
  def agendas
83
- @agendas ||= Array(@data['agendas']).map do |agenda_attrs|
84
- agenda_visit_motives = visit_motives.select do |vm|
85
- agenda_attrs['visit_motive_ids'].include?(vm.id)
86
- end
88
+ @agendas ||= begin
89
+ vm_index = visit_motives.to_h { |vm| [vm.id, vm] }
87
90
 
88
- Agenda.new(agenda_attrs.merge('visit_motives' => agenda_visit_motives))
91
+ Array(@data['agendas']).map do |agenda_attrs|
92
+ agenda_visit_motives = Array(agenda_attrs['visit_motive_ids']).filter_map { |id| vm_index[id] }
93
+ Agenda.new(agenda_attrs.merge('visit_motives' => agenda_visit_motives))
94
+ end
89
95
  end
90
96
  end
91
97
 
@@ -120,11 +126,35 @@ module TocDoc
120
126
  profile.organization?
121
127
  end
122
128
 
123
- # Returns the raw data hash.
129
+ # Returns the raw data hash as received from the API.
124
130
  #
125
131
  # @return [Hash]
126
- def to_h
132
+ def raw
127
133
  @data
128
134
  end
135
+
136
+ # Returns a hydrated hash with all typed collections serialized to plain
137
+ # Hashes. Unlike {#raw}, nested objects are converted via their own
138
+ # +#to_h+ methods.
139
+ #
140
+ # @return [Hash{String => Object}]
141
+ def to_h
142
+ {
143
+ 'profile' => profile.to_h,
144
+ 'specialities' => specialities.map(&:to_h),
145
+ 'visit_motives' => visit_motives.map(&:to_h),
146
+ 'agendas' => agendas.map(&:to_h),
147
+ 'places' => places.map(&:to_h),
148
+ 'practitioners' => practitioners.map(&:to_h)
149
+ }
150
+ end
151
+
152
+ # Serialize the booking info to a JSON string.
153
+ #
154
+ # @param args [Array] forwarded to +Hash#to_json+
155
+ # @return [String]
156
+ def to_json(*)
157
+ to_h.to_json(*)
158
+ end
129
159
  end
130
160
  end
@@ -27,6 +27,8 @@ module TocDoc
27
27
  # place.latitude #=> 44.8386722
28
28
  # place.elevator #=> true
29
29
  class Place < Resource
30
+ main_attrs :id, :city, :full_address
31
+
30
32
  # Returns the geographic coordinates of the place.
31
33
  #
32
34
  # @return [Array(Float, Float)] +[latitude, longitude]+
@@ -3,6 +3,8 @@
3
3
  module TocDoc
4
4
  class Profile
5
5
  # An organization profile.
6
- class Organization < Profile; end
6
+ class Organization < Profile
7
+ main_attrs :name
8
+ end
7
9
  end
8
10
  end
@@ -165,6 +165,28 @@ module TocDoc
165
165
  def organization?
166
166
  is_a?(Organization)
167
167
  end
168
+
169
+ # Returns the profile ID, falling back to the +value+ key used in
170
+ # autocomplete responses when +id+ is absent.
171
+ #
172
+ # @return [Integer, String, nil]
173
+ def id
174
+ self['id'] || self['value']
175
+ end
176
+
177
+ # Replaces this profile's attributes with those from the full profile page,
178
+ # making a network request only when the profile is currently partial.
179
+ # Returns +self+ for chaining.
180
+ #
181
+ # @return [self]
182
+ def load_full_profile!
183
+ return unless partial
184
+
185
+ full_profile = self.class.find(id)
186
+ @attrs = full_profile.instance_variable_get(:@attrs)
187
+ @partial = false
188
+ self
189
+ end
168
190
  end
169
191
  end
170
192
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module TocDoc
4
6
  # A lightweight wrapper providing dot-notation access to response fields.
5
7
  # Backed by a Hash, with +method_missing+ for attribute access and +#to_h+ for
@@ -14,6 +16,8 @@ module TocDoc
14
16
  # resource[:date] #=> "2026-02-28"
15
17
  # resource.to_h #=> { "date" => "2026-02-28", "slots" => [] }
16
18
  class Resource
19
+ attr_reader :attrs
20
+
17
21
  class << self
18
22
  # Normalises a raw attribute hash to string keys, mirroring what
19
23
  # {#initialize} does internally. Useful in class-level factory methods
@@ -73,11 +77,20 @@ module TocDoc
73
77
  @attrs[key.to_s] = value
74
78
  end
75
79
 
76
- # Return a plain Hash representation (shallow copy).
80
+ # Return a plain Hash representation with all nested {Resource} values
81
+ # recursively converted to plain Hashes.
77
82
  #
78
83
  # @return [Hash{String => Object}]
79
84
  def to_h
80
- @attrs.dup
85
+ @attrs.transform_values { |v| deep_convert(v) }
86
+ end
87
+
88
+ # Serialize the resource to a JSON string.
89
+ #
90
+ # @param args [Array] forwarded to +Hash#to_json+
91
+ # @return [String]
92
+ def to_json(*)
93
+ to_h.to_json(*)
81
94
  end
82
95
 
83
96
  # Equality comparison.
@@ -100,24 +113,80 @@ module TocDoc
100
113
  @attrs.key?(method_name.to_s) || super
101
114
  end
102
115
 
116
+ # Returns the list of attribute names present on this resource.
117
+ #
118
+ # @return [Array<String>] attribute names as strings
119
+ #
120
+ # @example
121
+ # resource = TocDoc::Resource.new('date' => '2026-02-28', 'slots' => [])
122
+ # resource.attribute_names #=> ["date", "slots"]
123
+ def attribute_names
124
+ @attrs.keys
125
+ end
126
+
103
127
  # Provides dot-notation access to response fields.
104
128
  #
105
- # Any method call whose name matches a key in the underlying attribute
106
- # hash is dispatched here.
129
+ # On first access, defines a singleton method so that subsequent calls
130
+ # bypass +method_missing+ entirely. The defined method reads live from
131
+ # +@attrs+, so mutations via +[]=+ are always reflected.
107
132
  #
108
133
  # @param method_name [Symbol] the method name
109
134
  # @return [Object] the attribute value
110
135
  # @raise [NoMethodError] when the key does not exist
111
136
  def method_missing(method_name, *_args)
112
137
  key = method_name.to_s
113
- @attrs.key?(key) ? @attrs[key] : super
138
+ if @attrs.key?(key)
139
+ define_singleton_method(key) { @attrs[key] }
140
+ @attrs[key]
141
+ else
142
+ super
143
+ end
114
144
  end
115
145
 
116
- # @!visibility private
146
+ # Returns a human-readable representation of the resource showing only the
147
+ # declared {.main_attrs} (or all attrs when none are declared).
148
+ #
149
+ # @return [String]
117
150
  def inspect
118
- keys = self.class.main_attrs || @attrs.keys
119
- pairs = keys.map { |k| "@#{k}=#{@attrs[k.to_s].inspect}" }.join(', ')
151
+ pairs = inspect_hash.map do |key, value|
152
+ "@#{key}=#{value.inspect}"
153
+ end.join(', ')
154
+
120
155
  "#<#{self.class} #{pairs}>"
121
156
  end
157
+
158
+ private
159
+
160
+ # Builds the key/value pairs used by {#inspect}.
161
+ #
162
+ # For each target key, the raw value from +@attrs+ is used when present.
163
+ # When the key is absent from +@attrs+ but the resource responds to the
164
+ # method (e.g. a computed attribute defined by a subclass), the method
165
+ # return value is used as a fallback.
166
+ #
167
+ # @return [Hash{String => Object}]
168
+ def inspect_hash
169
+ target_keys = self.class.main_attrs || @attrs.keys
170
+
171
+ target_keys.to_h do |target_key|
172
+ value = @attrs[target_key.to_s]
173
+ value = send(target_key) if value.nil? && respond_to?(target_key)
174
+ [target_key, value]
175
+ end
176
+ end
177
+
178
+ # Recursively converts {Resource} instances, Hashes, and Arrays to plain
179
+ # Ruby structures so that +to_h+ is fully serializable.
180
+ #
181
+ # @param value [Object]
182
+ # @return [Object]
183
+ def deep_convert(value)
184
+ case value
185
+ when Resource then value.to_h
186
+ when Hash then value.transform_values { |v| deep_convert(v) }
187
+ when Array then value.map { |v| deep_convert(v) }
188
+ else value
189
+ end
190
+ end
122
191
  end
123
192
  end
@@ -12,5 +12,6 @@ module TocDoc
12
12
  # speciality.slug #=> "homeopathe"
13
13
  # speciality.name #=> "Homéopathe"
14
14
  class Speciality < Resource
15
+ main_attrs :name, :slug
15
16
  end
16
17
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toc_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - 01max
@@ -70,6 +70,7 @@ files:
70
70
  - lib/toc_doc/core/error.rb
71
71
  - lib/toc_doc/core/uri_utils.rb
72
72
  - lib/toc_doc/core/version.rb
73
+ - lib/toc_doc/http/middleware/logging.rb
73
74
  - lib/toc_doc/http/middleware/raise_error.rb
74
75
  - lib/toc_doc/middleware/.keep
75
76
  - lib/toc_doc/models.rb