rspec-openapi 0.35.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.
@@ -3,11 +3,11 @@
3
3
  require 'fileutils'
4
4
  require 'yaml'
5
5
  require 'json'
6
+ require 'stringio'
6
7
 
7
8
  # For Ruby 2.7
8
9
  require 'date'
9
10
 
10
- # TODO: Support JSON
11
11
  class RSpec::OpenAPI::SchemaFile
12
12
  # @param [String] path
13
13
  def initialize(path)
@@ -16,28 +16,24 @@ class RSpec::OpenAPI::SchemaFile
16
16
 
17
17
  # Reads the existing spec, lets the block mutate it, writes the result back
18
18
  # and returns the (symbolized) spec so it can be mirrored to other files.
19
+ # A document that does not load is left as it is.
19
20
  # @return [Hash]
20
- def edit(&block)
21
+ def edit
21
22
  spec = read
22
- block.call(spec)
23
+ begin
24
+ yield spec
25
+ ensure
26
+ write(spec)
27
+ end
23
28
  spec
24
- ensure
25
- write(spec)
26
29
  end
27
30
 
28
31
  # Writes an already-built spec to this file, choosing the format from the
29
- # file extension.
32
+ # file extension. Both formats write the symbol keys as strings themselves.
30
33
  # @param [Hash] spec
31
34
  def write(spec)
32
- stringified = RSpec::OpenAPI::KeyTransformer.stringify(spec)
33
35
  FileUtils.mkdir_p(File.dirname(@path))
34
- output =
35
- if json?
36
- JSON.pretty_generate(stringified)
37
- else
38
- prepend_comment(YAML.dump(stringified))
39
- end
40
- File.write(@path, output)
36
+ File.write(@path, json? ? JSON.pretty_generate(spec) : yaml_document(spec))
41
37
  end
42
38
 
43
39
  private
@@ -46,18 +42,29 @@ class RSpec::OpenAPI::SchemaFile
46
42
  def read
47
43
  return {} unless File.exist?(@path)
48
44
 
49
- content = YAML.safe_load(File.read(@path), permitted_classes: [Date, Time]) # this can also parse JSON
50
- return {} if content.nil?
45
+ content = File.read(@path)
46
+ spec = json? ? read_json(content) : RSpec::OpenAPI::YAMLLoader.load(content)
47
+ spec.nil? ? {} : spec
48
+ end
49
+
50
+ def read_json(content)
51
+ return nil if content.match?(/\A\s*\z/)
51
52
 
52
- RSpec::OpenAPI::KeyTransformer.symbolize(content)
53
+ spec = JSON.parse(content, symbolize_names: true, max_nesting: false)
54
+ RSpec::OpenAPI::KeyTransformer.normalize_example_names!(spec)
53
55
  end
54
56
 
55
- def prepend_comment(content)
56
- return content if RSpec::OpenAPI.comment.nil?
57
+ def yaml_document(spec)
58
+ io = StringIO.new(+'')
59
+ io.write(comment_lines) unless RSpec::OpenAPI.comment.nil?
60
+ RSpec::OpenAPI::YAMLDumper.dump(spec, io)
61
+ io.string
62
+ end
57
63
 
64
+ def comment_lines
58
65
  comment = RSpec::OpenAPI.comment.dup
59
66
  comment << "\n" unless comment.end_with?("\n")
60
- "#{comment.gsub(/^/, '# ').gsub(/^# \n/, "#\n")}#{content}"
67
+ comment.gsub(/^/, '# ').gsub(/^# \n/, "#\n")
61
68
  end
62
69
 
63
70
  def json?
@@ -25,6 +25,16 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
25
25
  merge_schema!(base, RSpec::OpenAPI::KeyTransformer.symbolize(spec))
26
26
  end
27
27
 
28
+ # Same, for a spec built for this merge alone, like the schema of a record.
29
+ # Its keys are symbols already, and its hashes and arrays become part of base
30
+ # rather than being copied, so the caller must not use them afterwards.
31
+ #
32
+ # @param [Hash] base
33
+ # @param [Hash] spec
34
+ def merge_built!(base, spec)
35
+ merge_schema!(base, spec)
36
+ end
37
+
28
38
  SCHEMA_KEYS = [:schema, :schemas, :itemSchema].freeze
29
39
  DATA_KEYS = [:example, :examples, :default, :enum].freeze
30
40
  NUMERIC_TYPES = ['integer', 'number'].freeze
@@ -32,6 +42,8 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
32
42
  :title, :description, :deprecated, :readOnly, :writeOnly, :example, :examples, :default, :externalDocs, :xml,
33
43
  :discriminator,
34
44
  ].freeze
