json_resource 1.0.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: dbada0c6c6821280079957d75f002a39fa338c6566a4daf7cda3650b98fb29fd
4
+ data.tar.gz: a232cf53b5e86dd6e9df328786c5db0ab150747284e11682d5b51bf426fef46c
5
+ SHA512:
6
+ metadata.gz: 1a8a3aae1d4e7b21df4e09cc5d8da6f807219e6fc3841c02ca200d9bce621aa222f5e4caa1ee61b190c6041d2b1269674dcaf9b3453e4d8ccdebf3bc41e3e518
7
+ data.tar.gz: ab5f72f1a266e1ba3e8ae993d951339c9f63c66a5960e241968931343233ce2bafdcf8667eb58378589e698b628573d0f4b2f9b54d427693667095c574348d5b
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ ## 1.0.0
2
+
3
+ - First release
4
+
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Matthias Grosser
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,64 @@
1
+ [![Gem Version](https://badge.fury.io/rb/json_resource.svg)](http://badge.fury.io/rb/json_resource)
2
+ [![build](https://github.com/mtgrosser/json_resource/actions/workflows/build.yml/badge.svg)](https://github.com/mtgrosser/json_resource/actions/workflows/build.yml)
3
+
4
+ # json_resource – Create Ruby objects from JSON data
5
+
6
+ ```ruby
7
+ class Post
8
+ include JsonResource::Model
9
+
10
+ attribute :id, type: :integer
11
+ attribute :body, type: :string
12
+ attribute :timestamp, type: :time
13
+ attribute :status_code, type: :integer, path: %w[status statusCode]
14
+ attribute :status_text, type: :string, path: %w[status statusText]
15
+
16
+ has_collection :comments
17
+ end
18
+
19
+ class Comment
20
+ include JsonResource::Model
21
+
22
+ attribute :author, type: :string
23
+ attribute :text, type: :string
24
+ attribute :timestamp, type: :time
25
+ end
26
+ ```
27
+
28
+ ```json
29
+ {
30
+ "posts": [
31
+ {
32
+ "id": 123,
33
+ "body": "Lorem ipsum",
34
+ "timestamp": "2021-12-08T18:43:00",
35
+ "status": {
36
+ "statusCode": 2,
37
+ "statusText": "published"
38
+ },
39
+ "comments": [
40
+ {
41
+ "author": "Mr. Spock",
42
+ "text": "Fascinating!",
43
+ "timestamp": "2350-05-01T19:00:00"
44
+ },
45
+ {
46
+ "author": "Chekov",
47
+ "text": "Warp 10",
48
+ "timestamp": "2350-05-01T20:00:00"
49
+ }
50
+ ]
51
+
52
+ }
53
+ ]
54
+ }
55
+ ```
56
+
57
+ ```ruby
58
+ posts = Post.collection_from_json(json, root: 'posts')
59
+
60
+ posts.first.id => 123
61
+ posts.first.body => 'Lorem ipsum'
62
+ posts.first.status_text => 'published'
63
+ posts.first.comments.first.author => 'Mr. Spock'
64
+ ```
@@ -0,0 +1,5 @@
1
+ module JsonResource
2
+ class Error < StandardError; end
3
+ class ParseError < Error; end
4
+ class TypeCastError < Error; end
5
+ end
@@ -0,0 +1,78 @@
1
+ module JsonResource
2
+ module Inflections
3
+
4
+ class << self
5
+ attr_accessor :pluralizations, :singularizations, :irregulars, :uncountables, :acronyms
6
+ end
7
+
8
+ self.pluralizations = [
9
+ [/\z/, 's'],
10
+ [/s\z/i, 's'],
11
+ [/(ax|test)is\z/i, '\1es'],
12
+ [/(.*)us\z/i, '\1uses'],
13
+ [/(octop|vir|cact)us\z/i, '\1i'],
14
+ [/(octop|vir)i\z/i, '\1i'],
15
+ [/(alias|status)\z/i, '\1es'],
16
+ [/(buffal|domin|ech|embarg|her|mosquit|potat|tomat)o\z/i, '\1oes'],
17
+ [/(?<!b)um\z/i, '\1a'],
18
+ [/([ti])a\z/i, '\1a'],
19
+ [/sis\z/i, 'ses'],
20
+ [/(.*)(?:([^f]))fe*\z/i, '\1\2ves'],
21
+ [/(hive|proof)\z/i, '\1s'],
22
+ [/([^aeiouy]|qu)y\z/i, '\1ies'],
23
+ [/(x|ch|ss|sh)\z/i, '\1es'],
24
+ [/(stoma|epo)ch\z/i, '\1chs'],
25
+ [/(matr|vert|ind)(?:ix|ex)\z/i, '\1ices'],
26
+ [/([m|l])ouse\z/i, '\1ice'],
27
+ [/([m|l])ice\z/i, '\1ice'],
28
+ [/^(ox)\z/i, '\1en'],
29
+ [/^(oxen)\z/i, '\1'],
30
+ [/(quiz)\z/i, '\1zes'],
31
+ [/(.*)non\z/i, '\1na'],
32
+ [/(.*)ma\z/i, '\1mata'],
33
+ [/(.*)(eau|eaux)\z/, '\1eaux']]
34
+
35
+ self.singularizations = [
36
+ [/s\z/i, ''],
37
+ [/(n)ews\z/i, '\1ews'],
38
+ [/([ti])a\z/i, '\1um'],
39
+ [/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)\z/i, '\1\2sis'],
40
+ [/(^analy)(sis|ses)\z/i, '\1sis'],
41
+ [/([^f])ves\z/i, '\1fe'],
42
+ [/(hive)s\z/i, '\1'],
43
+ [/(tive)s\z/i, '\1'],
44
+ [/([lr])ves\z/i, '\1f'],
45
+ [/([^aeiouy]|qu)ies\z/i, '\1y'],
46
+ [/(s)eries\z/i, '\1eries'],
47
+ [/(m)ovies\z/i, '\1ovie'],
48
+ [/(ss)\z/i, '\1'],
49
+ [/(x|ch|ss|sh)es\z/i, '\1'],
50
+ [/([m|l])ice\z/i, '\1ouse'],
51
+ [/(us)(es)?\z/i, '\1'],
52
+ [/(o)es\z/i, '\1'],
53
+ [/(shoe)s\z/i, '\1'],
54
+ [/(cris|ax|test)(is|es)\z/i, '\1is'],
55
+ [/(octop|vir)(us|i)\z/i, '\1us'],
56
+ [/(alias|status)(es)?\z/i, '\1'],
57
+ [/^(ox)en/i, '\1'],
58
+ [/(vert|ind)ices\z/i, '\1ex'],
59
+ [/(matr)ices\z/i, '\1ix'],
60
+ [/(quiz)zes\z/i, '\1'],
61
+ [/(database)s\z/i, '\1']]
62
+
63
+ self.irregulars = [
64
+ ['person', 'people'],
65
+ ['man', 'men'],
66
+ ['human', 'humans'],
67
+ ['child', 'children'],
68
+ ['sex', 'sexes'],
69
+ ['foot', 'feet'],
70
+ ['tooth', 'teeth'],
71
+ ['goose', 'geese'],
72
+ ['forum', 'forums']]
73
+
74
+ self.uncountables = %w[hovercraft moose deer milk rain Swiss grass equipment information rice money species series fish sheep jeans]
75
+
76
+ self.acronyms = {}
77
+ end
78
+ end
@@ -0,0 +1,211 @@
1
+ module JsonResource
2
+ using JsonResource::Refinements
3
+
4
+ module Model
5
+ TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE', 'on', 'ON'].to_set
6
+
7
+ def self.included(base)
8
+ base.extend ClassMethods
9
+ base.class_attribute :attributes, :collections, :objects, :inflection
10
+ base.attributes = {}
11
+ base.collections = {}
12
+ base.objects = {}
13
+ base.inflection = :lower_camelcase
14
+ end
15
+
16
+ module ClassMethods
17
+
18
+ def from_json(obj, defaults: {}, root: nil)
19
+ return unless json = parse(obj)
20
+ json = json_dig(json, *root) if root
21
+ attrs = {}
22
+ self.attributes.each do |name, options|
23
+ if path = attribute_path(name)
24
+ value = json_dig(json, *path)
25
+ if !value.nil? && type = attribute_type(name)
26
+ value = cast_to(type, value)
27
+ end
28
+ attrs[name] = value
29
+ end
30
+ end
31
+ instance = new(defaults.merge(attrs.compact))
32
+ self.objects.each do |name, options|
33
+ if path = object_path(name) and obj = json_dig(json, *path)
34
+ instance.public_send("#{name}=", object_class(name).from_json(obj))
35
+ end
36
+ end
37
+ self.collections.each do |name, options|
38
+ collection = if path = collection_path(name) and obj = json_dig(json, *path)
39
+ instance.public_send("#{name}=", collection_class(name).collection_from_json(obj))
40
+ else
41
+ []
42
+ end
43
+ end
44
+ instance
45
+ end
46
+
47
+ def collection_from_json(obj, defaults: {}, root: nil)
48
+ json = parse(obj)
49
+ json = json_dig(json, root) if root
50
+ json.map { |hsh| from_json(hsh, defaults: defaults) }.compact
51
+ end
52
+
53
+ def basename
54
+ name.sub(/^.*::/, '')
55
+ end
56
+
57
+ [:attribute, :object, :collection].each do |method_name|
58
+ define_method "#{method_name}_names" do
59
+ send("#{method_name}s").keys
60
+ end
61
+ end
62
+
63
+ protected
64
+
65
+ def attribute(name, options = {})
66
+ self.attributes = attributes.merge(name.to_sym => options.symbolize_keys)
67
+ attribute_accessor_method name
68
+ end
69
+
70
+ def attributes(*args)
71
+ options = args.extract_options!
72
+ args.each { |arg| has_attribute arg, options }
73
+ end
74
+
75
+ def has_object(name, options = {})
76
+ self.objects = objects.merge(name.to_sym => options.symbolize_keys)
77
+ attr_accessor name
78
+ end
79
+
80
+ def has_collection(name, options = {})
81
+ self.collections = collections.merge(name.to_sym => options.symbolize_keys)
82
+ define_method "#{name}" do
83
+ instance_variable_get("@#{name}") or instance_variable_set("@#{name}", [])
84
+ end
85
+ define_method "#{name}=" do |assignment|
86
+ instance_variable_set("@#{name}", assignment) if assignment
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def attribute_accessor_method(name)
93
+ define_method("#{name}") { self[name] }
94
+ define_method("#{name}=") { |value| self[name] = value }
95
+ end
96
+
97
+ [:attribute, :object, :collection].each do |method_name|
98
+ define_method "#{method_name}_path" do |name|
99
+ options = send(method_name.to_s.pluralize)
100
+ Array(options[name].try(:[], :path)).presence || [inflect(name)]
101
+ end
102
+ end
103
+
104
+ def attribute_type(name)
105
+ attributes[name] && attributes[name][:type]
106
+ end
107
+
108
+ def object_class(name)
109
+ if objects[name] && class_name = objects[name][:class_name]
110
+ class_name.constantize
111
+ else
112
+ name.to_s.classify.constantize
113
+ end
114
+ end
115
+
116
+ def collection_class(name)
117
+ if collections[name] && class_name = collections[name][:class_name]
118
+ class_name.constantize
119
+ else
120
+ name.to_s.singularize.classify.constantize
121
+ end
122
+ end
123
+
124
+ def cast_to(type, value) # only called for non-nil values
125
+ case type
126
+ when :string then value
127
+ when :integer then value.to_i
128
+ when :float then value.to_f
129
+ when :boolean then cast_to_boolean(value)
130
+ when :decimal then BigDecimal(value)
131
+ when :date then value.presence && Date.parse(value)
132
+ when :time then value.presence && Time.parse(value)
133
+ else
134
+ raise JsonResource::TypeCastError, "don't know how to cast #{value.inspect} to #{type}"
135
+ end
136
+ end
137
+
138
+ def cast_to_boolean(value)
139
+ if value.is_a?(String) && value.blank?
140
+ nil
141
+ else
142
+ TRUE_VALUES.include?(value)
143
+ end
144
+ end
145
+
146
+ def parse(obj)
147
+ case obj
148
+ when Hash, Array then obj
149
+ when String then JSON.parse(obj)
150
+ else
151
+ raise JsonResource::ParseError, "cannot parse #{obj.inspect}"
152
+ end
153
+ end
154
+
155
+ def inflect(string)
156
+ string = string.to_s
157
+ case inflection
158
+ when :lower_camelcase
159
+ string.camelcase(:lower)
160
+ when :upper_camelcase
161
+ string.camelcase(:upper)
162
+ when :dasherize
163
+ string.underscore.dasherize
164
+ when nil
165
+ string.underscore
166
+ else
167
+ string.public_send(inflection)
168
+ end
169
+ end
170
+
171
+ def json_dig(obj, *path)
172
+ path.inject(obj) do |receiver, key|
173
+ next nil if receiver.nil?
174
+ if key.respond_to?(:match) and idx = key.match(/\A\[(?<idx>\d+)\]\z/).try(:[], :idx)
175
+ receiver[idx.to_i]
176
+ else
177
+ receiver[key]
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ def initialize(attrs = {})
184
+ self.attributes = attrs if attrs
185
+ super()
186
+ end
187
+
188
+ def valid?
189
+ true
190
+ end
191
+
192
+ def attributes=(attrs)
193
+ attrs.each do |attr, value|
194
+ self.public_send("#{attr}=", value)
195
+ end
196
+ end
197
+
198
+ def attributes
199
+ @attributes ||= {}
200
+ end
201
+
202
+ def [](attr_name)
203
+ attributes[attr_name.to_sym]
204
+ end
205
+
206
+ def []=(attr_name, value)
207
+ attributes[attr_name.to_sym] = value
208
+ end
209
+
210
+ end
211
+ end
@@ -0,0 +1,209 @@
1
+ module JsonResource
2
+ module Refinements
3
+
4
+ refine Object do
5
+
6
+ def blank?
7
+ respond_to?(:empty?) ? !!empty? : !self
8
+ end
9
+
10
+ def present?
11
+ !blank?
12
+ end
13
+
14
+ def presence
15
+ self if present?
16
+ end
17
+
18
+ def try(*a, &b)
19
+ try!(*a, &b) if a.empty? || respond_to?(a.first)
20
+ end
21
+
22
+ def try!(*a, &b)
23
+ if a.empty? && block_given?
24
+ if b.arity == 0
25
+ instance_eval(&b)
26
+ else
27
+ yield self
28
+ end
29
+ else
30
+ public_send(*a, &b)
31
+ end
32
+ end
33
+
34
+ end
35
+
36
+ refine NilClass do
37
+
38
+ def try(*args)
39
+ nil
40
+ end
41
+
42
+ def try!(*args)
43
+ nil
44
+ end
45
+
46
+ end
47
+
48
+ refine Class do
49
+
50
+ def class_attribute(*attrs)
51
+ attrs.each do |name|
52
+ define_singleton_method(name) { nil }
53
+
54
+ ivar = "@#{name}"
55
+
56
+ define_singleton_method("#{name}=") do |val|
57
+ singleton_class.class_eval do
58
+ undef_method(name) if method_defined?(name) || private_method_defined?(name)
59
+ define_method(name) { val }
60
+ end
61
+
62
+ if singleton_class?
63
+ class_eval do
64
+ undef_method(name) if method_defined?(name) || private_method_defined?(name)
65
+ define_method(name) do
66
+ if instance_variable_defined? ivar
67
+ instance_variable_get ivar
68
+ else
69
+ singleton_class.send name
70
+ end
71
+ end
72
+ end
73
+ end
74
+ val
75
+ end
76
+
77
+ undef_method(name) if method_defined?(name) || private_method_defined?(name)
78
+ define_method(name) do
79
+ if instance_variable_defined?(ivar)
80
+ instance_variable_get ivar
81
+ else
82
+ self.class.public_send name
83
+ end
84
+ end
85
+
86
+ attr_writer name
87
+ end
88
+ end
89
+
90
+ end
91
+
92
+ refine String do
93
+
94
+ def constantize
95
+ if blank? || !include?("::")
96
+ Object.const_get(self)
97
+ else
98
+ names = split("::")
99
+
100
+ # Trigger a built-in NameError exception including the ill-formed constant in the message.
101
+ Object.const_get(self) if names.empty?
102
+
103
+ # Remove the first blank element in case of '::ClassName' notation.
104
+ names.shift if names.size > 1 && names.first.empty?
105
+
106
+ names.inject(Object) do |constant, name|
107
+ if constant == Object
108
+ constant.const_get(name)
109
+ else
110
+ candidate = constant.const_get(name)
111
+ next candidate if constant.const_defined?(name, false)
112
+ next candidate unless Object.const_defined?(name)
113
+
114
+ # Go down the ancestors to check if it is owned directly. The check
115
+ # stops when we reach Object or the end of ancestors tree.
116
+ constant = constant.ancestors.inject(constant) do |const, ancestor|
117
+ break const if ancestor == Object
118
+ break ancestor if ancestor.const_defined?(name, false)
119
+ const
120
+ end
121
+
122
+ # owner is in Object, so raise
123
+ constant.const_get(name, false)
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ def camelcase(*separators)
130
+ case separators.first
131
+ when Symbol, TrueClass, FalseClass, NilClass
132
+ first_letter = separators.shift
133
+ end
134
+
135
+ separators = ['_', '\s'] if separators.empty?
136
+
137
+ str = self.dup
138
+
139
+ separators.each do |s|
140
+ str = str.gsub(/(?:#{s}+)([a-z])/){ $1.upcase }
141
+ end
142
+
143
+ case first_letter
144
+ when :upper, true
145
+ str = str.gsub(/(\A|\s)([a-z])/){ $1 + $2.upcase }
146
+ when :lower, false
147
+ str = str.gsub(/(\A|\s)([A-Z])/){ $1 + $2.downcase }
148
+ end
149
+
150
+ str
151
+ end
152
+
153
+ def underscore
154
+ gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
155
+ gsub(/([a-z\d])([A-Z])/,'\1_\2').
156
+ tr('-', '_').
157
+ gsub(/\s/, '_').
158
+ gsub(/__+/, '_').
159
+ downcase
160
+ end
161
+
162
+ def dasherize
163
+ underscore.gsub('_', '-')
164
+ end
165
+
166
+ def pluralize
167
+ return dup if Inflections.uncountables.reverse.any? { |suffix| end_with?(suffix) }
168
+ Inflections.irregulars.reverse.each do |suffix, irregluar|
169
+ return sub(/#{Regexp.escape(suffix)}\z/, irregluar) if end_with?(suffix)
170
+ end
171
+ plural = dup
172
+ Inflections.pluralizations.reverse.each do |pattern, substitution|
173
+ return plural if plural.gsub!(pattern, substitution)
174
+ end
175
+ plural
176
+ end
177
+
178
+ def singularize
179
+ return dup if Inflections.uncountables.reverse.any? { |suffix| end_with?(suffix) }
180
+ Inflections.irregulars.reverse.each do |suffix, irregular|
181
+ return sub(/#{Regexp.escape(irregular)}\z/, suffix) if end_with?(irregular)
182
+ end
183
+ singular = dup
184
+ Inflections.singularizations.reverse.each do |pattern, substitution|
185
+ return singular if singular.gsub!(pattern, substitution)
186
+ end
187
+ singular
188
+ end
189
+
190
+ def camelize
191
+ camelcase(:upper)
192
+ end
193
+
194
+ def classify
195
+ sub(/.*\./, '').singularize.camelize
196
+ end
197
+
198
+ end
199
+
200
+ refine Hash do
201
+
202
+ def symbolize_keys
203
+ transform_keys(&:to_sym)
204
+ end
205
+
206
+ end
207
+
208
+ end
209
+ end
@@ -0,0 +1,3 @@
1
+ module JsonResource
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,9 @@
1
+ require 'bigdecimal'
2
+ require 'json'
3
+ require 'time'
4
+
5
+ require_relative 'json_resource/version'
6
+ require_relative 'json_resource/errors'
7
+ require_relative 'json_resource/inflections'
8
+ require_relative 'json_resource/refinements'
9
+ require_relative 'json_resource/model'
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: json_resource
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Matthias Grosser
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-12-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description:
28
+ email:
29
+ - mtgrosser@gmx.net
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE
36
+ - README.md
37
+ - lib/json_resource.rb
38
+ - lib/json_resource/errors.rb
39
+ - lib/json_resource/inflections.rb
40
+ - lib/json_resource/model.rb
41
+ - lib/json_resource/refinements.rb
42
+ - lib/json_resource/version.rb
43
+ homepage: https://github.com/mtgrosser/json_resource
44
+ licenses:
45
+ - MIT
46
+ metadata: {}
47
+ post_install_message:
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 2.6.0
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.1.4
63
+ signing_key:
64
+ specification_version: 4
65
+ summary: Create Ruby objects from JSON data
66
+ test_files: []