api-regulator 0.1.17 → 0.1.19

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a47dad32fbf8cb59ba8f74f6f78d7bfd6ff3a69a1df21e7ebd81546267838db8
4
- data.tar.gz: 4e735d0b0b66b8e671681a3c7e1c0f507fc440a33e2ed4f8a20562e6709ae84b
3
+ metadata.gz: 2222bdbbc060773ad1c512bfdedd9f7b7acee3920249223236e4468317520353
4
+ data.tar.gz: 3642bc547ab2ddde180ab34b551d42e496fcc1e49bda947b1f0f9f521a79d498
5
5
  SHA512:
6
- metadata.gz: 3f0170ac751809bea9a71f1b3216a129446e433bc77bd30527a4ab17411c8dae8b795baa42e01afaa91f7259a4a8bab8cb4f327d18e9c735d48e7d90d1f7a31c
7
- data.tar.gz: ccc6b5deba5c6ce6c2f291b3f81aefb0f199356cf15ca30f6dc354767b400d96531953a9c576977b73603b12a607879be5208d1577d6d9c0254a7651afafddef
6
+ metadata.gz: 22a0cb6db6778f6fdbe0cc795658628120c57907a513c73bdd52f3d138135d8895b1044931e9392e67e265bf5e112b22003837c924d56717f5043494a68401e0
7
+ data.tar.gz: e728660a393d6f57028fa26732b160c94bcc7e7567bc6957fa0d55322de4cfac490c4a742136c53f1119df79bd5d6ea088d393fcc363115e91734b7e858adac4
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- api-regulator (0.1.17)
4
+ api-regulator (0.1.19)
5
5
  activemodel (~> 8.0)
6
6
  activesupport (~> 8.0)
7
7
 
@@ -88,7 +88,7 @@ GEM
88
88
  connection_pool (2.5.0)
89
89
  crass (1.0.6)
90
90
  date (3.4.1)
91
- diff-lcs (1.5.1)
91
+ diff-lcs (1.6.0)
92
92
  drb (2.2.1)
93
93
  erubi (1.13.1)
94
94
  globalid (1.2.1)
@@ -100,7 +100,7 @@ GEM
100
100
  pp (>= 0.6.0)
101
101
  rdoc (>= 4.0.0)
102
102
  reline (>= 0.4.2)
103
- logger (1.6.5)
103
+ logger (1.6.6)
104
104
  loofah (2.24.0)
105
105
  crass (~> 1.0.2)
106
106
  nokogiri (>= 1.12.0)
@@ -113,7 +113,7 @@ GEM
113
113
  mini_mime (1.1.5)
114
114
  mini_portile2 (2.8.8)
115
115
  minitest (5.25.4)
116
- net-imap (0.5.5)
116
+ net-imap (0.5.6)
117
117
  date
118
118
  net-protocol
119
119
  net-pop (0.1.2)
@@ -135,7 +135,7 @@ GEM
135
135
  date
136
136
  stringio
137
137
  racc (1.8.1)
138
- rack (3.1.9)
138
+ rack (3.1.10)
139
139
  rack-session (2.1.0)
140
140
  base64 (>= 0.1.0)
141
141
  rack (>= 3.0.0)
@@ -16,8 +16,8 @@ module ApiRegulator
16
16
  instance_eval(&block) if block_given?
17
17
  end
18
18
 
19
- def param(name, type = nil, item_type: nil, desc: "", location: :body, **options, &block)
20
- param = Param.new(name, type, item_type: item_type, desc: desc, location: location, api: self, **options, &block)
19
+ def param(name, type = nil, item_type: nil, desc: "", location: :body, allow_arbitrary_keys: false, **options, &block)
20
+ param = Param.new(name, type, item_type: item_type, desc: desc, location: location, api: self, allow_arbitrary_keys: allow_arbitrary_keys, **options, &block)
21
21
  @params << param
22
22
  end
23
23
 
@@ -100,8 +100,12 @@ module ApiRegulator
100
100
  @api_definitions ||= []
101
101
  end
102
102
 
103
+ def set_api_definitions(defs)
104
+ @api_definitions = defs
105
+ end
106
+
103
107
  def reset_api_definitions
104
- @api_definitions = []
108
+ set_api_definitions([])
105
109
  end
106
110
  end
107
111
  end
@@ -1,15 +1,16 @@
1
1
  module ApiRegulator
2
2
  module ControllerMixin
3
3
  def validate_params!
4
+ check_for_extra_params!
5
+
4
6
  validator_class = Validator.get(params[:controller], params[:action])
5
7
 
6
8
  unless validator_class
