openapi-sourcetools 0.10.1 → 0.12.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.
data/bin/openapi-order ADDED
@@ -0,0 +1,573 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Copyright © 2026 Ismo Kärkkäinen
5
+ # Licensed under Universal Permissive License. See LICENSE.txt.
6
+
7
+ require_relative '../lib/openapi/sourcetools/common'
8
+ require_relative '../lib/openapi/sourcetools/order'
9
+ require 'optparse'
10
+ require 'yaml'
11
+ require 'json'
12
+
13
+ # Functions related to order instructions.
14
+
15
+ def metadata
16
+ 'openapi-order-metadata'
17
+ end
18
+
19
+ def metadata_key
20
+ 'key'
21
+ end
22
+
23
+ def metadata_regexp
24
+ 'regexp'
25
+ end
26
+
27
+ def default_order_data(order_key, regexp_prefix)
28
+ <<EOF
29
+ ---
30
+ #{metadata}:
31
+ #{metadata_key}: "#{order_key}"
32
+ #{metadata_regexp}: "#{regexp_prefix}"
33
+ # Generic orders for any location of a key.
34
+ '**':
35
+ servers:
36
+ #{order_key}: &ServerObject_order
37
+ - url
38
+ - name
39
+ - description
40
+ - variables
41
+ '*':
42
+ #{order_key}: &ServerObject
43
+ - name
44
+ - description
45
+ variables:
46
+ #{order_key}:
47
+ - '*'
48
+ '*':
49
+ #{order_key}: &ServerVariableObject
50
+ - description
51
+ - enum
52
+ - default
53
+ server: *ServerObject
54
+ parameters:
55
+ #{order_key}: &ParameterObject_order
56
+ - name
57
+ - in
58
+ '*': &ParameterObject
59
+ #{order_key}:
60
+ - '$ref'
61
+ - name
62
+ - in
63
+ - required
64
+ - deprecated
65
+ - description
66
+ - content
67
+ - schema
68
+ - '*'
69
+ - #{regexp_prefix}^example[s]?$
70
+ schema: &Schema
71
+ #{order_key}:
72
+ - '$ref'
73
+ - title
74
+ - description
75
+ - deprecated
76
+ - type
77
+ - format
78
+ - enum
79
+ - const
80
+ - default
81
+ - readOnly
82
+ - writeOnly
83
+ # Numeric types.
84
+ - minimum
85
+ - exclusiveMinimum
86
+ - exclusiveMaximum
87
+ - maximum
88
+ - multipleOf
89
+ # Strings.
90
+ - minLength
91
+ - maxLength
92
+ - pattern
93
+ # Core.
94
+ - allOf
95
+ - anyOf
96
+ - oneOf
97
+ - not
98
+ - if
99
+ - then
100
+ - else
101
+ # Objects.
102
+ - required
103
+ - dependentRequired
104
+ - minProperties
105
+ - maxProperties
106
+ - properties
107
+ - #{regexp_prefix}^.+Properties$
108
+ - propertyNames
109
+ - dependentSchemas
110
+ - discriminator
111
+ # Arrays.
112
+ - minItems
113
+ - maxItems
114
+ - uniqueItems
115
+ - items
116
+ - #{regexp_prefix}^.+Items$
117
+ - contains
118
+ - minContains
119
+ - maxContains
120
+ # Content
121
+ - #{regexp_prefix}^content.*$
122
+ - examples
123
+ required:
124
+ #{order_key}:
125
+ - '*'
126
+ dependentRequired:
127
+ #{order_key}:
128
+ - '*'
129
+ '*':
130
+ #{order_key}:
131
+ - '*'
132
+ allOf: &Schema_map
133
+ '*': *Schema
134
+ anyOf: *Schema_map
135
+ oneOf: *Schema_map
136
+ not: *Schema
137
+ if: *Schema
138
+ then: *Schema
139
+ else: *Schema
140
+ properties: &Schema_ordered_map
141
+ #{order_key}:
142
+ - '*'
143
+ '*': *Schema
144
+ patternProperties: *Schema_ordered_map
145
+ additionalProperties: *Schema
146
+ propertyNames: *Schema
147
+ items: *Schema
148
+ contains: *Schema
149
+ prefixItems: *Schema_map
150
+ unevaluatedItems: *Schema
151
+ unevaluatedProperties: *Schema
152
+ dependentSchemas: *Schema_ordered_map
153
+ discriminator:
154
+ #{order_key}:
155
+ - propertyName
156
+ - mapping
157
+ - defaultMapping
158
+ content:
159
+ #{order_key}:
160
+ - '*'
161
+ '*': &MediaTypeObject
162
+ #{order_key}:
163
+ - schema
164
+ - #{regexp_prefix}.*Schema$
165
+ - #{regexp_prefix}^(.*E|e)ncoding$
166
+ - '*'
167
+ - #{regexp_prefix}^example[s]?$
168
+ headers: &HeaderObject
169
+ #{order_key}:
170
+ - description
171
+ - required
172
+ - '*'
173
+ - schema
174
+ '*': *ParameterObject
175
+ responses:
176
+ #{order_key}:
177
+ - '*'
178
+ - default
179
+ '*': &ResponseObject
180
+ #{order_key}:
181
+ - '$ref'
182
+ - summary
183
+ - description
184
+ - headers
185
+ - content
186
+ - links
187
+ links:
188
+ #{order_key}:
189
+ - '*'
190
+ '*': &LinkObject
191
+ #{order_key}:
192
+ - description
193
+ - #{regexp_prefix}^operation.*
194
+ - parameters
195
+ - requestBody
196
+ requestBody: &RequestBodyObject
197
+ #{order_key}:
198
+ - description
199
+ - required
200
+ - content
201
+ security:
202
+ #{order_key}: &SecurityRequirementObject_order
203
+ - '*'
204
+ webhooks:
205
+ #{order_key}:
206
+ - '*'
207
+ '*': &PathItemObject
208
+ #{order_key}:
209
+ - '$ref'
210
+ - summary
211
+ - description
212
+ - servers
213
+ - parameters
214
+ - '*'
215
+ - additionalOperations
216
+ '*':
217
+ #{order_key}: &OperationObject
218
+ - '$ref'
219
+ - operationId
220
+ - summary
221
+ - description
222
+ - parameters
223
+ - requestBody
224
+ - responses
225
+ additionalOperations:
226
+ #{order_key}:
227
+ - '*'
228
+ '*': *OperationObject
229
+ callbacks:
230
+ #{order_key}:
231
+ - '*'
232
+ '*': *PathItemObject
233
+ #{order_key}:
234
+ - openapi
235
+ - '$self'
236
+ - jsonSchemaDialect
237
+ - info
238
+ - servers
239
+ - '*'
240
+ - paths
241
+ - components
242
+ info:
243
+ #{order_key}:
244
+ - title
245
+ - version
246
+ - summary
247
+ - description
248
+ - contact
249
+ - license
250
+ - termsOfService
251
+ contact:
252
+ #{order_key}:
253
+ - name
254
+ license:
255
+ #{order_key}:
256
+ - name
257
+ servers:
258
+ #{order_key}: *ServerObject_order
259
+ '*': *ServerObject
260
+ security:
261
+ #{order_key}: *SecurityRequirementObject_order
262
+ tags: # Do not order tags array but do order Tag Objects.
263
+ '*':
264
+ #{order_key}:
265
+ - name
266
+ - summary
267
+ - description
268
+ paths:
269
+ #{order_key}:
270
+ - '*'
271
+ '*': *PathItemObject
272
+ components:
273
+ #{order_key}:
274
+ - schemas
275
+ - '*'
276
+ - securitySchemes
277
+ - links
278
+ - callbacks
279
+ - examples
280
+ # Regular expressions are for names from various openapi-add* programs.
281
+ # Variations for number of digits ensure ordering by number.
282
+ schemas:
283
+ #{order_key}:
284
+ - '*'
285
+ - #{regexp_prefix}^Schema[0-9]{1}x$
286
+ - #{regexp_prefix}^Schema[0-9]{2}x$
287
+ - #{regexp_prefix}^Schema[0-9]{3,}x$
288
+ '*': *Schema
289
+ responses:
290
+ #{order_key}:
291
+ - '*'
292
+ - #{regexp_prefix}^Response[0-9]{1}x$
293
+ - #{regexp_prefix}^Response[0-9]{2}x$
294
+ - #{regexp_prefix}^Response[0-9]{3,}x$
295
+ '*': *ResponseObject
296
+ parameters:
297
+ #{order_key}:
298
+ - '*'
299
+ - #{regexp_prefix}^Parameter[0-9]{1}x$
300
+ - #{regexp_prefix}^Parameter[0-9]{2}x$
301
+ - #{regexp_prefix}^Parameter[0-9]{3,}x$
302
+ '*': *ParameterObject
303
+ requestBodies:
304
+ #{order_key}:
305
+ - '*'
306
+ - #{regexp_prefix}^RequestBody[0-9]{1}x$
307
+ - #{regexp_prefix}^RequestBody[0-9]{2}x$
308
+ - #{regexp_prefix}^RequestBody[0-9]{3,}x$
309
+ '*': *RequestBodyObject
310
+ headers:
311
+ #{order_key}:
312
+ - '*'
313
+ - #{regexp_prefix}^Header[0-9]{1}x$
314
+ - #{regexp_prefix}^Header[0-9]{2}x$
315
+ - #{regexp_prefix}^Header[0-9]{3,}x$
316
+ '*': *HeaderObject
317
+ securitySchemes:
318
+ #{order_key}:
319
+ - '*'
320
+ links:
321
+ #{order_key}:
322
+ - '*'
323
+ '*': *LinkObject
324
+ callbacks:
325
+ #{order_key}:
326
+ - '*'
327
+ '*': *PathItemObject
328
+ pathItems:
329
+ #{order_key}:
330
+ - '*'
331
+ '*': *PathItemObject
332
+ mediaTypes:
333
+ #{order_key}:
334
+ - '*'
335
+ - #{regexp_prefix}^MediaType[0-9]{1}x$
336
+ - #{regexp_prefix}^MediaType[0-9]{2}x$
337
+ - #{regexp_prefix}^MediaType[0-9]{3,}x$
338
+ '*': *MediaTypeObject
339
+ examples:
340
+ #{order_key}:
341
+ - '*'
342
+ EOF
343
+ end
344
+
345
+ def default_order(order_key, regexp_prefix)
346
+ YAML.load(default_order_data(order_key, regexp_prefix), aliases: true)
347
+ end
348
+
349
+ # This needs to extract all path/order pairs, store and sort.
350
+
351
+ def extract_orders(path, order, collection, order_key, regexp_prefix)
352
+ return true unless order.is_a?(Hash)
353
+ order.each do |key, value|
354
+ if key == order_key
355
+ unless value.is_a?(Array)
356
+ OpenAPISourceTools::Common.aargh("Not an array: #{path.join('.')}.#{order_key} =\n#{YAML.dump(value)}")
357
+ return false
358
+ end
359
+ value.each_with_index do |v, k|
360
+ next if v.is_a?(String)
361
+ OpenAPISourceTools::Common.aargh("Not a string: #{path}.#{order_key}[#{k}] =\n#{YAML.dump(v)}")
362
+ return false
363
+ end
364
+ key_path = OpenAPISourceTools::Ordering::KeyPath.new(path, regexp_prefix)
365
+ key_order = OpenAPISourceTools::Ordering::Order.new(value, regexp_prefix)
366
+ collection.push(OpenAPISourceTools::Ordering::KeyPathOrder.new(key_path, key_order))
367
+ else
368
+ key = key.to_s unless key.is_a?(String)
369
+ path.push(key)
370
+ return false unless extract_orders(path, value, collection, order_key, regexp_prefix)
371
+ path.pop
372
+ end
373
+ end
374
+ end
375
+
376
+ # Functions related to applying the order to the document.
377
+
378
+ def reorder(orders, path, item)
379
+ return false unless OpenAPISourceTools::Ordering::Order.orderable?(item)
380
+ orders.each do |order|
381
+ next unless order.path.match?(path)
382
+ order.order.apply(item)
383
+ break
384
+ end
385
+ true
386
+ end
387
+
388
+ def order_document(orders, path, item)
389
+ return unless reorder(orders, path, item) # Scalars do not pass.
390
+ if item.is_a?(Array)
391
+ item.each_with_index do |element, idx|
392
+ # This needs something and using index as string matches the '*'
393
+ # used when elements of the array are meant to be processed. By
394
+ # using the index alone, the '*' can be replaced by a regular
395
+ # expression that matches specific indices, if so desired.
396
+ path.push(idx.to_s)
397
+ order_document(orders, path, element)
398
+ path.pop
399
+ end
400
+ else
401
+ item.each do |key, value|
402
+ key = key.to_s unless key.is_a?(String)
403
+ path.push(key)
404
+ order_document(orders, path, value)
405
+ path.pop
406
+ end
407
+ end
408
+ end
409
+
410
+ # Functions related to saving the output.
411
+
412
+ def to_json(doc, _line_width)
413
+ JSON.dump(doc)
414
+ end
415
+
416
+ def to_pretty_json(doc, _line_width)
417
+ JSON.pretty_generate(doc)
418
+ end
419
+
420
+ def to_yaml(doc, line_width)
421
+ YAML.dump(doc, line_width: line_width)
422
+ end
423
+
424
+ def main
425
+ input_name = nil
426
+ output_name = nil
427
+ order_name = nil
428
+ order_key = 'x-order'
429
+ regexp_prefix = 're:'
430
+ format = 'yaml'
431
+ formats = {
432
+ 'yaml' => method(:to_yaml),
433
+ 'json' => method(:to_json),
434
+ 'pretty_json' => method(:to_pretty_json)
435
+ }
436
+ line_width = 80
437
+ output_default = false
438
+
439
+ parser = OptionParser.new do |opts|
440
+ opts.summary_indent = ' '
441
+ opts.summary_width = 20
442
+ opts.banner = 'Usage: openapi-order [options]'
443
+ opts.separator ''
444
+ opts.separator 'Options:'
445
+ opts.on('-i', '--input FILE', 'Read API spec from FILE, not stdin.') do |f|
446
+ input_name = f
447
+ end
448
+ opts.on('-o', '--output FILE', 'Output to FILE, not stdout.') do |f|
449
+ output_name = f
450
+ end
451
+ opts.on('--order FILE', 'Read order instructions from FILE.') do |f|
452
+ order_name = f
453
+ end
454
+ opts.on('-f', '--format FORMAT', "Output format: #{formats.keys.sort!.join(', ')}, default: #{format}") do |f|
455
+ unless formats.key?(f.downcase)
456
+ exit(OpenAPISourceTools::Common.aargh("Unknown format: #{f}", 1))
457
+ end
458
+ format = f
459
+ end
460
+ opts.on('-w', '--width WIDTH', "Set YAML line width, default: #{line_width}") do |w|
461
+ line_width = w.to_i
462
+ end
463
+ opts.separator ''
464
+ opts.separator 'Order options:'
465
+ opts.on('--default', 'Output default order to --output, ignore input.') do
466
+ output_default = true
467
+ end
468
+ opts.on('-k', '--key KEY', "Use KEY as default order key, default: #{order_key}") do |k|
469
+ order_key = k
470
+ end
471
+ opts.on('-r', '--regexp PREFIX', "Use PREFIX to indicate regular expression, default: #{regexp_prefix}") do |p|
472
+ regexp_prefix = p
473
+ end
474
+ opts.on('-h', '--help', 'Print this help and exit.') do
475
+ $stdout.puts %(#{opts}
476
+ Loads YAML document and applies ordering to object keys, and arrays with
477
+ objects, when an order matches the key path to object.
478
+
479
+ Can be used on generic YAML or JSON files. Default order is for OpenAPI
480
+ documents.
481
+
482
+ The order instructions key "#{order_key}" value must be an array of strings.
483
+ The values can be any combination of the following:
484
+ - Literal key name.
485
+ - Regular expression starting with "#{regexp_prefix}".
486
+ - "*" that matches any key that does not match anything else.
487
+ First match against a value determines the position, and "*" receives all
488
+ unmatched keys. If order array has no "*", every unmatched key will be placed
489
+ last. Only the first "*" has any effect. Multiple keys placed in the same
490
+ position are sorted within that position.
491
+
492
+ The path to the "#{order_key}" can have any combination of the following:
493
+ - Literal key name.
494
+ - Regular expression starting with "#{regexp_prefix}".
495
+ - "*" that matches one key of any value.
496
+ - "**" that matches one or more keys of any value.
497
+ The path is used to determine if a value in the document should be ordered
498
+ using the instructions. At top level the path is empty and applies to the root
499
+ of the document. Long paths are considered to be more specific than short
500
+ paths, because they supposedly contain more literal keys or limiting regular
501
+ expressions so thay are intended for a specific case. Of paths of same length
502
+ one with "**" is considered less specific than one without.
503
+
504
+ The path to a value in the document is matched against paths to "#{order_key}"
505
+ to find the most specific order. For elements in an array, the last item in
506
+ the path is the element index as a string. Hence you can use a "*" parallel
507
+ to "#{order_key}" to match any index, but also literals or regular expressions
508
+ to match specific index can be used.
509
+
510
+ For object value in the document, the order is used to sort the keys of the
511
+ object. Absence of a key in the object has no effect. Any non-string key is
512
+ converted to string for ordering purposes. Original keys are retained.
513
+
514
+ For array value in the document, the order is used to select values of keys
515
+ from array elements to use in comparison for sorting the array.
516
+ - Using known object property names is recommended.
517
+ - Values of keys should be scalars and have the same type for same key.
518
+ - Missing key places the element after one that has the key.
519
+ - Object and array values are considered to be a missing value.
520
+ - Elements that are not objects end up last because they have no keys.
521
+ Mixture of objects, arrays, and scalars is not expected in the intended use.
522
+
523
+ Scalar value in the document is not affected.
524
+
525
+ Non-string keys in the document and order paths are converted to strings for
526
+ processing.
527
+
528
+ The --key, --regexp, --format, and --output apply to --default output. If
529
+ format is YAML, the internal YAML document is output as is.
530
+
531
+ To avoid errors, the value of --key should not appear in the document so that
532
+ it only appears as order instructions key in the order document.
533
+
534
+ In case the document has a key that starts with "#{regexp_prefix}" you can change it in the
535
+ order file or use "#{regexp_prefix}^#{regexp_prefix}the_rest$" as an anchored regular
536
+ expression that only matches the key. The ":" is part of the prefix itself,
537
+ so provide it if you want to keep it. Useful for readability.
538
+
539
+ When reading an order file, --key and --regexp are over-ridden by root-level
540
+ object "#{metadata}" keys "#{metadata_key}" and "#{metadata_regexp}" values.
541
+ )
542
+ exit 0
543
+ end
544
+ end
545
+ parser.order!
546
+
547
+ if output_default
548
+ if format == 'yaml'
549
+ doc = default_order_data(order_key, regexp_prefix)
550
+ else
551
+ doc = default_order(order_key, regexp_prefix)
552
+ doc = formats[format].call(doc, line_width)
553
+ end
554
+ else
555
+ doc = OpenAPISourceTools::Common.load_source(input_name)
556
+ return 2 if doc.nil?
557
+
558
+ order = order_name.nil? ? default_order(order_key, regexp_prefix) : OpenAPISourceTools::Common.load_source(order_name)
559
+ return 4 if order.nil?
560
+ md = order.fetch(metadata, {})
561
+ order_key = md.fetch(metadata_key, order_key)
562
+ regexp_prefix = md.fetch(metadata_regexp, regexp_prefix)
563
+ path_orders = []
564
+ return 4 unless extract_orders([], order, path_orders, order_key, regexp_prefix)
565
+ path_orders.sort!.reverse! # Longest key pattern sequence first.
566
+ order_document(path_orders, [], doc)
567
+ doc = formats[format].call(doc, line_width)
568
+ end
569
+
570
+ OpenAPISourceTools::Common.dump_result(output_name, doc, 3)
571
+ end
572
+
573
+ exit(main) if File.basename(__FILE__) == File.basename($PROGRAM_NAME)
@@ -37,10 +37,99 @@ module OpenAPISourceTools
37
37
  end
