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