schemable 0.1.4 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,119 @@
1
+ module Schemable
2
+ # The RelationshipSchemaGenerator class is responsible for generating the 'relationships' part of a JSON:API compliant response.
3
+ # This class generates schemas for each relationship of a model, including 'belongs_to' (and has_many) and 'has_many' relationships.
4
+ #
5
+ # @see Schemable
6
+ class RelationshipSchemaGenerator
7
+ attr_reader :model_definition, :schema_modifier, :relationships
8
+
9
+ # Initializes a new RelationshipSchemaGenerator instance.
10
+ #
11
+ # @param model_definition [ModelDefinition] The model definition to generate the schema for.
12
+ #
13
+ # @example
14
+ # generator = RelationshipSchemaGenerator.new(model_definition)
15
+ def initialize(model_definition)
16
+ @model_definition = model_definition
17
+ @schema_modifier = SchemaModifier.new
18
+ @relationships = model_definition.relationships
19
+ end
20
+
21
+ # Generates the 'relationships' part of the JSON:API response.
22
+ # It iterates over each relationship type (belongs_to, has_many) and for each relationship,
23
+ # it prepares a schema unless the relationship is excluded from expansion.
24
+ # If the 'expand' option is true, it changes the schema to include type and id properties inside the 'meta' property.
25
+ #
26
+ # @param relationships_to_exclude_from_expansion [Array] The relationships to exclude from expansion.
27
+ # @param expand [Boolean] Whether to include the relationships of the related resource in the schema.
28
+ #
29
+ # @example
30
+ # schema = generator.generate(expand: true, relationships_to_exclude_from_expansion: [:some_relationship])
31
+ #
32
+ # @return [Hash] The generated schema.
33
+ def generate(relationships_to_exclude_from_expansion: [], expand: false)
34
+ return {} if @relationships.blank? || @relationships == { belongs_to: {}, has_many: {} }
35
+
36
+ schema = {
37
+ type: :object,
38
+ properties: {}
39
+ }
40
+
41
+ %i[belongs_to has_many].each do |relation_type|
42
+ @relationships[relation_type]&.each do |relation, definition|
43
+ non_expanded_data_properties = {
44
+ type: :object,
45
+ properties: {
46
+ meta: {
47
+ type: :object,
48
+ properties: {
49
+ included: { type: :boolean, default: false }
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ result = relation_type == :belongs_to ? generate_schema(definition.model_name) : generate_schema(definition.model_name, collection: true)
56
+
57
+ result = non_expanded_data_properties if !expand || relationships_to_exclude_from_expansion.include?(definition.model_name)
58
+
59
+ schema[:properties].merge!(relation => result)
60
+ end
61
+ end
62
+
63
+ # Modify the schema to include additional response relations
64
+ schema = @schema_modifier.add_properties(schema, @model_definition.additional_response_relations, 'properties')
65
+
66
+ # Modify the schema to exclude response relations
67
+ @model_definition.excluded_response_relations.each do |key|
68
+ schema = @schema_modifier.delete_properties(schema, "properties.#{key}")
69
+ end
70
+
71
+ schema
72
+ end
73
+
74
+ # Generates the schema for a specific relationship.
75
+ # If the 'collection' option is true, it generates a schema for a 'has_many' relationship,
76
+ # otherwise it generates a schema for a 'belongs_to' relationship. The difference between the two is that
77
+ # 'data' is an array in the 'has_many' relationship and an object in the 'belongs_to' relationship.
78
+ #
79
+ # @param type_name [String] The type of the related resource.
80
+ # @param collection [Boolean] Whether the relationship is a 'has_many' relationship.
81
+ #
82
+ # @example
83
+ # relationship_schema = generator.generate_schema('resource_type', collection: true)
84
+ #
85
+ # @return [Hash] The generated schema for the relationship.
86
+ def generate_schema(type_name, collection: false)
87
+ if collection
88
+ {
89
+ type: :object,
90
+ properties: {
91
+ data: {
92
+ type: :array,
93
+ items: {
94
+ type: :object,
95
+ properties: {
96
+ id: { type: :string },
97
+ type: { type: :string, default: type_name }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ else
104
+ {
105
+ type: :object,
106
+ properties: {
107
+ data: {
108
+ type: :object,
109
+ properties: {
110
+ id: { type: :string },
111
+ type: { type: :string, default: type_name }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,88 @@
1
+ module Schemable
2
+ # The RequestSchemaGenerator class is responsible for generating JSON schemas for create and update requests.
3
+ # This class generates schemas based on the model definition, including additional and excluded attributes.
4
+ #
5
+ # @see Schemable
6
+ class RequestSchemaGenerator
7
+ attr_reader :model_definition, :schema_modifier
8
+
9
+ # Initializes a new RequestSchemaGenerator instance.
10
+ #
11
+ # @param model_definition [ModelDefinition] The model definition to generate the schema for.
12
+ #
13
+ # @example
14
+ # generator = RequestSchemaGenerator.new(model_definition)
15
+ def initialize(model_definition)
16
+ @model_definition = model_definition
17
+ @schema_modifier = SchemaModifier.new
18
+ end
19
+
20
+ # Generates the JSON schema for a create request.
21
+ # It generates a schema for the model attributes and then modifies it based on the additional and excluded attributes for create requests.
22
+ # It also determines the required attributes based on the optional and nullable attributes.
23
+ # Note that it is presumed that the model is using the same fields/columns for create as well as responses.
24
+ #
25
+ # @example
26
+ # schema = generator.generate_for_create
27
+ #
28
+ # @return [Hash] The generated schema for create requests.
29
+ def generate_for_create
30
+ schema = {
31
+ type: :object,
32
+ properties: {
33
+ data: AttributeSchemaGenerator.new(@model_definition).generate
34
+ }
35
+ }
36
+
37
+ @schema_modifier.add_properties(schema, @model_definition.additional_create_request_attributes, 'properties.data.properties')
38
+
39
+ @model_definition.excluded_create_request_attributes.each do |key|
40
+ @schema_modifier.delete_properties(schema, "properties.data.properties.#{key}")
41
+ end
42
+
43
+ required_attributes = {
44
+ required: (
45
+ schema.as_json['properties']['data']['properties'].keys -
46
+ @model_definition.optional_create_request_attributes.map(&:to_s) -
47
+ @model_definition.nullable_attributes.map(&:to_s)
48
+ ).map { |key| key.to_s.camelize(:lower).to_sym }
49
+ }
50
+
51
+ @schema_modifier.add_properties(schema, required_attributes, 'properties.data')
52
+ end
53
+
54
+ # Generates the JSON schema for a update request.
55
+ # It generates a schema for the model attributes and then modifies it based on the additional and excluded attributes for update requests.
56
+ # It also determines the required attributes based on the optional and nullable attributes.
57
+ # Note that it is presumed that the model is using the same fields/columns for update as well as responses.
58
+ #
59
+ # @example
60
+ # schema = generator.generate_for_update
61
+ #
62
+ # @return [Hash] The generated schema for update requests.
63
+ def generate_for_update
64
+ schema = {
65
+ type: :object,
66
+ properties: {
67
+ data: AttributeSchemaGenerator.new(@model_definition).generate
68
+ }
69
+ }
70
+
71
+ @schema_modifier.add_properties(schema, @model_definition.additional_update_request_attributes, 'properties.data.properties')
72
+
73
+ @model_definition.excluded_update_request_attributes.each do |key|
74
+ @schema_modifier.delete_properties(schema, "properties.data.properties.#{key}")
75
+ end
76
+
77
+ required_attributes = {
78
+ required: (
79
+ schema.as_json['properties']['data']['properties'].keys -
80
+ @model_definition.optional_update_request_attributes.map(&:to_s) -
81
+ @model_definition.nullable_attributes.map(&:to_s)
82
+ ).map { |key| key.to_s.camelize(:lower).to_sym }
83
+ }
84
+
85
+ @schema_modifier.add_properties(schema, required_attributes, 'properties.data')
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,124 @@
1
+ module Schemable
2
+ # The ResponseSchemaGenerator class is responsible for generating JSON schemas for responses.
3
+ # This class generates schemas based on the model definition, including attributes, relationships, and included resources.
4
+ #
5
+ # @see Schemable
6
+ class ResponseSchemaGenerator
7
+ attr_reader :model_definition, :model, :schema_modifier, :configuration
8
+
9
+ # Initializes a new ResponseSchemaGenerator instance.
10
+ #
11
+ # @param model_definition [ModelDefinition] The model definition to generate the schema for.
12
+ #
13
+ # @example
14
+ # generator = ResponseSchemaGenerator.new(model_definition)
15
+ def initialize(model_definition)
16
+ @model_definition = model_definition
17
+ @model = model_definition.model
18
+ @schema_modifier = SchemaModifier.new
19
+ @configuration = Schemable.configuration
20
+ end
21
+
22
+ # Generates the JSON schema for a response.
23
+ # It generates a schema for the model attributes and relationships, and if the 'expand' option is true,
24
+ # it also includes the included resources in the schema.
25
+ # It also adds meta and jsonapi information to the schema.
26
+ #
27
+ # @param expand [Boolean] Whether to include the included resources in the schema.
28
+ # @param relationships_to_exclude_from_expansion [Array] The relationships to exclude from expansion in the schema.
29
+ # @param collection [Boolean] Whether the response is for a collection of resources.
30
+ #
31
+ # @example
32
+ # schema = generator.generate(expand: true, relationships_to_exclude_from_expansion: ['some_relationship'], collection: true)
33
+ #
34
+ # @return [Hash] The generated schema.
35
+ def generate(expand: false, relationships_to_exclude_from_expansion: [], collection: false)
36
+ data = {
37
+ type: :object,
38
+ properties: {
39
+ type: { type: :string, default: @model_definition.model_name },
40
+ id: { type: :string },
41
+ attributes: AttributeSchemaGenerator.new(@model_definition).generate
42
+ }.merge(
43
+ if @model_definition.relationships.blank? || @model_definition.relationships == { belongs_to: {}, has_many: {} }
44
+ {}
45
+ else
46
+ { relationships: RelationshipSchemaGenerator.new(@model_definition).generate(expand:, relationships_to_exclude_from_expansion:) }
47
+ end
48
+ )
49
+ }
50
+
51
+ schema = collection ? { data: { type: :array, items: data } } : { data: }
52
+
53
+ if expand
54
+ included_schema = IncludedSchemaGenerator.new(@model_definition).generate(expand:, relationships_to_exclude_from_expansion:)
55
+ @schema_modifier.add_properties(schema, included_schema, '.')
56
+ end
57
+
58
+ @schema_modifier.add_properties(schema, { meta: }, '.') if collection
59
+ @schema_modifier.add_properties(schema, { jsonapi: }, '.')
60
+
61
+ { type: :object, properties: schema }.compact_blank
62
+ end
63
+
64
+ # Generates the JSON schema for the 'meta' part of a response.
65
+ # It returns a custom meta response schema if one is defined in the configuration, otherwise it generates a default meta schema.
66
+ #
67
+ # @example
68
+ # meta_schema = generator.meta
69
+ #
70
+ # @return [Hash] The generated schema for the 'meta' part of a response.
71
+ def meta
72
+ return @configuration.custom_meta_response_schema if @configuration.custom_meta_response_schema.present?
73
+
74
+ if @configuration.pagination_enabled
75
+ {
76
+ type: :object,
77
+ properties: {
78
+ page: {
79
+ type: :object,
80
+ properties: {
81
+ totalPages: {
82
+ type: :integer,
83
+ default: 1
84
+ },
85
+ count: {
86
+ type: :integer,
87
+ default: 1
88
+ },
89
+ rowsPerPage: {
90
+ type: :integer,
91
+ default: 1
92
+ },
93
+ currentPage: {
94
+ type: :integer,
95
+ default: 1
96
+ }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ else
102
+ {}
103
+ end
104
+ end
105
+
106
+ # Generates the JSON schema for the 'jsonapi' part of a response.
107
+ #
108
+ # @example
109
+ # jsonapi_schema = generator.jsonapi
110
+ #
111
+ # @return [Hash] The generated schema for the 'jsonapi' part of a response.
112
+ def jsonapi
113
+ {
114
+ type: :object,
115
+ properties: {
116
+ version: {
117
+ type: :string,
118
+ default: '1.0'
119
+ }
120
+ }
121
+ }
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,248 @@
1
+ module Schemable
2
+ # The SchemaModifier class provides methods for modifying a given schema.
3
+ # It includes methods for parsing paths, checking if a path exists in a schema,
4
+ # deeply merging hashes, adding properties to a schema, and deleting properties from a schema.
5
+ #
6
+ # @see Schemable
7
+ class SchemaModifier
8
+
9
+ # Parses a given path into an array of symbols.
10
+ #
11
+ # @note This method accepts paths in the following formats:
12
+ # - 'path.to.property'
13
+ # - 'path.to.array.[0].property'
14
+ #
15
+ # @example
16
+ # parse_path('path.to.property') #=> [:path, :to, :property]
17
+ # parse_path('path.to.array.[0].property') #=> [:path, :to, :array, :[0], :property]
18
+ #
19
+ # @param path [String] The path to parse.
20
+ # @return [Array<Symbol>] The parsed path.
21
+ def parse_path(path)
22
+ path.split('.').map(&:to_sym)
23
+ end
24
+
25
+ # Checks if a given path exists in a schema.
26
+ #
27
+ # @example
28
+ # schema = {
29
+ # path: {
30
+ # type: :object,
31
+ # properties: {
32
+ # to: {
33
+ # type: :object,
34
+ # properties: {
35
+ # property: {
36
+ # type: :string
37
+ # }
38
+ # }
39
+ # }
40
+ # }
41
+ # }
42
+ # }
43
+ #
44
+ # path = 'path.properties.to.properties.property'
45
+ # incorrect_path = 'path.properties.to.properties.invalid'
46
+ # path_exists?(schema, path) #=> true
47
+ # path_exists?(schema, incorrect_path) #=> false
48
+ #
49
+ # @param schema [Hash, Array] The schema to check.
50
+ # @param path [String] The path to check for.
51
+ # @return [Boolean] True if the path exists in the schema, false otherwise.
52
+ def path_exists?(schema, path)
53
+ path_segments = parse_path(path)
54
+
55
+ path_segments.reduce(schema) do |current_segment, next_segment|
56
+ if current_segment.is_a?(Array)
57
+ # The regex pattern '/\[(\d+)\]|\d+/' matches square brackets containing one or more digits,
58
+ # or standalone digits. Used for parsing array indices in a path.
59
+ index = next_segment.to_s.match(/\[(\d+)\]|\d+/)[1]
60
+ # The regex pattern '/\A\d+\z/' matches a sequence of one or more digits from the start ('\A')
61
+ # to the end ('\z') of a string. It checks if a string consists of only digits.
62
+ return false if index.nil? || !index.match?(/\A\d+\z/) || index.to_i >= current_segment.length
63
+
64
+ current_segment[index.to_i]
65
+ else
66
+ return false unless current_segment.is_a?(Hash) && current_segment.key?(next_segment)
67
+
68
+ current_segment[next_segment]
69
+ end
70
+ end
71
+
72
+ true
73
+ end
74
+
75
+ # Deeply merges two hashes.
76
+ #
77
+ # @example
78
+ # destination = { level1: { level2: { level3: 'value' } } }
79
+ # new_data = { level1_again: 'value' }
80
+ # deep_merge_hashes(destination, new_data)
81
+ # #=> { level1: { level2: { level3: 'value' } }, level1_again: 'value' }
82
+ #
83
+ # new_destination = [{ object1: 'value' }, { object2: 'value' }]
84
+ # new_new_data = { object3: 'value' }
85
+ # deep_merge_hashes(new_destination, new_new_data)
86
+ # #=> [{ object1: 'value' }, { object2: 'value' }, { object3: 'value' }]
87
+ #
88
+ # new_destination = { object1: 'value' }
89
+ # new_new_data = [{ object2: 'value' }, { object3: 'value' }]
90
+ # deep_merge_hashes(new_destination, new_new_data)
91
+ # #=> { object1: 'value', object2: 'value', object3: 'value' }
92
+ #
93
+ # @param destination [Hash] The hash to merge into.
94
+ # @param new_data [Hash] The hash to merge from.
95
+ # @return [Hash] The merged hashes.
96
+ def deep_merge_hashes(destination, new_data)
97
+ if destination.is_a?(Hash) && new_data.is_a?(Array)
98
+ destination.merge(new_data)
99
+ elsif destination.is_a?(Array) && new_data.is_a?(Hash)
100
+ destination.push(new_data)
101
+ elsif destination.is_a?(Hash) && new_data.is_a?(Hash)
102
+ new_data.each do |key, value|
103
+ if destination[key].is_a?(Hash) && value.is_a?(Hash)
104
+ destination[key] = deep_merge_hashes(destination[key], value)
105
+ elsif destination[key].is_a?(Array) && value.is_a?(Array)
106
+ destination[key].concat(value)
107
+ elsif destination[key].is_a?(Array) && value.is_a?(Hash)
108
+ destination[key].push(value)
109
+ else
110
+ destination[key] = value
111
+ end
112
+ end
113
+ end
114
+
115
+ destination
116
+ end
117
+
118
+ # Adds properties to a schema at a given path.
119
+ #
120
+ # @example
121
+ # original_schema = { level1: { level2: { level3: 'value' } } }
122
+ # new_data = { L3: 'value' }
123
+ # path = 'level1.level2'
124
+ # add_properties(original_schema, new_schema, path)
125
+ # #=> { level1: { level2: { level3: 'value', L3: 'value' } } }
126
+ #
127
+ # new_original_schema = { test: [{ object1: 'value' }, { object2: 'value' }] }
128
+ # new_new_schema = { object2_again: 'value' }
129
+ # path = 'test.[1]'
130
+ # add_properties(new_original_schema, new_new_schema, path)
131
+ # #=> { test: [{ object1: 'value' }, { object2: 'value', object2_again: 'value' }] }
132
+ #
133
+ # @param original_schema [Hash] The original schema.
134
+ # @param new_schema [Hash] The new schema to add.
135
+ # @param path [String] The path at which to add the new schema.
136
+ # @note This method accepts paths in the following formats:
137
+ # - 'path.to.property'
138
+ # - 'path.to.array.[0].property'
139
+ # - '.'
140
+ #
141
+ # @return [Hash] The modified schema.
142
+ def add_properties(original_schema, new_schema, path)
143
+ return deep_merge_hashes(original_schema, new_schema) if path == '.'
144
+
145
+ unless path_exists?(original_schema, path)
146
+ puts "Error: Path '#{path}' does not exist in the original schema"
147
+ return original_schema
148
+ end
149
+
150
+ path_segments = parse_path(path)
151
+ current_segment = original_schema
152
+ last_segment = path_segments.pop
153
+
154
+ # Navigate to the specified location in the schema
155
+ path_segments.each do |segment|
156
+ if current_segment.is_a?(Array)
157
+ index = segment.to_s.match(/\[(\d+)\]|\d+/)[1]
158
+ if index&.match?(/\A\d+\z/) && index.to_i < current_segment.length
159
+ current_segment = current_segment[index.to_i]
160
+ else
161
+ puts "Error: Invalid index in path '#{path}'"
162
+ return original_schema
163
+ end
164
+ elsif current_segment.is_a?(Hash) && current_segment.key?(segment)
165
+ current_segment = current_segment[segment]
166
+ else
167
+ puts "Error: Expected a Hash but found #{current_segment.class} in path '#{path}'"
168
+ return original_schema
169
+ end
170
+ end
171
+
172
+ # Merge the new schema into the specified location
173
+ if current_segment.is_a?(Array)
174
+ index = last_segment.to_s.match(/\[(\d+)\]|\d+/)[1]
175
+ if index&.match?(/\A\d+\z/) && index.to_i < current_segment.length
176
+ current_segment[index.to_i] = deep_merge_hashes(current_segment[index.to_i], new_schema)
177
+ else
178
+ puts "Error: Invalid index in path '#{path}'"
179
+ end
180
+ else
181
+ current_segment[last_segment] = deep_merge_hashes(current_segment[last_segment], new_schema)
182
+ end
183
+
184
+ original_schema
185
+ end
186
+
187
+ # Deletes properties from a schema at a given path.
188
+ #
189
+ # @example
190
+ # original_schema = { level1: { level2: { level3: 'value' } } }
191
+ # path = 'level1.level2'
192
+ # delete_properties(original_schema, path)
193
+ # #=> { level1: {} }
194
+ #
195
+ # new_original_schema = { test: [{ object1: 'value' }, { object2: 'value' }] }
196
+ # path = 'test.[1]'
197
+ # delete_properties(new_original_schema, path)
198
+ # #=> { test: [{ object1: 'value' }] }
199
+ #
200
+ # @param original_schema [Hash] The original schema.
201
+ # @param path [String] The path at which to delete properties.
202
+ # @return [Hash] The modified schema.
203
+ def delete_properties(original_schema, path)
204
+ return original_schema if path == '.'
205
+
206
+ unless path_exists?(original_schema, path)
207
+ puts "Error: Path '#{path}' does not exist in the original schema"
208
+ return original_schema
209
+ end
210
+
211
+ path_segments = parse_path(path)
212
+ current_segment = original_schema
213
+ last_segment = path_segments.pop
214
+
215
+ # Navigate to the parent of the last segment in the path
216
+ path_segments.each do |segment|
217
+ if current_segment.is_a?(Array)
218
+ index = segment.to_s.match(/\[(\d+)\]|\d+/)[1]
219
+ if index&.match?(/\A\d+\z/) && index.to_i < current_segment.length
220
+ current_segment = current_segment[index.to_i]
221
+ else
222
+ puts "Error: Invalid index in path '#{path}'"
223
+ return original_schema
224
+ end
225
+ elsif current_segment.is_a?(Hash) && current_segment.key?(segment)
226
+ current_segment = current_segment[segment]
227
+ else
228
+ puts "Error: Expected a Hash but found #{current_segment.class} in path '#{path}'"
229
+ return original_schema
230
+ end
231
+ end
232
+
233
+ # Delete the last segment in the path
234
+ if current_segment.is_a?(Array)
235
+ index = last_segment.to_s.match(/\[(\d+)\]|\d+/)[1]
236
+ if index&.match?(/\A\d+\z/) && index.to_i < current_segment.length
237
+ current_segment.delete_at(index.to_i)
238
+ else
239
+ puts "Error: Invalid index in path '#{path}'"
240
+ end
241
+ else
242
+ current_segment.delete(last_segment)
243
+ end
244
+
245
+ original_schema
246
+ end
247
+ end
248
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Schemable
4
- VERSION = '0.1.4'
4
+ VERSION = '1.0.0'
5
5
  end