45
+ # Like nullable, readOnly describes the whole value, so it stays beside a oneOf.
46
+ ONE_OF_WRAPPER_KEYS = [:nullable, :readOnly].freeze
35
47
 
36
48
  private
37
49
 
@@ -63,6 +75,8 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
63
75
  def merge_schema_entry!(base, key, value, in_schema)
64
76
  if in_schema && key == :properties && base[key].is_a?(Hash) && value.is_a?(Hash)
65
77
  value.each { |name, property| merge_entry!(base[key], name, property, true) }
78
+ elsif in_schema && key == :readOnly
79
+ merge_read_only!(base, value)
66
80
  elsif DATA_KEYS.include?(key)
67
81
  merge_data!(base, key, value)
68
82
  else
@@ -70,6 +84,12 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
70
84
  end
71
85
  end
72
86
 
87
+ # A property only responses hold is readOnly, one a request sends is not. So
88
+ # false wins, and a side without an opinion leaves the other's.
89
+ def merge_read_only!(base, value)
90
+ base[:readOnly] = value && base.fetch(:readOnly, true)
91
+ end
92
+
73
93
  # Recorded data stays data however it is shaped, so a field named like a
74
94
  # schema key never gets merged as one.
75
95
  def merge_data!(base, key, value)
@@ -137,6 +157,7 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
137
157
 
138
158
  one_of_variants(spec).each { |variant| merge_variant!(base, variant) }
139
159
  base[:nullable] = true if nullable
160
+ merge_read_only!(base, spec[:readOnly]) if spec.key?(:readOnly)
140
161
  base
141
162
  end
142
163
 
@@ -156,7 +177,7 @@ class << RSpec::OpenAPI::SchemaMerger = Object.new
156
177
  end
157
178
 
158
179
  def one_of_variants(schema)
159
- variants = schema[:oneOf] || [schema.reject { |key, _| key == :nullable }]
180
+ variants = schema[:oneOf] || [schema.reject { |key, _| ONE_OF_WRAPPER_KEYS.include?(key) }]
160
181
  variants.reject { |variant| variant.is_a?(Hash) && variant.empty? }
161
182
  end
162
183
 
@@ -23,7 +23,12 @@ class << RSpec::OpenAPI::SchemaSorter = Object.new
23
23
  deep_sort_by_selector!(spec, "#{operation}.responses")
24
24
 
25
25
  # content-type
26
+ deep_sort_by_selector!(spec, "#{operation}.requestBody.content")
26
27
  deep_sort_by_selector!(spec, "#{operation}.responses.*.content")
28
+
29
+ # named examples (keyed by test description)
30
+ deep_sort_by_selector!(spec, "#{operation}.requestBody.content.*.examples")
31
+ deep_sort_by_selector!(spec, "#{operation}.responses.*.content.*.examples")
27
32
  end
28
33
  end
29
34
 
@@ -32,14 +37,29 @@ class << RSpec::OpenAPI::SchemaSorter = Object.new
32
37
  # @param [Hash] base
33
38
  # @param [String] selector
34
39
  def deep_sort_by_selector!(base, selector)
35
- RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths|
36
- deep_sort_hash!(base.dig(*paths))
40
+ RSpec::OpenAPI::HashHelper.each_matched(base, nil, selector) do |_parent, _key, hash, _counterpart|
41
+ deep_sort_hash!(hash)
37
42
  end
38
43
  end
39
44
 
40
45
  # Re-inserting a key moves it to the end, so re-inserting every key in sorted
41
- # order sorts the hash in place.
46
+ # order sorts the hash in place. A document read back from a file is sorted
47
+ # already, so a hash whose keys are in order is left alone, without the two
48
+ # arrays of keys a sort would build. The keys are all symbols, which sort as
49
+ # their names do.
42
50
  def deep_sort_hash!(hash)
43
- hash.keys.sort_by(&:to_s).each { |key| hash[key.to_sym] = hash.delete(key) }
51
+ return if sorted?(hash)
52
+
53
+ hash.keys.sort.each { |key| hash[key] = hash.delete(key) }
54
+ end
55
+
56
+ def sorted?(hash)
57
+ previous = nil
58
+ hash.each_key do |key|
59
+ return false if previous && previous > key
60
+
61
+ previous = key
62
+ end
63
+ true
44
64
  end
45
65
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module OpenAPI
5
- VERSION = '0.35.0'
5
+ VERSION = '0.36.0'
6
6
  end
