rc-minitest-openapi 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.
- checksums.yaml +7 -0
- data/README.md +15 -0
- data/lib/minitest/openapi/configuration.rb +68 -0
- data/lib/minitest/openapi/document.rb +96 -0
- data/lib/minitest/openapi/dsl.rb +34 -0
- data/lib/minitest/openapi/railtie.rb +13 -0
- data/lib/minitest/openapi/recorder.rb +76 -0
- data/lib/minitest/openapi/spec.rb +89 -0
- data/lib/minitest/openapi/tasks/openapi.rake +17 -0
- data/lib/minitest/openapi/validator.rb +67 -0
- data/lib/minitest/openapi/version.rb +7 -0
- data/lib/minitest/openapi.rb +80 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dc9be68f3e6661181cebec8f0470921dc0a94d4f5692d31d83b6e5ebb956c6fd
|
|
4
|
+
data.tar.gz: aae8e8280d8668d86fcddf48fd505ba225b10468b1b1cd4aaa511c3e7209e216
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 70227955c8d7e173818dcc119dcedc28e1eaaec110991a1915ea30672f20c81f617e39d08b8d1f130dfe6ddcc06104ee8d7b9bd1005ec306c5b1ca019a4cd343
|
|
7
|
+
data.tar.gz: 2f06ed50395e49d2039fe8a43a16e2cad2a210fff0dde93bafc7a0e0ea50db2e3284ac0eef35b5563ec7ae741dd31f176885b643026586d1291eef3b32ab0707
|
data/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# rc-minitest-openapi
|
|
2
|
+
|
|
3
|
+
Generate an OpenAPI 3.0 document from your Rails minitest API tests.
|
|
4
|
+
|
|
5
|
+
Tests declare each operation and its response schema; responses are validated
|
|
6
|
+
against that schema as the suite runs; the `openapi:generate` rake task writes
|
|
7
|
+
the document. A helper-method DSL and an rswag-style nested block DSL are both
|
|
8
|
+
provided.
|
|
9
|
+
|
|
10
|
+
See the [repository README](https://github.com/RailsComposer/minitest-openapi)
|
|
11
|
+
for full documentation.
|
|
12
|
+
|
|
13
|
+
## License
|
|
14
|
+
|
|
15
|
+
MIT
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Minitest
|
|
7
|
+
module OpenAPI
|
|
8
|
+
# Per-suite settings. Configure in test_helper.rb:
|
|
9
|
+
#
|
|
10
|
+
# Minitest::OpenAPI.configure do |c|
|
|
11
|
+
# c.output_path = "openapi/v1/openapi.json"
|
|
12
|
+
# c.base = "openapi/base.json" # or a Hash
|
|
13
|
+
# end
|
|
14
|
+
class Configuration
|
|
15
|
+
# Where openapi:generate writes the document (relative to Rails.root).
|
|
16
|
+
attr_accessor :output_path
|
|
17
|
+
|
|
18
|
+
# Validate each response body against its declared schema as tests run.
|
|
19
|
+
attr_accessor :validate_responses
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE = {
|
|
22
|
+
"openapi" => "3.0.3",
|
|
23
|
+
"info" => {"title" => "API", "version" => "1.0.0"},
|
|
24
|
+
"components" => {"schemas" => {}}
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@output_path = "openapi/openapi.json"
|
|
29
|
+
@validate_responses = true
|
|
30
|
+
@base = deep_dup(DEFAULT_BASE)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The base document — everything not derived from tests (info, servers,
|
|
34
|
+
# security schemes, reusable component schemas). A Hash, or a path to a
|
|
35
|
+
# JSON/YAML file. Test-recorded operations are merged into `paths`.
|
|
36
|
+
attr_reader :base
|
|
37
|
+
|
|
38
|
+
def base=(value)
|
|
39
|
+
@base =
|
|
40
|
+
case value
|
|
41
|
+
when Hash then value
|
|
42
|
+
when String then load_base_file(value)
|
|
43
|
+
else raise ArgumentError, "base must be a Hash or a file path"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def base_document
|
|
48
|
+
deep_dup(@base)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def load_base_file(path)
|
|
54
|
+
resolved = Minitest::OpenAPI.resolve_path(path)
|
|
55
|
+
content = File.read(resolved)
|
|
56
|
+
path.end_with?(".json") ? JSON.parse(content) : YAML.safe_load(content)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def deep_dup(obj)
|
|
60
|
+
case obj
|
|
61
|
+
when Hash then obj.transform_values { |v| deep_dup(v) }
|
|
62
|
+
when Array then obj.map { |v| deep_dup(v) }
|
|
63
|
+
else obj
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Minitest
|
|
4
|
+
module OpenAPI
|
|
5
|
+
# The in-memory OpenAPI document. Tests record operations into it as they
|
|
6
|
+
# run; #to_h merges those operations into the base document's `paths`.
|
|
7
|
+
class Document
|
|
8
|
+
# Canonical ordering, so the emitted document is identical regardless
|
|
9
|
+
# of the order tests recorded into it (minitest randomizes test order).
|
|
10
|
+
VERB_ORDER = %w[get put post patch delete options head trace].freeze
|
|
11
|
+
OPERATION_KEYS = %w[
|
|
12
|
+
tags summary description operationId parameters requestBody responses
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(base)
|
|
16
|
+
@base = base
|
|
17
|
+
@paths = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The base document's reusable schemas, used to resolve $refs while
|
|
21
|
+
# validating responses.
|
|
22
|
+
def components
|
|
23
|
+
@base["components"] || {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Records one operation/response pair. Called by the recorder.
|
|
27
|
+
def record(verb:, path:, status:, schema: nil, summary: nil, operation_id: nil,
|
|
28
|
+
description: nil, tags: nil, parameters: nil, request_body: nil,
|
|
29
|
+
response_description: nil, content_type: "application/json")
|
|
30
|
+
operation = ((@paths[path] ||= {})[verb.to_s.downcase] ||= {})
|
|
31
|
+
operation["summary"] ||= summary if summary
|
|
32
|
+
operation["operationId"] ||= operation_id if operation_id
|
|
33
|
+
operation["description"] ||= description if description
|
|
34
|
+
operation["tags"] ||= tags if tags && !tags.empty?
|
|
35
|
+
operation["parameters"] ||= parameters if parameters && !parameters.empty?
|
|
36
|
+
operation["requestBody"] ||= request_body if request_body
|
|
37
|
+
|
|
38
|
+
responses = (operation["responses"] ||= {})
|
|
39
|
+
entry = (responses[status.to_s] ||= {})
|
|
40
|
+
entry["description"] ||= response_description || "#{status} response"
|
|
41
|
+
if schema
|
|
42
|
+
entry["content"] ||= {content_type => {"schema" => schema}}
|
|
43
|
+
end
|
|
44
|
+
operation
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The assembled document: the base with recorded operations merged into
|
|
48
|
+
# `paths`. Paths, verbs, operation keys, and response statuses are all
|
|
49
|
+
# canonically ordered, so the output is stable across test runs.
|
|
50
|
+
def to_h
|
|
51
|
+
doc = deep_dup(@base)
|
|
52
|
+
base_paths = doc["paths"] || {}
|
|
53
|
+
paths = {}
|
|
54
|
+
(base_paths.keys | @paths.keys).sort.each do |path|
|
|
55
|
+
merged = (base_paths[path] || {}).merge(@paths[path] || {})
|
|
56
|
+
paths[path] = canonical_path_item(merged)
|
|
57
|
+
end
|
|
58
|
+
doc["paths"] = paths
|
|
59
|
+
doc
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def empty?
|
|
63
|
+
@paths.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Orders the verbs within a path item, and each operation's keys.
|
|
69
|
+
def canonical_path_item(verbs)
|
|
70
|
+
ordered = {}
|
|
71
|
+
(VERB_ORDER & verbs.keys).each { |verb| ordered[verb] = canonical_operation(verbs[verb]) }
|
|
72
|
+
(verbs.keys - VERB_ORDER).sort.each { |key| ordered[key] = verbs[key] }
|
|
73
|
+
ordered
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Orders an operation's keys and sorts its responses by status code.
|
|
77
|
+
def canonical_operation(operation)
|
|
78
|
+
ordered = {}
|
|
79
|
+
OPERATION_KEYS.each { |key| ordered[key] = operation[key] if operation.key?(key) }
|
|
80
|
+
(operation.keys - OPERATION_KEYS).sort.each { |key| ordered[key] = operation[key] }
|
|
81
|
+
if ordered["responses"].is_a?(Hash)
|
|
82
|
+
ordered["responses"] = ordered["responses"].sort_by { |status, _| status.to_i }.to_h
|
|
83
|
+
end
|
|
84
|
+
ordered
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def deep_dup(obj)
|
|
88
|
+
case obj
|
|
89
|
+
when Hash then obj.transform_values { |v| deep_dup(v) }
|
|
90
|
+
when Array then obj.map { |v| deep_dup(v) }
|
|
91
|
+
else obj
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Minitest
|
|
4
|
+
module OpenAPI
|
|
5
|
+
# Helper-method DSL for classic class-based integration tests:
|
|
6
|
+
#
|
|
7
|
+
# class MediaEntriesApiTest < ActionDispatch::IntegrationTest
|
|
8
|
+
# include Minitest::OpenAPI::DSL
|
|
9
|
+
#
|
|
10
|
+
# test "lists media entries" do
|
|
11
|
+
# openapi_get "/api/v1/media_entries",
|
|
12
|
+
# summary: "List media entries",
|
|
13
|
+
# response: {status: 200, schema: {"$ref" => "#/components/schemas/MediaEntry"}}
|
|
14
|
+
# assert_response :success
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Each openapi_<verb> performs the request, validates the response body
|
|
19
|
+
# against the declared schema, records the operation, and returns the
|
|
20
|
+
# response so further assertions can be made.
|
|
21
|
+
module DSL
|
|
22
|
+
%i[get post patch put delete].each do |verb|
|
|
23
|
+
define_method(:"openapi_#{verb}") do |request_path, response:, doc_path: nil, summary: nil, operation_id: nil, description: nil, tags: nil, parameters: nil, params: nil, headers: nil, body: nil, request_body: nil|
|
|
24
|
+
Recorder.run(
|
|
25
|
+
test: self, verb: verb, request_path: request_path, doc_path: doc_path,
|
|
26
|
+
response: response, summary: summary, operation_id: operation_id,
|
|
27
|
+
description: description, tags: tags, parameters: parameters,
|
|
28
|
+
params: params, headers: headers, body: body, request_body: request_body
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Minitest
|
|
4
|
+
module OpenAPI
|
|
5
|
+
# Shared engine behind both DSLs: performs the HTTP request through the
|
|
6
|
+
# integration test, validates the response body against the declared
|
|
7
|
+
# schema, and records the operation into the document.
|
|
8
|
+
module Recorder
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# test - the ActionDispatch::IntegrationTest instance
|
|
12
|
+
# verb - :get/:post/:patch/:put/:delete
|
|
13
|
+
# request_path - the URL to request
|
|
14
|
+
# doc_path - templated path recorded in the document (default: request_path)
|
|
15
|
+
# response - an Integer status, or { status:, schema:, description: }
|
|
16
|
+
# assert_status - when true, fails the test if the status differs
|
|
17
|
+
def run(test:, verb:, request_path:, response:, doc_path: nil, summary: nil,
|
|
18
|
+
operation_id: nil, description: nil, tags: nil, parameters: nil, params: nil,
|
|
19
|
+
headers: nil, body: nil, request_body: nil, assert_status: false)
|
|
20
|
+
spec = normalize_response(response)
|
|
21
|
+
doc_path ||= request_path
|
|
22
|
+
|
|
23
|
+
options = {}
|
|
24
|
+
payload = body.nil? ? params : body
|
|
25
|
+
options[:params] = payload unless payload.nil?
|
|
26
|
+
options[:headers] = headers if headers
|
|
27
|
+
options[:as] = :json unless body.nil?
|
|
28
|
+
test.public_send(verb, request_path, **options)
|
|
29
|
+
|
|
30
|
+
actual = test.response
|
|
31
|
+
|
|
32
|
+
Minitest::OpenAPI.document.record(
|
|
33
|
+
verb: verb, path: doc_path, status: spec[:status], schema: spec[:schema],
|
|
34
|
+
summary: summary, operation_id: operation_id, description: description,
|
|
35
|
+
tags: tags, parameters: parameters, request_body: request_body,
|
|
36
|
+
response_description: spec[:description]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if assert_status
|
|
40
|
+
test.assert_equal spec[:status], actual.status,
|
|
41
|
+
"expected #{verb.to_s.upcase} #{request_path} to respond #{spec[:status]}, " \
|
|
42
|
+
"got #{actual.status}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if Minitest::OpenAPI.configuration.validate_responses &&
|
|
46
|
+
spec[:schema] && actual.status == spec[:status]
|
|
47
|
+
Validator.new(Minitest::OpenAPI.components).validate!(
|
|
48
|
+
spec[:schema], parse_body(actual),
|
|
49
|
+
context: "#{verb.to_s.upcase} #{doc_path} -> #{spec[:status]}"
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
actual
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def normalize_response(response)
|
|
57
|
+
case response
|
|
58
|
+
when Integer
|
|
59
|
+
{status: response, schema: nil, description: nil}
|
|
60
|
+
when Hash
|
|
61
|
+
{status: Integer(response.fetch(:status)), schema: response[:schema],
|
|
62
|
+
description: response[:description]}
|
|
63
|
+
else
|
|
64
|
+
raise ArgumentError, "response: must be a status Integer or a Hash with :status"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_body(actual)
|
|
69
|
+
JSON.parse(actual.body)
|
|
70
|
+
rescue JSON::ParserError
|
|
71
|
+
raise Validator::ResponseMismatch,
|
|
72
|
+
"response body was not valid JSON (HTTP #{actual.status})"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/spec"
|
|
4
|
+
|
|
5
|
+
module Minitest
|
|
6
|
+
module OpenAPI
|
|
7
|
+
# Nested block DSL for minitest/spec users. Extend an integration test
|
|
8
|
+
# class with it; api_path / api_operation / api_response build nested
|
|
9
|
+
# describe blocks and run_api_test! defines the test:
|
|
10
|
+
#
|
|
11
|
+
# class MediaEntriesApiTest < ActionDispatch::IntegrationTest
|
|
12
|
+
# extend Minitest::OpenAPI::Spec
|
|
13
|
+
#
|
|
14
|
+
# api_path "/api/v1/media_entries" do
|
|
15
|
+
# api_operation :get, summary: "List media entries" do
|
|
16
|
+
# api_response 200, schema: {"$ref" => "#/components/schemas/MediaEntry"} do
|
|
17
|
+
# run_api_test!
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Metadata accumulates down the nesting; run_api_test! merges the whole
|
|
24
|
+
# chain. Its optional block runs in the test instance and returns request
|
|
25
|
+
# overrides ({ path:, params:, headers:, body: }) — use it to build a
|
|
26
|
+
# concrete URL from records created in the test.
|
|
27
|
+
module Spec
|
|
28
|
+
def self.extended(base)
|
|
29
|
+
base.extend(Minitest::Spec::DSL) unless base.is_a?(Minitest::Spec::DSL)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Metadata declared on this describe merged onto its parent's.
|
|
33
|
+
def openapi_metadata
|
|
34
|
+
inherited = superclass.respond_to?(:openapi_metadata) ? superclass.openapi_metadata : {}
|
|
35
|
+
inherited.merge(@openapi_metadata || {})
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def api_path(path, &block)
|
|
39
|
+
describe("path #{path}") do
|
|
40
|
+
@openapi_metadata = {path: path}
|
|
41
|
+
class_eval(&block)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def api_operation(verb, **meta, &block)
|
|
46
|
+
describe(verb.to_s) do
|
|
47
|
+
@openapi_metadata = {verb: verb}.merge(meta)
|
|
48
|
+
class_eval(&block)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def api_response(status, **meta, &block)
|
|
53
|
+
describe("#{status} response") do
|
|
54
|
+
@openapi_metadata = {response_status: status}.merge(meta)
|
|
55
|
+
class_eval(&block)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def run_api_test!(description = nil, &request_block)
|
|
60
|
+
meta = openapi_metadata
|
|
61
|
+
verb = meta.fetch(:verb) { raise Error, "run_api_test! must be nested in api_operation" }
|
|
62
|
+
status = meta.fetch(:response_status) { raise Error, "run_api_test! must be nested in api_response" }
|
|
63
|
+
doc_path = meta.fetch(:path) { raise Error, "run_api_test! must be nested in api_path" }
|
|
64
|
+
|
|
65
|
+
it(description || "conforms to the #{status} response") do
|
|
66
|
+
overrides = request_block ? instance_exec(&request_block) : {}
|
|
67
|
+
overrides ||= {}
|
|
68
|
+
Recorder.run(
|
|
69
|
+
test: self,
|
|
70
|
+
verb: verb,
|
|
71
|
+
request_path: overrides[:path] || doc_path,
|
|
72
|
+
doc_path: doc_path,
|
|
73
|
+
response: {status: status, schema: meta[:schema], description: meta[:description]},
|
|
74
|
+
summary: meta[:summary],
|
|
75
|
+
operation_id: meta[:operation_id],
|
|
76
|
+
description: meta[:operation_description],
|
|
77
|
+
tags: meta[:tags],
|
|
78
|
+
parameters: meta[:parameters],
|
|
79
|
+
request_body: meta[:request_body],
|
|
80
|
+
params: overrides[:params],
|
|
81
|
+
headers: overrides[:headers],
|
|
82
|
+
body: overrides[:body],
|
|
83
|
+
assert_status: true
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :openapi do
|
|
4
|
+
desc "Run the API tests and write the OpenAPI document. Pass test paths to " \
|
|
5
|
+
"scope the run, e.g. openapi:generate[test/integration/api]."
|
|
6
|
+
task :generate, [:paths] do |_task, args|
|
|
7
|
+
paths = [args[:paths], *args.extras].compact
|
|
8
|
+
paths = ["test/integration"] if paths.empty?
|
|
9
|
+
command = ["bin/rails", "test", *paths]
|
|
10
|
+
puts "openapi: #{command.join(" ")}"
|
|
11
|
+
|
|
12
|
+
# MINITEST_OPENAPI makes minitest-openapi write the document after the run.
|
|
13
|
+
# PARALLEL_WORKERS=1 keeps every operation in one process / one document.
|
|
14
|
+
env = {"MINITEST_OPENAPI" => "1", "PARALLEL_WORKERS" => "1"}
|
|
15
|
+
abort "openapi: test run failed; document not written" unless system(env, *command)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json_schemer"
|
|
4
|
+
|
|
5
|
+
module Minitest
|
|
6
|
+
module OpenAPI
|
|
7
|
+
# Validates a response body against the schema a test declared for it.
|
|
8
|
+
# OpenAPI schemas are JSON Schema with a few divergences; `nullable: true`
|
|
9
|
+
# is normalized to a "null" type so a standard JSON Schema validator can
|
|
10
|
+
# be used. $refs of the form #/components/schemas/X resolve against the
|
|
11
|
+
# base document's components.
|
|
12
|
+
class Validator
|
|
13
|
+
class ResponseMismatch < StandardError; end
|
|
14
|
+
|
|
15
|
+
def initialize(components)
|
|
16
|
+
@components = components || {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Raises ResponseMismatch if `body` does not satisfy `schema`.
|
|
20
|
+
def validate!(schema, body, context:)
|
|
21
|
+
return if schema.nil?
|
|
22
|
+
|
|
23
|
+
root = {
|
|
24
|
+
"$schema" => "https://json-schema.org/draft/2020-12/schema",
|
|
25
|
+
"allOf" => [normalize(schema)],
|
|
26
|
+
"components" => normalize(@components)
|
|
27
|
+
}
|
|
28
|
+
errors = JSONSchemer.schema(root).validate(body).to_a
|
|
29
|
+
return if errors.empty?
|
|
30
|
+
|
|
31
|
+
raise ResponseMismatch, "#{context} response did not match its declared schema:\n" +
|
|
32
|
+
errors.first(8).map { |e| " #{format_error(e)}" }.join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def format_error(error)
|
|
38
|
+
pointer = error["data_pointer"].to_s
|
|
39
|
+
location = pointer.empty? ? "(root)" : pointer
|
|
40
|
+
"#{location}: #{error["type"]}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# OpenAPI `nullable: true` has no JSON Schema equivalent. Normalize it:
|
|
44
|
+
# add "null" to `type`, and — since an `enum` is exhaustive — add `nil`
|
|
45
|
+
# to any `enum` so a null value declared nullable is actually allowed.
|
|
46
|
+
def normalize(node)
|
|
47
|
+
case node
|
|
48
|
+
when Hash
|
|
49
|
+
normalized = node.each_with_object({}) { |(k, v), acc| acc[k] = normalize(v) }
|
|
50
|
+
if normalized.delete("nullable")
|
|
51
|
+
if normalized["type"].is_a?(String)
|
|
52
|
+
normalized["type"] = [normalized["type"], "null"]
|
|
53
|
+
end
|
|
54
|
+
if normalized["enum"].is_a?(Array) && !normalized["enum"].include?(nil)
|
|
55
|
+
normalized["enum"] += [nil]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
normalized
|
|
59
|
+
when Array
|
|
60
|
+
node.map { |child| normalize(child) }
|
|
61
|
+
else
|
|
62
|
+
node
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "minitest"
|
|
8
|
+
|
|
9
|
+
require_relative "openapi/version"
|
|
10
|
+
require_relative "openapi/configuration"
|
|
11
|
+
require_relative "openapi/document"
|
|
12
|
+
require_relative "openapi/validator"
|
|
13
|
+
require_relative "openapi/recorder"
|
|
14
|
+
require_relative "openapi/dsl"
|
|
15
|
+
require_relative "openapi/spec"
|
|
16
|
+
|
|
17
|
+
require_relative "openapi/railtie" if defined?(Rails::Railtie)
|
|
18
|
+
|
|
19
|
+
module Minitest
|
|
20
|
+
# Generates an OpenAPI 3.0 document from minitest API tests. Tests declare
|
|
21
|
+
# each operation and its response schema; the live response is validated
|
|
22
|
+
# against that schema as the suite runs; `openapi:generate` writes the doc.
|
|
23
|
+
module OpenAPI
|
|
24
|
+
class Error < StandardError; end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def configuration
|
|
28
|
+
@configuration ||= Configuration.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def configure
|
|
32
|
+
yield configuration
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The document being assembled this run.
|
|
36
|
+
def document
|
|
37
|
+
@document ||= Document.new(configuration.base_document)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Component schemas available for $ref resolution during validation.
|
|
41
|
+
def components
|
|
42
|
+
document.components
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Drops the accumulated document (used between isolated test runs).
|
|
46
|
+
def reset!
|
|
47
|
+
@document = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Writes the assembled document. Returns the absolute path written.
|
|
51
|
+
def generate!(path = nil)
|
|
52
|
+
target = resolve_path(path || configuration.output_path)
|
|
53
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
54
|
+
File.write(target, "#{JSON.pretty_generate(document.to_h)}\n")
|
|
55
|
+
target
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Resolves a path relative to Rails.root when available, else the cwd.
|
|
59
|
+
def resolve_path(path)
|
|
60
|
+
return path.to_s if Pathname.new(path).absolute?
|
|
61
|
+
|
|
62
|
+
root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
63
|
+
File.join((root || Dir.pwd).to_s, path)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# When MINITEST_OPENAPI is set (the openapi:generate rake task sets it), write
|
|
70
|
+
# the document once the suite finishes. Registered here, at require time,
|
|
71
|
+
# rather than via a minitest plugin file: plugin auto-discovery is unreliable
|
|
72
|
+
# under Rails' test runner and with git-sourced gems, whereas test_helper.rb
|
|
73
|
+
# requires this file directly. Ordinary test runs leave MINITEST_OPENAPI unset
|
|
74
|
+
# and are unaffected (responses are still validated; the file is just not
|
|
75
|
+
# written).
|
|
76
|
+
if ENV["MINITEST_OPENAPI"]
|
|
77
|
+
Minitest.after_run do
|
|
78
|
+
warn "minitest-openapi: wrote #{Minitest::OpenAPI.generate!}"
|
|
79
|
+
end
|
|
80
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rc-minitest-openapi
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- RailsComposer
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: json_schemer
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: railties
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '7.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '7.1'
|
|
54
|
+
description: rc-minitest-openapi turns Rails minitest integration tests into the source
|
|
55
|
+
of truth for an OpenAPI 3.0 document. Tests declare each operation and its response
|
|
56
|
+
schema, the live response is validated against that schema as the suite runs, and
|
|
57
|
+
the openapi:generate rake task writes the document. An rswag-style block DSL and
|
|
58
|
+
plain helper methods are both supported.
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- README.md
|
|
64
|
+
- lib/minitest/openapi.rb
|
|
65
|
+
- lib/minitest/openapi/configuration.rb
|
|
66
|
+
- lib/minitest/openapi/document.rb
|
|
67
|
+
- lib/minitest/openapi/dsl.rb
|
|
68
|
+
- lib/minitest/openapi/railtie.rb
|
|
69
|
+
- lib/minitest/openapi/recorder.rb
|
|
70
|
+
- lib/minitest/openapi/spec.rb
|
|
71
|
+
- lib/minitest/openapi/tasks/openapi.rake
|
|
72
|
+
- lib/minitest/openapi/validator.rb
|
|
73
|
+
- lib/minitest/openapi/version.rb
|
|
74
|
+
homepage: https://github.com/RailsComposer/minitest-openapi
|
|
75
|
+
licenses:
|
|
76
|
+
- MIT
|
|
77
|
+
metadata:
|
|
78
|
+
homepage_uri: https://github.com/RailsComposer/minitest-openapi
|
|
79
|
+
source_code_uri: https://github.com/RailsComposer/minitest-openapi
|
|
80
|
+
changelog_uri: https://github.com/RailsComposer/minitest-openapi/blob/main/CHANGELOG.md
|
|
81
|
+
rubygems_mfa_required: 'true'
|
|
82
|
+
rdoc_options: []
|
|
83
|
+
require_paths:
|
|
84
|
+
- lib
|
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.2'
|
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
requirements: []
|
|
96
|
+
rubygems_version: 3.6.9
|
|
97
|
+
specification_version: 4
|
|
98
|
+
summary: Generate an OpenAPI document from your minitest API tests.
|
|
99
|
+
test_files: []
|