apiwork 0.3.1 → 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 (54) 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 +4 -3
  4. data/lib/apiwork/adapter/standard/capability/writing/contract_builder.rb +13 -9
  5. data/lib/apiwork/api/base.rb +105 -17
  6. data/lib/apiwork/api/element.rb +35 -4
  7. data/lib/apiwork/api/object.rb +72 -7
  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 +19 -4
  12. data/lib/apiwork/contract/object/coercer.rb +31 -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 +49 -11
  16. data/lib/apiwork/contract/object.rb +79 -9
  17. data/lib/apiwork/element.rb +34 -1
  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/api.rb +18 -0
  29. data/lib/apiwork/introspection/dump/action.rb +1 -1
  30. data/lib/apiwork/introspection/dump/api.rb +2 -0
  31. data/lib/apiwork/introspection/dump/param.rb +36 -20
  32. data/lib/apiwork/introspection/dump/resource.rb +7 -4
  33. data/lib/apiwork/introspection/dump/type.rb +31 -25
  34. data/lib/apiwork/introspection/param/array.rb +26 -0
  35. data/lib/apiwork/introspection/param/base.rb +15 -25
  36. data/lib/apiwork/introspection/param/binary.rb +36 -0
  37. data/lib/apiwork/introspection/param/boolean.rb +36 -0
  38. data/lib/apiwork/introspection/param/date.rb +36 -0
  39. data/lib/apiwork/introspection/param/date_time.rb +36 -0
  40. data/lib/apiwork/introspection/param/decimal.rb +26 -0
  41. data/lib/apiwork/introspection/param/integer.rb +26 -0
  42. data/lib/apiwork/introspection/param/number.rb +26 -0
  43. data/lib/apiwork/introspection/param/record.rb +71 -0
  44. data/lib/apiwork/introspection/param/string.rb +26 -0
  45. data/lib/apiwork/introspection/param/time.rb +36 -0
  46. data/lib/apiwork/introspection/param/uuid.rb +36 -0
  47. data/lib/apiwork/introspection/param.rb +1 -0
  48. data/lib/apiwork/introspection.rb +17 -4
  49. data/lib/apiwork/object.rb +246 -4
  50. data/lib/apiwork/representation/attribute.rb +2 -2
  51. data/lib/apiwork/representation/base.rb +107 -2
  52. data/lib/apiwork/representation/element.rb +15 -5
  53. data/lib/apiwork/version.rb +1 -1
  54. metadata +6 -4
@@ -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)
@@ -156,6 +176,13 @@ module Apiwork
156
176
 
157
177
  next if discriminator
158
178
 
179
+ if PRIMITIVES.key?(variant_type) && !value.is_a?(Hash) && !value.is_a?(Array)
180
+ coerced = coerce_primitive(value, variant_type)
181
+ return coerced unless coerced.nil?
182
+
183
+ next
184
+ end
185
+
159
186
  custom_shape = resolve_custom_shape(variant_type)
160
187
  next unless custom_shape
161
188
 
@@ -184,7 +211,9 @@ module Apiwork
184
211
  type_definition = @shape.contract_class.resolve_custom_type(type_name)
185
212
  return @type_cache[type_name] = nil unless type_definition
186
213
 
187
- @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|
188
217
  type_shape.copy_type_definition_params(type_definition, type_shape)
189
218
  end
190
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
@@ -129,14 +129,7 @@ module Apiwork
129
129
  return nil if param_options[:optional]
130
130
  return nil if param_options[:nullable] && data.key?(name) && value.nil?
131
131
 
132
- missing = case param_options[:type]
133
- when :boolean, :string
134
- value.nil?
135
- else
136
- value.blank?
137
- end
138
-
139
- return nil unless missing
132
+ return nil unless value.nil?
140
133
 
141
134
  if param_options[:enum].present?
142
135
  Issue.new(
@@ -208,6 +201,8 @@ module Apiwork
208
201
  validate_shape_object(value, param_options[:shape], field_path, max_depth, current_depth)
209
202
  elsif param_options[:type] == :array && value.is_a?(Array)
210
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)
211
206
  else
