apiwork 0.0.0.pre → 0.1.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 (202) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +2 -2
  3. data/README.md +117 -1
  4. data/Rakefile +5 -3
  5. data/app/controllers/apiwork/errors_controller.rb +13 -0
  6. data/app/controllers/apiwork/exports_controller.rb +22 -0
  7. data/lib/apiwork/abstractable.rb +26 -0
  8. data/lib/apiwork/adapter/base.rb +369 -0
  9. data/lib/apiwork/adapter/builder/api/base.rb +66 -0
  10. data/lib/apiwork/adapter/builder/contract/base.rb +86 -0
  11. data/lib/apiwork/adapter/capability/api/base.rb +51 -0
  12. data/lib/apiwork/adapter/capability/api/scope.rb +64 -0
  13. data/lib/apiwork/adapter/capability/base.rb +291 -0
  14. data/lib/apiwork/adapter/capability/contract/base.rb +37 -0
  15. data/lib/apiwork/adapter/capability/contract/scope.rb +110 -0
  16. data/lib/apiwork/adapter/capability/operation/base.rb +172 -0
  17. data/lib/apiwork/adapter/capability/operation/metadata_shape.rb +165 -0
  18. data/lib/apiwork/adapter/capability/result.rb +21 -0
  19. data/lib/apiwork/adapter/capability/runner.rb +56 -0
  20. data/lib/apiwork/adapter/capability/transformer/request/base.rb +72 -0
  21. data/lib/apiwork/adapter/capability/transformer/response/base.rb +45 -0
  22. data/lib/apiwork/adapter/registry.rb +16 -0
  23. data/lib/apiwork/adapter/serializer/error/base.rb +72 -0
  24. data/lib/apiwork/adapter/serializer/error/default/api_builder.rb +32 -0
  25. data/lib/apiwork/adapter/serializer/error/default.rb +37 -0
  26. data/lib/apiwork/adapter/serializer/resource/base.rb +84 -0
  27. data/lib/apiwork/adapter/serializer/resource/default/contract_builder.rb +209 -0
  28. data/lib/apiwork/adapter/serializer/resource/default.rb +39 -0
  29. data/lib/apiwork/adapter/standard/capability/filtering/api_builder.rb +75 -0
  30. data/lib/apiwork/adapter/standard/capability/filtering/constants.rb +37 -0
  31. data/lib/apiwork/adapter/standard/capability/filtering/contract_builder.rb +193 -0
  32. data/lib/apiwork/adapter/standard/capability/filtering/operation/filter/builder.rb +47 -0
  33. data/lib/apiwork/adapter/standard/capability/filtering/operation/filter/operator_builder.rb +36 -0
  34. data/lib/apiwork/adapter/standard/capability/filtering/operation/filter.rb +462 -0
  35. data/lib/apiwork/adapter/standard/capability/filtering/operation.rb +22 -0
  36. data/lib/apiwork/adapter/standard/capability/filtering/request_transformer.rb +47 -0
  37. data/lib/apiwork/adapter/standard/capability/filtering.rb +18 -0
  38. data/lib/apiwork/adapter/standard/capability/including/contract_builder.rb +169 -0
  39. data/lib/apiwork/adapter/standard/capability/including/operation.rb +20 -0
  40. data/lib/apiwork/adapter/standard/capability/including.rb +16 -0
  41. data/lib/apiwork/adapter/standard/capability/pagination/api_builder.rb +34 -0
  42. data/lib/apiwork/adapter/standard/capability/pagination/contract_builder.rb +35 -0
  43. data/lib/apiwork/adapter/standard/capability/pagination/operation/paginate/cursor.rb +84 -0
  44. data/lib/apiwork/adapter/standard/capability/pagination/operation/paginate/offset.rb +66 -0
  45. data/lib/apiwork/adapter/standard/capability/pagination/operation/paginate.rb +24 -0
  46. data/lib/apiwork/adapter/standard/capability/pagination/operation.rb +24 -0
  47. data/lib/apiwork/adapter/standard/capability/pagination.rb +21 -0
  48. data/lib/apiwork/adapter/standard/capability/sorting/api_builder.rb +19 -0
  49. data/lib/apiwork/adapter/standard/capability/sorting/contract_builder.rb +84 -0
  50. data/lib/apiwork/adapter/standard/capability/sorting/operation/sort.rb +83 -0
  51. data/lib/apiwork/adapter/standard/capability/sorting/operation.rb +22 -0
  52. data/lib/apiwork/adapter/standard/capability/sorting.rb +17 -0
  53. data/lib/apiwork/adapter/standard/capability/writing/constants.rb +15 -0
  54. data/lib/apiwork/adapter/standard/capability/writing/contract_builder.rb +253 -0
  55. data/lib/apiwork/adapter/standard/capability/writing/operation/issue_mapper.rb +210 -0
  56. data/lib/apiwork/adapter/standard/capability/writing/operation.rb +32 -0
  57. data/lib/apiwork/adapter/standard/capability/writing/request_transformer.rb +37 -0
  58. data/lib/apiwork/adapter/standard/capability/writing.rb +17 -0
  59. data/lib/apiwork/adapter/standard/includes_resolver.rb +106 -0
  60. data/lib/apiwork/adapter/standard.rb +22 -0
  61. data/lib/apiwork/adapter/wrapper/base.rb +70 -0
  62. data/lib/apiwork/adapter/wrapper/collection/base.rb +60 -0
  63. data/lib/apiwork/adapter/wrapper/collection/default.rb +47 -0
  64. data/lib/apiwork/adapter/wrapper/error/base.rb +30 -0
  65. data/lib/apiwork/adapter/wrapper/error/default.rb +34 -0
  66. data/lib/apiwork/adapter/wrapper/member/base.rb +58 -0
  67. data/lib/apiwork/adapter/wrapper/member/default.rb +40 -0
  68. data/lib/apiwork/adapter/wrapper/shape.rb +203 -0
  69. data/lib/apiwork/adapter.rb +50 -0
  70. data/lib/apiwork/api/base.rb +802 -0
  71. data/lib/apiwork/api/element.rb +110 -0
  72. data/lib/apiwork/api/enum_registry/definition.rb +51 -0
  73. data/lib/apiwork/api/enum_registry.rb +98 -0
  74. data/lib/apiwork/api/info/contact.rb +67 -0
  75. data/lib/apiwork/api/info/license.rb +50 -0
  76. data/lib/apiwork/api/info/server.rb +50 -0
  77. data/lib/apiwork/api/info.rb +221 -0
  78. data/lib/apiwork/api/object.rb +235 -0
  79. data/lib/apiwork/api/registry.rb +33 -0
  80. data/lib/apiwork/api/representation_registry.rb +76 -0
  81. data/lib/apiwork/api/resource/action.rb +41 -0
  82. data/lib/apiwork/api/resource.rb +648 -0
  83. data/lib/apiwork/api/router.rb +104 -0
  84. data/lib/apiwork/api/type_registry/definition.rb +117 -0
  85. data/lib/apiwork/api/type_registry.rb +99 -0
  86. data/lib/apiwork/api/union.rb +49 -0
  87. data/lib/apiwork/api.rb +85 -0
  88. data/lib/apiwork/configurable.rb +71 -0
  89. data/lib/apiwork/configuration/option.rb +125 -0
  90. data/lib/apiwork/configuration/validatable.rb +25 -0
  91. data/lib/apiwork/configuration.rb +95 -0
  92. data/lib/apiwork/configuration_error.rb +6 -0
  93. data/lib/apiwork/constraint_error.rb +20 -0
  94. data/lib/apiwork/contract/action/request.rb +79 -0
  95. data/lib/apiwork/contract/action/response.rb +87 -0
  96. data/lib/apiwork/contract/action.rb +258 -0
  97. data/lib/apiwork/contract/base.rb +714 -0
  98. data/lib/apiwork/contract/element.rb +130 -0
  99. data/lib/apiwork/contract/object/coercer.rb +194 -0
  100. data/lib/apiwork/contract/object/deserializer.rb +101 -0
  101. data/lib/apiwork/contract/object/transformer.rb +95 -0
  102. data/lib/apiwork/contract/object/validator/result.rb +27 -0
  103. data/lib/apiwork/contract/object/validator.rb +734 -0
  104. data/lib/apiwork/contract/object.rb +566 -0
  105. data/lib/apiwork/contract/request_parser/result.rb +25 -0
  106. data/lib/apiwork/contract/request_parser.rb +72 -0
  107. data/lib/apiwork/contract/response_parser/result.rb +25 -0
  108. data/lib/apiwork/contract/response_parser.rb +35 -0
  109. data/lib/apiwork/contract/union.rb +56 -0
  110. data/lib/apiwork/contract_error.rb +9 -0
  111. data/lib/apiwork/controller.rb +300 -0
  112. data/lib/apiwork/domain_error.rb +13 -0
  113. data/lib/apiwork/element.rb +386 -0
  114. data/lib/apiwork/engine.rb +20 -0
  115. data/lib/apiwork/error.rb +6 -0
  116. data/lib/apiwork/error_code/definition.rb +63 -0
  117. data/lib/apiwork/error_code/registry.rb +18 -0
  118. data/lib/apiwork/error_code.rb +132 -0
  119. data/lib/apiwork/export/base.rb +291 -0
  120. data/lib/apiwork/export/open_api.rb +600 -0
  121. data/lib/apiwork/export/pipeline/writer.rb +66 -0
  122. data/lib/apiwork/export/pipeline.rb +84 -0
  123. data/lib/apiwork/export/registry.rb +16 -0
  124. data/lib/apiwork/export/surface_resolver.rb +189 -0
  125. data/lib/apiwork/export/type_analysis.rb +170 -0
  126. data/lib/apiwork/export/type_script.rb +23 -0
  127. data/lib/apiwork/export/type_script_mapper.rb +349 -0
  128. data/lib/apiwork/export/zod.rb +39 -0
  129. data/lib/apiwork/export/zod_mapper.rb +421 -0
  130. data/lib/apiwork/export.rb +80 -0
  131. data/lib/apiwork/http_error.rb +16 -0
  132. data/lib/apiwork/introspection/action/request.rb +66 -0
  133. data/lib/apiwork/introspection/action/response.rb +57 -0
  134. data/lib/apiwork/introspection/action.rb +124 -0
  135. data/lib/apiwork/introspection/api/info/contact.rb +59 -0
  136. data/lib/apiwork/introspection/api/info/license.rb +49 -0
  137. data/lib/apiwork/introspection/api/info/server.rb +50 -0
  138. data/lib/apiwork/introspection/api/info.rb +107 -0
  139. data/lib/apiwork/introspection/api/resource.rb +83 -0
  140. data/lib/apiwork/introspection/api.rb +92 -0
  141. data/lib/apiwork/introspection/contract.rb +63 -0
  142. data/lib/apiwork/introspection/dump/action.rb +101 -0
  143. data/lib/apiwork/introspection/dump/api.rb +119 -0
  144. data/lib/apiwork/introspection/dump/contract.rb +129 -0
  145. data/lib/apiwork/introspection/dump/param.rb +486 -0
  146. data/lib/apiwork/introspection/dump/resource.rb +112 -0
  147. data/lib/apiwork/introspection/dump/type.rb +339 -0
  148. data/lib/apiwork/introspection/dump.rb +17 -0
  149. data/lib/apiwork/introspection/enum.rb +63 -0
  150. data/lib/apiwork/introspection/error_code.rb +44 -0
  151. data/lib/apiwork/introspection/param/array.rb +88 -0
  152. data/lib/apiwork/introspection/param/base.rb +285 -0
  153. data/lib/apiwork/introspection/param/binary.rb +73 -0
  154. data/lib/apiwork/introspection/param/boolean.rb +73 -0
  155. data/lib/apiwork/introspection/param/date.rb +73 -0
  156. data/lib/apiwork/introspection/param/date_time.rb +73 -0
  157. data/lib/apiwork/introspection/param/decimal.rb +121 -0
  158. data/lib/apiwork/introspection/param/integer.rb +131 -0
  159. data/lib/apiwork/introspection/param/literal.rb +45 -0
  160. data/lib/apiwork/introspection/param/number.rb +121 -0
  161. data/lib/apiwork/introspection/param/object.rb +59 -0
  162. data/lib/apiwork/introspection/param/reference.rb +45 -0
  163. data/lib/apiwork/introspection/param/string.rb +122 -0
  164. data/lib/apiwork/introspection/param/time.rb +73 -0
  165. data/lib/apiwork/introspection/param/union.rb +57 -0
  166. data/lib/apiwork/introspection/param/unknown.rb +26 -0
  167. data/lib/apiwork/introspection/param/uuid.rb +73 -0
  168. data/lib/apiwork/introspection/param.rb +31 -0
  169. data/lib/apiwork/introspection/type.rb +129 -0
  170. data/lib/apiwork/introspection.rb +28 -0
  171. data/lib/apiwork/issue.rb +80 -0
  172. data/lib/apiwork/json_pointer.rb +21 -0
  173. data/lib/apiwork/object.rb +1618 -0
  174. data/lib/apiwork/reference_generator.rb +622 -0
  175. data/lib/apiwork/registry.rb +56 -0
  176. data/lib/apiwork/representation/association.rb +391 -0
  177. data/lib/apiwork/representation/attribute.rb +335 -0
  178. data/lib/apiwork/representation/base.rb +819 -0
  179. data/lib/apiwork/representation/deserializer.rb +95 -0
  180. data/lib/apiwork/representation/element.rb +128 -0
  181. data/lib/apiwork/representation/inheritance.rb +78 -0
  182. data/lib/apiwork/representation/model_detector.rb +75 -0
  183. data/lib/apiwork/representation/root_key.rb +35 -0
  184. data/lib/apiwork/representation/serializer.rb +127 -0
  185. data/lib/apiwork/request.rb +79 -0
  186. data/lib/apiwork/response.rb +56 -0
  187. data/lib/apiwork/union.rb +102 -0
  188. data/lib/apiwork/version.rb +2 -2
  189. data/lib/apiwork.rb +61 -3
  190. data/lib/generators/apiwork/api_generator.rb +38 -0
  191. data/lib/generators/apiwork/contract_generator.rb +25 -0
  192. data/lib/generators/apiwork/install_generator.rb +27 -0
  193. data/lib/generators/apiwork/representation_generator.rb +25 -0
  194. data/lib/generators/apiwork/templates/api/api.rb.tt +4 -0
  195. data/lib/generators/apiwork/templates/contract/contract.rb.tt +6 -0
  196. data/lib/generators/apiwork/templates/install/application_contract.rb.tt +5 -0
  197. data/lib/generators/apiwork/templates/install/application_representation.rb.tt +5 -0
  198. data/lib/generators/apiwork/templates/representation/representation.rb.tt +6 -0
  199. data/lib/tasks/apiwork.rake +102 -0
  200. metadata +319 -19
  201. data/.rubocop.yml +0 -8
  202. data/sig/apiwork.rbs +0 -4
@@ -0,0 +1,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apiwork
4
+ module Representation
5
+ # @api public
6
+ # Represents an association defined on a representation.
7
+ #
8
+ # Associations map to model relationships and define serialization behavior.
9
+ # Used by adapters to build contracts and serialize records.
10
+ #
11
+ # @example
12
+ # association = InvoiceRepresentation.associations[:customer]
13
+ # association.name # => :customer
14
+ # association.type # => :belongs_to
15
+ # association.representation_class # => CustomerRepresentation
16
+ class Association
17
+ # @!attribute [r] description
18
+ # @api public
19
+ # The description for this association.
20
+ #
21
+ # @return [String, nil]
22
+ # @!attribute [r] example
23
+ # @api public
24
+ # The example for this association.
25
+ #
26
+ # @return [Object, nil]
27
+ # @!attribute [r] include
28
+ # @api public
29
+ # The inclusion strategy for this association.
30
+ #
31
+ # @return [Symbol]
32
+ # @!attribute [r] name
33
+ # @api public
34
+ # The name for this association.
35
+ #
36
+ # @return [Symbol]
37
+ # @!attribute [r] polymorphic
38
+ # @api public
39
+ # The polymorphic representations for this association.
40
+ #
41
+ # @return [Array<Class<Representation::Base>>, nil]
42
+ # @!attribute [r] type
43
+ # @api public
44
+ # The type for this association.
45
+ #
46
+ # @return [Symbol]
47
+ # @!attribute [r] model_class
48
+ # @api public
49
+ # The model class for this association.
50
+ #
51
+ # @return [Class<ActiveRecord::Base>]
52
+ attr_reader :allow_destroy,
53
+ :description,
54
+ :discriminator,
55
+ :example,
56
+ :include,
57
+ :model_class,
58
+ :name,
59
+ :polymorphic,
60
+ :type
61
+
62
+ def initialize(
63
+ name,
64
+ type,
65
+ owner_representation_class,
66
+ allow_destroy: false,
67
+ deprecated: false,
68
+ description: nil,
69
+ example: nil,
70
+ filterable: false,
71
+ include: :optional,
72
+ nullable: nil,
73
+ polymorphic: nil,
74
+ representation: nil,
75
+ sortable: false,
76
+ writable: false
77
+ )
78
+ @name = name
79
+ @type = type
80
+ @owner_representation_class = owner_representation_class
81
+ @model_class = owner_representation_class.model_class
82
+ @representation_class = representation
83
+ validate_representation!
84
+ @polymorphic = normalize_polymorphic(polymorphic)
85
+
86
+ @filterable = filterable
87
+ @sortable = sortable
88
+ @include = include
89
+ @writable = writable
90
+ @allow_destroy = allow_destroy
91
+ @nullable = nullable
92
+ @description = description
93
+ @example = example
94
+ @deprecated = deprecated
95
+
96
+ detect_polymorphic_discriminator! if @polymorphic
97
+
98
+ validate_include_option!
99
+ validate_association_exists!
100
+ validate_polymorphic!
101
+ validate_nested_attributes!
102
+ validate_query_options!
103
+ end
104
+
105
+ # @api public
106
+ # Whether this association is deprecated.
107
+ #
108
+ # @return [Boolean]
109
+ def deprecated?
110
+ @deprecated
111
+ end
112
+
113
+ # @api public
114
+ # Whether this association is filterable.
115
+ #
116
+ # @return [Boolean]
117
+ def filterable?
118
+ @filterable
119
+ end
120
+
121
+ # @api public
122
+ # Whether this association is sortable.
123
+ #
124
+ # @return [Boolean]
125
+ def sortable?
126
+ @sortable
127
+ end
128
+
129
+ # @api public
130
+ # Whether this association is writable.
131
+ #
132
+ # @return [Boolean]
133
+ # @see #writable_for?
134
+ def writable?
135
+ [true, :create, :update].include?(@writable)
136
+ end
137
+
138
+ # @api public
139
+ # Whether this association is writable for the given action.
140
+ #
141
+ # @param action [Symbol] [:create, :update]
142
+ # The action.
143
+ # @return [Boolean]
144
+ # @see #writable?
145
+ def writable_for?(action)
146
+ [true, action].include?(@writable)
147
+ end
148
+
149
+ # @api public
150
+ # Whether this association is a collection.
151
+ #
152
+ # @return [Boolean]
153
+ def collection?
154
+ @type == :has_many
155
+ end
156
+
157
+ # @api public
158
+ # Whether this association is singular.
159
+ #
160
+ # @return [Boolean]
161
+ def singular?
162
+ %i[has_one belongs_to].include?(@type)
163
+ end
164
+
165
+ # @api public
166
+ # Whether this association is polymorphic.
167
+ #
168
+ # @return [Boolean]
169
+ def polymorphic?
170
+ @polymorphic.present?
171
+ end
172
+
173
+ # @api public
174
+ # Whether this association is nullable.
175
+ #
176
+ # @return [Boolean]
177
+ def nullable?
178
+ return @nullable unless @nullable.nil?
179
+
180
+ case @type
181
+ when :belongs_to
182
+ return false unless @model_class
183
+
184
+ foreign_key = detect_foreign_key
185
+ column = column_for(foreign_key)
186
+ return false unless column
187
+
188
+ column.null
189
+ when :has_one, :has_many
190
+ false
191
+ end
192
+ end
193
+
194
+ # @api public
195
+ # Uses explicit `representation:` if set, otherwise inferred from the model.
196
+ #
197
+ # @return [Class<Representation::Base>, nil]
198
+ def representation_class
199
+ @representation_class || inferred_representation_class
200
+ end
201
+
202
+ def representation_class_name
203
+ @representation_class_name ||= @owner_representation_class
204
+ .name
205
+ .demodulize
206
+ .delete_suffix('Representation')
207
+ .underscore
208
+ end
209
+
210
+ def find_representation_for_type(type_value)
211
+ return nil unless @polymorphic
212
+
213
+ @polymorphic.find do |representation_class|
214
+ representation_class.model_class.polymorphic_name == type_value
215
+ end
216
+ end
217
+
218
+ private
219
+
220
+ def inferred_representation_class
221
+ return nil if polymorphic?
222
+ return nil unless @model_class
223
+
224
+ reflection = @model_class.reflect_on_association(@name)
225
+ return nil if reflection.nil? || reflection.polymorphic?
226
+
227
+ namespace = @owner_representation_class.name.deconstantize
228
+ "#{namespace}::#{reflection.klass.name.demodulize}Representation".safe_constantize
229
+ end
230
+
231
+ def normalize_polymorphic(value)
232
+ return nil unless value
233
+ return nil unless value.is_a?(Array)
234
+
235
+ value.each do |item|
236
+ validate_polymorphic_item!(item)
237
+ end
238
+
239
+ value
240
+ end
241
+
242
+ def validate_polymorphic_item!(item)
243
+ return if item.is_a?(Class) && item < Apiwork::Representation::Base
244
+
245
+ if item.is_a?(Symbol)
246
+ raise ConfigurationError,
247
+ 'polymorphic requires representation classes, not symbols. ' \
248
+ "Use `polymorphic: [#{item.to_s.camelize}Representation]` instead of `polymorphic: [:#{item}]`"
249
+ elsif item.is_a?(String)
250
+ raise ConfigurationError,
251
+ 'polymorphic requires representation classes, not strings. ' \
252
+ "Use `polymorphic: [#{item.split('::').last}]` instead of `polymorphic: ['#{item}']`"
253
+ else
254
+ raise ConfigurationError,
255
+ "polymorphic requires representation classes, got #{item.class}"
256
+ end
257
+ end
258
+
259
+ def validate_representation!
260
+ return unless @representation_class
261
+ return if @representation_class.is_a?(Class) && @representation_class < Apiwork::Representation::Base
262
+
263
+ case @representation_class
264
+ when Symbol
265
+ raise ConfigurationError,
266
+ 'representation must be a Representation class, not a symbol. ' \
267
+ "Use: representation: #{@representation_class.to_s.camelize}Representation (not :#{@representation_class})"
268
+ when String
269
+ raise ConfigurationError,
270
+ 'representation must be a Representation class, not a string. ' \
271
+ "Use: representation: #{@representation_class.split('::').last} (not '#{@representation_class}')"
272
+ when Class
273
+ raise ConfigurationError,
274
+ 'representation must be a Representation class (subclass of Apiwork::Representation::Base), ' \
275
+ "got #{@representation_class}"
276
+ else
277
+ raise ConfigurationError,
278
+ "representation must be a Representation class, got #{@representation_class.class}"
279
+ end
280
+ end
281
+
282
+ def column_for(name)
283
+ @model_class.columns_hash[name.to_s]
284
+ end
285
+
286
+ def detect_foreign_key
287
+ reflection = @model_class.reflect_on_association(@name)
288
+ return "#{@name}_id" unless reflection
289
+
290
+ reflection.foreign_key
291
+ end
292
+
293
+ def detect_polymorphic_discriminator!
294
+ return unless @model_class
295
+
296
+ reflection = @model_class.reflect_on_association(@name)
297
+ return unless reflection
298
+ return unless reflection.foreign_type
299
+
300
+ @discriminator = reflection.foreign_type.to_sym
301
+ end
302
+
303
+ def validate_polymorphic!
304
+ return unless polymorphic?
305
+
306
+ raise_polymorphic_error(:filterable) if @filterable
307
+ raise_polymorphic_error(:sortable) if @sortable
308
+ raise_polymorphic_error(:writable, suffix: '. Rails does not support accepts_nested_attributes_for on polymorphic associations') if writable?
309
+ end
310
+
311
+ def raise_polymorphic_error(option, suffix: '')
312
+ raise ConfigurationError.new(
313
+ code: :invalid_polymorphic_option,
314
+ detail: "Polymorphic association '#{@name}' cannot use #{option}: true#{suffix}",
315
+ path: [@name],
316
+ )
317
+ end
318
+
319
+ def validate_include_option!
320
+ valid_options = %i[always optional]
321
+ return if valid_options.include?(@include)
322
+
323
+ detail = "Invalid include option ':#{@include}' for association '#{@name}'. " \
324
+ 'Must be :always or :optional'
325
+ error = ConfigurationError.new(
326
+ detail:,
327
+ code: :invalid_include_option,
328
+ path: [@name],
329
+ )
330
+
331
+ raise error
332
+ end
333
+
334
+ def validate_association_exists!
335
+ return if @owner_representation_class.abstract?
336
+ return if @model_class.nil?
337
+ return if @representation_class
338
+
339
+ reflection = @model_class.reflect_on_association(@name)
340
+ return if reflection
341
+
342
+ detail = "Undefined association '#{@name}' in #{@owner_representation_class.name}: no association on model"
343
+ error = ConfigurationError.new(
344
+ detail:,
345
+ code: :invalid_association,
346
+ path: [@name],
347
+ )
348
+
349
+ raise error
350
+ end
351
+
352
+ def validate_nested_attributes!
353
+ return unless @model_class
354
+ return unless writable?
355
+
356
+ nested_attribute_method = "#{@name}_attributes="
357
+ unless @model_class.instance_methods.include?(nested_attribute_method.to_sym)
358
+ detail = "#{@model_class.name} doesn't accept nested attributes for #{@name}. " \
359
+ "Add: accepts_nested_attributes_for :#{@name}"
360
+ error = ConfigurationError.new(
361
+ detail:,
362
+ code: :missing_nested_attributes,
363
+ path: [@name],
364
+ )
365
+
366
+ raise error
367
+ end
368
+
369
+ nested_options = @model_class.nested_attributes_options[@name]
370
+ return unless nested_options
371
+
372
+ @allow_destroy = nested_options[:allow_destroy]
373
+ end
374
+
375
+ def validate_query_options!
376
+ return unless @filterable || @sortable
377
+ return if @owner_representation_class.abstract?
378
+ return unless @model_class
379
+
380
+ reflection = @model_class.reflect_on_association(@name)
381
+ return if reflection
382
+
383
+ raise ConfigurationError.new(
384
+ code: :query_option_requires_association,
385
+ detail: "Association #{@name}: filterable/sortable requires an ActiveRecord association for JOINs",
386
+ path: [@name],
387
+ )
388
+ end
389
+ end
390
+ end
391
+ end
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apiwork
4
+ module Representation
5
+ # @api public
6
+ # Represents an attribute defined on a representation.
7
+ #
8
+ # Attributes map to model columns and define serialization behavior.
9
+ # Used by adapters to build contracts and serialize records.
10
+ #
11
+ # @example
12
+ # attribute = InvoiceRepresentation.attributes[:title]
13
+ # attribute.name # => :title
14
+ # attribute.type # => :string
15
+ # attribute.filterable? # => true
16
+ class Attribute
17
+ ALLOWED_FORMATS = {
18
+ decimal: %i[float double],
19
+ integer: %i[int32 int64],
20
+ number: %i[float double],
21
+ string: %i[email uuid url date datetime ipv4 ipv6 password hostname],
22
+ }.freeze
23
+
24
+ # @!attribute [r] description
25
+ # @api public
26
+ # The description for this attribute.
27
+ #
28
+ # @return [String, nil]
29
+ # @!attribute [r] enum
30
+ # @api public
31
+ # The enum for this attribute.
32
+ #
33
+ # @return [Array<Object>, nil]
34
+ # @!attribute [r] example
35
+ # @api public
36
+ # The example for this attribute.
37
+ #
38
+ # @return [Object, nil]
39
+ # @!attribute [r] format
40
+ # @api public
41
+ # The format for this attribute.
42
+ #
43
+ # @return [Symbol, nil]
44
+ # @!attribute [r] max
45
+ # @api public
46
+ # The maximum for this attribute.
47
+ #
48
+ # @return [Integer, nil]
49
+ # @!attribute [r] min
50
+ # @api public
51
+ # The minimum for this attribute.
52
+ #
53
+ # @return [Integer, nil]
54
+ # @!attribute [r] name
55
+ # @api public
56
+ # The name for this attribute.
57
+ #
58
+ # @return [Symbol]
59
+ # @!attribute [r] of
60
+ # @api public
61
+ # The of for this attribute.
62
+ #
63
+ # @return [Symbol, nil]
64
+ # @!attribute [r] preload
65
+ # @api public
66
+ # The preload for this attribute.
67
+ #
68
+ # @return [Symbol, Array, Hash, nil]
69
+ # @!attribute [r] type
70
+ # @api public
71
+ # The type for this attribute.
72
+ #
73
+ # @return [Symbol]
74
+ attr_reader :description,
75
+ :element,
76
+ :empty,
77
+ :enum,
78
+ :example,
79
+ :format,
80
+ :max,
81
+ :min,
82
+ :name,
83
+ :of,
84
+ :optional,
85
+ :preload,
86
+ :type
87
+
88
+ def initialize(
89
+ name,
90
+ owner_representation_class,
91
+ decode: nil,
92
+ deprecated: false,
93
+ description: nil,
94
+ empty: false,
95
+ encode: nil,
96
+ enum: nil,
97
+ example: nil,
98
+ filterable: false,
99
+ format: nil,
100
+ max: nil,
101
+ min: nil,
102
+ nullable: nil,
103
+ optional: nil,
104
+ preload: nil,
105
+ sortable: false,
106
+ type: nil,
107
+ writable: false,
108
+ &block
109
+ )
110
+ @name = name
111
+ @owner_representation_class = owner_representation_class
112
+ @of = nil
113
+
114
+ if block
115
+ element = Element.new
116
+ block.arity.positive? ? yield(element) : element.instance_eval(&block)
117
+ element.validate!
118
+ @element = element
119
+ type = element.type
120
+ @of = element.inner&.type if element.type == :array
121
+ end
122
+
123
+ if owner_representation_class.model_class.present?
124
+ @model_class = owner_representation_class.model_class
125
+
126
+ begin
127
+ @db_column = @model_class.column_names.include?(name.to_s)
128
+
129
+ detected_enum = detect_enum_values(name)
130
+ enum ||= detected_enum
131
+ type ||= detect_type(name) if @db_column
132
+ type = :string if detected_enum && type == :integer
133
+ optional = detect_optional(name) if optional.nil?
134
+ nullable = detect_nullable(name) if nullable.nil?
135
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
136
+ @db_column = false
137
+ end
138
+ end
139
+
140
+ optional = false if optional.nil?
141
+ nullable = false if nullable.nil?
142
+
143
+ @filterable = filterable
144
+ @preload = preload
145
+ @sortable = sortable
146
+ @writable = writable
147
+ @encode = encode
148
+ @decode = decode
149
+ @empty = empty
150
+ @nullable = nullable
151
+ @optional = optional
152
+ @type = type || :unknown
153
+ @enum = enum
154
+ @min = min
155
+ @max = max
156
+ @description = description
157
+ @example = example
158
+ @format = format
159
+ @deprecated = deprecated
160
+
161
+ validate_min_max_range!
162
+ validate_format!
163
+ validate_empty!
164
+ end
165
+
166
+ # @api public
167
+ # Whether this attribute is deprecated.
168
+ #
169
+ # @return [Boolean]
170
+ def deprecated?
171
+ @deprecated
172
+ end
173
+
174
+ # @api public
175
+ # Whether this attribute is filterable.
176
+ #
177
+ # @return [Boolean]
178
+ def filterable?
179
+ @filterable
180
+ end
181
+
182
+ # @api public
183
+ # Whether this attribute is sortable.
184
+ #
185
+ # @return [Boolean]
186
+ def sortable?
187
+ @sortable
188
+ end
189
+
190
+ # @api public
191
+ # Whether this attribute is optional.
192
+ #
193
+ # @return [Boolean]
194
+ def optional?
195
+ @optional
196
+ end
197
+
198
+ # @api public
199
+ # Whether this attribute is nullable.
200
+ #
201
+ # @return [Boolean]
202
+ def nullable?
203
+ return false if @empty
204
+
205
+ @nullable
206
+ end
207
+
208
+ # @api public
209
+ # Whether this attribute is writable.
210
+ #
211
+ # @return [Boolean]
212
+ # @see #writable_for?
213
+ def writable?
214
+ [true, :create, :update].include?(@writable)
215
+ end
216
+
217
+ # @api public
218
+ # Whether this attribute is writable for the given action.
219
+ #
220
+ # @param action [Symbol] [:create, :update]
221
+ # The action.
222
+ # @return [Boolean]
223
+ # @see #writable?
224
+ def writable_for?(action)
225
+ [true, action].include?(@writable)
226
+ end
227
+
228
+ def encode(value)
229
+ result = @empty && value.nil? ? '' : value
230
+ @encode ? @encode.call(result) : result
231
+ end
232
+
233
+ def decode(value)
234
+ result = @decode ? @decode.call(value) : value
235
+ @empty ? result.presence : result
236
+ end
237
+
238
+ def representation_class_name
239
+ @representation_class_name ||= @owner_representation_class
240
+ .name
241
+ .demodulize
242
+ .delete_suffix('Representation')
243
+ .underscore
244
+ end
245
+
246
+ private
247
+
248
+ def detect_enum_values(name)
249
+ return nil unless @model_class.defined_enums.key?(name.to_s)
250
+
251
+ @model_class.defined_enums[name.to_s].keys
252
+ end
253
+
254
+ def detect_type(name)
255
+ raw_type = @model_class.type_for_attribute(name).type
256
+ normalize_db_type(raw_type)
257
+ end
258
+
259
+ def normalize_db_type(type)
260
+ case type
261
+ when :text then :string
262
+ when :jsonb, :json then :unknown
263
+ when :float then :number
264
+ else type
265
+ end
266
+ end
267
+
268
+ def detect_optional(name)
269
+ return false unless @model_class
270
+ return false unless db_column?
271
+
272
+ column = column_for(name)
273
+ return false unless column
274
+
275
+ return true if column.default.present?
276
+
277
+ column.null
278
+ end
279
+
280
+ def detect_nullable(name)
281
+ return false unless @model_class
282
+ return false unless db_column?
283
+
284
+ column = column_for(name)
285
+ return false unless column
286
+
287
+ column.null
288
+ end
289
+
290
+ def column_for(name)
291
+ @model_class.columns_hash[name.to_s]
292
+ end
293
+
294
+ def db_column?
295
+ @db_column
296
+ end
297
+
298
+ def validate_min_max_range!
299
+ return if @min.nil?
300
+ return if @max.nil?
301
+ return unless @min > @max
302
+
303
+ raise ConfigurationError,
304
+ "Attribute #{@name}: min (#{@min}) cannot be greater than max (#{@max})"
305
+ end
306
+
307
+ def validate_format!
308
+ return if @format.nil?
309
+ return if @type == :unknown
310
+
311
+ allowed_formats = ALLOWED_FORMATS[@type]
312
+
313
+ unless allowed_formats
314
+ raise ConfigurationError,
315
+ "Attribute #{@name}: format option is not supported for type :#{@type}"
316
+ end
317
+
318
+ return if allowed_formats.include?(@format.to_sym)
319
+
320
+ raise ConfigurationError,
321
+ "Attribute #{@name}: format :#{@format} is not valid for type :#{@type}. " \
322
+ "Allowed formats: #{allowed_formats.join(', ')}"
323
+ end
324
+
325
+ def validate_empty!
326
+ return unless @empty
327
+ return if @type == :unknown
328
+ return if @type == :string
329
+
330
+ raise ConfigurationError,
331
+ "Attribute #{@name}: empty option is only supported for type :string"
332
+ end
333
+ end
334
+ end
335
+ end