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
@@ -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.
@@ -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
@@ -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
 
@@ -7,8 +7,12 @@ module Apiwork
7
7
  output :string
8
8
  file_extension '.ts'
9
9
 
10
+ option :builders, default: false, type: :boolean
11
+
10
12
  def generate
11
- SorbusMapper.map(self, surface)
13
+ output = SorbusMapper.map(self, surface)
14
+ output += "\n\n#{BuilderMapper.map(self, surface)}" if options[:builders]
15
+ output
12
16
  end
13
17
 
14
18
  private
@@ -37,10 +37,8 @@ module Apiwork
37
37
  end
38
38
 
39
39
  def build_contract
40
- contract = {
41
- endpoints: build_endpoint_tree(@export.api.resources),
42
- error: build_error_schema,
43
- }
40
+ contract = { endpoints: build_endpoint_tree(@export.api.resources) }
41
+ contract[:error] = build_error_schema if @surface.types.key?(:error)
44
42
  "export const contract = #{format_object(contract, indent: 0)} as const;"
45
43
  end
46
44
 
@@ -80,9 +78,7 @@ module Apiwork
80
78
  end
81
79
 
82
80
  def transform_path(path)
83
- path.gsub(%r{(/:?)(\w+)}) do
84
- "#{::Regexp.last_match(1)}#{@export.transform_key(::Regexp.last_match(2))}"
85
- end
81
+ path.to_s.gsub(/:(\w+)/) { ":#{@export.transform_key(::Regexp.last_match(1))}" }
86
82
  end
87
83
 
88
84
  def extract_path_params(path)
@@ -4,9 +4,8 @@ module Apiwork
4
4
  module Export
5
5
  class TypeAnalysis
6
6
  PRIMITIVE_TYPES = %i[
7
- string integer boolean datetime date uuid object array
8
- decimal float literal union enum text binary json number time
9
- unknown
7
+ array binary boolean date datetime decimal enum float integer json
8
+ literal number object record string text time union unknown uuid
10
9
  ].to_set.freeze
11
10
 
12
11
  class << self
@@ -39,11 +38,22 @@ module Apiwork
39
38
 
40
39
  def find_cycle_breaking_types(graph)
41
40
  lazy_types = Set.new
41
+ reduced_graph = graph
42
42
 
43
- find_strongly_connected_components(graph).each do |component|
44
- next if component.size == 1 && !graph[component.first].include?(component.first)
43
+ loop do
44
+ previous_size = lazy_types.size
45
+
46
+ find_strongly_connected_components(reduced_graph).each do |component|
47
+ next if component.size == 1 && !reduced_graph[component.first].include?(component.first)
48
+
49
+ lazy_types.add(select_cycle_breaker(component, reduced_graph))
50
+ end
45
51
 
46
- lazy_types.add(component.min_by(&:to_s))
52
+ break if lazy_types.size == previous_size
53
+
54
+ reduced_graph = reduced_graph
55
+ .reject { |node, _| lazy_types.include?(node) }
56
+ .transform_values { |deps| deps - lazy_types.to_a }
47
57
  end
48
58
 
49
59
  lazy_types
@@ -114,6 +124,10 @@ module Apiwork
114
124
  state[:components] << component
115
125
  end
116
126
 
127
+ def select_cycle_breaker(component, graph)
128
+ component.min_by { |node| [-(graph[node] & component).size, node.to_s] }
129
+ end
130
+
117
131
  def collect_references(node, references, filter)
118
132
  return unless node.is_a?(Hash)
119
133
 
@@ -7,10 +7,13 @@ module Apiwork
7
7
  output :string
8
8
  file_extension '.ts'
9
9
 
10
+ option :builders, default: false, type: :boolean
10
11
  option :version, default: '5', enum: %w[4 5], type: :string
11
12
 
12
13
  def generate
13
- TypeScriptMapper.map(self, surface)
14
+ output = TypeScriptMapper.map(self, surface)
15
+ output += "\n\n#{BuilderMapper.map(self, surface)}" if options[:builders]
16
+ output
14
17
  end
15
18
 
16
19
  private
@@ -30,7 +30,7 @@ module Apiwork
30
30
  ts_type = map_field(param)
31
31
  optional_marker = param.optional? ? '?' : ''
32
32
 
33
- prop_jsdoc = jsdoc(description: param.description, example: param.example)
33
+ prop_jsdoc = jsdoc(description: param.description, example: param.concrete? ? param.example : nil)
34
34
  if prop_jsdoc
35
35
  indented_jsdoc = prop_jsdoc.lines.map { |line| " #{line.chomp}" }.join("\n")
36
36
  "#{indented_jsdoc}\n #{key}#{optional_marker}: #{ts_type};"
@@ -165,6 +165,8 @@ module Apiwork
165
165
  map_object_type(param)
