apiwork 0.6.0 → 0.7.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/lib/apiwork/adapter/serializer/error/default/api_builder.rb +6 -1
  3. data/lib/apiwork/adapter/serializer/resource/default/contract_builder.rb +2 -0
  4. data/lib/apiwork/adapter/standard/capability/writing/contract_builder.rb +1 -0
  5. data/lib/apiwork/api/base.rb +31 -0
  6. data/lib/apiwork/api/object.rb +12 -12
  7. data/lib/apiwork/contract/object.rb +74 -74
  8. data/lib/apiwork/controller.rb +6 -2
  9. data/lib/apiwork/export/apiwork.rb +20 -0
  10. data/lib/apiwork/export/apiwork_mapper.rb +232 -0
  11. data/lib/apiwork/export/open_api.rb +2 -0
  12. data/lib/apiwork/export/registry.rb +1 -1
  13. data/lib/apiwork/export/sorbus.rb +1 -5
  14. data/lib/apiwork/export/type_script.rb +1 -4
  15. data/lib/apiwork/export/type_script_mapper.rb +12 -4
  16. data/lib/apiwork/export/zod.rb +0 -9
  17. data/lib/apiwork/export/zod_mapper.rb +22 -1
  18. data/lib/apiwork/export.rb +1 -0
  19. data/lib/apiwork/introspection/api/resource.rb +9 -0
  20. data/lib/apiwork/introspection/dump/param.rb +9 -10
  21. data/lib/apiwork/introspection/dump/resource.rb +3 -1
  22. data/lib/apiwork/introspection/dump/type.rb +15 -4
  23. data/lib/apiwork/introspection/enum.rb +9 -0
  24. data/lib/apiwork/introspection/param/array.rb +3 -0
  25. data/lib/apiwork/introspection/param/base.rb +11 -0
  26. data/lib/apiwork/introspection/param/binary.rb +3 -0
  27. data/lib/apiwork/introspection/param/boolean.rb +3 -0
  28. data/lib/apiwork/introspection/param/date.rb +3 -0
  29. data/lib/apiwork/introspection/param/date_time.rb +3 -0
  30. data/lib/apiwork/introspection/param/decimal.rb +3 -0
  31. data/lib/apiwork/introspection/param/integer.rb +3 -0
  32. data/lib/apiwork/introspection/param/number.rb +3 -0
  33. data/lib/apiwork/introspection/param/record.rb +3 -0
  34. data/lib/apiwork/introspection/param/string.rb +3 -0
  35. data/lib/apiwork/introspection/param/time.rb +3 -0
  36. data/lib/apiwork/introspection/param/uuid.rb +3 -0
  37. data/lib/apiwork/introspection/type.rb +9 -0
  38. data/lib/apiwork/issue.rb +1 -1
  39. data/lib/apiwork/object.rb +99 -99
  40. data/lib/apiwork/reference_generator.rb +43 -0
  41. data/lib/apiwork/representation/attribute.rb +98 -2
  42. data/lib/apiwork/representation/base.rb +9 -0
  43. data/lib/apiwork/representation/serializer.rb +2 -0
  44. data/lib/apiwork/version.rb +1 -1
  45. data/lib/apiwork.rb +4 -0
  46. metadata +4 -3
  47. data/lib/apiwork/export/builder_mapper.rb +0 -184
@@ -21,6 +21,14 @@ module Apiwork
21
21
  string: %i[date datetime email hostname ipv4 ipv6 password text url uuid],
22
22
  }.freeze
23
23
 
24
+ # @!attribute [r] default
25
+ # @api public
26
+ # The default for this attribute.
27
+ #
28
+ # Returns `nil` for both "no default" and "default is explicitly `nil`".
29
+ # Use {#default?} to distinguish these cases.
30
+ #
31
+ # @return [Object, nil]
24
32
  # @!attribute [r] description
25
33
  # @api public
26
34
  # The description for this attribute.
@@ -71,7 +79,8 @@ module Apiwork
71
79
  # The type for this attribute.
72
80
  #
73
81
  # @return [Symbol]
74
- attr_reader :description,
82
+ attr_reader :default,
83
+ :description,
75
84
  :element,
76
85
  :empty,
77
86
  :enum,
@@ -83,12 +92,14 @@ module Apiwork
83
92
  :of,
84
93
  :optional,
85
94
  :preload,
86
- :type
95
+ :type,
96
+ :write_only
87
97
 
