gemstack-schema 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: 83d696c90043ae45d43cf3c1a111ccdb4994074eefc92fa1ffee45b76a22099f
4
+ data.tar.gz: 3e15497f7f407e896075eee23f6b7fd571842652dfdff5fbf9ebcc69b5684a3e
5
+ SHA512:
6
+ metadata.gz: de8fd8a7fc6706af6c571004b003430a83e4fb68cc6786f7045827b0eb8f03de65f079a8195d27f0b4fe77e595e1e19309b8e4c97fdf10a7815722b9ba910173
7
+ data.tar.gz: 80904233c21486bac7a3031a162ac245686cd1c32afd76aee93710649991ec079648f6b404ad9e24ee74fa4e743bc8a0e5180f229adc9796838455c7ac74d6c4
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shoaib Malik
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,24 @@
1
+ # gemstack-schema
2
+
3
+ GemStack schema: shared types, request schemas and serializers.
4
+
5
+ Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
+ applications. All GemStack gems are developed together in that repository and released with the same
7
+ version.
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`).
21
+
22
+ ## License
23
+
24
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,251 @@
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"
@@ -0,0 +1,186 @@
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
@@ -0,0 +1,163 @@
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 (DECISIONS D-022).
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
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemstack-schema
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shoaib Malik
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bigdecimal
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '3.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: gemstack-core
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - '='
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - '='
38
+ - !ruby/object:Gem::Version
39
+ version: 0.1.0
40
+ email:
41
+ - gemstack26@gmail.com
42
+ executables: []
43
+ extensions: []
44
+ extra_rdoc_files: []
45
+ files:
46
+ - CHANGELOG.md
47
+ - LICENSE.txt
48
+ - README.md
49
+ - lib/gemstack/schema.rb
50
+ - lib/gemstack/serializer.rb
51
+ - lib/gemstack/types.rb
52
+ homepage: https://github.com/gemstack-rb/gemstack
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ rubygems_mfa_required: 'true'
57
+ source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-schema
58
+ changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-schema/CHANGELOG.md
59
+ bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
60
+ documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '4.0'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 4.0.20
76
+ specification_version: 4
77
+ summary: 'GemStack schema: shared types, request schemas and serializers'
78
+ test_files: []