gemstack-schema 0.2.5 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fea92113cb4c2f1c6bd382e86fa92f6e718985b36a4c6bc75790928cc8c56afe
4
- data.tar.gz: 8f16ea47ad9db40eb45f48bfdd2aeae7be4ac82a9fbfcaaccecde65e28583948
3
+ metadata.gz: 1070dd8a0c48cdf0e7b69610bb3c1a59d174063621f88afec56e055d2caf795b
4
+ data.tar.gz: 2409a30865825b9cb073e8ac647349eab6ca1ced42ee87917ce98b903388c916
5
5
  SHA512:
6
- metadata.gz: 487981983da3f2ba47ec05c30366a940f0ec6d5b43c321b9d7a12ddeb73a1048a96d9416f6ccea362e733dd26b2ffd9e060d5eaa95dce3a8f776b262dab1ba5d
7
- data.tar.gz: f6e9f61cec18886f213bcdbbe50e0a0d2d58ab68f6c7508150e1d0e4eaf2db5570c0009444fc9f86b74dca00be4d1d2f505bc60e1d2a31fe17f93466847ddf45
6
+ metadata.gz: e7e6ae60a1248d9d4a2b2fbb3db43fd149ac760f69f0d91958f3f62d4ecc2e97105cbcf31ed111218f15999225c00c6c37d276eb863f874914503ada676ccd81
7
+ data.tar.gz: 1961f085af76ca45e4f3cef864d62fadd4bd68f0e62ffdca3959b569f18ab262e4dfe4451f5e6a8eac11d70e6a9b3b89b70d9d8e157719658f1d89a06401aaaa
data/CHANGELOG.md CHANGED
@@ -1,29 +1,5 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.5
3
+ ## 0.3.0
4
4
 
5
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
6
-
7
- ## 0.2.4
8
-
9
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
10
-
11
- ## 0.2.3
12
-
13
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
14
-
15
- ## 0.2.2
16
-
17
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
18
-
19
- ## 0.2.1
20
-
21
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
22
-
23
- ## 0.2.0
24
-
25
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
26
-
27
- ## 0.1.0
28
-
29
- First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
5
+ Merged into the gemstack gem; this version is a transition shim. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/README.md CHANGED
@@ -1,23 +1,13 @@
1
1
  # gemstack-schema
2
2
 
3
- GemStack schema: shared types, request schemas and serializers.
3
+ **Merged into [`gemstack`](https://rubygems.org/gems/gemstack) in GemStack 0.3.0.**
4
4
 
5
- Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
- applications by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed together in that repository and released with the same
7
- version.
5
+ This version is a transition shim: it depends on `gemstack` and loads `gemstack/schema`, so
6
+ Gemfiles that still list `gemstack-schema` keep working. To finish upgrading, remove `gem "gemstack-schema"`
7
+ from your Gemfile (it's loaded by `require "gemstack"`).
8
8
 
9
- ## Installation
10
-
11
- Installed with the `gemstack` gem; you rarely need to add it yourself.
12
-
13
- ## Documentation
14
-
15
- - [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/validation.md)
16
- - [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
17
- [Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
18
-
19
- Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
20
- (this gem lives in `gems/gemstack-schema`).
9
+ GemStack is a modular Ruby API framework for Next.js applications by
10
+ [Adware Technologies](https://www.adwaretech.com) — [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack).
21
11
 
22
12
  ## License
23
13
 
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # gemstack-schema is part of the gemstack gem since 0.3.0. This shim keeps Gemfiles
4
+ # that still list gemstack-schema working; remove the line from your Gemfile.
5
+ require "gemstack/schema"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemstack-schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adware Technologies
@@ -11,33 +11,28 @@ cert_chain: []
11
11
  date: 1980-01-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: bigdecimal
14
+ name: gemstack
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '3.1'
19
+ version: 0.3.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '1.0'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
27
  - - ">="
25
28
  - !ruby/object:Gem::Version
26
- version: '3.1'
27
- - !ruby/object:Gem::Dependency
28
- name: gemstack-core
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - '='
32
- - !ruby/object:Gem::Version
33
- version: 0.2.5
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - '='
29
+ version: 0.3.0
30
+ - - "<"
39
31
  - !ruby/object:Gem::Version
40
- version: 0.2.5
32
+ version: '1.0'
33
+ description: Since GemStack 0.3.0, gemstack-schema is part of the gemstack gem. This
34
+ version only depends on gemstack and loads gemstack/schema, so Gemfiles that still
35
+ list it keep working.
41
36
  email:
42
37
  - gemstack26@gmail.com
43
38
  executables: []
@@ -47,18 +42,18 @@ files:
47
42
  - CHANGELOG.md
48
43
  - LICENSE.txt
49
44
  - README.md
50
- - lib/gemstack/schema.rb
51
- - lib/gemstack/serializer.rb
52
- - lib/gemstack/types.rb
45
+ - lib/gemstack-schema.rb
53
46
  homepage: https://github.com/gemstack-rb/gemstack
54
47
  licenses:
55
48
  - MIT
56
49
  metadata:
57
50
  rubygems_mfa_required: 'true'
58
- source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-schema
59
- changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-schema/CHANGELOG.md
51
+ source_code_uri: https://github.com/gemstack-rb/gemstack
52
+ changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md
60
53
  bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
61
54
  documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
55
+ post_install_message: gemstack-schema is now part of the gemstack gem. Remove `gem
56
+ "gemstack-schema"` from your Gemfile (it's loaded by `require "gemstack"`).
62
57
  rdoc_options: []
63
58
  require_paths:
64
59
  - lib
@@ -75,5 +70,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
75
70
  requirements: []
76
71
  rubygems_version: 4.0.20
77
72
  specification_version: 4
78
- summary: 'GemStack schema: shared types, request schemas and serializers'
73
+ summary: Merged into the gemstack gem — remove gemstack-schema from your Gemfile
79
74
  test_files: []
@@ -1,251 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "gemstack/core"
4
- require_relative "types"
5
-
6
- module GemStack
7
- # Declarative input validation and coercion.
8
- #
9
- # class ProductInput < GemStack::Schema
10
- # required :name, :string, max_length: 120
11
- # required :price, :decimal, gt: 0
12
- # optional :active, :boolean, default: true
13
- # optional :tags, [:string]
14
- # optional :dimensions do
15
- # required :width, :integer
16
- # end
17
- # end
18
- #
19
- # ProductInput.call(params) # => { name: "Lamp", price: 0.999e1, active: true }
20
- # # or raises GemStack::ValidationError (422) with
21
- # # errors: { "price" => ["must be greater than 0"] }
22
- #
23
- # - Output has symbol keys and only declared fields (an allow-list).
24
- # - Values are coerced to their type ("12" → 12 for :integer).
25
- # - For non-string types an empty string counts as "not given" (HTML forms).
26
- # - A required string that is blank is reported as "is required".
27
- # - Error keys are paths: "dimensions.width", "tags.2".
28
- class Schema
29
- Field = Struct.new(:name, :type, :required, :nullable, :default, :rules, :schema, :array, keyword_init: true) do
30
- def default? = !default.equal?(NO_DEFAULT)
31
- def default_value = default.respond_to?(:call) ? default.call : default
32
- def ts_optional? = !required && !default?
33
- end
34
-
35
- NO_DEFAULT = Object.new.freeze
36
- RULES = %i[gt gte lt lte min_length max_length in format].freeze
37
- STRING_TYPES = %i[string text].freeze
38
-
39
- # rule => [passes?(value, arg), message(arg)]
40
- RULE_CHECKS = {
41
- gt: [->(v, a) { v > a }, ->(a) { "must be greater than #{a}" }],
42
- gte: [->(v, a) { v >= a }, ->(a) { "must be greater than or equal to #{a}" }],
43
- lt: [->(v, a) { v < a }, ->(a) { "must be less than #{a}" }],
44
- lte: [->(v, a) { v <= a }, ->(a) { "must be less than or equal to #{a}" }],
45
- min_length: [->(v, a) { v.to_s.length >= a }, ->(a) { "is too short (minimum #{a} characters)" }],
46
- max_length: [->(v, a) { v.to_s.length <= a }, ->(a) { "is too long (maximum #{a} characters)" }],
47
- in: [->(v, a) { a.include?(v) }, ->(a) { "must be one of: #{a.to_a.join(", ")}" }],
48
- format: [->(v, a) { a.match?(v.to_s) }, ->(_) { "is invalid" }]
49
- }.freeze
50
-
51
- class << self
52
- def fields
53
- @fields ||= superclass.respond_to?(:fields) ? superclass.fields.dup : {}
54
- end
55
-
56
- # Name used for this schema's TypeScript/OpenAPI type.
57
- attr_writer :type_name
58
-
59
- def type_name
60
- @type_name || name&.split("::")&.join
61
- end
62
-
63
- def required(name, type = nil, **options, &) = add_field(name, type, true, options, &)
64
- def optional(name, type = nil, **options, &) = add_field(name, type, false, options, &)
65
-
66
- # An anonymous schema from a block: Schema.define { required :q, :string }
67
- def define(type_name = nil, &)
68
- Class.new(self) do
69
- self.type_name = type_name
70
- class_exec(&)
71
- end
72
- end
73
-
74
- # Every field optional and without defaults — for PATCH-style updates.
75
- def partial(type_name = nil)
76
- source = self
77
- Class.new(Schema) do
78
- self.type_name = type_name
79
- source.fields.each_value do |field|
80
- fields[field.name] = field.dup.tap do |copy|
81
- copy.required = false
82
- copy.default = NO_DEFAULT
83
- end
84
- end
85
- end
86
- end
87
-
88
- # A schema derived from a model's field declarations (anything that
89
- # responds to #gemstack_fields → { name => field with #type and #options }).
90
- # Fields that are NOT NULL without a default become required.
91
- def from_model(model, only: nil, except: nil, type_name: nil)
92
- declared = model.gemstack_fields
93
- names = only ? Array(only).map(&:to_sym) : declared.keys
94
- names -= Array(except).map(&:to_sym)
95
- define(type_name) do
96
- names.each do |name|
97
- field = declared.fetch(name) { raise ArgumentError, "#{model} has no field #{name.inspect}" }
98
- opts = field.options
99
- rules = opts.slice(*RULES)
100
- rules[:max_length] ||= opts[:size] if opts[:size].is_a?(Integer)
101
- required = opts[:null] == false && !opts.key?(:default) && field.type != :boolean
102
- add_field(name, field.type, required, rules.merge(nullable: opts[:null] != false))
103
- end
104
- end
105
- end
106
-
107
- def call(input)
108
- errors = {}
109
- result = coerce_object(input, nil, errors)
110
- raise ValidationError.new(errors: errors) unless errors.empty?
111
-
112
- result
113
- end
114
-
115
- # Like #call but returns [result, errors] instead of raising.
116
- def validate(input)
117
- errors = {}
118
- result = coerce_object(input, nil, errors)
119
- [errors.empty? ? result : nil, errors]
120
- end
121
-
122
- def coerce_object(input, path, errors)
123
- input = normalize_input(input)
124
- unless input.is_a?(Hash)
125
- errors[path || "base"] = ["must be an object"]
126
- return nil
127
- end
128
-
129
- fields.each_value.with_object({}) do |field, output|
130
- coerce_field(field, input, path, output, errors)
131
- end
132
- end
133
-
134
- private
135
-
136
- def add_field(name, type, required, options, &block)
137
- options = options.dup
138
- nullable = options.delete(:nullable) || false
139
- default = options.key?(:default) ? options.delete(:default) : NO_DEFAULT
140
- unknown = options.keys - RULES
141
- raise ArgumentError, "unknown option(s) #{unknown.inspect} for #{name}" unless unknown.empty?
142
-
143
- array = false
144
- schema = nil
145
- if type.is_a?(Array)
146
- array = true
147
- type = type.first
148
- elsif type == :array
149
- array = true
150
- type = nil
151
- end
152
- if block
153
- schema = Schema.define(&block)
154
- type = nil
155
- elsif type.is_a?(Class) && type <= Schema
156
- schema = type
157
- type = nil
158
- elsif type.nil?
159
- raise ArgumentError, "#{name}: give a type (e.g. :string) or a block"
160
- else
161
- Types.fetch(type) # validate early
162
- type = Types::CLASS_ALIASES.fetch(type, type).to_sym
163
- end
164
-
165
- fields[name.to_sym] = Field.new(name: name.to_sym, type: type, required: required, nullable: nullable,
166
- default: default, rules: options.freeze, schema: schema, array: array)
167
- end
168
-
169
- # absent → default or omitted; required + absent/nil/blank → "is required";
170
- # explicit null → nil when nullable, otherwise "can't be null".
171
- def coerce_field(field, input, path, output, errors)
172
- key = field.name.to_s
173
- field_path = path ? "#{path}.#{key}" : key
174
- value = input[key]
175
- present = input.key?(key) && !empty_form_value?(field, value)
176
-
177
- if field.required && (!present || value.nil? || blank_string?(value))
178
- missing_required(field, present, field_path, output, errors)
179
- elsif !present
180
- output[field.name] = field.default_value if field.default?
181
- else
182
- coerce_present(field, value, field_path, output, errors)
183
- end
184
- end
185
-
186
- def coerce_present(field, value, path, output, errors)
187
- if value.nil?
188
- field.nullable ? output[field.name] = nil : (errors[path] ||= []) << "can't be null"
189
- else
190
- coerced = coerce_value(field, value, path, errors)
191
- output[field.name] = coerced unless coerced.equal?(NO_DEFAULT)
192
- end
193
- end
194
-
195
- def missing_required(field, present, path, output, errors)
196
- return output[field.name] = field.default_value if field.default? && !present
197
-
198
- (errors[path] ||= []) << "is required"
199
- end
200
-
201
- def coerce_value(field, value, path, errors)
202
- return coerce_array(field, value, path, errors) if field.array
203
-
204
- coerce_single(field, value, path, errors)
205
- end
206
-
207
- def coerce_array(field, value, path, errors)
208
- value = value.values if value.is_a?(Hash) && value.keys.all? { |k| k.to_s.match?(/\A\d+\z/) } # form arrays
209
- unless value.is_a?(Array)
210
- (errors[path] ||= []) << "must be a list"
211
- return NO_DEFAULT
212
- end
213
-
214
- value.each_with_index.map { |item, index| coerce_single(field, item, "#{path}.#{index}", errors) }
215
- end
216
-
217
- def coerce_single(field, value, path, errors)
218
- return field.schema.coerce_object(value, path, errors) if field.schema
219
-
220
- coerced = Types.fetch(field.type).coerce(value)
221
- check_rules(field.rules, coerced, path, errors)
222
- coerced
223
- rescue Types::CoercionError => e
224
- (errors[path] ||= []) << e.message
225
- NO_DEFAULT
226
- end
227
-
228
- def check_rules(rules, value, path, errors)
229
- messages = rules.filter_map { |rule, arg| rule_message(rule, arg, value) }
230
- (errors[path] ||= []).concat(messages) unless messages.empty?
231
- end
232
-
233
- def rule_message(rule, arg, value)
234
- check, message = RULE_CHECKS.fetch(rule)
235
- check.call(value, arg) ? nil : message.call(arg)
236
- end
237
-
238
- def normalize_input(input)
239
- input = input.to_unsafe_h if input.respond_to?(:to_unsafe_h) # GemStack::Params
240
- input.is_a?(Hash) ? input.transform_keys(&:to_s) : input
241
- end
242
-
243
- def blank_string?(value) = value.is_a?(String) && value.strip.empty?
244
-
245
- # An empty form input for a non-string field means "not given".
246
- def empty_form_value?(field, value) = value == "" && !STRING_TYPES.include?(field.type)
247
- end
248
- end
249
- end
250
-
251
- require_relative "serializer"
@@ -1,186 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- # Explicit, typed JSON representations. Nothing is exposed unless listed.
5
- #
6
- # class ProductSerializer < GemStack::Serializer
7
- # attributes :id, :name, :price, :active, :created_at # types inferred from Product's fields
8
- # attribute :display_price, :string do |product|
9
- # "€#{product.price}"
10
- # end
11
- # attribute :category, CategorySerializer # nested
12
- # attribute :reviews, [ReviewSerializer] # nested list
13
- # end
14
- #
15
- # ProductSerializer.serialize(product) # => { id: 1, name: "Lamp", price: "9.99", ... }
16
- # ProductSerializer.many(products) # => [ {...}, ... ]
17
- #
18
- # Values are dumped by type: decimals become strings, times ISO 8601 UTC.
19
- # Types come from an explicit type argument, else from the model's field
20
- # declarations (the model is found by name: ProductSerializer → Product),
21
- # and drive the generated TypeScript interfaces.
22
- class Serializer
23
- Attribute = Struct.new(:name, :type, :block, :nullable, keyword_init: true) do
24
- def serializer? = type.is_a?(Class) && type <= Serializer
25
- def list? = type.is_a?(Array)
26
- end
27
-
28
- CONVENTIONAL_TYPES = { id: [:bigint, false], created_at: [:datetime, false], updated_at: [:datetime, false] }.freeze
29
-
30
- class << self
31
- def attributes_list
32
- @attributes_list ||= superclass.respond_to?(:attributes_list) ? superclass.attributes_list.dup : {}
33
- end
34
-
35
- # attributes :id, :name (types inferred)
36
- # attributes name: :string (explicit)
37
- def attributes(*names, **typed)
38
- names.each { |name| attribute(name) }
39
- typed.each { |name, type| attribute(name, type) }
40
- end
41
-
42
- def attribute(name, type = nil, nullable: nil, &block)
43
- if type && !type.is_a?(Array) && !(type.is_a?(Class) && type <= Serializer)
44
- type = Types::CLASS_ALIASES.fetch(type, type).to_sym
45
- Types.fetch(type)
46
- end
47
- attributes_list[name.to_sym] = Attribute.new(name: name.to_sym, type: type, block: block, nullable: nullable)
48
- @resolved_attributes = nil
49
- end
50
-
51
- # The model used for type inference. Defaults to the class named like
52
- # the serializer without "Serializer" (Admin::ProductSerializer → Admin::Product, then Product).
53
- def model(klass = nil)
54
- @model = klass if klass
55
- return @model if defined?(@model) && @model
56
-
57
- base = name&.delete_suffix("Serializer")
58
- return nil if base.nil? || base.empty?
59
-
60
- [base, base.split("::").last].uniq.each do |candidate|
61
- constant = Object.const_get(candidate) if Object.const_defined?(candidate)
62
- return constant if constant.respond_to?(:gemstack_fields)
63
- rescue NameError
64
- next
65
- end
66
- nil
67
- end
68
-
69
- attr_writer :type_name
70
-
71
- # TypeScript/OpenAPI name: ProductSerializer → "Product", Admin::ProductSerializer → "AdminProduct".
72
- def type_name
73
- @type_name || (name && name.delete_suffix("Serializer").split("::").join)
74
- end
75
-
76
- # Attributes with resolved types: [{name:, type:, nullable:}], type being
77
- # a type Symbol, a Serializer class, or [Serializer]/[:type] for lists.
78
- def resolved_attributes
79
- @resolved_attributes ||= attributes_list.values.map do |attr|
80
- type, nullable = infer(attr)
81
- { name: attr.name, type: type, nullable: attr.nullable.nil? ? nullable : attr.nullable }
82
- end.freeze
83
- end
84
-
85
- # [[name, block_or_nil, dumper_or_nil], ...] — types resolved to
86
- # dumpers once, so serializing an object does no type lookups.
87
- def compiled
88
- @compiled ||= resolved_attributes.map do |attr|
89
- [attr[:name], attributes_list[attr[:name]].block, dumper_for(attr[:type])]
90
- end.freeze
91
- end
92
-
93
- def serialize(object, context = {})
94
- object.nil? ? nil : new(object, context).to_h
95
- end
96
-
97
- def many(objects, context = {})
98
- objects = objects.all if objects.respond_to?(:all) && !objects.is_a?(Array) # Sequel datasets
99
- objects.map { |object| new(object, context).to_h }
100
- end
101
-
102
- # Convention-based rendering, shared by controllers (`render`) and
103
- # realtime (`GemStack.broadcast`): an object of class Product uses
104
- # ProductSerializer, arrays and datasets of them too; plain JSON values
105
- # and objects without a serializer pass through unchanged.
106
- def render(value, context = {})
107
- case value
108
- when Hash, String, Numeric, Symbol, true, false, nil then value
109
- when Array
110
- serializer = value.first && self.for(value.first.class)
111
- serializer ? serializer.many(value, context) : value
112
- else
113
- if value.respond_to?(:model) && value.respond_to?(:all) # a dataset / query
114
- serializer = self.for(value.model)
115
- return serializer ? serializer.many(value, context) : value.all
116
- end
117
- serializer = self.for(value.class)
118
- serializer ? serializer.serialize(value, context) : value
119
- end
120
- end
121
-
122
- # Defaults for convention-based lookup: ProductSerializer for Product.
123
- def for(object_class)
124
- return nil unless object_class.name
125
-
126
- name = "#{object_class.name}Serializer"
127
- Object.const_defined?(name) ? Object.const_get(name) : nil
128
- rescue NameError
129
- nil
130
- end
131
-
132
- private
133
-
134
- # A lambda (value, context) -> JSON value, or nil when values pass through.
135
- def dumper_for(type)
136
- case type
137
- when Array
138
- item = dumper_for(type.first)
139
- return nil unless item
140
-
141
- ->(values, ctx) { values.map { |v| v.nil? ? nil : item.call(v, ctx) } }
142
- when Symbol then scalar_dumper(Types.fetch(type))
143
- else ->(value, ctx) { type.serialize(value, ctx) } # nested serializer
144
- end
145
- end
146
-
147
- def scalar_dumper(type)
148
- if %i[string text].include?(type.name)
149
- ->(value, _) { value.is_a?(String) ? value : type.coerce(value) }
150
- elsif type.dumper
151
- dump = type.dumper
152
- ->(value, _) { dump.call(value) }
153
- end
154
- end
155
-
156
- # Explicit types are non-null unless declared nullable or the model's
157
- # field allows null; inferred types follow the model's field.
158
- def infer(attr)
159
- field = model&.gemstack_fields&.[](attr.name)
160
- return [attr.type, field ? field.options[:null] != false : false] if attr.type
161
- return [field.type, field.options[:null] != false] if field
162
-
163
- CONVENTIONAL_TYPES.fetch(attr.name) { [:json, true] }
164
- end
165
- end
166
-
167
- attr_reader :object, :context
168
-
169
- def initialize(object, context = {})
170
- @object = object
171
- @context = context
172
- end
173
-
174
- # Hot path: iterates the plan compiled once per serializer class
175
- # (docs/performance.md — this loop dominates JSON response time).
176
- def to_h
177
- hash = {}
178
- self.class.compiled.each do |name, block, dumper|
179
- value = block ? instance_exec(object, &block) : object.public_send(name)
180
- hash[name] = value.nil? || dumper.nil? ? value : dumper.call(value, context)
181
- end
182
- hash
183
- end
184
- alias as_json to_h
185
- end
186
- end
@@ -1,163 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
- require "date"
5
- require "time"
6
-
7
- module GemStack
8
- # The type system shared by model fields, request schemas, serializers and
9
- # the TypeScript/OpenAPI generators — one name per concept everywhere:
10
- #
11
- # field :price, :decimal (model)
12
- # required :price, :decimal (request schema)
13
- # attribute :price, :decimal (serializer)
14
- # price: string (generated TypeScript)
15
- #
16
- # Each type knows how to coerce input (strings from forms and query strings,
17
- # JSON values), how to dump values into JSON, and its TypeScript / OpenAPI
18
- # representation. Register your own with Types.register.
19
- module Types
20
- # Raised by a coercer when a value can't be converted; the message is the
21
- # client-facing validation message ("must be an integer").
22
- class CoercionError < StandardError; end
23
-
24
- Type = Struct.new(:name, :ts, :openapi, :coercer, :dumper, keyword_init: true) do
25
- def coerce(value) = coercer ? coercer.call(value) : value
26
- def dump(value) = value.nil? || dumper.nil? ? value : dumper.call(value)
27
- end
28
-
29
- INTEGER = /\A[-+]?\d+\z/
30
- NUMBER = /\A[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?\z/
31
- UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/i
32
- TRUE_VALUES = [true, 1, "1", "true", "TRUE", "True", "on", "yes"].freeze
33
- FALSE_VALUES = [false, 0, "0", "false", "FALSE", "False", "off", "no"].freeze
34
-
35
- CLASS_ALIASES = {
36
- String => :string, Integer => :integer, Float => :float, BigDecimal => :decimal,
37
- TrueClass => :boolean, FalseClass => :boolean, Date => :date, Time => :datetime,
38
- DateTime => :datetime, Hash => :json
39
- }.freeze
40
-
41
- @registry = {}
42
-
43
- class << self
44
- def register(name, ts:, openapi:, coerce: nil, dump: nil)
45
- @registry[name.to_sym] = Type.new(name: name.to_sym, ts: ts, openapi: openapi.freeze,
46
- coercer: coerce, dumper: dump).freeze
47
- end
48
-
49
- # Accepts a type name (:string) or a Ruby class (String, Integer, ...).
50
- def fetch(name)
51
- key = CLASS_ALIASES.fetch(name, name)
52
- @registry.fetch(key.to_sym) do
53
- raise ArgumentError, "unknown type #{name.inspect}; known types: #{@registry.keys.join(", ")}"
54
- end
55
- end
56
-
57
- def type?(name)
58
- CLASS_ALIASES.key?(name) || ((name.is_a?(Symbol) || name.is_a?(String)) && @registry.key?(name.to_sym))
59
- end
60
-
61
- def names = @registry.keys
62
-
63
- def fail!(message) = raise(CoercionError, message)
64
-
65
- def to_integer(value)
66
- case value
67
- when Integer then value
68
- when Float then value == value.floor ? value.to_i : fail!("must be an integer")
69
- when String then value.strip.match?(INTEGER) ? Integer(value.strip, 10) : fail!("must be an integer")
70
- else fail!("must be an integer")
71
- end
72
- end
73
-
74
- def to_float(value)
75
- case value
76
- when Float then value
77
- when Numeric then value.to_f
78
- when String then value.strip.match?(NUMBER) ? Float(value.strip) : fail!("must be a number")
79
- else fail!("must be a number")
80
- end
81
- end
82
-
83
- def to_decimal(value)
84
- case value
85
- when BigDecimal then value
86
- when Integer then BigDecimal(value)
87
- when Float then BigDecimal(value.to_s)
88
- when String then value.strip.match?(NUMBER) ? BigDecimal(value.strip) : fail!("must be a decimal number")
89
- else fail!("must be a decimal number")
90
- end
91
- end
92
-
93
- def to_boolean(value)
94
- return true if TRUE_VALUES.include?(value)
95
- return false if FALSE_VALUES.include?(value)
96
-
97
- fail!("must be true or false")
98
- end
99
-
100
- def to_date(value)
101
- case value
102
- when DateTime, Time then value.to_date
103
- when Date then value
104
- when String then Date.iso8601(value.strip)
105
- else fail!("must be a date (YYYY-MM-DD)")
106
- end
107
- rescue ArgumentError # includes Date::Error
108
- fail!("must be a date (YYYY-MM-DD)")
109
- end
110
-
111
- def to_datetime(value)
112
- case value
113
- when Time then value
114
- when DateTime then value.to_time
115
- when String then parse_time(value.strip)
116
- else fail!("must be a date-time (ISO 8601)")
117
- end
118
- end
119
-
120
- private
121
-
122
- # ISO 8601 with a zone, or a zone-less "YYYY-MM-DDTHH:MM[:SS]" (as sent
123
- # by <input type="datetime-local">), which is read as UTC.
124
- def parse_time(string)
125
- Time.iso8601(string)
126
- rescue ArgumentError
127
- match = string.match(/\A(\d{4})-(\d\d)-(\d\d)[T ](\d\d):(\d\d)(?::(\d\d))?\z/)
128
- fail!("must be a date-time (ISO 8601)") unless match
129
- Time.utc(*match.captures.compact.map(&:to_i))
130
- end
131
- end
132
-
133
- string = lambda do |value|
134
- case value
135
- when String then value
136
- when Symbol, Numeric then value.to_s
137
- else fail!("must be a string")
138
- end
139
- end
140
- iso_time = ->(value) { (value.respond_to?(:to_time) ? value.to_time : value).utc.iso8601(3) }
141
-
142
- register :string, ts: "string", openapi: { type: "string" }, coerce: string
143
- register :text, ts: "string", openapi: { type: "string" }, coerce: string
144
- register :integer, ts: "number", openapi: { type: "integer" }, coerce: method(:to_integer)
145
- register :bigint, ts: "number", openapi: { type: "integer", format: "int64" }, coerce: method(:to_integer)
146
- register :references, ts: "number", openapi: { type: "integer", format: "int64" }, coerce: method(:to_integer)
147
- register :float, ts: "number", openapi: { type: "number" }, coerce: method(:to_float)
148
- # Decimals travel as strings so no precision is lost in JavaScript.
149
- register :decimal, ts: "string", openapi: { type: "string", format: "decimal" },
150
- coerce: method(:to_decimal),
151
- dump: ->(value) { (value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)).to_s("F") }
152
- register :boolean, ts: "boolean", openapi: { type: "boolean" }, coerce: method(:to_boolean)
153
- register :date, ts: "string", openapi: { type: "string", format: "date" },
154
- coerce: method(:to_date), dump: lambda(&:iso8601)
155
- register :datetime, ts: "string", openapi: { type: "string", format: "date-time" },
156
- coerce: method(:to_datetime), dump: iso_time
157
- register :uuid, ts: "string", openapi: { type: "string", format: "uuid" },
158
- coerce: lambda { |value|
159
- value.is_a?(String) && value.match?(UUID) ? value.downcase : fail!("must be a UUID")
160
- }
161
- register :json, ts: "unknown", openapi: {}
162
- end
163
- end