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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +24 -0
- data/lib/gemstack/schema.rb +251 -0
- data/lib/gemstack/serializer.rb +186 -0
- data/lib/gemstack/types.rb +163 -0
- metadata +78 -0
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
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: []
|