212
207
  [[], value]
213
208
  end
@@ -237,6 +232,41 @@ module Apiwork
237
232
  array_issues.empty? ? [[], array_values] : [array_issues, NOT_SET]
238
233
  end
239
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
+
240
270
  def check_unknown_params(data, path)
241
271
  extra_keys = data.keys - @shape.params.keys
242
272
  extra_keys.map do |key|
@@ -287,7 +317,10 @@ module Apiwork
287
317
  array.each_with_index do |item, index|
288
318
  item_path = field_path + [index]
289
319
 
290
- 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
291
324
  validator = Validator.new(normalize_shape(of_shape))
292
325
  shape_result = validator.validate(
293
326
  item,
@@ -376,6 +409,7 @@ module Apiwork
376
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)
377
410
  when :object then value.is_a?(Hash)
378
411
  when :array then value.is_a?(Array)
412
+ when :record then value.is_a?(Hash)
379
413
  when :decimal, :number then value.is_a?(Numeric)
380
414
  else true
381
415
  end
@@ -437,6 +471,8 @@ module Apiwork
437
471
  most_specific_error = error
438
472
  elsif error.code == :value_invalid && (most_specific_error.nil? || most_specific_error.code != :field_unknown)
439
473
  most_specific_error = error
474
+ elsif error.path.length > path.length && most_specific_error.nil?
475
+ most_specific_error = error
440
476
  end
441
477
  end
442
478
 
@@ -448,7 +484,7 @@ module Apiwork
448
484
  translate_detail(:type_invalid),
449
485
  path:,
450
486
  meta: {
451
- actual: value.is_a?(Hash) ? :hash : value.class.name.underscore.to_sym,
487
+ actual: value.is_a?(Hash) ? :object : value.class.name.underscore.to_sym,
452
488
  expected: expected_types.join(' | '),
453
489
  field: name,
454
490
  },
@@ -605,7 +641,9 @@ module Apiwork
605
641
  end
606
642
 
607
643
  def validate_with_type_definition(type_definition, value, path, current_depth:, exclude_param: nil, max_depth:)
608
- 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)
609
647
  type_shape.copy_type_definition_params(type_definition, type_shape)
610
648
  type_shape.params.delete(exclude_param) if exclude_param
611
649
 
@@ -56,7 +56,7 @@ module Apiwork
56
56
  #
57
57
  # @param name [Symbol]
58
58
  # The param name.
59
- # @param type [Symbol, nil] (nil) [:array, :binary, :boolean, :date, :datetime, :decimal, :integer, :literal, :number, :object, :string, :time, :union, :uuid]
59
+ # @param type [Symbol, nil] (nil) [:array, :binary, :boolean, :date, :datetime, :decimal, :integer, :literal, :number, :object, :record, :string, :time, :union, :unknown, :uuid]
60
60
  # The param type.
61
61
  # @param as [Symbol, nil] (nil)
62
62
  # The target attribute name.
@@ -72,10 +72,10 @@ module Apiwork
72
72
  # The allowed values or enum reference.
73
73
  # @param example [Object, nil] (nil)
74
74
  # The example value. Metadata included in exports.
75
- # @param format [Symbol, nil] (nil) [:date, :datetime, :double, :email, :float, :hostname, :int32, :int64, :ipv4, :ipv6, :password, :url, :uuid]
75
+ # @param format [Symbol, nil] (nil) [:date, :datetime, :double, :email, :float, :hostname, :int32, :int64, :ipv4, :ipv6, :password, :text, :url, :uuid]
76
76
  # Format hint for exports. Does not change the type, but exports may add validation or documentation based on it.
77
77
  # Valid formats by type: `:decimal`/`:number` (`:double`, `:float`), `:integer` (`:int32`, `:int64`),
