openapi-sourcetools 0.4.2 → 0.5.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/LICENSE.txt +1 -1
- data/bin/openapi-addheaders +73 -0
- data/bin/openapi-addresponses +72 -0
- data/bin/openapi-addschemas +104 -0
- data/bin/openapi-checkschemas +217 -0
- data/bin/openapi-frequencies +57 -84
- data/bin/openapi-merge +63 -81
- data/bin/openapi-processpaths +61 -88
- data/lib/apiobjects.rb +304 -0
- data/lib/common.rb +53 -27
- metadata +18 -8
- data/bin/openapi-generatecode +0 -128
data/bin/openapi-merge
CHANGED
@@ -1,60 +1,14 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
# frozen_string_literal: true
|
3
3
|
|
4
|
-
# Copyright © 2021 Ismo Kärkkäinen
|
4
|
+
# Copyright © 2021-2024 Ismo Kärkkäinen
|
5
5
|
# Licensed under Universal Permissive License. See LICENSE.txt.
|
6
6
|
|
7
|
-
require_relative '../lib/common
|
7
|
+
require_relative '../lib/common'
|
8
8
|
require 'optparse'
|
9
9
|
require 'yaml'
|
10
|
-
require 'json'
|
11
10
|
require 'set'
|
12
11
|
|
13
|
-
|
14
|
-
default_env(:out, '')
|
15
|
-
default_env(:format, 'YAML')
|
16
|
-
default_env(:prune, false);
|
17
|
-
|
18
|
-
ENV['POSIXLY_CORRECT'] = '1'
|
19
|
-
parser = OptionParser.new do |opts|
|
20
|
-
opts.summary_indent = ' '
|
21
|
-
opts.summary_width = 26
|
22
|
-
opts.banner = 'Usage: openapi-merge [options] sources...'
|
23
|
-
opts.separator ''
|
24
|
-
opts.separator 'Options (equivalent environment variable and value in parentheses):'
|
25
|
-
opts.on('-o', '--output FILE', 'Output result to FILE, not stdout (OUT=FILE).') do |f|
|
26
|
-
env(:out, f)
|
27
|
-
end
|
28
|
-
opts.on('-p', '--prune', 'Remove all unreferenced objects under components.') do
|
29
|
-
env(:prune, true)
|
30
|
-
end
|
31
|
-
opts.on('--yaml', 'Output format is YAML (default).') do
|
32
|
-
env(:format, 'YAML')
|
33
|
-
end
|
34
|
-
opts.on('--json', 'Output format is JSON.') do
|
35
|
-
env(:format, 'JSON')
|
36
|
-
end
|
37
|
-
opts.on('-h', '--help', 'Print this help and exit.') do
|
38
|
-
$stdout.puts %(#{opts}
|
39
|
-
|
40
|
-
Source files are combined to form one API specification document. Sources are
|
41
|
-
allowed only to append to the merged document, not re-define anything in it.
|
42
|
-
)
|
43
|
-
exit 0
|
44
|
-
end
|
45
|
-
end
|
46
|
-
parser.parse!
|
47
|
-
|
48
|
-
aargh("Format neither JSON nor YAML: #{env(:format)}", 1) unless %w[JSON YAML].include? env(:format)
|
49
|
-
|
50
|
-
def read_source(filename)
|
51
|
-
YAML.safe_load(File.read(filename))
|
52
|
-
rescue Errno::ENOENT => e
|
53
|
-
aargh("Could not read #{filename}", 2)
|
54
|
-
rescue StandardError => e
|
55
|
-
aargh(e.to_s, 3)
|
56
|
-
end
|
57
|
-
|
58
12
|
def raise_se(message)
|
59
13
|
raise StandardError, message
|
60
14
|
end
|
@@ -74,12 +28,12 @@ end
|
|
74
28
|
|
75
29
|
def add_undefined(merged, incoming, filename, path, max_depths)
|
76
30
|
incoming.each_pair do |key, value|
|
77
|
-
unless merged.
|
31
|
+
unless merged.key? key
|
78
32
|
merged[key] = value
|
79
33
|
next
|
80
34
|
end
|
81
35
|
m = merged[key]
|
82
|
-
raise_se
|
36
|
+
raise_se("Path #{path_combo(path, false)} merged type #{m.class} differs from type #{value.class} in #{filename}") unless m.instance_of?(value.class)
|
83
37
|
raise_se("Re-definition of #{key} #{path_combo(path)} in #{filename}") if too_deep(path, max_depths)
|
84
38
|
if m.is_a? Hash # paths or similar
|
85
39
|
path.push key
|
@@ -94,22 +48,6 @@ def add_undefined(merged, incoming, filename, path, max_depths)
|
|
94
48
|
raise_se "Re-definition of #{key} #{path_combo(path)} in #{filename}"
|
95
49
|
end
|
96
50
|
end
|
97
|
-
rescue StandardError => e
|
98
|
-
aargh(e.to_s, 3)
|
99
|
-
end
|
100
|
-
|
101
|
-
max_depths = Hash.new(0)
|
102
|
-
max_depths['openapi'] = 1
|
103
|
-
max_depths['info'] = 1
|
104
|
-
max_depths['servers'] = 1
|
105
|
-
max_depths['paths'] = 2 # Allows get, post, options, etc. from different files.
|
106
|
-
max_depths['webhooks'] = 2
|
107
|
-
max_depths['components'] = 2
|
108
|
-
max_depths['security'] = 1
|
109
|
-
max_depths['tags'] = 1
|
110
|
-
merged = {}
|
111
|
-
ARGV.each do |filename|
|
112
|
-
add_undefined(merged, read_source(filename), filename, [], max_depths)
|
113
51
|
end
|
114
52
|
|
115
53
|
def gather_refs(doc, found)
|
@@ -126,11 +64,16 @@ def gather_refs(doc, found)
|
|
126
64
|
end
|
127
65
|
end
|
128
66
|
|
129
|
-
|
67
|
+
def prune(merged)
|
130
68
|
prev_refs = Set.new
|
131
69
|
loop do # May have references from deleted so repeat until nothing deleted.
|
132
70
|
refs = Set.new
|
133
71
|
gather_refs(merged, refs)
|
72
|
+
merged.fetch('security', []).each do |sec|
|
73
|
+
sec.each_key do |key|
|
74
|
+
refs.add("#/components/securitySchemes/#{key}")
|
75
|
+
end
|
76
|
+
end
|
134
77
|
used = {}
|
135
78
|
all = merged.fetch('components', {})
|
136
79
|
refs.each do |ref|
|
@@ -145,7 +88,7 @@ if env(:prune)
|
|
145
88
|
sub = used
|
146
89
|
p.each_index do |k|
|
147
90
|
if k + 1 < p.size
|
148
|
-
sub[p[k]] = {} unless sub.
|
91
|
+
sub[p[k]] = {} unless sub.key? p[k]
|
149
92
|
sub = sub[p[k]]
|
150
93
|
else
|
151
94
|
sub[p[k]] = item
|
@@ -158,19 +101,58 @@ if env(:prune)
|
|
158
101
|
end
|
159
102
|
end
|
160
103
|
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
104
|
+
def main
|
105
|
+
output_file = nil
|
106
|
+
keep = false
|
107
|
+
|
108
|
+
ENV['POSIXLY_CORRECT'] = '1'
|
109
|
+
parser = OptionParser.new do |opts|
|
110
|
+
opts.summary_indent = ' '
|
111
|
+
opts.summary_width = 26
|
112
|
+
opts.banner = 'Usage: openapi-merge [options] sources...'
|
113
|
+
opts.separator ''
|
114
|
+
opts.separator 'Options:'
|
115
|
+
opts.on('-o', '--output FILE', 'Output result to FILE, not stdout.') do |f|
|
116
|
+
output_file = f
|
117
|
+
end
|
118
|
+
opts.on('-k', '--keep', 'Keep all unreferenced objects under components.') do
|
119
|
+
keep = true
|
120
|
+
end
|
121
|
+
opts.on('-p', '--prune', 'Prune all unreferenced objects under components (default).') do
|
122
|
+
keep = false
|
123
|
+
end
|
124
|
+
opts.on('-h', '--help', 'Print this help and exit.') do
|
125
|
+
$stdout.puts %(#{opts}
|
126
|
+
|
127
|
+
Source files are combined to form one API specification document. Sources are
|
128
|
+
allowed only to append to the merged document, not re-define anything in it.
|
129
|
+
)
|
130
|
+
exit 0
|
131
|
+
end
|
132
|
+
end
|
133
|
+
parser.parse!
|
134
|
+
|
135
|
+
max_depths = Hash.new(0)
|
136
|
+
max_depths['openapi'] = 1
|
137
|
+
max_depths['info'] = 1
|
138
|
+
max_depths['servers'] = 1
|
139
|
+
max_depths['paths'] = 2 # Allows get, post, etc. from different files.
|
140
|
+
max_depths['webhooks'] = 2
|
141
|
+
max_depths['components'] = 2
|
142
|
+
max_depths['security'] = 1
|
143
|
+
max_depths['tags'] = 1
|
144
|
+
merged = {}
|
145
|
+
ARGV.each do |filename|
|
146
|
+
s = load_source(filename)
|
147
|
+
return 2 if s.nil?
|
148
|
+
add_undefined(merged, s, filename, [], max_depths)
|
149
|
+
rescue StandardError => e
|
150
|
+
return aargh(e.to_s, 4)
|
169
151
|
end
|
170
|
-
end
|
171
152
|
|
172
|
-
|
173
|
-
|
174
|
-
|
153
|
+
prune(merged) unless keep
|
154
|
+
|
155
|
+
dump_result(output_file, YAML.dump(merged), 3)
|
175
156
|
end
|
176
|
-
|
157
|
+
|
158
|
+
exit(main) if (defined? $unit_test).nil?
|
data/bin/openapi-processpaths
CHANGED
@@ -1,111 +1,84 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
# frozen_string_literal: true
|
3
3
|
|
4
|
-
# Copyright © 2021 Ismo Kärkkäinen
|
4
|
+
# Copyright © 2021-2024 Ismo Kärkkäinen
|
5
5
|
# Licensed under Universal Permissive License. See LICENSE.txt.
|
6
6
|
|
7
|
-
require_relative '../lib/common
|
7
|
+
require_relative '../lib/common'
|
8
8
|
require 'optparse'
|
9
9
|
require 'yaml'
|
10
|
-
require 'json'
|
11
10
|
|
12
11
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
12
|
+
def main
|
13
|
+
input_name = nil
|
14
|
+
output_name = nil
|
15
|
+
error = true
|
17
16
|
|
18
|
-
parser = OptionParser.new do |opts|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
end
|
36
|
-
opts.on('--json', 'Output format is JSON. (FORMAT=JSON)') do
|
37
|
-
env(:format, 'JSON')
|
38
|
-
end
|
39
|
-
opts.on('-h', '--help', 'Print this help and exit.') do
|
40
|
-
$stdout.puts %(#{opts}
|
17
|
+
parser = OptionParser.new do |opts|
|
18
|
+
opts.summary_indent = ' '
|
19
|
+
opts.summary_width = 24
|
20
|
+
opts.banner = 'Usage: openapi-processpaths [options]'
|
21
|
+
opts.separator ''
|
22
|
+
opts.separator 'Options:'
|
23
|
+
opts.on('-i', '--input FILE', 'Read source from FILE, not stdin.') do |f|
|
24
|
+
input_name = f
|
25
|
+
end
|
26
|
+
opts.on('-o', '--output FILE', 'Output result to FILE, not stdout.') do |f|
|
27
|
+
output_name = f
|
28
|
+
end
|
29
|
+
opts.on('--warn', 'Only warn of paths with same fixed parts.') do
|
30
|
+
error = false
|
31
|
+
end
|
32
|
+
opts.on('-h', '--help', 'Print this help and exit.') do
|
33
|
+
$stdout.puts %(#{opts}
|
41
34
|
|
42
35
|
Processes API specification document path objects into form that is expected by
|
43
36
|
later stage tools. Checks for paths that may be ambiguous.
|
44
37
|
)
|
45
|
-
|
38
|
+
exit 0
|
39
|
+
end
|
46
40
|
end
|
47
|
-
|
48
|
-
parser.parse!
|
41
|
+
parser.parse!
|
49
42
|
|
50
|
-
|
51
|
-
|
52
|
-
def read_source(filename)
|
53
|
-
YAML.safe_load(filename.empty? ? $stdin : File.read(filename))
|
54
|
-
rescue Errno::ENOENT => e
|
55
|
-
aargh("Could not read #{filename.empty? ? 'stdin' : filename}", 2)
|
56
|
-
rescue StandardError => e
|
57
|
-
aargh(e.to_s, 3)
|
58
|
-
end
|
59
|
-
doc = read_source(env(:in))
|
43
|
+
doc = load_source(input_name)
|
44
|
+
return 2 if doc.nil?
|
60
45
|
|
61
|
-
processed = {}
|
62
|
-
doc.fetch('paths', {}).each_pair do |path, value|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
end
|
71
|
-
|
72
|
-
# Find lookalike sets.
|
73
|
-
lookalikes = false
|
74
|
-
paths = processed.keys.sort
|
75
|
-
paths.each_index do |k|
|
76
|
-
pk = paths[k]
|
77
|
-
a = processed[pk]
|
78
|
-
(0...k).each do |n|
|
79
|
-
pn = paths[n]
|
80
|
-
b = processed[pn]
|
81
|
-
next unless (a[:path].compare(b[:path])) == 0
|
82
|
-
a['lookalike'].push pn
|
83
|
-
b['lookalike'].push pk
|
84
|
-
$stderr.puts("Similar: #{pn} #{pk}")
|
85
|
-
lookalikes = true
|
46
|
+
processed = {}
|
47
|
+
doc.fetch('paths', {}).each_pair do |path, value|
|
48
|
+
parts = split_path(path, true)
|
49
|
+
processed[path] = {
|
50
|
+
'parts' => parts,
|
51
|
+
'orig' => value,
|
52
|
+
'lookalike' => [],
|
53
|
+
path: ServerPath.new(parts)
|
54
|
+
}
|
86
55
|
end
|
87
|
-
end
|
88
|
-
aargh('Similar paths found.', 7) if lookalikes && env(:error)
|
89
56
|
|
90
|
-
#
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
57
|
+
# Find lookalike sets.
|
58
|
+
lookalikes = false
|
59
|
+
paths = processed.keys.sort!
|
60
|
+
paths.size.times do |k|
|
61
|
+
pk = paths[k]
|
62
|
+
a = processed[pk]
|
63
|
+
k.times do |n|
|
64
|
+
pn = paths[n]
|
65
|
+
b = processed[pn]
|
66
|
+
next unless a[:path].compare(b[:path]).zero?
|
67
|
+
a['lookalike'].push pn
|
68
|
+
b['lookalike'].push pk
|
69
|
+
$stderr.puts("Similar: #{pn} #{pk}")
|
70
|
+
lookalikes = true
|
71
|
+
end
|
72
|
+
end
|
73
|
+
return aargh('Similar paths found.', 4) if lookalikes && error
|
95
74
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
else
|
100
|
-
begin
|
101
|
-
output = File.open(output, 'w')
|
102
|
-
rescue StandardError
|
103
|
-
aargh("Failed to open for writing: #{output}", 6)
|
75
|
+
# Remove temporary fields.
|
76
|
+
processed.each_value do |v|
|
77
|
+
v.keys.each { |k| v.delete(k) if k.is_a? Symbol }
|
104
78
|
end
|
105
|
-
|
79
|
+
doc['paths'] = processed
|
106
80
|
|
107
|
-
|
108
|
-
when 'JSON' then output.puts JSON.generate(doc)
|
109
|
-
when 'YAML' then output.puts YAML.dump(doc)
|
81
|
+
dump_result(output_name, YAML.dump(doc), 3)
|
110
82
|
end
|
111
|
-
|
83
|
+
|
84
|
+
exit(main) if (defined? $unit_test).nil?
|
data/lib/apiobjects.rb
ADDED
@@ -0,0 +1,304 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative './common'
|
4
|
+
require 'set'
|
5
|
+
|
6
|
+
def same(a, b, ignored_keys = Set.new(%w[summary description]))
|
7
|
+
return a == b unless a.is_a?(Hash) && b.is_a?(Hash)
|
8
|
+
keys = Set.new(a.keys + b.keys) - ignored_keys
|
9
|
+
keys.to_a.each do |k|
|
10
|
+
return false unless a.key?(k) && b.key?(k)
|
11
|
+
return false unless same(a[k], b[k], ignored_keys)
|
12
|
+
end
|
13
|
+
true
|
14
|
+
end
|
15
|
+
|
16
|
+
def ref_string(name, schema_path)
|
17
|
+
"#{schema_path}/#{name}"
|
18
|
+
end
|
19
|
+
|
20
|
+
def reference(obj, schemas, schema_path, ignored_keys = Set.new(%w[summary description]), prefix = 'Schema')
|
21
|
+
# Check if identical schema has been added and if so, return the $ref string.
|
22
|
+
schemas.keys.sort.each do |k|
|
23
|
+
return ref_string(k, schema_path) if same(obj, schemas[k], ignored_keys)
|
24
|
+
end
|
25
|
+
# One of the numbers will not match existing keys. More number than keys.
|
26
|
+
(schemas.size + 1).times do |n|
|
27
|
+
# 'x' is to simplify find and replace (Schema1x vs Schema1 and Schema10)
|
28
|
+
k = "#{prefix}#{n}x"
|
29
|
+
next if schemas.key?(k)
|
30
|
+
schemas[k] = obj.merge
|
31
|
+
return ref_string(k, schema_path)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class Components
|
36
|
+
attr_reader :path, :prefix
|
37
|
+
attr_accessor :items, :ignored_keys
|
38
|
+
|
39
|
+
def initialize(path, prefix, ignored_keys = %w[summary description examples example])
|
40
|
+
@items = {}
|
41
|
+
path = "#/#{path.join('/')}/" if path.is_a?(Array)
|
42
|
+
path = "#{path}/" unless path.end_with?('/')
|
43
|
+
@path = path
|
44
|
+
@prefix = prefix
|
45
|
+
@ignored_keys = Set.new(ignored_keys)
|
46
|
+
end
|
47
|
+
|
48
|
+
def add_options(opts)
|
49
|
+
opts.on('--use FIELD', 'Use FIELD in comparisons.') do |f|
|
50
|
+
@ignored_keys.delete(f)
|
51
|
+
end
|
52
|
+
opts.on('--ignore FIELD', 'Ignore FIELD in comparisons.') do |f|
|
53
|
+
@ignored_keys.add(f)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def help
|
58
|
+
%(All fields are used in object equality comparisons except:\n#{@ignored_keys.to_a.sort!.join("\n")})
|
59
|
+
end
|
60
|
+
|
61
|
+
def ref_string(name)
|
62
|
+
"#{@path}#{name}"
|
63
|
+
end
|
64
|
+
|
65
|
+
def reference(obj)
|
66
|
+
# Check if identical schema has been added. If so, return the $ref string.
|
67
|
+
@items.each do |k, v|
|
68
|
+
return ref_string(k) if same(obj, v, @ignored_keys)
|
69
|
+
end
|
70
|
+
# One of the numbers will not match existing keys. More number than keys.
|
71
|
+
(@items.size + 1).times do |n|
|
72
|
+
# 'x' is to simplify find and replace (Schema1x vs Schema1 and Schema10)
|
73
|
+
cand = "#{@prefix}#{n}x"
|
74
|
+
next if @items.key?(cand)
|
75
|
+
@items[cand] = obj.merge
|
76
|
+
return ref_string(cand)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
|
82
|
+
class PathOperation
|
83
|
+
attr_accessor :path, :operation, :info, :parameters
|
84
|
+
attr_accessor :servers, :security, :tags
|
85
|
+
attr_accessor :summary, :description
|
86
|
+
end
|
87
|
+
|
88
|
+
# Should mapping JSON schema types to native types be a separate step?
|
89
|
+
# Just add x-openapi-sourcetools-native: nativetype
|
90
|
+
# Separate program that can pipe modified spec out to code generation?
|
91
|
+
|
92
|
+
# JSON schema is way too complex to simplify here. One could have a convenience
|
93
|
+
# method that determines how many bytes the value needs, and if it needs to be
|
94
|
+
# signed.
|
95
|
+
|
96
|
+
# When creating types for schemas or otherwise, the type name can be added
|
97
|
+
# into the item and that way be used as an indicator that the type has been
|
98
|
+
# declared or needs a declaration.
|
99
|
+
|
100
|
+
def make_path_operations(apidoc)
|
101
|
+
# Check openapi
|
102
|
+
# Store info as is for reference
|
103
|
+
# Store servers as is for default value for PathOperation
|
104
|
+
# Process components. Lazy manner, only when referenced.
|
105
|
+
# Store security as is for default value for PathOperation.
|
106
|
+
# Store tags as mapping from name to object for use with PathOperation.
|
107
|
+
# Process paths:
|
108
|
+
# Store parameters as is for default value for PathOperation.
|
109
|
+
# All other fields, check if it looks like OperationObject and create a
|
110
|
+
# PathOperation using it. For others, store as is for default value.
|
111
|
+
|
112
|
+
end
|
113
|
+
|
114
|
+
|
115
|
+
class ServerPath
|
116
|
+
include Comparable
|
117
|
+
|
118
|
+
attr_accessor :parts
|
119
|
+
|
120
|
+
def initialize(parts)
|
121
|
+
@parts = parts
|
122
|
+
end
|
123
|
+
|
124
|
+
def <=>(other) # Variables are after fixed strings.
|
125
|
+
pp = other.is_a?(Array) ? other : p.parts
|
126
|
+
parts.each_index do |k|
|
127
|
+
return 1 if pp.size <= k # Longer comes after shorter.
|
128
|
+
pk = parts[k]
|
129
|
+
ppk = pp[k]
|
130
|
+
if pk.is_a? String
|
131
|
+
if ppk.is_a? String
|
132
|
+
c = pk <=> ppk
|
133
|
+
else
|
134
|
+
return -1
|
135
|
+
end
|
136
|
+
else
|
137
|
+
if ppk.is_a? String
|
138
|
+
return 1
|
139
|
+
else
|
140
|
+
c = pk.fetch('var', '') <=> ppk.fetch('var', '')
|
141
|
+
end
|
142
|
+
end
|
143
|
+
return c unless c.zero?
|
144
|
+
end
|
145
|
+
(parts.size < pp.size) ? -1 : 0
|
146
|
+
end
|
147
|
+
|
148
|
+
def compare(p, range = nil) # Not fit for sorting. Variable equals anything.
|
149
|
+
pp = p.is_a?(Array) ? p : p.parts
|
150
|
+
if range.nil?
|
151
|
+
range = 0...parts.size
|
152
|
+
elsif range.is_a? Number
|
153
|
+
range = range...(range + 1)
|
154
|
+
end
|
155
|
+
range.each do |k|
|
156
|
+
return 1 if pp.size <= k # Longer comes after shorter.
|
157
|
+
ppk = pp[k]
|
158
|
+
next unless ppk.is_a? String
|
159
|
+
pk = parts[k]
|
160
|
+
next unless pk.is_a? String
|
161
|
+
c = pk <=> ppk
|
162
|
+
return c unless c.zero?
|
163
|
+
end
|
164
|
+
(parts.size < pp.size) ? -1 : 0
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Adds all refs found in the array to refs with given required state.
|
169
|
+
def gather_array_refs(refs, items, required)
|
170
|
+
items.each do |s|
|
171
|
+
r = s['$ref']
|
172
|
+
next if r.nil?
|
173
|
+
refs[r] = required || refs.fetch(r, false)
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
# For any key '$ref' adds to refs whether referred type is required.
|
178
|
+
# Requires that there are no in-lined schemas, openapi-addschemas has been run.
|
179
|
+
def gather_refs(refs, schema)
|
180
|
+
# This implies types mixed together according to examples. Needs mixed type.
|
181
|
+
# AND. Also, mixing may fail. Adds a new schema, do here.
|
182
|
+
items = schema['allOf']
|
183
|
+
return gather_array_refs(refs, items, true) unless items.nil?
|
184
|
+
# As long as one schema is fulfilled, it is ok. OR, first that fits.
|
185
|
+
items = schema['anyOf'] if items.nil?
|
186
|
+
# oneOf implies selection between different types. No multiple matches. XOR.
|
187
|
+
# Needs to ensure that later types do not match.
|
188
|
+
# Should check if there is enough difference to ensure single match.
|
189
|
+
# Use separate program run after addschemas to create allOf mixed schema
|
190
|
+
# and verify the others can be dealt with.
|
191
|
+
items = schema['oneOf'] if items.nil?
|
192
|
+
return gather_array_refs(refs, items, false) unless items.nil?
|
193
|
+
# Defaults below handle it if "type" is not "object".
|
194
|
+
reqs = schema.fetch('required', [])
|
195
|
+
schema.fetch('properties', {}).each do |name, spec|
|
196
|
+
r = spec['$ref']
|
197
|
+
next if r.nil?
|
198
|
+
refs[r] = reqs.include?(name) || refs.fetch(r, false)
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
class SchemaInfo
|
203
|
+
attr_accessor :ref, :schema, :direct_refs, :name, :post_refs
|
204
|
+
|
205
|
+
def initialize(ref, name, schema)
|
206
|
+
@ref = ref
|
207
|
+
@name = name
|
208
|
+
@schema = schema
|
209
|
+
@direct_refs = {}
|
210
|
+
gather_refs(@direct_refs, schema)
|
211
|
+
end
|
212
|
+
|
213
|
+
def set_post_refs(seen)
|
214
|
+
@post_refs = Set.new(@direct_refs.keys) - seen
|
215
|
+
end
|
216
|
+
|
217
|
+
def to_s
|
218
|
+
v = @direct_refs.keys.sort.map { |k| "#{k}:#{@direct_refs[k] ? 'req' : 'opt'}" }
|
219
|
+
"#{@ref}: #{v.join(' ')}"
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
def var_or_method_value(x, name)
|
224
|
+
if name.start_with?('@')
|
225
|
+
n = name
|
226
|
+
else
|
227
|
+
n = "@#{name}"
|
228
|
+
end
|
229
|
+
return x.instance_variable_get(n) if x.instance_variable_defined?(n)
|
230
|
+
return x.public_send(name) if x.respond_to?(name)
|
231
|
+
raise ArgumentError, "#{name} is not #{x.class} instance variable nor public method"
|
232
|
+
end
|
233
|
+
|
234
|
+
class SchemaOrderer
|
235
|
+
attr_accessor :schemas, :order, :orderer
|
236
|
+
|
237
|
+
def initialize(path, schema_specs)
|
238
|
+
@schemas = {}
|
239
|
+
schema_specs.each do |name, schema|
|
240
|
+
r = "#{path}#{name}"
|
241
|
+
@schemas[r] = SchemaInfo.new(r, name, schema)
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
def sort!(orderer = 'required_first')
|
246
|
+
case orderer
|
247
|
+
when 'required_first' then @order = required_first
|
248
|
+
when '<=>' then @order = @schemas.values.sort { |a, b| a <=> b }
|
249
|
+
else
|
250
|
+
@order = @schemas.values.sort do |a, b|
|
251
|
+
va = var_or_method_value(a, orderer)
|
252
|
+
vb = var_or_method_value(b, orderer)
|
253
|
+
va <=> vb
|
254
|
+
end
|
255
|
+
end
|
256
|
+
@orderer = orderer
|
257
|
+
seen = Set.new
|
258
|
+
@order.each do |si|
|
259
|
+
si.set_post_refs(seen)
|
260
|
+
seen.add(si.ref)
|
261
|
+
end
|
262
|
+
@order
|
263
|
+
end
|
264
|
+
|
265
|
+
def required_first
|
266
|
+
chosen = []
|
267
|
+
until chosen.size == @schemas.size
|
268
|
+
used = Set.new(chosen.map { |si| si.ref })
|
269
|
+
avail = @schemas.values.select { |si| !used.member?(si.ref) }
|
270
|
+
best = nil
|
271
|
+
avail.each do |si|
|
272
|
+
prereq = chosen.count { |x| x.direct_refs.fetch(si.ref, false) }
|
273
|
+
fulfilled = chosen.count { |x| si.direct_refs.fetch(x.ref, false) }
|
274
|
+
postreq = si.direct_refs.size - (prereq + fulfilled)
|
275
|
+
better = false
|
276
|
+
if best.nil?
|
277
|
+
better = true
|
278
|
+
else
|
279
|
+
# Minimize preceding types requiring this.
|
280
|
+
if prereq < best.first
|
281
|
+
better = true
|
282
|
+
elsif prereq == best.first
|
283
|
+
# Minimize remaining unfulfilled requires.
|
284
|
+
if postreq < best[1]
|
285
|
+
better = true
|
286
|
+
elsif postreq == best[1]
|
287
|
+
# Check mutual direct requirements.
|
288
|
+
best_req_si = best.last.direct_refs.fetch(si.ref, false)
|
289
|
+
si_req_best = si.direct_refs.fetch(best.last.ref, false)
|
290
|
+
if best_req_si
|
291
|
+
better = true unless si_req_best
|
292
|
+
end
|
293
|
+
# Order by name if no other difference.
|
294
|
+
better = si.ref < best.last.ref unless better
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
298
|
+
best = [ prereq, postreq, si ] if better
|
299
|
+
end
|
300
|
+
chosen.push(best.last)
|
301
|
+
end
|
302
|
+
chosen
|
303
|
+
end
|
304
|
+
end
|