schemable 0.1.4 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,146 @@
1
+ module Schemable
2
+ # The AttributeSchemaGenerator class is responsible for generating JSON schemas for model attributes.
3
+ # It includes methods for generating the overall schema and individual attribute schemas.
4
+ #
5
+ # @see Schemable
6
+ class AttributeSchemaGenerator
7
+ attr_reader :model_definition, :configuration, :model, :schema_modifier, :response
8
+
9
+ # Initializes a new AttributeSchemaGenerator instance.
10
+ #
11
+ # @param model_definition [ModelDefinition] The model definition to generate the schema for.
12
+ # @example
13
+ # generator = AttributeSchemaGenerator.new(model_definition)
14
+ def initialize(model_definition)
15
+ @model_definition = model_definition
16
+ @model = model_definition.model
17
+ @configuration = Schemable.configuration
18
+ @schema_modifier = SchemaModifier.new
19
+ @response = nil
20
+ end
21
+
22
+ # Generates the JSON schema for the model attributes.
23
+ #
24
+ # @return [Hash] The generated schema.
25
+ # @example
26
+ # schema = generator.generate
27
+ def generate
28
+ schema = {
29
+ type: :object,
30
+ properties: @model_definition.attributes.index_with do |attr|
31
+ generate_attribute_schema(attr)
32
+ end
33
+ }
34
+
35
+ # Rename enum attributes to remove the suffix or prefix if mongoid is used
36
+ if @configuration.orm == :mongoid
37
+ schema[:properties].transform_keys! do |key|
38
+ key.to_s.gsub(@configuration.enum_prefix_for_simple_enum || @configuration.enum_suffix_for_simple_enum, '')
39
+ end
40
+ end
41
+
42
+ # modify the schema to include additional response relations
43
+ schema = @schema_modifier.add_properties(schema, @model_definition.additional_response_attributes, 'properties')
44
+
45
+ # modify the schema to exclude response relations
46
+ @model_definition.excluded_response_attributes.each do |key|
47
+ schema = @schema_modifier.delete_properties(schema, "properties.#{key}")
48
+ end
49
+
50
+ schema
51
+ end
52
+
53
+ # Generates the JSON schema for a specific attribute.
54
+ #
55
+ # @param attribute [Symbol, String] The attribute to generate the schema for.
56
+ # @return [Hash] The generated schema for the attribute.
57
+ # @example
58
+ # attribute_schema = generator.generate_attribute_schema(:attribute_name)
59
+ def generate_attribute_schema(attribute)
60
+ if @configuration.orm == :mongoid
61
+ # Get the column hash for the attribute
62
+ attribute_hash = @model.fields[attribute.to_s]
63
+
64
+ # Check if this attribute has a custom JSON Schema definition
65
+ return @model_definition.array_types[attribute] if @model_definition.array_types.keys.include?(attribute.to_sym)
66
+ return @model_definition.additional_response_attributes[attribute] if @model_definition.additional_response_attributes.keys.include?(attribute)
67
+
68
+ # Check if this is an array attribute
69
+ return @configuration.type_mapper(:array) if attribute_hash.try(:[], 'options').try(:[], 'type') == 'Array'
70
+
71
+ # Check if this is an enum attribute
72
+ @response = if attribute_hash.name.end_with?('_cd')
73
+ @configuration.type_mapper(:string)
74
+ else
75
+ # Map the column type to a JSON Schema type if none of the above conditions are met
76
+ @configuration.type_mapper(attribute_hash.try(:type).to_s.downcase.to_sym)
77
+ end
78
+
79
+ elsif @configuration.orm == :active_record
80
+ # Get the column hash for the attribute
81
+ attribute_hash = @model.columns_hash[attribute.to_s]
82
+
83
+ # Check if this attribute has a custom JSON Schema definition
84
+ return @model_definition.array_types[attribute] if @model_definition.array_types.keys.include?(attribute.to_sym)
85
+ return @model_definition.additional_response_attributes[attribute] if @model_definition.additional_response_attributes.keys.include?(attribute)
86
+
87
+ # Check if this is an array attribute
88
+ return @configuration.type_mapper(:array) if attribute_hash.as_json.try(:[], 'sql_type_metadata').try(:[], 'sql_type').include?('[]')
89
+
90
+ # Map the column type to a JSON Schema type if none of the above conditions are met
91
+ @response = @configuration.type_mapper(attribute_hash.try(:type))
92
+
93
+ else
94
+ raise 'ORM not supported'
95
+ end
96
+
97
+ # If the attribute is nullable, modify the schema accordingly
98
+ return @schema_modifier.add_properties(@response, { nullable: true }, '.') if @response && @model_definition.nullable_attributes.include?(attribute)
99
+
100
+ # If attribute is an enum, modify the schema accordingly
101
+ if @configuration.custom_defined_enum_method && @model.respond_to?(@configuration.custom_defined_enum_method)
102
+ defined_enums = @model.send(@configuration.custom_defined_enum_method)
103
+ enum_attribute = attribute.to_s.gsub(@configuration.enum_prefix_for_simple_enum || @configuration.enum_suffix_for_simple_enum, '').to_s
104
+ if @response && defined_enums[enum_attribute].present?
105
+ return @schema_modifier.add_properties(
106
+ @response,
107
+ {
108
+ enum: defined_enums[enum_attribute].keys,
109
+ default: @model_definition.default_value_for_enum_attributes[attribute.to_sym] || defined_enums[enum_attribute].keys.first
110
+ },
111
+ '.'
112
+ )
113
+ end
114
+ elsif @model.respond_to?(:defined_enums) && @response && @model.defined_enums.key?(attribute.to_s)
115
+ return @schema_modifier.add_properties(
116
+ @response,
117
+ {
118
+ enum: @model.defined_enums[attribute.to_s].keys,
119
+ default: @model_definition.default_value_for_enum_attributes[attribute.to_sym] || @model.defined_enums[attribute.to_s].keys.first
120
+ },
121
+ '.'
122
+ )
123
+ end
124
+
125
+ return @response unless @response.nil?
126
+
127
+ # If we haven't found a schema type yet, try to infer it from the type of the attribute's value in the instance data
128
+ if @configuration.use_serialized_instance
129
+ serialized_instance = @model_definition.serialized_instance
130
+
131
+ type_from_instance = serialized_instance.as_json['data']['attributes'][attribute.to_s.camelize(:lower)]&.class&.name&.downcase
132
+
133
+ @response = @configuration.type_mapper(type_from_instance) if type_from_instance.present?
134
+
135
+ return @response unless @response.nil?
136
+ end
137
+
138
+ # If we still haven't found a schema type, default to object
139
+ @configuration.type_mapper(:object)
140
+ rescue NoMethodError
141
+ # Log a warning if the attribute does not exist on the @model
142
+ Rails.logger.warn("\e[33mWARNING: #{@model} does not have an attribute named \e[31m#{attribute}\e[0m")
143
+ {}
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,114 @@
1
+ module Schemable
2
+ # The Configuration class provides a set of configuration options for the Schemable module.
3
+ # It includes options for setting the ORM, handling enums, custom type mappers, and more.
4
+ # It is worth noting that the configuration options are global, and will affect all Definitions.
5
+ #
6
+ # @see Schemable
7
+ class Configuration
8
+ attr_accessor(
9
+ :orm,
10
+ :float_as_string,
11
+ :decimal_as_string,
12
+ :pagination_enabled,
13
+ :custom_type_mappers,
14
+ :use_serialized_instance,
15
+ :custom_defined_enum_method,
16
+ :enum_prefix_for_simple_enum,
17
+ :enum_suffix_for_simple_enum,
18
+ :custom_meta_response_schema,
19
+ :infer_attributes_from_custom_method,
20
+ :infer_attributes_from_jsonapi_serializable
21
+ )
22
+
23
+ # Initializes a new Configuration instance with default values.
24
+ def initialize
25
+ @orm = :active_record # orm options are :active_record, :mongoid
26
+ @float_as_string = false
27
+ @custom_type_mappers = {}
28
+ @pagination_enabled = true
29
+ @decimal_as_string = false
30
+ @use_serialized_instance = false
31
+ @custom_defined_enum_method = nil
32
+ @custom_meta_response_schema = nil
33
+ @enum_prefix_for_simple_enum = nil
34
+ @enum_suffix_for_simple_enum = nil
35
+ @infer_attributes_from_custom_method = nil
36
+ @infer_attributes_from_jsonapi_serializable = false
37
+ end
38
+
39
+ # Returns a type mapper for a given type name.
40
+ #
41
+ # @note If a custom type mapper is defined for the given type name, it will be returned.
42
+ #
43
+ # @example
44
+ # type_mapper(:string) #=> { type: :string }
45
+ #
46
+ # @param type_name [Symbol, String] The name of the type.
47
+ # @return [Hash] The type mapper for the given type name.
48
+ def type_mapper(type_name)
49
+ return @custom_type_mappers[type_name] if @custom_type_mappers.key?(type_name.to_sym)
50
+
51
+ {
52
+ text: { type: :string },
53
+ string: { type: :string },
54
+ symbol: { type: :string },
55
+ integer: { type: :integer },
56
+ boolean: { type: :boolean },
57
+ date: { type: :string, format: :date },
58
+ time: { type: :string, format: :time },
59
+ json: { type: :object, properties: {} },
60
+ hash: { type: :object, properties: {} },
61
+ jsonb: { type: :object, properties: {} },
62
+ object: { type: :object, properties: {} },
63
+ binary: { type: :string, format: :binary },
64
+ trueclass: { type: :boolean, default: true },
65
+ falseclass: { type: :boolean, default: false },
66
+ datetime: { type: :string, format: :'date-time' },
67
+ big_decimal: { type: (@decimal_as_string ? :string : :number).to_s.to_sym, format: :double },
68
+ 'bson/objectid': { type: :string, format: :object_id },
69
+ 'mongoid/boolean': { type: :boolean },
70
+ 'mongoid/stringified_symbol': { type: :string },
71
+ 'active_support/time_with_zone': { type: :string, format: :date_time },
72
+ float: {
73
+ type: (@float_as_string ? :string : :number).to_s.to_sym,
74
+ format: :float
75
+ },
76
+ decimal: {
77
+ type: (@decimal_as_string ? :string : :number).to_s.to_sym,
78
+ format: :double
79
+ },
80
+ array: {
81
+ type: :array,
82
+ items: {
83
+ anyOf: [
84
+ { type: :string },
85
+ { type: :integer },
86
+ { type: :boolean },
87
+ { type: :number, format: :float },
88
+ { type: :object, properties: {} },
89
+ { type: :number, format: :double }
90
+ ]
91
+ }
92
+ }
93
+ }[type_name.to_s.underscore.try(:to_sym)]
94
+ end
95
+
96
+ # Adds a custom type mapper for a given type name.
97
+ #
98
+ # @example
99
+ # add_custom_type_mapper(:custom_type, { type: :custom })
100
+ # type_mapper(:custom_type) #=> { type: :custom }
101
+ #
102
+ # # It preferable to invoke this method in the config/initializers/schemable.rb file.
103
+ # # This way, the custom type mapper will be available for all Definitions.
104
+ # Schemable.configure do |config|
105
+ # config.add_custom_type_mapper(:custom_type, { type: :custom })
106
+ # end
107
+ #
108
+ # @param type_name [Symbol, String] The name of the type.
109
+ # @param mapping [Hash] The mapping to add.
110
+ def add_custom_type_mapper(type_name, mapping)
111
+ custom_type_mappers[type_name.to_sym] = mapping
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,335 @@
1
+ module Schemable
2
+ # The Definition class provides a blueprint for generating and modifying schemas.
3
+ # It includes methods for handling attributes, relationships, and various request and response attributes.
4
+ # The definition class is meant to be inherited by a class that represents a model.
5
+ # This class should be configured to match the model's attributes and relationships.
6
+ # The default configuration is set in this class, but can be overridden in the model's definition class.
7
+ #
8
+ # @see Schemable
9
+ class Definition
10
+ attr_reader :configuration
11
+ attr_writer :relationships, :additional_create_request_attributes, :additional_update_request_attributes
12
+
13
+ def initialize
14
+ @configuration = Schemable.configuration
15
+ end
16
+
17
+ # Returns the serializer of the model for the definition.
18
+ # @example
19
+ # UsersSerializer
20
+ # @return [JSONAPI::Serializable::Resource, nil] The model's serializer.
21
+ def serializer
22
+ raise NotImplementedError, 'You must implement the serializer method in the definition class in order to use the infer_serializer_from_jsonapi_serializable configuration option.' if configuration.infer_attributes_from_jsonapi_serializable
23
+
24
+ nil
25
+ end
26
+
27
+ # Returns the attributes for the definition based on the configuration.
28
+ # The attributes are inferred from the model's attribute names by default.
29
+ # If the infer_attributes_from_custom_method configuration option is set, the attributes are inferred from the method specified.
30
+ # If the infer_attributes_from_jsonapi_serializable configuration option is set, the attributes are inferred from the serializer's attribute blocks.
31
+ #
32
+ # @example
33
+ # attributes = definition.attributes # => [:id, :name, :email]
34
+ #
35
+ # @return [Array<Symbol>] The attributes used for generating the schemas.
36
+ def attributes
37
+ return (serializer&.attribute_blocks&.transform_keys { |key| key.to_s.underscore.to_sym }&.keys || nil) if configuration.infer_attributes_from_jsonapi_serializable
38
+
39
+ return model.send(configuration.infer_attributes_from_custom_method).map(&:to_sym) if configuration.infer_attributes_from_custom_method
40
+
41
+ model.attribute_names.map(&:to_sym)
42
+ end
43
+
44
+ # Returns the relationships defined in the serializer.
45
+ #
46
+ # @note Note that the format of the relationships is as follows:
47
+ # {
48
+ # belongs_to: { relationship_name: relationship_definition },
49
+ # has_many: { relationship_name: relationship_definition },
50
+ # addition_to_included: { relationship_name: relationship_definition }
51
+ # }
52
+ #
53
+ # @note The addition_to_included is used to define the extra nested relationships that are not defined in the belongs_to or has_many for included.
54
+ #
55
+ # @example
56
+ # {
57
+ # belongs_to: {
58
+ # district: Swagger::Definitions::District,
59
+ # user: Swagger::Definitions::User
60
+ # },
61
+ # has_many: {
62
+ # applicants: Swagger::Definitions::Applicant,
63
+ # },
64
+ # addition_to_included: {
65
+ # applicants: Swagger::Definitions::Applicant
66
+ # }
67
+ # }
68
+ #
69
+ # @return [Hash] The relationships defined in the serializer.
70
+ def relationships
71
+ { belongs_to: {}, has_many: {} }
72
+ end
73
+
74
+ # Returns a hash of all the arrays defined for the model.
75
+ # The schema for each array is defined in the definition class manually.
76
+ # This method must be implemented in the definition class if there are any arrays.
77
+ #
78
+ # @return [Hash] The arrays of the model and their schemas.
79
+ #
80
+ # @example
81
+ # {
82
+ # metadata: {
83
+ # type: :array,
84
+ # items: {
85
+ # type: :object, nullable: true,
86
+ # properties: { name: { type: :string, nullable: true } }
87
+ # }
88
+ # }
89
+ # }
90
+ def array_types
91
+ {}
92
+ end
93
+
94
+ # Attributes that are not required in the create request.
95
+ #
96
+ # @example
97
+ # optional_create_request_attributes = definition.optional_create_request_attributes
98
+ # # => [:email]
99
+ #
100
+ # @return [Array<Symbol>] The attributes that are not required in the create request.
101
+ def optional_create_request_attributes
102
+ %i[]
103
+ end
104
+
105
+ # Attributes that are not required in the update request.
106
+ #
107
+ # @example
108
+ # optional_update_request_attributes = definition.optional_update_request_attributes
109
+ # # => [:email]
110
+ #
111
+ # @return [Array<Symbol>] The attributes that are not required in the update request.
112
+ def optional_update_request_attributes
113
+ %i[]
114
+ end
115
+
116
+ # Returns the attributes that are nullable in the request/response body.
117
+ # This means that they can be present in the request/response body but they can be null.
118
+ # They are not required to be present in the request body.
119
+ #
120
+ # @example
121
+ # [:name, :email]
122
+ #
123
+ # @return [Array<Symbol>] The attributes that are nullable in the request/response body.
124
+ def nullable_attributes
125
+ %i[]
126
+ end
127
+
128
+ # Returns the additional create request attributes that are not automatically generated.
129
+ # These attributes are appended to the create request schema.
130
+ #
131
+ # @example
132
+ # { name: { type: :string } }
133
+ #
134
+ # @return [Hash] The additional create request attributes that are not automatically generated (if any).
135
+ def additional_create_request_attributes
136
+ {}
137
+ end
138
+
139
+ # Returns the additional update request attributes that are not automatically generated.
140
+ # These attributes are appended to the update request schema.
141
+ #
142
+ # @example
143
+ # { name: { type: :string } }
144
+ #
145
+ # @return [Hash] The additional update request attributes that are not automatically generated (if any).
146
+ def additional_update_request_attributes
147
+ {}
148
+ end
149
+
150
+ # Returns the additional response attributes that are not automatically generated. These attributes are appended to the response schema.
151
+ #
152
+ # @example
153
+ # { name: { type: :string } }
154
+ #
155
+ # @return [Hash] The additional response attributes that are not automatically generated (if any).
156
+ def additional_response_attributes
157
+ {}
158
+ end
159
+
160
+ # Returns the additional response relations that are not automatically generated.
161
+ # These relations are appended to the response schema's relationships.
162
+ #
163
+ # @example
164
+ # {
165
+ # users: {
166
+ # type: :object,
167
+ # properties: {
168
+ # data: {
169
+ # type: :array,
170
+ # items: {
171
+ # type: :object,
172
+ # properties: {
173
+ # id: { type: :string },
174
+ # type: { type: :string }
175
+ # }
176
+ # }
177
+ # }
178
+ # }
179
+ # }
180
+ # }
181
+ #
182
+ # @return [Hash] The additional response relations that are not automatically generated (if any).
183
+ def additional_response_relations
184
+ {}
185
+ end
186
+
187
+ # Returns the additional response included that are not automatically generated.
188
+ # These included additions are appended to the response schema's included.
189
+ #
190
+ # @example
191
+ # {
192
+ # type: :object,
193
+ # properties: {
194
+ # id: { type: :string },
195
+ # type: { type: :string },
196
+ # attributes: {
197
+ # type: :object,
198
+ # properties: {
199
+ # name: { type: :string }
200
+ # }
201
+ # }
202
+ # }
203
+ # }
204
+ #
205
+ # @return [Hash] The additional response included that are not automatically generated (if any).
206
+ def additional_response_included
207
+ {}
208
+ end
209
+
210
+ # Returns the attributes that are excluded from the create request schema.
211
+ # These attributes are not required or not needed to be present in the create request body.
212
+ #
213
+ # @example
214
+ # [:id, :updated_at, :created_at]
215
+ #
216
+ # @return [Array<Symbol>] The attributes that are excluded from the create request schema.
217
+ def excluded_create_request_attributes
218
+ %i[]
219
+ end
220
+
221
+ # Returns the attributes that are excluded from the response schema.
222
+ # These attributes are not needed to be present in the response body.
223
+ #
224
+ # @example
225
+ # [:id, :updated_at, :created_at]
226
+ #
227
+ # @return [Array<Symbol>] The attributes that are excluded from the response schema.
228
+ def excluded_update_request_attributes
229
+ %i[]
230
+ end
231
+
232
+ # Returns the attributes that are excluded from the update request schema.
233
+ # These attributes are not required or not needed to be present in the update request body.
234
+ #
235
+ # @example
236
+ # [:id, :updated_at, :created_at]
237
+ #
238
+ # @return [Array<Symbol>] The attributes that are excluded from the update request schema.
239
+ def excluded_response_attributes
240
+ %i[]
241
+ end
242
+
243
+ # Returns the relationships that are excluded from the response schema.
244
+ # These relationships are not needed to be present in the response body.
245
+ #
246
+ # @example
247
+ # [:users, :applicants]
248
+ #
249
+ # @return [Array<Symbol>] The relationships that are excluded from the response schema.
250
+ def excluded_response_relations
251
+ %i[]
252
+ end
253
+
254
+ # Returns the included that are excluded from the response schema.
255
+ # These included are not needed to be present in the response body.
256
+ #
257
+ # @example
258
+ # [:users, :applicants]
259
+ #
260
+ # @todo
261
+ # This method is not used anywhere yet.
262
+ #
263
+ # @return [Array<Symbol>] The included that are excluded from the response schema.
264
+ def excluded_response_included
265
+ %i[]
266
+ end
267
+
268
+ # Returns the default value for the enum attributes.
269
+ #
270
+ # @example
271
+ # {
272
+ # status: 'pending',
273
+ # flag: 0
274
+ # }
275
+ #
276
+ # @return [Hash] The custom default values for the enum attributes.
277
+ def default_value_for_enum_attributes
278
+ {}
279
+ end
280
+
281
+ # Returns an instance of the model class that is already serialized into jsonapi format.
282
+ #
283
+ # @return [Hash] The serialized instance of the model class.
284
+ def serialized_instance
285
+ {}
286
+ end
287
+
288
+ # Returns the model class (Constantized from the definition class name)
289
+ #
290
+ # @example
291
+ # User
292
+ #
293
+ # @return [Class] The model class (Constantized from the definition class name)
294
+ def model
295
+ self.class.name.gsub('Swagger::Definitions::', '').constantize
296
+ end
297
+
298
+ # Returns the model name. Used for schema type naming.
299
+ #
300
+ # @return [String] The model name.
301
+ #
302
+ # @example
303
+ # 'users' for the User model
304
+ # 'citizen_applications' for the CitizenApplication model
305
+ def model_name
306
+ self.class.name.gsub('Swagger::Definitions::', '').pluralize.underscore.downcase
307
+ end
308
+
309
+ # Given a hash, it returns a new hash with all the keys camelized.
310
+ #
311
+ # @param hash [Hash] The hash with all the keys camelized.
312
+ #
313
+ # @return [Hash, Array] The hash with all the keys camelized.
314
+ #
315
+ # @example
316
+ # { first_name: 'John', last_name: 'Doe' } => { firstName: 'John', lastName: 'Doe' }
317
+ def camelize_keys(hash)
318
+ hash.deep_transform_keys { |key| key.to_s.camelize(:lower).to_sym }
319
+ end
320
+
321
+ # Returns the schema for the create request body, update request body, and response body.
322
+ #
323
+ # @return [Array<Hash>] The schema for the create request body, update request body, and response body.
324
+ def self.generate
325
+ instance = new
326
+
327
+ [
328
+ "#{instance.model}CreateRequest": instance.camelize_keys(RequestSchemaGenerator.new(instance).generate_for_create),
329
+ "#{instance.model}UpdateRequest": instance.camelize_keys(RequestSchemaGenerator.new(instance).generate_for_update),
330
+ "#{instance.model}Response": instance.camelize_keys(ResponseSchemaGenerator.new(instance).generate(collection: true)),
331
+ "#{instance.model}ResponseExpanded": instance.camelize_keys(ResponseSchemaGenerator.new(instance).generate(expand: true))
332
+ ]
333
+ end
334
+ end
335
+ end