7
9
  raise "No validator found for HTTP method #{request.method} and API path #{api_definition.path}"
8
10
  end
9
-
10
11
  validator = validator_class.new(api_params)
11
12
  unless validator.valid?(params[:action].to_sym)
12
- raise ApiRegulator::ValidationError.new(validator.errors)
13
+ raise ApiRegulator::InvalidParams.new(validator.errors)
13
14
  end
14
15
  end
15
16
 
@@ -63,5 +64,17 @@ module ApiRegulator
63
64
  end
64
65
  end
65
66
  end
67
+
68
+ def check_for_extra_params!
69
+ actual_keys = Validator.flatten_keys(params.to_unsafe_h)
70
+
71
+ # Identify extra keys
72
+ extra_keys = Validator.find_extra_keys(actual_keys, api_definition.params)
73
+
74
+ # Raise an error if there are unexpected keys
75
+ unless extra_keys.empty?
76
+ raise ApiRegulator::UnexpectedParams.new(extra_keys)
77
+ end
78
+ end
66
79
  end
67
80
  end
@@ -99,6 +99,7 @@ module ApiRegulator
99
99
  def self.generate_object_schema(param)
100
100
  object_schema = expand_nested_params(param.children)
101
101
  object_schema[:description] = param.desc if param.desc.present?
102
+ object_schema[:type] = param.allow_nil? ? ['object', 'null'] : 'object'
102
103
  object_schema
103
104
  end
104
105
 
@@ -119,10 +120,17 @@ module ApiRegulator
119
120
 
120
121
  if param.parameter?
121
122
  schema[:in] = param.location
122
- schema[:schema] = { type: param.type.to_s.downcase }
123
+ schema[:schema] = { type: param.schema_type }
124
+ if param.type == :object && param.allow_arbitrary_keys
125
+ schema[:schema][:additionalProperties] = true
126
+ end
127
+ if param.type == :object && param.location == :query
128
+ schema[:explode] = true
129
+ schema[:style] = "deepObject"
130
+ end
123
131
  generate_param_schema_details(param, schema[:schema])
124
132
  else
125
- schema[:type] = param.type.to_s.downcase
133
+ schema[:type] = param.schema_type
126
134
  generate_param_schema_details(param, schema)
127
135
  end
128
136
  schema
@@ -151,7 +159,7 @@ module ApiRegulator
151
159
  end
152
160
 
153
161
  if numericality = param.options[:numericality]
154
- schema[:type] = numericality[:only_integer] || schema[:type] == "integer" ? 'integer' : 'number'
162
+ schema[:type] = numericality[:only_integer] || param.type == :integer ? 'integer' : 'number'
155
163
  schema[:minimum] = numericality[:greater_than_or_equal_to] if numericality[:greater_than_or_equal_to]
156
164
  schema[:maximum] = numericality[:less_than_or_equal_to] if numericality[:less_than_or_equal_to]
157
165
  schema[:exclusiveMinimum] = numericality[:greater_than] if numericality[:greater_than]
@@ -1,8 +1,8 @@
1
1
  module ApiRegulator
2
2
  class Param
3
- attr_reader :name, :type, :options, :item_type, :desc, :location, :children, :api
3
+ attr_reader :name, :type, :options, :item_type, :desc, :location, :children, :api, :allow_arbitrary_keys
4
4
 
5
- def initialize(name, type = nil, item_type: nil, desc: "", location: :body, api: nil, **options, &block)
5
+ def initialize(name, type = nil, item_type: nil, desc: "", location: :body, api: nil, allow_arbitrary_keys: false, **options, &block)
6
6
  @name = name
7
7
  @type = type&.to_sym || (block_given? ? :object : :string)
8
8
  @item_type = item_type
@@ -11,12 +11,13 @@ module ApiRegulator
11
11
  @options = options
12
12
  @children = []
13
13
  @api = api
14
+ @allow_arbitrary_keys = allow_arbitrary_keys
14
15
 
15
16
  instance_eval(&block) if block_given?
16
17
  end
17
18
 
18
- def param(name, type = nil, item_type: nil, desc: "", location: :body, **options, &block)
19
- child = Param.new(name, type, item_type: item_type, desc: desc, location: location, api: api, **options, &block)
19
+ def param(name, type = nil, item_type: nil, desc: "", location: :body, allow_arbitrary_keys: false, **options, &block)
20
+ child = Param.new(name, type, item_type: item_type, desc: desc, location: location, api: api, allow_arbitrary_keys: allow_arbitrary_keys, **options, &block)
20
21
  @children << child
21
22
  end
22
23
 
