schemable 0.1.4 → 1.0.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.
@@ -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