serdes 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1b3503dd727a37b864788db7ee33c3f3e4f78e2c74e507ffb5c609063f7bba74
4
+ data.tar.gz: 2da0aab394b27b6f3a4446c002149ad2948734b9cc3be6ef535bb6edb6729314
5
+ SHA512:
6
+ metadata.gz: 80a98b17cdabde8aec50f520d811123c199166576af8965ff5a52b636d49b082203ee7aebc74c344c80039ba09d6a3e1c6ca03201936c8be76c20160cdd10cca
7
+ data.tar.gz: 34a2506c7337a94c676f5969f1f448de64ee34e2c377d0fa0f129056fa987f062dd41ff6a818c80e0071b15fde31155cdb5756c50a52f2dede5c6cc59c071f32
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-02-03
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Shia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # Serdes
2
+
3
+ Serdes is a tool for *ser*ializing and *des*erializing class.
4
+ It provides:
5
+
6
+ - general way to serialize and deserialize
7
+ - simple type checking for attributes
8
+ - basic implementation for some class to Hash.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ bundle add serdes
14
+ ```
15
+
16
+ If bundler is not being used to manage dependencies, install the gem by executing:
17
+
18
+ ```bash
19
+ gem install serdes
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```ruby
25
+ require "serdes"
26
+
27
+ class User
28
+ include Serdes
29
+
30
+ rename_all_attributes :PascalCase
31
+
32
+ attribute :name, String
33
+ attribute :age, Integer
34
+ attribute :profile, optional(String)
35
+ attribute :tags, array(String)
36
+ attribute :has_pet, Boolean
37
+ end
38
+
39
+ user_hash = {
40
+ "Name" => "Alice",
41
+ "Age" => 20,
42
+ "HasPet" => true,
43
+ "Tags" => ["tag1", "tag2"]
44
+ }
45
+
46
+ user = User.from(user_hash)
47
+
48
+ user_hash = {
49
+ "Name" => "Alice",
50
+ "Age" => 20,
51
+ "HasPet" => true,
52
+ "Tags" => ["tag1", "tag2"]
53
+ }
54
+
55
+ User.from(user_hash) # => raise Serdes::TypeError
56
+ ```
57
+
58
+ ### API
59
+
60
+ - `<class>.from(obj)`: Deserialize object to <class> instance.
61
+ - `from` will call `from_<obj.class>` method if it exists. if not, it returns obj as it is.
62
+ - `<class>#to_hash`: Serialize <class> instance to Hash.
63
+ - There is no support for serializaion, as only you need to do is just implement `to_<class>` method where you want.
64
+
65
+ ### Types
66
+
67
+ `serdes` provides some convenient types for type checking:
68
+
69
+ - `optional(type)`: `type` | `nil`
70
+ - `array`: Array of `type`
71
+
72
+ ### Macro
73
+
74
+ - `rename_all_attributes`: Rename all attributes when serializing and deserializing.
75
+ - Supported: `:snake_case`, `:PascalCase`
76
+
77
+ ## Development
78
+
79
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
80
+
81
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
82
+
83
+ ## Contributing
84
+
85
+ Bug reports and pull requests are welcome on GitHub at https://github.com/riseshia/serdes.
86
+
87
+ ## License
88
+
89
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ path = File.expand_path(__dir__)
7
+ Dir.glob("#{path}/lib/tasks/**/*.rake").each { |f| import f }
8
+
9
+ RSpec::Core::RakeTask.new(:spec)
10
+
11
+ task default: :spec
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Serdes
4
+ VERSION = "0.1.0"
5
+ end
data/lib/serdes.rb ADDED
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "serdes/version"
4
+
5
+ module Serdes
6
+ DeclareError = Class.new(StandardError)
7
+ RequiredAttributeError = Class.new(StandardError)
8
+ SerializeError = Class.new(StandardError)
9
+ TypeError = Class.new(StandardError)
10
+ AttributeAlreadyDefined = Class.new(StandardError)
11
+ RenameStrategyNotFound = Class.new(StandardError)
12
+
13
+ Boolean = [TrueClass, FalseClass].freeze
14
+
15
+ class TypeBase
16
+ def optional?
17
+ false
18
+ end
19
+
20
+ def array?
21
+ false
22
+ end
23
+ end
24
+
25
+ class ConcreteType < TypeBase
26
+ attr_reader :exact_type
27
+
28
+ def initialize(exact_type)
29
+ @exact_type = exact_type
30
+ end
31
+
32
+ def permit?(value)
33
+ @exact_type == value.class
34
+ end
35
+
36
+ def to_s
37
+ @exact_type.to_s
38
+ end
39
+ end
40
+
41
+ class OptionalType < TypeBase
42
+ attr_reader :base_type
43
+
44
+ def initialize(base_type)
45
+ @base_type = base_type
46
+ end
47
+
48
+ def optional?
49
+ true
50
+ end
51
+
52
+ def permit?(value)
53
+ return true if value.nil?
54
+
55
+ if @base_type.is_a?(TypeBase)
56
+ @base_type.permit?(value)
57
+ else
58
+ @base_type == value.class
59
+ end
60
+ end
61
+
62
+ def to_s
63
+ "optional(#{@base_type})"
64
+ end
65
+ end
66
+
67
+ class ArrayType < TypeBase
68
+ attr_reader :element_type
69
+
70
+ def initialize(element_type)
71
+ @element_type = element_type
72
+ end
73
+
74
+ def array?
75
+ true
76
+ end
77
+
78
+ def permit?(value)
79
+ return false if !value.is_a?(Array)
80
+
81
+ if @element_type.is_a?(TypeBase)
82
+ value.all? { |v| @element_type.permit?(v) }
83
+ else
84
+ value.all? { |v| v.class == @element_type }
85
+ end
86
+ end
87
+
88
+ def to_s
89
+ "array(#{@element_type})"
90
+ end
91
+ end
92
+
93
+ class Attribute
94
+ attr_accessor :class_name, :name, :attr_type, :options
95
+
96
+ def initialize(class_name:, name:, attr_type:, options: {})
97
+ @class_name = class_name
98
+ @name = name
99
+ @attr_type = attr_type
100
+ @options = options
101
+ end
102
+
103
+ def serialized_name(rename_strategy = nil)
104
+ if rename_strategy && rename_strategy != :snake_case
105
+ Functions._serde_rename_strategy_func_fetch(:snake_case, rename_strategy).call(@name)
106
+ else
107
+ @name.to_s
108
+ end
109
+ end
110
+ end
111
+
112
+ def self.included(base)
113
+ base.extend ClassMethods
114
+ base.include InstanceMethods
115
+ end
116
+
117
+ module ClassMethods
118
+ def optional(type)
119
+ OptionalType.new(type)
120
+ end
121
+
122
+ def array(type)
123
+ ArrayType.new(type)
124
+ end
125
+
126
+ def attribute(name, attr_type, options = {})
127
+ attr_reader name
128
+
129
+ attr_type = ConcreteType.new(attr_type) if !attr_type.is_a?(TypeBase)
130
+ attr = Attribute.new(class_name: self.name, name: name, attr_type: attr_type, options: options)
131
+ _serde_attrs[name] = attr
132
+ _serde_deserialize_keymap[attr.serialized_name(_serde_rename_strategy)] = attr.name
133
+
134
+ define_method("#{name}=") do |value|
135
+ Functions.validate_type!(attr, value)
136
+ instance_variable_set("@#{name}", value)
137
+ end
138
+ end
139
+
140
+ def rename_all_attributes(strategy)
141
+ if _serde_attrs.size.positive?
142
+ raise AttributeAlreadyDefined, "Call rename_all_attributes before defining attributes."
143
+ end
144
+
145
+ rename_strategy =
146
+ case strategy
147
+ when :PascalCase
148
+ when :snake_case
149
+ else
150
+ raise RenameStrategyNotFound, "Rename strategy '#{strategy}' not found."
151
+ end
152
+
153
+ @_serde_rename_strategy = strategy
154
+ end
155
+
156
+ def from(obj)
157
+ Functions.from__proxy(self, obj)
158
+ end
159
+
160
+ def from_hash(hash)
161
+ new.tap do |instance|
162
+ _serde_attrs.each_value do |attr|
163
+ key = attr.serialized_name(_serde_rename_strategy)
164
+ serialized_value = hash[key]
165
+
166
+ value = Functions.from__proxy(attr.attr_type, serialized_value)
167
+
168
+ instance.__send__("#{attr.name}=", value)
169
+ end
170
+ end
171
+ end
172
+
173
+ private def _serde_attrs
174
+ @_serde_attrs ||= {}
175
+ end
176
+
177
+ private def _serde_deserialize_keymap
178
+ @_serde_deserialize_keymap ||= {}
179
+ end
180
+
181
+ private def _serde_rename_strategy
182
+ @_serde_rename_strategy ||= :snake_case
183
+ end
184
+ end
185
+
186
+ module InstanceMethods
187
+ def to_hash
188
+ self.class.__send__(:_serde_attrs).each_with_object({}) do |(name, attr), hash|
189
+ key = attr.serialized_name(self.class.__send__(:_serde_rename_strategy))
190
+ value = __send__(name)
191
+
192
+ hash[key] =
193
+ if value.respond_to?(:to_hash)
194
+ value.to_hash
195
+ elsif value.is_a?(Array)
196
+ value.map { |v| Functions.skip_to_hash?(v) ? v : v.to_hash }
197
+ elsif Functions.skip_to_hash?(value)
198
+ value
199
+ else
200
+ raise SerializeError, "Cannot serialize #{attr.class_name}##{attr.name} to Hash."
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ module Functions
207
+ module_function
208
+
209
+ # To avoid monkey patching stdlib classes, we use this proxy.
210
+ def from__proxy(from_type, to_value)
211
+ case from_type
212
+ when OptionalType
213
+ if to_value.nil?
214
+ nil
215
+ else
216
+ from_type = from_type.base_type
217
+ from__native_type__proxy(from_type, to_value)
218
+ end
219
+ when ArrayType
220
+ if to_value.is_a?(Array)
221
+ elem_type = from_type.element_type
222
+ to_value.map { |v| from__native_type__proxy(elem_type, v) }
223
+ else
224
+ from__native_type__proxy(from_type, to_value)
225
+ end
226
+ when ConcreteType
227
+ from_type = from_type.exact_type if from_type.is_a?(ConcreteType)
228
+ from__native_type__proxy(from_type, to_value)
229
+ else
230
+ from__native_type__proxy(from_type, to_value)
231
+ end
232
+ end
233
+
234
+ def from__native_type__proxy(from_type, to_value)
235
+ from_method = Functions.from_interface_for(to_value)
236
+
237
+ if from_type.respond_to?(from_method)
238
+ from_type.__send__(from_method, to_value)
239
+ else
240
+ # Do nothing, delegate error to validate_type! for better error message if mismached.
241
+ to_value
242
+ end
243
+ end
244
+
245
+ def from_interface_for(obj)
246
+ "from_#{const_name_to_snake_case(obj.class.name)}"
247
+ end
248
+
249
+ def const_name_to_snake_case(const_name)
250
+ const_name.gsub(/A-Z/, "_\\1").downcase.gsub("::", "__")
251
+ end
252
+
253
+ def validate_type!(attr, value)
254
+ valid = attr.attr_type.permit?(value)
255
+ return if valid
256
+
257
+ actual_type = humanize_type(value)
258
+
259
+ raise TypeError, "Wrong type for #{attr.class_name}##{attr.name}. Expected type #{attr.attr_type}, got #{value.class} (val: '#{value}')."
260
+ end
261
+
262
+ def humanize_type(value)
263
+ if value.is_a?(Array)
264
+ "array(" + value.map { |el| humanize_type(el) }.join(", ") + ")"
265
+ else
266
+ case value
267
+ when NilClass then "nil"
268
+ when TrueClass then "boolean"
269
+ when FalseClass then "boolean"
270
+ else value.class.to_s
271
+ end
272
+ end
273
+ end
274
+
275
+ def skip_to_hash?(value)
276
+ case value
277
+ when NilClass, String, Numeric, TrueClass, FalseClass
278
+ true
279
+ else
280
+ false
281
+ end
282
+ end
283
+
284
+ def _serde_rename_strategy_func_fetch(from, to)
285
+ _serde_rename_strategy_func[[from, to]]
286
+ end
287
+
288
+ def _serde_rename_strategy_func
289
+ @_serde_rename_strategy_func ||= {
290
+ [:PascalCase, :snake_case] => ->(name) { name.to_s.gsub(/([A-Z])/, '_\1').downcase },
291
+ [:snake_case, :PascalCase] => ->(name) { name.to_s.split('_').map(&:capitalize).join },
292
+ }
293
+ end
294
+ end
295
+ end
metadata ADDED
@@ -0,0 +1,49 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: serdes
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shia
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-02-03 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Serdes is a tool for serializing and deserializing class.
13
+ email:
14
+ - rise.shia@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - LICENSE.txt
21
+ - README.md
22
+ - Rakefile
23
+ - lib/serdes.rb
24
+ - lib/serdes/version.rb
25
+ homepage: https://github.com/riseshia/serdes
26
+ licenses:
27
+ - MIT
28
+ metadata:
29
+ homepage_uri: https://github.com/riseshia/serdes
30
+ source_code_uri: https://github.com/riseshia/serdes
31
+ changelog_uri: https://github.com/riseshia/serdes/blob/main/CHANGELOG.md
32
+ rdoc_options: []
33
+ require_paths:
34
+ - lib
35
+ required_ruby_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 3.2.0
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ requirements: []
46
+ rubygems_version: 3.6.2
47
+ specification_version: 4
48
+ summary: Serdes is a tool for serializing and deserializing class.
49
+ test_files: []