apiwork 0.4.0 → 0.5.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/lib/apiwork/adapter/serializer/resource/base.rb +15 -0
  3. data/lib/apiwork/adapter/serializer/resource/default/contract_builder.rb +3 -2
  4. data/lib/apiwork/adapter/standard/capability/writing/contract_builder.rb +2 -2
  5. data/lib/apiwork/api/base.rb +67 -17
  6. data/lib/apiwork/api/element.rb +33 -2
  7. data/lib/apiwork/api/object.rb +70 -5
  8. data/lib/apiwork/api/router.rb +16 -0
  9. data/lib/apiwork/configuration/validatable.rb +1 -0
  10. data/lib/apiwork/configuration.rb +2 -0
  11. data/lib/apiwork/contract/element.rb +17 -2
  12. data/lib/apiwork/contract/object/coercer.rb +24 -2
  13. data/lib/apiwork/contract/object/deserializer.rb +5 -1
  14. data/lib/apiwork/contract/object/transformer.rb +15 -2
  15. data/lib/apiwork/contract/object/validator.rb +45 -2
  16. data/lib/apiwork/contract/object.rb +77 -7
  17. data/lib/apiwork/element.rb +33 -0
  18. data/lib/apiwork/export/base.rb +1 -4
  19. data/lib/apiwork/export/builder_mapper.rb +184 -0
  20. data/lib/apiwork/export/open_api.rb +9 -2
  21. data/lib/apiwork/export/sorbus.rb +5 -1
  22. data/lib/apiwork/export/sorbus_mapper.rb +3 -7
  23. data/lib/apiwork/export/type_analysis.rb +20 -6
  24. data/lib/apiwork/export/type_script.rb +4 -1
  25. data/lib/apiwork/export/type_script_mapper.rb +25 -2
  26. data/lib/apiwork/export/zod.rb +9 -0
  27. data/lib/apiwork/export/zod_mapper.rb +22 -1
  28. data/lib/apiwork/introspection/dump/action.rb +1 -1
  29. data/lib/apiwork/introspection/dump/param.rb +36 -20
  30. data/lib/apiwork/introspection/dump/type.rb +31 -25
  31. data/lib/apiwork/introspection/param/array.rb +26 -0
  32. data/lib/apiwork/introspection/param/base.rb +16 -18
  33. data/lib/apiwork/introspection/param/binary.rb +36 -0
  34. data/lib/apiwork/introspection/param/boolean.rb +36 -0
  35. data/lib/apiwork/introspection/param/date.rb +36 -0
  36. data/lib/apiwork/introspection/param/date_time.rb +36 -0
  37. data/lib/apiwork/introspection/param/decimal.rb +26 -0
  38. data/lib/apiwork/introspection/param/integer.rb +26 -0
  39. data/lib/apiwork/introspection/param/number.rb +26 -0
  40. data/lib/apiwork/introspection/param/record.rb +71 -0
  41. data/lib/apiwork/introspection/param/string.rb +26 -0
  42. data/lib/apiwork/introspection/param/time.rb +36 -0
  43. data/lib/apiwork/introspection/param/uuid.rb +36 -0
  44. data/lib/apiwork/introspection/param.rb +1 -0
  45. data/lib/apiwork/object.rb +244 -2
  46. data/lib/apiwork/representation/attribute.rb +1 -1
  47. data/lib/apiwork/representation/base.rb +105 -0
  48. data/lib/apiwork/representation/element.rb +15 -5
  49. data/lib/apiwork/version.rb +1 -1
  50. metadata +4 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5716ac8fad77cf2a8acaaba2580b0ce732ba68ebeefb4cf33253a436eaa6578d
4
- data.tar.gz: 68225f4d7f8d5dc9826073e1d83febc94dc10b500492530a98791abc77476621
3
+ metadata.gz: 02f6a539dd3708f867af6e298daec039dd03bc4ea38f22e21172940332ee800f
4
+ data.tar.gz: a5603ca5b09b0a8baa3195629b848ceb186e3b1e1d40b987539f02195a76d76f
5
5
  SHA512:
