openapi-sourcetools 0.11.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b790daa9dc40626f360f32ba98853c2b5410e5214d0d2ef32334df1858a9262
4
- data.tar.gz: 842e1b54d72995112b38f678f3b0f853d9e0e6ea03652d56797715cb215e2c35
3
+ metadata.gz: 830a477c8647ad883e5a6619767f19cfae7c2d9b9baa100b0e10bdd911ef0fc1
4
+ data.tar.gz: 9e4a89d44c344c0099f826bf08c459b2eaa448e4d8b76b982e7ee8b7146199b2
5
5
  SHA512:
6
- metadata.gz: 5fa1f5ac001251e2d5b56b701bac7d8489efd01fdbbfc2884a0be84c7c5f86dd1a85d1e1cff6120ae09d0d462912711a70ad269d2c5c740c4639a6e495057e78
7
- data.tar.gz: ec9dc7d99bfcd12387d163a88c7502052c44cc0159c7bd5865d86a17af0511e979224289ec56f7f049400f4280e02fa583498a553791be9f1b762153cac4b903
6
+ metadata.gz: eef01e1c3095d747213533d8ceeb4d0ba4107970b6541632b92b9ffaa0a0202d583ec6934f2af4af3dbf0dff4d0344e053bbf9201d8128cd474406fe11a8b308
7
+ data.tar.gz: 92fcbc4c9128e213ee4d67d7eebe69c5de9f5ef2576a0efdeae33b2f512a786d586e0eebe903fc932b43f59475ecbd780d925263adbd9f5039b113292db8ae97
@@ -29,31 +29,39 @@ def replace_inlines(obj, components, top_level_name = nil)
29
29
  return true
30
30
  end
31
31
  # Is inlined, process parts recursively.
32
- items = obj['allOf']
33
- items = obj['anyOf'] if items.nil?
34
- items = obj['oneOf'] if items.nil?
35
- # All above need special treatment in code generation templates.
36
- return replace_inlines(items, components) unless items.nil?
37
- t = obj['type']
38
- if t.nil? # Some kind of intermediate-level object.
32
+ %w[allOf anyOf oneOf items prefixItems additionalProperties unevaluatedItems
33
+ unevaluatedProperties if then else not
34
+ ].each do |prop_name|
35
+ return false unless replace_inlines(obj[prop_name], components)
36
+ end
37
+ %w[properties patternProperties dependentSchemas].each do |prop_name|
38
+ props = obj[prop_name]
39
+ next if props.nil?
40
+ props.keys.sort!.each do |name|
41
+ return false unless replace_inlines(props[name], components)
42
+ end
43
+ end
44
+ looks_like_type = false
45
+ # required is omitted since it is in schema and in parameters.
46
+ %w[type items prefixItems properties patternProperties additionalProperties
47
+ unevaluatedItems unevaluatedProperties enum const minimum maximum multipleOf
48
+ exclusiveMinimum exclusiveMaximum maxLength minLength pattern maxItems minItems
49
+ uniqueItems maxContains minContains maxProperties minProperties propertyNames
50
+ dependentRequired format contentEncoding contentMediaType contentSchema
51
+ ].each do |kw|
52
+ if obj.key?(kw)
53
+ looks_like_type = true
54
+ break
55
+ end
56
+ end
57
+ unless looks_like_type
58
+ # Some kind of intermediate-level object.
39
59
  (obj.keys.sort! { |a, b| a.to_s <=> b.to_s }).each do |key|
40
60
  next if key == 'securitySchemes'
41
61
  return false unless replace_inlines(obj[key], components)
42
62
  end
43
63
  return true
44
64
  end
45
- case t
46
- when 'array'
47
- return false unless replace_inlines(obj['items'], components)
48
- when 'object'
49
- %w[properties patternProperties].each do |prop_name|
50
- props = obj.fetch(prop_name, {})
51
- props.keys.sort!.each do |name|
52
- return false unless replace_inlines(props[name], components)
53
- end
54
- end
55
- return false unless replace_inlines(obj['additionalProperties'], components)
56
- end
57
65
  r = components.ref_string(top_level_name) || components.reference(obj)
