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,600 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apiwork
4
+ module Export
5
+ class OpenAPI < Base
6
+ KEY_ORDER = %i[openapi info servers paths components].freeze
7
+
8
+ export_name :openapi
9
+ output :hash
10
+
11
+ option :version, default: '3.1.0', enum: %w[3.1.0], type: :string
12
+
13
+ def generate
14
+ {
15
+ components: { schemas: build_schemas },
16
+ info: build_info,
17
+ openapi: options[:version],
18
+ paths: build_paths,
19
+ servers: build_servers,
20
+ }.slice(*KEY_ORDER).compact
21
+ end
22
+
23
+ private
24
+
25
+ def build_info
26
+ return { title: api_base_path, version: '1.0.0' } unless api.info
27
+
28
+ info = api.info
29
+ {
30
+ contact: build_contact(info.contact),
31
+ description: info.description,
32
+ license: build_license(info.license),
33
+ summary: info.summary,
34
+ termsOfService: info.terms_of_service,
35
+ title: info.title || api_base_path,
36
+ version: info.version || '1.0.0',
37
+ }.compact
38
+ end
39
+
40
+ def build_servers
41
+ return nil unless api.info&.servers&.any?
42
+
43
+ api.info.servers.map do |server|
44
+ {
45
+ description: server.description,
46
+ url: server.url,
47
+ }.compact
48
+ end
49
+ end
50
+
51
+ def build_contact(contact)
52
+ return nil unless contact
53
+
54
+ {
55
+ email: contact.email,
56
+ name: contact.name,
57
+ url: contact.url,
58
+ }.compact.presence
59
+ end
60
+
61
+ def build_license(license)
62
+ return nil unless license
63
+
64
+ {
65
+ name: license.name,
66
+ url: license.url,
67
+ }.compact.presence
68
+ end
69
+
70
+ def build_paths
71
+ paths = {}
72
+
73
+ traverse_resources do |resource|
74
+ build_resource_paths(paths, resource)
75
+ end
76
+
77
+ paths
78
+ end
79
+
80
+ def build_resource_paths(paths, resource)
81
+ resource.actions.each do |action_name, action|
82
+ openapi_formatted_path = openapi_path(action.path)
83
+
84
+ paths[openapi_formatted_path] ||= {}
85
+ paths[openapi_formatted_path][action.method.to_s.downcase] = build_operation(
86
+ resource,
87
+ action_name,
88
+ action,
89
+ )
90
+ end
91
+ end
92
+
93
+ def build_operation(resource, action_name, action)
94
+ operation = {
95
+ deprecated: action.deprecated? || nil,
96
+ description: action.description,
97
+ operationId: action.operation_id || operation_id(resource, action_name),
98
+ summary: action.summary,
99
+ tags: build_tags(resource.to_h[:tags], action.tags),
100
+ }
101
+
102
+ path_params = extract_path_parameters(action.path)
103
+
104
+ request = action.request
105
+ if request
106
+ query_params = request.query? ? build_query_parameters(request.query) : []
107
+ all_params = path_params + query_params
108
+ operation[:parameters] = all_params if all_params.any?
109
+ operation[:requestBody] = build_request_body(request.body) if request.body?
110
+ elsif path_params.any?
111
+ operation[:parameters] = path_params
112
+ end
113
+
114
+ operation[:responses] = build_responses(action_name, action.response, raises: action.raises)
115
+
116
+ operation.compact
117
+ end
118
+
119
+ def build_tags(resource_tags, action_tags)
120
+ tags = []
121
+ tags.concat(Array(resource_tags)) if resource_tags&.any?
122
+ tags.concat(Array(action_tags)) if action_tags.any?
123
+ tags.any? ? tags : nil
124
+ end
125
+
126
+ def operation_id(resource, action_name)
127
+ joined = (resource.parent_identifiers + [resource.identifier, action_name.to_s]).join('_')
128
+
129
+ if key_format == :keep
130
+ joined
131
+ else
132
+ transform_key(joined)
133
+ end
134
+ end
135
+
136
+ def openapi_path(path)
137
+ path.to_s.gsub(/:(\w+)/) { "{#{transform_key(Regexp.last_match(1))}}" }
138
+ end
139
+
140
+ def extract_path_parameters(path)
141
+ return [] unless path
142
+
143
+ path.to_s.scan(/:(\w+)/).flatten.map do |param_name|
144
+ {
145
+ in: 'path',
146
+ name: transform_key(param_name),
147
+ required: true,
148
+ schema: { type: 'string' },
149
+ }
150
+ end
151
+ end
152
+
153
+ def build_query_parameters(query_params)
154
+ return [] unless query_params.any?
155
+
156
+ query_params.map do |name, param|
157
+ {
158
+ in: 'query',
159
+ name: transform_key(name),
160
+ required: !param.optional?,
161
+ schema: build_parameter_schema(param),
162
+ }.tap do |result|
163
+ result[:description] = param.description if param.description
164
+ end
165
+ end
166
+ end
167
+
168
+ def build_parameter_schema(param)
169
+ return { '$ref': "#/components/schemas/#{transform_key(param.reference)}" } if param.reference? && type_exists?(param.reference)
170
+
171
+ map_param(param)
172
+ end
173
+
174
+ def build_request_body(body_params)
175
+ {
176
+ content: {
177
+ 'application/json': {
178
+ schema: build_body_schema(body_params),
179
+ },
180
+ },
181
+ required: true,
182
+ }
183
+ end
184
+
185
+ def build_body_schema(body_params)
186
+ properties = {}
187
+ required_fields = []
188
+
189
+ body_params.each do |name, param|
190
+ transformed_key = transform_key(name)
191
+ properties[transformed_key] = map_field(param)
192
+ required_fields << transformed_key unless param.optional?
193
+ end
194
+
195
+ result = { properties:, type: 'object' }
196
+ result[:required] = required_fields if required_fields.any?
197
+ result
198
+ end
199
+
200
+ def build_responses(action_name, response, raises: [])
201
+ responses = {}
202
+
203
+ if response.no_content?
204
+ responses[:'204'] = { description: 'No content' }
205
+ elsif response.body
206
+ body = response.body
207
+
208
+ if body.union? && body.discriminator.nil?
209
+ success_variant = body.variants[0]
210
+ error_variant = body.variants[1]
211
+
212
+ responses[:'200'] = {
213
+ content: {
214
+ 'application/json': {
215
+ schema: map_param(success_variant),
216
+ },
217
+ },
218
+ description: 'Successful response',
219
+ }
220
+
221
+ raises.each do |code|
222
+ error_code = api.error_codes[code]
223
+ responses[error_code.status.to_s.to_sym] = build_union_error_response(error_code.description, error_variant)
224
+ end
225
+ else
226
+ responses[:'200'] = {
227
+ content: {
228
+ 'application/json': {
229
+ schema: map_param(body),
230
+ },
231
+ },
232
+ description: 'Successful response',
233
+ }
234
+
235
+ raises.each do |code|
236
+ error_code = api.error_codes[code]
237
+ responses[error_code.status.to_s.to_sym] = build_error_response(error_code.description)
238
+ end
239
+ end
240
+ elsif response
241
+ responses[:'200'] = {
242
+ content: {
243
+ 'application/json': {
244
+ schema: {
245
+ properties: {
246
+ meta: { type: 'object' },
247
+ },
248
+ type: 'object',
249
+ },
250
+ },
251
+ },
252
+ description: 'Successful response',
253
+ }
254
+ else
255
+ responses[:'204'] = { description: 'No content' }
256
+ end
257
+
258
+ responses
259
+ end
260
+
261
+ def build_error_response(description)
262
+ {
263
+ description:,
264
+ content: {
265
+ 'application/json': {
266
+ schema: {
267
+ properties: {
268
+ issues: {
269
+ items: {
270
+ '$ref': "#/components/schemas/#{transform_key(:error)}",
271
+ },
272
+ type: 'array',
273
+ },
274
+ },
275
+ required: ['issues'],
276
+ type: 'object',
277
+ },
278
+ },
279
+ },
280
+ }
281
+ end
282
+
283
+ def build_union_error_response(description, error_variant)
284
+ {
285
+ description:,
286
+ content: {
287
+ 'application/json': {
288
+ schema: map_param(error_variant),
289
+ },
290
+ },
291
+ }
292
+ end
293
+
294
+ def surface
295
+ @surface ||= SurfaceResolver.resolve(api)
296
+ end
297
+
298
+ def build_schemas
299
+ schemas = {}
300
+
301
+ surface.types.each do |name, type|
302
+ component_name = transform_key(name)
303
+
304
+ schemas[component_name] = if type.union?
305
+ map_union(type)
306
+ elsif type.extends?
307
+ map_object_with_extends(type)
308
+ else
309
+ map_object(type)
310
+ end
311
+ end
312
+
313
+ schemas
314
+ end
315
+
316
+ def map_object_with_extends(type)
317
+ refs = type.extends.map { |base_type| { '$ref': "#/components/schemas/#{transform_key(base_type)}" } }
318
+ object_schema = map_object(type)
319
+
320
+ if object_schema[:properties].empty?
321
+ refs.size == 1 ? refs.first : { allOf: refs }
322
+ else
323
+ { allOf: refs + [object_schema] }
324
+ end
325
+ end
326
+
327
+ def map_field(param)
328
+ if param.reference? && type_exists?(param.reference)
329
+ return apply_nullable(
330
+ { '$ref': "#/components/schemas/#{transform_key(param.reference)}" },
331
+ param.nullable?,
332
+ )
333
+ end
334
+
335
+ if param.scalar? && param.enum?
336
+ if param.enum_reference? && enum_exists?(param.enum)
337
+ enum_obj = surface.enums[param.enum]
338
+ schema = { enum: enum_obj.values, type: 'string' }
339
+ else
340
+ schema = { enum: param.enum, type: 'string' }
341
+ end
342
+ return apply_nullable(schema, param.nullable?)
343
+ end
344
+
345
+ schema = map_param(param)
346
+
347
+ schema[:description] = param.description if param.description
348
+ schema[:example] = param.example if param.example
349
+ schema[:deprecated] = true if param.deprecated?
350
+
351
+ schema[:format] = param.format.to_s if param.formattable? && param.format
352
+
353
+ apply_nullable(schema, param.nullable?)
354
+ end
355
+
356
+ def map_param(param)
357
+ if param.object?
358
+ map_object(param)
359
+ elsif param.array?
360
+ map_array(param)
361
+ elsif param.union?
362
+ map_union(param)
363
+ elsif param.literal?
364
+ map_literal(param)
365
+ elsif param.reference? && type_exists?(param.reference)
366
+ { '$ref': "#/components/schemas/#{transform_key(param.reference)}" }
367
+ elsif param.reference? && enum_exists?(param.reference)
368
+ enum_obj = surface.enums[param.reference]
369
+ { enum: enum_obj.values, type: 'string' }
370
+ else
371
+ map_primitive(param)
372
+ end
373
+ end
374
+
375
+ def map_object(param)
376
+ result = {
377
+ properties: {},
378
+ type: 'object',
379
+ }
380
+
381
+ result[:description] = param.description if param.description
382
+ result[:example] = param.example if param.example
383
+
384
+ param.shape.each do |name, field|
385
+ result[:properties][transform_key(name)] = map_field(field)
386
+ end
387
+
388
+ if param.shape.any? && (required = param.shape.reject { |_name, field| field.optional? }.keys.map { |key| transform_key(key) }).any?
389
+ result[:required] = required
390
+ end
391
+
392
+ result
393
+ end
394
+
395
+ def map_array(param)
396
+ items_param = param.of
397
+
398
+ return { items: map_inline_object(param.shape), type: 'array' } if items_param.nil? && param.shape.any?
399
+
400
+ return { items: {}, type: 'array' } unless items_param
401
+
402
+ {
403
+ items: if items_param.reference? && type_exists?(items_param.reference)
404
+ { '$ref': "#/components/schemas/#{transform_key(items_param.reference)}" }
405
+ else
406
+ map_param(items_param)
407
+ end,
408
+ type: 'array',
409
+ }
410
+ end
411
+
412
+ def map_inline_object(shape)
413
+ result = { properties: {}, type: 'object' }
414
+
415
+ shape.each do |name, field|
416
+ result[:properties][transform_key(name)] = map_field(field)
417
+ end
418
+
419
+ if (required = shape.reject { |_name, field| field.optional? }.keys.map { |key| transform_key(key) }).any?
420
+ result[:required] = required
421
+ end
422
+
423
+ result
424
+ end
425
+
426
+ def map_union(param)
427
+ if param.discriminator
428
+ map_discriminated_union(param)
429
+ else
430
+ {
431
+ oneOf: param.variants.map { |variant| map_param(variant) },
432
+ }
433
+ end
434
+ end
435
+
436
+ def map_discriminated_union(param)
437
+ discriminator_field = param.discriminator
438
+ variants = param.variants
439
+
440
+ one_of_schemas = variants.map do |variant|
441
+ base_schema = map_param(variant)
442
+
443
+ if variant.tag && !reference_contains_discriminator?(variant, discriminator_field)
444
+ discriminator_key = transform_key(discriminator_field)
445
+ {
446
+ allOf: [
447
+ base_schema,
448
+ {
449
+ properties: {
450
+ discriminator_key => {
451
+ const: variant.tag,
452
+ type: 'string',
453
+ },
454
+ },
455
+ required: [discriminator_key],
456
+ type: 'object',
457
+ },
458
+ ],
459
+ }
460
+ else
461
+ base_schema
462
+ end
463
+ end
464
+
465
+ mapping = {}
466
+ variants.each do |variant|
467
+ tag = variant.tag
468
+ next unless tag
469
+
470
+ if variant.reference? && type_exists?(variant.reference)
471
+ mapping[transform_key(tag.to_s)] =
472
+ "#/components/schemas/#{transform_key(variant.reference)}"
473
+ end
474
+ end
475
+
476
+ result = { oneOf: one_of_schemas }
477
+
478
+ result[:discriminator] = if mapping.any?
479
+ {
480
+ mapping:,
481
+ propertyName: transform_key(discriminator_field),
482
+ }
483
+ else
484
+ {
485
+ propertyName: transform_key(discriminator_field),
486
+ }
487
+ end
488
+
489
+ result
490
+ end
491
+
492
+ def map_literal(param)
493
+ {
494
+ const: param.value,
495
+ type: openapi_type_for_value(param.value),
496
+ }
497
+ end
498
+
499
+ def map_primitive(param)
500
+ return {} if param.unknown?
501
+
502
+ type_value = openapi_type(param.type)
503
+
504
+ return {} if type_value.nil?
505
+
506
+ result = { type: type_value }
507
+
508
+ format_value = openapi_format(param.type)
509
+ result[:format] = format_value if format_value
510
+
511
+ if param.boundable?
512
+ result[:minimum] = param.min unless param.min.nil?
513
+ result[:maximum] = param.max unless param.max.nil?
514
+ end
515
+
516
+ result
517
+ end
518
+
519
+ def openapi_type(type)
520
+ return nil unless type
521
+
522
+ case type.to_sym
523
+ when :string then 'string'
524
+ when :integer then 'integer'
525
+ when :number, :decimal then 'number'
526
+ when :boolean then 'boolean'
527
+ when :date, :datetime, :time, :uuid, :binary then 'string'
528
+ when :unknown then nil
529
+ end
530
+ end
531
+
532
+ def openapi_format(type)
533
+ return nil unless type
534
+
535
+ case type.to_sym # rubocop:disable Style/HashLikeCase
536
+ when :number then 'double'
537
+ when :date then 'date'
538
+ when :datetime then 'date-time'
539
+ when :time then 'time'
540
+ when :uuid then 'uuid'
541
+ when :binary then 'byte'
542
+ end
543
+ end
544
+
545
+ def openapi_type_for_value(value)
546
+ case value
547
+ when String then 'string'
548
+ when Integer then 'integer'
549
+ when Float then 'number'
550
+ when TrueClass, FalseClass then 'boolean'
551
+ else 'string'
552
+ end
553
+ end
554
+
555
+ def apply_nullable(schema, nullable)
556
+ return schema unless nullable
557
+
558
+ if schema[:'$ref']
559
+ {
560
+ oneOf: [
561
+ schema,
562
+ { type: 'null' },
563
+ ],
564
+ }
565
+ else
566
+ schema[:type] = [schema[:type], 'null']
567
+ schema
568
+ end
569
+ end
570
+
571
+ def type_exists?(symbol)
572
+ return false unless symbol
573
+
574
+ surface.types.key?(symbol)
575
+ end
576
+
577
+ def enum_exists?(symbol)
578
+ return false unless symbol
579
+
580
+ surface.enums.key?(symbol)
581
+ end
582
+
583
+ def reference_contains_discriminator?(variant, discriminator)
584
+ return false unless variant.reference?
585
+
586
+ referenced_type = surface.types[variant.reference]
587
+ return false unless referenced_type
588
+
589
+ referenced_type.shape.key?(discriminator)
590
+ end
591
+
592
+ def traverse_resources(resources: api.resources, &block)
593
+ resources.each_value do |resource|
594
+ yield(resource)
595
+ traverse_resources(resources: resource.resources, &block) if resource.resources.any?
596
+ end
597
+ end
598
+ end
599
+ end
600
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+
6
+ module Apiwork
7
+ module Export
8
+ class Pipeline
9
+ class Writer
10
+ class << self
11
+ def write(api_base_path: nil, content:, export_name: nil, extension:, output:)
12
+ if file_path?(output)
13
+ write_file(content, output)
14
+ else
15
+ raise ArgumentError, 'api_base_path and export_name required when output is a directory' if api_base_path.blank? || export_name.blank?
16
+
17
+ file_path = build_file_path(output, api_base_path, export_name, extension)
18
+ write_file(content, file_path)
19
+ end
20
+ end
21
+
22
+ def clean(output:)
23
+ if File.exist?(output)
24
+ if File.directory?(output)
25
+ FileUtils.rm_rf(output)
26
+ Rails.logger.debug "Cleaned directory: #{output}"
27
+ else
28
+ FileUtils.rm_f(output)
29
+ Rails.logger.debug "Cleaned file: #{output}"
30
+ end
31
+ else
32
+ Rails.logger.debug "Path does not exist: #{output}"
33
+ end
34
+ end
35
+
36
+ def file_path?(path)
37
+ File.extname(path) != ''
38
+ end
39
+
40
+ private
41
+
42
+ def build_file_path(output, api_base_path, export_name, extension)
43
+ path_parts = api_base_path.split('/').reject(&:empty?)
44
+ File.join(output, *path_parts, "#{export_name}#{extension}")
45
+ end
46
+
47
+ def write_file(content, file_path)
48
+ FileUtils.mkdir_p(File.dirname(file_path))
49
+
50
+ temp_file = Tempfile.new(['artifact', File.extname(file_path)])
51
+ begin
52
+ temp_file.write(content)
53
+ temp_file.close
54
+ FileUtils.mv(temp_file.path, file_path)
55
+ ensure
56
+ temp_file.close
57
+ temp_file.unlink if File.exist?(temp_file.path)
58
+ end
59
+
60
+ file_path
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end