6
- metadata.gz: 94be026e4bd4260f8a69a6d8655ff4f8329aed9f4a2c1a72275e2e895c3759e873d178ce8bf3c1b0c79c5e7f1e99d1cdad8f873ca6461ed31e962abb0afe2f88
7
- data.tar.gz: 6982da2ffa9df35994e22b1fcb6f238f4ab0b7a8745ad85175839a446c2842de574de9423c51bcc3e33836f898e9f549b704536893d4944ac12dd1b1710ce3a1
6
+ metadata.gz: 187ae2ea46e4df89ccca25bc2b88fd5a81b31f1887def0f7acfdb3dbc1fad3284f4b6f91c13956631aa20d6dd6793ac4ff1015ce36c6150c0b29dc669ca2aa18
7
+ data.tar.gz: 844cb5df21fe9803dabd1e450ce6a9c2f1d7f745fcb40d1d92dbfc2d95660d9ee1be719856d1b922ca5a0eeb11f8e711ce8be1eb423f20d90d348f7c55335a32
@@ -58,6 +58,8 @@ module Apiwork
58
58
  end
59
59
 
60
60
  def contract_types(contract_class)
61
+ register_representation_types(contract_class)
62
+
61
63
  builder_class = self.class.contract_builder
62
64
  return unless builder_class
63
65
 
@@ -77,6 +79,19 @@ module Apiwork
77
79
  def serialize(resource, context:, serialize_options:)
78
80
  raise NotImplementedError
79
81
  end
82
+
83
+ private
84
+
85
+ def register_representation_types(contract_class)
86
+ representation_class.type_definitions.each do |name, type_definition|
87
+ case type_definition[:kind]
88
+ when :object
89
+ contract_class.object(name, **type_definition[:options], &type_definition[:block])
90
+ when :union
91
+ contract_class.union(name, **type_definition[:options], &type_definition[:block])
92
+ end
93
+ end
94
+ end
80
95
  end
81
96
  end
82
97
  end
@@ -72,7 +72,7 @@ module Apiwork
72
72
 
73
73
  element = attribute.element
74
74
  if element
75
- if element.type == :array
75
+ if [:array, :record].include?(element.type)
76
76
  param_options[:of] = element.inner
77
77
  else
78
78
  param_options[:shape] = element.shape
@@ -95,7 +95,8 @@ module Apiwork
95
95
  }
96
96
 
97
97
  if association.singular?
98
- object.param(name, type: association_type || :object, **base_options)
98
+ resolved_type = association_type || :object
99
+ object.param(name, type: resolved_type, custom_type: association_type, **base_options)
99
100
  elsif association.collection?
100
101
  if association_type
101
102
  object.param(name, type: :array, **base_options) do |param|
@@ -90,7 +90,7 @@ module Apiwork
90
90
  writable = action_name != :delete
91
91
 
92
92
  object(type_name) do |object|
93
- object.literal(Constants::OP, optional: true, value: action_name.to_s)
93
+ object.literal(Constants::OP, value: action_name.to_s)
94
94
  object.param(:id, optional: action_name != :delete, type: primary_key_type) unless action_name == :create
95
95
 
96
96
  next unless writable
@@ -176,7 +176,7 @@ module Apiwork
176
176
  if attribute.element
177
177
  element = attribute.element
178
178
 
179
- if element.type == :array
179
+ if [:array, :record].include?(element.type)
180
180
  options[:of] = element.inner
181
181
  else
182
182
  options[:shape] = element.shape
@@ -27,6 +27,7 @@ module Apiwork
27
27
  # @return [String]
28
28
  attr_reader :base_path,
29
29
  :enum_registry,
30
+ :explorer_config,
30
31
  :export_configs,
31
32
  :representation_registry,
32
33
  :root_resource,
