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,802 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apiwork
4
+ module API
5
+ # @api public
6
+ # Base class for API definitions.
7
+ #
8
+ # Created via {API.define}. Configure resources, types, enums,
9
+ # adapters, and exports. Each API is mounted at a unique path.
10
+ #
11
+ # @example Define an API
12
+ # Apiwork::API.define '/api/v1' do
13
+ # key_format :camel
14
+ #
15
+ # resources :invoices do
16
+ # resources :items
17
+ # end
18
+ # end
19
+ class Base
20
+ VALID_FORMATS = %i[keep camel pascal kebab underscore].freeze
21
+
22
+ class << self
23
+ # @!attribute [r] base_path
24
+ # @api public
25
+ # The base path for this API.
26
+ #
27
+ # @return [String]
28
+ attr_reader :base_path,
29
+ :enum_registry,
30
+ :export_configs,
31
+ :representation_registry,
32
+ :root_resource,
33
+ :type_registry
34
+
35
+ # @api public
36
+ # Configures key transformation for this API.
37
+ #
38
+ # Transforms JSON keys in request bodies, response bodies, and query parameters. Incoming requests are
39
+ # normalized to underscore internally, so controllers always receive `params[:user_name]` regardless of
40
+ # format.
41
+ #
42
+ # With `:camel`, `user_name` becomes `userName`. With `:pascal`, `user_name` becomes `UserName`.
43
+ # With `:kebab`, `user_name` becomes `user-name`.
44
+ #
45
+ # @param format [Symbol, nil] (nil) [:camel, :kebab, :keep, :pascal, :underscore]
46
+ # The key format. Default is `:keep`.
47
+ # @return [Symbol, nil]
48
+ # @raise [ConfigurationError] if format is invalid
49
+ #
50
+ # @example camelCase keys
51
+ # key_format :camel
52
+ #
53
+ # # Client sends: { "userName": "alice" }
54
+ # # Controller receives: params[:user_name]
55
+ # # Response: { "userName": "alice", "createdAt": "2024-01-01" }
56
+ def key_format(format = nil)
57
+ return @key_format if format.nil?
58
+
59
+ raise ConfigurationError, "key_format must be one of #{VALID_FORMATS}" unless VALID_FORMATS.include?(format)
60
+
61
+ @key_format = format
62
+ end
63
+
64
+ # @api public
65
+ # Configures URL path transformation for this API.
66
+ #
67
+ # Transforms URL path segments: base path, resource paths, action paths, and explicit `path:` options.
68
+ # Path parameters like `:id` and `:user_id` are not transformed. Controllers and params remain underscore
69
+ # internally.
70
+ #
71
+ # With `:kebab`, `/api/user_profiles/:id` becomes `/api/user-profiles/:id`.
72
+ # With `:pascal`, `/api/user_profiles/:id` becomes `/api/UserProfiles/:id`.
73
+ #
74
+ # @param format [Symbol, nil] (nil) [:camel, :kebab, :keep, :pascal, :underscore]
75
+ # The path format. Default is `:keep`.
76
+ # @return [Symbol, nil]
77
+ # @raise [ConfigurationError] if format is invalid
78
+ #
79
+ # @example kebab-case paths
80
+ # path_format :kebab
81
+ #
82
+ # resources :user_profiles
83
+ # # URL: /user-profiles/:id
84
+ # # Controller: UserProfilesController
85
+ # # Params: params[:user_profile]
86
+ def path_format(format = nil)
87
+ return @path_format if format.nil?
88
+
89
+ raise ConfigurationError, "path_format must be one of #{VALID_FORMATS}" unless VALID_FORMATS.include?(format)
90
+
91
+ @path_format = format
92
+ end
93
+
94
+ # @api public
95
+ # Enables an export for this API.
96
+ #
97
+ # @param name [Symbol]
98
+ # The registered export name. Built-in: :openapi, :typescript, :zod.
99
+ # @yield Block evaluated in export context.
100
+ # @yieldparam export [Configuration]
101
+ # @return [void]
102
+ #
103
+ # @example
104
+ # export :openapi
105
+ # export :typescript do
106
+ # endpoint do
107
+ # mode :always
108
+ # end
109
+ # end
110
+ def export(name, &block)
111
+ unless Export.exists?(name)
112
+ available = Export.keys.join(', ')
113
+ raise ConfigurationError,
114
+ "Unknown export: :#{name}. " \
115
+ "Available: #{available}"
116
+ end
117
+
118
+ unless @export_configs[name]
119
+ export_class = Export.find!(name)
120
+
121
+ options = Configurable.define(extends: export_class) do
122
+ option :endpoint, type: :hash do
123
+ option :mode, default: :auto, enum: %i[auto always never], type: :symbol
124
+ option :path, type: :string
125
+ end
126
+ end
127
+
128
+ @export_configs[name] = Configuration.new(options)
129
+ end
130
+
131
+ return unless block
132
+
133
+ block.arity.positive? ? yield(@export_configs[name]) : @export_configs[name].instance_eval(&block)
134
+ end
135
+
136
+ # @api public
137
+ # Sets or configures the adapter for this API.
138
+ #
139
+ # Without arguments, returns the adapter instance. With a block, configures the current adapter.
140
+ # Without a name, the built-in `:standard` adapter is used.
141
+ #
142
+ # Custom adapters must be registered via {Adapter.register} and referenced by their `adapter_name`.
143
+ #
144
+ # @param name [Symbol, nil] (nil)
145
+ # A registered adapter name matching `adapter_name` in the adapter class.
146
+ # @yield Block evaluated in adapter configuration context.
147
+ # @yieldparam config [Configuration]
148
+ # @return [Adapter::Base, void] the adapter instance when called without block
149
+ #
150
+ # @example Configure the default :standard adapter
151
+ # adapter do
152
+ # pagination do
153
+ # default_size 25
154
+ # max_size 100
155
+ # end
156
+ # end
157
+ #
158
+ # @example Use a registered custom adapter
159
+ # adapter :jsonapi
160
+ #
161
+ # @example Use and configure a custom adapter
162
+ # adapter :jsonapi do
163
+ # pagination do
164
+ # strategy :cursor
165
+ # end
166
+ # end
167
+ #
168
+ # @see Adapter::Standard The built-in adapter
169
+ # @see Adapter.register How to register custom adapters
170
+ def adapter(name = nil, &block)
171
+ @adapter_name = name if name.is_a?(Symbol)
172
+
173
+ if block
174
+ block.arity.positive? ? yield(adapter_config) : adapter_config.instance_eval(&block)
175
+ return
176
+ end
177
+
178
+ @adapter ||= adapter_class.new
179
+ end
180
+
181
+ # @api public
182
+ # Defines a reusable object type.
183
+ #
184
+ # @param name [Symbol]
185
+ # The object name.
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 example [Object, nil] (nil)
191
+ # The example. Metadata included in exports.
192
+ # @yieldparam object [API::Object]
193
+ # @return [void]
194
+ #
195
+ # @example
196
+ # object :item do
197
+ # string :description
198
+ # decimal :amount
199
+ # end
200
+ #
201
+ def object(name, deprecated: false, description: nil, example: nil, &block)
202
+ register_object(name, deprecated:, description:, example:, &block)
203
+ end
204
+
205
+ # @api public
206
+ # Defines a fragment type for composition.
207
+ #
208
+ # Fragments are only available for merging into other types and never appear as standalone types. Use
209
+ # fragments to define reusable field groups.
210
+ #
211
+ # @param name [Symbol]
212
+ # The fragment name.
213
+ # @yieldparam object [API::Object]
214
+ # @return [void]
215
+ #
216
+ # @example Reusable timestamps
217
+ # fragment :timestamps do
218
+ # datetime :created_at
219
+ # datetime :updated_at
220
+ # end
221
+ #
222
+ # object :invoice do
223
+ # merge :timestamps
224
+ # string :number
225
+ # end
226
+ def fragment(name, &block)
227
+ register_fragment(name, &block)
228
+ end
229
+
230
+ # @api public
231
+ # Defines a reusable enumeration type.
232
+ #
233
+ # @param name [Symbol]
234
+ # The enum name.
235
+ # @param values [Array<String>, nil] (nil)
236
+ # The allowed values.
237
+ # @param description [String, nil] (nil)
238
+ # The description. Metadata included in exports.
239
+ # @param example [String, nil] (nil)
240
+ # The example. Metadata included in exports.
241
+ # @param deprecated [Boolean] (false)
242
+ # Whether deprecated. Metadata included in exports.
243
+ # @return [void]
244
+ #
245
+ # @example
246
+ # enum :status, values: %w[draft sent paid]
247
+ def enum(name, deprecated: false, description: nil, example: nil, values: nil)
248
+ register_enum(name, deprecated:, description:, example:, values:)
249
+ end
250
+
251
+ # @api public
252
+ # Defines a discriminated union type.
253
+ #
254
+ # @param name [Symbol]
255
+ # The union name.
256
+ # @param deprecated [Boolean] (false)
257
+ # Whether deprecated. Metadata included in exports.
258
+ # @param description [String, nil] (nil)
259
+ # The description. Metadata included in exports.
260
+ # @param discriminator [Symbol, nil] (nil)
261
+ # The discriminator field name.
262
+ # @param example [Object, nil] (nil)
263
+ # The example. Metadata included in exports.
264
+ # @yieldparam union [API::Union]
265
+ # @return [void]
266
+ #
267
+ # @example
268
+ # union :payment_method, discriminator: :type do
269
+ # variant tag: 'card' do
270
+ # object do
271
+ # string :last_four
272
+ # end
273
+ # end
274
+ # end
275
+ def union(name, deprecated: false, description: nil, discriminator: nil, example: nil, &block)
276
+ register_union(name, deprecated:, description:, discriminator:, example:, &block)
277
+ end
278
+
279
+ # @api public
280
+ # API-wide error codes.
281
+ #
282
+ # Included in generated specs (OpenAPI, etc.) as possible error responses.
283
+ #
284
+ # @param error_code_keys [Array<Symbol>]
285
+ # The registered error code keys.
286
+ # @return [Array<Symbol>]
287
+ # @raise [ConfigurationError] if error code is not registered
288
+ #
289
+ # @example
290
+ # raises :unauthorized, :forbidden, :not_found
291
+ # api_class.raises # => [:unauthorized, :forbidden, :not_found]
292
+ def raises(*error_code_keys)
293
+ return @raises if error_code_keys.empty?
294
+
295
+ error_code_keys = error_code_keys.flatten.uniq
296
+ error_code_keys.each do |error_code_key|
297
+ unless error_code_key.is_a?(Symbol)
298
+ hint = error_code_key.is_a?(Integer) ? " Use :#{ErrorCode.key_for_status(error_code_key)} instead." : ''
299
+ raise ConfigurationError, "raises must be symbols, got #{error_code_key.class}: #{error_code_key}.#{hint}"
300
+ end
301
+
302
+ next if ErrorCode.exists?(error_code_key)
303
+
304
+ raise ConfigurationError,
305
+ "Unknown error code :#{error_code_key}. Register it with: " \
306
+ "Apiwork::ErrorCode.register :#{error_code_key}, status: <status>"
307
+ end
308
+ @raises = error_code_keys
309
+ end
310
+
311
+ # @api public
312
+ # The info for this API.
313
+ #
314
+ # @yield Block for defining API info.
315
+ # @yieldparam info [Info]
316
+ # @return [Info, nil]
317
+ #
318
+ # @example instance_eval style
319
+ # info do
320
+ # title 'My API'
321
+ # version '1.0.0'
322
+ # end
323
+ #
324
+ # @example yield style
325
+ # info do |info|
326
+ # info.title 'My API'
327
+ # info.version '1.0.0'
328
+ # end
329
+ def info(&block)
330
+ return @info unless block
331
+
332
+ @info = Info.new
333
+ block.arity.positive? ? yield(@info) : @info.instance_eval(&block)
334
+ @info
335
+ end
336
+
337
+ # @api public
338
+ # Defines a RESTful resource with standard CRUD actions.
339
+ #
340
+ # This is the main method for declaring API endpoints. Creates
341
+ # routes for index, show, create, update, destroy actions.
342
+ # Nested resources and custom actions can be defined in the block.
343
+ #
344
+ # @param name [Symbol]
345
+ # The resource name (plural).
346
+ # @param concerns [Array<Symbol>, nil] (nil)
347
+ # The concerns to include.
348
+ # @param constraints [Hash, Proc, nil] (nil)
349
+ # The route constraints (regex, lambdas).
350
+ # @param contract [String, nil] (nil)
351
+ # The custom contract path.
352
+ # @param controller [String, nil] (nil)
353
+ # The custom controller path.
354
+ # @param defaults [Hash, nil] (nil)
355
+ # The default parameters for routes.
356
+ # @param except [Array<Symbol>, nil] (nil)
357
+ # The CRUD actions to exclude.
358
+ # @param only [Array<Symbol>, nil] (nil)
359
+ # The CRUD actions to include.
360
+ # @param param [Symbol, nil] (nil)
361
+ # The custom parameter name for ID.
362
+ # @param path [String, nil] (nil)
363
+ # The custom URL path segment.
364
+ # @yield Block for nested resources and custom actions.
365
+ # @yieldparam resource [Resource]
366
+ # @return [void]
367
+ #
368
+ # @example instance_eval style
369
+ # resources :invoices do
370
+ # member { post :archive }
371
+ # resources :items
372
+ # end
373
+ #
374
+ # @example yield style
375
+ # resources :invoices do |resource|
376
+ # resource.member { |member| member.post :archive }
377
+ # resource.resources :items
378
+ # end
379
+ def resources(
380
+ name,
381
+ concerns: nil,
382
+ constraints: nil,
383
+ contract: nil,
384
+ controller: nil,
385
+ defaults: nil,
386
+ except: nil,
387
+ only: nil,
388
+ param: nil,
389
+ path: nil,
390
+ &block
391
+ )
392
+ @root_resource.resources(
393
+ name,
394
+ concerns:,
395
+ constraints:,
396
+ contract:,
397
+ controller:,
398
+ defaults:,
399
+ except:,
400
+ only:,
401
+ param:,
402
+ path:,
403
+ &block
404
+ )
405
+ end
406
+
407
+ # @api public
408
+ # Defines a singular resource (no index action, no :id in URL).
409
+ #
410
+ # Useful for resources where only one instance exists,
411
+ # like user profile or application settings.
412
+ #
413
+ # @param name [Symbol]
414
+ # The resource name (singular).
415
+ # @param concerns [Array<Symbol>, nil] (nil)
416
+ # The concerns to include.
417
+ # @param constraints [Hash, Proc, nil] (nil)
418
+ # The route constraints (regex, lambdas).
419
+ # @param contract [String, nil] (nil)
420
+ # The custom contract path.
421
+ # @param controller [String, nil] (nil)
422
+ # The custom controller path.
423
+ # @param defaults [Hash, nil] (nil)
424
+ # The default parameters for routes.
425
+ # @param except [Array<Symbol>, nil] (nil)
426
+ # The CRUD actions to exclude.
427
+ # @param only [Array<Symbol>, nil] (nil)
428
+ # The CRUD actions to include.
429
+ # @param param [Symbol, nil] (nil)
430
+ # The custom parameter name for ID.
431
+ # @param path [String, nil] (nil)
432
+ # The custom URL path segment.
433
+ # @yield Block for nested resources and custom actions.
434
+ # @yieldparam resource [Resource]
435
+ # @return [void]
436
+ #
437
+ # @example instance_eval style
438
+ # resource :profile do
439
+ # resources :settings
440
+ # end
441
+ #
442
+ # @example yield style
443
+ # resource :profile do |resource|
444
+ # resource.resources :settings
445
+ # end
446
+ def resource(
447
+ name,
448
+ concerns: nil,
449
+ constraints: nil,
450
+ contract: nil,
451
+ controller: nil,
452
+ defaults: nil,
453
+ except: nil,
454
+ only: nil,
455
+ param: nil,
456
+ path: nil,
457
+ &block
458
+ )
459
+ @root_resource.resource(
460
+ name,
461
+ concerns:,
462
+ constraints:,
463
+ contract:,
464
+ controller:,
465
+ defaults:,
466
+ except:,
467
+ only:,
468
+ param:,
469
+ path:,
470
+ &block
471
+ )
472
+ end
473
+
474
+ # @api public
475
+ # Defines a reusable concern for resources.
476
+ #
477
+ # Concerns are reusable blocks of resource configuration that can
478
+ # be included in multiple resources via the `concerns` option.
479
+ #
480
+ # @param name [Symbol]
481
+ # The concern name.
482
+ # @yield Block defining shared actions and configuration.
483
+ # @yieldparam resource [Resource]
484
+ # @return [void]
485
+ #
486
+ # @example instance_eval style
487
+ # concern :archivable do
488
+ # member do
489
+ # post :archive
490
+ # post :unarchive
491
+ # end
492
+ # end
493
+ #
494
+ # resources :posts, concerns: [:archivable]
495
+ #
496
+ # @example yield style
497
+ # concern :archivable do |resource|
498
+ # resource.member do |member|
499
+ # member.post :archive
500
+ # member.post :unarchive
501
+ # end
502
+ # end
503
+ #
504
+ # resources :posts, concerns: [:archivable]
505
+ def concern(name, &block)
506
+ @root_resource.concern(name, &block)
507
+ end
508
+
509
+ # @api public
510
+ # Applies options to all nested resource definitions.
511
+ #
512
+ # Useful for applying common configuration to a group of resources.
513
+ # Accepts the same options as {#resources}: only, except, defaults,
514
+ # constraints, controller, param, path.
515
+ #
516
+ # @param options [Hash] ({})
517
+ # The options to apply to nested resources.
518
+ # @yield Block containing resource definitions.
519
+ # @yieldparam resource [Resource]
520
+ # @return [void]
521
+ #
522
+ # @example instance_eval style
523
+ # with_options only: [:index, :show] do
524
+ # resources :reports
525
+ # resources :analytics
526
+ # end
527
+ #
528
+ # @example yield style
529
+ # with_options only: [:index, :show] do |resource|
530
+ # resource.resources :reports
531
+ # resource.resources :analytics
532
+ # end
533
+ def with_options(options = {}, &block)
534
+ @root_resource.with_options(options, &block)
535
+ end
536
+
537
+ def register_object(name, deprecated: false, description: nil, example: nil, scope: nil, &block)
538
+ type_registry.register(
539
+ name,
540
+ deprecated:,
541
+ description:,
542
+ example:,
543
+ scope:,
544
+ kind: :object,
545
+ &block
546
+ )
547
+ end
548
+
549
+ def register_fragment(name, scope: nil, &block)
550
+ type_registry.register(
551
+ name,
552
+ scope:,
553
+ fragment: true,
554
+ kind: :object,
555
+ &block
556
+ )
557
+ end
558
+
559
+ def register_enum(name, deprecated: false, description: nil, example: nil, scope: nil, values: nil)
560
+ raise ConfigurationError, 'Values must be an array' if values && !values.is_a?(Array)
561
+
562
+ enum_registry.register(
563
+ name,
564
+ values,
565
+ deprecated:,
566
+ description:,
567
+ example:,
568
+ scope:,
569
+ )
570
+ end
571
+
572
+ def register_union(name, deprecated: false, description: nil, discriminator: nil, example: nil, scope: nil, &block)
573
+ raise ConfigurationError, 'Union requires a block' unless block_given?
574
+
575
+ type_registry.register(
576
+ name,
577
+ deprecated:,
578
+ description:,
579
+ discriminator:,
580
+ example:,
581
+ scope:,
582
+ kind: :union,
583
+ &block
584
+ )
585
+ end
586
+
587
+ def adapter_class
588
+ Adapter.find!(@adapter_name || :standard)
589
+ end
590
+
591
+ def adapter_config
592
+ @adapter_config ||= Configuration.new(adapter_class)
593
+ end
594
+
595
+ def locale_key
596
+ @locale_key ||= base_path.delete_prefix('/')
597
+ end
598
+
599
+ def namespaces
600
+ @namespaces ||= extract_namespaces(base_path)
601
+ end
602
+
603
+ def mount(base_path)
604
+ @base_path = base_path
605
+ @locale_key = nil
606
+ @namespaces = nil
607
+ @info = nil
608
+ @raises = []
609
+ @export_configs = {}
610
+ @adapter_config = nil
611
+ @root_resource = Resource.new(self)
612
+ @type_registry = TypeRegistry.new
613
+ @enum_registry = EnumRegistry.new
614
+ @representation_registry = RepresentationRegistry.new
615
+ @built_contracts = Set.new
616
+ @key_format = :keep
617
+ @path_format = :keep
618
+ @introspect_cache = {}
619
+ @introspect_contract_cache = {}
620
+
621
+ Registry.register(self)
622
+ end
623
+
624
+ def translate(*segments, default: nil)
625
+ key = :"apiwork.apis.#{locale_key}.#{segments.join('.')}"
626
+ I18n.translate(key, default:)
627
+ end
628
+
629
+ def transform_path(path)
630
+ path_string = path.to_s
631
+ return path_string if @path_format == :keep
632
+ return path_string if path_string == '/'
633
+
634
+ path_string.split('/').map do |segment|
635
+ next segment if segment.empty?
636
+
637
+ case @path_format
638
+ when :camel then segment.camelize(:lower)
639
+ when :pascal then segment.camelize
640
+ when :kebab then segment.dasherize
641
+ when :underscore then segment.underscore
642
+ else segment
643
+ end
644
+ end.join('/')
645
+ end
646
+
647
+ def normalize_request(request)
648
+ return request if %i[camel pascal kebab].exclude?(key_format)
649
+
650
+ request.transform do |hash|
651
+ hash.deep_transform_keys do |key|
652
+ key_string = key.to_s
653
+ next key if key_string.match?(/\A[A-Z]+\z/)
654
+
655
+ key_string.underscore.to_sym
656
+ end
657
+ end
658
+ end
659
+
660
+ def prepare_request(request)
661
+ request
662
+ end
663
+
664
+ def prepare_response(response)
665
+ result = adapter.apply_response_transformers(response)
666
+ case key_format
667
+ when :camel
668
+ result.transform { |hash| hash.deep_transform_keys { |key| key.to_s.camelize(:lower).to_sym } }
669
+ when :pascal
670
+ result.transform { |hash| hash.deep_transform_keys { |key| key.to_s.camelize.to_sym } }
671
+ when :kebab
672
+ result.transform { |hash| hash.deep_transform_keys { |key| key.to_s.dasherize.to_sym } }
673
+ else
674
+ result
675
+ end
676
+ end
677
+
678
+ def type?(name, scope: nil)
679
+ type_registry.exists?(name, scope:)
680
+ end
681
+
682
+ def type_definition(name, scope: nil)
683
+ type_registry.find(name, scope:)
684
+ end
685
+
686
+ def enum?(name, scope: nil)
687
+ enum_registry.exists?(name, scope:)
688
+ end
689
+
690
+ def enum_values(name, scope: nil)
691
+ enum_registry.values(name, scope:)
692
+ end
693
+
694
+ def scoped_type_name(scope, name)
695
+ type_registry.scoped_name(scope, name)
696
+ end
697
+
698
+ def scoped_enum_name(scope, name)
699
+ enum_registry.scoped_name(scope, name)
700
+ end
701
+
702
+ def introspect(locale: nil)
703
+ ensure_all_contracts_built!
704
+ @introspect_cache[locale] ||= Introspection.api(self, locale:)
705
+ end
706
+
707
+ def introspect_contract(contract_class, expand:, locale:)
708
+ ensure_all_contracts_built!
709
+ cache_key = [contract_class, locale, expand]
710
+ @introspect_contract_cache[cache_key] ||= Introspection.contract(contract_class, expand:, locale:)
711
+ end
712
+
713
+ def reset_contracts!
714
+ @built_contracts = Set.new
715
+ @introspect_cache = {}
716
+ @introspect_contract_cache = {}
717
+ end
718
+
719
+ def ensure_contract_built!(contract_class)
720
+ return if @built_contracts.include?(contract_class)
721
+
722
+ ensure_pre_pass_complete!
723
+
724
+ representation_class = contract_class.representation_class
725
+ return unless representation_class
726
+
727
+ @built_contracts.add(contract_class)
728
+
729
+ resource = @root_resource.find_resource { |resource| resource.resolve_contract_class == contract_class }
730
+ actions = resource ? resource.actions : {}
731
+
732
+ adapter.register_contract(contract_class, representation_class, actions)
733
+ end
734
+
735
+ def ensure_pre_pass_complete!
736
+ return if @pre_pass_complete
737
+
738
+ mark_nested_writable_representations!
739
+ adapter.register_api(self)
740
+ @pre_pass_complete = true
741
+ end
742
+
743
+ def ensure_all_contracts_built!
744
+ ensure_pre_pass_complete!
745
+
746
+ @root_resource.each_resource do |resource|
747
+ build_contracts_for_resource(resource)
748
+ end
749
+ end
750
+
751
+ private
752
+
753
+ def extract_namespaces(mount_path)
754
+ return [] if mount_path.nil? || mount_path == '/'
755
+
756
+ mount_path.split('/').reject(&:empty?).map { |segment| segment.tr('-', '_').to_sym }
757
+ end
758
+
759
+ def mark_nested_writable_representations!
760
+ visited = Set.new
761
+ @root_resource.each_resource do |resource|
762
+ representation_class = resource.resolve_contract_class&.representation_class
763
+ next unless representation_class
764
+
765
+ representation_registry.register(representation_class)
766
+ mark_writable_associations(representation_class, visited)
767
+ end
768
+ end
769
+
770
+ def mark_writable_associations(representation_class, visited)
771
+ return if visited.include?(representation_class)
772
+
773
+ visited.add(representation_class)
774
+
775
+ representation_class.associations.each_value do |association|
776
+ next unless association.writable?
777
+
778
+ target_representation = association.representation_class
779
+ next unless target_representation
780
+
781
+ representation_registry.register(target_representation)
782
+ representation_registry.mark(target_representation, :nested_writable)
783
+ mark_writable_associations(target_representation, visited)
784
+ end
785
+ end
786
+
787
+ def build_contracts_for_resource(resource)
788
+ contract_class = resource.resolve_contract_class
789
+ return unless contract_class
790
+ return if @built_contracts.include?(contract_class)
791
+
792
+ representation_class = contract_class.representation_class
793
+ return unless representation_class
794
+
795
+ @built_contracts.add(contract_class)
796
+
797
+ adapter.register_contract(contract_class, representation_class, resource.actions)
798
+ end
799
+ end
800
+ end
801
+ end
802
+ end