7
7
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ # Writes a document straight to the libyaml emitter. YAML.dump first builds a
6
+ # tree of Psych nodes and then walks it to make the same emitter calls, so the
7
+ # output is the same without the tree. Keys are written as strings, which is
8
+ # what they are read back as, so the document needs no stringified copy.
9
+ #
10
+ # Nothing in a document is shared, so there are no anchors to find and the
11
+ # bookkeeping for them is skipped.
12
+ class RSpec::OpenAPI::YAMLDumper < Psych::Visitors::YAMLTree
13
+ # @param [Object] object
14
+ # @param [IO, StringIO] io
15
+ def self.dump(object, io)
16
+ dumper = create({}, Psych::Emitter.new(io))
17
+ dumper << object
18
+ dumper.finish
19
+ io
20
+ end
21
+
22
+ # Stands in for the emitter while the superclass writes a string, to keep
23
+ # the arguments of the one scalar call it makes.
24
+ class Recorder
25
+ attr_reader :scalar_arguments
26
+
27
+ def initialize(emitter)
28
+ @emitter = emitter
29
+ end
30
+
31
+ def scalar(value, anchor, tag, plain, quoted, style) # rubocop:disable Metrics/ParameterLists
32
+ # The emitter copies a string of any encoding but UTF-8 on every call.
33
+ value = value.dup.force_encoding(Encoding::UTF_8) if value.encoding != Encoding::UTF_8 && value.ascii_only?
34
+ @scalar_arguments = [value, tag, plain, quoted, style].freeze
35
+ @emitter.scalar(value, anchor, tag, plain, quoted, style)
36
+ end
37
+ end
38
+ private_constant :Recorder
39
+
40
+ def initialize(emitter, scanner, options)
41
+ super
42
+ # How a string is written takes several regexp matches to decide, and a
43
+ # document repeats its keys and the names of its types thousands of times.
44
+ # So the decision is kept by key, which saves the string of the key too,
45
+ # and by string.
46
+ @key_scalars = {}.compare_by_identity
47
+ @string_scalars = {}
48
+ end
49
+
50
+ def visit_Hash(hash) # rubocop:disable Naming/MethodName
51
+ @emitter.start_mapping(nil, nil, true, Psych::Nodes::Mapping::BLOCK)
52
+ hash.each do |key, value|
53
+ emit_key(key)
54
+ accept(value)
55
+ end
56
+ @emitter.end_mapping
57
+ end
58
+
59
+ def visit_Array(array) # rubocop:disable Naming/MethodName
60
+ @emitter.start_sequence(nil, nil, true, Psych::Nodes::Sequence::BLOCK)
61
+ array.each { |item| accept(item) }
62
+ @emitter.end_sequence
63
+ end
64
+
65
+ def visit_String(string) # rubocop:disable Naming/MethodName
66
+ scalar = @string_scalars[string]
67
+ return emit_scalar(scalar) if scalar
68
+
69
+ @string_scalars[string] = recording { super }
70
+ end
71
+
72
+ private
73
+
74
+ def emit_key(key)
75
+ scalar = @key_scalars[key]
76
+ return emit_scalar(scalar) if scalar
77
+
78
+ name = key.to_s
79
+ visit_String(name)
80
+ @key_scalars[key] = @string_scalars[name]
81
+ end
82
+
83
+ def emit_scalar(scalar)
84
+ @emitter.scalar(scalar[0], nil, scalar[1], scalar[2], scalar[3], scalar[4])
85
+ end
86
+
87
+ def recording
88
+ emitter = @emitter
89
+ @emitter = recorder = Recorder.new(emitter)
90
+ yield
91
+ recorder.scalar_arguments
92
+ ensure
93
+ @emitter = emitter
94
+ end
95
+
96
+ # :nocov:
97
+ # Reached by values other than hashes, arrays and plain scalars, such as the
98
+ # dates of a hand-written document, which the suite does not hold.
99
+ def register(_target, yaml_obj)
100
+ yaml_obj
101
+ end
102
+ # :nocov:
103
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'date'
5
+
6
+ # Builds the document with symbol keys straight from the parser events.
7
+ # YAML.safe_load builds a tree of Psych nodes first and hands back string keys,
8
+ # which KeyTransformer.symbolize then converted in a copy of the whole document.
9
+ # The names of examples are normalized on the way, as that copy did.
10
+ #
11
+ # A key names an entry, so it is a string however it reads: `200:` is the name
12
+ # of a response, not a number. Resolving it as YAML.safe_load does would give
13
+ # a key with no name to take.
14
+ #
15
+ # Anchors, tags and merge keys are left to YAML.safe_load, which accepts or
16
+ # rejects them as it always did. A valid alias follows its anchor, so only a
17
+ # dangling one gets this far, and it is handed off as well.
18
+ class RSpec::OpenAPI::YAMLLoader < Psych::Handler
19
+ class Unsupported < StandardError; end
20
+ private_constant :Unsupported
21
+
22
+ PERMITTED_CLASSES = [Date, Time].freeze
23
+ # Marks a mapping waiting for its next key.
24
+ NONE = Object.new.freeze
25
+ private_constant :NONE
26
+
27
+ # @param [String] yaml
28
+ # @return [Object, nil] the first document, or nil when there is none
29
+ def self.load(yaml)
30
+ loader = new
31
+ Psych::Parser.new(loader).parse(yaml)
32
+ loader.root
33
+ rescue Unsupported
34
+ RSpec::OpenAPI::KeyTransformer.symbolize(YAML.safe_load(yaml, permitted_classes: PERMITTED_CLASSES))
35
+ end
36
+
37
+ def initialize
38
+ super
39
+ class_loader = Psych::ClassLoader::Restricted.new(PERMITTED_CLASSES.map(&:name), [])
40
+ @scanner = Psych::ScalarScanner.new(class_loader)
41
+ @documents = []
42
+ @containers = [@documents]
43
+ # Per open mapping: the key waiting for its value, and whether the mapping
44
+ # holds named examples.
45
+ @pending_keys = []
46
+ @examples_maps = []
47
+ end
48
+
49
+ def root
50
+ @documents.first
51
+ end
52
+
53
+ def scalar(value, anchor, tag, _plain, quoted, _style) # rubocop:disable Metrics/ParameterLists
54
+ supported!(anchor, tag, value)
55
+ value = @scanner.tokenize(value) unless quoted || key_next?
56
+ add(value)
57
+ end
58
+
59
+ def start_sequence(anchor, tag, _implicit, _style)
60
+ supported!(anchor, tag)
61
+ @containers.push(add([]))
62
+ end
63
+
64
+ def end_sequence
65
+ @containers.pop
66
+ end
67
+
68
+ def start_mapping(anchor, tag, _implicit, _style)
69
+ supported!(anchor, tag)
70
+ @examples_maps.push(@containers.last.is_a?(Hash) && @pending_keys.last == :examples)
71
+ @containers.push(add({}))
72
+ @pending_keys.push(NONE)
73
+ end
74
+
75
+ def end_mapping
76
+ @containers.pop
77
+ @pending_keys.pop
78
+ @examples_maps.pop
79
+ end
80
+
81
+ def alias(_anchor)
82
+ raise Unsupported
83
+ end
84
+
85
+ private
86
+
87
+ # A `<<` key merges its value into the mapping, so it is handed off too.
88
+ def supported!(anchor, tag, value = nil)
89
+ raise Unsupported if anchor || tag || value == '<<'
90
+ end
91
+
92
+ # A sequence inside a mapping pushes no pending key, so the mapping's NONE
93
+ # shows through it and the container is checked as well.
94
+ def key_next?
95
+ @pending_keys.last.equal?(NONE) && @containers.last.is_a?(Hash)
96
+ end
97
+
98
+ # Values arrive in document order: onto the open sequence, or as the key and
99
+ # then the value of the open mapping. The documents are the outermost
100
+ # sequence.
101
+ def add(value)
102
+ container = @containers.last
103
+ if container.is_a?(Array)
104
+ container << value
105
+ elsif @pending_keys.last.equal?(NONE)
106
+ @pending_keys[-1] = key_for(value)
107
+ else
108
+ container[@pending_keys[-1]] = value
109
+ @pending_keys[-1] = NONE
110
+ end
111
+ value
112
+ end
113
+
114
+ def key_for(key)
115
+ key = RSpec::OpenAPI::ExampleKey.normalize(key) if @examples_maps.last && key.is_a?(String)
116
+ key.to_sym
117
+ end
118
+ end
data/lib/rspec/openapi.rb CHANGED
@@ -9,6 +9,7 @@ require 'rspec/openapi/stream_parser'
9
9
  require 'rspec/openapi/record_builder'