@@ -133,6 +134,44 @@ module Apiwork
133
134
  block.arity.positive? ? yield(@export_configs[name]) : @export_configs[name].instance_eval(&block)
134
135
  end
135
136
 
137
+ # @api public
138
+ # Configures the explorer for this API.
139
+ #
140
+ # The explorer is an interactive UI for browsing and testing API endpoints.
141
+ # Requires the `apiwork-explorer` gem.
142
+ #
143
+ # @yield Block evaluated in explorer context.
144
+ # @yieldparam explorer [Configuration]
145
+ # @return [void]
146
+ #
147
+ # @example Enable with defaults
148
+ # explorer
149
+ #
150
+ # @example Custom configuration
151
+ # explorer do
152
+ # mode :always
153
+ # path '/explorer'
154
+ # end
155
+ def explorer(&block)
156
+ unless defined?(Apiwork::Explorer::Engine)
157
+ raise ConfigurationError,
158
+ 'explorer requires the apiwork-explorer gem. ' \
159
+ "Add it to your Gemfile: gem 'apiwork-explorer'"
160
+ end
161
+
162
+ unless @explorer_config
163
+ options = Configurable.define do
164
+ option :mode, default: :auto, enum: %i[auto always never], type: :symbol
165
+ option :path, default: '/.explorer', type: :string
166
+ end
167
+ @explorer_config = Configuration.new(options)
168
+ end
169
+
170
+ return @explorer_config unless block
171
+
172
+ block.arity.positive? ? yield(@explorer_config) : @explorer_config.instance_eval(&block)
173
+ end
174
+
136
175
  # @api public
137
176
  # Sets or configures the adapter for this API.
138
177
  #
@@ -644,6 +683,7 @@ module Apiwork
644
683
  @info = nil
645
684
  @locales = []
646
685
  @raises = []
686
+ @explorer_config = nil
647
687
  @export_configs = {}
648
688
  @adapter_config = nil
649
689
  @root_resource = Resource.new(self)
@@ -682,16 +722,33 @@ module Apiwork
682
722
  end.join('/')
683
723
  end
684
724
 
725
+ def transform_key(key)
726
+ key_string = key.to_s
727
+
728
+ return key_string if key_string.match?(/\A[A-Z]+\z/)
729
+
730
+ case key_format
731
+ when :camel then key_string.camelize(:lower)
732
+ when :pascal then key_string.camelize
733
+ when :kebab then key_string.dasherize
734
+ when :underscore then key_string.underscore
735
+ else key_string
736
+ end
737
+ end
738
+
739
+ def normalize_key(key)
740
+ key_string = key.to_s
741
+
742
+ return key_string if key_string.match?(/\A[A-Z]+\z/)
743
+
744
+ key_string.underscore
745
+ end
746
+
685
747
  def normalize_request(request)
686
- return request if %i[camel pascal kebab].exclude?(key_format)
748
+ return request if key_format == :keep
687
749
 
688
750
  request.transform do |hash|
689
- hash.deep_transform_keys do |key|
690
- key_string = key.to_s
691
- next key if key_string.match?(/\A[A-Z]+\z/)
692
-
693
- key_string.underscore.to_sym
694
- end
751
+ hash.deep_transform_keys { |key| normalize_key(key).to_sym }
695
752
  end
696
753
  end
697
754
 
@@ -701,16 +758,9 @@ module Apiwork
701
758
 
702
759
  def prepare_response(response)
703
760
  result = adapter.apply_response_transformers(response)
704
- case key_format
705
- when :camel
706
- result.transform { |hash| hash.deep_transform_keys { |key| key.to_s.camelize(:lower).to_sym } }
707
- when :pascal
708
- result.transform { |hash| hash.deep_transform_keys { |key| key.to_s.camelize.to_sym } }
709
- when :kebab
710
- result.transform { |hash| hash.deep_transform_keys { |key| key.to_s.dasherize.to_sym } }
711
- else
712
- result
713
- end
761
+ return result if key_format == :keep
762
+
763
+ result.transform { |hash| hash.deep_transform_keys { |key| transform_key(key).to_sym } }
714
764
  end
