schemable 0.1.3 → 1.0.0

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,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.3'
4
+ VERSION = '1.0.0'
5
5
  end