@@ -46,7 +47,9 @@ module ApiRegulator
46
47
  return false if @options[:presence].nil?
47
48
 
48
49
  if @options[:presence].is_a?(Hash)
49
- if context.nil?
50
+ if @options[:presence].key?(:allow_nil)
51
+ !@options[:presence][:allow_nil]
52
+ elsif context.nil?
50
53
  true # No context provided
51
54
  elsif @options[:presence][:required_on].present?
52
55
  Array(@options[:presence][:required_on]).map(&:to_sym).include?(context.to_sym)
@@ -60,6 +63,54 @@ module ApiRegulator
60
63
  end
61
64
  end
62
65
 
66
+ def allow_nil?
67
+ @options.any? do |_, opts|
68
+ opts.is_a?(Hash) && opts[:allow_nil]
69
+ end
70
+ end
71
+
72
+ def allowed_keys(parent_key = nil)
73
+ key = parent_key.present? ? "#{parent_key}.#{name}" : name.to_s
74
+ key += "[]" if array?
75
+
76
+ if options[:ref]
77
+ shared_schema = ApiRegulator.shared_schema(options[:ref])
78
+ shared_schema.params.flat_map do |param|
79
+ param.allowed_keys
80
+ end
81
+ elsif children.any?
82
+ key = nil if key == "root"
83
+ child_keys = children.flat_map do |child|
84
+ child.allowed_keys(key)
85
+ end
86
+ child_keys << key if allow_nil?
87
+ child_keys
88
+ else
89
+ key
90
+ end
91
+ end
92
+
93
+ def allowed_arbitrary_keys(parent_key = nil)
94
+ key = parent_key.present? ? "#{parent_key}.#{name}" : name.to_s
95
+ key += "[]" if array?
96
+
97
+ if options[:ref]
98
+ shared_schema = ApiRegulator.shared_schema(options[:ref])
99
+ shared_schema.params.flat_map do |param|
100
+ param.allowed_arbitrary_keys
101
+ end.compact
102
+ elsif children.any?
103
+ key = nil if key == "root"
104
+ child_keys = children.flat_map do |child|
105
+ child.allowed_arbitrary_keys(key)
106
+ end.compact
107
+ child_keys << key if allow_nil? && allow_arbitrary_keys
108
+ child_keys
109
+ else
110
+ key if allow_arbitrary_keys
111
+ end
112
+ end
113
+
63
114
  def body?
64
115
  location == :body
65
116
  end
@@ -83,5 +134,13 @@ module ApiRegulator
83
134
  def array?
84
135
  type.to_sym == :array
85
136
  end
137
+
138
+ def schema_type
139
+ if allow_nil?
140
+ [type.to_s.downcase, "null"]
141
+ else
142
+ type.to_s.downcase
143
+ end
144
+ end
86
145
  end
87
146
  end
@@ -10,8 +10,8 @@ module ApiRegulator
10
10
  instance_eval(&block) if block_given?
11
11
  end
12
12
 
13
- def param(name, type = nil, item_type: nil, desc: "", location: :body, **options, &block)
14
- param = Param.new(name, type, item_type: item_type, desc: desc, location: location, **options, &block)
13
+ def param(name, type = nil, item_type: nil, desc: "", location: :body, allow_arbitrary_keys: false, **options, &block)
14
+ param = Param.new(name, type, item_type: item_type, desc: desc, location: location, allow_arbitrary_keys: allow_arbitrary_keys, **options, &block)
15
15
  @params << param
16
16
  end
17
17
 
@@ -1,6 +1,21 @@
1
1
  module ApiRegulator
2
2
  class ValidationError < StandardError
3
- attr_reader :errors, :details
3
+ attr_reader :details
4
+ end
5
+
6
+ class UnexpectedParams < ValidationError
7
+ def initialize(unexpected_keys)
8
+ @details = {}
9
+ unexpected_keys.each do |key|
10
+ @details[key] = ["is unexpected"]
11
+ end
12
+
13
+ super("Unexpected parameters")
14
+ end
15
+ end
16
+
17
+ class InvalidParams < ValidationError
18
+ attr_reader :errors
4
19
 
5
20
  def initialize(errors)
6
21
  @errors = errors
@@ -193,11 +193,8 @@ module ApiRegulator
193
193
  def validate_nested_object(attribute, nested_validator_class, param)
194
194
  raw_value = @raw_attributes[attribute]
195
195
 
196
- # Skip validation if optional and not present
197
- return if raw_value.nil? && param.options[:optional]
198
-
199
196
  if raw_value.nil?