715
765
 
716
766
  def type?(name, scope: nil)
@@ -32,7 +32,7 @@ module Apiwork
32
32
  # This is the verbose form. Prefer sugar methods (string, integer, etc.)
33
33
  # for static definitions. Use `of` for dynamic element generation.
34
34
  #
35
- # @param type [Symbol] [:array, :binary, :boolean, :date, :datetime, :decimal, :integer, :literal, :number, :object, :string, :time, :union, :uuid]
35
+ # @param type [Symbol] [:array, :binary, :boolean, :date, :datetime, :decimal, :integer, :literal, :number, :object, :string, :time, :union, :unknown, :uuid]
36
36
  # The element type. Custom type references are also allowed.
37
37
  # @param discriminator [Symbol, nil] (nil)
38
38
  # The discriminator field name. Unions only.
@@ -64,9 +64,25 @@ module Apiwork
64
64
  # string :name
65
65
  # end
66
66
  # end
67
+ #
68
+ # @example Discriminated union
69
+ # array :payments do
70
+ # of :union, discriminator: :type do
71
+ # variant tag: 'card' do
72
+ # object do
73
+ # string :last_four
74
+ # end
75
+ # end
76
+ # variant tag: 'bank' do
77
+ # object do
78
+ # string :account_number
79
+ # end
80
+ # end
81
+ # end
82
+ # end
67
83
  def of(type, discriminator: nil, enum: nil, format: nil, max: nil, min: nil, value: nil, &block)
68
84
  case type
69
- when :string, :integer, :decimal, :boolean, :number, :datetime, :date, :uuid, :time, :binary
85
+ when :string, :integer, :decimal, :boolean, :number, :datetime, :date, :uuid, :time, :binary, :unknown
70
86
  set_type(type, enum:, format:, max:, min:)
71
87
  when :literal
72
88
  @type = :literal
@@ -90,6 +106,15 @@ module Apiwork
90
106
  @inner = inner
91
107
  @shape = inner.shape
92
108
  @defined = true
109
+ when :record
110
+ raise ConfigurationError, 'record requires a block' unless block
111
+
112
+ inner = Element.new
113
+ block.arity.positive? ? yield(inner) : inner.instance_eval(&block)
114
+ inner.validate!
115
+ @type = :record
116
+ @inner = inner
117
+ @defined = true
93
118
  when :union
94
119
  raise ConfigurationError, 'union requires a block' unless block
95
120
 
@@ -105,6 +130,12 @@ module Apiwork
105
130
  @defined = true
106
131
  end
107
132
  end
133
+
134
+ def reference(type_name)
135
+ @type = type_name
136
+ @custom_type = type_name
137
+ @defined = true
138
+ end
108
139
  end
109
140
  end
110
141
  end
@@ -7,7 +7,7 @@ module Apiwork
7
7
  #
8
8
  # Accessed via `object :name do` in API or contract definitions.
9
9
  # Use type methods to define params: {#string}, {#integer}, {#decimal},
10
- # {#boolean}, {#array}, {#object}, {#union}, {#reference}.
10
+ # {#boolean}, {#array}, {#record}, {#object}, {#union}, {#reference}.
11
11
  #
12
12
  # @see API::Element Block context for array/variant elements
13
13
  # @see Contract::Object Block context for inline objects
@@ -32,7 +32,7 @@ module Apiwork
32
32
  #
33
33
  # @param name [Symbol]
34
34
  # The param name.
35
- # @param type [Symbol, nil] (nil) [:array, :binary, :boolean, :date, :datetime, :decimal, :integer, :literal, :number, :object, :string, :time, :union, :uuid]
35
+ # @param type [Symbol, nil] (nil) [:array, :binary, :boolean, :date, :datetime, :decimal, :integer, :literal, :number, :object, :record, :string, :time, :union, :unknown, :uuid]
36
36
  # The param type.
