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 +4 -4
- data/CHANGELOG.md +2 -26
- data/README.md +6 -16
- data/lib/gemstack-schema.rb +5 -0
- metadata +18 -23
- data/lib/gemstack/schema.rb +0 -251
- data/lib/gemstack/serializer.rb +0 -186
- data/lib/gemstack/types.rb +0 -163
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1070dd8a0c48cdf0e7b69610bb3c1a59d174063621f88afec56e055d2caf795b
|
|
4
|
+
data.tar.gz: 2409a30865825b9cb073e8ac647349eab6ca1ced42ee87917ce98b903388c916
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e7e6ae60a1248d9d4a2b2fbb3db43fd149ac760f69f0d91958f3f62d4ecc2e97105cbcf31ed111218f15999225c00c6c37d276eb863f874914503ada676ccd81
|
|
7
|
+
data.tar.gz: 1961f085af76ca45e4f3cef864d62fadd4bd68f0e62ffdca3959b569f18ab262e4dfe4451f5e6a8eac11d70e6a9b3b89b70d9d8e157719658f1d89a06401aaaa
|
data/CHANGELOG.md
CHANGED
|
@@ -1,29 +1,5 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
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
|
-
|
|
3
|
+
**Merged into [`gemstack`](https://rubygems.org/gems/gemstack) in GemStack 0.3.0.**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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:
|
|
14
|
+
name: gemstack
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version:
|
|
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:
|
|
27
|
-
-
|
|
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
|
|
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
|
|
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
|
|
59
|
-
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/
|
|
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:
|
|
73
|
+
summary: Merged into the gemstack gem — remove gemstack-schema from your Gemfile
|
|
79
74
|
test_files: []
|
data/lib/gemstack/schema.rb
DELETED
|
@@ -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"
|
data/lib/gemstack/serializer.rb
DELETED
|
@@ -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
|
data/lib/gemstack/types.rb
DELETED
|
@@ -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
|