38
38
  end
39
39
 
40
+ # Any value with given key has all but retained keys removed.
41
+ class ValueRetainer
42
+ attr_reader :key
43
+ attr_accessor :retain
44
+
45
+ def initialize(trigger_key)
46
+ @key = trigger_key
47
+ @retain = [ trigger_key ]
48
+ end
49
+
50
+ def process(obj)
51
+ if obj.is_a?(Array)
52
+ obj.each { |item| process(item) }
53
+ return
54
+ end
55
+ return unless obj.is_a?(Hash)
56
+ if obj.key?(@key)
57
+ obj.delete_if { |k, _v| !@retain.include?(k) }
58
+ end
59
+ obj.each_value do |value|
60
+ process(value)
61
+ end
62
+ end
63
+ end
64
+
65
+ # A replacer for value of a given key.
66
+ class ValueReplacer
67
+ attr_reader :key, :components
68
+
69
+ def initialize(trigger_key, components)
70
+ @key = trigger_key
71
+ @components = components
72
+ end
73
+
74
+ def replace(obj)
75
+ if obj.is_a?(Array)
76
+ obj.each { |item| replace(item) }
77
+ return
78
+ end
79
+ return unless obj.is_a?(Hash)
80
+ obj.each do |key, value|
81
+ if key == @key
82
+ if value.is_a?(Array)
83
+ value.each do |item|
84
+ next unless item.is_a?(Hash)
85
+ next if item.key?('$ref')
86
+ @components.to_reference_object(item)
87
+ end
88
+ elsif value.is_a?(Hash)
89
+ next if value.key?('$ref')
90
+ @components.to_reference_object(value)
91
+ end
92
+ else
93
+ replace(value)
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ # A replacer for sub-values of a value of a given key.
100
+ class ValueSubValueReplacer
101
+ attr_reader :key, :components
102
+
103
+ def initialize(trigger_key, components)
104
+ @key = trigger_key
105
+ @components = components
106
+ end
107
+
108
+ def replace(obj)
109
+ if obj.is_a?(Array)
110
+ obj.each { |item| replace(item) }
111
+ return
112
+ end
113
+ return unless obj.is_a?(Hash)
114
+ obj.each do |key, value|
115
+ if key == @key && value.is_a?(Hash)
116
+ value.keys.sort!.each do |sub_key|
117
+ sub_value = value[sub_key]
118
+ next unless sub_value.is_a?(Hash)
119
+ next if sub_value.key?('$ref')
120
+ @components.to_reference_object(value[sub_key])
121
+ end
122
+ else
123
+ replace(value)
124
+ end
125
+ end
126
+ end
127
+ end
128
+
40
129
  # A component in the API specification for reference and anchor handling.
