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,734 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apiwork
4
+ module Contract
5
+ class Object
6
+ class Validator
7
+ NOT_SET = Module.new.freeze
8
+ NUMERIC_TYPES = Set[:integer, :number, :decimal].freeze
9
+
10
+ class << self
11
+ def validate(shape, data, current_depth: 0, max_depth: 10, path: [])
12
+ new(shape).validate(data, current_depth:, max_depth:, path:)
13
+ end
14
+ end
15
+
16
+ ISSUE_DETAILS = {
17
+ array_too_large: 'Too many items',
18
+ array_too_small: 'Too few items',
19
+ depth_exceeded: 'Too deeply nested',
20
+ field_missing: 'Required',
21
+ field_unknown: 'Unknown field',
22
+ number_too_large: 'Too large',
23
+ number_too_small: 'Too small',
24
+ string_too_long: 'Too long',
25
+ string_too_short: 'Too short',
26
+ type_invalid: 'Invalid type',
27
+ value_invalid: 'Invalid value',
28
+ value_null: 'Cannot be null',
29
+ }.freeze
30
+
31
+ def initialize(shape)
32
+ @shape = shape
33
+ end
34
+
35
+ def validate(data, current_depth: 0, max_depth: 10, path: [])
36
+ issues = []
37
+ params = {}
38
+ data = data.deep_symbolize_keys if data.is_a?(Hash)
39
+
40
+ return max_depth_error(current_depth, max_depth, path) if current_depth > max_depth
41
+
42
+ @shape.params.each do |name, param_options|
43
+ param_issues, param_value = validate_param(
44
+ name,
45
+ data[name],
46
+ param_options,
47
+ data,
48
+ path,
49
+ current_depth:,
50
+ max_depth:,
51
+ )
52
+ issues.concat(param_issues)
53
+ params[name] = param_value unless param_value.equal?(NOT_SET)
54
+ end
55
+
56
+ issues.concat(check_unknown_params(data, path))
57
+
58
+ Result.new(issues:, params:)
59
+ end
60
+
61
+ private
62
+
63
+ def max_depth_error(current_depth, max_depth, path)
64
+ issues = [Issue.new(
65
+ :depth_exceeded,
66
+ translate_detail(:depth_exceeded),
67
+ path:,
68
+ meta: { max:, depth: current_depth },
69
+ )]
70
+ Result.new(issues:, params: {})
71
+ end
72
+
73
+ def validate_param(name, value, param_options, data, path, current_depth:, max_depth:)
74
+ field_path = path + [name]
75
+
76
+ required_error = validate_required(name, value, param_options, data, field_path)
77
+ return [[required_error], NOT_SET] if required_error
78
+
79
+ value = param_options[:default] if value.nil? && param_options[:default]
80
+
81
+ nullable_error = validate_nullable(name, value, param_options, data, field_path)
82
+ return [[nullable_error], NOT_SET] if nullable_error
83
+
84
+ return [[], NOT_SET] if value.nil?
85
+
86
+ enum_error = validate_enum_value(name, value, param_options[:enum], field_path)
87
+ return [[enum_error], NOT_SET] if enum_error
88
+
89
+ if param_options[:type] == :literal
90
+ expected = param_options[:value]
91
+ unless value == expected
92
+ error = Issue.new(
93
+ :value_invalid,
94
+ translate_detail(:value_invalid),
95
+ meta: {
96
+ expected:,
97
+ actual: value,
98
+ field: name,
99
+ },
100
+ path: field_path,
101
+ )
102
+ return [[error], NOT_SET]
103
+ end
104
+ return [[], value]
105
+ end
106
+
107
+ return validate_union_param(name, value, param_options, field_path, max_depth, current_depth) if param_options[:type] == :union
108
+
109
+ type_error = validate_type(name, value, param_options[:type], field_path)
110
+ return [[type_error], NOT_SET] if type_error
111
+
112
+ if param_options[:type] == :string
113
+ length_error = validate_string_length(name, value, param_options, field_path)
114
+ return [[length_error], NOT_SET] if length_error
115
+ end
116
+
117
+ if numeric_type?(param_options[:type])
118
+ range_error = validate_numeric_range(name, value, param_options, field_path)
119
+ return [[range_error], NOT_SET] if range_error
120
+ end
121
+
122
+ custom_type_result = validate_custom_type(value, param_options[:type], field_path, max_depth, current_depth)
123
+ return custom_type_result if custom_type_result
124
+
125
+ validate_shape_or_array(value, param_options, field_path, max_depth, current_depth)
126
+ end
127
+
128
+ def validate_required(name, value, param_options, data, field_path)
129
+ return nil if param_options[:optional]
130
+ return nil if param_options[:nullable] && data.key?(name) && value.nil?
131
+
132
+ missing = case param_options[:type]
133
+ when :boolean, :string
134
+ value.nil?
135
+ else
136
+ value.blank?
137
+ end
138
+
139
+ return nil unless missing
140
+
141
+ if param_options[:enum].present?
142
+ Issue.new(
143
+ :value_invalid,
144
+ translate_detail(:value_invalid),
145
+ meta: {
146
+ actual: value,
147
+ expected: resolve_enum(param_options[:enum]),
148
+ field: name,
149
+ },
150
+ path: field_path,
151
+ )
152
+ else
153
+ Issue.new(:field_missing, translate_detail(:field_missing), meta: { field: name, type: param_options[:type] }, path: field_path)
154
+ end
155
+ end
156
+
157
+ def validate_nullable(name, value, param_options, data, field_path)
158
+ return nil unless data.key?(name)
159
+ return nil unless value.nil?
160
+ return nil if param_options[:nullable] == true
161
+
162
+ Issue.new(
163
+ :value_null,
164
+ translate_detail(:value_null),
165
+ meta: { field: name, type: param_options[:type] },
166
+ path: field_path,
167
+ )
168
+ end
169
+
170
+ def validate_enum_value(name, value, enum, field_path)
171
+ enum_values = resolve_enum(enum)
172
+ return nil unless enum_values
173
+ return nil if enum_values.include?(value.to_s) || enum_values.include?(value)
174
+
175
+ Issue.new(
176
+ :value_invalid,
177
+ translate_detail(:value_invalid),
178
+ meta: {
179
+ actual: value,
180
+ expected: enum_values,
181
+ field: name,
182
+ },
183
+ path: field_path,
184
+ )
185
+ end
186
+
187
+ def resolve_enum(enum)
188
+ return nil if enum.nil?
189
+ return enum if enum.is_a?(Array)
190
+
191
+ @shape.contract_class.enum_values(enum)
192
+ end
193
+
194
+ def validate_union_param(name, value, param_options, field_path, max_depth, current_depth)
195
+ union_error, union_value = validate_union(
196
+ name,
197
+ value,
198
+ param_options[:union],
199
+ field_path,
200
+ current_depth:,
201
+ max_depth:,
202
+ )
203
+ union_error ? [[union_error], NOT_SET] : [[], union_value]
204
+ end
205
+
206
+ def validate_shape_or_array(value, param_options, field_path, max_depth, current_depth)
207
+ if param_options[:shape] && value.is_a?(Hash)
208
+ validate_shape_object(value, param_options[:shape], field_path, max_depth, current_depth)
209
+ elsif param_options[:type] == :array && value.is_a?(Array)
210
+ validate_array_param(value, param_options, field_path, max_depth, current_depth)
211
+ else
212
+ [[], value]
213
+ end
214
+ end
215
+
216
+ def validate_shape_object(value, nested_shape, field_path, max_depth, current_depth)
217
+ validator = Validator.new(normalize_shape(nested_shape))
218
+ shape_result = validator.validate(
219
+ value,
220
+ max_depth:,
221
+ current_depth: current_depth + 1,
222
+ path: field_path,
223
+ )
224
+ shape_result.invalid? ? [shape_result.issues, NOT_SET] : [[], shape_result.params]
225
+ end
226
+
227
+ def validate_array_param(value, param_options, field_path, max_depth, current_depth)
228
+ array_issues, array_values = validate_array(
229
+ value,
230
+ {
231
+ current_depth:,
232
+ field_path:,
233
+ max_depth:,
234
+ param_options:,
235
+ },
236
+ )
237
+ array_issues.empty? ? [[], array_values] : [array_issues, NOT_SET]
238
+ end
239
+
240
+ def check_unknown_params(data, path)
241
+ extra_keys = data.keys - @shape.params.keys
242
+ extra_keys.map do |key|
243
+ Issue.new(
244
+ :field_unknown,
245
+ translate_detail(:field_unknown),
246
+ meta: { allowed: @shape.params.keys, field: key },
247
+ path: path + [key],
248
+ )
249
+ end
250
+ end
251
+
252
+ def validate_array(array, options)
253
+ param_options = options[:param_options]
254
+ field_path = options[:field_path]
255
+ max_depth = options[:max_depth]
256
+ current_depth = options[:current_depth]
257
+
258
+ issues = []
259
+ values = []
260
+
261
+ max = param_options[:max]
262
+ min = param_options[:min]
263
+
264
+ if max && array.length > max
265
+ issues << Issue.new(
266
+ :array_too_large,
267
+ translate_detail(:array_too_large),
268
+ meta: { max:, actual: array.length },
269
+ path: field_path,
270
+ )
271
+ return [issues, []]
272
+ end
273
+
274
+ if min && array.length < min
275
+ issues << Issue.new(
276
+ :array_too_small,
277
+ translate_detail(:array_too_small),
278
+ meta: { min:, actual: array.length },
279
+ path: field_path,
280
+ )
281
+ return [issues, []]
282
+ end
283
+
284
+ of = param_options[:of]
285
+ of_shape = of&.shape
286
+
287
+ array.each_with_index do |item, index|
288
+ item_path = field_path + [index]
289
+
290
+ if of_shape
291
+ validator = Validator.new(normalize_shape(of_shape))
292
+ shape_result = validator.validate(
293
+ item,
294
+ max_depth:,
295
+ current_depth: current_depth + 1,
296
+ path: item_path,
297
+ )
298
+ if shape_result.invalid?
299
+ issues.concat(shape_result.issues)
300
+ else
301
+ values << shape_result.params
302
+ end
303
+ elsif of
304
+ result = validate_array_item_with_type(item, index, param_options, item_path, current_depth, max_depth)
305
+ result[:issues].any? ? issues.concat(result[:issues]) : values << result[:value]
306
+ else
307
+ values << item
308
+ end
309
+ end
310
+
311
+ [issues, values]
312
+ end
313
+
314
+ def validate_array_item_with_type(item, index, param_options, item_path, current_depth, max_depth)
315
+ of = param_options[:of]
316
+ type_name = of&.type
317
+
318
+ type_definition = @shape.contract_class.resolve_custom_type(type_name)
319
+
320
+ if type_definition
321
+ return validate_array_item_with_type_definition(
322
+ item, index, type_definition, item_path, type_name, current_depth, max_depth
323
+ )
324
+ end
325
+
326
+ type_error = validate_type(index, item, type_name, item_path)
327
+ type_error ? { issues: [type_error], value: nil } : { issues: [], value: item }
328
+ end
329
+
330
+ def validate_array_item_with_type_definition(item, index, type_definition, item_path, type_name, current_depth, max_depth)
331
+ unless item.is_a?(Hash)
332
+ return {
333
+ issues: [Issue.new(
334
+ :type_invalid,
335
+ translate_detail(:type_invalid),
336
+ meta: { index:, actual: item.class.name.underscore.to_sym, expected: type_name },
337
+ path: item_path,
338
+ )],
339
+ value: nil,
340
+ }
341
+ end
342
+
343
+ if type_definition.union?
344
+ error, validated_value = validate_union(
345
+ index, item, type_definition.shape, item_path, current_depth:, max_depth:
346
+ )
347
+ error ? { issues: [error], value: nil } : { issues: [], value: validated_value }
348
+ else
349
+ validate_array_item_with_object_type(item, type_definition, item_path, current_depth, max_depth)
350
+ end
351
+ end
352
+
353
+ def validate_array_item_with_object_type(item, type_definition, item_path, current_depth, max_depth)
354
+ result = validate_with_type_definition(type_definition, item, item_path, current_depth:, max_depth:)
355
+
356
+ result.invalid? ? { issues: result.issues, value: nil } : { issues: [], value: result.params }
357
+ end
358
+
359
+ def validate_custom_type(value, type_name, field_path, max_depth, current_depth)
360
+ return nil unless type_name.is_a?(Symbol)
361
+
362
+ type_definition = @shape.contract_class.resolve_custom_type(type_name)
363
+ return nil unless type_definition&.object?
364
+
365
+ result = validate_with_type_definition(type_definition, value, field_path, current_depth:, max_depth:)
366
+ result.invalid? ? [result.issues, NOT_SET] : [[], result.params]
367
+ end
368
+
369
+ def validate_type(name, value, expected_type, path)
370
+ valid = case expected_type
371
+ when :string then value.is_a?(String)
372
+ when :integer then value.is_a?(Integer)
373
+ when :boolean then [true, false].include?(value)
374
+ when :datetime then value.is_a?(Time) || value.is_a?(DateTime) || value.is_a?(ActiveSupport::TimeWithZone)
375
+ when :date then value.is_a?(Date)
376
+ when :uuid then value.is_a?(String) && value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
377
+ when :object then value.is_a?(Hash)
378
+ when :array then value.is_a?(Array)
379
+ when :decimal, :number then value.is_a?(Numeric)
380
+ else true
381
+ end
382
+
383
+ return nil if valid
384
+
385
+ Issue.new(
386
+ :type_invalid,
387
+ translate_detail(:type_invalid),
388
+ path:,
389
+ meta: {
390
+ actual: value.class.name.underscore.to_sym,
391
+ expected: expected_type,
392
+ field: name,
393
+ },
394
+ )
395
+ end
396
+
397
+ def validate_union(name, value, union, path, current_depth:, max_depth:)
398
+ discriminator = union.discriminator
399
+ variants = union.variants
400
+
401
+ if discriminator
402
+ return [build_type_invalid_error(name, value, :object, path), nil] unless value.is_a?(Hash)
403
+
404
+ if value.key?(discriminator)
405
+ return validate_discriminated_variant(
406
+ name, value, variants, discriminator, path, current_depth:, max_depth:
407
+ )
408
+ end
409
+
410
+ unless discriminator_optional_in_all_variants?(discriminator, variants)
411
+ error = Issue.new(
412
+ :field_missing,
413
+ translate_detail(:field_missing),
414
+ meta: { field: discriminator },
415
+ path: path + [discriminator],
416
+ )
417
+ return [error, nil]
418
+ end
419
+ end
420
+
421
+ most_specific_error = nil
422
+
423
+ variants.each do |variant|
424
+ error, validated_value = validate_variant(
425
+ name,
426
+ value,
427
+ variant,
428
+ path,
429
+ current_depth:,
430
+ discriminator:,
431
+ max_depth:,
432
+ )
433
+
434
+ return [nil, validated_value] if error.nil?
435
+
436
+ if error.code == :field_unknown
437
+ most_specific_error = error
438
+ elsif error.code == :value_invalid && (most_specific_error.nil? || most_specific_error.code != :field_unknown)
439
+ most_specific_error = error
440
+ end
441
+ end
442
+
443
+ return [most_specific_error, nil] if most_specific_error
444
+
445
+ expected_types = variants.map { |variant| variant[:type] }
446
+ error = Issue.new(
447
+ :type_invalid,
448
+ translate_detail(:type_invalid),
449
+ path:,
450
+ meta: {
451
+ actual: value.is_a?(Hash) ? :hash : value.class.name.underscore.to_sym,
452
+ expected: expected_types.join(' | '),
453
+ field: name,
454
+ },
455
+ )
456
+
457
+ [error, nil]
458
+ end
459
+
460
+ def validate_discriminated_variant(name, value, variants, discriminator, path, current_depth:, max_depth:)
461
+ discriminator_value = value[discriminator]
462
+
463
+ normalized_discriminator = normalize_discriminator_value(discriminator_value)
464
+ matching_variant = variants.find do |variant|
465
+ normalize_discriminator_value(variant[:tag]) == normalized_discriminator
466
+ end
467
+
468
+ unless matching_variant
469
+ valid_tags = variants.filter_map { |variant| variant[:tag] }
470
+ error = Issue.new(
471
+ :value_invalid,
472
+ translate_detail(:value_invalid),
473
+ meta: {
474
+ actual: discriminator_value,
475
+ expected: valid_tags,
476
+ field: discriminator,
477
+ },
478
+ path: path + [discriminator],
479
+ )
480
+ return [error, nil]
481
+ end
482
+
483
+ value_without_discriminator = value.except(discriminator)
484
+
485
+ error, validated_value = validate_variant(
486
+ name,
487
+ value_without_discriminator,
488
+ matching_variant,
489
+ path,
490
+ current_depth:,
491
+ discriminator:,
492
+ max_depth:,
493
+ )
494
+
495
+ validated_value = validated_value.merge(discriminator => discriminator_value) if validated_value.is_a?(Hash)
496
+
497
+ [error, validated_value]
498
+ end
499
+
500
+ def normalize_discriminator_value(value)
501
+ case value
502
+ when true then 'true'
503
+ when false then 'false'
504
+ else value
505
+ end
506
+ end
507
+
508
+ def validate_variant(name, value, variant, path, current_depth:, discriminator: nil, max_depth:)
509
+ variant_type = variant[:type]
510
+ variant_of = variant[:of]
511
+ variant_shape = variant[:shape]
512
+
513
+ type_definition = @shape.contract_class.resolve_custom_type(variant_type)
514
+ if type_definition
515
+ return [build_type_invalid_error(name, value, variant_type, path), nil] unless value.is_a?(Hash)
516
+
517
+ result = validate_with_type_definition(
518
+ type_definition, value, path, current_depth:, max_depth:, exclude_param: discriminator
519
+ )
520
+
521
+ return [result.issues.first, nil] if result.invalid?
522
+
523
+ return [nil, result.params]
524
+ end
525
+
526
+ if variant_type == :array
527
+ return [build_type_invalid_error(name, value, :array, path), nil] unless value.is_a?(Array)
528
+
529
+ if variant_of
530
+ array_issues, array_values = validate_array(
531
+ value,
532
+ {
533
+ current_depth:,
534
+ max_depth:,
535
+ field_path: path,
536
+ param_options: { of: variant_of },
537
+ },
538
+ )
539
+
540
+ return [array_issues.first, nil] if array_issues.any?
541
+
542
+ return [nil, array_values]
543
+ end
544
+
545
+ return [nil, value]
546
+ end
547
+
548
+ if variant_type == :object && variant_shape
549
+ return [build_type_invalid_error(name, value, :object, path), nil] unless value.is_a?(Hash)
550
+
551
+ validator = Validator.new(normalize_shape(variant_shape))
552
+ result = validator.validate(
553
+ value,
554
+ max_depth:,
555
+ path:,
556
+ current_depth: current_depth + 1,
557
+ )
558
+
559
+ return [result.issues.first, nil] if result.invalid?
560
+
561
+ return [nil, result.params]
562
+ end
563
+
564
+ type_error = validate_type(name, value, variant_type, path)
565
+ return [type_error, nil] if type_error
566
+
567
+ if variant[:enum]&.exclude?(value)
568
+ enum_error = Issue.new(
569
+ :value_invalid,
570
+ translate_detail(:value_invalid),
571
+ path:,
572
+ meta: {
573
+ actual: value,
574
+ expected: variant[:enum],
575
+ field: name,
576
+ },
577
+ )
578
+ return [enum_error, nil]
579
+ end
580
+
581
+ [nil, value]
582
+ end
583
+
584
+ def discriminator_optional_in_all_variants?(discriminator, variants)
585
+ contract_class = @shape.contract_class
586
+
587
+ variants.all? do |variant|
588
+ variant_type = variant[:type]
589
+ shape = variant[:shape]
590
+
591
+ if variant_type == :object && shape
592
+ discriminator_param = shape.params[discriminator]
593
+
594
+ else
595
+ type_definition = contract_class.resolve_custom_type(variant_type)
596
+ next false unless type_definition&.object?
597
+
598
+ discriminator_param = type_definition.shape.params[discriminator]
599
+
600
+ end
601
+ next false unless discriminator_param
602
+
603
+ discriminator_param[:optional] == true
604
+ end
605
+ end
606
+
607
+ def validate_with_type_definition(type_definition, value, path, current_depth:, exclude_param: nil, max_depth:)
608
+ type_shape = Object.new(@shape.contract_class, action_name: @shape.action_name)
609
+ type_shape.copy_type_definition_params(type_definition, type_shape)
610
+ type_shape.params.delete(exclude_param) if exclude_param
611
+
612
+ Validator.new(type_shape).validate(value, max_depth:, path:, current_depth: current_depth + 1)
613
+ end
614
+
615
+ def build_type_invalid_error(name, value, expected, path)
616
+ Issue.new(
617
+ :type_invalid,
618
+ translate_detail(:type_invalid),
619
+ path:,
620
+ meta: {
621
+ expected:,
622
+ actual: value.class.name.underscore.to_sym,
623
+ field: name,
624
+ },
625
+ )
626
+ end
627
+
628
+ def validate_numeric_range(name, value, param_options, field_path)
629
+ return nil unless value.is_a?(Numeric)
630
+
631
+ min_value = param_options[:min]
632
+ max_value = param_options[:max]
633
+
634
+ if min_value && value < min_value
635
+ return Issue.new(
636
+ :number_too_small,
637
+ translate_detail(:number_too_small),
638
+ meta: {
639
+ actual: value,
640
+ field: name,
641
+ min: min_value,
642
+ },
643
+ path: field_path,
644
+ )
645
+ end
646
+
647
+ if max_value && value > max_value
648
+ return Issue.new(
649
+ :number_too_large,
650
+ translate_detail(:number_too_large),
651
+ meta: {
652
+ actual: value,
653
+ field: name,
654
+ max: max_value,
655
+ },
656
+ path: field_path,
657
+ )
658
+ end
659
+
660
+ nil
661
+ end
662
+
663
+ def validate_string_length(name, value, param_options, field_path)
664
+ return nil unless value.is_a?(String)
665
+
666
+ return nil if value.empty?
667
+
668
+ min_length = param_options[:min]
669
+ max_length = param_options[:max]
670
+
671
+ if min_length && value.length < min_length
672
+ return Issue.new(
673
+ :string_too_short,
674
+ translate_detail(:string_too_short),
675
+ meta: {
676
+ actual: value.length,
677
+ field: name,
678
+ min: min_length,
679
+ },
680
+ path: field_path,
681
+ )
682
+ end
683
+
684
+ if max_length && value.length > max_length
685
+ return Issue.new(
686
+ :string_too_long,
687
+ translate_detail(:string_too_long),
688
+ meta: {
689
+ actual: value.length,
690
+ field: name,
691
+ max: max_length,
692
+ },
693
+ path: field_path,
694
+ )
695
+ end
696
+
697
+ nil
698
+ end
699
+
700
+ def numeric_type?(type)
701
+ return false unless type
702
+
703
+ NUMERIC_TYPES.include?(type.to_sym)
704
+ end
705
+
706
+ def normalize_shape(shape)
707
+ return shape if shape.is_a?(Contract::Object)
708
+
709
+ contract_shape = Object.new(@shape.contract_class, action_name: @shape.action_name)
710
+ shape.params.each do |name, param_options|
711
+ contract_shape.params[name] = param_options
712
+ end
713
+ contract_shape
714
+ end
715
+
716
+ def translate_detail(code)
717
+ locale_key = @shape.contract_class.api_class&.locale_key
718
+
719
+ if locale_key
720
+ api_key = :"apiwork.apis.#{locale_key}.issues.#{code}.detail"
721
+ result = I18n.translate(api_key, default: nil)
722
+ return result if result
723
+ end
724
+
725
+ global_key = :"apiwork.issues.#{code}.detail"
726
+ result = I18n.translate(global_key, default: nil)
727
+ return result if result
728
+
729
+ ISSUE_DETAILS[code]
730
+ end
731
+ end
732
+ end
733
+ end
734
+ end