10
10
  require 'rspec/openapi/exchange_recorder'
11
11
  require 'rspec/openapi/parallel_records'
12
+ require 'rspec/openapi/partial_run'
12
13
  require 'rspec/openapi/result_recorder'
13
14
  require 'rspec/openapi/schema_builder'
14
15
  require 'rspec/openapi/schema_file'
@@ -17,6 +18,8 @@ require 'rspec/openapi/schema_merger'
17
18
  require 'rspec/openapi/schema_cleaner'
18
19
  require 'rspec/openapi/schema_sorter'
19
20
  require 'rspec/openapi/key_transformer'
21
+ require 'rspec/openapi/yaml_dumper'
22
+ require 'rspec/openapi/yaml_loader'
20
23
  require 'rspec/openapi/shared_hooks'
21
24
  require 'rspec/openapi/extractors'
22
25
  require 'rspec/openapi/extractors/shared_extractor'
@@ -32,6 +35,11 @@ module RSpec::OpenAPI
32
35
  'text/event-stream',
33
36
  ].freeze
34
37
 
38
+ # Properties a server assigns by convention, for read_only_builder. Foreign
39
+ # keys such as `user_id` are left out, since clients send them.
40
+ DEFAULT_READ_ONLY_KEYS = ['id', 'uuid', 'created_at', 'updated_at'].freeze
41
+ DEFAULT_READ_ONLY_BUILDER = ->(_example, key) { DEFAULT_READ_ONLY_KEYS.include?(key) }
42
+
35
43
  class Config