200
- errors.add(attribute, "can't be blank") if param.options[:presence]
197
+ errors.add(attribute, "can't be blank") if param.required?(validation_context)
201
198
  return
202
199
  end
203
200
 
@@ -245,13 +242,10 @@ module ApiRegulator
245
242
  include ActiveModel::Attributes
246
243
  include AttributeDefinitionMixin
247
244
 
248
- @validators = {}
249
-
250
245
  def self.build_all(api_definitions)
251
246
  api_definitions.each do |api_definition|
252
- class_name = "#{api_definition.controller_path}/#{api_definition.action_name}".gsub("/", "_").camelcase
247
+ class_name = build_class_name(api_definition.controller_path, api_definition.action_name)
253
248
  validator_class = build_class(api_definition.params, api_definition.action_name)
254
- @validators[[api_definition.controller_path, api_definition.action_name]] = validator_class
255
249
  Validator.const_set(class_name, validator_class)
256
250
  end
257
251
  end
@@ -259,17 +253,23 @@ module ApiRegulator
259
253
  def self.build_response_validators(api_definitions = ApiRegulator.api_definitions)
260
254
  api_definitions.each do |api_definition|
261
255
  api_definition.responses.each do |code, params|
262
- class_name = "#{api_definition.controller_path}/#{api_definition.action_name}/Response#{code}".gsub("/", "_").camelcase
263
-
256
+ class_name = build_class_name(api_definition.controller_path, api_definition.action_name, code)
264
257
  validator_class = build_class(params.children, api_definition.action_name)
265
- @validators[[api_definition.controller_path, api_definition.action_name, code]] = validator_class
266
258
  Validator.const_set(class_name, validator_class)
267
259
  end
268
260
  end
269
261
  end
270
262
 
271
- def self.get(controller, action, code = nil)
272
- @validators[[controller.to_s, action.to_s, code].compact]
263
+ def self.build_class_name(controller_path, action, code = nil, prefix: false)
264
+ class_name = "#{controller_path}/#{action}"
265
+ class_name += "/Response#{code}" if code.present?
266
+ class_name = class_name.gsub("/", "_").camelcase
267
+ class_name = "ApiRegulator::Validator::" + class_name if prefix
268
+ class_name
269
+ end
270
+
271
+ def self.get(controller_path, action, code = nil)
272
+ build_class_name(controller_path, action, code, prefix: true).constantize
273
273
  end
274
274
 
275
275
  def self.build_class(params, action_name)
@@ -294,6 +294,10 @@ module ApiRegulator
294
294
  end
295
295
 
296
296
  def self.validate_response(controller, action, code, body)
297
+ body = {} if body.empty?
298
+
299
+ check_for_extra_response_params!(controller, action, code, body)
300
+
297
301
  validator_class = get(controller, action, code)
298
302
 
299
303
  unless validator_class
@@ -302,12 +306,60 @@ module ApiRegulator
302
306
 
303
307
  validator = validator_class.new(body)
304
308
  unless validator.valid?(:response) # using :response for validation context
305
- raise ApiRegulator::ValidationError.new(validator.errors)
309
+ raise ApiRegulator::InvalidParams.new(validator.errors)
306
310
  end
307
311
  end
308
312
 
309
- def self.reset_validators
310
- @validators = {}
313
+
314
+ def self.check_for_extra_response_params!(controller, action, code, body)
315
+ actual_keys = flatten_keys(body)
316
+
317
+ api_definition ||= ApiRegulator.api_definitions.find do |d|
318
+ d.controller_path == controller && d.action_name == action.to_s
319
+ end
320
+ response_definition = api_definition.responses[code]
321
+
322
+ # Identify extra keys
323
+ extra_keys = find_extra_keys(actual_keys, [response_definition])
324
+
325
+ # Raise an error if there are unexpected keys
326
+ unless extra_keys.empty?
327
+ raise ApiRegulator::UnexpectedParams.new(extra_keys)
328
+ end
329
+ end
330
+
331
+ def self.flatten_keys(hash, parent_key = nil)
332
+ hash.each_with_object([]) do |(key, value), keys|
333
+ next if ["controller", "action"].include?(key)
334
+
335
+ full_key = parent_key ? "#{parent_key}.#{key}" : key.to_s
336
+
337
+ if value.is_a?(Hash)
338
+ keys.concat(flatten_keys(value, full_key))
339
+ elsif value.is_a?(Array)
340
+ value.each_with_index do |item, index|
341
+ if item.is_a?(Hash)
342
+ keys.concat(flatten_keys(item, "#{full_key}[#{index}]"))
343
+ else
344
+ keys << "#{full_key}[#{index}]"
345
+ end
346
+ end
347
+ else
348
+ keys << full_key
349
+ end
350
+ end
351
+ end
352
+
353
+ def self.find_extra_keys(actual_keys, params)
354
+ allowed_keys = params.flat_map(&:allowed_keys)
355
+ allowed_arbitrary_keys = params.flat_map(&:allowed_arbitrary_keys).compact
356
+ extras = []
357
+ actual_keys.each do |key|
358
+ next if allowed_keys.include?(key.gsub(/\[\d+\]/, "[]"))
359
+ next if allowed_arbitrary_keys.any? { |aribitrary_key| key.starts_with?(aribitrary_key) }
360
+ extras << key
361
+ end
362
+ extras
311
363
  end
