prusalink 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "faraday"
5
+
6
+ module Prusalink
7
+ # The API client object.
8
+ #
9
+ # @example Digest authentication (current firmware)
10
+ # client = Prusalink::Client.new(host: "http://prusa-mk4.local", password: "s3cr3t")
11
+ #
12
+ # @example API key (older firmware)
13
+ # client = Prusalink::Client.new(host: "http://prusa-mini.local", api_key: "abcdef")
14
+ #
15
+ # @example Allow more time for large file uploads
16
+ # client = Prusalink::Client.new(host: "http://prusa-mk4.local", password: "s3cr3t", timeout: 300)
17
+ #
18
+ # @attr [String] host
19
+ # @attr [String] username
20
+ # @attr [String, nil] password
21
+ # @attr [String, nil] api_key
22
+ # @attr [Numeric, nil] open_timeout Seconds to wait for the connection to open
23
+ # @attr [Numeric, nil] timeout Seconds to wait for a read or a write
24
+ class Client
25
+ extend T::Sig
26
+
27
+ # PrusaLink's default username for Digest authentication
28
+ DEFAULT_USERNAME = "maker"
29
+ # Default number of seconds to wait for the connection to open
30
+ DEFAULT_OPEN_TIMEOUT = 3
31
+ # Default number of seconds to wait for a read or a write. Printers on Wi-Fi can be slow.
32
+ DEFAULT_TIMEOUT = 10
33
+
34
+ ERRORS = T.let({
35
+ 400 => Exceptions::BadRequestError,
36
+ 401 => Exceptions::AuthenticationError,
37
+ 403 => Exceptions::AuthenticationError,
38
+ 404 => Exceptions::NotFoundError,
39
+ 409 => Exceptions::ConflictError,
40
+ 415 => Exceptions::UnsupportedMediaTypeError,
41
+ 500 => Exceptions::InternalServerError,
42
+ 501 => Exceptions::NotImplementedError,
43
+ 503 => Exceptions::ServiceUnavailableError
44
+ }.freeze, T::Hash[Integer, T.class_of(Exceptions::Error)])
45
+
46
+ attr_reader :host, :username, :password, :api_key, :open_timeout, :timeout
47
+
48
+ # @param host [String, URI] Printer's address
49
+ # @param password [String, nil] PrusaLink password (Digest auth)
50
+ # @param username [String] PrusaLink username (Digest auth)
51
+ # @param api_key [String, nil] PrusaLink API key (older firmware)
52
+ # @param open_timeout [Numeric, nil] Seconds to wait for the connection to open
53
+ # @param timeout [Numeric, nil] Seconds to wait for a read or a write
54
+ # @raise [Exceptions::MissingCredentialsError] if host is missing, or both password and api_key are missing
55
+ sig do
56
+ params(
57
+ host: T.nilable(T.any(String, URI::Generic)),
58
+ password: T.nilable(String),
59
+ username: String,
60
+ api_key: T.nilable(String),
61
+ open_timeout: T.nilable(Numeric),
62
+ timeout: T.nilable(Numeric)
63
+ ).void
64
+ end
65
+ def initialize(host:, password: nil, username: DEFAULT_USERNAME, api_key: nil,
66
+ open_timeout: DEFAULT_OPEN_TIMEOUT, timeout: DEFAULT_TIMEOUT)
67
+ raise Exceptions::MissingCredentialsError if host.nil? || (password.nil? && api_key.nil?)
68
+
69
+ @host = host
70
+ @username = username
71
+ @password = password
72
+ @api_key = api_key
73
+ @open_timeout = open_timeout
74
+ @timeout = timeout
75
+ @connection = new_connection
76
+ end
77
+
78
+ # Sends a request to the printer and parses the JSON response.
79
+ #
80
+ # @param path [String] the path of the request
81
+ # @param http_method [Symbol] :get, :post, :put, :patch, :delete or :head
82
+ # @param body [Hash, String, IO, nil] a Hash is sent as JSON. A String or an IO is sent as-is
83
+ # @param headers [Hash] extra request headers
84
+ # @return [Hash, Array, true, nil] the parsed body. true when the printer answers without a body.
85
+ # nil when it answers 204 No Content to a GET
86
+ # @raise [Exceptions::Error] a subclass matching the HTTP status when the request fails
87
+ # @raise [Exceptions::TimeoutError] if the printer doesn't respond within the configured timeouts
88
+ # @raise [Exceptions::ConnectionError] if the printer can't be reached
89
+ sig do
90
+ params(
91
+ path: String,
92
+ http_method: Symbol,
93
+ body: T.untyped,
94
+ headers: T::Hash[T.any(String, Symbol), String]
95
+ ).returns(T.untyped)
96
+ end
97
+ def request(path, http_method: :get, body: nil, headers: {})
98
+ response = perform(http_method, path, body, headers)
99
+
100
+ process_error(response) if response.status >= 400
101
+ return nil if response.status == 204 && http_method == :get
102
+ return true if response.body.to_s.strip.empty?
103
+
104
+ parse_json(response.body)
105
+ end
106
+
107
+ # Sends a HEAD request and returns the response headers.
108
+ #
109
+ # @param path [String] the path of the request
110
+ # @return [Hash{String => String}] the response headers, with lowercase names
111
+ # @raise [Exceptions::Error] a subclass matching the HTTP status when the request fails
112
+ sig { params(path: String).returns(T::Hash[String, String]) }
113
+ def head(path)
114
+ response = perform(:head, path, nil, {})
115
+ process_error(response) if response.status >= 400
116
+
117
+ response.headers.to_h.transform_keys(&:downcase)
118
+ end
119
+
120
+ # Every request inside the block is sent with this client, without affecting the
121
+ # client configured for the thread.
122
+ #
123
+ # @example
124
+ # mk4.use { Prusalink::Status.get }
125
+ sig { params(block: T.proc.returns(T.untyped)).returns(T.untyped) }
126
+ def use(&block)
127
+ previous_client = Prusalink.client
128
+ Prusalink.client = self
129
+ block.call
130
+ ensure
131
+ Prusalink.client = previous_client
132
+ end
133
+
134
+ private
135
+
136
+ def perform(http_method, path, body, headers)
137
+ payload = body.is_a?(Hash) ? body.to_json : body
138
+ headers = headers.merge("Content-Type" => "application/json") if body.is_a?(Hash)
139
+
140
+ @connection.run_request(http_method, path, payload, headers)
141
+ rescue Faraday::TimeoutError => e
142
+ raise Exceptions::TimeoutError, e.message
143
+ rescue Faraday::ConnectionFailed, Faraday::SSLError => e
144
+ # Faraday reports open timeouts (Net::OpenTimeout) as connection failures
145
+ error_class = e.wrapped_exception.is_a?(Timeout::Error) ? Exceptions::TimeoutError : Exceptions::ConnectionError
146
+ raise error_class, e.message
147
+ end
148
+
149
+ def process_error(response)
150
+ error_class = ERRORS[response.status] || Exceptions::UnknownError
151
+
152
+ raise error_class, "[#{response.status}] #{error_message(response)}"
153
+ end
154
+
155
+ # PrusaLink answers errors with JSON ({ title:, text: }) when asked for JSON, plain text otherwise
156
+ def error_message(response)
157
+ parsed = parse_json(response.body)
158
+ return response.body.to_s unless parsed.is_a?(Hash)
159
+
160
+ [parsed[:title], parsed[:text]].compact.join(": ").presence || response.body.to_s
161
+ rescue JSON::ParserError
162
+ response.body.to_s
163
+ end
164
+
165
+ def parse_json(body)
166
+ transform_keys(JSON.parse(body))
167
+ end
168
+
169
+ def transform_keys(obj)
170
+ case obj
171
+ when Hash
172
+ obj.to_h { |key, value| [key.to_s.underscore.to_sym, transform_keys(value)] }
173
+ when Array
174
+ obj.map { |item| transform_keys(item) }
175
+ else
176
+ obj
177
+ end
178
+ end
179
+
180
+ def new_connection
181
+ Faraday.new(host) do |connection|
182
+ connection.use DigestAuth, username, password if password
183
+ connection.headers["X-Api-Key"] = api_key if api_key && password.nil?
184
+ connection.headers["Accept"] = "application/json"
185
+ connection.options.open_timeout = open_timeout
186
+ connection.options.timeout = timeout
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,491 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Prusalink
5
+ # Deserializable provides declarative deserialization configuration for API responses.
6
+ #
7
+ # This module allows classes to define how they should be deserialized from API response
8
+ # data using a clean, declarative DSL. It automatically handles common patterns like
9
+ # camelCase to snake_case conversion and unknown field collection, while supporting
10
+ # advanced features like nested object conversion, array deserialization, and custom transformations.
11
+ #
12
+ # @example Basic usage
13
+ # class User
14
+ # include Deserializable
15
+ # include AutoInitializable
16
+ #
17
+ # auto_attr :name, type: String
18
+ # auto_attr :display_name, type: String
19
+ # auto_attr :profile, type: Profile
20
+ #
21
+ # auto_initialize!
22
+ #
23
+ # deserialize_config do
24
+ # rename display: :display_name # Rename API keys
25
+ # nested :profile, Profile # Convert nested hash to Profile object
26
+ # end
27
+ # end
28
+ #
29
+ # # API response: { name: "John", display: "John Doe", profile: { bio: "Developer" } }
30
+ # user = User.deserialize(api_data)
31
+ # user.name # => "John"
32
+ # user.display_name # => "John Doe"
33
+ # user.profile # => Profile instance
34
+ #
35
+ # @example Advanced usage with arrays and transformations
36
+ # class Post
37
+ # include Deserializable
38
+ # include AutoInitializable
39
+ #
40
+ # auto_attr :title, type: String
41
+ # auto_attr :author, type: User
42
+ # auto_attr :tags, type: String, array: true
43
+ # auto_attr :metadata, type: Hash
44
+ # auto_attr :extra, type: Hash
45
+ #
46
+ # auto_initialize!
47
+ #
48
+ # deserialize_config do
49
+ # nested :author, User # Convert author hash to User
50
+ # array :tags, String # Array of strings (no conversion needed)
51
+ # collect_extras # Collect unknown fields into :extra
52
+ #
53
+ # # Custom transformation
54
+ # transform do |data|
55
+ # data[:metadata][:processed_at] = Time.now
56
+ # end
57
+ # end
58
+ # end
59
+ #
60
+ # @example Automatic features (always enabled)
61
+ # class ApiProfile
62
+ # include Deserializable
63
+ # include AutoInitializable
64
+ #
65
+ # auto_attr :first_name, type: String
66
+ # auto_attr :heated_bed, type: T::Boolean
67
+ # auto_attr :phone_number, type: String
68
+ # auto_attr :extra, type: Hash
69
+ #
70
+ # auto_initialize!
71
+ #
72
+ # # No configuration needed for common patterns!
73
+ # end
74
+ #
75
+ # # API response: { firstName: "John", heatedBed: true, phoneNumber: "123-456-7890", apiVersion: "1.2" }
76
+ # # Automatic conversion: firstName -> first_name, heatedBed -> heated_bed, phoneNumber -> phone_number
77
+ # # Unknown fields collected: apiVersion -> extra[:apiVersion]
78
+ # profile = ApiProfile.deserialize(api_data)
79
+ # profile.first_name # => "John"
80
+ # profile.heated_bed # => true
81
+ # profile.extra # => { apiVersion: "1.2" }
82
+ #
83
+ # @example Manual deserialization (without DSL)
84
+ # class LegacyClass
85
+ # include Deserializable
86
+ #
87
+ # def self.deserialize(data)
88
+ # # Manual processing
89
+ # rename_keys(data, { old_key: :new_key })
90
+ # deserialize_nested(data, :child, ChildClass)
91
+ # new(**data)
92
+ # end
93
+ # end
94
+ module Deserializable
95
+ extend T::Sig
96
+ extend T::Helpers
97
+
98
+ # Class methods added when Deserializable is included.
99
+ #
100
+ # These methods provide the DSL for configuring deserialization behavior
101
+ # and performing the actual deserialization of API responses.
102
+ module ClassMethods
103
+ extend T::Sig
104
+
105
+ # Configures deserialization behavior using a declarative DSL.
106
+ #
107
+ # This method provides a clean, readable way to define how API response data
108
+ # should be transformed into Ruby objects. The configuration is cached and
109
+ # reused across deserialize calls.
110
+ #
111
+ # @param block [Proc] Configuration block evaluated in DeserializationConfig context
112
+ # @yield [config] Provides DSL methods for configuring deserialization
113
+ #
114
+ # @example Basic configuration
115
+ # deserialize_config do
116
+ # rename display: :display_name # Rename keys
117
+ # nested :profile, Profile # Convert nested objects
118
+ # array :tags, Tag # Convert array elements
119
+ # collect_extras # Collect unknown fields
120
+ # end
121
+ #
122
+ # @see DeserializationConfig DSL methods available in the configuration block
123
+ sig { params(block: T.nilable(T.proc.bind(DeserializationConfig).void)).void }
124
+ def deserialize_config(&block)
125
+ @deserialize_config ||= DeserializationConfig.new
126
+ @deserialize_config.instance_eval(&block) if block
127
+ end
128
+
129
+ # Returns the current deserialization configuration.
130
+ #
131
+ # @return [DeserializationConfig, nil] The configuration object or nil if not configured
132
+ # @api private
133
+ sig { returns(T.nilable(DeserializationConfig)) }
134
+ def deserialize_configuration
135
+ @deserialize_config
136
+ end
137
+
138
+ # Deserializes API response data into a Ruby object.
139
+ #
140
+ # This method applies all configured transformations in order:
141
+ # 1. Nested object conversions
142
+ # 2. Array element conversions
143
+ # 3. Key renaming
144
+ # 4. Custom transformations
145
+ # 5. Extra field collection (if enabled)
146
+ # 6. Object instantiation
147
+ #
148
+ # @param data [Hash<Symbol, Object>] The API response data to deserialize
149
+ # @return [Object] A new instance of the class with deserialized data
150
+ #
151
+ # @example
152
+ # api_data = { name: "John", display: "John Doe", profile: { bio: "Dev" } }
153
+ # user = User.deserialize(api_data)
154
+ # user.name # => "John"
155
+ # user.display_name # => "John Doe" (renamed from display)
156
+ # user.profile # => Profile instance (converted from hash)
157
+ sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
158
+ def deserialize(data)
159
+ config = deserialize_configuration
160
+
161
+ # Apply automatic camelCase to snake_case conversion (always enabled)
162
+ convert_camel_case_keys(data)
163
+
164
+ if config
165
+ # Apply nested object deserializations
166
+ config.nested_objects.each do |field, klass|
167
+ deserialize_nested(data, field, klass)
168
+ end
169
+
170
+ # Apply array deserializations
171
+ config.array_objects.each do |field, klass|
172
+ deserialize_array(data, field, klass)
173
+ end
174
+
175
+ # Apply key renamings
176
+ rename_keys(data, config.key_mappings) if config.key_mappings.any?
177
+
178
+ # Apply custom transformations
179
+ config.transformations.each do |transformation|
180
+ transformation.call(data)
181
+ end
182
+ end
183
+
184
+ # Handle extras (always enabled for forward compatibility)
185
+ handle_extras(data)
186
+
187
+ T.unsafe(self).new(**data)
188
+ end
189
+
190
+ # Helper methods (kept for backward compatibility and direct use)
191
+
192
+ # Renames keys in a data hash according to the provided mapping.
193
+ #
194
+ # This is essential for handling API responses where field names conflict with
195
+ # Ruby built-in methods or don't match Ruby conventions. Many APIs use field
196
+ # names that would override important Object methods if used as attribute names.
197
+ #
198
+ # @param data [Hash<Symbol, Object>] The data hash to modify
199
+ # @param mapping [Hash<Symbol, Symbol>] Mapping from old keys to new keys
200
+ # @return [Hash<Symbol, Object>] The modified data hash
201
+ #
202
+ # @example Avoiding Ruby method conflicts
203
+ # data = { display: "John Doe", hash: "abc123", class: "User" }
204
+ # rename_keys(data, { display: :display_name, hash: :md5_hash, class: :klass })
205
+ # # data is now { display_name: "John Doe", md5_hash: "abc123", klass: "User" }
206
+ # # Avoids conflicts with Object#display, Object#hash, Object#class
207
+ #
208
+ # @note Common Ruby method conflicts include: display, hash, class, type, method, send
209
+ sig do
210
+ params(data: T::Hash[Symbol, T.untyped], mapping: T::Hash[Symbol, Symbol]).returns(T::Hash[Symbol, T.untyped])
211
+ end
212
+ def rename_keys(data, mapping)
213
+ mapping.each do |old_key, new_key|
214
+ data[new_key] = data.delete(old_key) if data.key?(old_key)
215
+ end
216
+ data
217
+ end
218
+
219
+ # Deserializes a nested object field within the data hash.
220
+ #
221
+ # Attempts to convert a nested hash or object into an instance of the specified class.
222
+ # First tries calling the class's `deserialize` method, then falls back to `new(**hash)`.
223
+ #
224
+ # @param data [Hash<Symbol, Object>] The data hash containing the field
225
+ # @param field [Symbol] The field name to deserialize
226
+ # @param klass [Class] The target class for deserialization
227
+ # @return [Hash<Symbol, Object>] The modified data hash
228
+ #
229
+ # @example
230
+ # data = { profile: { name: "John", age: 30 } }
231
+ # deserialize_nested(data, :profile, User)
232
+ # # data[:profile] is now a User instance
233
+ sig do
234
+ params(
235
+ data: T::Hash[Symbol, T.untyped],
236
+ field: Symbol,
237
+ klass: T.untyped
238
+ ).returns(T::Hash[Symbol, T.untyped])
239
+ end
240
+ def deserialize_nested(data, field, klass)
241
+ if data[field] && klass.respond_to?(:deserialize)
242
+ data[field] = T.unsafe(klass).deserialize(data[field])
243
+ elsif data[field] && klass.respond_to?(:new) && data[field].is_a?(Hash)
244
+ data[field] = T.unsafe(klass).new(**data[field])
245
+ end
246
+ data
247
+ end
248
+
249
+ # Deserializes an array field by converting each element to the specified class.
250
+ #
251
+ # Maps over each item in the array, attempting to deserialize hash items into
252
+ # instances of the specified class. Non-hash items are left unchanged.
253
+ #
254
+ # @param data [Hash<Symbol, Object>] The data hash containing the array field
255
+ # @param field [Symbol] The field name containing the array
256
+ # @param klass [Class] The target class for array elements
257
+ # @return [Hash<Symbol, Object>] The modified data hash
258
+ #
259
+ # @example
260
+ # data = { users: [{ name: "John" }, { name: "Jane" }] }
261
+ # deserialize_array(data, :users, User)
262
+ # # data[:users] is now [User("John"), User("Jane")]
263
+ sig do
264
+ params(
265
+ data: T::Hash[Symbol, T.untyped],
266
+ field: Symbol,
267
+ klass: T.untyped
268
+ ).returns(T::Hash[Symbol, T.untyped])
269
+ end
270
+ def deserialize_array(data, field, klass)
271
+ if data[field].is_a?(Array)
272
+ data[field] = data[field].map do |item|
273
+ if klass.respond_to?(:deserialize)
274
+ T.unsafe(klass).deserialize(item)
275
+ elsif klass.respond_to?(:new) && item.is_a?(Hash)
276
+ T.unsafe(klass).new(**item)
277
+ else
278
+ item
279
+ end
280
+ end
281
+ end
282
+ data
283
+ end
284
+
285
+ # Converts camelCase keys to snake_case keys in the data hash.
286
+ #
287
+ # Automatically converts API field names from camelCase to snake_case to match
288
+ # Ruby naming conventions. This eliminates the need for manual key renaming
289
+ # for common camelCase patterns.
290
+ #
291
+ # @param data [Hash<Symbol, Object>] The data hash to modify
292
+ # @return [Hash<Symbol, Object>] The modified data hash
293
+ #
294
+ # @example
295
+ # data = { firstName: "John", lastName: "Doe", phoneNumber: "123-456-7890" }
296
+ # convert_camel_case_keys(data)
297
+ # # data is now { first_name: "John", last_name: "Doe", phone_number: "123-456-7890" }
298
+ sig { params(data: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
299
+ def convert_camel_case_keys(data)
300
+ camel_case_keys = data.keys.select { |key| camel_case?(key) }
301
+
302
+ camel_case_keys.each do |camel_key|
303
+ snake_key = camel_to_snake(camel_key)
304
+ data[snake_key] = data.delete(camel_key)
305
+ end
306
+
307
+ data
308
+ end
309
+
310
+ private
311
+
312
+ # Checks if a symbol represents a camelCase identifier.
313
+ #
314
+ # @param key [Symbol] The key to check
315
+ # @return [Boolean] True if the key is in camelCase format
316
+ sig { params(key: Symbol).returns(T::Boolean) }
317
+ def camel_case?(key)
318
+ key_str = key.to_s
319
+ # Has lowercase letter followed by uppercase letter (e.g., firstName, heatedBed)
320
+ key_str.match?(/[a-z][A-Z]/)
321
+ end
322
+
323
+ # Converts a camelCase symbol to snake_case.
324
+ #
325
+ # @param camel_key [Symbol] The camelCase key to convert
326
+ # @return [Symbol] The snake_case equivalent
327
+ sig { params(camel_key: Symbol).returns(Symbol) }
328
+ def camel_to_snake(camel_key)
329
+ camel_key.to_s
330
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
331
+ .downcase
332
+ .to_sym
333
+ end
334
+
335
+ public
336
+
337
+ # Collects unknown fields into an :extra hash if the class supports it.
338
+ #
339
+ # Moves any fields that aren't defined as auto_attrs into a separate :extra
340
+ # hash to preserve API data that doesn't have corresponding Ruby attributes.
341
+ # This is useful for maintaining forward compatibility with API changes.
342
+ # Only collects extras if the class has an :extra attribute defined.
343
+ #
344
+ # @param data [Hash<Symbol, Object>] The data hash to process
345
+ # @return [void]
346
+ #
347
+ # @example
348
+ # # Class has auto_attrs: [:name, :email, :extra]
349
+ # data = { name: "John", email: "john@example.com", unknown_field: "value" }
350
+ # handle_extras(data)
351
+ # # data is now { name: "John", email: "john@example.com", extra: { unknown_field: "value" } }
352
+ sig { params(data: T::Hash[Symbol, T.untyped]).void }
353
+ def handle_extras(data)
354
+ # Get valid keys from AutoInitializable if included
355
+ valid_keys = if T.unsafe(self).respond_to?(:auto_attrs)
356
+ T.unsafe(self).auto_attrs.keys
357
+ else
358
+ []
359
+ end
360
+
361
+ # Only collect extras if the class has an :extra attribute
362
+ return unless valid_keys.include?(:extra)
363
+
364
+ # Get extra keys (excluding :extra itself since that's where we'll store them)
365
+ extra_keys = data.keys - valid_keys
366
+
367
+ return unless extra_keys.any?
368
+
369
+ extras_hash = {}
370
+ extra_keys.each do |key|
371
+ extras_hash[key] = data.delete(key)
372
+ end
373
+ data[:extra] = extras_hash
374
+ end
375
+ end
376
+
377
+ # Configuration object for declarative deserialization.
378
+ #
379
+ # This class provides the DSL methods available within deserialize_config blocks.
380
+ # It stores the configuration for how data should be transformed during deserialization.
381
+ #
382
+ # @api private
383
+ # @see Deserializable#deserialize_config
384
+ class DeserializationConfig
385
+ extend T::Sig
386
+
387
+ sig { void }
388
+ def initialize
389
+ @nested_objects = {}
390
+ @array_objects = {}
391
+ @key_mappings = {}
392
+ @transformations = []
393
+ end
394
+
395
+ # @return [Hash<Symbol, Class>] Mapping of field names to classes for nested object conversion
396
+ # @api private
397
+ sig { returns(T::Hash[Symbol, T.untyped]) }
398
+ attr_reader :nested_objects
399
+
400
+ # @return [Hash<Symbol, Class>] Mapping of field names to classes for array element conversion
401
+ # @api private
402
+ sig { returns(T::Hash[Symbol, T.untyped]) }
403
+ attr_reader :array_objects
404
+
405
+ # @return [Hash<Symbol, Symbol>] Mapping of old field names to new field names
406
+ # @api private
407
+ sig { returns(T::Hash[Symbol, Symbol]) }
408
+ attr_reader :key_mappings
409
+
410
+ # @return [Array<Proc>] Custom transformation blocks to apply to data
411
+ # @api private
412
+ sig { returns(T::Array[T.proc.params(data: T::Hash[Symbol, T.untyped]).void]) }
413
+ attr_reader :transformations
414
+
415
+ # Configures a field to be deserialized as a nested object.
416
+ #
417
+ # The field's hash value will be converted to an instance of the specified class
418
+ # by calling either `klass.deserialize(hash)` or `klass.new(**hash)`.
419
+ #
420
+ # @param field [Symbol] The field name containing the nested hash
421
+ # @param klass [Class] The class to instantiate for the nested object
422
+ #
423
+ # @example
424
+ # nested :profile, Profile # data[:profile] hash becomes Profile instance
425
+ # nested :location, Location # data[:location] becomes Location instance
426
+ sig { params(field: Symbol, klass: T.untyped).void }
427
+ def nested(field, klass)
428
+ @nested_objects[field] = klass
429
+ end
430
+
431
+ # Configures a field to be deserialized as an array of objects.
432
+ #
433
+ # Each element in the array will be converted to an instance of the specified class
434
+ # if it's a hash, otherwise left unchanged.
435
+ #
436
+ # @param field [Symbol] The field name containing the array
437
+ # @param klass [Class] The class to instantiate for each array element
438
+ #
439
+ # @example
440
+ # array :users, User # data[:users] array of hashes becomes array of User instances
441
+ # array :tags, Tag # data[:tags] becomes array of Tag instances
442
+ sig { params(field: Symbol, klass: T.untyped).void }
443
+ def array(field, klass)
444
+ @array_objects[field] = klass
445
+ end
446
+
447
+ # Configures key renaming for API response fields.
448
+ #
449
+ # This is essential when API field names conflict with Ruby built-in methods
450
+ # or don't match Ruby naming conventions. Many APIs use field names that
451
+ # would override important Object methods if used directly as attribute names.
452
+ #
453
+ # @param mappings [Hash<Symbol, Symbol>] Mapping from API field names to Ruby attribute names
454
+ #
455
+ # @example Avoiding Ruby method conflicts
456
+ # rename display: :display_name, hash: :md5_hash, class: :klass
457
+ # # "display" conflicts with Object#display (prints to stdout)
458
+ # # "hash" conflicts with Object#hash (returns object hash code)
459
+ # # "class" conflicts with Object#class (returns object's class)
460
+ #
461
+ # @example Converting API naming to Ruby conventions
462
+ # rename firstName: :first_name, lastName: :last_name
463
+ # # Convert camelCase API fields to snake_case Ruby attributes
464
+ sig { params(mappings: T::Hash[Symbol, Symbol]).void }
465
+ def rename(mappings)
466
+ @key_mappings.merge!(mappings)
467
+ end
468
+
469
+ # Adds a custom transformation to be applied to the data.
470
+ #
471
+ # Transformations are applied after nested/array conversions and key renaming,
472
+ # but before extra field collection. This allows for complex custom logic
473
+ # that can't be expressed through the other DSL methods.
474
+ #
475
+ # @param block [Proc] Block that receives the data hash and can modify it
476
+ # @yield [data] The data hash to transform
477
+ #
478
+ # @example
479
+ # transform do |data|
480
+ # data[:processed_at] = Time.now
481
+ # data[:full_name] = "#{data[:first_name]} #{data[:last_name]}"
482
+ # end
483
+ sig { params(block: T.proc.params(data: T::Hash[Symbol, T.untyped]).void).void }
484
+ def transform(&block)
485
+ @transformations << block
486
+ end
487
+ end
488
+
489
+ mixes_in_class_methods(ClassMethods)
490
+ end
491
+ end