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.
@@ -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 { |blocks| blocks.map(&:dup) }
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
- action_specs[action_name.to_sym] = apply_dry_blocks(endpoint).apply(block || proc {})
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 |dry_block|
53
- endpoint.apply(dry_block)
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, YAML.dump(document))
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] = { "description" => response.description.presence || "OK" }
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["default"] = schema.default unless schema.default.respond_to?(:call) || schema.default.nil?
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["example"] = schema.example if schema.example.present?
113
- definition["examples"] = schema.examples if schema.examples.present?
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
@@ -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:, allow_nil:, allow_blank:, desc: description, example:, examples:)
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, :allow_nil, :allow_blank, :description, :example, :examples
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
- @allow_nil = options[:allow_nil]
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
- def materialize_missing(_context:, _coerce:, _result:, _path:)
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.name] = resolved unless resolved.equal?(Schema::Missing)
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:, allow_nil:, allow_blank:, desc: description, example:, examples:)
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
- schema.cast(value, context:, coerce:, result:, path:)
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
- nil
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:, allow_nil:, allow_blank:, desc: description, example:, examples:)
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.present?
28
+ raise CastError, normalized if casted.nil? && !value.nil?
29
29
  end
30
30
  end
31
31
 
@@ -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 allow_nil allow_blank example examples].freeze
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
- schema = build_field_schema(definition)
46
- fields[field_name(name)] = Field.new(
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 - OPTION_KEYS).present?
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
- from_definition(definition.merge(type: String))
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