58
66
  components.store_anchor(obj, r)
59
67
  components.to_reference_object(obj, r) if top_level_name.nil?
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)
@@ -87,6 +87,10 @@ module OpenAPISourceTools
87
87
  return d unless d.zero?
88
88
  @path <=> other.path
89
89
  end
90
+
91
+ def to_s
92
+ "Root: #{@root} [#{@keys.join(', ')}] #{@path}"
93
+ end
90
94
  end
91
95
 
92
96
  def self.prepare_prefix(name_prefix, root)
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright © 2026 Ismo Kärkkäinen
4
+ # Licensed under Universal Permissive License. See LICENSE.txt.
5
+
6
+ # Top-level module.
7
+ module OpenAPISourceTools
8
+ # Ordering methods and classes for openapi-order.
9
+ module Ordering
10
+ # Used as replacement for '*'.
11
+ ANY = Regexp.new('^.*$')
12
+
13
+ def self.array_item_compare(a, b)
14
+ a[:values].size.times do |k|
15
+ av = a[:values][k]
16
+ bv = b[:values][k]
17
+ if av.nil?
18
+ return 1 unless bv.nil?
19
+ else
20
+ return -1 if bv.nil?
21
+ r = av <=> bv
22
+ return r unless r.nil? || r.zero?
23
+ end
24
+ end
25
+ 0
26
+ end
27
+
28
+ # Allowed keys starting from root, with pattern, any or multiple any key.
29
+ # Initialized with the path to the order array.
30
+ class KeyPath
31
+ include Comparable
32
+
33
+ attr_reader :items, :multiple, :first_multiple
34
+
35
+ # the parent_keys is processed to an array of regular expressions and :multi.
36
+ # A '*' is eventually replaced with regular expression that accepts anything.
37
+ # A '**' is replaced by :multi that matches at least one key during match.
38
+ # A string starting with regexp_prefix is changed to a regular expression.
39
+ # Other string is turned to anchored regular expression that matches only the string.
40
+ # The order of '*' and '**' can be modified to minimize occurrences of :multi.
41
+ def initialize(parent_keys, regexp_prefix)
42
+ @multiple = false
43
+ @items = []
44
+ parent_keys.each do |key|
45
+ if key == '*'
46
+ if @items.last == :multi
47
+ # :multi, :one is same as :one, :multi as at least 2 arbitrary items.
48
+ @items.pop
49
+ @items.push(:one, :multi)
50
+ else
51
+ @items.push(:one)
52
+ end
53
+ elsif key == '**'
54
+ @multiple = true
55
+ if @items.last == :multi
56
+ # :multi, :multi is same as :one, :multi as at least 2 arbitrary items.
57
+ @items.pop
58
+ @items.push(:one, :multi)
59
+ else
60
+ @items.push(:multi)
61
+ end
62
+ elsif key.start_with?(regexp_prefix)
63
+ @items.push(Regexp.new(key[regexp_prefix.size..]))
64
+ else
65
+ @items.push(Regexp.new("^#{Regexp.escape(key)}$"))
66
+ end
67
+ end
68
+ @items.each_with_index do |item, idx|
69
+ @items[idx] = ANY if item == :one
70
+ end
71
+ @first_multiple = @items.index(:multi) || @items.size
72
+ end
73
+
74
+ # Finds indexes of regular expressions that match key, skipping given number.
75
+ def matching_item_indexes(key, skip_non_multis)
76
+ indexes = []
77
+ ([skip_non_multis, @first_multiple].min...@items.size).each do |idx|
78
+ item = @items[idx]
79
+ indexes.push(idx) if item == :multi || item.match?(key)
80
+ end
81
+ indexes
82
+ end
83
+
84
+ # Given possible matches, uses position_index to check for matches relevant
85
+ # to the current situation and checks recursively if a solution ending to
86
+ # the desired point can be found.
87
+ def search(index_arrays, array_index, position_index)
88
+ if array_index == index_arrays.size
89
+ # Last key is known to match last item, so if that were tested, it would pass.
90
+ return position_index == @items.size - 1
91
+ end
92
+ if @items[position_index] == :multi
93
+ # Multi absorbs current key and releases to next position.
94
+ return true if search(index_arrays, array_index + 1, position_index + 1)
95
+ # Multi absorbs current key and stays for more.
96
+ search(index_arrays, array_index + 1, position_index)
97
+ else
98
+ # Only current item to match with.
99
+ return false if index_arrays[array_index].index(position_index).nil?
100
+ search(index_arrays, array_index + 1, position_index + 1)
101
+ end
102
+ end
103
+
104
+ # Performs base checks whether match is possible and if so, obtains indexes
105
+ # od matching patterns and starts search using those.
106
+ def match?(key_path)
107
+ return true if @items.empty? && key_path.empty?
108
+ slack = key_path.size - @items.size
109
+ return false if slack.negative? # Path too short.
110
+ return false if slack.positive? && !@multiple # Path too long.
111
+ # Last key must match last item.
112
+ return false unless @items.last == :multi || @items.last.match?(key_path.last)
113
+ # First key must match first item.
114
+ return false unless @items.first == :multi || @items.first.match?(key_path.first)
115
+ return true if key_path.size <= 2 # Tested both already.
116
+ # Form arrays of possible match indexes for search. Omit first and last.
117
+ index_arrays = []
118
+ (1...(key_path.size - 1)).each do |idx|
119
+ key = key_path[idx]
120
+ idxs = matching_item_indexes(key, idx)
121
+ return false if idxs.empty?
122
+ index_arrays.push(idxs)
123
+ end
124
+ search(index_arrays, 0, @items.first == :multi ? 0 : 1)
125
+ end
126
+
127
+ def <=>(other)
128
+ # The purpose is to place longest "exact" items to the end.
129
+ # Hence items.size, and the more multiple, the earlier.
130
+ d = @items.size <=> other.items.size
131
+ return d unless d.zero?
132
+ -@items.count(:multi) <=> -other.items.count(:multi)
133
+ end
134
+ end
135
+
136
+ # Book-keeping class for pattern and its location.
137
+ class KeyPattern
138
+ attr_reader :pattern, :index, :priority
139
+
140
+ def initialize(pattern, index, priority)
141
+ @pattern = pattern
142
+ @index = index
143
+ @priority = priority
144
+ end
145
+
146
+ def match?(string)
147
+ @pattern.match?(string)
148
+ end
149
+
150
+ def any?
151
+ @index != @priority
152
+ end
153
+ end
154
+
155
+ # Handles ordering keys or an array using the given key order.
156
+ class Order
157
+ def self.orderable?(item)
158
+ return true if item.is_a?(Hash)
159
+ return true if item.is_a?(Array)
160
+ false
161
+ end
162
+
163
+ attr_reader :patterns
164
+
165
+ def initialize(keys, regexp_prefix)
166
+ @patterns = []
167
+ has_asterisk = false
168
+ keys.each_with_index do |string, idx|
169
+ if string.start_with?(regexp_prefix)
170
+ @patterns.push(KeyPattern.new(Regexp.new(string[regexp_prefix.size..]), idx, idx))
171
+ elsif string == '*'
172
+ @patterns.push(KeyPattern.new(Regexp.new('^.*$'), idx, keys.size)) unless has_asterisk # First used.
173
+ has_asterisk = true
174
+ else
175
+ @patterns.push(KeyPattern.new(Regexp.new("^#{Regexp.escape(string)}$"), idx, idx))
176
+ end
177
+ end
178
+ @patterns.push(KeyPattern.new(Regexp.new('^.*$'), @patterns.size, keys.size + 1)) unless has_asterisk
179
+ end
180
+
181
+ def apply_array(array)
182
+ return true if array.size < 2
183
+ values = []
184
+ array.each do |item|
185
+ amended = {
186
+ item: item,
187
+ values: []
188
+ }
189
+ unless item.is_a?(Hash)
190
+ amended[:values] = Array.new(@patterns.size, nil)
191
+ values.push(amended)
192
+ next
193
+ end
194
+ @patterns.each do |kp|
195
+ if kp.any?
196
+ amended[:values].push(nil)
197
+ next
198
+ end
199
+ # Find first matching key.
200
+ v = nil
201
+ item.each_key do |item_key|
202
+ next unless kp.match?(item_key)
203
+ v = item[item_key]
204
+ break
205
+ end
206
+ amended[:values].push((v.is_a?(Array) || v.is_a?(Hash)) ? nil : v)
207
+ end
208
+ values.push(amended)
209
+ end
210
+ values.sort! { |a, b| OpenAPISourceTools::Ordering.array_item_compare(a, b) }
211
+ values.each_with_index { |amended, i| array[i] = amended[:item] }
212
+ true
213
+ end
214
+
215
+ def apply_hash(hash)
216
+ return true if hash.size < 2
217
+ groups = @patterns.map do |kp|
218
+ {
219
+ kp: kp,
220
+ keys: []
221
+ }
222
+ end
223
+ s2orig = {}
224
+ hash.each_key do |key|
225
+ skey = key.is_a?(String) ? key : key.to_s
226
+ s2orig[skey] = key
227
+ best_priority = groups.size + 1
228
+ best_idx = nil
229
+ groups.each do |group|
230
+ kp = group[:kp]
231
+ next if best_priority <= kp.priority
232
+ next unless kp.match?(skey)
233
+ best_priority = kp.priority
234
+ best_idx = kp.index
235
+ end
236
+ groups[best_idx][:keys].push(skey) unless best_idx.nil?
237
+ end
238
+ ordered = {}
239
+ groups.each do |group|
240
+ group[:keys].sort!
241
+ group[:keys].each { |k| ordered[s2orig[k]] = hash[s2orig[k]] }
242
+ end
243
+ hash.clear
244
+ hash.merge!(ordered)
245
+ true
246
+ end
247
+
248
+ def apply(item)
249
+ return apply_array(item) if item.is_a?(Array)
250
+ return apply_hash(item) if item.is_a?(Hash)
251
+ false
252
+ end
253
+ end
254
+
255
+ # Holds KeyPath and the order that was specified.
256
+ class KeyPathOrder
257
+ include Comparable
258
+
259
+ attr_reader :path, :order
260
+
261
+ def initialize(key_path, order)
262
+ @path = key_path
263
+ @order = order
264
+ end
265
+
266
+ def <=>(other)
267
+ @path <=> other.path
268
+ end
269
+ end
270
+ end
271
+ end
@@ -5,7 +5,7 @@
5
5
 
