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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +170 -0
- data/lib/prusalink/auto_initializable.rb +224 -0
- data/lib/prusalink/base_resource.rb +92 -0
- data/lib/prusalink/client.rb +190 -0
- data/lib/prusalink/deserializable.rb +491 -0
- data/lib/prusalink/digest_auth.rb +80 -0
- data/lib/prusalink/exceptions.rb +36 -0
- data/lib/prusalink/files/file.rb +87 -0
- data/lib/prusalink/files/folder.rb +84 -0
- data/lib/prusalink/files/refs.rb +36 -0
- data/lib/prusalink/files.rb +184 -0
- data/lib/prusalink/info.rb +82 -0
- data/lib/prusalink/job/file.rb +70 -0
- data/lib/prusalink/job.rb +191 -0
- data/lib/prusalink/server_version.rb +70 -0
- data/lib/prusalink/status/camera.rb +26 -0
- data/lib/prusalink/status/job.rb +41 -0
- data/lib/prusalink/status/printer.rb +136 -0
- data/lib/prusalink/status/storage.rb +40 -0
- data/lib/prusalink/status/transfer.rb +40 -0
- data/lib/prusalink/status.rb +64 -0
- data/lib/prusalink/storage.rb +87 -0
- data/lib/prusalink/transfer.rb +108 -0
- data/lib/prusalink/types.rb +27 -0
- data/lib/prusalink/version.rb +7 -0
- data/lib/prusalink.rb +77 -0
- data/lib/tapioca/dsl/compilers/auto_initializable.rb +88 -0
- metadata +158 -0
|
@@ -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
|