action_spec 1.2.0 → 1.4.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/README.md +125 -79
- data/lib/action_spec/configuration.rb +3 -1
- data/lib/action_spec/doc/dsl.rb +59 -31
- data/lib/action_spec/doc/endpoint.rb +87 -6
- data/lib/action_spec/doc.rb +14 -7
- data/lib/action_spec/open_api/generator.rb +19 -2
- data/lib/action_spec/open_api/operation.rb +22 -3
- data/lib/action_spec/open_api/schema.rb +141 -4
- data/lib/action_spec/railtie.rb +4 -0
- data/lib/action_spec/schema/array_of.rb +1 -1
- data/lib/action_spec/schema/base.rb +9 -4
- data/lib/action_spec/schema/field.rb +44 -3
- data/lib/action_spec/schema/object_of.rb +2 -2
- data/lib/action_spec/schema/resolver.rb +27 -4
- data/lib/action_spec/schema/scalar.rb +2 -2
- data/lib/action_spec/schema/type_caster.rb +1 -1
- data/lib/action_spec/schema.rb +68 -11
- data/lib/action_spec/validator/runner.rb +17 -1
- data/lib/action_spec/version.rb +1 -1
- data/lib/tasks/action_spec_tasks.rake +4 -1
- metadata +1 -1
data/lib/action_spec/doc.rb
CHANGED
|
@@ -8,6 +8,8 @@ module ActionSpec
|
|
|
8
8
|
extend ActiveSupport::Concern
|
|
9
9
|
|
|
10
10
|
class_methods do
|
|
11
|
+
DryEntry = Struct.new(:block, :options, keyword_init: true)
|
|
12
|
+
|
|
11
13
|
def action_specs
|
|
12
14
|
@action_specs ||= begin
|
|
13
15
|
parent = superclass.respond_to?(:action_specs) ? superclass.action_specs : {}
|
|
@@ -18,20 +20,24 @@ module ActionSpec
|
|
|
18
20
|
def dry_blocks
|
|
19
21
|
@dry_blocks ||= begin
|
|
20
22
|
parent = superclass.respond_to?(:dry_blocks) ? superclass.dry_blocks : {}
|
|
21
|
-
parent.transform_values
|
|
23
|
+
parent.transform_values do |entries|
|
|
24
|
+
entries.map { |entry| DryEntry.new(block: entry.block, options: entry.options.deep_dup) }
|
|
25
|
+
end
|
|
22
26
|
end
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
def doc(action_or_summary = nil, summary = nil, **options, &block)
|
|
26
30
|
action_name, endpoint_summary = normalize_doc_arguments(action_or_summary, summary)
|
|
27
31
|
action_name ||= infer_action_name(caller_locations(1, 1).first)
|
|
28
|
-
endpoint = Endpoint.new(action_name, summary: endpoint_summary, options:)
|
|
29
|
-
|
|
32
|
+
endpoint = Endpoint.new(action_name, summary: endpoint_summary, options: {})
|
|
33
|
+
endpoint = apply_dry_blocks(endpoint)
|
|
34
|
+
endpoint.options.merge!(options)
|
|
35
|
+
action_specs[action_name.to_sym] = endpoint.apply(block || proc {})
|
|
30
36
|
end
|
|
31
37
|
|
|
32
|
-
def doc_dry(actions = :all, &block)
|
|
38
|
+
def doc_dry(actions = :all, **options, &block)
|
|
33
39
|
Array(actions).each do |action|
|
|
34
|
-
(dry_blocks[action.to_sym] ||= []) << block
|
|
40
|
+
(dry_blocks[action.to_sym] ||= []) << DryEntry.new(block:, options:)
|
|
35
41
|
end
|
|
36
42
|
end
|
|
37
43
|
alias api_dry doc_dry
|
|
@@ -49,8 +55,9 @@ module ActionSpec
|
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
def apply_dry_blocks(endpoint)
|
|
52
|
-
[*dry_blocks[:all], *dry_blocks[endpoint.action]].compact.each do |
|
|
53
|
-
endpoint.
|
|
58
|
+
[*dry_blocks[:all], *dry_blocks[endpoint.action]].compact.each do |entry|
|
|
59
|
+
endpoint.options.merge!(entry.options)
|
|
60
|
+
endpoint.apply(entry.block) if entry.block
|
|
54
61
|
end
|
|
55
62
|
endpoint
|
|
56
63
|
end
|
|
@@ -8,8 +8,25 @@ module ActionSpec
|
|
|
8
8
|
document = new(application:, routes:, title:, version:, server_url:).call
|
|
9
9
|
|
|
10
10
|
FileUtils.mkdir_p(File.dirname(output))
|
|
11
|
-
File.write(output,
|
|
11
|
+
File.write(output, pretty_yaml(plain_data(document)))
|
|
12
12
|
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def pretty_yaml(document)
|
|
17
|
+
YAML.dump(document).gsub(/^(\s*)"\/([^"]+)":$/, '\1/\2:')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def plain_data(value)
|
|
21
|
+
case value
|
|
22
|
+
when Array
|
|
23
|
+
value.map { |item| plain_data(item) }
|
|
24
|
+
when Hash
|
|
25
|
+
value.each_with_object({}) { |(key, item), hash| hash[key] = plain_data(item) }
|
|
26
|
+
else
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
13
30
|
end
|
|
14
31
|
|
|
15
32
|
def initialize(application: nil, routes: nil, title: nil, version: nil, server_url: nil)
|
|
@@ -55,7 +72,7 @@ module ActionSpec
|
|
|
55
72
|
|
|
56
73
|
hash[path] ||= ActiveSupport::OrderedHash.new
|
|
57
74
|
route_verbs(route).each do |verb|
|
|
58
|
-
hash[path][verb] = Operation.new(endpoint).build
|
|
75
|
+
hash[path][verb] = Operation.new(endpoint, controller_path: controller.controller_path).build
|
|
59
76
|
end
|
|
60
77
|
end
|
|
61
78
|
end
|
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
module ActionSpec
|
|
4
4
|
module OpenApi
|
|
5
5
|
class Operation
|
|
6
|
-
def initialize(endpoint)
|
|
6
|
+
def initialize(endpoint, controller_path:)
|
|
7
7
|
@endpoint = endpoint
|
|
8
|
+
@controller_path = controller_path
|
|
8
9
|
@schema = Schema.new
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def build
|
|
12
13
|
{
|
|
13
14
|
"summary" => endpoint.summary.presence,
|
|
15
|
+
"operationId" => operation_id,
|
|
16
|
+
"tags" => tags,
|
|
14
17
|
"parameters" => parameters.presence,
|
|
15
18
|
"requestBody" => schema.request_body(endpoint.request),
|
|
16
19
|
"responses" => responses
|
|
@@ -19,7 +22,23 @@ module ActionSpec
|
|
|
19
22
|
|
|
20
23
|
private
|
|
21
24
|
|
|
22
|
-
attr_reader :endpoint, :schema
|
|
25
|
+
attr_reader :endpoint, :controller_path, :schema
|
|
26
|
+
|
|
27
|
+
def tags
|
|
28
|
+
[endpoint.options[:tag].presence || controller_path.presence].compact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def operation_id
|
|
32
|
+
[primary_tag, endpoint.action].compact.join("_")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def primary_tag
|
|
36
|
+
resolved_tag&.to_s&.tr("/", "_")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolved_tag
|
|
40
|
+
endpoint.options[:tag].presence || controller_path.presence
|
|
41
|
+
end
|
|
23
42
|
|
|
24
43
|
def parameters
|
|
25
44
|
%i[path query header cookie].flat_map do |location|
|
|
@@ -33,7 +52,7 @@ module ActionSpec
|
|
|
33
52
|
return { "200" => { "description" => "OK" } } if endpoint.responses.empty?
|
|
34
53
|
|
|
35
54
|
endpoint.responses.each_with_object(ActiveSupport::OrderedHash.new) do |(code, response), hash|
|
|
36
|
-
hash[code] =
|
|
55
|
+
hash[code] = schema.response(response)
|
|
37
56
|
end
|
|
38
57
|
end
|
|
39
58
|
end
|
|
@@ -36,7 +36,16 @@ module ActionSpec
|
|
|
36
36
|
end
|
|
37
37
|
return if content.empty?
|
|
38
38
|
|
|
39
|
-
{ "content" => content }
|
|
39
|
+
{ "content" => content }.tap do |body|
|
|
40
|
+
body["required"] = true if request.body_required?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def response(response)
|
|
45
|
+
{
|
|
46
|
+
"description" => response.description.presence || "OK",
|
|
47
|
+
"content" => response_content(response).presence
|
|
48
|
+
}.compact
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
def schema_for(schema)
|
|
@@ -50,6 +59,29 @@ module ActionSpec
|
|
|
50
59
|
|
|
51
60
|
private
|
|
52
61
|
|
|
62
|
+
def response_content(response)
|
|
63
|
+
response.media_types.each_with_object(ActiveSupport::OrderedHash.new) do |(media_type, content), hash|
|
|
64
|
+
normalized = response_media_type_content(content)
|
|
65
|
+
next if normalized.blank?
|
|
66
|
+
|
|
67
|
+
hash[MEDIA_TYPE_MAP.fetch(media_type, media_type.to_s)] = normalized
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def response_media_type_content(content)
|
|
72
|
+
{}.tap do |definition|
|
|
73
|
+
if (schema = content[:schema] || infer_schema_from_examples(content)).present?
|
|
74
|
+
definition["schema"] = schema_for(schema)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if (examples = normalize_response_examples(content[:examples])).present?
|
|
78
|
+
definition["examples"] = examples
|
|
79
|
+
elsif (example = normalize_response_example(content[:example])).present?
|
|
80
|
+
definition["example"] = example
|
|
81
|
+
end
|
|
82
|
+
end.presence
|
|
83
|
+
end
|
|
84
|
+
|
|
53
85
|
def parameter_name(field, location)
|
|
54
86
|
return field.name.to_s if location != :header
|
|
55
87
|
|
|
@@ -105,16 +137,23 @@ module ActionSpec
|
|
|
105
137
|
|
|
106
138
|
def apply_common_options(definition, schema)
|
|
107
139
|
definition["description"] = schema.description if schema.description.present?
|
|
108
|
-
definition
|
|
140
|
+
apply_literal_option(definition, "default", schema.default) unless schema.default.respond_to?(:call)
|
|
109
141
|
definition["enum"] = schema.enum if schema.enum.present?
|
|
110
142
|
definition["pattern"] = regex_source(schema.pattern) if schema.pattern.present?
|
|
111
143
|
apply_length(definition, schema.length, definition["type"])
|
|
112
|
-
definition
|
|
113
|
-
definition
|
|
144
|
+
apply_literal_option(definition, "example", schema.example)
|
|
145
|
+
apply_literal_option(definition, "examples", schema.examples)
|
|
114
146
|
apply_range(definition, schema.range)
|
|
115
147
|
definition
|
|
116
148
|
end
|
|
117
149
|
|
|
150
|
+
def apply_literal_option(definition, key, value)
|
|
151
|
+
normalized = openapi_literal(value)
|
|
152
|
+
return if normalized.nil? || normalized.equal?(invalid_openapi_literal)
|
|
153
|
+
|
|
154
|
+
definition[key] = normalized
|
|
155
|
+
end
|
|
156
|
+
|
|
118
157
|
def apply_range(definition, range)
|
|
119
158
|
return if range.blank?
|
|
120
159
|
|
|
@@ -166,6 +205,104 @@ module ActionSpec
|
|
|
166
205
|
def regex_source(pattern)
|
|
167
206
|
pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
|
|
168
207
|
end
|
|
208
|
+
|
|
209
|
+
def openapi_literal(value)
|
|
210
|
+
case value
|
|
211
|
+
when nil, String, Integer, Float, TrueClass, FalseClass
|
|
212
|
+
value
|
|
213
|
+
when Array
|
|
214
|
+
normalized = value.map { |item| openapi_literal(item) }
|
|
215
|
+
return invalid_openapi_literal if normalized.any? { |item| item.equal?(invalid_openapi_literal) }
|
|
216
|
+
|
|
217
|
+
normalized
|
|
218
|
+
when Hash
|
|
219
|
+
value.each_with_object(ActiveSupport::OrderedHash.new) do |(key, item), normalized|
|
|
220
|
+
item = openapi_literal(item)
|
|
221
|
+
return invalid_openapi_literal if item.equal?(invalid_openapi_literal)
|
|
222
|
+
|
|
223
|
+
normalized[key.to_s] = item
|
|
224
|
+
end
|
|
225
|
+
else
|
|
226
|
+
invalid_openapi_literal
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def invalid_openapi_literal
|
|
231
|
+
@invalid_openapi_literal ||= Object.new.freeze
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def normalize_response_example(example)
|
|
235
|
+
normalized = openapi_literal(example)
|
|
236
|
+
return if normalized.nil? || normalized.equal?(invalid_openapi_literal)
|
|
237
|
+
|
|
238
|
+
normalized
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def normalize_response_examples(examples)
|
|
242
|
+
return if examples.blank?
|
|
243
|
+
|
|
244
|
+
examples.each_with_object(ActiveSupport::OrderedHash.new) do |(name, value), hash|
|
|
245
|
+
normalized = openapi_literal(value)
|
|
246
|
+
next if normalized.nil? || normalized.equal?(invalid_openapi_literal)
|
|
247
|
+
|
|
248
|
+
hash[name.to_s] = { "value" => normalized }
|
|
249
|
+
end.presence
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def infer_schema_from_examples(content)
|
|
253
|
+
values =
|
|
254
|
+
if content[:examples].present?
|
|
255
|
+
content[:examples].values
|
|
256
|
+
elsif !content[:example].nil?
|
|
257
|
+
[content[:example]]
|
|
258
|
+
else
|
|
259
|
+
[]
|
|
260
|
+
end
|
|
261
|
+
return if values.blank?
|
|
262
|
+
|
|
263
|
+
definition = infer_definition(values)
|
|
264
|
+
return if definition.blank?
|
|
265
|
+
|
|
266
|
+
ActionSpec::Schema.from_definition(definition)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def infer_definition(values)
|
|
270
|
+
values = Array(values)
|
|
271
|
+
present_values = values.compact
|
|
272
|
+
return if present_values.empty?
|
|
273
|
+
|
|
274
|
+
return infer_object_definition(present_values) if present_values.all? { |value| value.is_a?(Hash) }
|
|
275
|
+
return infer_array_definition(present_values) if present_values.all? { |value| value.is_a?(Array) }
|
|
276
|
+
|
|
277
|
+
infer_scalar_definition(present_values)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def infer_object_definition(values)
|
|
281
|
+
keys = values.flat_map(&:keys).map(&:to_s).uniq
|
|
282
|
+
keys.each_with_object(ActiveSupport::OrderedHash.new) do |key, definition|
|
|
283
|
+
child_values = values.select { |value| value.key?(key) || value.key?(key.to_sym) }.map { |value| value[key] || value[key.to_sym] }
|
|
284
|
+
child_definition = infer_definition(child_values)
|
|
285
|
+
next if child_definition.blank?
|
|
286
|
+
|
|
287
|
+
name = values.all? { |value| value.key?(key) || value.key?(key.to_sym) } ? :"#{key}!" : key.to_sym
|
|
288
|
+
definition[name] = child_definition
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def infer_array_definition(values)
|
|
293
|
+
flattened = values.flatten(1)
|
|
294
|
+
item_definition = infer_definition(flattened)
|
|
295
|
+
item_definition ? [item_definition] : []
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def infer_scalar_definition(values)
|
|
299
|
+
return Integer if values.all? { |value| value.is_a?(Integer) }
|
|
300
|
+
return Float if values.all? { |value| value.is_a?(Numeric) }
|
|
301
|
+
return :boolean if values.all? { |value| value == true || value == false }
|
|
302
|
+
return String if values.all? { |value| value.is_a?(String) }
|
|
303
|
+
|
|
304
|
+
String
|
|
305
|
+
end
|
|
169
306
|
end
|
|
170
307
|
end
|
|
171
308
|
end
|
data/lib/action_spec/railtie.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
module ActionSpec
|
|
2
2
|
class Railtie < ::Rails::Railtie
|
|
3
|
+
rake_tasks do
|
|
4
|
+
load File.expand_path("../tasks/action_spec_tasks.rake", __dir__)
|
|
5
|
+
end
|
|
6
|
+
|
|
3
7
|
initializer "action_spec.controller" do
|
|
4
8
|
ActiveSupport.on_load(:action_controller_base) do
|
|
5
9
|
include ActionSpec::Doc
|
|
@@ -24,7 +24,7 @@ module ActionSpec
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def copy
|
|
27
|
-
self.class.new(item.copy, default:, enum:, range:, pattern:, length:,
|
|
27
|
+
self.class.new(item.copy, default:, enum:, range:, pattern:, length:, blank:, desc: description, example:, examples:)
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module ActionSpec
|
|
4
4
|
module Schema
|
|
5
5
|
class Base
|
|
6
|
-
attr_reader :default, :enum, :range, :pattern, :length, :
|
|
6
|
+
attr_reader :default, :enum, :range, :pattern, :length, :blank, :description, :example, :examples
|
|
7
7
|
|
|
8
8
|
def initialize(options = {})
|
|
9
9
|
options = options.symbolize_keys
|
|
@@ -12,17 +12,22 @@ module ActionSpec
|
|
|
12
12
|
@range = options[:range]
|
|
13
13
|
@pattern = options[:pattern]
|
|
14
14
|
@length = options[:length]
|
|
15
|
-
@
|
|
16
|
-
@allow_blank = options[:allow_blank]
|
|
15
|
+
@blank = options.key?(:blank) ? options[:blank] : options.fetch(:allow_blank, true)
|
|
17
16
|
@description = options[:desc] || options[:description]
|
|
18
17
|
@example = options[:example]
|
|
19
18
|
@examples = options[:examples]
|
|
20
19
|
end
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
alias allow_blank blank
|
|
22
|
+
|
|
23
|
+
def materialize_missing(context:, coerce:, result:, path:)
|
|
23
24
|
Schema::Missing
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
def blank_allowed?
|
|
28
|
+
blank != false
|
|
29
|
+
end
|
|
30
|
+
|
|
26
31
|
def validate_constraints(value, result:, path:)
|
|
27
32
|
return if value.nil?
|
|
28
33
|
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
module ActionSpec
|
|
4
4
|
module Schema
|
|
5
5
|
class Field
|
|
6
|
-
attr_reader :name, :schema, :scopes
|
|
6
|
+
attr_reader :name, :schema, :transform, :px_key, :scopes
|
|
7
7
|
|
|
8
|
-
def initialize(name:, required:, schema:, scopes: [])
|
|
8
|
+
def initialize(name:, required:, schema:, transform: nil, px_key: nil, scopes: [])
|
|
9
9
|
@name = name.to_sym
|
|
10
10
|
@required = required
|
|
11
11
|
@schema = schema
|
|
12
|
+
@transform = transform
|
|
13
|
+
@px_key = px_key&.to_sym
|
|
12
14
|
@scopes = Array(scopes).map(&:to_sym).freeze
|
|
13
15
|
end
|
|
14
16
|
|
|
@@ -20,9 +22,48 @@ module ActionSpec
|
|
|
20
22
|
schema.default
|
|
21
23
|
end
|
|
22
24
|
|
|
25
|
+
def output_name
|
|
26
|
+
px_key || name
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def transform_value(value, context: nil)
|
|
30
|
+
return value if transform.nil? || value.equal?(ActionSpec::Schema::Missing)
|
|
31
|
+
|
|
32
|
+
case transform
|
|
33
|
+
when Symbol, String then apply_symbol_transform(value, context:)
|
|
34
|
+
when Proc then apply_proc_transform(value, context:)
|
|
35
|
+
else value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
23
39
|
def copy
|
|
24
|
-
self.class.new(name:, required: required?, schema: schema.copy, scopes:)
|
|
40
|
+
self.class.new(name:, required: required?, schema: schema.copy, transform:, px_key:, scopes:)
|
|
25
41
|
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def apply_symbol_transform(value, context:)
|
|
46
|
+
symbol = transform.to_sym
|
|
47
|
+
return value.public_send(symbol) if value.respond_to?(symbol)
|
|
48
|
+
return invoke_context_transform(context, symbol, value) if context&.respond_to?(symbol, true)
|
|
49
|
+
|
|
50
|
+
value
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply_proc_transform(value, context:)
|
|
54
|
+
return context.instance_exec(&transform) if context && transform.arity.zero?
|
|
55
|
+
return context.instance_exec(value, &transform) if context && (transform.arity == 1 || transform.arity.negative?)
|
|
56
|
+
return transform.call if transform.arity.zero?
|
|
57
|
+
|
|
58
|
+
transform.call(value)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def invoke_context_transform(context, symbol, value)
|
|
62
|
+
method = context.method(symbol)
|
|
63
|
+
return context.public_send(symbol) if method.arity.zero?
|
|
64
|
+
|
|
65
|
+
context.public_send(symbol, value)
|
|
66
|
+
end
|
|
26
67
|
end
|
|
27
68
|
end
|
|
28
69
|
end
|
|
@@ -24,7 +24,7 @@ module ActionSpec
|
|
|
24
24
|
result:,
|
|
25
25
|
path:
|
|
26
26
|
).resolve
|
|
27
|
-
output[field.
|
|
27
|
+
output[field.output_name] = resolved unless resolved.equal?(Schema::Missing)
|
|
28
28
|
end
|
|
29
29
|
output.presence || (source.present? ? output : Schema::Missing)
|
|
30
30
|
end
|
|
@@ -34,7 +34,7 @@ module ActionSpec
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def copy
|
|
37
|
-
self.class.new(fields.transform_values(&:copy), default:, enum:, range:, pattern:, length:,
|
|
37
|
+
self.class.new(fields.transform_values(&:copy), default:, enum:, range:, pattern:, length:, blank:, desc: description, example:, examples:)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
private
|
|
@@ -15,7 +15,10 @@ module ActionSpec
|
|
|
15
15
|
def resolve
|
|
16
16
|
return resolve_missing unless present?
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
return resolve_nil if value.nil?
|
|
19
|
+
return resolve_blank if blank_disallowed?
|
|
20
|
+
|
|
21
|
+
finalize(schema.cast(value, context:, coerce:, result:, path:))
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
private
|
|
@@ -36,15 +39,35 @@ module ActionSpec
|
|
|
36
39
|
|
|
37
40
|
def resolve_missing
|
|
38
41
|
if schema.default.respond_to?(:call)
|
|
39
|
-
return schema.cast(evaluate_default(schema.default), context:, coerce:, result:, path:)
|
|
42
|
+
return finalize(schema.cast(evaluate_default(schema.default), context:, coerce:, result:, path:))
|
|
40
43
|
end
|
|
41
|
-
return schema.cast(schema.default, context:, coerce:, result:, path:) unless schema.default.nil?
|
|
42
|
-
return schema.materialize_missing(context:, coerce:, result:, path:) unless field.required?
|
|
44
|
+
return finalize(schema.cast(schema.default, context:, coerce:, result:, path:)) unless schema.default.nil?
|
|
45
|
+
return finalize(schema.materialize_missing(context:, coerce:, result:, path:)) unless field.required?
|
|
43
46
|
|
|
44
47
|
result.add_error(path.join("."), :required)
|
|
45
48
|
Schema::Missing
|
|
46
49
|
end
|
|
47
50
|
|
|
51
|
+
def resolve_nil
|
|
52
|
+
result.add_error(path.join("."), field.required? ? :required : :blank)
|
|
53
|
+
Schema::Missing
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resolve_blank
|
|
57
|
+
result.add_error(path.join("."), :blank)
|
|
58
|
+
Schema::Missing
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def blank_disallowed?
|
|
62
|
+
!schema.blank_allowed? && value.respond_to?(:blank?) && value.blank?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def finalize(resolved)
|
|
66
|
+
return resolved if resolved.equal?(Schema::Missing)
|
|
67
|
+
|
|
68
|
+
field.transform_value(resolved, context:)
|
|
69
|
+
end
|
|
70
|
+
|
|
48
71
|
def evaluate_default(default_proc)
|
|
49
72
|
return context.instance_exec(&default_proc) if context && default_proc.arity.zero?
|
|
50
73
|
return default_proc.call(context) if context && default_proc.arity == 1
|
|
@@ -14,7 +14,7 @@ module ActionSpec
|
|
|
14
14
|
candidate = TypeCaster.cast(type, value)
|
|
15
15
|
rescue TypeCaster::CastError => error
|
|
16
16
|
result.add_error(path.join("."), :invalid_type, expected: error.expected)
|
|
17
|
-
|
|
17
|
+
Schema::Missing
|
|
18
18
|
else
|
|
19
19
|
return candidate if candidate.nil?
|
|
20
20
|
|
|
@@ -23,7 +23,7 @@ module ActionSpec
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def copy
|
|
26
|
-
self.class.new(type, default:, enum:, range:, pattern:, length:,
|
|
26
|
+
self.class.new(type, default:, enum:, range:, pattern:, length:, blank:, desc: description, example:, examples:)
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|
|
@@ -25,7 +25,7 @@ module ActionSpec
|
|
|
25
25
|
return cast_decimal(value) if normalized == :decimal
|
|
26
26
|
|
|
27
27
|
active_model_type_for(normalized).cast(value).tap do |casted|
|
|
28
|
-
raise CastError, normalized if casted.nil? && value.
|
|
28
|
+
raise CastError, normalized if casted.nil? && !value.nil?
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
data/lib/action_spec/schema.rb
CHANGED
|
@@ -12,7 +12,8 @@ require "action_spec/schema/type_caster"
|
|
|
12
12
|
module ActionSpec
|
|
13
13
|
module Schema
|
|
14
14
|
Missing = Object.new.freeze
|
|
15
|
-
OPTION_KEYS = %i[default desc enum range pattern length
|
|
15
|
+
OPTION_KEYS = %i[default desc enum range pattern length blank allow_blank example examples].freeze
|
|
16
|
+
FIELD_OPTION_KEYS = (OPTION_KEYS + %i[required transform px px_key]).freeze
|
|
16
17
|
|
|
17
18
|
class << self
|
|
18
19
|
def build(type = nil, **options)
|
|
@@ -21,6 +22,17 @@ module ActionSpec
|
|
|
21
22
|
from_definition(definition)
|
|
22
23
|
end
|
|
23
24
|
|
|
25
|
+
def build_field(name, definition = nil, required: false, scopes: [])
|
|
26
|
+
Field.new(
|
|
27
|
+
name: field_name(name),
|
|
28
|
+
required: required_key?(name) || required || explicit_required?(definition),
|
|
29
|
+
schema: build_field_schema(strip_field_options(definition)),
|
|
30
|
+
transform: explicit_transform(definition),
|
|
31
|
+
px_key: explicit_px_key(definition),
|
|
32
|
+
scopes:
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
24
36
|
def from_definition(definition)
|
|
25
37
|
return Scalar.new(String) if definition.blank?
|
|
26
38
|
return ArrayOf.new(from_definition(type: definition.first)) if definition.is_a?(Array) && definition.one?
|
|
@@ -42,13 +54,8 @@ module ActionSpec
|
|
|
42
54
|
|
|
43
55
|
def build_fields(definition_hash, scopes: [])
|
|
44
56
|
definition_hash.each_with_object(ActiveSupport::OrderedHash.new) do |(name, definition), fields|
|
|
45
|
-
|
|
46
|
-
fields[
|
|
47
|
-
name: field_name(name),
|
|
48
|
-
required: required_key?(name),
|
|
49
|
-
schema:,
|
|
50
|
-
scopes:
|
|
51
|
-
)
|
|
57
|
+
field = build_field(name, definition, scopes:)
|
|
58
|
+
fields[field.name] = field
|
|
52
59
|
end
|
|
53
60
|
end
|
|
54
61
|
|
|
@@ -64,11 +71,61 @@ module ActionSpec
|
|
|
64
71
|
return from_definition(type: definition) unless definition.is_a?(Hash)
|
|
65
72
|
|
|
66
73
|
definition = definition.symbolize_keys
|
|
67
|
-
return from_definition(definition) if definition.key?(:type)
|
|
68
|
-
return from_definition(definition) if (definition.keys -
|
|
74
|
+
return from_definition(definition.except(:required)) if definition.key?(:type)
|
|
75
|
+
return from_definition(definition.except(:required)) if (definition.keys - FIELD_OPTION_KEYS).present?
|
|
76
|
+
|
|
77
|
+
from_definition(definition.except(:required).merge(type: String))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def schema_definition?(definition)
|
|
81
|
+
case definition
|
|
82
|
+
when Array
|
|
83
|
+
definition.one? && schema_definition?(definition.first)
|
|
84
|
+
when Hash
|
|
85
|
+
definition = definition.with_indifferent_access
|
|
86
|
+
return true if definition.key?(:type)
|
|
87
|
+
return true if definition.keys.all? { |key| FIELD_OPTION_KEYS.include?(key.to_sym) }
|
|
69
88
|
|
|
70
|
-
|
|
89
|
+
definition.any? do |name, value|
|
|
90
|
+
required_key?(name) || schema_definition?(value)
|
|
91
|
+
end
|
|
92
|
+
when Class
|
|
93
|
+
true
|
|
94
|
+
when Symbol
|
|
95
|
+
definition == :boolean || definition == :file || definition == :object
|
|
96
|
+
else
|
|
97
|
+
false
|
|
98
|
+
end
|
|
71
99
|
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def explicit_required?(definition)
|
|
104
|
+
definition.is_a?(Hash) && definition.symbolize_keys[:required] == true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def explicit_transform(definition)
|
|
108
|
+
definition.is_a?(Hash) ? definition.symbolize_keys[:transform] : nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def explicit_px_key(definition)
|
|
112
|
+
return unless definition.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
options = definition.symbolize_keys
|
|
115
|
+
normalize_px_key(options[:px_key] || options[:px])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def normalize_px_key(value)
|
|
119
|
+
return if value.nil? || value == true || value == false
|
|
120
|
+
|
|
121
|
+
value.is_a?(String) || value.is_a?(Symbol) ? value.to_sym : value
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def strip_field_options(definition)
|
|
125
|
+
return definition unless definition.is_a?(Hash)
|
|
126
|
+
|
|
127
|
+
definition.symbolize_keys.except(:required, :transform, :px, :px_key)
|
|
128
|
+
end
|
|
72
129
|
end
|
|
73
130
|
end
|
|
74
131
|
end
|