6
6
  module OpenAPISourceTools
7
7
  NAME = 'openapi-sourcetools'
8
- VERSION = '0.11.0'
8
+ VERSION = '0.12.0'
9
9
 
10
10
  def self.info(separator = ': ')
11
11
  "#{NAME}#{separator}#{VERSION}"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openapi-sourcetools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismo Kärkkäinen
@@ -47,6 +47,7 @@ executables:
47
47
  - openapi-generate
48
48
  - openapi-merge
49
49
  - openapi-modifypaths
50
+ - openapi-order
50
51
  - openapi-patterntests
51
52
  - openapi-processpaths
52
53
  extensions: []
@@ -64,6 +65,7 @@ files:
64
65
  - bin/openapi-generate
65
66
  - bin/openapi-merge
66
67
  - bin/openapi-modifypaths
68
+ - bin/openapi-order
67
69
  - bin/openapi-patterntests
68
70
  - bin/openapi-processpaths
69
71
  - lib/openapi/sourcetools.rb
@@ -75,6 +77,7 @@ files:
75
77
  - lib/openapi/sourcetools/generate.rb
76
78
  - lib/openapi/sourcetools/helper.rb
77
79
  - lib/openapi/sourcetools/loaders.rb
80
+ - lib/openapi/sourcetools/order.rb
78
81
  - lib/openapi/sourcetools/output.rb
79
82
  - lib/openapi/sourcetools/task.rb
80
83
  - lib/openapi/sourcetools/version.rb
@@ -90,7 +93,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
90
93
  requirements:
91
94
  - - ">="
92
95
  - !ruby/object:Gem::Version
93
- version: 3.2.5
96
+ version: 3.3.11
94
97
  required_rubygems_version: !ruby/object:Gem::Requirement
95
98
  requirements:
96
99
  - - ">="