rspec-openapi 0.34.0 → 0.36.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 +4 -4
- data/README.md +101 -0
- data/lib/rspec/openapi/components_updater.rb +49 -24
- data/lib/rspec/openapi/extractors/shared_extractor.rb +42 -6
- data/lib/rspec/openapi/hash_helper.rb +125 -21
- data/lib/rspec/openapi/key_transformer.rb +28 -13
- data/lib/rspec/openapi/minitest_hooks.rb +2 -1
- data/lib/rspec/openapi/nullable_converter.rb +24 -19
- data/lib/rspec/openapi/parallel_records.rb +59 -4
- data/lib/rspec/openapi/partial_run.rb +84 -0
- data/lib/rspec/openapi/record.rb +2 -0
- data/lib/rspec/openapi/result_recorder.rb +24 -10
- data/lib/rspec/openapi/rspec_hooks.rb +11 -3
- data/lib/rspec/openapi/schema_builder/build_context.rb +20 -6
- data/lib/rspec/openapi/schema_builder/variation_merger.rb +58 -20
- data/lib/rspec/openapi/schema_builder.rb +106 -64
- data/lib/rspec/openapi/schema_cleaner.rb +108 -62
- data/lib/rspec/openapi/schema_file.rb +27 -20
- data/lib/rspec/openapi/schema_merger.rb +142 -64
- data/lib/rspec/openapi/schema_sorter.rb +25 -4
- data/lib/rspec/openapi/version.rb +1 -1
- data/lib/rspec/openapi/yaml_dumper.rb +103 -0
- data/lib/rspec/openapi/yaml_loader.rb +118 -0
- data/lib/rspec/openapi.rb +14 -1
- metadata +6 -3
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'hash_helper'
|
|
4
|
+
|
|
3
5
|
# Converts between the internal `nullable: true` form and the 3.1+ JSON Schema
|
|
4
6
|
# null form (`type: [..., 'null']`). The builder and merger only use `nullable`;
|
|
5
7
|
# normalize! runs on read, to_json_schema! on write.
|
|
6
8
|
class << RSpec::OpenAPI::NullableConverter = Object.new
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
# named `type` or `nullable` would be silently mangled.
|
|
10
|
-
DATA_KEYS = [:example, :examples, :default, :enum].freeze
|
|
9
|
+
NULL_VARIANT = { type: 'null' }.freeze
|
|
10
|
+
NULLABLE_VARIANT = { nullable: true }.freeze
|
|
11
11
|
|
|
12
|
+
# Variants come before their oneOf, so a oneOf sees its variants converted.
|
|
12
13
|
def to_json_schema!(node)
|
|
13
|
-
each_schema(node) { |schema| nullable_to_type_null!(schema) }
|
|
14
|
+
RSpec::OpenAPI::HashHelper.each_schema(node) { |schema| nullable_to_type_null!(schema) }
|
|
14
15
|
node
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def normalize!(node)
|
|
18
|
-
each_schema(node) { |schema| type_null_to_nullable!(schema) }
|
|
19
|
+
RSpec::OpenAPI::HashHelper.each_schema(node) { |schema| type_null_to_nullable!(schema) }
|
|
19
20
|
node
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -23,6 +24,7 @@ class << RSpec::OpenAPI::NullableConverter = Object.new
|
|
|
23
24
|
|
|
24
25
|
def nullable_to_type_null!(schema)
|
|
25
26
|
return unless schema.delete(:nullable)
|
|
27
|
+
return insert_null_variant!(schema[:oneOf]) if schema[:oneOf] && !schema.key?(:type)
|
|
26
28
|
|
|
27
29
|
schema[:type] =
|
|
28
30
|
case (type = schema[:type])
|
|
@@ -32,7 +34,23 @@ class << RSpec::OpenAPI::NullableConverter = Object.new
|
|
|
32
34
|
end
|
|
33
35
|
end
|
|
34
36
|
|
|
37
|
+
def insert_null_variant!(variants)
|
|
38
|
+
return if variants.any? { |variant| accepts_null?(variant) }
|
|
39
|
+
|
|
40
|
+
index = variants.index { |variant| variant.is_a?(Hash) && variant[:type].to_s > 'null' } || variants.size
|
|
41
|
+
variants.insert(index, NULL_VARIANT.dup)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def accepts_null?(variant)
|
|
45
|
+
variant.is_a?(Hash) && Array(variant[:type]).include?('null')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A null variant beside a typed oneOf is written back as a variant, so it
|
|
49
|
+
# stays one. Beside a typeless oneOf it folds into the wrapper, where the
|
|
50
|
+
# builder and merger keep nullable.
|
|
35
51
|
def type_null_to_nullable!(schema)
|
|
52
|
+
schema[:nullable] = true if !schema.key?(:type) && schema[:oneOf]&.delete(NULLABLE_VARIANT)
|
|
53
|
+
|
|
36
54
|
case (type = schema[:type])
|
|
37
55
|
when 'null'
|
|
38
56
|
schema.delete(:type)
|
|
@@ -49,17 +67,4 @@ class << RSpec::OpenAPI::NullableConverter = Object.new
|
|
|
49
67
|
schema[:nullable] = true
|
|
50
68
|
end
|
|
51
69
|
end
|
|
52
|
-
|
|
53
|
-
# Yield every schema Hash in the tree, skipping the data subtrees in DATA_KEYS. Each
|
|
54
|
-
# transform mutates only its own node's keys, so the following each still
|
|
55
|
-
# iterates a stable Hash.
|
|
56
|
-
def each_schema(node, &block)
|
|
57
|
-
case node
|
|
58
|
-
when Hash
|
|
59
|
-
yield node
|
|
60
|
-
node.each { |key, value| each_schema(value, &block) unless DATA_KEYS.include?(key) }
|
|
61
|
-
when Array
|
|
62
|
-
node.each { |value| each_schema(value, &block) }
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
70
|
end
|
|
@@ -30,6 +30,16 @@ module RSpec::OpenAPI::ParallelRecords
|
|
|
30
30
|
# its content, so the file itself does not need to survive the trip.
|
|
31
31
|
UPLOADED_FILE_MARKER = '__rspec_openapi_uploaded_file'
|
|
32
32
|
|
|
33
|
+
# Members that hold a builder's lookup, a Proc when a builder is set.
|
|
34
|
+
LOOKUP_MEMBERS = [:formats, :response_read_only, :request_read_only].freeze
|
|
35
|
+
|
|
36
|
+
# Tags an error a lookup raised in a worker. It travels in place of the
|
|
37
|
+
# answers as [LOOKUP_ERROR_MARKER, message], a shape no answer Hash can take.
|
|
38
|
+
LOOKUP_ERROR_MARKER = '__rspec_openapi_lookup_error'
|
|
39
|
+
|
|
40
|
+
# Raised in the main process for an error a lookup raised in a worker.
|
|
41
|
+
class LookupError < StandardError; end
|
|
42
|
+
|
|
33
43
|
class << self
|
|
34
44
|
def worker?
|
|
35
45
|
Process.pid != MAIN_PID
|
|
@@ -52,7 +62,7 @@ module RSpec::OpenAPI::ParallelRecords
|
|
|
52
62
|
|
|
53
63
|
FileUtils.mkdir_p(dump_dir, mode: 0o700)
|
|
54
64
|
encoded = RSpec::OpenAPI.path_records.transform_values do |records|
|
|
55
|
-
records.map { |record|
|
|
65
|
+
records.map { |record| encode_record(answer_lookups(record)) }
|
|
56
66
|
end
|
|
57
67
|
File.write(File.join(dump_dir, "#{Process.pid}.yaml"), YAML.dump(encoded))
|
|
58
68
|
end
|
|
@@ -61,7 +71,7 @@ module RSpec::OpenAPI::ParallelRecords
|
|
|
61
71
|
Dir.glob(File.join(dump_dir, '*.yaml')).sort.each do |file|
|
|
62
72
|
records_by_path = YAML.safe_load(File.read(file), permitted_classes: permitted_classes, aliases: true)
|
|
63
73
|
records_by_path.each do |path, records|
|
|
64
|
-
decoded = records.map { |record|
|
|
74
|
+
decoded = records.map { |record| decode_record(record) }
|
|
65
75
|
RSpec::OpenAPI.path_records[path].concat(decoded)
|
|
66
76
|
end
|
|
67
77
|
end
|
|
@@ -83,8 +93,53 @@ module RSpec::OpenAPI::ParallelRecords
|
|
|
83
93
|
classes
|
|
84
94
|
end
|
|
85
95
|
|
|
86
|
-
def
|
|
87
|
-
RSpec::OpenAPI::Record.new(**record.to_h.transform_values(
|
|
96
|
+
def encode_record(record)
|
|
97
|
+
RSpec::OpenAPI::Record.new(**record.to_h.transform_values { |value| encode(value) })
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Only a lookup member can hold a lookup error, so only there is one looked for.
|
|
101
|
+
def decode_record(record)
|
|
102
|
+
members = record.to_h.to_h do |member, value|
|
|
103
|
+
[member, LOOKUP_MEMBERS.include?(member) ? decode_lookup(value) : decode(value)]
|
|
104
|
+
end
|
|
105
|
+
RSpec::OpenAPI::Record.new(**members)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def decode_lookup(value)
|
|
109
|
+
return value unless value.is_a?(Array) && value.first == LOOKUP_ERROR_MARKER
|
|
110
|
+
|
|
111
|
+
message = value.last
|
|
112
|
+
->(*) { raise LookupError, message }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# No serializer can carry a Proc. Building the schema here asks each lookup
|
|
116
|
+
# about every key the main process will ask about, and the answers travel
|
|
117
|
+
# as a Hash, which the builder reads the same way.
|
|
118
|
+
def answer_lookups(record)
|
|
119
|
+
lookups = LOOKUP_MEMBERS.select { |member| record[member].is_a?(Proc) }
|
|
120
|
+
return record if lookups.empty?
|
|
121
|
+
|
|
122
|
+
answers = lookups.to_h { |member| [member, {}] }
|
|
123
|
+
probe = record.dup
|
|
124
|
+
lookups.each { |member| probe[member] = recording_lookup(member, record[member], answers) }
|
|
125
|
+
begin
|
|
126
|
+
RSpec::OpenAPI::SchemaBuilder.build(probe)
|
|
127
|
+
rescue StandardError, NotImplementedError
|
|
128
|
+
# A value the builder cannot type, or a lookup error kept below. The
|
|
129
|
+
# main process builds this record again and reports the error itself.
|
|
130
|
+
end
|
|
131
|
+
record.dup.tap { |answered| answers.each { |member, value| answered[member] = value } }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The main process cannot call the lookup again, so an error it raises
|
|
135
|
+
# travels in place of its answers, and decode_lookup raises it there.
|
|
136
|
+
def recording_lookup(member, lookup, answers)
|
|
137
|
+
lambda do |key|
|
|
138
|
+
answers[member][key] = lookup.call(key)
|
|
139
|
+
rescue StandardError, NotImplementedError => e
|
|
140
|
+
answers[member] = [LOOKUP_ERROR_MARKER, "#{member} lookup raised #{e.class}: #{e.message}"]
|
|
141
|
+
raise
|
|
142
|
+
end
|
|
88
143
|
end
|
|
89
144
|
|
|
90
145
|
# Uploads hold an open Tempfile, which no serializer can represent, so
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
# Decides whether a run adds and updates the document without removing anything.
|
|
6
|
+
#
|
|
7
|
+
# A run over part of the suite cannot tell an endpoint that was removed from
|
|
8
|
+
# one whose spec did not run, so it removes nothing. Only a run over the whole
|
|
9
|
+
# suite does. RSpec::OpenAPI.partial_update forces either mode.
|
|
10
|
+
module RSpec::OpenAPI::PartialRun
|
|
11
|
+
class << self
|
|
12
|
+
# The spec file of an example that recorded a request. The run is partial
|
|
13
|
+
# when a spec file next to one of these was left out.
|
|
14
|
+
def record_file(path)
|
|
15
|
+
recorded_files << path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def rspec?
|
|
19
|
+
configured = RSpec::OpenAPI.partial_update
|
|
20
|
+
return configured unless configured.nil?
|
|
21
|
+
|
|
22
|
+
parallel_worker? || filtered? || files_left_out?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Minitest tells nothing about the run, so only the option and the
|
|
26
|
+
# parallel_tests marker count.
|
|
27
|
+
def minitest?
|
|
28
|
+
configured = RSpec::OpenAPI.partial_update
|
|
29
|
+
configured == true || (configured.nil? && parallel_worker?)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Printed when the run decided on its own, since the user may expect the
|
|
33
|
+
# removals of a full run.
|
|
34
|
+
def notice(partial)
|
|
35
|
+
return unless partial && RSpec::OpenAPI.partial_update.nil? && recorded_files.any?
|
|
36
|
+
|
|
37
|
+
'rspec-openapi: this run covered part of the suite, so nothing was removed from the document. ' \
|
|
38
|
+
'Run all specs to remove what no longer exists.'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def recorded_files
|
|
44
|
+
@recorded_files ||= Set.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# parallel_tests marks each worker with TEST_ENV_NUMBER, an empty string
|
|
48
|
+
# for the first one. A worker sees part of the files, whichever files it
|
|
49
|
+
# got, so the directory check below cannot tell it from a full run.
|
|
50
|
+
def parallel_worker?
|
|
51
|
+
ENV.key?('TEST_ENV_NUMBER')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# -e, --tag, a line number, --only-failures, and a `fit` once an example
|
|
55
|
+
# carries it. An exclusion such as `--tag ~slow` does not narrow the run.
|
|
56
|
+
def filtered?
|
|
57
|
+
RSpec.configuration.inclusion_filter.rules.any?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `rspec spec/requests/users_spec.rb` leaves out the other files of
|
|
61
|
+
# spec/requests. `rspec spec/requests` and `rspec spec/requests/*_spec.rb`
|
|
62
|
+
# do not, nor does a run that leaves out files of a directory that
|
|
63
|
+
# recorded nothing.
|
|
64
|
+
def files_left_out?
|
|
65
|
+
running = RSpec.configuration.files_to_run
|
|
66
|
+
recorded_files.map { |file| File.dirname(file) }.uniq.any? do |dir|
|
|
67
|
+
(spec_files_in(dir) - running).any?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The spec files directly in dir, by the pattern and exclude pattern of
|
|
72
|
+
# the run.
|
|
73
|
+
def spec_files_in(dir)
|
|
74
|
+
config = RSpec.configuration
|
|
75
|
+
files = Dir[glob(dir, config.pattern)] - Dir[glob(dir, config.exclude_pattern)]
|
|
76
|
+
files.map { |file| File.expand_path(file) }.select { |file| File.dirname(file) == dir }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A pattern may list alternatives, which glob as a brace group like RSpec does.
|
|
80
|
+
def glob(dir, pattern)
|
|
81
|
+
File.join(dir, "{#{pattern.to_s.gsub(/\s*,\s*/, ',')}}")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/rspec/openapi/record.rb
CHANGED
|
@@ -13,6 +13,8 @@ RSpec::OpenAPI::Record = Struct.new(
|
|
|
13
13
|
:summary, # @param [String] - "v1/statuses #show"
|
|
14
14
|
:tags, # @param [Array] - ["Status"]
|
|
15
15
|
:formats, # @param [Proc] - ->(key) { key.end_with?('_at') ? 'date-time' : nil }
|
|
16
|
+
:response_read_only, # @param [Proc] - ->(key) { key == 'id' }, what a response marks readOnly
|
|
17
|
+
:request_read_only, # @param [Proc] - ->(key) { key == 'id' }, what a request may cancel
|
|
16
18
|
:operation_id, # @param [String] - "request-1234"
|
|
17
19
|
:description, # @param [String] - "returns a status"
|
|
18
20
|
:example_key, # @param [String] - "with_flat_query_parameters"
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class RSpec::OpenAPI::ResultRecorder
|
|
4
|
-
|
|
4
|
+
# @param [Boolean] partial add and update without removing anything
|
|
5
|
+
def initialize(path_records, partial: false)
|
|
5
6
|
@path_records = path_records
|
|
7
|
+
@partial = partial
|
|
6
8
|
@error_records = {}
|
|
7
9
|
end
|
|
8
10
|
|
|
@@ -45,19 +47,24 @@ class RSpec::OpenAPI::ResultRecorder
|
|
|
45
47
|
def build_spec(primary, records)
|
|
46
48
|
title = records.first.title
|
|
47
49
|
RSpec::OpenAPI::SchemaFile.new(primary).edit do |spec|
|
|
50
|
+
# The cleanup compares the document with one built from this run alone.
|
|
51
|
+
# Without an existing document nothing can be stale, so the document is
|
|
52
|
+
# that one and the records are merged once.
|
|
53
|
+
new_from_zero = spec.empty? ? spec : {}
|
|
48
54
|
# Normalize an existing file back to the internal form so merge/cleanup is
|
|
49
55
|
# version-agnostic; cleanup_schema! re-applies the target form.
|
|
50
56
|
RSpec::OpenAPI::NullableConverter.normalize!(spec)
|
|
51
57
|
RSpec::OpenAPI::OperationConverter.normalize!(spec)
|
|
52
58
|
schema = RSpec::OpenAPI::DefaultSchema.build(title)
|
|
53
59
|
schema[:info].merge!(RSpec::OpenAPI.info)
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
# SchemaFile#read already symbolized the document, so no merge here needs
|
|
61
|
+
# to normalize it again.
|
|
62
|
+
RSpec::OpenAPI::SchemaMerger.merge_normalized!(spec, schema)
|
|
57
63
|
records.each do |record|
|
|
58
64
|
record_schema = RSpec::OpenAPI::SchemaBuilder.build(record)
|
|
59
|
-
|
|
60
|
-
RSpec::OpenAPI::SchemaMerger.merge_normalized!(new_from_zero, record_schema)
|
|
65
|
+
# The document takes the schema over, so the fresh one gets a copy.
|
|
66
|
+
RSpec::OpenAPI::SchemaMerger.merge_normalized!(new_from_zero, record_schema) unless new_from_zero.equal?(spec)
|
|
67
|
+
RSpec::OpenAPI::SchemaMerger.merge_built!(spec, record_schema)
|
|
61
68
|
rescue StandardError, NotImplementedError => e # e.g. SchemaBuilder raises a NotImplementedError
|
|
62
69
|
@error_records[e] = record # Avoid failing the build
|
|
63
70
|
end
|
|
@@ -67,14 +74,21 @@ class RSpec::OpenAPI::ResultRecorder
|
|
|
67
74
|
end
|
|
68
75
|
|
|
69
76
|
def execute_post_process_hook(path, records, spec)
|
|
70
|
-
|
|
77
|
+
return unless RSpec::OpenAPI.post_process_hook.is_a?(Proc)
|
|
78
|
+
|
|
79
|
+
RSpec::OpenAPI.post_process_hook.call(path, records, spec)
|
|
80
|
+
# The hook may add string keys beside the symbol ones, and the files are
|
|
81
|
+
# written from the keys as they are. Folding them keeps one entry per name.
|
|
82
|
+
spec.replace(RSpec::OpenAPI::KeyTransformer.symbolize(spec))
|
|
71
83
|
end
|
|
72
84
|
|
|
73
85
|
def cleanup_schema!(new_from_zero, spec)
|
|
74
86
|
RSpec::OpenAPI::SchemaCleaner.cleanup_conflicting_security_parameters!(spec)
|
|
75
|
-
RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero)
|
|
76
|
-
RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero)
|
|
77
|
-
|
|
87
|
+
RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero) unless @partial
|
|
88
|
+
RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero, partial: @partial)
|
|
89
|
+
# After the update, which reads the request side's readOnly: false from
|
|
90
|
+
# new_from_zero, the very spec when there was no document yet.
|
|
91
|
+
RSpec::OpenAPI::SchemaCleaner.cleanup_schemas!(spec)
|
|
78
92
|
RSpec::OpenAPI::OperationConverter.to_additional_operations!(spec) if RSpec::OpenAPI.supports_additional_operations?
|
|
79
93
|
RSpec::OpenAPI::SchemaSorter.deep_sort!(spec)
|
|
80
94
|
RSpec::OpenAPI::NullableConverter.to_json_schema!(spec) if RSpec::OpenAPI.json_schema_based?
|
|
@@ -10,16 +10,24 @@ RSpec.configuration.after(:each) do |example|
|
|
|
10
10
|
if RSpec::OpenAPI.example_types.include?(example.metadata[:type]) && example.metadata[:openapi] != false
|
|
11
11
|
path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p }
|
|
12
12
|
record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: SharedHooks.find_extractor)
|
|
13
|
-
|
|
13
|
+
if record
|
|
14
|
+
RSpec::OpenAPI.path_records[path] << record
|
|
15
|
+
RSpec::OpenAPI::PartialRun.record_file(example.metadata[:absolute_file_path])
|
|
16
|
+
end
|
|
14
17
|
end
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
RSpec.configuration.after(:suite) do
|
|
18
|
-
|
|
21
|
+
partial = RSpec::OpenAPI::PartialRun.rspec?
|
|
22
|
+
result_recorder = RSpec::OpenAPI::ResultRecorder.new(RSpec::OpenAPI.path_records, partial: partial)
|
|
19
23
|
result_recorder.record_results!
|
|
24
|
+
reporter = RSpec.configuration.reporter
|
|
25
|
+
if (notice = RSpec::OpenAPI::PartialRun.notice(partial))
|
|
26
|
+
reporter.message notice
|
|
27
|
+
end
|
|
20
28
|
if result_recorder.errors?
|
|
21
29
|
error_message = result_recorder.error_message
|
|
22
30
|
colorizer = RSpec::Core::Formatters::ConsoleCodes
|
|
23
|
-
|
|
31
|
+
reporter.message colorizer.wrap(error_message, :failure)
|
|
24
32
|
end
|
|
25
33
|
end
|
|
@@ -4,16 +4,30 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
4
4
|
# Lookup context threaded through schema building. Bundles the metadata
|
|
5
5
|
# needed to resolve formats, enums, and additionalProperties overrides for
|
|
6
6
|
# a value at a given path under a record's request/response side.
|
|
7
|
-
|
|
7
|
+
#
|
|
8
|
+
# Positional members: one is built per property of every body, and
|
|
9
|
+
# keyword_init would add a keyword hash to each. The path is only ever looked
|
|
10
|
+
# up in the enum and additionalProperties maps of the record, so it is built
|
|
11
|
+
# for a side of a record that holds one.
|
|
12
|
+
BuildContext = Struct.new(:record, :context, :path, :key, :lookups) do
|
|
13
|
+
def self.root(record, context, path = nil, key = nil)
|
|
14
|
+
lookups = record[ENUM_MEMBERS.fetch(context)] ||
|
|
15
|
+
OVERRIDE_MEMBERS.any? { |_kind, members| record[members.fetch(context)] }
|
|
16
|
+
new(record, context, path, key, lookups)
|
|
17
|
+
end
|
|
18
|
+
|
|
8
19
|
def descend(child_key)
|
|
9
|
-
self.class.new(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
self.class.new(record, context, child_path(child_key), child_key, lookups)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def child_path(child_key)
|
|
24
|
+
return nil unless lookups
|
|
25
|
+
|
|
26
|
+
path ? "#{path}.#{child_key}" : child_key.to_s
|
|
13
27
|
end
|
|
14
28
|
|
|
15
29
|
def for_array_items
|
|
16
|
-
self.class.new(record
|
|
30
|
+
self.class.new(record, context, path, nil, lookups)
|
|
17
31
|
end
|
|
18
32
|
end
|
|
19
33
|
private_constant :BuildContext
|
|
@@ -12,7 +12,7 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
12
12
|
# Drop empty `{}` schemas (e.g. items of an empty array) — they carry no
|
|
13
13
|
# type info and would otherwise spuriously mark every property of their
|
|
14
14
|
# populated siblings as nullable via the missing-key nullable rule.
|
|
15
|
-
non_empty = variations.reject(&:empty?)
|
|
15
|
+
non_empty = widen_integers(variations.reject(&:empty?))
|
|
16
16
|
return {} if non_empty.empty?
|
|
17
17
|
return non_empty.first if non_empty.size == 1
|
|
18
18
|
|
|
@@ -27,11 +27,10 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def merge_non_object_item_variations(schemas)
|
|
30
|
-
|
|
31
|
-
typed = schemas.reject(&nullable_only)
|
|
30
|
+
typed = schemas.reject { |schema| nullable_only?(schema) }
|
|
32
31
|
|
|
33
32
|
merged = typed.size == 1 ? typed.first.dup : merge_multi(typed)
|
|
34
|
-
merged[:nullable] = true if schemas.any?(
|
|
33
|
+
merged[:nullable] = true if schemas.any? { |schema| nullable_only?(schema) } && merged.is_a?(Hash)
|
|
35
34
|
merged
|
|
36
35
|
end
|
|
37
36
|
|
|
@@ -43,25 +42,42 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
43
42
|
def merge_property_variations(variations, allow_recursive_merge:)
|
|
44
43
|
property_keys(variations).each_with_object({}) do |key, merged_props|
|
|
45
44
|
all = variations.map { |v| v[:properties]&.[](key) }
|
|
46
|
-
|
|
45
|
+
read_only = lift_read_only!(all)
|
|
46
|
+
prop_variations = all.reject { |p| p.nil? || nullable_only?(p) }
|
|
47
47
|
has_nullable = nullable_present?(all, recursive: allow_recursive_merge)
|
|
48
48
|
|
|
49
49
|
next if prop_variations.empty? && !has_nullable
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
merged = merge_single_property(prop_variations, has_nullable,
|
|
52
|
+
variations_total: variations.size,
|
|
53
|
+
allow_recursive_merge: allow_recursive_merge,)
|
|
54
|
+
merged[:readOnly] = read_only unless read_only.nil?
|
|
55
|
+
merged_props[key] = merged
|
|
54
56
|
end
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
private
|
|
58
60
|
|
|
61
|
+
# The variations of one property come from one side of one exchange, so
|
|
62
|
+
# they agree on readOnly. The merge rebuilds the schema, so the caller puts
|
|
63
|
+
# it back on the result, beside a oneOf like nullable. The schemas are the
|
|
64
|
+
# builder's own, so the flag comes off them in place.
|
|
65
|
+
def lift_read_only!(all)
|
|
66
|
+
read_only = all.find { |p| p.is_a?(Hash) && p.key?(:readOnly) }&.[](:readOnly)
|
|
67
|
+
all.each { |p| p&.delete(:readOnly) } unless read_only.nil?
|
|
68
|
+
read_only
|
|
69
|
+
end
|
|
70
|
+
|
|
59
71
|
def merge_object_variations(variations)
|
|
60
|
-
{
|
|
61
|
-
|
|
62
|
-
properties
|
|
63
|
-
required
|
|
64
|
-
|
|
72
|
+
merged = { type: 'object' }
|
|
73
|
+
if variations.any? { |v| v.key?(:properties) }
|
|
74
|
+
merged[:properties] = merge_property_variations(variations, allow_recursive_merge: true)
|
|
75
|
+
merged[:required] = variations.map { |v| v[:required] || [] }.reduce(:&) || []
|
|
76
|
+
end
|
|
77
|
+
# Every variation sits at one path, so they all carry the same override.
|
|
78
|
+
additional = variations.find { |v| v.key?(:additionalProperties) }
|
|
79
|
+
merged[:additionalProperties] = additional[:additionalProperties] if additional
|
|
80
|
+
merged
|
|
65
81
|
end
|
|
66
82
|
|
|
67
83
|
# Merge multiple array schemas by merging their `items` schemas.
|
|
@@ -70,7 +86,10 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
70
86
|
end
|
|
71
87
|
|
|
72
88
|
def property_keys(variations)
|
|
73
|
-
|
|
89
|
+
keys = []
|
|
90
|
+
variations.each { |v| v[:properties]&.each_key { |key| keys << key } }
|
|
91
|
+
keys.uniq!
|
|
92
|
+
keys
|
|
74
93
|
end
|
|
75
94
|
|
|
76
95
|
# `recursive` mirrors merge_variations' original rule that also treats
|
|
@@ -78,10 +97,14 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
78
97
|
# looks at outright nil or `{ nullable: true }` markers.
|
|
79
98
|
def nullable_present?(all_props, recursive:)
|
|
80
99
|
all_props.any? do |p|
|
|
81
|
-
p.nil? || (p.is_a?(Hash) && (p
|
|
100
|
+
p.nil? || (p.is_a?(Hash) && (nullable_only?(p) || (recursive && p[:nullable] == true)))
|
|
82
101
|
end
|
|
83
102
|
end
|
|
84
103
|
|
|
104
|
+
def nullable_only?(schema)
|
|
105
|
+
schema.size == 1 && schema.key?(:nullable)
|
|
106
|
+
end
|
|
107
|
+
|
|
85
108
|
def merge_single_property(prop_variations, has_nullable, variations_total:, allow_recursive_merge:)
|
|
86
109
|
return { nullable: true } if prop_variations.empty?
|
|
87
110
|
|
|
@@ -112,6 +135,7 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
112
135
|
def merge_multi(prop_variations)
|
|
113
136
|
return { oneOf: flatten_one_of(prop_variations) } if prop_variations.any? { |p| p.key?(:oneOf) }
|
|
114
137
|
|
|
138
|
+
prop_variations = widen_integers(prop_variations)
|
|
115
139
|
prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
|
|
116
140
|
return one_of_schema(prop_variations) if prop_types.size > 1
|
|
117
141
|
|
|
@@ -122,15 +146,23 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
122
146
|
end
|
|
123
147
|
end
|
|
124
148
|
|
|
149
|
+
# Every integer is a number, so integers seen beside numbers are numbers.
|
|
150
|
+
def widen_integers(variations)
|
|
151
|
+
return variations unless variations.any? { |v| v[:type] == 'number' }
|
|
152
|
+
|
|
153
|
+
variations.reject { |v| v[:type] == 'integer' }
|
|
154
|
+
end
|
|
155
|
+
|
|
125
156
|
def flatten_one_of(prop_variations)
|
|
126
|
-
prop_variations.each_with_object([]) do |prop,
|
|
157
|
+
options = prop_variations.each_with_object([]) do |prop, acc|
|
|
127
158
|
clean = without_nullable(prop)
|
|
128
159
|
if clean.key?(:oneOf)
|
|
129
|
-
|
|
160
|
+
acc.concat(clean[:oneOf])
|
|
130
161
|
elsif !clean.empty?
|
|
131
|
-
|
|
162
|
+
acc << clean
|
|
132
163
|
end
|
|
133
|
-
end
|
|
164
|
+
end
|
|
165
|
+
sort_by_type(options.uniq)
|
|
134
166
|
end
|
|
135
167
|
|
|
136
168
|
def without_nullable(prop)
|
|
@@ -138,7 +170,13 @@ class << RSpec::OpenAPI::SchemaBuilder
|
|
|
138
170
|
end
|
|
139
171
|
|
|
140
172
|
def one_of_schema(variations)
|
|
141
|
-
{ oneOf: variations.map { |p| without_nullable(p) }.uniq }
|
|
173
|
+
{ oneOf: sort_by_type(variations.map { |p| without_nullable(p) }.uniq) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The same order the merger keeps, so the document does not depend on which
|
|
177
|
+
# example ran first.
|
|
178
|
+
def sort_by_type(options)
|
|
179
|
+
options.sort_by.with_index { |option, index| [option[:type].to_s, index] }
|
|
142
180
|
end
|
|
143
181
|
end
|
|
144
182
|
|