36
44
  class << self
37
45
  attr_accessor :debug_enabled
@@ -52,6 +60,7 @@ module RSpec::OpenAPI
52
60
  @summary_builder = ->(example) { example.metadata[:summary] }
53
61
  @tags_builder = ->(example) { example.metadata[:tags] }
54
62
  @formats_builder = ->(example) { example.metadata[:formats] }
63
+ @read_only_builder = nil
55
64
  @info = {}
56
65
  @application_version = '1.0.0'
57
66
  @openapi_version = '3.2.0'
@@ -65,6 +74,8 @@ module RSpec::OpenAPI
65
74
  @ignored_path_params = [:controller, :action, :format]
66
75
  @ignored_paths = []
67
76
  @post_process_hook = nil
77
+ # true or false forces the mode. nil lets each run decide, see PartialRun.
78
+ @partial_update = { '1' => true, '0' => false }[ENV.fetch('OPENAPI_PARTIAL_UPDATE', nil)]
68
79
 
69
80
  # This is the configuraion override file name we look for within each path.
70
81
  @config_filename = 'rspec_openapi.rb'
@@ -80,6 +91,7 @@ module RSpec::OpenAPI
80
91
  :summary_builder,
81
92
  :tags_builder,
82
93
  :formats_builder,
94
+ :read_only_builder,
83
95
  :info,
84
96
  :application_version,
85
97
  :request_headers,
@@ -91,7 +103,8 @@ module RSpec::OpenAPI
91
103
  :path_records,
92
104
  :ignored_paths,
93
105
  :ignored_path_params,
94
- :post_process_hook
106
+ :post_process_hook,
107
+ :partial_update
95
108
 
96
109
  attr_reader :config_filename, :openapi_version
97
110
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-openapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.35.0
4
+ version: 0.36.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Takashi Kokubun
@@ -78,6 +78,7 @@ files:
78
78
  - lib/rspec/openapi/nullable_converter.rb
79
79
  - lib/rspec/openapi/operation_converter.rb
80
80
  - lib/rspec/openapi/parallel_records.rb
81
+ - lib/rspec/openapi/partial_run.rb
81
82
  - lib/rspec/openapi/record.rb
82
83
  - lib/rspec/openapi/record_builder.rb
83
84
  - lib/rspec/openapi/result_recorder.rb
@@ -92,6 +93,8 @@ files:
92
93
  - lib/rspec/openapi/shared_hooks.rb
93
94
  - lib/rspec/openapi/stream_parser.rb
94
95
  - lib/rspec/openapi/version.rb
96
+ - lib/rspec/openapi/yaml_dumper.rb
97
+ - lib/rspec/openapi/yaml_loader.rb
95
98
  - rspec-openapi.gemspec
96
99
  homepage: https://github.com/exoego/rspec-openapi
97
100
  licenses:
@@ -99,7 +102,7 @@ licenses:
99
102
  metadata:
100
103
  homepage_uri: https://github.com/exoego/rspec-openapi
101
104
  source_code_uri: https://github.com/exoego/rspec-openapi
102
- changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.35.0
105
+ changelog_uri: https://github.com/exoego/rspec-openapi/releases/tag/v0.36.0
103
106
  rubygems_mfa_required: 'true'
104
107
  rdoc_options: []
105
108
  require_paths: