apiwork 0.0.0.pre → 0.1.2

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 +638 -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,819 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apiwork
4
+ module Representation
5
+ # @api public
6
+ # Base class for representations.
7
+ #
8
+ # Defines how an ActiveRecord model is represented in the API. Drives contracts and runtime behavior.
9
+ # Sensible defaults are auto-detected from database columns but can be overridden. Supports STI and
10
+ # polymorphic associations.
11
+ #
12
+ # @example Basic representation
13
+ # class InvoiceRepresentation < Apiwork::Representation::Base
14
+ # attribute :id
15
+ # attribute :title
16
+ # attribute :status, filterable: true, sortable: true
17
+ #
18
+ # belongs_to :customer
19
+ # has_many :items
20
+ # end
21
+ #
22
+ # @example Contract
23
+ # class InvoiceContract < Apiwork::Contract::Base
24
+ # representation InvoiceRepresentation
25
+ # end
26
+ #
27
+ # @!scope class
28
+ # @!method abstract!
29
+ # @api public
30
+ # Marks this representation as abstract.
31
+ #
32
+ # Abstract representations don't require a model and serve as base classes for other representations.
33
+ # Use this when creating application-wide base representations. Subclasses automatically become non-abstract.
34
+ # @return [void]
35
+ # @example Application base representation
36
+ # class ApplicationRepresentation < Apiwork::Representation::Base
37
+ # abstract!
38
+ # end
39
+ #
40
+ # @!method abstract?
41
+ # @api public
42
+ # Whether this representation is abstract.
43
+ # @return [Boolean]
44
+ class Base
45
+ include Abstractable
46
+
47
+ # @!method self.attributes
48
+ # @api public
49
+ # The attributes for this representation.
50
+ #
51
+ # @return [Hash{Symbol => Attribute}]
52
+ class_attribute :attributes, default: {}, instance_accessor: false
53
+
54
+ # @!method self.associations
55
+ # @api public
56
+ # The associations for this representation.
57
+ #
58
+ # @return [Hash{Symbol => Association}]
59
+ class_attribute :associations, default: {}, instance_accessor: false
60
+
61
+ # @!method self.inheritance
62
+ # @api public
63
+ # The inheritance configuration for this representation.
64
+ #
65
+ # Auto-configured when the model uses STI and representation classes mirror the model hierarchy.
66
+ # Subclasses share the parent's inheritance configuration.
67
+ #
68
+ # @return [Representation::Inheritance, nil]
69
+ class_attribute :inheritance, default: nil, instance_accessor: false
70
+
71
+ class_attribute :_adapter_config, default: {}, instance_accessor: false
72
+
73
+ # @!attribute [r] context
74
+ # @api public
75
+ # The serialization context.
76
+ #
77
+ # Passed from controller or directly to {.serialize}. Use for data that isn't on the record, like
78
+ # current user or permissions.
79
+ #
80
+ # @return [Hash]
81
+ #
82
+ # @example Override in controller
83
+ # def context
84
+ # { current_user: current_user }
85
+ # end
86
+ #
87
+ # @example Access in custom attribute
88
+ # attribute :editable, type: :boolean
89
+ #
90
+ # def editable
91
+ # context[:current_user]&.admin?
92
+ # end
93
+ #
94
+ # @!attribute [r] record
95
+ # @api public
96
+ # The record for this representation.
97
+ #
98
+ # Available in custom attributes and associations.
99
+ #
100
+ # @return [ActiveRecord::Base]
101
+ #
102
+ # @example Custom attribute
103
+ # attribute :full_name, type: :string
104
+ #
105
+ # def full_name
106
+ # "#{record.first_name} #{record.last_name}"
107
+ # end
108
+ attr_reader :context,
109
+ :record
110
+
111
+ class << self
112
+ # @api public
113
+ # Configures the model class for this representation.
114
+ #
115
+ # Auto-detected from representation name when not set. Use {.model_class} to retrieve.
116
+ #
117
+ # @param value [Class<ActiveRecord::Base>]
118
+ # The model class.
119
+ # @return [void]
120
+ # @raise [ArgumentError] if value is not an ActiveRecord model class
121
+ #
122
+ # @example
123
+ # model Invoice
124
+ def model(value)
125
+ unless value.is_a?(Class)
126
+ raise ConfigurationError,
127
+ "model must be an ActiveRecord model class, got #{value.class}. " \
128
+ "Use: model Post (not 'Post' or :post)"
129
+ end
130
+ unless value < ActiveRecord::Base
131
+ raise ConfigurationError,
132
+ "model must be an ActiveRecord model class, got #{value}"
133
+ end
134
+ @model_class = value
135
+ end
136
+
137
+ # @api public
138
+ # Configures the JSON root key for this representation.
139
+ #
140
+ # Auto-detected from model name when not set. Use {.root_key} to retrieve.
141
+ #
142
+ # @param singular [String, Symbol]
143
+ # The singular root key.
144
+ # @param plural [String, Symbol] (singular.pluralize)
145
+ # The plural root key.
146
+ # @return [void]
147
+ #
148
+ # @example
149
+ # root :bill, :bills
150
+ def root(singular, plural = singular.to_s.pluralize)
151
+ @root = { plural: plural.to_s, singular: singular.to_s }
152
+ end
153
+
154
+ # @api public
155
+ # Configures adapter options for this representation.
156
+ #
157
+ # Overrides API-level options. Subclasses inherit parent adapter options.
158
+ #
159
+ # @yieldparam adapter [Configuration]
160
+ # @return [void]
161
+ #
162
+ # @example
163
+ # adapter do
164
+ # pagination do
165
+ # strategy :cursor
166
+ # default_size 50
167
+ # end
168
+ # end
169
+ def adapter(&block)
170
+ return unless block
171
+
172
+ self._adapter_config = _adapter_config.dup
173
+ config = Configuration.new(api_class.adapter_class, _adapter_config)
174
+ block.arity.positive? ? yield(config) : config.instance_eval(&block)
175
+ end
176
+
177
+ # @api public
178
+ # Defines an attribute for this representation.
179
+ #
180
+ # Subclasses inherit parent attributes.
181
+ #
182
+ # @param name [Symbol]
183
+ # The attribute name.
184
+ # @param decode [Proc, nil] (nil)
185
+ # Transform for request input (API to database). Must preserve the attribute type.
186
+ # @param deprecated [Boolean] (false)
187
+ # Whether deprecated. Metadata included in exports.
188
+ # @param description [String, nil] (nil)
189
+ # The description. Metadata included in exports.
190
+ # @param empty [Boolean, nil] (nil)
191
+ # Whether to use empty string instead of `null`. Serializes `nil` as `""` and deserializes `""` as `nil`. Only valid for `:string` type.
192
+ # @param encode [Proc, nil] (nil)
193
+ # Transform for response output (database to API). Must preserve the attribute type.
194
+ # @param enum [Array, nil] (nil)
195
+ # The allowed values. If `nil`, auto-detected from Rails enum definition.
196
+ # @param example [Object, nil] (nil)
197
+ # The example. Metadata included in exports.
198
+ # @param filterable [Boolean] (false)
199
+ # Whether the attribute is filterable.
200
+ # @param format [Symbol, nil] (nil) [:date, :datetime, :double, :email, :float, :hostname, :int32, :int64, :ipv4, :ipv6, :password, :url, :uuid]
201
+ # Format hint for exports. Does not change the type, but exports may add validation or
202
+ # documentation based on it. Valid formats by type: `:decimal`/`:number` (`:double`, `:float`),
203
+ # `:integer` (`:int32`, `:int64`), `:string` (`:date`, `:datetime`, `:email`, `:hostname`,
204
+ # `:ipv4`, `:ipv6`, `:password`, `:url`, `:uuid`).
205
+ # @param max [Integer, nil] (nil)
206
+ # The maximum. For `:array`: size. For `:decimal`, `:integer`, `:number`: value. For `:string`: length.
207
+ # @param min [Integer, nil] (nil)
208
+ # The minimum. For `:array`: size. For `:decimal`, `:integer`, `:number`: value. For `:string`: length.
209
+ # @param nullable [Boolean, nil] (nil)
210
+ # Whether the value can be `null`. If `nil` and name maps to a database column, auto-detected from column NULL constraint.
211
+ # @param optional [Boolean, nil] (nil)
212
+ # Whether the attribute is optional for writes. If `nil` and name maps to a database column,
213
+ # auto-detected from column default or NULL constraint.
214
+ # @param preload [Symbol, Array, Hash, nil] (nil)
215
+ # Associations to preload for this attribute. Use when custom attributes depend on associations.
216
+ # @param sortable [Boolean] (false)
217
+ # Whether the attribute is sortable.
218
+ # @param type [Symbol, nil] (nil) [:array, :binary, :boolean, :date, :datetime, :decimal, :integer, :number, :object, :string, :time, :unknown, :uuid]
219
+ # The type. If `nil` and name maps to a database column, auto-detected from column type.
220
+ # Defaults to `:unknown` for json/jsonb columns and when no column exists (custom attributes).
221
+ # Use an explicit type or block in those cases.
222
+ # @param writable [Boolean, Symbol] (false) [:create, :update]
223
+ # The write access. `true` for both create and update, `:create` for create only, `:update` for update only.
224
+ # @yieldparam element [Representation::Element]
225
+ # @return [void]
226
+ #
227
+ # @example Basic
228
+ # attribute :title
229
+ # attribute :price, type: :decimal, min: 0
230
+ # attribute :status, filterable: true, sortable: true
231
+ #
232
+ # @example Custom attribute with preload
233
+ # attribute :total, type: :decimal, preload: :items
234
+ #
235
+ # def total
236
+ # record.items.sum(:amount)
237
+ # end
238
+ #
239
+ # @example Nested preload
240
+ # attribute :total_with_tax, type: :decimal, preload: { items: :tax_rate }
241
+ #
242
+ # def total_with_tax
243
+ # record.items.sum { |item| item.amount * (1 + item.tax_rate.rate) }
244
+ # end
245
+ #
246
+ # @example Inline type for JSON column
247
+ # attribute :settings do
248
+ # object do
249
+ # string :theme
250
+ # boolean :notifications
251
+ # end
252
+ # end
253
+ #
254
+ # @example Encode/decode transforms
255
+ # attribute :status, encode: ->(value) { value.upcase }, decode: ->(value) { value.downcase }
256
+ #
257
+ # @example Writable only on create
258
+ # attribute :slug, writable: :create
259
+ #
260
+ # @example Explicit enum values
261
+ # attribute :priority, enum: [:low, :medium, :high]
262
+ #
263
+ # @example Multiple preloads
264
+ # attribute :summary, type: :string, preload: [:items, :customer]
265
+ #
266
+ # def summary
267
+ # "#{record.customer.name}: #{record.items.count} items"
268
+ # end
269
+ def attribute(
270
+ name,
271
+ decode: nil,
272
+ deprecated: false,
273
+ description: nil,
274
+ empty: nil,
275
+ encode: nil,
276
+ enum: nil,
277
+ example: nil,
278
+ filterable: false,
279
+ format: nil,
280
+ max: nil,
281
+ min: nil,
282
+ nullable: nil,
283
+ optional: nil,
284
+ preload: nil,
285
+ sortable: false,
286
+ type: nil,
287
+ writable: false,
288
+ &block
289
+ )
290
+ self.attributes = attributes.merge(
291
+ name => Attribute.new(
292
+ name,
293
+ self,
294
+ decode:,
295
+ deprecated:,
296
+ description:,
297
+ empty:,
298
+ encode:,
299
+ enum:,
300
+ example:,
301
+ filterable:,
302
+ format:,
303
+ max:,
304
+ min:,
305
+ nullable:,
306
+ optional:,
307
+ preload:,
308
+ sortable:,
309
+ type:,
310
+ writable:,
311
+ &block
312
+ ),
313
+ )
314
+ end
315
+
316
+ # @api public
317
+ # Defines a has_one association for this representation.
318
+ #
319
+ # Subclasses inherit parent associations.
320
+ #
321
+ # @param name [Symbol]
322
+ # The association name.
323
+ # @param deprecated [Boolean] (false)
324
+ # Whether deprecated. Metadata included in exports.
325
+ # @param description [String, nil] (nil)
326
+ # The description. Metadata included in exports.
327
+ # @param example [Object, nil] (nil)
328
+ # The example. Metadata included in exports.
329
+ # @param filterable [Boolean] (false)
330
+ # Whether the association is filterable.
331
+ # @param include [Symbol] (:optional) [:always, :optional]
332
+ # The inclusion mode.
333
+ # @param nullable [Boolean, nil] (nil)
334
+ # Whether the association can be `null`.
335
+ # @param representation [Class<Representation::Base>, nil] (nil)
336
+ # The representation class. If `nil`, inferred from the associated model in the same
337
+ # namespace (e.g., `CustomerRepresentation` for `Customer`).
338
+ # @param sortable [Boolean] (false)
339
+ # Whether the association is sortable.
340
+ # @param writable [Boolean, Symbol] (false) [:create, :update]
341
+ # The write access. `true` for both create and update, `:create` for create only, `:update` for update only.
342
+ # Requires `accepts_nested_attributes_for` on the model, where `allow_destroy: true` also enables deletion.
343
+ # @return [void]
344
+ #
345
+ # @example Basic
346
+ # has_one :profile
347
+ #
348
+ # @example Explicit representation
349
+ # has_one :author, representation: AuthorRepresentation
350
+ #
351
+ # @example Always included
352
+ # has_one :customer, include: :always
353
+ #
354
+ # @example Custom association
355
+ # has_one :profile
356
+ #
357
+ # def profile
358
+ # record.profile || record.build_profile
359
+ # end
360
+ def has_one(
361
+ name,
362
+ deprecated: false,
363
+ description: nil,
364
+ example: nil,
365
+ filterable: false,
366
+ include: :optional,
367
+ nullable: nil,
368
+ representation: nil,
369
+ sortable: false,
370
+ writable: false
371
+ )
372
+ self.associations = associations.merge(
373
+ name => Association.new(
374
+ name,
375
+ :has_one,
376
+ self,
377
+ deprecated:,
378
+ description:,
379
+ example:,
380
+ filterable:,
381
+ include:,
382
+ nullable:,
383
+ representation:,
384
+ sortable:,
385
+ writable:,
386
+ ),
387
+ )
388
+ end
389
+
390
+ # @api public
391
+ # Defines a has_many association for this representation.
392
+ #
393
+ # Subclasses inherit parent associations.
394
+ #
395
+ # @param name [Symbol]
396
+ # The association name.
397
+ # @param deprecated [Boolean] (false)
398
+ # Whether deprecated. Metadata included in exports.
399
+ # @param description [String, nil] (nil)
400
+ # The description. Metadata included in exports.
401
+ # @param example [Object, nil] (nil)
402
+ # The example. Metadata included in exports.
403
+ # @param filterable [Boolean] (false)
404
+ # Whether the association is filterable.
405
+ # @param include [Symbol] (:optional) [:always, :optional]
406
+ # The inclusion mode.
407
+ # @param representation [Class<Representation::Base>, nil] (nil)
408
+ # The representation class. If `nil`, inferred from the associated model in the same
409
+ # namespace (e.g., `CustomerRepresentation` for `Customer`).
410
+ # @param sortable [Boolean] (false)
411
+ # Whether the association is sortable.
412
+ # @param writable [Boolean, Symbol] (false) [:create, :update]
413
+ # The write access. `true` for both create and update, `:create` for create only, `:update` for update only.
414
+ # Requires `accepts_nested_attributes_for` on the model, where `allow_destroy: true` also enables deletion.
415
+ # @return [void]
416
+ # @see #has_one
417
+ #
418
+ # @example Basic
419
+ # has_many :items
420
+ #
421
+ # @example Explicit representation
422
+ # has_many :comments, representation: CommentRepresentation
423
+ #
424
+ # @example Always included
425
+ # has_many :items, include: :always
426
+ #
427
+ # @example Custom association
428
+ # has_many :items
429
+ #
430
+ # def items
431
+ # record.items.limit(5)
432
+ # end
433
+ def has_many(
434
+ name,
435
+ deprecated: false,
436
+ description: nil,
437
+ example: nil,
438
+ filterable: false,
439
+ include: :optional,
440
+ representation: nil,
441
+ sortable: false,
442
+ writable: false
443
+ )
444
+ self.associations = associations.merge(
445
+ name => Association.new(
446
+ name,
447
+ :has_many,
448
+ self,
449
+ deprecated:,
450
+ description:,
451
+ example:,
452
+ filterable:,
453
+ include:,
454
+ representation:,
455
+ sortable:,
456
+ writable:,
457
+ ),
458
+ )
459
+ end
460
+
461
+ # @api public
462
+ # Defines a belongs_to association for this representation.
463
+ #
464
+ # Subclasses inherit parent associations.
465
+ #
466
+ # @param name [Symbol]
467
+ # The association name.
468
+ # @param deprecated [Boolean] (false)
469
+ # Whether deprecated. Metadata included in exports.
470
+ # @param description [String, nil] (nil)
471
+ # The description. Metadata included in exports.
472
+ # @param example [Object, nil] (nil)
473
+ # The example. Metadata included in exports.
474
+ # @param filterable [Boolean] (false)
475
+ # Whether the association is filterable.
476
+ # @param include [Symbol] (:optional) [:always, :optional]
477
+ # The inclusion mode.
478
+ # @param nullable [Boolean, nil] (nil)
479
+ # Whether the association can be `null`. If `nil`, auto-detected from foreign key column NULL constraint.
480
+ # @param polymorphic [Array<Class<Representation::Base>>, nil] (nil)
481
+ # The allowed representation classes for polymorphic associations.
482
+ # @param representation [Class<Representation::Base>, nil] (nil)
483
+ # The representation class. If `nil`, inferred from the associated model in the same
484
+ # namespace (e.g., `CustomerRepresentation` for `Customer`).
485
+ # @param sortable [Boolean] (false)
486
+ # Whether the association is sortable.
487
+ # @param writable [Boolean, Symbol] (false) [:create, :update]
488
+ # The write access. `true` for both create and update, `:create` for create only, `:update` for update only.
489
+ # Requires `accepts_nested_attributes_for` on the model, where `allow_destroy: true` also enables deletion.
490
+ # @return [void]
491
+ # @see #has_one
492
+ #
493
+ # @example Basic
494
+ # belongs_to :customer
495
+ #
496
+ # @example Explicit representation
497
+ # belongs_to :author, representation: AuthorRepresentation
498
+ #
499
+ # @example Always included
500
+ # belongs_to :customer, include: :always
501
+ #
502
+ # @example Polymorphic
503
+ # belongs_to :commentable, polymorphic: [PostRepresentation, CustomerRepresentation]
504
+ #
505
+ # @example Custom association
506
+ # belongs_to :customer
507
+ #
508
+ # def customer
509
+ # record.customer || Customer.default
510
+ # end
511
+ def belongs_to(
512
+ name,
513
+ deprecated: false,
514
+ description: nil,
515
+ example: nil,
516
+ filterable: false,
517
+ include: :optional,
518
+ nullable: nil,
519
+ polymorphic: nil,
520
+ representation: nil,
521
+ sortable: false,
522
+ writable: false
523
+ )
524
+ self.associations = associations.merge(
525
+ name => Association.new(
526
+ name,
527
+ :belongs_to,
528
+ self,
529
+ deprecated:,
530
+ description:,
531
+ example:,
532
+ filterable:,
533
+ include:,
534
+ nullable:,
535
+ polymorphic:,
536
+ representation:,
537
+ sortable:,
538
+ writable:,
539
+ ),
540
+ )
541
+ end
542
+
543
+ # @api public
544
+ # The type name for this representation.
545
+ #
546
+ # Overrides the model's default for STI and polymorphic types.
547
+ #
548
+ # @param value [String, Symbol, nil] (nil)
549
+ # The type name.
550
+ # @return [String, nil]
551
+ # @see .sti_name
552
+ # @see .polymorphic_name
553
+ #
554
+ # @example
555
+ # type_name :person
556
+ def type_name(value = nil)
557
+ return @type_name = value.to_s if value
558
+
559
+ @type_name
560
+ end
561
+
562
+ # @api public
563
+ # The STI name for this representation.
564
+ #
565
+ # Uses {.type_name} if set, otherwise the model's `sti_name`.
566
+ #
567
+ # @return [String]
568
+ def sti_name
569
+ @type_name || model_class.sti_name
570
+ end
571
+
572
+ # @api public
573
+ # The polymorphic name for this representation.
574
+ #
575
+ # Uses {.type_name} if set, otherwise the model's `polymorphic_name`.
576
+ #
577
+ # @return [String]
578
+ def polymorphic_name
579
+ @type_name || model_class.polymorphic_name
580
+ end
581
+
582
+ # @api public
583
+ # Whether this representation is an STI subclass.
584
+ #
585
+ # @return [Boolean]
586
+ def subclass?
587
+ superclass.respond_to?(:inheritance) && superclass.inheritance&.subclass?(self)
588
+ end
589
+
590
+ # @api public
591
+ # The description for this representation.
592
+ #
593
+ # Metadata included in exports.
594
+ #
595
+ # @param value [String, nil] (nil)
596
+ # The description.
597
+ # @return [String, nil]
598
+ #
599
+ # @example
600
+ # description 'A customer invoice'
601
+ def description(value = nil)
602
+ return @description if value.nil?
603
+
604
+ @description = value
605
+ end
606
+
607
+ # @api public
608
+ # Marks this representation as deprecated.
609
+ #
610
+ # Metadata included in exports.
611
+ #
612
+ # @return [void]
613
+ #
614
+ # @example
615
+ # deprecated!
616
+ def deprecated!
617
+ @deprecated = true
618
+ end
619
+
620
+ # @api public
621
+ # The example value for this representation.
622
+ #
623
+ # Metadata included in exports.
624
+ #
625
+ # @param value [Hash, nil] (nil)
626
+ # The example.
627
+ # @return [Hash, nil]
628
+ #
629
+ # @example
630
+ # example id: 1, total: 99.00, status: 'paid'
631
+ def example(value = nil)
632
+ return @example if value.nil?
633
+
634
+ @example = value
635
+ end
636
+
637
+ # @api public
638
+ # Transforms a record or an array of records to hashes.
639
+ #
640
+ # Applies attribute encoders, maps STI and polymorphic type names,
641
+ # and recursively serializes nested associations.
642
+ #
643
+ # @param resource [ActiveRecord::Base, Array<ActiveRecord::Base>]
644
+ # The resource to serialize.
645
+ # @param context [Hash] ({})
646
+ # The serialization context.
647
+ # @param include [Symbol, Array, Hash, nil] (nil)
648
+ # The associations to include.
649
+ # @return [Hash, Array<Hash>]
650
+ #
651
+ # @example Basic
652
+ # InvoiceRepresentation.serialize(invoice)
653
+ # # => { id: 1, total: 99.00, status: 'paid' }
654
+ #
655
+ # @example Collection
656
+ # InvoiceRepresentation.serialize(invoices)
657
+ # # => [{ id: 1, ... }, { id: 2, ... }]
658
+ #
659
+ # @example With associations
660
+ # InvoiceRepresentation.serialize(invoice, include: [:customer, :items])
661
+ # # => { id: 1, ..., customer: { id: 1, name: 'Acme' }, items: [...] }
662
+ #
663
+ # @example Nested associations
664
+ # InvoiceRepresentation.serialize(invoice, include: { customer: [:address] })
665
+ # # => { id: 1, ..., customer: { id: 1, name: 'Acme', address: { ... } } }
666
+ def serialize(resource, context: {}, include: nil)
667
+ if resource.is_a?(Enumerable)
668
+ resource.map { |record| serialize_record(record, context:, include:) }
669
+ else
670
+ serialize_record(resource, context:, include:)
671
+ end
672
+ end
673
+
674
+ # @api public
675
+ # Transforms a hash or an array of hashes for records.
676
+ #
677
+ # Applies attribute decoders, maps STI and polymorphic type names,
678
+ # and recursively deserializes nested associations.
679
+ #
680
+ # @param payload [Hash, Array<Hash>]
681
+ # The payload to deserialize.
682
+ # @return [Hash, Array<Hash>]
683
+ #
684
+ # @example
685
+ # InvoiceRepresentation.deserialize(params[:invoice])
686
+ def deserialize(payload)
687
+ Deserializer.deserialize(self, payload)
688
+ end
689
+
690
+ # @api public
691
+ # The root key for this representation.
692
+ #
693
+ # Derived from model name when {.root} is not set.
694
+ #
695
+ # @return [RootKey]
696
+ def root_key
697
+ if @root
698
+ RootKey.new(@root[:singular], @root[:plural])
699
+ else
700
+ RootKey.new(model_class.model_name.element)
701
+ end
702
+ end
703
+
704
+ # @api public
705
+ # The model class for this representation.
706
+ #
707
+ # Auto-detected from representation name or set via {.model}.
708
+ #
709
+ # @return [Class<ActiveRecord::Base>]
710
+ def model_class
711
+ ensure_auto_detection_complete
712
+ ensure_sti_auto_configuration_complete
713
+ @model_class
714
+ end
715
+
716
+ # @api public
717
+ # Whether this representation is deprecated.
718
+ #
719
+ # @return [Boolean]
720
+ def deprecated?
721
+ @deprecated == true
722
+ end
723
+
724
+ def adapter_config
725
+ @adapter_config ||= api_class.adapter_config.merge(_adapter_config)
726
+ end
727
+
728
+ def api_class
729
+ return nil unless name
730
+
731
+ namespace = name.deconstantize
732
+ return nil if namespace.blank?
733
+
734
+ API.find("/#{namespace.underscore.tr('::', '/')}")
735
+ end
736
+
737
+ def preloads
738
+ attributes.values.filter_map(&:preload)
739
+ end
740
+
741
+ def polymorphic_association_for_type_column(column_name)
742
+ associations.values.find do |association|
743
+ association.polymorphic? && association.discriminator == column_name
744
+ end
745
+ end
746
+
747
+ def inheritance_for_column(column_name)
748
+ target_class = subclass? ? superclass : self
749
+ target_inheritance = target_class.inheritance
750
+ target_model = target_class.model_class
751
+
752
+ return nil unless target_inheritance&.subclasses&.any?
753
+ return nil unless target_model.respond_to?(:inheritance_column)
754
+
755
+ target_inheritance if column_name.to_sym == target_model.inheritance_column.to_sym
756
+ end
757
+
758
+ def serialize_record(record, context: {}, include: nil)
759
+ subclass_representation_class = inheritance.resolve(record) if inheritance&.subclasses&.any?
760
+ representation_class = subclass_representation_class || self
761
+
762
+ representation_class.new(record, context:, include:).as_json
763
+ end
764
+
765
+ private
766
+
767
+ def ensure_auto_detection_complete
768
+ return if @auto_detection_complete
769
+
770
+ @auto_detection_complete = true
771
+ return if @model_class.present?
772
+
773
+ detected = ModelDetector.new(self).detect
774
+ @model_class = detected if detected
775
+ end
776
+
777
+ def ensure_sti_auto_configuration_complete
778
+ return if @sti_auto_configuration_complete
779
+
780
+ @sti_auto_configuration_complete = true
781
+ ensure_auto_detection_complete
782
+ return unless @model_class
783
+
784
+ model_detector = ModelDetector.new(self)
785
+
786
+ auto_configure_inheritance if model_detector.sti_base?(@model_class) && inheritance.nil?
787
+
788
+ return unless model_detector.sti_subclass?(@model_class)
789
+ return unless model_detector.superclass_is_sti_base?(@model_class)
790
+ return if subclass?
791
+
792
+ auto_register_subclass
793
+ end
794
+
795
+ def auto_configure_inheritance
796
+ self.inheritance = Inheritance.new(self)
797
+ end
798
+
799
+ def auto_register_subclass
800
+ superclass.send(:ensure_sti_auto_configuration_complete)
801
+ return unless superclass.inheritance
802
+
803
+ superclass.inheritance.register(self)
804
+ superclass._abstract = true
805
+ end
806
+ end
807
+
808
+ def initialize(record, context: {}, include: nil)
809
+ @record = record
810
+ @context = context
811
+ @include = include
812
+ end
813
+
814
+ def as_json
815
+ Serializer.serialize(self, @include)
816
+ end
817
+ end
818
+ end
819
+ end