apiwork 0.6.1 → 0.7.1

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/README.md +7 -2
  3. data/lib/apiwork/adapter/serializer/error/default/api_builder.rb +6 -1
  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 +16 -15
  7. data/lib/apiwork/contract/object/validator.rb +1 -1
  8. data/lib/apiwork/contract/object.rb +79 -77
  9. data/lib/apiwork/controller.rb +6 -2
  10. data/lib/apiwork/export/apiwork.rb +20 -0
  11. data/lib/apiwork/export/apiwork_mapper.rb +232 -0
  12. data/lib/apiwork/export/open_api.rb +2 -0
  13. data/lib/apiwork/export/registry.rb +1 -1
  14. data/lib/apiwork/export/sorbus.rb +1 -5
  15. data/lib/apiwork/export/type_script.rb +1 -4
  16. data/lib/apiwork/export/type_script_mapper.rb +12 -4
  17. data/lib/apiwork/export/zod.rb +0 -9
  18. data/lib/apiwork/export/zod_mapper.rb +22 -1
  19. data/lib/apiwork/export.rb +1 -0
  20. data/lib/apiwork/introspection/api/resource.rb +9 -0
  21. data/lib/apiwork/introspection/dump/param.rb +9 -10
  22. data/lib/apiwork/introspection/dump/resource.rb +3 -1
  23. data/lib/apiwork/introspection/dump/type.rb +15 -4
  24. data/lib/apiwork/introspection/enum.rb +9 -0
  25. data/lib/apiwork/introspection/param/array.rb +3 -0
  26. data/lib/apiwork/introspection/param/base.rb +11 -0
  27. data/lib/apiwork/introspection/param/binary.rb +3 -0
  28. data/lib/apiwork/introspection/param/boolean.rb +3 -0
  29. data/lib/apiwork/introspection/param/date.rb +3 -0
  30. data/lib/apiwork/introspection/param/date_time.rb +3 -0
  31. data/lib/apiwork/introspection/param/decimal.rb +3 -0
  32. data/lib/apiwork/introspection/param/integer.rb +3 -0
  33. data/lib/apiwork/introspection/param/number.rb +3 -0
  34. data/lib/apiwork/introspection/param/record.rb +3 -0
  35. data/lib/apiwork/introspection/param/string.rb +3 -0
  36. data/lib/apiwork/introspection/param/time.rb +3 -0
  37. data/lib/apiwork/introspection/param/uuid.rb +3 -0
  38. data/lib/apiwork/introspection/type.rb +9 -0
  39. data/lib/apiwork/issue.rb +1 -1
  40. data/lib/apiwork/object.rb +111 -103
  41. data/lib/apiwork/reference_generator.rb +43 -0
  42. data/lib/apiwork/representation/attribute.rb +87 -2
  43. data/lib/apiwork/representation/base.rb +4 -0
  44. data/lib/apiwork/version.rb +1 -1
  45. data/lib/apiwork.rb +4 -0
  46. metadata +4 -17
  47. data/lib/apiwork/export/builder_mapper.rb +0 -184
@@ -47,6 +47,26 @@ module Apiwork
47
47
  .sort_by { |mod| mod[:path] }
48
48
  end
49
49
 
50
+ def extract_constants
51
+ YARD::Registry.all(:constant)
52
+ .select do |yard_object|
53
+ yard_object.path.start_with?('Apiwork') &&
54
+ yard_object.docstring.to_s.strip.present? &&
55
+ yard_object.docstring.tags(:api).any? { |tag| tag.text == 'public' }
56
+ end
57
+ .map { |yard_object| serialize_constant(yard_object) }
58
+ .sort_by { |constant| constant[:name] }
59
+ end
60
+
61
+ def serialize_constant(yard_object)
62
+ {
63
+ docstring: yard_object.docstring.to_s,
64
+ file: relative_path(yard_object.file),
65
+ line: yard_object.line,
66
+ name: yard_object.name.to_s,
67
+ }
68
+ end
69
+
50
70
  def public_api?(yard_object)
51
71
  api_tag = yard_object.docstring.tags(:api).find { |tag| tag.text == 'public' }
52
72
 
@@ -247,6 +267,29 @@ module Apiwork
247
267
  end
248
268
 
249
269
  write_namespace_indexes