37
37
  # @param as [Symbol, nil] (nil)
38
38
  # The target attribute name.
@@ -59,7 +59,7 @@ module Apiwork
59
59
  # @param nullable [Boolean] (false)
60
60
  # Whether the value can be `null`.
61
61
  # @param of [Symbol, Hash, nil] (nil)
62
- # The element type. Arrays only.
62
+ # The element or value type. Arrays and records only.
63
63
  # @param optional [Boolean] (false)
64
64
  # Whether the param is optional.
65
65
  # @param required [Boolean] (false)
@@ -85,6 +85,7 @@ module Apiwork
85
85
  name,
86
86
  type: nil,
87
87
  as: nil,
88
+ custom_type: nil,
88
89
  default: nil,
89
90
  deprecated: false,
90
91
  description: nil,
@@ -103,11 +104,12 @@ module Apiwork
103
104
  &block
104
105
  )
105
106
  resolved_of = resolve_of(of, type, &block)
106
- resolved_shape = type == :array ? nil : (shape || build_shape(type, discriminator, &block))
107
+ resolved_shape = [:array, :record].include?(type) ? nil : (shape || build_shape(type, discriminator, &block))
107
108
  discriminator = resolved_of&.discriminator if type == :array
108
109
 
109
110
  param_hash = {
110
111
  as:,
112
+ custom_type:,
111
113
  default:,
112
114
  deprecated:,
113
115
  description:,
@@ -193,10 +195,73 @@ module Apiwork
193
195
  )
194
196
  end
195
197
 
198
+ # @api public
199
+ # Defines a record param with value type.
200
+ #
201
+ # @param name [Symbol]
202
+ # The param name.
203
+ # @param as [Symbol, nil] (nil)
204
+ # The target attribute name.
205
+ # @param default [Object, nil] (nil)
206
+ # The default value.
207
+ # @param deprecated [Boolean] (false)
208
+ # Whether deprecated. Metadata included in exports.
209
+ # @param description [String, nil] (nil)
210
+ # The description. Metadata included in exports.
211
+ # @param nullable [Boolean] (false)
212
+ # Whether the value can be `null`.
213
+ # @param optional [Boolean] (false)
214
+ # Whether the param is optional.
215
+ # @param required [Boolean] (false)
216
+ # Whether the param is required.
217
+ # @yield block for defining value type
218
+ # @yieldparam element [API::Element]
219
+ # @return [void]
220
+ #
221
+ # @example instance_eval style
222
+ # record :scores do
223
+ # integer
224
+ # end
225
+ #
226
+ # @example yield style
227
+ # record :scores do |element|
228
+ # element.integer
229
+ # end
230
+ def record(
231
+ name,
232
+ as: nil,
233
+ default: nil,
234
+ deprecated: false,
235
+ description: nil,
236
+ nullable: false,
237
+ optional: false,
238
+ required: false,
239
+ &block
240
+ )
241
+ raise ArgumentError, 'record requires a block' unless block
242
+
243
+ element = Element.new
244
+ block.arity.positive? ? yield(element) : element.instance_eval(&block)
245
+ element.validate!
246
+
247
+ param(
248
+ name,
249
+ as:,
250
+ default:,
251
+ deprecated:,
252
+ description:,
253
+ nullable:,
254
+ optional:,
255
+ required:,
256
+ of: element,
257
+ type: :record,
258
+ )
259
+ end
260
+
196
261
  private
197
262
 
198
263
  def resolve_of(of, type, &block)
199
- return nil unless type == :array
264
+ return nil unless [:array, :record].include?(type)
200
265
 
201
266
  if block
202
267
  element = Element.new
@@ -35,6 +35,22 @@ module Apiwork
35
35
  end
36
36
  end
37
37
 
38
+ if api_class.explorer_config && defined?(Apiwork::Explorer::Engine)
39
+ mount_explorer = case api_class.explorer_config.mode
40
+ when :always then true
41
+ when :never then false
42
+ when :auto then Rails.env.development?
43
+ end
44
+
45
+ if mount_explorer
46
+ scope path: api_class.transform_path(api_class.base_path) do
47
+ mount Apiwork::Explorer::Engine,
48
+ at: api_class.explorer_config.path,
49
+ defaults: { api_base_path: api_class.base_path }
50
+ end
51
+ end
52
+ end
53
+
38
54
  scope module: api_class.namespaces.map(&:to_s).join('/').underscore,
39
55
  path: api_class.transform_path(api_class.base_path) do
40
56
  router.draw_resources(self, api_class.root_resource.resources, api_class)
@@ -7,6 +7,7 @@ module Apiwork
7
7
 
8
8
  def validate_type!(value)
9
9
  valid = case type
10
+ when :boolean then value.is_a?(TrueClass) || value.is_a?(FalseClass)
10
11
  when :symbol then value.is_a?(Symbol)
11
12
  when :string then value.is_a?(String)
12
13
  when :integer then value.is_a?(Integer)
@@ -24,6 +24,8 @@ module Apiwork
24
24
  raise ConfigurationError, "Unknown option: #{name}" unless option
25
25
 
26
26
  if args.empty? && !block
27
+ return @storage[name] = true if option.type == :boolean
28
+
27
29
  stored = @storage[name]
28
30
 
29
31
  if option.nested?
@@ -37,7 +37,7 @@ module Apiwork
37
37
  # This is the verbose form. Prefer sugar methods (string, integer, etc.)
38
38
  # for static definitions. Use `of` for dynamic element generation.
39
39
  #
40
- # @param type [Symbol] [:array, :binary, :boolean, :date, :datetime, :decimal, :integer, :literal, :number, :object, :string, :time, :union, :uuid]
40
+ # @param type [Symbol] [:array, :binary, :boolean, :date, :datetime, :decimal, :integer, :literal, :number, :object, :string, :time, :union, :unknown, :uuid]
41
41
  # The element type. Custom type references are also allowed.
42
42
  # @param discriminator [Symbol, nil] (nil)
43
43
  # The discriminator field name. Unions only.
@@ -75,7 +75,7 @@ module Apiwork
75
75
  resolved_enum = enum.is_a?(Symbol) ? resolve_enum(enum) : enum
76
76
 
77
77
  case type
78
- when :string, :integer, :decimal, :boolean, :number, :datetime, :date, :uuid, :time, :binary
78
+ when :string, :integer, :decimal, :boolean, :number, :datetime, :date, :uuid, :time, :binary, :unknown
79
79
  set_type(type, format:, max:, min:, enum: resolved_enum)
80
80
  when :literal
81
81
  @type = :literal
@@ -99,6 +99,15 @@ module Apiwork
99
99
  @inner = inner
100
100
  @shape = inner.shape
101
101
  @defined = true
102
+ when :record
103
+ raise ConfigurationError, 'record requires a block' unless block
104
+
105
+ inner = Element.new(@contract_class)
106
+ block.arity.positive? ? yield(inner) : inner.instance_eval(&block)
107
+ inner.validate!
108
+ @type = :record
109
+ @inner = inner
110
+ @defined = true
102
111
  when :union
103
112
  raise ConfigurationError, 'union requires a block' unless block
104
113
 
@@ -115,6 +124,12 @@ module Apiwork
115
124
  end
116
125
  end
117
126
 
127
+ def reference(type_name)
128
+ @type = type_name
129
+ @custom_type = type_name
130
+ @defined = true
131
+ end
132
+
118
133
  private
119
134
 
120
135
  def resolve_enum(enum)
@@ -85,6 +85,7 @@ module Apiwork
85
85
 
86
86
  return coerce_union(value, param_options[:union]) if type == :union
87
87
  return coerce_array(value, param_options) if type == :array && value.is_a?(Array)
88
+ return coerce_record(value, param_options) if type == :record && value.is_a?(Hash)
88
89
  return Coercer.coerce(param_options[:shape], value) if param_options[:shape] && value.is_a?(Hash)
89
90
 
90
91
  if value.is_a?(Hash) && type && !PRIMITIVES.key?(type)
@@ -105,7 +106,9 @@ module Apiwork
105
106
  custom_shape = resolve_custom_shape(of_type) if of_type && !PRIMITIVES.key?(of_type)
106
107
 
107
108
  array.map do |item|
108
- if of_shape && item.is_a?(Hash)
109
+ if of_type == :union && item.is_a?(Hash)
110
+ coerce_union(item, of_shape)
111
+ elsif of_shape && item.is_a?(Hash)
109
112
  Coercer.coerce(of_shape, item)
110
113
  elsif of_type && PRIMITIVES.key?(of_type)
111
114
  coerced = coerce_primitive(item, of_type)
@@ -120,6 +123,23 @@ module Apiwork
120
123
  end
121
124
  end
122
125
 
126
+ def coerce_record(hash, param_options)
127
+ of = param_options[:of]
128
+ of_type = of&.type
129
+ of_shape = of&.shape
130
+
131
+ hash.transform_values do |item|
132
+ if of_shape && item.is_a?(Hash)
133
+ Coercer.coerce(of_shape, item)
134
+ elsif of_type && PRIMITIVES.key?(of_type)
135
+ coerced = coerce_primitive(item, of_type)
136
+ coerced.nil? ? item : coerced
137
+ else
138
+ item
139
+ end
140
+ end
141
+ end
142
+
123
143
  def coerce_union(value, union)
124
144
  if union.variants.any? { |variant| variant[:type] == :boolean }
125
145
  coerced = coerce_primitive(value, :boolean)
@@ -191,7 +211,9 @@ module Apiwork
191
211
  type_definition = @shape.contract_class.resolve_custom_type(type_name)
192
212
  return @type_cache[type_name] = nil unless type_definition
193
213
 
194
- @type_cache[type_name] = Object.new(@shape.contract_class).tap do |type_shape|
214
+ scope = type_definition.scope || @shape.contract_class
215
+
216
+ @type_cache[type_name] = Object.new(scope).tap do |type_shape|
195
217
  type_shape.copy_type_definition_params(type_definition, type_shape)
196
218
  end
197
219
  end
@@ -85,10 +85,14 @@ module Apiwork
85
85
  end
86
86
 
87
87
  def deserialize_array(array, param_options)
88
+ of = param_options[:of]
89
+
88
90
  array.map do |item|
89
91
  next item unless item.is_a?(Hash)
90
92
 
91
- if param_options[:shape]
93
+ if of&.type == :union && of.shape.is_a?(Apiwork::Union)
94
+ deserialize_union(item, of.shape)
95
+ elsif param_options[:shape]
92
96
  Deserializer.deserialize(param_options[:shape], item)
93
97
  else
94
98
  item
@@ -32,11 +32,22 @@ module Apiwork
32
32
 
33
33
  if param_options[:shape] && value.is_a?(Hash)
34
34
  transformed[name] = Transformer.transform(param_options[:shape], value)
35
- elsif param_options[:type] == :array && value.is_a?(Array)
35
+ elsif param_options[:type] == :record && value.is_a?(Hash)
36
36
  of = param_options[:of]
37
37
  of_shape = of&.shape
38
38
 
39
39
  if of_shape
40
+ transformed[name] = value.transform_values do |item|
41
+ item.is_a?(Hash) ? Transformer.transform(of_shape, item) : item
42
+ end
43
+ end
44
+ elsif param_options[:type] == :array && value.is_a?(Array)
45
+ of = param_options[:of]
46
+ of_shape = of&.shape
47
+
48
+ if of&.type == :union
49
+ transformed[name] = value
50
+ elsif of_shape
40
51
  transformed[name] = value.map do |item|
41
52
  item.is_a?(Hash) ? Transformer.transform(of_shape, item) : item
42
53
  end
@@ -85,7 +96,9 @@ module Apiwork
85
96
  end
