Skip to content

Commit

Permalink
Initial support for readOnly & writeOnly keywords
Browse files Browse the repository at this point in the history
  • Loading branch information
skryukov committed Jan 14, 2025
1 parent 47ed7fb commit 0de63d0
Show file tree
Hide file tree
Showing 11 changed files with 726 additions and 28 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning].

## [Unreleased]

### Added

- Experimental support for `readOnly` and `writeOnly` keywords. ([@skryukov])

```ruby
# spec/rails_helper.rb

RSpec.configure do |config|
# To enable support for readOnly and writeOnly keywords, pass `enforce_access_modes: true` option:
config.include Skooma::RSpec[Rails.root.join("docs", "openapi.yml"), enforce_access_modes: true], type: :request
end
```

## [0.3.3] - 2024-10-14

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi, path_p
# To enable coverage, pass `coverage: :report` option,
# and to raise an error when an operation is not covered, pass `coverage: :strict` option:
ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi, coverage: :report], type: :request

# EXPERIMENTAL
# To enable support for readOnly and writeOnly keywords, pass `enforce_access_modes: true` option:
ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi, enforce_access_modes: true], type: :request
```

#### Validate OpenAPI document
Expand Down
3 changes: 3 additions & 0 deletions lib/skooma/dialects/oas_3_1.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ def call(registry, **options)
Skooma::Keywords::OAS31::Dialect::OneOf,
Skooma::Keywords::OAS31::Dialect::Discriminator,
Skooma::Keywords::OAS31::Dialect::Xml,
Skooma::Keywords::OAS31::Dialect::Properties,
Skooma::Keywords::OAS31::Dialect::AdditionalProperties,
Skooma::Keywords::OAS31::Dialect::Required,
Skooma::Keywords::OAS31::Dialect::ExternalDocs,
Skooma::Keywords::OAS31::Dialect::Example
)
Expand Down
63 changes: 63 additions & 0 deletions lib/skooma/keywords/oas_3_1/dialect/additional_properties.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# frozen_string_literal: true

module Skooma
module Keywords
module OAS31
module Dialect
class AdditionalProperties < JSONSkooma::Keywords::Applicator::AdditionalProperties
self.key = "additionalProperties"
self.instance_types = "object"
self.value_schema = :schema
self.depends_on = %w[properties patternProperties]

def evaluate(instance, result)
known_property_names = result.sibling(instance, "properties")&.schema_node&.keys || []
known_property_patterns = (result.sibling(instance, "patternProperties")&.schema_node&.keys || [])
.map { |pattern| Regexp.new(pattern) }

forbidden = []

if json.root.enforce_access_modes?
only_key = result.path.include?("responses") ? "writeOnly" : "readOnly"
properties_result = result.sibling(instance, "properties")
instance.each_key do |name|
res = properties_result&.children&.[](instance[name]&.path)&.[]name
forbidden << name.tap { puts "adding #{name}" } if annotation_exists?(res, key: only_key)
end
end

annotation = []
error = []

instance.each do |name, item|
if forbidden.include?(name) || !known_property_names.include?(name) && known_property_patterns.none? { |pattern| pattern.match?(name) }
if json.evaluate(item, result).passed?
annotation << name
else
error << name
# reset to success for the next iteration
result.success
end
end
end
return result.annotate(annotation) if error.empty?

result.failure(error)
end

private

def annotation_exists?(result, key:)
return result if result.key == key && result.annotation

result.each_children do |child|
return child if annotation_exists?(child, key: key)
end

nil
end
end
end
end
end
end
50 changes: 50 additions & 0 deletions lib/skooma/keywords/oas_3_1/dialect/properties.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

module Skooma
module Keywords
module OAS31
module Dialect
class Properties < JSONSkooma::Keywords::Applicator::Properties
self.key = "properties"
self.instance_types = "object"
self.value_schema = :object_of_schemas

def evaluate(instance, result)
annotation = []
err_names = []
instance.each do |name, item|
next unless json.value.key?(name)

result.call(item, name) do |subresult|
json[name].evaluate(item, subresult)
if ignored_with_only_key?(subresult)
subresult.discard
elsif subresult.passed?
annotation << name
else
err_names << name
end
end
end

return result.annotate(annotation) if err_names.empty?

result.failure("Properties #{err_names.join(", ")} are invalid")
end

private

def ignored_with_only_key?(subresult)
return false unless json.root.enforce_access_modes?

if subresult.parent.path.include?("responses")
subresult.children["readOnly"]&.value == true
else
subresult.children["writeOnly"]&.value == true
end
end
end
end
end
end
end
51 changes: 51 additions & 0 deletions lib/skooma/keywords/oas_3_1/dialect/required.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

module Skooma
module Keywords
module OAS31
module Dialect
class Required < JSONSkooma::Keywords::Validation::Required
self.key = "required"
self.instance_types = "object"
self.depends_on = %w[properties]

def evaluate(instance, result)
missing = required_keys.reject { |key| instance.key?(key) }
return if missing.none?

if json.root.enforce_access_modes?
properties_schema = result.sibling(instance, "properties")&.schema_node || {}
only_key = result.path.include?("responses") ? "writeOnly" : "readOnly"
ignore = []
missing.each do |name|
next unless properties_schema.key?(name)

result.call(nil, name) do |subresult|
properties_schema[name].evaluate(nil, subresult)
ignore << name if annotation_exists?(subresult, key: only_key)
subresult.discard
end
end

return if (missing - ignore).none?
end

result.failure(missing_keys_message(missing))
end

private

def annotation_exists?(result, key:)
return result if result.key == key && result.annotation

result.each_children do |child|
return child if annotation_exists?(child, key: key)
end

nil
end
end
end
end
end
end
3 changes: 2 additions & 1 deletion lib/skooma/matchers/wrapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def skooma_openapi_schema
end
end

def initialize(helper_methods_module, openapi_path, base_uri: "https://skoomarb.dev/", path_prefix: "", **params)
def initialize(helper_methods_module, openapi_path, base_uri: "https://skoomarb.dev/", path_prefix: "", enforce_access_modes: false, **params)
super()

registry = create_test_registry
Expand All @@ -57,6 +57,7 @@ def initialize(helper_methods_module, openapi_path, base_uri: "https://skoomarb.
)
@schema = registry.schema(URI.parse("#{source_uri}#{pathname.basename}"), schema_class: Skooma::Objects::OpenAPI)
@schema.path_prefix = path_prefix
@schema.enforce_access_modes = enforce_access_modes

@coverage = Coverage.new(@schema, mode: params[:coverage], format: params[:coverage_format])

Expand Down
10 changes: 10 additions & 0 deletions lib/skooma/objects/openapi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ def path_prefix=(value)
@path_prefix = @path_prefix.delete_suffix("/") if @path_prefix.end_with?("/")
end

def enforce_access_modes=(value)
raise ArgumentError, "Enforce access modes must be a boolean" unless [true, false].include?(value)

@enforce_access_modes = value
end

def enforce_access_modes?
@enforce_access_modes
end

def path_prefix
@path_prefix || ""
end
Expand Down
Loading

0 comments on commit 0de63d0

Please sign in to comment.