tomograph 3.2.7 → 4.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df200f372e9f0e40bb72b2fb5bda369f79519b3081ce0d562f172d91011328ae
4
- data.tar.gz: c34517a4d760e0ed3412663976744e4200f7e2bc5b1171d07e96fb7d8d279be6
3
+ metadata.gz: 97a07b518360a088260ba2e9ead6ece87b10235bb7766490bc659277387ef9bc
4
+ data.tar.gz: 239ed051e682a6ea540dc9579f3d58c75594b3a89f4c9ad0fd8c3c4c4ee1b0ae
5
5
  SHA512:
6
- metadata.gz: 3cfe0cc68a80c58eb6f0a1da50cf36f3f4ce30497be8b3c0f639d3922a9ecf4ef2afe52ce04c5e9267361233628f432b19f72fd39deee74aa0e3fa7ffec67868
7
- data.tar.gz: 0df481b090aaf42a50c10eec7ffafe2e04077432de5e89ab1c8041a276a4095e01e2fe510f40fa34c0e6baae33d3dcf2229a194d4cfdfc26e6e2c081283f9404
6
+ metadata.gz: adc5d107c07a96565b5ed80d1a6f4ee5069c5ce1785ffa23230494537e73950c24c9455466a30dfd3921575f1c04f81d37bb64037147ff3856cab4570c8ad416
7
+ data.tar.gz: 48260bf6279100f0972b53e1a5192242361c487ffccec6b8d5bf234b1cd2bf1ed72c9a08047d08b54988cf03e80feac54e17b1bc7ab58a49c4b1d3e638fddcc7
@@ -26,7 +26,7 @@ jobs:
26
26
  uses: ruby/setup-ruby@v1
27
27
  # uses: ruby/setup-ruby@ec106b438a1ff6ff109590de34ddc62c540232e0
28
28
  with:
29
- ruby-version: 2.7
29
+ ruby-version: '3.0'
30
30
  - name: Install dependencies
31
31
  run: bundle install
32
32
  - name: Run tests
data/.rubocop.yml CHANGED
@@ -1,5 +1,6 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.4
2
+ TargetRubyVersion: 3.0
3
+ NewCops: disable
3
4
 
4
5
  Layout/LineLength:
5
6
  Max: 120
@@ -30,3 +31,9 @@ Metrics/AbcSize:
30
31
 
31
32
  Metrics/ClassLength:
32
33
  Max: 150
34
+
35
+ Metrics/CyclomaticComplexity:
36
+ Max: 16
37
+
38
+ Metrics/PerceivedComplexity:
39
+ Max: 18
data/.tool-versions CHANGED
@@ -1,2 +1,2 @@
1
- ruby 2.7.1
1
+ ruby 3.0.2
2
2
  nodejs 11.3.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Change log
2
2
 
3
+ ### 4.0.0 - 2026-11-03
4
+
5
+ * breaking changes
6
+ * require Ruby 3.0 or newer; Ruby 2.x is no longer supported
7
+ * features
8
+ * resolve external OpenAPI 3 path and component references
9
+ * support multipart and form-urlencoded request schemas
10
+ * bug fixes
11
+ * include referenced component schemas in composed response schemas
12
+ * convert OpenAPI nullable fields to JSON Schema null types
13
+ * ignore path-level parameters when collecting operations
14
+ * preserve optional empty response bodies through `x-empty-body`
15
+ * maintenance
16
+ * align local Ruby version managers and CI on Ruby 3.0.2
17
+ * update RuboCop target Ruby version and complexity limits
18
+
3
19
  ### 3.2.7 - 2024-12-08
4
20
 
5
21
  * bug fixes
data/README.md CHANGED
@@ -371,6 +371,15 @@ Exclude "description" keys from json-schemas.
371
371
  tomograph -d 4 apielemetns.yaml doc.json --exclude-description
372
372
  ```
373
373
 
374
+ #### json-schema-draft4
375
+
376
+ Add `"$schema": "http://json-schema.org/draft-04/schema#"` to every non-empty request and response body.
377
+ Empty bodies stay `{}`.
378
+
379
+ ```bash
380
+ tomograph -d openapi3 openapi3.yaml doc.json --json-schema-draft4
381
+ ```
382
+
374
383
  #### split
375
384
 
376
385
  Split output into files by method. Output in dir path.
data/exe/tomograph CHANGED
@@ -17,6 +17,7 @@ description 'Converts API Blueprint to JSON Schema'
17
17
  on('-d DRAFTER_VERSION', '--drafter', 'Choose drafter version: crafter or 4,
18
18
  or OpenAPI version: openapi2 or openapi3. Default: use drafter v.4.')
19
19
  on('--exclude-description', 'Exclude "description" keys.')
20
+ on('--json-schema-draft4', 'Add "$schema": draft-04 to every non-empty request and response body.')
20
21
  on('--split', 'Split output into files by method.')
21
22
  arg :input, 'path/to/doc.yaml (API Elements)'
22
23
  arg :output, 'path/to/doc.json or path/to/dir if --split is used.'
@@ -30,6 +31,21 @@ def prune!(obj, unwanted_key)
30
31
  end
31
32
  end
32
33
 
34
+ JSON_SCHEMA_DRAFT4 = 'http://json-schema.org/draft-04/schema#'.freeze
35
+
36
+ def with_draft4(schema)
37
+ return schema unless schema.is_a?(Hash) && !schema.empty?
38
+
39
+ { '$schema' => JSON_SCHEMA_DRAFT4 }.merge(schema)
40
+ end
41
+
42
+ def add_draft4!(actions)
43
+ actions.each do |action|
44
+ action['requests'] = action['requests'].map { |request| with_draft4(request) }
45
+ action['responses'].each { |response| response['body'] = with_draft4(response['body']) }
46
+ end
47
+ end
48
+
33
49
  def choose_drafter(opt_parser)
34
50
  case opt_parser
35
51
  when 'crafter'
@@ -80,6 +96,7 @@ main do |input, output|
80
96
  actions = tomogram.to_a.map(&:to_hash)
81
97
 
82
98
  prune!(actions, 'description') if options['exclude-description']
99
+ add_draft4!(actions) if options['json-schema-draft4']
83
100
 
84
101
  if options['split']
85
102
  write_split_json(actions, output)
@@ -0,0 +1,170 @@
1
+ require 'yaml'
2
+
3
+ module Tomograph
4
+ module OpenApi
5
+ # Loads an OpenAPI document and rewrites external $ref (./paths/auth.yaml,
6
+ # ../components/schemas.yaml#/LoginRequest) into internal #/components/... pointers.
7
+ class ExternalRefResolver
8
+ COMPONENT_KINDS = {
9
+ 'schema' => 'schemas',
10
+ 'schemas' => 'schemas',
11
+ 'response' => 'responses',
12
+ 'responses' => 'responses',
13
+ 'parameter' => 'parameters',
14
+ 'parameters' => 'parameters'
15
+ }.freeze
16
+
17
+ def self.load(path)
18
+ new.load(path)
19
+ end
20
+
21
+ def initialize
22
+ @cache = {}
23
+ @registered = {}
24
+ @stack = []
25
+ @components = {
26
+ 'schemas' => {},
27
+ 'responses' => {},
28
+ 'parameters' => {}
29
+ }
30
+ end
31
+
32
+ def load(path)
33
+ path = File.expand_path(path)
34
+ resolved = resolve_node(read_yaml(path), File.dirname(path))
35
+ merge_components(resolved)
36
+ end
37
+
38
+ private
39
+
40
+ def merge_components(doc)
41
+ @components.each do |kind, values|
42
+ next if values.empty?
43
+
44
+ doc['components'] ||= {}
45
+ doc['components'][kind] = values.merge(doc['components'][kind] || {})
46
+ end
47
+ doc
48
+ end
49
+
50
+ def resolve_node(node, base_dir)
51
+ case node
52
+ when Hash
53
+ resolved = {}
54
+ node.each { |key, value| resolved[key] = resolve_node(value, base_dir) }
55
+ ref = node['$ref']
56
+ return resolved unless ref.is_a?(String) && external_ref?(ref)
57
+
58
+ target = resolve_external(ref, base_dir)
59
+ extra = resolved.reject { |key, _| key == '$ref' }
60
+ target.is_a?(Hash) ? target.merge(extra) : target
61
+ when Array
62
+ node.map { |value| resolve_node(value, base_dir) }
63
+ else
64
+ node
65
+ end
66
+ end
67
+
68
+ def resolve_external(ref, base_dir)
69
+ abs_ref = absolute_ref(ref, base_dir)
70
+ return { '$ref' => ref } if @stack.include?(abs_ref)
71
+
72
+ @stack.push(abs_ref)
73
+ begin
74
+ file, fragment = split_external(ref, base_dir)
75
+ data = read_yaml(file)
76
+ segments = pointer_segments(fragment)
77
+ kind = component_kind(file)
78
+ if kind && segments.length == 1 && data.is_a?(Hash) && data.key?(segments[0])
79
+ register_component_file(file, kind, data)
80
+ { '$ref' => "#/components/#{kind}/#{segments[0]}" }
81
+ else
82
+ target = segments.empty? ? data : dig_pointer(data, segments)
83
+ resolve_node(deepcopy(target), File.dirname(file))
84
+ end
85
+ ensure
86
+ @stack.pop
87
+ end
88
+ end
89
+
90
+ def register_component_file(file, kind, data)
91
+ return if @registered[file]
92
+
93
+ @registered[file] = true
94
+ names = data.keys
95
+ names.each do |name|
96
+ rewritten = rewrite_local_refs(deepcopy(data[name]), names, kind)
97
+ @components[kind][name] = resolve_node(rewritten, File.dirname(file))
98
+ end
99
+ end
100
+
101
+ def rewrite_local_refs(node, names, kind)
102
+ case node
103
+ when Hash
104
+ node.each_with_object({}) do |(key, value), result|
105
+ if key == '$ref' && local_component_ref?(value, names)
106
+ result[key] = "#/components/#{kind}/#{value[2..-1]}"
107
+ else
108
+ result[key] = rewrite_local_refs(value, names, kind)
109
+ end
110
+ end
111
+ when Array
112
+ node.map { |value| rewrite_local_refs(value, names, kind) }
113
+ else
114
+ node
115
+ end
116
+ end
117
+
118
+ def local_component_ref?(value, names)
119
+ value.is_a?(String) && value =~ /\A#\/([^\/]+)\z/ && names.include?(value[2..-1])
120
+ end
121
+
122
+ def external_ref?(ref)
123
+ !ref.start_with?('#')
124
+ end
125
+
126
+ def absolute_ref(ref, base_dir)
127
+ file, fragment = ref.split('#', 2)
128
+ "#{File.expand_path(file, base_dir)}##{fragment}"
129
+ end
130
+
131
+ def split_external(ref, base_dir)
132
+ file, fragment = ref.split('#', 2)
133
+ [File.expand_path(file, base_dir), fragment]
134
+ end
135
+
136
+ def component_kind(path)
137
+ COMPONENT_KINDS[File.basename(path, '.*')]
138
+ end
139
+
140
+ def pointer_segments(fragment)
141
+ return [] if fragment.nil? || fragment.empty? || fragment == '/'
142
+
143
+ fragment.sub(%r{\A/}, '').split('/').map { |segment| segment.gsub('~1', '/').gsub('~0', '~') }
144
+ end
145
+
146
+ def dig_pointer(data, segments)
147
+ segments.reduce(data) do |memo, segment|
148
+ case memo
149
+ when Hash then memo[segment]
150
+ when Array then memo[segment.to_i]
151
+ end
152
+ end
153
+ end
154
+
155
+ def read_yaml(path)
156
+ return @cache[path] if @cache.key?(path)
157
+
158
+ @cache[path] = YAML.safe_load(File.read(path)) || {}
159
+ end
160
+
161
+ def deepcopy(obj)
162
+ case obj
163
+ when Hash then obj.each_with_object({}) { |(key, value), copy| copy[key] = deepcopy(value) }
164
+ when Array then obj.map { |value| deepcopy(value) }
165
+ else obj
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -1,25 +1,33 @@
1
1
  require 'tomograph/tomogram/action'
2
+ require 'tomograph/openapi/external_ref_resolver'
2
3
 
3
4
  module Tomograph
4
5
  module OpenApi
5
6
  class OpenApi3
7
+ HTTP_METHODS = %w[get put post delete options head patch trace].freeze
8
+ REQUEST_SCHEMA_TYPES = ['application/json', 'multipart/form-data', 'application/x-www-form-urlencoded'].freeze
9
+
6
10
  def initialize(prefix, openapi3_yaml_path)
7
11
  @prefix = prefix
8
- @documentation = YAML.safe_load(File.read(openapi3_yaml_path))
12
+ @documentation = ExternalRefResolver.load(openapi3_yaml_path)
9
13
  end
10
14
 
11
15
  def to_tomogram
12
16
  @tomogram ||= @documentation['paths'].each_with_object([]) do |(path, action_definition), result|
13
- action_definition.keys.each do |method|
14
- ajj = valuekey(action_definition[method]['requestBody'], 'content')
15
- aj = valuekey(ajj, 'application/json')
17
+ next unless action_definition.is_a?(Hash)
18
+
19
+ action_definition.each do |method, operation|
20
+ next unless HTTP_METHODS.include?(method)
21
+ next unless operation.is_a?(Hash)
16
22
 
23
+ request_body = operation['requestBody']
24
+ content = valuekey(request_body, 'content')
17
25
  result.push(Tomograph::Tomogram::Action.new(
18
26
  path: "#{@prefix}#{path}",
19
27
  method: method.upcase,
20
- content_type: action_definition[method]['requestBody'].nil? ? nil : action_definition[method]['requestBody']['content'].keys[0],
21
- requests: [schema_new(valuekey(aj, 'schema'), @documentation['definitions'])].compact,
22
- responses: responses(action_definition[method]['responses']),
28
+ content_type: request_body.nil? ? nil : content&.keys&.first,
29
+ requests: [request_schema(content)].compact,
30
+ responses: responses(operation['responses']),
23
31
  resource: ''
24
32
  ))
25
33
  end
@@ -30,8 +38,29 @@ module Tomograph
30
38
  value.nil? ? nil : value[key]
31
39
  end
32
40
 
41
+ def request_schema(content)
42
+ return if content.nil?
43
+
44
+ type = REQUEST_SCHEMA_TYPES.find { |item| content.key?(item) }
45
+ return if type.nil?
46
+
47
+ node = valuekey(content[type], 'schema')
48
+ return if node.nil?
49
+
50
+ if node.to_s.include?('#/components/schemas/')
51
+ schema(node)
52
+ else
53
+ schema_new(node, @documentation['definitions'])
54
+ end
55
+ end
56
+
33
57
  def schema_new(sche, defi)
58
+ json_schema(schema_new_raw(sche, defi))
59
+ end
60
+
61
+ def schema_new_raw(sche, defi)
34
62
  return sche unless sche
63
+
35
64
  if sche.keys.include?('$ref')
36
65
  res = sche.merge('definitions' => { sche['$ref'][14..-1] => defi[sche['$ref'][14..-1]] })
37
66
  if defi[sche['$ref'][14..-1]].to_s.include?('$ref')
@@ -66,12 +95,22 @@ module Tomograph
66
95
  if response['content'].nil?
67
96
  result.push(
68
97
  'status' => response_code,
69
- 'body'=> {},
98
+ 'body' => {},
70
99
  'content-type' => nil
71
100
  )
72
101
  else
73
102
  result += responses_by_content_types(response['content'], response_code)
74
103
  end
104
+
105
+ # API Blueprint allowed a second response with the same status and an
106
+ # empty body. Fitting treats that pair as an optional schema.
107
+ if response['x-empty-body']
108
+ result.push(
109
+ 'status' => response_code,
110
+ 'body' => {},
111
+ 'content-type' => 'application/json'
112
+ )
113
+ end
75
114
  end
76
115
  result
77
116
  end
@@ -87,6 +126,12 @@ module Tomograph
87
126
  end
88
127
 
89
128
  def schema(sche)
129
+ json_schema(schema_raw(sche))
130
+ end
131
+
132
+ def schema_raw(sche)
133
+ return sche unless sche.is_a?(Hash)
134
+
90
135
  if @documentation['components']
91
136
  defi = @documentation['components']['schemas']
92
137
  elsif @documentation['definitions']
@@ -122,17 +167,87 @@ module Tomograph
122
167
  sche
123
168
 
124
169
  elsif sche.to_s.include?('$ref')
125
- res = sche.merge('definitions' => {})
126
- keys = sche.to_s.split('"').find_all { |word| word.include?('definitions') }
127
- keys.each do |key|
128
- res['definitions'].merge!({ key.split('/')[-1] => defi[key.split('/')[-1]] })
129
- end
130
- res
170
+ res = attach_definitions(sche, defi)
171
+ attach_component_schemas(res)
131
172
  else
132
173
  sche
133
174
  end
134
175
  end
135
176
 
177
+ def attach_definitions(sche, defi)
178
+ keys = sche.to_s.split('"').find_all { |word| word.include?('definitions') }
179
+ return sche if keys.empty?
180
+
181
+ res = sche.merge('definitions' => {})
182
+ keys.each do |key|
183
+ name = key.split('/')[-1]
184
+ res['definitions'].merge!(name => defi[name])
185
+ end
186
+ res
187
+ end
188
+
189
+ def attach_component_schemas(sche)
190
+ schemas = @documentation.dig('components', 'schemas')
191
+ return sche unless schemas
192
+
193
+ copied = {}
194
+ seen = {}
195
+ copy_component_schemas(copied, seen, component_ref_names(sche), schemas)
196
+ return sche if copied.empty?
197
+
198
+ previous = (sche['components'] || {})
199
+ schemas = (previous['schemas'] || {}).merge(copied)
200
+ sche.merge('components' => previous.merge('schemas' => schemas))
201
+ end
202
+
203
+ def copy_component_schemas(copied, seen, names, schemas)
204
+ names.each do |name|
205
+ next if seen[name]
206
+ next unless schemas[name]
207
+
208
+ seen[name] = true
209
+ copied[name] = schemas[name]
210
+ copy_component_schemas(copied, seen, component_ref_names(schemas[name]), schemas)
211
+ end
212
+ end
213
+
214
+ def component_ref_names(schema)
215
+ schema.to_s.split('"').find_all { |word| word.include?('#/components/schemas/') }
216
+ .map { |word| word[21..-1] }
217
+ .uniq
218
+ end
219
+
220
+ # OpenAPI 3 `nullable: true` is not a JSON Schema keyword. Draft-04, which
221
+ # the tomogram is validated against, allows null only via the type list.
222
+ def json_schema(node)
223
+ case node
224
+ when Hash
225
+ converted = {}
226
+ node.each { |key, value| converted[key] = json_schema(value) }
227
+ nullable_to_type(converted)
228
+ when Array
229
+ node.map { |value| json_schema(value) }
230
+ else
231
+ node
232
+ end
233
+ end
234
+
235
+ def nullable_to_type(schema)
236
+ return schema unless schema.key?('nullable')
237
+
238
+ nullable = schema.delete('nullable')
239
+ return schema unless nullable
240
+
241
+ case schema['type']
242
+ when String
243
+ schema['type'] = [schema['type'], 'null']
244
+ when Array
245
+ schema['type'] = schema['type'].dup
246
+ schema['type'] << 'null' unless schema['type'].include?('null')
247
+ end
248
+ schema
249
+ end
250
+
136
251
  def search_hash(hash, key)
137
252
  return hash[key] if hash.assoc(key)
138
253
 
@@ -1,3 +1,3 @@
1
1
  module Tomograph
2
- VERSION = '3.2.7'.freeze
2
+ VERSION = '4.0.0'.freeze
3
3
  end
data/tomograph.gemspec CHANGED
@@ -5,8 +5,8 @@ require 'tomograph/version'
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = 'tomograph'
7
7
  spec.version = Tomograph::VERSION
8
- spec.authors = ['d.efimov']
9
- spec.email = ['d.efimov@fun-box.ru']
8
+ spec.authors = ['Dmitry Efimov']
9
+ spec.email = ['tuwilof@gmail.com']
10
10
 
11
11
  spec.summary = 'Convert API Blueprint, Swagger and OpenAPI to Tomogram'
12
12
  spec.description = 'Convert API Blueprint, Swagger and OpenAPI to routes and JSON-Schemas'
@@ -25,5 +25,5 @@ Gem::Specification.new do |spec|
25
25
  spec.add_development_dependency 'rspec', '~> 3.10', '>= 3.10.0'
26
26
  spec.add_development_dependency 'rubocop', '~> 0.81', '>= 0.81.0'
27
27
  spec.add_development_dependency 'simplecov', '~> 0.21', '>= 0.21.2'
28
- spec.required_ruby_version = '>= 2.4.0'
28
+ spec.required_ruby_version = '>= 3.0.0'
29
29
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tomograph
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.7
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
- - d.efimov
8
- autorequire:
7
+ - Dmitry Efimov
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-12-08 00:00:00.000000000 Z
11
+ date: 2026-11-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: methadone
@@ -146,7 +146,7 @@ dependencies:
146
146
  version: 0.21.2
147
147
  description: Convert API Blueprint, Swagger and OpenAPI to routes and JSON-Schemas
148
148
  email:
149
- - d.efimov@fun-box.ru
149
+ - tuwilof@gmail.com
150
150
  executables:
151
151
  - tomograph
152
152
  extensions: []
@@ -172,6 +172,7 @@ files:
172
172
  - lib/tomograph/api_blueprint/drafter_4/yaml.rb
173
173
  - lib/tomograph/api_blueprint/drafter_4/yaml/action.rb
174
174
  - lib/tomograph/api_blueprint/json_schema.rb
175
+ - lib/tomograph/openapi/external_ref_resolver.rb
175
176
  - lib/tomograph/openapi/openapi2.rb
176
177
  - lib/tomograph/openapi/openapi3.rb
177
178
  - lib/tomograph/path.rb
@@ -184,7 +185,7 @@ homepage: https://github.com/tuwilof/tomograph
184
185
  licenses:
185
186
  - MIT
186
187
  metadata: {}
187
- post_install_message:
188
+ post_install_message:
188
189
  rdoc_options: []
189
190
  require_paths:
190
191
  - lib
@@ -192,7 +193,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
192
193
  requirements:
193
194
  - - ">="
194
195
  - !ruby/object:Gem::Version
195
- version: 2.4.0
196
+ version: 3.0.0
196
197
  required_rubygems_version: !ruby/object:Gem::Requirement
197
198
  requirements:
198
199
  - - ">="
@@ -200,7 +201,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
200
201
  version: '0'
201
202
  requirements: []
202
203
  rubygems_version: 3.2.22
203
- signing_key:
204
+ signing_key:
204
205
  specification_version: 4
205
206
  summary: Convert API Blueprint, Swagger and OpenAPI to Tomogram
206
207
  test_files: []