api-regulator 0.1.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,60 @@
1
+ module ApiRegulator
2
+ module ControllerMixin
3
+ def validate_params!
4
+ validator_class = Validator.get(params[:controller], params[:action])
5
+
6
+ unless validator_class
7
+ raise "No validator found for HTTP method #{request.method} and API path #{api_definition.path}"
8
+ end
9
+
10
+ validator = validator_class.new(api_params)
11
+ unless validator.valid?
12
+ raise ApiRegulator::ValidationError.new(validator.errors)
13
+ end
14
+ end
15
+
16
+ def api_definition
17
+ return @api_definition if defined?(@api_definition)
18
+
19
+ @api_definition ||= self.class.api_definitions.find do |d|
20
+ d.controller_path == params[:controller] && d.action_name == params[:action]
21
+ end
22
+
23
+ raise "API definition not found for #{params[:controller]}##{params[:action]}" unless @api_definition
24
+
25
+ @api_definition
26
+ end
27
+
28
+ def api_params
29
+ path_params = api_definition.params.select(&:path?).each_with_object({}) do |param, hash|
30
+ hash[param.name.to_sym] = params[param.name] if params.key?(param.name)
31
+ end.symbolize_keys
32
+
33
+ query_params = api_definition.params.select(&:query?).each_with_object({}) do |param, hash|
34
+ hash[param.name.to_sym] = params[param.name] if params.key?(param.name)
35
+ end.symbolize_keys
36
+
37
+ body_params = api_definition.params.select(&:body?)
38
+ permitted_body = body_params.each_with_object({}) do |param, hash|
39
+ if params.key?(param.name)
40
+ params.require(param.name).permit(*build_permitted_keys(param.children)).to_h.symbolize_keys
41
+ hash[param.name.to_sym] = params.require(param.name).permit(*build_permitted_keys(param.children)).to_h.symbolize_keys
42
+ end
43
+ end.symbolize_keys
44
+
45
+ path_params.merge(query_params).merge(permitted_body).symbolize_keys
46
+ end
47
+
48
+ private
49
+
50
+ def build_permitted_keys(params)
51
+ params.map do |param|
52
+ if param.children.any?
53
+ { param.name.to_sym => build_permitted_keys(param.children) }
54
+ else
55
+ param.name.to_sym
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,30 @@
1
+ require "active_support/concern"
2
+
3
+ module ApiRegulator
4
+ module DSL
5
+ extend ActiveSupport::Concern
6
+
7
+ def self.included(base)
8
+ base.extend(ClassMethods)
9
+ end
10
+
11
+ module ClassMethods
12
+ def api(controller_class, action, description, &block)
13
+ @api_definitions ||= []
14
+
15
+ api_definition = Api.new(
16
+ controller_class,
17
+ action.to_s,
18
+ description,
19
+ &block
20
+ )
21
+
22
+ @api_definitions << api_definition
23
+ end
24
+
25
+ def api_definitions
26
+ @api_definitions || []
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,11 @@
1
+ require 'active_model'
2
+ require 'uri'
3
+
4
+ module ApiRegulator
5
+ module Formats
6
+ DATE = ActiveModel::Type::Date::ISO_DATE
7
+ EMAIL = URI::MailTo::EMAIL_REGEXP
8
+ ZIP_CODE = /\A\d{5}(-\d{4})?\z/
9
+ URI = URI::DEFAULT_PARSER.make_regexp
10
+ end
11
+ end
@@ -0,0 +1,224 @@
1
+ require 'json'
2
+
3
+ module ApiRegulator
4
+ class OpenApiGenerator
5
+ def self.generate(api_definitions)
6
+ schema = {
7
+ openapi: '3.1.0', # Explicitly target OpenAPI 3.1.0
8
+ info: {
9
+ title: ApiRegulator.configuration.app_name,
10
+ version: '1.0.0',
11
+ description: 'Generated by ApiRegulator'
12
+ },
13
+ servers: ApiRegulator.configuration.servers,
14
+ paths: {}
15
+ }
16
+
17
+ add_components(schema)
18
+ add_security(schema)
19
+
20
+ api_definitions.each do |api|
21
+ add_api_to_schema(schema, api)
22
+ end
23
+
24
+ File.write(ApiRegulator.configuration.docs_path, JSON.pretty_generate(schema))
25
+ puts "OpenAPI schema generated: #{ApiRegulator.configuration.docs_path}"
26
+ end
27
+
28
+ private
29
+
30
+ def self.add_api_to_schema(schema, api)
31
+ schema[:paths][api.path] ||= {}
32
+ data = {
33
+ summary: api.description,
34
+ description: api.description,
35
+ operationId: api.operation_id,
36
+ tags: api.tags,
37
+ parameters: generate_parameters(api),
38
+ responses: generate_responses(api)
39
+ }
40
+
41
+ data[:requestBody] = generate_request_body(api) if api.allows_body?
42
+ data.delete(:requestBody) if data[:requestBody].blank?
43
+
44
+ schema[:paths][api.path][api.http_method] = data
45
+ end
46
+
47
+ def self.generate_request_body(api)
48
+ params = api.params.select(&:body?)
49
+ return {} if params.empty?
50
+
51
+ {
52
+ required: true,
53
+ content: {
54
+ 'application/json' => {
55
+ schema: expand_nested_params(params)
56
+ }
57
+ }
58
+ }
59
+ end
60
+
61
+ def self.expand_nested_params(params)
62
+ schema = {
63
+ type: 'object',
64
+ properties: {},
65
+ required: []
66
+ }
67
+
68
+ params.each do |param|
69
+ schema[:properties][param.name] =
70
+ if param.type == :array
71
+ generate_array_schema(param)
72
+ elsif param.children.any?
73
+ generate_object_schema(param)
74
+ else
75
+ generate_param_schema(param)
76
+ end
77
+
78
+ # Add to required array if marked as required
79
+ schema[:required] << param.name if param.options[:presence]
80
+ end
81
+
82
+ # Remove the required key if it's empty (not needed in OpenAPI spec)
83
+ schema.delete(:required) if schema[:required].empty?
84
+ schema
85
+ end
86
+
87
+ def self.generate_array_schema(param)
88
+ item_schema = param.children.any? ? generate_object_schema(param) : { type: param.item_type }
89
+
90
+ {
91
+ type: 'array',
92
+ items: item_schema,
93
+ description: param.desc.presence
94
+ }.compact
95
+ end
96
+
97
+ def self.generate_object_schema(param)
98
+ object_schema = expand_nested_params(param.children)
99
+ object_schema[:description] = param.desc if param.desc.present?
100
+ object_schema
101
+ end
102
+
103
+ def self.generate_parameters(api)
104
+ api.params.select(&:parameter?).map do |p|
105
+ generate_param_schema(p)
106
+ .merge({
107
+ name: p.name,
108
+ required: p.location == :path || p.options[:presence] || false
109
+ })
110
+ end
111
+ end
112
+
113
+ def self.generate_param_schema(param)
114
+ schema = {}
115
+
116
+ if param.parameter?
117
+ schema[:in] = param.location
118
+ schema[:schema] = { type: param.type.to_s.downcase }
119
+ else
120
+ schema[:type] = param.type.to_s.downcase
121
+ end
122
+
123
+ schema[:description] = param.desc if param.desc.present?
124
+
125
+
126
+ # Add length constraints
127
+ if param.options[:length]
128
+ schema[:minLength] = param.options[:length][:minimum] if param.options[:length][:minimum]
129
+ schema[:maxLength] = param.options[:length][:maximum] if param.options[:length][:maximum]
130
+ end
131
+
132
+ # Add format constraints
133
+ if param.options[:format]
134
+ if param.options[:format][:with] == Formats::EMAIL
135
+ schema[:format] = 'email'
136
+ elsif param.options[:format][:with] == Formats::DATE
137
+ schema[:format] = 'date'
138
+ elsif param.options[:format][:with] == Formats::URI
139
+ schema[:format] = 'uri'
140
+ elsif param.options[:format][:with].is_a?(Regexp)
141
+ schema[:pattern] = param.options[:format][:with].source
142
+ end
143
+ end
144
+
145
+ if param.options[:numericality]
146
+ schema[:type] = param.options[:only_integer] ? 'integer' : 'number'
147
+ schema[:minimum] = param.options[:greater_than_or_equal_to] if param.options[:greater_than_or_equal_to]
148
+ schema[:maximum] = param.options[:less_than_or_equal_to] if param.options[:less_than_or_equal_to]
149
+ schema[:exclusiveMinimum] = param.options[:greater_than] if param.options[:greater_than]
150
+ schema[:exclusiveMaximum] = param.options[:less_than] if param.options[:less_than]
151
+ end
152
+
153
+ if param.options[:inclusion]
154
+ schema[:enum] = param.options[:inclusion][:in]
155
+ end
156
+
157
+ if param.options[:exclusion]
158
+ schema[:not] = { enum: param.options[:exclusion][:in] }
159
+ end
160
+
161
+ schema[:nullable] = true if param.options[:allow_nil]
162
+
163
+ schema
164
+ end
165
+
166
+ def self.generate_responses(api)
167
+ api.responses.each_with_object({}) do |(status_code, schema), responses|
168
+ if schema.options[:ref]
169
+ shared_schema = ApiRegulator.shared_schemas[schema.options[:ref]]
170
+ raise "Shared schema not found for ref: #{schema.options[:ref]}" unless shared_schema
171
+
172
+ responses[status_code.to_s] = {
173
+ description: shared_schema.description.presence,
174
+ content: {
175
+ "application/json" => {
176
+ schema: { "$ref" => "#/components/schemas/#{schema.options[:ref]}" }
177
+ }
178
+ }
179
+ }.compact
180
+ else
181
+ responses[status_code.to_s] = {
182
+ description: schema.desc.presence,
183
+ content: {
184
+ "application/json" => {
185
+ schema: expand_nested_params(schema.children)
186
+ }
187
+ }
188
+ }.compact
189
+ end
190
+ end
191
+ end
192
+
193
+ def self.add_components(schema)
194
+ add_shared_schemas(schema)
195
+ add_security_schemes(schema)
196
+ end
197
+
198
+ def self.add_shared_schemas(schema)
199
+ return unless ApiRegulator.shared_schemas.present?
200
+
201
+ schema[:components] ||= {}
202
+ schema[:components][:schemas] ||= {}
203
+
204
+ ApiRegulator.shared_schemas.each do |name, shared_schema|
205
+ schema[:components][:schemas][name] = expand_nested_params(shared_schema.params).merge(
206
+ description: shared_schema.description
207
+ ).compact
208
+ end
209
+ end
210
+
211
+ def self.add_security_schemes(schema)
212
+ return unless ApiRegulator.security_schemes.present?
213
+
214
+ schema[:components] ||= {}
215
+ schema[:components][:securitySchemes] = ApiRegulator.security_schemes
216
+ end
217
+
218
+ def self.add_security(schema)
219
+ return unless ApiRegulator.security.present?
220
+
221
+ schema[:security] = ApiRegulator.security
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,55 @@
1
+ module ApiRegulator
2
+ class Param
3
+ attr_reader :name, :type, :options, :item_type, :desc, :location, :children
4
+
5
+ def initialize(name, type = nil, item_type: nil, desc: "", location: :body, **options, &block)
6
+ @name = name
7
+ @type = type&.to_sym || (block_given? ? :object : :string)
8
+ @item_type = item_type
9
+ @location = location.to_sym
10
+ @desc = desc
11
+ @options = options
12
+ @children = []
13
+
14
+ instance_eval(&block) if block_given?
15
+ end
16
+
17
+ def param(name, type = nil, item_type: nil, desc: "", location: :body, **options, &block)
18
+ child = Param.new(name, type, item_type: item_type, desc: desc, location: location, **options, &block)
19
+ @children << child
20
+ end
21
+
22
+ def ref(ref_name)
23
+ shared_schema = ApiRegulator.shared_schemas[ref_name]
24
+ raise "Shared schema #{ref_name} not found" unless shared_schema
25
+
26
+ shared_schema.params.each do |shared_param|
27
+ @children << shared_param
28
+ end
29
+ end
30
+
31
+ def body?
32
+ location == :body
33
+ end
34
+
35
+ def query?
36
+ location == :query
37
+ end
38
+
39
+ def path?
40
+ location == :path
41
+ end
42
+
43
+ def parameter?
44
+ [:path, :query].include?(location)
45
+ end
46
+
47
+ def object?
48
+ type.to_sym == :object
49
+ end
50
+
51
+ def array?
52
+ type.to_sym == :array
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,37 @@
1
+ module ApiRegulator
2
+ class SharedSchema
3
+ attr_reader :name, :description, :params
4
+
5
+ def initialize(name, description, &block)
6
+ @name = name
7
+ @description = description
8
+ @params = []
9
+ instance_eval(&block) if block_given?
10
+ end
11
+
12
+ def param(name, type = nil, item_type: nil, desc: "", location: :body, **options, &block)
13
+ param = Param.new(name, type, item_type: item_type, desc: desc, location: location, **options, &block)
14
+ @params << param
15
+ end
16
+ end
17
+
18
+ class << self
19
+ attr_accessor :security
20
+
21
+ def shared_schemas
22
+ @shared_schemas ||= {}
23
+ end
24
+
25
+ def security_schemes
26
+ @security_schemes ||= {}
27
+ end
28
+
29
+ def shared_schema(name, description, &block)
30
+ shared_schemas[name] = SharedSchema.new(name, description, &block)
31
+ end
32
+
33
+ def security_schemes=(scheme)
34
+ @security_schemes = scheme
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,11 @@
1
+ module ApiRegulator
2
+ class ValidationError < StandardError
3
+ attr_reader :errors, :details
4
+
5
+ def initialize(errors)
6
+ @errors = errors
7
+ @details = errors.messages
8
+ super("Validation failed with errors, see details")
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,245 @@
1
+ require "active_model"
2
+
3
+ module ApiRegulator
4
+ module AttributeDefinitionMixin
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :defined_attributes, default: []
9
+ class_attribute :nested_validators, default: {}
10
+ end
11
+
12
+ class_methods do
13
+ def define_attribute_and_validations(param, parent_key = nil)
14
+ # Construct the full key
15
+ full_key = parent_key ? "#{parent_key}.#{param.name}".to_sym : param.name.to_sym
16
+
17
+ case param.type
18
+ when :array
19
+ define_array_validations(param, full_key)
20
+ when :object
21
+ define_object_validations(param, full_key)
22
+ else
23
+ define_scalar_validations(param, full_key)
24
+ end
25
+ end
26
+
27
+ def define_scalar_validations(param, full_key)
28
+ # Define scalar attributes
29
+ attribute full_key, param.type if param.type
30
+ self.defined_attributes += [full_key]
31
+
32
+ # Add validations for scalar attributes
33
+ param.options.each do |option, value|
34
+ validates full_key, option => value
35
+ end
36
+
37
+ # Add type-specific validations
38
+ validate -> { validate_boolean(full_key) } if param.type == :boolean
39
+ validate -> { validate_integer(full_key) } if param.type == :integer
40
+ validate -> { validate_string(full_key) } if param.type == :string
41
+ end
42
+
43
+ def define_object_validations(param, full_key)
44
+ # Build nested validator class
45
+ nested_validator_class = build_nested_validator_class(param.children, param.name, self)
46
+
47
+ # Add a custom validation for the nested object
48
+ validate -> { validate_nested_object(full_key, nested_validator_class, param) }
49
+
50
+ # Store the nested validator
51
+ nested_validators[full_key] = nested_validator_class
52
+ end
53
+
54
+ def define_array_validations(param, full_key)
55
+ if param.children.any?
56
+ # Build a nested validator class for array items
57
+ item_validator_class = build_nested_validator_class(param.children, param.name, self)
58
+ validate -> { validate_array_of_objects(full_key, item_validator_class, param) }
59
+ elsif param.item_type
60
+ # Scalar array with specific item type
61
+ validate -> { validate_array_of_scalars(full_key, param) }
62
+ else
63
+ raise "Arrays must have children or an item_type"
64
+ end
65
+ end
66
+
67
+ def build_nested_validator_class(children, parent_key, parent_class)
68
+ # Create a unique class name based on the parent key
69
+ class_name = "#{parent_key.to_s.camelize}"
70
+
71
+ # Return the existing class if already defined
72
+ if parent_class.const_defined?(class_name, false)
73
+ return parent_class.const_get(class_name)
74
+ end
75
+
76
+ # Create the nested validator class
77
+ nested_validator_class = Class.new do
78
+ include ActiveModel::Model
79
+ include ActiveModel::Attributes
80
+ include AttributeDefinitionMixin
81
+
82
+ def initialize(attributes = {})
83
+ @raw_attributes = attributes
84
+ allowed_attributes = attributes.slice(*self.class.defined_attributes.map(&:to_sym))
85
+ super(allowed_attributes)
86
+ end
87
+ end
88
+
89
+ # Add child attributes and validations
90
+ children.each do |child|
91
+ nested_validator_class.define_attribute_and_validations(child)
92
+ end
93
+
94
+ # Store the nested class under the parent class namespace
95
+ parent_class.const_set(class_name, nested_validator_class)
96
+ nested_validator_class
97
+ end
98
+ end
99
+
100
+ # Instance methods
101
+ def validate_array_of_objects(attribute, item_validator_class, param)
102
+ raw_value = @raw_attributes[attribute]
103
+ if raw_value.blank?
104
+ errors.add(attribute, "can't be blank") if param.options[:presence]
105
+ return
106
+ end
107
+ unless raw_value.is_a?(Array)
108
+ errors.add(attribute, "must be an array")
109
+ return
110
+ end
111
+
112
+ raw_value.each_with_index do |value, index|
113
+ unless value.is_a?(Hash)
114
+ errors.add("#{attribute}[#{index}]", "must be a hash")
115
+ next
116
+ end
117
+
118
+ validator = item_validator_class.new(value)
119
+ unless validator.valid?
120
+ validator.errors.each do |error|
121
+ nested_attr = "#{attribute}[#{index}].#{error.attribute}"
122
+ errors.add(nested_attr, error.message)
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ def validate_array_of_scalars(attribute, param)
129
+ raw_value = @raw_attributes[attribute]
130
+
131
+ if raw_value.nil?
132
+ errors.add(attribute, "can't be blank") if param.options[:presence]
133
+ return
134
+ end
135
+ unless raw_value.is_a?(Array)
136
+ errors.add(attribute, "must be an array")
137
+ return
138
+ end
139
+
140
+ raw_value.each_with_index do |value, index|
141
+ case param.item_type
142
+ when :string
143
+ unless value.is_a?(String)
144
+ errors.add("#{attribute}[#{index}]", "must be a string")
145
+ end
146
+ when :integer
147
+ unless value.is_a?(Integer)
148
+ errors.add("#{attribute}[#{index}]", "must be an integer")
149
+ end
150
+ when :boolean
151
+ unless [true, false].include?(value)
152
+ errors.add("#{attribute}[#{index}]", "must be a boolean")
153
+ end
154
+ else
155
+ errors.add("#{attribute}[#{index}]", "has an unsupported type")
156
+ end
157
+ end
158
+ end
159
+
160
+ def validate_nested_object(attribute, nested_validator_class, param)
161
+ raw_value = @raw_attributes[attribute]
162
+
163
+ if raw_value.nil?
164
+ errors.add(attribute, "can't be blank") if param.options[:presence]
165
+ return
166
+ end
167
+ unless raw_value.is_a?(Hash)
168
+ errors.add(attribute, "must be a hash")
169
+ return
170
+ end
171
+
172
+ # Initialize the nested validator with the raw value
173
+ validator = nested_validator_class.new(raw_value)
174
+
175
+ # If validation fails, propagate errors
176
+ unless validator.valid?
177
+ validator.errors.each do |error|
178
+ nested_attr = "#{attribute}.#{error.attribute}"
179
+ errors.add(nested_attr, error.message)
180
+ end
181
+ end
182
+ end
183
+
184
+ def validate_boolean(attribute)
185
+ raw_value = @raw_attributes[attribute]
186
+ unless [true, false, "true", "false", nil].include?(raw_value)
187
+ errors.add(attribute, "must be a boolean (true, false, or nil)")
188
+ end
189
+ end
190
+
191
+ def validate_integer(attribute)
192
+ raw_value = @raw_attributes[attribute]
193
+ unless raw_value.to_s =~ /\A[-+]?\d+\z/ || raw_value.nil?
194
+ errors.add(attribute, "must be an integer")
195
+ end
196
+ end
197
+
198
+ def validate_string(attribute)
199
+ raw_value = @raw_attributes[attribute]
200
+ unless raw_value.is_a?(String) || raw_value.nil?
201
+ errors.add(attribute, "must be a string")
202
+ end
203
+ end
204
+ end
205
+
206
+ class Validator
207
+ include ActiveModel::Model
208
+ include ActiveModel::Attributes
209
+ include AttributeDefinitionMixin
210
+
211
+ @validators = {}
212
+
213
+ def self.build_all(api_definitions)
214
+ api_definitions.each do |api_definition|
215
+ class_name = "#{api_definition.controller_path}/#{api_definition.action_name}".gsub("/", "_").camelcase
216
+ validator_class = build_class(api_definition)
217
+ @validators[[api_definition.controller_path, api_definition.action_name]] = validator_class
218
+ Validator.const_set(class_name, validator_class)
219
+ end
220
+ end
221
+
222
+ def self.get(controller, action)
223
+ @validators[[controller, action]]
224
+ end
225
+
226
+ def self.build_class(api_definition)
227
+ Class.new(self) do
228
+ api_definition.params.each do |param|
229
+ define_attribute_and_validations(param)
230
+ end
231
+ end
232
+ end
233
+
234
+ def initialize(attributes = {})
235
+ @raw_attributes = attributes.deep_symbolize_keys
236
+ allowed_attributes = attributes.slice(*self.class.defined_attributes.map(&:to_sym))
237
+ super(allowed_attributes)
238
+ end
239
+ end
240
+ end
241
+
242
+ class FooValidator
243
+ include ActiveModel::Model
244
+ include ActiveModel::Attributes
245
+ end
@@ -0,0 +1,3 @@
1
+ module ApiRegulator
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1 @@
1
+ require_relative 'api-regulator'