88
98
  def initialize(
89
99
  name,
90
100
  owner_representation_class,
91
101
  decode: nil,
102
+ default: UNSET,
92
103
  deprecated: false,
93
104
  description: nil,
94
105
  empty: false,
@@ -105,6 +116,7 @@ module Apiwork
105
116
  sortable: false,
106
117
  type: nil,
107
118
  writable: false,
119
+ write_only: false,
108
120
  &block
109
121
  )
110
122
  @name = name
@@ -132,6 +144,24 @@ module Apiwork
132
144
  type = :string if detected_enum && type == :integer
133
145
  optional = detect_optional(name) if optional.nil?
134
146
  nullable = detect_nullable(name) if nullable.nil?
147
+ default = detect_default(name, empty:, nullable:, optional:) if UNSET.equal?(default)
148
+
149
+ if @db_column && type == :string
150
+ detected_max = detect_string_max_length(name)
151
+ max = [max, detected_max].compact.min
152
+ end
153
+
154
+ if @db_column && type == :decimal
155
+ detected_min, detected_max = detect_decimal_bounds(name)
156
+ min = [min, detected_min].compact.max
157
+ max = [max, detected_max].compact.min
158
+ end
159
+
160
+ if @db_column && type == :integer
161
+ detected_min, detected_max = detect_integer_bounds(name)
162
+ min = [min, detected_min].compact.max
163
+ max = [max, detected_max].compact.min
164
+ end
135
165
  rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
136
166
  @db_column = false
137
167
  end
@@ -139,6 +169,7 @@ module Apiwork
139
169
 
140
170
  optional = false if optional.nil?
141
171
  nullable = false if nullable.nil?
172
+ default = '' if UNSET.equal?(default) && empty
142
173
 
143
174
  @filterable = filterable
144
175
  @preload = preload
@@ -157,12 +188,26 @@ module Apiwork
157
188
  @example = example
158
189
  @format = format
159
190
  @deprecated = deprecated
191
+ @write_only = write_only
192
+ @default_set = !UNSET.equal?(default)
193
+ @default = @default_set ? default : nil
160
194
 
161
195
  validate_min_max_range!
162
196
  validate_format!
163
197
  validate_empty!
164
198
  end
165
199
 
200
+ # @api public
201
+ # Whether this attribute has a default value.
202
+ #
203
+ # Use this to distinguish "no default" from "default is explicitly `nil`".
204
+ # The {#default} accessor returns `nil` in both cases.
205
+ #
206
+ # @return [Boolean]
207
+ def default?
208
+ @default_set
209
+ end
210
+
166
211
  # @api public
167
212
  # Whether this attribute is deprecated.
168
213
  #
@@ -225,6 +270,14 @@ module Apiwork
225
270
  [true, action].include?(@writable)
226
271
  end
227
272
 
273
+ # @api public
274
+ # Whether this attribute is write-only.
275
+ #
276
+ # @return [Boolean]
277
+ def write_only?
278
+ @write_only
279
+ end
280
+
228
281
  def encode(value)
229
282
  result = @empty && value.nil? ? '' : value
230
283
  @encode ? @encode.call(result) : result
@@ -265,6 +318,32 @@ module Apiwork
265
318
  end
266
319
  end
267
320
 
321
+ def detect_string_max_length(name)
322
+ column = column_for(name)
323
+ return nil unless column
324
+
325
+ column.limit
326
+ end
327
+
328
+ def detect_decimal_bounds(name)
329
+ column = column_for(name)
330
+ return [nil, nil] unless column
331
+ return [nil, nil] unless column.precision
332
+
333
+ scale = column.scale || 0
334
+ max = (10**(column.precision - scale) - 10.0**(-scale)).to_f
335
+ [-max, max]
336
+ end
337
+
338
+ def detect_integer_bounds(name)
339
+ column = column_for(name)
340
+ return [nil, nil] unless column
341
+
342
+ limit = column.limit || 4
343
+ max = 2**(8 * limit - 1) - 1
344
+ [-max - 1, max]
345
+ end
346
+
268
347
  def detect_optional(name)
269
348
  return false unless @model_class
270
349
  return false unless db_column?
@@ -277,6 +356,23 @@ module Apiwork
277
356
  column.null
278
357
  end
279
358
 