41
130
  class Components
42
131
  attr_reader :path, :prefix, :anchor2ref, :schema_names
43
- attr_accessor :items, :ignored_keys
132
+ attr_accessor :items, :ignored_keys, :retain_ignored
44
133
 
45
134
  def initialize(path, prefix, ignored_keys = %w[summary description examples example $anchor])
46
135
  path = "#/#{path.join('/')}/" if path.is_a?(Array)
@@ -51,6 +140,7 @@ module OpenAPISourceTools
51
140
  @schema_names = Set.new
52
141
  @items = {}
53
142
  @ignored_keys = Set.new(ignored_keys)
143
+ @retain_ignored = false
54
144
  end
55
145
 
56
146
  def add_options(opts)
@@ -60,6 +150,9 @@ module OpenAPISourceTools
60
150
  opts.on('--ignore FIELD', 'Ignore FIELD in comparisons.') do |f|
61
151
  @ignored_keys.add(f)
62
152
  end
153
+ opts.on('--retain-ignored', 'Retain ignored fields in reference object.') do
154
+ @retain_ignored = true
155
+ end
63
156
  end
64
157
 
65
158
  def help
@@ -91,6 +184,17 @@ module OpenAPISourceTools
91
184
  end
92
185
  end
93
186
 
187
+ def to_reference_object(obj, ref = nil)
188
+ ref = reference(obj) if ref.nil?
189
+ if @retain_ignored
190
+ obj.delete_if { |k, _v| !@ignored_keys.member?(k) }
191
+ else
192
+ obj.clear
193
+ end
194
+ obj['$ref'] = ref
195
+ obj
196
+ end
197
+
94
198
  def store_anchor(obj, ref = nil)
95
199
  anchor_name = obj['$anchor']
96
200
  return if anchor_name.nil?