skooma 0.2.3 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6263d8960b9a3b49287103608c6770c967b6fadfcf58d826df2a492c4dedbd35
4
- data.tar.gz: 5dd2c141d4baa7f0b95c1b3f3fbfd1afdafaae0097a1139956ba1c7e45f876a1
3
+ metadata.gz: 680f73ad3319719625bdf805f0c50d8ce56ec6294329dcf225a6e7f4bf29d53f
4
+ data.tar.gz: 7e512d7bb1d3a39d03e35a63ec687c9de23c35d3f13b777d7b1bbbdf0880e529
5
5
  SHA512:
6
- metadata.gz: e5ddc4010685045a5b2a9908597096af07fb5ac21cce3e1c456568111fed4fc2fb708603be0a47dbd6e61bec0d64e0d85e27841ab36583f0fb61b73759d3f05b
7
- data.tar.gz: 1210a2ed7f56206369242baa147795acd3a6bc77aa6b719cb3ccc26ab33367a775921d0d94fd8741b651274afdc9183c20af194e4a289190d5968de239a9ae56
6
+ metadata.gz: ecc1c3a07b2fc417fbc47ba497ee53af09f3186ecf8d35c13a7e0d8e03bc0cfc0ca9bacdc6bd5a879d46f7b8d517858d605cafc02494d11bc39a794f0c385eea
7
+ data.tar.gz: 94f69160fb4914679a94bd28ccc48e7e5d0a9664a1cb26ed469906be66e3232dc7be13c4ce11190d04b08ebce52557c17e24b57adb14d0b15542f9fa0994fb52
data/CHANGELOG.md CHANGED
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning].
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2024-04-11
11
+
12
+ ### Added
13
+
14
+ - Add coverage for tested API operations. ([@skryukov])
15
+
16
+ ```ruby
17
+
18
+ # spec/rails_helper.rb
19
+
20
+ RSpec.configure do |config|
21
+ # To enable coverage, pass `coverage: :report` option,
22
+ # and to raise an error when an operation is not covered, pass `coverage: :strict` option:
23
+ config.include Skooma::RSpec[Rails.root.join("docs", "openapi.yml"), coverage: :report], type: :request
24
+ end
25
+ ```
26
+
27
+ ```shell
28
+ $ bundle exec rspec
29
+ # ...
30
+ OpenAPI schema /openapi.yml coverage report: 110 / 194 operations (56.7%) covered.
31
+ Uncovered paths:
32
+ GET /api/uncovered 200
33
+ GET /api/partially_covered 403
34
+ # ...
35
+ ```
36
+
37
+ ## [0.3.0] - 2024-04-09
38
+
39
+ ### Changed
40
+
41
+ - BREAKING CHANGE: Pass `headers` parameter to registered `BodyParsers`. ([@skryukov])
42
+
43
+ ```ruby
44
+ # Before:
45
+ Skooma::BodyParsers.register("application/xml", ->(body) { Hash.from_xml(body) })
46
+ # After:
47
+ Skooma::BodyParsers.register("application/xml", ->(body, headers:) { Hash.from_xml(body) })
48
+ ```
49
+ ### Fixed
50
+
51
+ - Fix wrong path when combined with Rails exceptions_app. ([@ursm])
52
+
10
53
  ## [0.2.3] - 2024-01-18
11
54
 
12
55
  ### Added
@@ -23,16 +66,16 @@ and this project adheres to [Semantic Versioning].
23
66
 
24
67
  - Add support for APIs mounted under a path prefix. ([@skryukov])
25
68
 
26
- ```ruby
27
- # spec/rails_helper.rb
28
-
29
- RSpec.configure do |config|
30
- # ...
31
- path_to_openapi = Rails.root.join("docs", "openapi.yml")
32
- # pass path_prefix option if your API is mounted under a prefix:
33
- config.include Skooma::RSpec[path_to_openapi, path_prefix: "/internal/api"], type: :request
34
- end
35
- ```
69
+ ```ruby
70
+ # spec/rails_helper.rb
71
+
72
+ RSpec.configure do |config|
73
+ # ...
74
+ path_to_openapi = Rails.root.join("docs", "openapi.yml")
75
+ # pass path_prefix option if your API is mounted under a prefix:
76
+ config.include Skooma::RSpec[path_to_openapi, path_prefix: "/internal/api"], type: :request
77
+ end
78
+ ```
36
79
 
37
80
  ### Changed
38
81
 
@@ -68,8 +111,10 @@ end
68
111
  - Initial implementation. ([@skryukov])
69
112
 
70
113
  [@skryukov]: https://github.com/skryukov
114
+ [@ursm]: https://github.com/ursm
71
115
 
72
- [Unreleased]: https://github.com/skryukov/skooma/compare/v0.2.3...HEAD
116
+ [Unreleased]: https://github.com/skryukov/skooma/compare/v0.3.0...HEAD
117
+ [0.3.0]: https://github.com/skryukov/skooma/compare/v0.2.3...v0.3.0
73
118
  [0.2.3]: https://github.com/skryukov/skooma/compare/v0.2.2...v0.2.3
74
119
  [0.2.2]: https://github.com/skryukov/skooma/compare/v0.2.1...v0.2.2
75
120
  [0.2.1]: https://github.com/skryukov/skooma/compare/v0.2.0...v0.2.1
data/README.md CHANGED
@@ -16,7 +16,7 @@ Skooma is a Ruby library for validating API implementations against OpenAPI docu
16
16
 
17
17
  ### Learn more
18
18
 
19
- - [Let there be docs! A documentation-first approach to Rails API development](https://evilmartians.com/events/let-there-be-docs-a-documentation-first-approach-to-rails-api-development) – Talk and slides from Friendly.rb 2023
19
+ - [Let there be docs! A documentation-first approach to Rails API development](https://evilmartians.com/chronicles/let-there-be-docs-a-documentation-first-approach-to-rails-api-development)
20
20
 
21
21
  <a href="https://evilmartians.com/?utm_source=skooma&utm_campaign=project_page">
22
22
  <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54">
@@ -51,6 +51,10 @@ RSpec.configure do |config|
51
51
 
52
52
  # OR pass path_prefix option if your API is mounted under a prefix:
53
53
  config.include Skooma::RSpec[path_to_openapi, path_prefix: "/internal/api"], type: :request
54
+
55
+ # To enable coverage, pass `coverage: :report` option,
56
+ # and to raise an error when an operation is not covered, pass `coverage: :strict` option:
57
+ config.include Skooma::RSpec[path_to_openapi, coverage: :report], type: :request
54
58
  end
55
59
  ```
56
60
 
@@ -115,8 +119,15 @@ end
115
119
 
116
120
  ```ruby
117
121
  # test/test_helper.rb
122
+ path_to_openapi = Rails.root.join("docs", "openapi.yml")
123
+ ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi]
124
+
125
+ # OR pass path_prefix option if your API is mounted under a prefix:
126
+ ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi, path_prefix: "/internal/api"], type: :request
118
127
 
119
- ActionDispatch::IntegrationTest.include Skooma::Minitest[Rails.root.join("docs", "openapi.yml")]
128
+ # To enable coverage, pass `coverage: :report` option,
129
+ # and to raise an error when an operation is not covered, pass `coverage: :strict` option:
130
+ ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi, coverage: :report], type: :request
120
131
  ```
121
132
 
122
133
  #### Validate OpenAPI document
@@ -3,7 +3,7 @@
3
3
  module Skooma
4
4
  module BodyParsers
5
5
  class << self
6
- DEFAULT_PARSER = ->(body) { body }
6
+ DEFAULT_PARSER = ->(body, **_options) { body }
7
7
 
8
8
  def [](media_type)
9
9
  parsers[media_type.to_s.strip.downcase] || DEFAULT_PARSER
@@ -20,7 +20,7 @@ module Skooma
20
20
  self.parsers = {}
21
21
 
22
22
  module JSONParser
23
- def self.call(body)
23
+ def self.call(body, **_options)
24
24
  JSON.parse(body)
25
25
  rescue JSON::ParserError
26
26
  body
@@ -0,0 +1,90 @@
1
+ module Skooma
2
+ class NoopCoverage
3
+ def track_request(*)
4
+ end
5
+
6
+ def report
7
+ end
8
+ end
9
+
10
+ class Coverage
11
+ class SimpleReport
12
+ def initialize(coverage)
13
+ @coverage = coverage
14
+ end
15
+
16
+ attr_reader :coverage
17
+
18
+ def report
19
+ puts <<~MSG
20
+ OpenAPI schema #{URI.parse(coverage.schema.uri.to_s).path} coverage report: #{coverage.covered_paths.count} / #{coverage.defined_paths.count} operations (#{coverage.covered_percent.round(2)}%) covered.
21
+ #{coverage.uncovered_paths.empty? ? "All paths are covered!" : "Uncovered paths:"}
22
+ #{coverage.uncovered_paths.map { |method, path, status| "#{method.upcase} #{path} #{status}" }.join("\n")}
23
+ MSG
24
+ end
25
+ end
26
+
27
+ def self.new(schema, mode: nil, format: nil)
28
+ case mode
29
+ when nil, false
30
+ NoopCoverage.new
31
+ when :report, :strict
32
+ super
33
+ else
34
+ raise ArgumentError, "Invalid coverage: #{mode}, expected :report, :strict, or false"
35
+ end
36
+ end
37
+
38
+ attr_reader :mode, :format, :defined_paths, :covered_paths, :schema
39
+
40
+ def initialize(schema, mode:, format:)
41
+ @schema = schema
42
+ @mode = mode
43
+ @format = format || SimpleReport
44
+ @defined_paths = find_defined_paths(schema)
45
+ @covered_paths = Set.new
46
+ end
47
+
48
+ def track_request(result)
49
+ operation = [nil, nil, nil]
50
+ result.collect_annotations(result.instance, keys: %w[paths responses]) do |node|
51
+ case node.key
52
+ when "paths"
53
+ operation[0] = node.annotation["method"]
54
+ operation[1] = node.annotation["current_path"]
55
+ when "responses"
56
+ operation[2] = node.annotation
57
+ end
58
+ end
59
+ covered_paths << operation
60
+ end
61
+
62
+ def uncovered_paths
63
+ defined_paths - covered_paths
64
+ end
65
+
66
+ def covered_percent
67
+ covered_paths.count * 100.0 / defined_paths.count
68
+ end
69
+
70
+ def report
71
+ format.new(self).report
72
+ exit 1 if mode == :strict && uncovered_paths.any?
73
+ end
74
+
75
+ private
76
+
77
+ def find_defined_paths(schema)
78
+ Set.new.tap do |paths|
79
+ schema["paths"].each do |path, path_item|
80
+ resolved_path_item = (path_item.key?("$ref") ? path_item.resolve_ref(path_item["$ref"]) : path_item)
81
+ resolved_path_item.slice("get", "post", "put", "patch", "delete", "options", "head", "trace").each do |method, operation|
82
+ operation["responses"]&.each do |code, _|
83
+ paths << [method, path, code]
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -9,7 +9,7 @@ module Skooma
9
9
  def call(env, response = nil, with_response: true, with_request: true)
10
10
  result = {
11
11
  "method" => env["REQUEST_METHOD"].downcase,
12
- "path" => env["PATH_INFO"]
12
+ "path" => env["action_dispatch.original_path"] || env["PATH_INFO"]
13
13
  }
14
14
  result["request"] = map_request(env) if with_request
15
15
  result["response"] = map_response(response) if response && with_response
@@ -23,10 +23,19 @@ module Skooma
23
23
  {
24
24
  "query" => env["rack.request.query_string"] || env["QUERY_STRING"],
25
25
  "headers" => env.select { |k, _| k.start_with?("HTTP_") || PLAIN_HEADERS.include?(k) }.transform_keys { |k| k.sub(REGEXP_HTTP, "").split("_").map(&:capitalize).join("-") },
26
- "body" => env["RAW_POST_DATA"]
26
+ "body" => env["RAW_POST_DATA"] || read_rack_input(env["rack.input"])
27
27
  }
28
28
  end
29
29
 
30
+ def read_rack_input(input)
31
+ return nil unless input.respond_to?(:rewind)
32
+
33
+ input.rewind
34
+ raw_input = input.read
35
+ input.rewind
36
+ raw_input
37
+ end
38
+
30
39
  def map_response(response)
31
40
  status, headers, body = response.to_a
32
41
  full_body = +""
@@ -63,16 +63,16 @@ module Skooma
63
63
  data = {}
64
64
  data["status"] = JSONSkooma::JSONNode.new(value.fetch("status"), key: "status", parent: self)
65
65
  data["headers"] = Headers.new(value.fetch("headers", {}), key: "headers", parent: self)
66
- body_value = parse_body(value["body"], data["headers"]&.[]("Content-Type"))
66
+ body_value = parse_body(value["body"], data["headers"])
67
67
  data["body"] = Attribute.new(body_value, key: "body", parent: self)
68
68
  ["object", data]
69
69
  end
70
70
 
71
- def parse_body(body, content_type)
71
+ def parse_body(body, headers)
72
72
  return nil unless body
73
73
 
74
- parser = BodyParsers[content_type&.split(";")&.first]
75
- parser ? parser.call(body) : body
74
+ parser = BodyParsers[headers["Content-Type"]&.value&.split(";")&.first]
75
+ parser ? parser.call(body, headers: headers) : body
76
76
  end
77
77
  end
78
78
 
@@ -83,16 +83,16 @@ module Skooma
83
83
  data = {}
84
84
  data["query"] = Attribute.new(value.fetch("query", ""), key: "query", parent: self)
85
85
  data["headers"] = Headers.new(value.fetch("headers", {}), key: "headers", parent: self)
86
- body_value = parse_body(value["body"], data["headers"]&.[]("Content-Type"))
86
+ body_value = parse_body(value["body"], data["headers"])
87
87
  data["body"] = Attribute.new(body_value, key: "body", parent: self)
88
88
  ["object", data]
89
89
  end
90
90
 
91
- def parse_body(body, content_type)
91
+ def parse_body(body, headers)
92
92
  return nil unless body
93
93
 
94
- parser = BodyParsers[content_type&.split(";")&.first]
95
- parser ? parser.call(body) : body
94
+ parser = BodyParsers[headers["Content-Type"]&.value&.split(";")&.first]
95
+ parser ? parser.call(body, headers: headers) : body
96
96
  end
97
97
  end
98
98
 
@@ -5,13 +5,17 @@ require "pp"
5
5
  module Skooma
6
6
  module Matchers
7
7
  class ConformRequestSchema
8
- def initialize(schema, mapped_response)
9
- @schema = schema
8
+ def initialize(skooma, mapped_response)
9
+ @skooma = skooma
10
+ @schema = skooma.schema
10
11
  @mapped_response = mapped_response
11
12
  end
12
13
 
13
14
  def matches?(*)
14
15
  @result = @schema.evaluate(@mapped_response)
16
+
17
+ @skooma.coverage.track_request(@result) if @mapped_response["response"]
18
+
15
19
  @result.valid?
16
20
  end
17
21
 
@@ -3,8 +3,8 @@
3
3
  module Skooma
4
4
  module Matchers
5
5
  class ConformResponseSchema < ConformRequestSchema
6
- def initialize(schema, mapped_response, expected)
7
- super(schema, mapped_response)
6
+ def initialize(skooma, mapped_response, expected)
7
+ super(skooma, mapped_response)
8
8
  @expected = expected
9
9
  end
10
10
 
@@ -38,30 +38,39 @@ module Skooma
38
38
 
39
39
  raise "Response object not found"
40
40
  end
41
+
42
+ def skooma_openapi_schema
43
+ skooma.schema
44
+ end
41
45
  end
42
46
 
43
- def initialize(helper_methods_module, openapi_path, base_uri: "https://skoomarb.dev/", path_prefix: "")
47
+ def initialize(helper_methods_module, openapi_path, base_uri: "https://skoomarb.dev/", path_prefix: "", **params)
44
48
  super()
45
49
 
46
50
  registry = create_test_registry
47
51
  pathname = Pathname.new(openapi_path)
48
- source_uri = "#{base_uri}#{path_prefix.delete_suffix("/")}"
52
+ source_uri = "#{base_uri}#{path_prefix.delete_suffix("/").delete_prefix("/")}"
49
53
  source_uri += "/" unless source_uri.end_with?("/")
50
54
  registry.add_source(
51
55
  source_uri,
52
56
  JSONSkooma::Sources::Local.new(pathname.dirname.to_s)
53
57
  )
54
- schema = registry.schema(URI.parse("#{source_uri}#{pathname.basename}"), schema_class: Skooma::Objects::OpenAPI)
55
- schema.path_prefix = path_prefix
58
+ @schema = registry.schema(URI.parse("#{source_uri}#{pathname.basename}"), schema_class: Skooma::Objects::OpenAPI)
59
+ @schema.path_prefix = path_prefix
60
+
61
+ @coverage = Coverage.new(@schema, mode: params[:coverage], format: params[:coverage_format])
56
62
 
57
63
  include DefaultHelperMethods
58
64
  include helper_methods_module
59
65
 
60
- define_method :skooma_openapi_schema do
61
- schema
66
+ skooma_self = self
67
+ define_method :skooma do
68
+ skooma_self
62
69
  end
63
70
  end
64
71
 
72
+ attr_accessor :schema, :coverage
73
+
65
74
  private
66
75
 
67
76
  def create_test_registry
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "minitest/unit"
4
+
3
5
  module Skooma
4
6
  # Minitest helpers for OpenAPI schema validation
5
7
  # @example
@@ -10,19 +12,19 @@ module Skooma
10
12
  class Minitest < Matchers::Wrapper
11
13
  module HelperMethods
12
14
  def assert_conform_schema(expected_status)
13
- matcher = Matchers::ConformSchema.new(skooma_openapi_schema, mapped_response, expected_status)
15
+ matcher = Matchers::ConformSchema.new(skooma, mapped_response, expected_status)
14
16
 
15
17
  assert matcher.matches?, -> { matcher.failure_message }
16
18
  end
17
19
 
18
20
  def assert_conform_request_schema
19
- matcher = Matchers::ConformRequestSchema.new(skooma_openapi_schema, mapped_response(with_response: false))
21
+ matcher = Matchers::ConformRequestSchema.new(skooma, mapped_response(with_response: false))
20
22
 
21
23
  assert matcher.matches?, -> { matcher.failure_message }
22
24
  end
23
25
 
24
26
  def assert_conform_response_schema(expected_status)
25
- matcher = Matchers::ConformResponseSchema.new(skooma_openapi_schema, mapped_response(with_request: false), expected_status)
27
+ matcher = Matchers::ConformResponseSchema.new(skooma, mapped_response(with_request: false), expected_status)
26
28
 
27
29
  assert matcher.matches?, -> { matcher.failure_message }
28
30
  end
@@ -36,6 +38,8 @@ module Skooma
36
38
 
37
39
  def initialize(openapi_path, **params)
38
40
  super(HelperMethods, openapi_path, **params)
41
+
42
+ MiniTest::Unit.after_tests { coverage.report }
39
43
  end
40
44
  end
41
45
  end
@@ -28,6 +28,8 @@ module Skooma
28
28
 
29
29
  return result.failure("Path #{instance["path"]} not found in schema") unless path
30
30
 
31
+ result.annotate({"current_path" => path})
32
+
31
33
  result.call(instance, path) do |subresult|
32
34
  subresult.annotate({"path_attributes" => attributes})
33
35
  path_schema.evaluate(instance, subresult)
@@ -13,12 +13,16 @@ module Skooma
13
13
  end
14
14
 
15
15
  json.evaluate(instance, result)
16
- return result.success if result.passed?
17
16
 
18
17
  path_item_result = result.parent
19
18
  path_item_result = path_item_result.parent until path_item_result.key.start_with?("/")
20
19
 
21
- path = path_item_result.annotation["path"]
20
+ paths_result = path_item_result.parent
21
+ paths_result.annotate(paths_result.annotation.merge("method" => key))
22
+
23
+ return result.success if result.passed?
24
+
25
+ path = paths_result.annotation["current_path"]
22
26
 
23
27
  result.failure("Path #{path}/#{key} is invalid")
24
28
  end
@@ -5,7 +5,7 @@ module Skooma
5
5
  class PathItem
6
6
  module Keywords
7
7
  class Delete < BaseOperation
8
- self.key = "options"
8
+ self.key = "delete"
9
9
  self.depends_on = %w[parameters]
10
10
  self.value_schema = :schema
11
11
  self.schema_value_class = Objects::Operation
data/lib/skooma/rspec.rb CHANGED
@@ -10,15 +10,15 @@ module Skooma
10
10
  class RSpec < Matchers::Wrapper
11
11
  module HelperMethods
12
12
  def conform_schema(expected_status)
13
- Matchers::ConformSchema.new(skooma_openapi_schema, mapped_response, expected_status)
13
+ Matchers::ConformSchema.new(skooma, mapped_response, expected_status)
14
14
  end
15
15
 
16
16
  def conform_response_schema(expected_status)
17
- Matchers::ConformResponseSchema.new(skooma_openapi_schema, mapped_response(with_request: false), expected_status)
17
+ Matchers::ConformResponseSchema.new(skooma, mapped_response(with_request: false), expected_status)
18
18
  end
19
19
 
20
20
  def conform_request_schema
21
- Matchers::ConformRequestSchema.new(skooma_openapi_schema, mapped_response(with_response: false))
21
+ Matchers::ConformRequestSchema.new(skooma, mapped_response(with_response: false))
22
22
  end
23
23
 
24
24
  def be_valid_document
@@ -28,6 +28,13 @@ module Skooma
28
28
 
29
29
  def initialize(openapi_path, **params)
30
30
  super(HelperMethods, openapi_path, **params)
31
+
32
+ skooma_self = self
33
+ ::RSpec.configure do |c|
34
+ c.after(:suite) do
35
+ at_exit { skooma_self.coverage.report }
36
+ end
37
+ end
31
38
  end
32
39
  end
33
40
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Skooma
4
- VERSION = "0.2.3"
4
+ VERSION = "0.3.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: skooma
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-01-18 00:00:00.000000000 Z
11
+ date: 2024-04-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk
@@ -54,6 +54,7 @@ files:
54
54
  - data/oas-3.1/schema/2022-10-07.json
55
55
  - lib/skooma.rb
56
56
  - lib/skooma/body_parsers.rb
57
+ - lib/skooma/coverage.rb
57
58
  - lib/skooma/dialects/oas_3_1.rb
58
59
  - lib/skooma/env_mapper.rb
59
60
  - lib/skooma/inflector.rb
@@ -163,7 +164,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
163
164
  - !ruby/object:Gem::Version
164
165
  version: '0'
165
166
  requirements: []
166
- rubygems_version: 3.3.7
167
+ rubygems_version: 3.5.7
167
168
  signing_key:
168
169
  specification_version: 4
169
170
  summary: Validate API implementations against OpenAPI documents.