359
+ def detect_default(name, empty:, nullable:, optional:)
360
+ return UNSET unless @model_class
361
+ return UNSET unless db_column?
362
+
363
+ column = column_for(name)
364
+ return UNSET unless column
365
+ return UNSET if column.default_function
366
+
367
+ default = @model_class.column_defaults[name.to_s]
368
+ return default unless default.nil?
369
+
370
+ return '' if empty && nullable && optional
371
+ return nil if nullable && optional
372
+
373
+ UNSET
374
+ end
375
+
280
376
  def detect_nullable(name)
281
377
  return false unless @model_class
282
378
  return false unless db_column?
@@ -184,6 +184,8 @@ module Apiwork
184
184
  # The attribute name.
185
185
  # @param decode [Proc, nil] (nil)
186
186
  # Transform for request input (API to database). Must preserve the attribute type.
187
+ # @param default [Object] (UNSET)
188
+ # The default value. Omit to declare no default. Pass `nil` for an explicit null default. If omitted and name maps to a database column, auto-detected from the column's static default.
187
189
  # @param deprecated [Boolean] (false)
188
190
  # Whether deprecated. Metadata included in exports.
189
191
  # @param description [String, nil] (nil)
@@ -222,6 +224,9 @@ module Apiwork
222
224
  # Use an explicit type or block in those cases.
223
225
  # @param writable [Boolean, Symbol] (false) [:create, :update]
224
226
  # The write access. `true` for both create and update, `:create` for create only, `:update` for update only.
227
+ # @param write_only [Boolean] (false)
228
+ # Whether the attribute is write-only. Write-only attributes are excluded from response
229
+ # serialization and response types but remain in writable payloads.
225
230
  # @yieldparam element [Representation::Element]
226
231
  # @return [void]
227
232
  #
@@ -270,6 +275,7 @@ module Apiwork
270
275
  def attribute(
271
276
  name,
272
277
  decode: nil,
278
+ default: UNSET,
273
279
  deprecated: false,
274
280
  description: nil,
275
281
  empty: nil,
@@ -286,6 +292,7 @@ module Apiwork
286
292
  sortable: false,
287
293
  type: nil,
288
294
  writable: false,
295
+ write_only: false,
289
296
  &block
290
297
  )
291
298
  self.attributes = attributes.merge(
@@ -293,6 +300,7 @@ module Apiwork
293
300
  name,
294
301
  self,
295
302
  decode:,
303
+ default:,
296
304
  deprecated:,
297
305
  description:,
298
306
  empty:,
@@ -309,6 +317,7 @@ module Apiwork
309
317
  sortable:,
310
318
  type:,
311
319
  writable:,
320
+ write_only:,
312
321
  &block
313
322
  ),
314
323
  )
@@ -21,6 +21,8 @@ module Apiwork
21
21
  add_discriminator_field(fields) if @representation_class.subclass?
22
22
 
23
23
  @representation_class.attributes.each do |name, attribute|
24
+ next if attribute.write_only?
25
+
24
26
  value = @representation.respond_to?(name) ? @representation.public_send(name) : @representation.record.public_send(name)
25
27
  value = map_type_column_output(name, value)
26
28
  value = attribute.encode(value)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apiwork
4
- VERSION = '0.6.0'
4
+ VERSION = '0.7.0'
5
5
  end
data/lib/apiwork.rb CHANGED
@@ -4,6 +4,10 @@ require 'zeitwerk'
4
4
  require_relative 'apiwork/version'
5
5
 
6
6
  module Apiwork
7
+ # @api public
8
+ # Sentinel for an unset value. Distinguishes from explicit `nil`.
9
+ UNSET = Object.new.freeze
10
+
7
11
  class << self
8
12
  def call(env)
9
13
  routes.call(env)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: apiwork
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - skiftle
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-03 00:00:00.000000000 Z
11
+ date: 2026-04-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -260,8 +260,9 @@ files:
260
260
  - lib/apiwork/error_code/definition.rb
261
261
  - lib/apiwork/error_code/registry.rb
262
262
  - lib/apiwork/export.rb
263
+ - lib/apiwork/export/apiwork.rb
264
+ - lib/apiwork/export/apiwork_mapper.rb
263
265
  - lib/apiwork/export/base.rb
264
- - lib/apiwork/export/builder_mapper.rb
265
266
  - lib/apiwork/export/open_api.rb
266
267
  - lib/apiwork/export/pipeline.rb
267
268
  - lib/apiwork/export/pipeline/writer.rb
@@ -1,184 +0,0 @@
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