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.
@@ -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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionSpec
4
+ class HeaderHash < ActiveSupport::HashWithIndifferentAccess
5
+ private
6
+
7
+ def convert_key(key)
8
+ super.to_s.sub(/\AHTTP_/, "").tr("_", "-").downcase
9
+ end
10
+ end
11
+ 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