78
- # `:string` (`:date`, `:datetime`, `:email`, `:hostname`, `:ipv4`, `:ipv6`, `:password`, `:url`, `:uuid`).
78
+ # `:string` (`:date`, `:datetime`, `:email`, `:hostname`, `:ipv4`, `:ipv6`, `:password`, `:text`, `:url`, `:uuid`).
79
79
  # @param max [Integer, nil] (nil)
80
80
  # The maximum. For `:array`: size. For `:decimal`, `:integer`, `:number`: value. For `:string`: length.
81
81
  # @param min [Integer, nil] (nil)
@@ -83,7 +83,7 @@ module Apiwork
83
83
  # @param nullable [Boolean] (false)
84
84
  # Whether the value can be `null`.
85
85
  # @param of [Symbol, Hash, nil] (nil)
86
- # The element type. Arrays only.
86
+ # The element or value type. Arrays and records only.
87
87
  # @param optional [Boolean] (false)
88
88
  # Whether the param is optional.
89
89
  # @param required [Boolean] (false)
@@ -109,6 +109,7 @@ module Apiwork
109
109
  name,
110
110
  type: nil,
111
111
  as: nil,
112
+ custom_type: nil,
112
113
  default: nil,
113
114
  deprecated: false,
114
115
  description: nil,
@@ -238,6 +239,69 @@ module Apiwork
238
239
  )
239
240
  end
240
241
 
242
+ # @api public
243
+ # Defines a record param with value type.
244
+ #
245
+ # @param name [Symbol]
246
+ # The param name.
247
+ # @param as [Symbol, nil] (nil)
248
+ # The target attribute name.
249
+ # @param default [Object, nil] (nil)
250
+ # The default value.
251
+ # @param deprecated [Boolean] (false)
252
+ # Whether deprecated. Metadata included in exports.
253
+ # @param description [String, nil] (nil)
254
+ # The description. Metadata included in exports.
255
+ # @param nullable [Boolean] (false)
256
+ # Whether the value can be `null`.
257
+ # @param optional [Boolean] (false)
258
+ # Whether the param is optional.
259
+ # @param required [Boolean] (false)
260
+ # Whether the param is required.
261
+ # @yield block for defining value type
262
+ # @yieldparam element [Contract::Element]
263
+ # @return [void]
264
+ #
265
+ # @example instance_eval style
266
+ # record :scores do
267
+ # integer
268
+ # end
269
+ #
270
+ # @example yield style
271
+ # record :scores do |element|
272
+ # element.integer
273
+ # end
274
+ def record(
275
+ name,
276
+ as: nil,
277
+ default: nil,
278
+ deprecated: false,
279
+ description: nil,
280
+ nullable: false,
281
+ optional: false,
282
+ required: false,
283
+ &block
284
+ )
285
+ raise ConfigurationError, 'record requires a block' unless block
286
+
287
+ element = Element.new(@contract_class)
288
+ block.arity.positive? ? yield(element) : element.instance_eval(&block)
289
+ element.validate!
290
+
291
+ param(
292
+ name,
293
+ as:,
294
+ default:,
295
+ deprecated:,
296
+ description:,
297
+ nullable:,
298
+ optional:,
299
+ required:,
300
+ of: element,
301
+ type: :record,
302
+ )
303
+ end
304
+
241
305
  def wrapped?
242
306
  @wrapped
243
307
  end
@@ -264,8 +328,10 @@ module Apiwork
264
328
  type_definition.params.each do |param_name, param_options|
265
329
  nested_shape = param_options[:shape]
266
330
 
267
- if param_options[:type] == :array && nested_shape.is_a?(Apiwork::API::Object)
331
+ if param_options[:type] == :array && nested_shape.is_a?(API::Object)
268
332
  target_param.param(param_name, **param_options.except(:name))
333
+ elsif param_options[:type] == :array
334
+ target_param.param(param_name, **param_options.except(:name, :shape, :discriminator))
269
335
  elsif nested_shape.is_a?(API::Object)
270
336
  copy_nested_object_param(target_param, param_name, param_options, nested_shape)
271
337
  elsif nested_shape.is_a?(API::Union)
@@ -416,7 +482,9 @@ module Apiwork
416
482
  as:,
417
483
  options:
418
484
  )
419
- union = Union.new(@contract_class, discriminator: type_definition.discriminator)
485
+ scope = type_definition.scope || @contract_class
486
+
487
+ union = Union.new(scope, discriminator: type_definition.discriminator)
420
488
 
421
489
  type_definition.variants.each do |variant|
422
490
  if variant[:shape].is_a?(API::Object)
@@ -467,8 +535,10 @@ module Apiwork
467
535
  options:,
468
536
  &block
469
537
  )
538
+ scope = type_definition.scope || @contract_class
539
+
470
540
  shape = Object.new(
471
- @contract_class,
541
+ scope,
472
542
  action_name: @action_name,
473
543
  visited_types: visited_types.dup.add([@contract_class.object_id, type]),
474
544
  )
@@ -516,7 +586,7 @@ module Apiwork
516
586
  end
517
587
 
518
588
  def resolve_of(of, type, &block)
519
- return nil unless type == :array
589
+ return nil unless [:array, :record].include?(type)
520
590
 
521
591
  if block_given?
522
592
  element = Element.new(@contract_class)
@@ -531,7 +601,7 @@ module Apiwork
531
601
  end
532
602
 
533
603
  def resolve_shape(shape, type, &block)
534
- return nil if type == :array
604
+ return nil if [:array, :record].include?(type)
535
605
 
536
606
  if shape
537
607
  shape
@@ -82,7 +82,7 @@ module Apiwork
82
82
  #
83
83
  # @param enum [Array, Symbol, nil] (nil)
84
84
  # The allowed values.
85
- # @param format [Symbol, nil] (nil) [:date, :datetime, :email, :hostname, :ipv4, :ipv6, :password, :url, :uuid]
85
+ # @param format [Symbol, nil] (nil) [:date, :datetime, :email, :hostname, :ipv4, :ipv6, :password, :text, :url, :uuid]
86
86
  # Format hint for exports. Does not change the type, but exports may add validation or documentation based on it.
87
87
  # Valid formats by type: `:string`.
88
88
  # @param max [Integer, nil] (nil)
@@ -250,6 +250,19 @@ module Apiwork
250
250
  of(:binary)
251
251
  end
252
252
 
253
+ # @api public
254
+ # Defines an unknown.
255
+ #
256
+ # @return [void]
257
+ #
258
+ # @example
259
+ # array :values do
260
+ # unknown
261
+ # end
262
+ def unknown
263
+ of(:unknown)
264
+ end
265
+
253
266
  # @api public
254
267
  # Defines an object.
255
268
  #
@@ -300,6 +313,26 @@ module Apiwork
300
313
  of(:array, &block)
301
314
  end
302
315
 
316
+ # @api public
317
+ # Defines a record.
318
+ #
319
+ # @yield block defining value type
320
+ # @yieldparam element [Element]
321
+ # @return [void]
322
+ #
323
+ # @example instance_eval style
324
+ # record :scores do
325
+ # integer
326
+ # end
327
+ #
328
+ # @example yield style
329
+ # record :scores do |element|
330
+ # element.integer
331
+ # end
332
+ def record(&block)
333
+ of(:record, &block)
334
+ end
335
+
303
336
  # @api public
304
337
  # Defines a union.
305
338
  #
@@ -263,10 +263,7 @@ module Apiwork
263
263
  end
264
264
 
265
265
  def extract_options_from_config(config)
266
- self.class.options.keys.each_with_object({}) do |key, hash|
267
- value = config.public_send(key)
268
- hash[key] = value unless value.nil?
269
- end
266
+ config.to_h.slice(*self.class.options.keys).compact
270
267
  end
271
268
 
272
269
  def validate_options!
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apiwork
4
+ module Export
5
+ class BuilderMapper
6
+ class << self
7
+ def map(export, surface)
8
+ new(export).map(surface)
9
+ end
10
+ end
11
+
12
+ def initialize(export)
13
+ @export = export
14
+ end
15
+
16
+ def map(surface)
17
+ build_builders(surface.types)
18
+ end
19
+
20
+ def build_builders(types)
21
+ builders = types.sort_by { |name, _type| name.to_s }.flat_map do |name, type|
22
+ if type.union?
23
+ build_union_builders(name, type, types)
24
+ elsif type.object?
25
+ [build_object_builder(name, type)]
26
+ end
27
+ end.compact
28
+
29
+ builders.join("\n\n")
30
+ end
31
+
32
+ private
33
+
34
+ def build_object_builder(name, type)
35
+ type_name = pascal_case(name)
36
+ defaulted, required = classify_fields(type.shape)
37
+
38
+ if defaulted.empty?
39
+ build_passthrough_builder(type_name)
40
+ elsif required.empty?
41
+ build_all_defaulted_builder(type_name, defaulted)
42
+ else
43
+ build_mixed_builder(type_name, defaulted, required)
44
+ end
45
+ end
46
+
47
+ def build_passthrough_builder(type_name)
48
+ "export function build#{type_name}(fields: #{type_name}): #{type_name} {\n" \
49
+ " return fields;\n" \
50
+ '}'
51
+ end
52
+
53
+ def build_all_defaulted_builder(type_name, defaulted)
54
+ defaults = build_defaults_body(defaulted)
55
+
56
+ "export function build#{type_name}(fields?: Partial<#{type_name}>): #{type_name} {\n" \
57
+ " return {\n" \
58
+ "#{defaults}\n" \
59
+ " ...fields,\n" \
60
+ " };\n" \
61
+ '}'
62
+ end
63
+
64
+ def build_mixed_builder(type_name, defaulted, required)
65
+ required_keys = required.keys.map { |name| "'#{@export.transform_key(name)}'" }.sort.join(' | ')
66
+ fields_type = "Pick<#{type_name}, #{required_keys}> & Partial<#{type_name}>"
67
+ defaults = build_defaults_body(defaulted)
68
+
69
+ "export function build#{type_name}(fields: #{fields_type}): #{type_name} {\n" \
70
+ " return {\n" \
71
+ "#{defaults}\n" \
72
+ " ...fields,\n" \
73
+ " };\n" \
74
+ '}'
75
+ end
76
+
77
+ def build_defaults_body(defaulted)
78
+ defaulted.sort_by { |name, _param| name.to_s }.map do |name, param|
79
+ " #{@export.transform_key(name)}: #{serialize_default(param)},"
80
+ end.join("\n")
81
+ end
82
+
83
+ def build_union_builders(name, type, types)
84
+ type_name = pascal_case(name)
85
+ builders = [build_passthrough_builder(type_name)]
86
+
87
+ if type.discriminator
88
+ type.variants.each do |variant|
89
+ next unless variant.tag
90
+ next if variant.reference? && types.key?(variant.reference)
91
+
92
+ builders << build_variant_builder(type_name, type.discriminator, variant)
93
+ end
94
+ end
95
+
96
+ builders
97
+ end
98
+
99
+ def build_variant_builder(type_name, discriminator, variant)
100
+ discriminator_key = @export.transform_key(discriminator)
101
+ builder_name = variant.reference? ? "build#{pascal_case(variant.reference)}" : "build#{type_name}#{pascal_case(variant.tag)}"
102
+ extract_type = "Extract<#{type_name}, { #{discriminator_key}: '#{variant.tag}' }>"
103
+
104
+ variant_defaults = {}
105
+ variant_required = {}
106
+
107
+ variant_defaults, variant_required = classify_fields(variant.shape) if variant.object? && variant.shape.any?
108
+
109
+ fields_type = build_variant_fields_type(extract_type, discriminator_key, variant_defaults, variant_required)
110
+
111
+ body_lines = [" #{discriminator_key}: '#{variant.tag}',"]
112
+ variant_defaults.sort_by { |name, _param| name.to_s }.each do |name, param|
113
+ body_lines << " #{@export.transform_key(name)}: #{serialize_default(param)},"
114
+ end
115
+ body_lines << ' ...fields,'
116
+
117
+ "export function #{builder_name}(fields: #{fields_type}): #{type_name} {\n" \
118
+ " return {\n" \
119
+ "#{body_lines.join("\n")}\n" \
120
+ " };\n" \
121
+ '}'
122
+ end
123
+
124
+ def build_variant_fields_type(extract_type, discriminator_key, defaulted, required)
125
+ omitted_type = "Omit<#{extract_type}, '#{discriminator_key}'>"
126
+
127
+ if required.empty? && defaulted.empty?
128
+ omitted_type
129
+ elsif required.empty?
130
+ "Partial<#{omitted_type}>"
131
+ else
132
+ required_keys = required.keys.map { |name| "'#{@export.transform_key(name)}'" }.sort.join(' | ')
133
+ "Pick<#{omitted_type}, #{required_keys}> & Partial<#{omitted_type}>"
134
+ end
135
+ end
136
+
137
+ def classify_fields(shape)
138
+ defaulted = {}
139
+ required = {}
140
+
141
+ shape.sort_by { |name, _param| name.to_s }.each do |name, param|
142
+ if defaulted_field?(param)
143
+ defaulted[name] = param
144
+ else
145
+ required[name] = param
146
+ end
147
+ end
148
+
149
+ [defaulted, required]
150
+ end
151
+
152
+ def defaulted_field?(param)
153
+ return true if param.nullable?
154
+ return true if param.respond_to?(:default) && !param.default.nil?
155
+
156
+ false
157
+ end
158
+
159
+ def serialize_default(param)
160
+ if param.respond_to?(:default) && !param.default.nil?
161
+ serialize_value(param.default)
162
+ elsif param.nullable?
163
+ 'null'
164
+ end
165
+ end
166
+
167
+ def serialize_value(value)
168
+ case value
169
+ when String then "'#{value.gsub("'", "\\\\'")}'"
170
+ when Integer, Float then value.to_s
171
+ when BigDecimal then value.to_s('F')
172
+ when TrueClass, FalseClass then value.to_s
173
+ when Array then '[]'
174
+ when Hash then '{}'
175
+ else value.to_s
176
+ end
177
+ end
178
+
179
+ def pascal_case(name)
180
+ name.to_s.camelize(:upper)
181
+ end
182
+ end
183
+ end
184
+ end
@@ -319,7 +319,7 @@ module Apiwork
319
319
  schema = map_param(param)
320
320
 
321
321
  schema[:description] = param.description if param.description
322
- schema[:example] = param.example if param.example
322
+ schema[:example] = param.example if param.concrete? && param.example
323
323
  schema[:deprecated] = true if param.deprecated?
324
324
 
325
325
  schema[:format] = param.format.to_s if param.formattable? && param.format
@@ -332,6 +332,8 @@ module Apiwork
332
332
  map_object(param)
333
333
  elsif param.array?
334
334
  map_array(param)
335
+ elsif param.record?
336
+ map_record(param)
335
337
  elsif param.union?
336
338
  map_union(param)
337
339
  elsif param.literal?
@@ -353,7 +355,7 @@ module Apiwork
353
355
  }
354
356
 
355
357
  result[:description] = param.description if param.description
356
- result[:example] = param.example if param.example
358
+ result[:example] = param.example if param.respond_to?(:example) && param.example
357
359
 
358
360
  param.shape.each do |name, field|
359
361
  result[:properties][transform_key(name)] = map_field(field)
@@ -383,6 +385,11 @@ module Apiwork
383
385
  }
384
386
  end
385
387
 
388
+ def map_record(param)
389
+ value_schema = param.of ? map_param(param.of) : {}
390
+ { additionalProperties: value_schema, type: 'object' }
391
+ end
392
+
386
393
  def map_inline_object(shape)
387
394
  result = { properties: {}, type: 'object' }
388
395