270
+ write_constant_files(extract_constants)
271
+ end
272
+
273
+ def write_constant_files(constants)
274
+ constants.each.with_index(@modules.size + 1) do |constant, order|
275
+ filepath = File.join(OUTPUT_DIR, "#{dasherize(constant[:name])}.md")
276
+ File.write(filepath, render_constant(constant, order))
277
+ end
278
+ end
279
+
280
+ def render_constant(constant, order)
281
+ parts = []
282
+ parts << "---\norder: #{order}\nprev: false\nnext: false\n---\n"
283
+ parts << "# #{constant[:name]}\n"
284
+
285
+ if constant[:file] && constant[:line]
286
+ github_link = "#{GITHUB_URL}/#{constant[:file]}#L#{constant[:line]}"
287
+ parts << "[GitHub](#{github_link})\n"
288
+ end
289
+
290
+ parts << "#{linkify_yard_refs(constant[:docstring])}\n" if constant[:docstring].present?
291
+
292
+ parts.join("\n")
250
293
  end
251
294
 
252
295
  def write_root_index
@@ -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,
@@ -90,6 +99,7 @@ module Apiwork
90
99
  name,
91
100
  owner_representation_class,
92
101
  decode: nil,
102
+ default: UNSET,
93
103
  deprecated: false,
94
104
  description: nil,
95
105
  empty: false,
@@ -134,6 +144,24 @@ module Apiwork
134
144
  type = :string if detected_enum && type == :integer
135
145
  optional = detect_optional(name) if optional.nil?
136
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
137
165
  rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
138
166
  @db_column = false
139
167
  end
@@ -141,6 +169,7 @@ module Apiwork
141
169
 
142
170
  optional = false if optional.nil?
143
171
  nullable = false if nullable.nil?
172
+ default = '' if UNSET.equal?(default) && empty
144
173
 
145
174
  @filterable = filterable
146
175
  @preload = preload
@@ -160,12 +189,25 @@ module Apiwork
160
189
  @format = format
161
190
  @deprecated = deprecated
162
191
  @write_only = write_only
192
+ @default_set = !UNSET.equal?(default)
193
+ @default = @default_set ? default : nil
163
194
 
164
195
  validate_min_max_range!
165
196
  validate_format!
166
197
  validate_empty!
167
198
  end
168
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
+
169
211
  # @api public
170
212
  # Whether this attribute is deprecated.
171
213
  #
@@ -276,6 +318,32 @@ module Apiwork
276
318
  end
277
319
  end
278
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
+
279
347
  def detect_optional(name)
280
348
  return false unless @model_class
281
349
  return false unless db_column?
@@ -283,11 +351,28 @@ module Apiwork
283
351
  column = column_for(name)
284
352
  return false unless column
285
353
 
286
- return true if column.default.present?
354
+ return true unless column.default.nil?
287
355
 
288
356
  column.null
289
357
  end
290
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
+
291
376
  def detect_nullable(name)
292
377
  return false unless @model_class
293
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)
@@ -273,6 +275,7 @@ module Apiwork
273
275
  def attribute(
274
276
  name,
275
277
  decode: nil,
278
+ default: UNSET,
276
279
  deprecated: false,
277
280
  description: nil,
278
281
  empty: nil,
@@ -297,6 +300,7 @@ module Apiwork
297
300
  name,
298
301
  self,
299
302
  decode:,
303
+ default:,
300
304
  deprecated:,
301
305
  description:,
302
306
  empty:,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apiwork
4
- VERSION = '0.6.1'
4
+ VERSION = '0.7.1'
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.1
4
+ version: 0.7.1
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-04 00:00:00.000000000 Z
11
+ date: 2026-08-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -24,20 +24,6 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '8.0'
27
- - !ruby/object:Gem::Dependency
28
- name: bundler-audit
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '0.9'
34
- type: :development
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '0.9'
41
27
  - !ruby/object:Gem::Dependency
42
28
  name: lefthook
43
29
  requirement: !ruby/object:Gem::Requirement
@@ -260,8 +246,9 @@ files:
260
246
  - lib/apiwork/error_code/definition.rb
261
247
  - lib/apiwork/error_code/registry.rb
262
248
  - lib/apiwork/export.rb
249
+ - lib/apiwork/export/apiwork.rb
250
+ - lib/apiwork/export/apiwork_mapper.rb
263
251
  - lib/apiwork/export/base.rb
264
- - lib/apiwork/export/builder_mapper.rb
265
252
  - lib/apiwork/export/open_api.rb
266
253
  - lib/apiwork/export/pipeline.rb
267
254
  - 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