86
97
 
87
98
  def build_type_shape(type_definition, contract_class)
88
- type_shape = Object.new(contract_class, action_name: @shape.action_name)
99
+ scope = type_definition.scope || contract_class
100
+
101
+ type_shape = Object.new(scope, action_name: @shape.action_name)
89
102
  type_shape.copy_type_definition_params(type_definition, type_shape)
90
103
  type_shape
91
104
  end
@@ -201,6 +201,8 @@ module Apiwork
201
201
  validate_shape_object(value, param_options[:shape], field_path, max_depth, current_depth)
202
202
  elsif param_options[:type] == :array && value.is_a?(Array)
203
203
  validate_array_param(value, param_options, field_path, max_depth, current_depth)
204
+ elsif param_options[:type] == :record && value.is_a?(Hash)
205
+ validate_record_param(value, param_options, field_path, max_depth, current_depth)
204
206
  else
205
207
  [[], value]
206
208
  end
@@ -230,6 +232,41 @@ module Apiwork
230
232
  array_issues.empty? ? [[], array_values] : [array_issues, NOT_SET]
231
233
  end
232
234
 
235
+ def validate_record_param(value, param_options, field_path, max_depth, current_depth)
236
+ issues = []
237
+ validated = {}
238
+
239
+ of = param_options[:of]
240
+ of_type = of&.type
241
+ of_shape = of&.shape
242
+
243
+ value.each do |key, item|
244
+ item_path = field_path + [key]
245
+
246
+ if of_shape
247
+ validator = Validator.new(normalize_shape(of_shape))
248
+ shape_result = validator.validate(
249
+ item,
250
+ max_depth:,
251
+ current_depth: current_depth + 1,
252
+ path: item_path,
253
+ )
254
+ if shape_result.invalid?
255
+ issues.concat(shape_result.issues)
256
+ else
257
+ validated[key] = shape_result.params
258
+ end
259
+ elsif of_type
260
+ type_error = validate_type(key, item, of_type, item_path)
261
+ type_error ? issues << type_error : validated[key] = item
262
+ else
263
+ validated[key] = item
264
+ end
265
+ end
266
+
267
+ issues.empty? ? [[], validated] : [issues, NOT_SET]
268
+ end
269
+
233
270
  def check_unknown_params(data, path)
234
271
  extra_keys = data.keys - @shape.params.keys
235
272
  extra_keys.map do |key|
@@ -280,7 +317,10 @@ module Apiwork
280
317
  array.each_with_index do |item, index|
281
318
  item_path = field_path + [index]
282
319
 
283
- if of_shape
320
+ if of&.type == :union && of_shape.is_a?(Apiwork::Union)
321
+ error, validated = validate_union(nil, item, of_shape, item_path, current_depth:, max_depth:)
322
+ error ? issues << error : values << validated
323
+ elsif of_shape
284
324
  validator = Validator.new(normalize_shape(of_shape))
285
325
  shape_result = validator.validate(
286
326
  item,
@@ -369,6 +409,7 @@ module Apiwork
369
409
  when :uuid then value.is_a?(String) && value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
370
410
  when :object then value.is_a?(Hash)
371
411
  when :array then value.is_a?(Array)
412
+ when :record then value.is_a?(Hash)
372
413
  when :decimal, :number then value.is_a?(Numeric)
373
414
  else true
374
415
  end
@@ -600,7 +641,9 @@ module Apiwork
600
641
  end
601
642
 
602
643
  def validate_with_type_definition(type_definition, value, path, current_depth:, exclude_param: nil, max_depth:)
603
- type_shape = Object.new(@shape.contract_class, action_name: @shape.action_name)
644
+ scope = type_definition.scope || @shape.contract_class
645
+
646
+ type_shape = Object.new(scope, action_name: @shape.action_name)
604
647
  type_shape.copy_type_definition_params(type_definition, type_shape)
605
648
  type_shape.params.delete(exclude_param) if exclude_param
606
649