166
166
  elsif param.array?
167
167
  map_array_type(param)
168
+ elsif param.record?
169
+ map_record_type(param)
168
170
  elsif param.union?
169
171
  map_union_type(param)
170
172
  elsif param.literal?
@@ -207,8 +209,29 @@ module Apiwork
207
209
  end
208
210
  end
209
211
 
212
+ def map_record_type(param)
213
+ value_type = param.of ? map_param(param.of) : 'unknown'
214
+ "Record<string, #{value_type}>"
215
+ end
216
+
210
217
  def map_union_type(param)
211
- param.variants.map { |variant| map_param(variant) }.sort.join(' | ')
218
+ variant_types = param.variants.map do |variant|
219
+ if param.discriminator && variant.tag && variant.object?
220
+ discriminator_prop = "#{@export.transform_key(param.discriminator)}: '#{variant.tag}'"
221
+ properties = variant.shape.sort_by { |name, _| name.to_s }.map do |name, field|
222
+ "#{@export.transform_key(name)}: #{map_field(field)}"
223
+ end
224
+ all_properties = [discriminator_prop, *properties].join('; ')
225
+ "{ #{all_properties} }"
226
+ elsif param.discriminator && variant.tag
227
+ base_type = map_param(variant)
228
+ "{ #{@export.transform_key(param.discriminator)}: '#{variant.tag}' } & #{base_type}"
229
+ else
230
+ map_param(variant)
231
+ end
232
+ end
233
+
234
+ variant_types.sort.join(' | ')
212
235
  end
213
236
 
214
237
  def map_literal_type(param)
@@ -7,6 +7,7 @@ module Apiwork
7
7
  output :string
8
8
  file_extension '.ts'
9
9
 
10
+ option :builders, default: false, type: :boolean
10
11
  option :version, default: '4', enum: %w[4], type: :string
11
12
 
12
13
  def generate
@@ -26,6 +27,14 @@ module Apiwork
26
27
  parts << ''
27
28
  end
28
29
 
30
+ if options[:builders]
31
+ builder_output = BuilderMapper.map(self, surface)
32
+ if builder_output.present?
33
+ parts << builder_output
34
+ parts << ''
35
+ end
36
+ end
37
+
29
38
  parts.join("\n")
30
39
  end
31
40
 
@@ -200,6 +200,8 @@ module Apiwork
200
200
  map_object_type(param)
201
201
  elsif param.array?
202
202
  map_array_type(param)
203
+ elsif param.record?
204
+ map_record_type(param)
203
205
  elsif param.union?
204
206
  map_union_type(param)
205
207
  elsif param.literal?
@@ -248,6 +250,11 @@ module Apiwork
248
250
  base
249
251
  end
250
252
 
253
+ def map_record_type(param)
254
+ value_schema = param.of ? map_param(param.of) : 'z.unknown()'
255
+ "z.record(z.string(), #{value_schema})"
256
+ end
257
+
251
258
  def map_union_type(param)
252
259
  if param.discriminator
253
260
  map_discriminated_union(param)
@@ -260,7 +267,21 @@ module Apiwork
260
267
  def map_discriminated_union(param)
261
268
  discriminator_field = @export.transform_key(param.discriminator)
262
269
 
263
- variant_schemas = param.variants.map { |variant| map_param(variant) }
270
+ variant_schemas = param.variants.map do |variant|
271
+ if variant.tag && variant.object?
272
+ discriminator_prop = "#{discriminator_field}: z.literal('#{variant.tag}')"
273
+ properties = variant.shape.sort_by { |name, _| name.to_s }.map do |name, field|
274
+ "#{@export.transform_key(name)}: #{map_field(field)}"
275
+ end
276
+ all_properties = [discriminator_prop, *properties].join(', ')
277
+ "z.object({ #{all_properties} })"
278
+ elsif variant.tag
279
+ base_schema = map_param(variant)
280
+ "#{base_schema}.extend({ #{discriminator_field}: z.literal('#{variant.tag}') })"
281
+ else
282
+ map_param(variant)
283
+ end
284
+ end
264
285
 
265
286
  "z.discriminatedUnion('#{discriminator_field}', [#{variant_schemas.join(', ')}])"
266
287
  end
@@ -52,7 +52,7 @@ module Apiwork
52
52
 
53
53
  def build_response(response)
54
54
  return { body: {}, description: i18n_lookup(:response, :description), no_content: false } unless response
55
- return { body: {}, description: response.description || i18n_lookup(:response, :description), no_content: true } if response.no_content?
55
+ return { body: nil, description: response.description || i18n_lookup(:response, :description), no_content: true } if response.no_content?
56
56
 
57
57
  description = response.description || i18n_lookup(:response, :description)
58
58
  body_shape = response.body