312
364
  end
313
365
  end
@@ -1,3 +1,3 @@
1
1
  module ApiRegulator
2
- VERSION = "0.1.17"
2
+ VERSION = "0.1.19"
3
3
  end
@@ -13,8 +13,8 @@ module ApiRegulator
13
13
  instance_eval(&block) if block_given?
14
14
  end
15
15
 
16
- def param(name, type = nil, item_type: nil, desc: "", location: :body, **options, &block)
17
- param = Param.new(name, type, item_type: item_type, desc: desc, location: location, **options, &block)
16
+ def param(name, type = nil, item_type: nil, desc: "", location: :body, allow_arbitrary_keys: false, **options, &block)
17
+ param = Param.new(name, type, item_type: item_type, desc: desc, location: location, allow_arbitrary_keys: allow_arbitrary_keys, **options, &block)
18
18
  @params << param
19
19
  end
20
20
 
@@ -3,6 +3,9 @@ require 'yaml'
3
3
  namespace :api_docs do
4
4
  desc 'Generate OpenAPI schema'
5
5
  task generate: :environment do
6
+ # Set an empty api key if none is provided
7
+ ENV['RDME_API_KEY'] ||= ''
8
+
6
9
  Rails.application.eager_load! # Ensure all controllers and API definitions are loaded
7
10
 
8
11
  ApiRegulator::OpenApiGenerator.generate(ApiRegulator.api_definitions)
@@ -11,7 +14,7 @@ namespace :api_docs do
11
14
  desc "Upload OpenAPI schema to ReadMe"
12
15
  task :upload => :environment do
13
16
  # ReadMe API key and version
14
- readme_api_key = ENV['RDME_API_KEY'] || raise("RDME_API_KEY is not set")
17
+ readme_api_key = ENV['RDME_API_KEY'].presence || raise("RDME_API_KEY is not set")
15
18
 
16
19
  # ReadMe API endpoint
17
20
  readme_api_endpoint = "https://dash.readme.com/api/v1/api-specification"
@@ -71,7 +74,7 @@ namespace :api_docs do
71
74
  desc "Upload custom pages to ReadMe"
72
75
  task :upload_pages => :environment do
73
76
  # Configuration
74
- readme_api_key = ENV['RDME_API_KEY'] || raise("RDME_API_KEY is not set")
77
+ readme_api_key = ENV['RDME_API_KEY'].presence || raise("RDME_API_KEY is not set")
75
78
  base_readme_api_endpoint = "https://dash.readme.com/api/v1/docs"
76
79
 
77
80
  # Discover all documentation files
@@ -130,7 +133,7 @@ namespace :api_docs do
130
133
  desc "View all categories on Readme.com"
131
134
  task :fetch_categories => :environment do
132
135
  # Configuration
133
- readme_api_key = ENV['RDME_API_KEY'] || raise("RDME_API_KEY is not set")
136
+ readme_api_key = ENV['RDME_API_KEY'].presence || raise("RDME_API_KEY is not set")
134
137
  readme_categories_api_endpoint = "https://dash.readme.com/api/v1/categories"
135
138
 
136
139
  # Build the API request
@@ -173,7 +176,7 @@ namespace :api_docs do
173
176
  end
174
177
 
175
178
  def check_if_page_exists(slug)
176
- readme_api_key = ENV['RDME_API_KEY'] || raise("RDME_API_KEY is not set")
179
+ readme_api_key = ENV['RDME_API_KEY'].presence || raise("RDME_API_KEY is not set")
177
180
  uri = URI.parse("https://dash.readme.com/api/v1/docs/#{slug}")
178
181
  request = Net::HTTP::Get.new(uri)
179
182
  request["Authorization"] = "Basic #{Base64.strict_encode64(readme_api_key)}"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: api-regulator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.17
4
+ version: 0.1.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geoff Massanek
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-02-07 00:00:00.000000000 Z
11
+ date: 2025-02-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport