serdes 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []