action_spec 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/MIT-LICENSE +20 -0
- data/README.md +480 -0
- data/Rakefile +3 -0
- data/config/locales/en.yml +6 -0
- data/config/locales/zh.yml +6 -0
- data/lib/action_spec/configuration.rb +38 -0
- data/lib/action_spec/doc/dsl.rb +109 -0
- data/lib/action_spec/doc/endpoint.rb +140 -0
- data/lib/action_spec/doc.rb +68 -0
- data/lib/action_spec/header_hash.rb +11 -0
- data/lib/action_spec/invalid_parameters.rb +14 -0
- data/lib/action_spec/railtie.rb +14 -0
- data/lib/action_spec/schema/array_of.rb +31 -0
- data/lib/action_spec/schema/base.rb +67 -0
- data/lib/action_spec/schema/field.rb +27 -0
- data/lib/action_spec/schema/object_of.rb +52 -0
- data/lib/action_spec/schema/resolver.rb +56 -0
- data/lib/action_spec/schema/scalar.rb +30 -0
- data/lib/action_spec/schema/type_caster.rb +115 -0
- data/lib/action_spec/schema.rb +72 -0
- data/lib/action_spec/validation_result.rb +71 -0
- data/lib/action_spec/validator/runner.rb +79 -0
- data/lib/action_spec/validator.rb +46 -0
- data/lib/action_spec/version.rb +3 -0
- data/lib/action_spec.rb +24 -0
- data/lib/tasks/action_spec_tasks.rake +4 -0
- metadata +103 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module Doc
|
|
5
|
+
class Endpoint
|
|
6
|
+
attr_reader :action, :summary, :options, :request, :responses
|
|
7
|
+
|
|
8
|
+
def initialize(action, summary: nil, options: {})
|
|
9
|
+
@action = action.to_sym
|
|
10
|
+
@summary = summary
|
|
11
|
+
@options = options
|
|
12
|
+
@request = Request.new
|
|
13
|
+
@responses = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def dsl
|
|
17
|
+
@dsl ||= Dsl.new(self)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def apply(block)
|
|
21
|
+
dsl.instance_exec(&block)
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def add_response(code, response)
|
|
26
|
+
@responses[code.to_s] = response
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def copy
|
|
30
|
+
self.class.new(action, summary:, options: options.deep_dup).tap do |endpoint|
|
|
31
|
+
endpoint.request.replace_with(request.copy)
|
|
32
|
+
responses.each do |code, response|
|
|
33
|
+
endpoint.add_response(code, response.copy)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class Request
|
|
40
|
+
attr_reader :header, :path, :query, :cookie, :body, :body_media_types
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
@header = Location.new(:header)
|
|
44
|
+
@path = Location.new(:path)
|
|
45
|
+
@query = Location.new(:query)
|
|
46
|
+
@cookie = Location.new(:cookie)
|
|
47
|
+
@body = Location.new(:body)
|
|
48
|
+
@body_media_types = {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def location(name)
|
|
52
|
+
public_send(name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def add_param(location_name, field)
|
|
56
|
+
location(location_name).add(field)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def add_body(media_type, field)
|
|
60
|
+
body.add(field)
|
|
61
|
+
(@body_media_types[media_type.to_sym] ||= Location.new(media_type.to_sym)).add(field.copy)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def replace_with(other)
|
|
65
|
+
@header = other.header
|
|
66
|
+
@path = other.path
|
|
67
|
+
@query = other.query
|
|
68
|
+
@cookie = other.cookie
|
|
69
|
+
@body = other.body
|
|
70
|
+
@body_media_types = other.body_media_types
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def copy
|
|
74
|
+
self.class.new.tap do |request|
|
|
75
|
+
request.instance_variable_set(:@header, header.copy)
|
|
76
|
+
request.instance_variable_set(:@path, path.copy)
|
|
77
|
+
request.instance_variable_set(:@query, query.copy)
|
|
78
|
+
request.instance_variable_set(:@cookie, cookie.copy)
|
|
79
|
+
request.instance_variable_set(:@body, body.copy)
|
|
80
|
+
request.instance_variable_set(
|
|
81
|
+
:@body_media_types,
|
|
82
|
+
body_media_types.transform_values(&:copy)
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class Location
|
|
89
|
+
include Enumerable
|
|
90
|
+
|
|
91
|
+
attr_reader :name
|
|
92
|
+
|
|
93
|
+
def initialize(name)
|
|
94
|
+
@name = name
|
|
95
|
+
@fields = ActiveSupport::OrderedHash.new
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def add(field)
|
|
99
|
+
@fields[field.name] = field
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def field(name)
|
|
103
|
+
@fields[name.to_sym]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def [](name)
|
|
107
|
+
field(name)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fields
|
|
111
|
+
@fields.values
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def each(&block)
|
|
115
|
+
fields.each(&block)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def copy
|
|
119
|
+
self.class.new(name).tap do |location|
|
|
120
|
+
fields.each { |field| location.add(field.copy) }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class Response
|
|
126
|
+
attr_reader :code, :description, :media_type, :options
|
|
127
|
+
|
|
128
|
+
def initialize(code:, description:, media_type:, options:)
|
|
129
|
+
@code = code.to_s
|
|
130
|
+
@description = description
|
|
131
|
+
@media_type = media_type
|
|
132
|
+
@options = options
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def copy
|
|
136
|
+
self.class.new(code:, description:, media_type:, options: options.deep_dup)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_spec/doc/endpoint"
|
|
4
|
+
require "action_spec/doc/dsl"
|
|
5
|
+
|
|
6
|
+
module ActionSpec
|
|
7
|
+
module Doc
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
class_methods do
|
|
11
|
+
def action_specs
|
|
12
|
+
@action_specs ||= begin
|
|
13
|
+
parent = superclass.respond_to?(:action_specs) ? superclass.action_specs : {}
|
|
14
|
+
parent.transform_values(&:copy)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def dry_blocks
|
|
19
|
+
@dry_blocks ||= begin
|
|
20
|
+
parent = superclass.respond_to?(:dry_blocks) ? superclass.dry_blocks : {}
|
|
21
|
+
parent.transform_values { |blocks| blocks.map(&:dup) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def doc(action_or_summary = nil, summary = nil, **options, &block)
|
|
26
|
+
action_name, endpoint_summary = normalize_doc_arguments(action_or_summary, summary)
|
|
27
|
+
action_name ||= infer_action_name(caller_locations(1, 1).first)
|
|
28
|
+
endpoint = Endpoint.new(action_name, summary: endpoint_summary, options:)
|
|
29
|
+
action_specs[action_name.to_sym] = apply_dry_blocks(endpoint).apply(block || proc {})
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def doc_dry(actions = :all, &block)
|
|
33
|
+
Array(actions).each do |action|
|
|
34
|
+
(dry_blocks[action.to_sym] ||= []) << block
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
alias api_dry doc_dry
|
|
38
|
+
|
|
39
|
+
def action_spec_for(action)
|
|
40
|
+
action_specs[action.to_sym]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def normalize_doc_arguments(action_or_summary, summary)
|
|
46
|
+
return [action_or_summary, summary] if action_or_summary.is_a?(Symbol)
|
|
47
|
+
|
|
48
|
+
[nil, action_or_summary]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def apply_dry_blocks(endpoint)
|
|
52
|
+
[*dry_blocks[:all], *dry_blocks[endpoint.action]].compact.each do |dry_block|
|
|
53
|
+
endpoint.apply(dry_block)
|
|
54
|
+
end
|
|
55
|
+
endpoint
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def infer_action_name(location)
|
|
59
|
+
file = location.absolute_path || location.path
|
|
60
|
+
lines = File.readlines(file)
|
|
61
|
+
method_definition = lines[(location.lineno - 1)..].find { |line| line.match?(/^\s*def\s+(?!self\.)/) }
|
|
62
|
+
return Regexp.last_match(1).to_sym if method_definition&.match(/^\s*def\s+([a-zA-Z_]\w*[!?=]?)/)
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "ActionSpec could not infer the target action for `doc`; use `doc :action` instead"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
class InvalidParameters < ActionController::BadRequest
|
|
5
|
+
attr_reader :result
|
|
6
|
+
alias parameters result
|
|
7
|
+
delegate :errors, :px, to: :result
|
|
8
|
+
|
|
9
|
+
def initialize(result)
|
|
10
|
+
@result = result
|
|
11
|
+
super(result.errors.full_messages.to_sentence.presence || "Invalid parameters")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module ActionSpec
|
|
2
|
+
class Railtie < ::Rails::Railtie
|
|
3
|
+
initializer "action_spec.i18n" do |app|
|
|
4
|
+
app.config.i18n.load_path += Dir[root.join("config/locales/*.yml")]
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
initializer "action_spec.controller" do
|
|
8
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
9
|
+
include ActionSpec::Doc
|
|
10
|
+
include ActionSpec::Validator
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module Schema
|
|
5
|
+
class ArrayOf < Base
|
|
6
|
+
attr_reader :item
|
|
7
|
+
|
|
8
|
+
def initialize(item, options = {})
|
|
9
|
+
super(options)
|
|
10
|
+
@item = item
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def cast(value, context:, coerce:, result:, path:)
|
|
14
|
+
unless value.is_a?(Array)
|
|
15
|
+
result.add_error(path.join("."), :invalid)
|
|
16
|
+
return []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
output = value.each_with_index.map do |entry, index|
|
|
20
|
+
item.cast(entry, context:, coerce:, result:, path: [*path, index])
|
|
21
|
+
end
|
|
22
|
+
validate_constraints(output, result:, path:)
|
|
23
|
+
output
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def copy
|
|
27
|
+
self.class.new(item.copy, default:, enum:, range:, pattern:, allow_nil:, allow_blank:)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module Schema
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :default, :enum, :range, :pattern, :allow_nil, :allow_blank
|
|
7
|
+
|
|
8
|
+
def initialize(options = {})
|
|
9
|
+
options = options.symbolize_keys
|
|
10
|
+
@default = options[:default]
|
|
11
|
+
@enum = options[:enum]
|
|
12
|
+
@range = options[:range]
|
|
13
|
+
@pattern = options[:pattern]
|
|
14
|
+
@allow_nil = options[:allow_nil]
|
|
15
|
+
@allow_blank = options[:allow_blank]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def materialize_missing(_context:, _coerce:, _result:, _path:)
|
|
19
|
+
Schema::Missing
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def validate_constraints(value, result:, path:)
|
|
23
|
+
return if value.nil?
|
|
24
|
+
|
|
25
|
+
validate_enum(value, result:, path:)
|
|
26
|
+
validate_range(value, result:, path:)
|
|
27
|
+
validate_pattern(value, result:, path:)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def copy
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def add_error(result, path, type, **options)
|
|
37
|
+
result.add_error(path.join("."), type, **options)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate_enum(value, result:, path:)
|
|
41
|
+
return if enum.blank?
|
|
42
|
+
return if Array(enum).include?(value)
|
|
43
|
+
|
|
44
|
+
add_error(result, path, :inclusion)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_range(value, result:, path:)
|
|
48
|
+
return if range.blank?
|
|
49
|
+
|
|
50
|
+
rules = range.symbolize_keys
|
|
51
|
+
add_error(result, path, :greater_than_or_equal_to, count: rules[:ge]) if rules.key?(:ge) && value < rules[:ge]
|
|
52
|
+
add_error(result, path, :greater_than, count: rules[:gt]) if rules.key?(:gt) && value <= rules[:gt]
|
|
53
|
+
add_error(result, path, :less_than_or_equal_to, count: rules[:le]) if rules.key?(:le) && value > rules[:le]
|
|
54
|
+
add_error(result, path, :less_than, count: rules[:lt]) if rules.key?(:lt) && value >= rules[:lt]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_pattern(value, result:, path:)
|
|
58
|
+
return if pattern.blank?
|
|
59
|
+
|
|
60
|
+
matcher = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
|
|
61
|
+
return if value.to_s.match?(matcher)
|
|
62
|
+
|
|
63
|
+
add_error(result, path, :invalid)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module Schema
|
|
5
|
+
class Field
|
|
6
|
+
attr_reader :name, :schema
|
|
7
|
+
|
|
8
|
+
def initialize(name:, required:, schema:)
|
|
9
|
+
@name = name.to_sym
|
|
10
|
+
@required = required
|
|
11
|
+
@schema = schema
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def required?
|
|
15
|
+
@required
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def default_value
|
|
19
|
+
schema.default
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def copy
|
|
23
|
+
self.class.new(name:, required: required?, schema: schema.copy)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module Schema
|
|
5
|
+
class ObjectOf < Base
|
|
6
|
+
attr_reader :fields
|
|
7
|
+
|
|
8
|
+
def initialize(fields, options = {})
|
|
9
|
+
super(options)
|
|
10
|
+
@fields = fields
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def cast(value, context:, coerce:, result:, path:)
|
|
14
|
+
source = normalize_source(value, result:, path:)
|
|
15
|
+
return Schema::Missing if source.equal?(Schema::Missing)
|
|
16
|
+
|
|
17
|
+
output = ActiveSupport::HashWithIndifferentAccess.new
|
|
18
|
+
fields.each_value do |field|
|
|
19
|
+
resolved = Resolver.new(
|
|
20
|
+
field:,
|
|
21
|
+
source:,
|
|
22
|
+
context:,
|
|
23
|
+
coerce:,
|
|
24
|
+
result:,
|
|
25
|
+
path:
|
|
26
|
+
).resolve
|
|
27
|
+
output[field.name] = resolved unless resolved.equal?(Schema::Missing)
|
|
28
|
+
end
|
|
29
|
+
output.presence || (source.present? ? output : Schema::Missing)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def materialize_missing(context:, coerce:, result:, path:)
|
|
33
|
+
cast({}, context:, coerce:, result:, path:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def copy
|
|
37
|
+
self.class.new(fields.transform_values(&:copy), default:, enum:, range:, pattern:, allow_nil:, allow_blank:)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def normalize_source(value, result:, path:)
|
|
43
|
+
return {} if value.nil?
|
|
44
|
+
return value.to_unsafe_h.with_indifferent_access if value.is_a?(ActionController::Parameters)
|
|
45
|
+
return value.with_indifferent_access if value.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
result.add_error(path.join("."), :invalid)
|
|
48
|
+
Schema::Missing
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module Schema
|
|
5
|
+
class Resolver
|
|
6
|
+
def initialize(field:, source:, context:, coerce:, result:, path:)
|
|
7
|
+
@field = field
|
|
8
|
+
@source = source
|
|
9
|
+
@context = context
|
|
10
|
+
@coerce = coerce
|
|
11
|
+
@result = result
|
|
12
|
+
@path = [*path, field.name]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def resolve
|
|
16
|
+
return resolve_missing unless present?
|
|
17
|
+
|
|
18
|
+
schema.cast(value, context:, coerce:, result:, path:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :field, :source, :context, :coerce, :result, :path
|
|
24
|
+
|
|
25
|
+
def schema
|
|
26
|
+
field.schema
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def value
|
|
30
|
+
source[field.name]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def present?
|
|
34
|
+
source.key?(field.name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def resolve_missing
|
|
38
|
+
if schema.default.respond_to?(:call)
|
|
39
|
+
return schema.cast(evaluate_default(schema.default), context:, coerce:, result:, path:)
|
|
40
|
+
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?
|
|
43
|
+
|
|
44
|
+
result.add_error(path.join("."), :required)
|
|
45
|
+
Schema::Missing
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def evaluate_default(default_proc)
|
|
49
|
+
return context.instance_exec(&default_proc) if context && default_proc.arity.zero?
|
|
50
|
+
return default_proc.call(context) if context && default_proc.arity == 1
|
|
51
|
+
|
|
52
|
+
default_proc.call
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module Schema
|
|
5
|
+
class Scalar < Base
|
|
6
|
+
attr_reader :type
|
|
7
|
+
|
|
8
|
+
def initialize(type, options = {})
|
|
9
|
+
super(options)
|
|
10
|
+
@type = type
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def cast(value, context: nil, coerce:, result:, path:)
|
|
14
|
+
candidate = TypeCaster.cast(type, value)
|
|
15
|
+
rescue TypeCaster::CastError => error
|
|
16
|
+
result.add_error(path.join("."), :invalid_type, expected: error.expected)
|
|
17
|
+
nil
|
|
18
|
+
else
|
|
19
|
+
return candidate if candidate.nil?
|
|
20
|
+
|
|
21
|
+
validate_constraints(candidate, result:, path:)
|
|
22
|
+
coerce ? candidate : value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def copy
|
|
26
|
+
self.class.new(type, default:, enum:, range:, pattern:, allow_nil:, allow_blank:)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module Schema
|
|
5
|
+
class TypeCaster
|
|
6
|
+
class CastError < StandardError
|
|
7
|
+
attr_reader :expected
|
|
8
|
+
|
|
9
|
+
def initialize(expected)
|
|
10
|
+
@expected = expected
|
|
11
|
+
super()
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def cast(type, value)
|
|
17
|
+
return value if value.nil?
|
|
18
|
+
|
|
19
|
+
normalized = normalize(type)
|
|
20
|
+
return value if normalized == :object
|
|
21
|
+
return cast_file(value) if normalized == :file
|
|
22
|
+
return cast_boolean(value) if normalized == :boolean
|
|
23
|
+
return cast_integer(value) if normalized == :integer
|
|
24
|
+
return cast_float(value) if normalized == :float
|
|
25
|
+
return cast_decimal(value) if normalized == :decimal
|
|
26
|
+
|
|
27
|
+
active_model_type_for(normalized).cast(value).tap do |casted|
|
|
28
|
+
raise CastError, normalized if casted.nil? && value.present?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def normalize(type)
|
|
33
|
+
return :boolean if boolean_type?(type)
|
|
34
|
+
return :string if type == String
|
|
35
|
+
return :integer if type == Integer
|
|
36
|
+
return :float if type == Float
|
|
37
|
+
return :decimal if type == BigDecimal
|
|
38
|
+
return :date if type == Date
|
|
39
|
+
return :datetime if [DateTime, ActiveSupport::TimeWithZone].include?(type)
|
|
40
|
+
return :time if type == Time
|
|
41
|
+
return :string if type == Symbol
|
|
42
|
+
return :file if type == File
|
|
43
|
+
return :object if [Hash, Object].include?(type)
|
|
44
|
+
|
|
45
|
+
type
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def active_model_type_for(type)
|
|
51
|
+
case type
|
|
52
|
+
when :string then ActiveModel::Type::String.new
|
|
53
|
+
when :integer then ActiveModel::Type::Integer.new
|
|
54
|
+
when :float then ActiveModel::Type::Float.new
|
|
55
|
+
when :decimal then ActiveModel::Type::Decimal.new
|
|
56
|
+
when :boolean then ActiveModel::Type::Boolean.new
|
|
57
|
+
when :date then ActiveModel::Type::Date.new
|
|
58
|
+
when :datetime then ActiveModel::Type::DateTime.new
|
|
59
|
+
when :time then ActiveModel::Type::Time.new
|
|
60
|
+
else ActiveModel::Type::Value.new
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def cast_file(value)
|
|
65
|
+
return value if file_like?(value)
|
|
66
|
+
|
|
67
|
+
raise CastError, :file
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def cast_integer(value)
|
|
71
|
+
return value if value.is_a?(Integer)
|
|
72
|
+
raise CastError, :integer unless value.is_a?(String) && value.match?(/\A[+-]?\d+\z/)
|
|
73
|
+
|
|
74
|
+
active_model_type_for(:integer).cast(value)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cast_float(value)
|
|
78
|
+
return value.to_f if value.is_a?(Numeric)
|
|
79
|
+
raise CastError, :float unless value.is_a?(String) && value.match?(/\A[+-]?\d+(\.\d+)?\z/)
|
|
80
|
+
|
|
81
|
+
active_model_type_for(:float).cast(value)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cast_decimal(value)
|
|
85
|
+
return value if value.is_a?(BigDecimal)
|
|
86
|
+
raise CastError, :decimal unless value.is_a?(Numeric) || (value.is_a?(String) && value.match?(/\A[+-]?\d+(\.\d+)?\z/))
|
|
87
|
+
|
|
88
|
+
active_model_type_for(:decimal).cast(value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def cast_boolean(value)
|
|
92
|
+
return value if value == true || value == false
|
|
93
|
+
return true if value.in?([1, "1", "true", "t", "yes", "on"])
|
|
94
|
+
return false if value.in?([0, "0", "false", "f", "no", "off"])
|
|
95
|
+
|
|
96
|
+
raise CastError, :boolean
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def boolean_type?(type)
|
|
100
|
+
type == :boolean ||
|
|
101
|
+
type.to_s == "boolean" ||
|
|
102
|
+
(Object.const_defined?(:Boolean) && type == ::Boolean)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def file_like?(value)
|
|
106
|
+
value.is_a?(File) ||
|
|
107
|
+
value.is_a?(Tempfile) ||
|
|
108
|
+
value.respond_to?(:read) ||
|
|
109
|
+
value.is_a?(ActionDispatch::Http::UploadedFile) ||
|
|
110
|
+
(value.is_a?(Hash) && (value.key?(:tempfile) || value.key?("tempfile")))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|