-
Notifications
You must be signed in to change notification settings - Fork 4.3k
/
Copy pathopenapi.go
935 lines (819 loc) · 30.9 KB
/
openapi.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
package framework
import (
"errors"
"fmt"
"reflect"
"regexp"
"regexp/syntax"
"sort"
"strconv"
"strings"
log "github.com/hashicorp/go-hclog"
"github.com/hashicorp/vault/sdk/helper/wrapping"
"github.com/hashicorp/vault/sdk/logical"
"github.com/mitchellh/mapstructure"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// OpenAPI specification (OAS): https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md
const OASVersion = "3.0.2"
// NewOASDocument returns an empty OpenAPI document.
func NewOASDocument(version string) *OASDocument {
return &OASDocument{
Version: OASVersion,
Info: OASInfo{
Title: "HashiCorp Vault API",
Description: "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.",
Version: version,
License: OASLicense{
Name: "Mozilla Public License 2.0",
URL: "https://www.mozilla.org/en-US/MPL/2.0",
},
},
Paths: make(map[string]*OASPathItem),
Components: OASComponents{
Schemas: make(map[string]*OASSchema),
},
}
}
// NewOASDocumentFromMap builds an OASDocument from an existing map version of a document.
// If a document has been decoded from JSON or received from a plugin, it will be as a map[string]interface{}
// and needs special handling beyond the default mapstructure decoding.
func NewOASDocumentFromMap(input map[string]interface{}) (*OASDocument, error) {
// The Responses map uses integer keys (the response code), but once translated into JSON
// (e.g. during the plugin transport) these become strings. mapstructure will not coerce these back
// to integers without a custom decode hook.
decodeHook := func(src reflect.Type, tgt reflect.Type, inputRaw interface{}) (interface{}, error) {
// Only alter data if:
// 1. going from string to int
// 2. string represent an int in status code range (100-599)
if src.Kind() == reflect.String && tgt.Kind() == reflect.Int {
if input, ok := inputRaw.(string); ok {
if intval, err := strconv.Atoi(input); err == nil {
if intval >= 100 && intval < 600 {
return intval, nil
}
}
}
}
return inputRaw, nil
}
doc := new(OASDocument)
config := &mapstructure.DecoderConfig{
DecodeHook: decodeHook,
Result: doc,
}
decoder, err := mapstructure.NewDecoder(config)
if err != nil {
return nil, err
}
if err := decoder.Decode(input); err != nil {
return nil, err
}
return doc, nil
}
type OASDocument struct {
Version string `json:"openapi" mapstructure:"openapi"`
Info OASInfo `json:"info"`
Paths map[string]*OASPathItem `json:"paths"`
Components OASComponents `json:"components"`
}
type OASComponents struct {
Schemas map[string]*OASSchema `json:"schemas"`
}
type OASInfo struct {
Title string `json:"title"`
Description string `json:"description"`
Version string `json:"version"`
License OASLicense `json:"license"`
}
type OASLicense struct {
Name string `json:"name"`
URL string `json:"url"`
}
type OASPathItem struct {
Description string `json:"description,omitempty"`
Parameters []OASParameter `json:"parameters,omitempty"`
Sudo bool `json:"x-vault-sudo,omitempty" mapstructure:"x-vault-sudo"`
Unauthenticated bool `json:"x-vault-unauthenticated,omitempty" mapstructure:"x-vault-unauthenticated"`
CreateSupported bool `json:"x-vault-createSupported,omitempty" mapstructure:"x-vault-createSupported"`
DisplayNavigation bool `json:"x-vault-displayNavigation,omitempty" mapstructure:"x-vault-displayNavigation"`
DisplayAttrs *DisplayAttributes `json:"x-vault-displayAttrs,omitempty" mapstructure:"x-vault-displayAttrs"`
Get *OASOperation `json:"get,omitempty"`
Post *OASOperation `json:"post,omitempty"`
Delete *OASOperation `json:"delete,omitempty"`
}
// NewOASOperation creates an empty OpenAPI Operations object.
func NewOASOperation() *OASOperation {
return &OASOperation{
Responses: make(map[int]*OASResponse),
}
}
type OASOperation struct {
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
OperationID string `json:"operationId,omitempty"`
Tags []string `json:"tags,omitempty"`
Parameters []OASParameter `json:"parameters,omitempty"`
RequestBody *OASRequestBody `json:"requestBody,omitempty"`
Responses map[int]*OASResponse `json:"responses"`
Deprecated bool `json:"deprecated,omitempty"`
}
type OASParameter struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
In string `json:"in"`
Schema *OASSchema `json:"schema,omitempty"`
Required bool `json:"required,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
}
type OASRequestBody struct {
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
Content OASContent `json:"content,omitempty"`
}
type OASContent map[string]*OASMediaTypeObject
type OASMediaTypeObject struct {
Schema *OASSchema `json:"schema,omitempty"`
}
type OASSchema struct {
Ref string `json:"$ref,omitempty"`
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Properties map[string]*OASSchema `json:"properties,omitempty"`
// Required is a list of keys in Properties that are required to be present. This is a different
// approach than OASParameter (unfortunately), but is how JSONSchema handles 'required'.
Required []string `json:"required,omitempty"`
Items *OASSchema `json:"items,omitempty"`
Format string `json:"format,omitempty"`
Pattern string `json:"pattern,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
Default interface{} `json:"default,omitempty"`
Example interface{} `json:"example,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
// DisplayName string `json:"x-vault-displayName,omitempty" mapstructure:"x-vault-displayName,omitempty"`
DisplayValue interface{} `json:"x-vault-displayValue,omitempty" mapstructure:"x-vault-displayValue,omitempty"`
DisplaySensitive bool `json:"x-vault-displaySensitive,omitempty" mapstructure:"x-vault-displaySensitive,omitempty"`
DisplayGroup string `json:"x-vault-displayGroup,omitempty" mapstructure:"x-vault-displayGroup,omitempty"`
DisplayAttrs *DisplayAttributes `json:"x-vault-displayAttrs,omitempty" mapstructure:"x-vault-displayAttrs,omitempty"`
}
type OASResponse struct {
Description string `json:"description"`
Content OASContent `json:"content,omitempty"`
}
var OASStdRespOK = &OASResponse{
Description: "OK",
}
var OASStdRespNoContent = &OASResponse{
Description: "empty body",
}
// Regex for handling fields in paths, and string cleanup.
// Predefined here to avoid substantial recompilation.
var (
nonWordRe = regexp.MustCompile(`[^\w]+`) // Match a sequence of non-word characters
pathFieldsRe = regexp.MustCompile(`{(\w+)}`) // Capture OpenAPI-style named parameters, e.g. "lookup/{urltoken}",
wsRe = regexp.MustCompile(`\s+`) // Match whitespace, to be compressed during cleaning
)
// documentPaths parses all paths in a framework.Backend into OpenAPI paths.
func documentPaths(backend *Backend, requestResponsePrefix string, doc *OASDocument) error {
for _, p := range backend.Paths {
if err := documentPath(p, backend.SpecialPaths(), requestResponsePrefix, backend.BackendType, doc); err != nil {
return err
}
}
return nil
}
// documentPath parses a framework.Path into one or more OpenAPI paths.
func documentPath(p *Path, specialPaths *logical.Paths, requestResponsePrefix string, backendType logical.BackendType, doc *OASDocument) error {
var sudoPaths []string
var unauthPaths []string
if specialPaths != nil {
sudoPaths = specialPaths.Root
unauthPaths = specialPaths.Unauthenticated
}
// Convert optional parameters into distinct patterns to be processed independently.
forceUnpublished := false
paths, err := expandPattern(p.Pattern)
if err != nil {
if errors.Is(err, errUnsupportableRegexpOperationForOpenAPI) {
// Pattern cannot be transformed into sensible OpenAPI paths. In this case, we override the later
// processing to use the regexp, as is, as the path, and behave as if Unpublished was set on every
// operation (meaning the operations will not be represented in the OpenAPI document).
//
// This allows a human reading the OpenAPI document to notice that, yes, a path handler does exist,
// even though it was not able to contribute actual OpenAPI operations.
forceUnpublished = true
paths = []string{p.Pattern}
} else {
return err
}
}
for _, path := range paths {
// Construct a top level PathItem which will be populated as the path is processed.
pi := OASPathItem{
Description: cleanString(p.HelpSynopsis),
}
pi.Sudo = specialPathMatch(path, sudoPaths)
pi.Unauthenticated = specialPathMatch(path, unauthPaths)
pi.DisplayAttrs = p.DisplayAttrs
// If the newer style Operations map isn't defined, create one from the legacy fields.
operations := p.Operations
if operations == nil {
operations = make(map[logical.Operation]OperationHandler)
for opType, cb := range p.Callbacks {
operations[opType] = &PathOperation{
Callback: cb,
Summary: p.HelpSynopsis,
}
}
}
// Process path and header parameters, which are common to all operations.
// Body fields will be added to individual operations.
pathFields, bodyFields := splitFields(p.Fields, path)
for name, field := range pathFields {
location := "path"
required := true
if field == nil {
continue
}
if field.Query {
location = "query"
required = false
}
t := convertType(field.Type)
p := OASParameter{
Name: name,
Description: cleanString(field.Description),
In: location,
Schema: &OASSchema{
Type: t.baseType,
Pattern: t.pattern,
Enum: field.AllowedValues,
Default: field.Default,
DisplayAttrs: field.DisplayAttrs,
},
Required: required,
Deprecated: field.Deprecated,
}
pi.Parameters = append(pi.Parameters, p)
}
// Sort parameters for a stable output
sort.Slice(pi.Parameters, func(i, j int) bool {
return strings.ToLower(pi.Parameters[i].Name) < strings.ToLower(pi.Parameters[j].Name)
})
// Process each supported operation by building up an Operation object
// with descriptions, properties and examples from the framework.Path data.
for opType, opHandler := range operations {
props := opHandler.Properties()
if props.Unpublished || forceUnpublished {
continue
}
if opType == logical.CreateOperation {
pi.CreateSupported = true
// If both Create and Update are defined, only process Update.
if operations[logical.UpdateOperation] != nil {
continue
}
}
// If both List and Read are defined, only process Read.
if opType == logical.ListOperation && operations[logical.ReadOperation] != nil {
continue
}
op := NewOASOperation()
op.Summary = props.Summary
op.Description = props.Description
op.Deprecated = props.Deprecated
// Add any fields not present in the path as body parameters for POST.
if opType == logical.CreateOperation || opType == logical.UpdateOperation {
s := &OASSchema{
Type: "object",
Properties: make(map[string]*OASSchema),
Required: make([]string, 0),
}
for name, field := range bodyFields {
// Removing this field from the spec as it is deprecated in favor of using "sha256"
// The duplicate sha_256 and sha256 in these paths cause issues with codegen
if name == "sha_256" && strings.Contains(path, "plugins/catalog/") {
continue
}
openapiField := convertType(field.Type)
if field.Required {
s.Required = append(s.Required, name)
}
p := OASSchema{
Type: openapiField.baseType,
Description: cleanString(field.Description),
Format: openapiField.format,
Pattern: openapiField.pattern,
Enum: field.AllowedValues,
Default: field.Default,
Deprecated: field.Deprecated,
DisplayAttrs: field.DisplayAttrs,
}
if openapiField.baseType == "array" {
p.Items = &OASSchema{
Type: openapiField.items,
}
}
s.Properties[name] = &p
}
// If examples were given, use the first one as the sample
// of this schema.
if len(props.Examples) > 0 {
s.Example = props.Examples[0].Data
}
// Set the final request body. Only JSON request data is supported.
if len(s.Properties) > 0 || s.Example != nil {
requestName := constructRequestResponseName(path, requestResponsePrefix, "Request")
doc.Components.Schemas[requestName] = s
op.RequestBody = &OASRequestBody{
Required: true,
Content: OASContent{
"application/json": &OASMediaTypeObject{
Schema: &OASSchema{Ref: fmt.Sprintf("#/components/schemas/%s", requestName)},
},
},
}
}
}
// LIST is represented as GET with a `list` query parameter.
if opType == logical.ListOperation {
// Only accepts List (due to the above skipping of ListOperations that also have ReadOperations)
op.Parameters = append(op.Parameters, OASParameter{
Name: "list",
Description: "Must be set to `true`",
Required: true,
In: "query",
Schema: &OASSchema{Type: "string", Enum: []interface{}{"true"}},
})
} else if opType == logical.ReadOperation && operations[logical.ListOperation] != nil {
// Accepts both Read and List
op.Parameters = append(op.Parameters, OASParameter{
Name: "list",
Description: "Return a list if `true`",
In: "query",
Schema: &OASSchema{Type: "string"},
})
}
// Add tags based on backend type
var tags []string
switch backendType {
case logical.TypeLogical:
tags = []string{"secrets"}
case logical.TypeCredential:
tags = []string{"auth"}
}
op.Tags = append(op.Tags, tags...)
// Set default responses.
if len(props.Responses) == 0 {
if opType == logical.DeleteOperation {
op.Responses[204] = OASStdRespNoContent
} else {
op.Responses[200] = OASStdRespOK
}
}
// Add any defined response details.
for code, responses := range props.Responses {
var description string
content := make(OASContent)
for i, resp := range responses {
if i == 0 {
description = resp.Description
}
if resp.Example != nil {
mediaType := resp.MediaType
if mediaType == "" {
mediaType = "application/json"
}
// create a version of the response that will not emit null items
cr := cleanResponse(resp.Example)
// Only one example per media type is allowed, so first one wins
if _, ok := content[mediaType]; !ok {
content[mediaType] = &OASMediaTypeObject{
Schema: &OASSchema{
Example: cr,
},
}
}
}
responseSchema := &OASSchema{
Type: "object",
Properties: make(map[string]*OASSchema),
}
for name, field := range resp.Fields {
openapiField := convertType(field.Type)
p := OASSchema{
Type: openapiField.baseType,
Description: cleanString(field.Description),
Format: openapiField.format,
Pattern: openapiField.pattern,
Enum: field.AllowedValues,
Default: field.Default,
Deprecated: field.Deprecated,
DisplayAttrs: field.DisplayAttrs,
}
if openapiField.baseType == "array" {
p.Items = &OASSchema{
Type: openapiField.items,
}
}
responseSchema.Properties[name] = &p
}
if len(resp.Fields) != 0 {
responseName := constructRequestResponseName(path, requestResponsePrefix, "Response")
doc.Components.Schemas[responseName] = responseSchema
content = OASContent{
"application/json": &OASMediaTypeObject{
Schema: &OASSchema{Ref: fmt.Sprintf("#/components/schemas/%s", responseName)},
},
}
}
}
op.Responses[code] = &OASResponse{
Description: description,
Content: content,
}
}
switch opType {
case logical.CreateOperation, logical.UpdateOperation:
pi.Post = op
case logical.ReadOperation, logical.ListOperation:
pi.Get = op
case logical.DeleteOperation:
pi.Delete = op
}
}
doc.Paths["/"+path] = &pi
}
return nil
}
// constructRequestResponseName joins the given path with prefix & suffix into
// a CamelCase request or response name.
//
// For example, path=/config/lease/{name}, prefix="secret", suffix="request"
// will result in "SecretConfigLeaseRequest"
func constructRequestResponseName(path, prefix, suffix string) string {
var b strings.Builder
title := cases.Title(language.English)
b.WriteString(title.String(prefix))
// split the path by / _ - separators
for _, token := range strings.FieldsFunc(path, func(r rune) bool {
return r == '/' || r == '_' || r == '-'
}) {
// exclude request fields
if !strings.ContainsAny(token, "{}") {
b.WriteString(title.String(token))
}
}
b.WriteString(suffix)
return b.String()
}
func specialPathMatch(path string, specialPaths []string) bool {
// Test for exact or prefix match of special paths.
for _, sp := range specialPaths {
if sp == path ||
(strings.HasSuffix(sp, "*") && strings.HasPrefix(path, sp[0:len(sp)-1])) {
return true
}
}
return false
}
// expandPattern expands a regex pattern by generating permutations of any optional parameters
// and changing named parameters into their {openapi} equivalents.
func expandPattern(pattern string) ([]string, error) {
// Happily, the Go regexp library exposes its underlying "parse to AST" functionality, so we can rely on that to do
// the hard work of interpreting the regexp syntax.
rx, err := syntax.Parse(pattern, syntax.Perl)
if err != nil {
// This should be impossible to reach, since regexps have previously been compiled with MustCompile in
// Backend.init.
panic(err)
}
paths, err := collectPathsFromRegexpAST(rx)
if err != nil {
return nil, err
}
return paths, nil
}
type pathCollector struct {
strings.Builder
conditionalSlashAppendedAtLength int
}
// collectPathsFromRegexpAST performs a depth-first recursive walk through a regexp AST, collecting an OpenAPI-style
// path as it goes.
//
// Each time it encounters alternation (a|b) or an optional part (a?), it forks its processing to produce additional
// results, to account for each possibility. Note: This does mean that an input pattern with lots of these regexp
// features can produce a lot of different OpenAPI endpoints. At the time of writing, the most complex known example is
//
// "issuer/" + framework.GenericNameRegex(issuerRefParam) + "/crl(/pem|/der|/delta(/pem|/der)?)?"
//
// in the PKI secrets engine which expands to 6 separate paths.
//
// Each named capture group - i.e. (?P<name>something here) - is replaced with an OpenAPI parameter - i.e. {name} - and
// the subtree of regexp AST inside the parameter is completely skipped.
func collectPathsFromRegexpAST(rx *syntax.Regexp) ([]string, error) {
pathCollectors, err := collectPathsFromRegexpASTInternal(rx, []*pathCollector{{}})
if err != nil {
return nil, err
}
paths := make([]string, 0, len(pathCollectors))
for _, collector := range pathCollectors {
if collector.conditionalSlashAppendedAtLength != collector.Len() {
paths = append(paths, collector.String())
}
}
return paths, nil
}
var errUnsupportableRegexpOperationForOpenAPI = errors.New("path regexp uses an operation that cannot be translated to an OpenAPI pattern")
func collectPathsFromRegexpASTInternal(rx *syntax.Regexp, appendingTo []*pathCollector) ([]*pathCollector, error) {
var err error
// Depending on the type of this regexp AST node (its Op, i.e. operation), figure out whether it contributes any
// characters to the URL path, and whether we need to recurse through child AST nodes.
//
// Each element of the appendingTo slice tracks a separate path, defined by the alternatives chosen when traversing
// the | and ? conditional regexp features, and new elements are added as each of these features are traversed.
//
// To share this slice across multiple recursive calls of this function, it is passed down as a parameter to each
// recursive call, potentially modified throughout this switch block, and passed back up as a return value at the
// end of this function - the parent call uses the return value to update its own local variable.
switch rx.Op {
// These AST operations are leaf nodes (no children), that match zero characters, so require no processing at all
case syntax.OpEmptyMatch: // e.g. (?:)
case syntax.OpBeginLine: // i.e. ^ when (?m)
case syntax.OpEndLine: // i.e. $ when (?m)
case syntax.OpBeginText: // i.e. \A, or ^ when (?-m)
case syntax.OpEndText: // i.e. \z, or $ when (?-m)
case syntax.OpWordBoundary: // i.e. \b
case syntax.OpNoWordBoundary: // i.e. \B
// OpConcat simply represents multiple parts of the pattern appearing one after the other, so just recurse through
// those pieces.
case syntax.OpConcat:
for _, child := range rx.Sub {
appendingTo, err = collectPathsFromRegexpASTInternal(child, appendingTo)
if err != nil {
return nil, err
}
}
// OpLiteral is a literal string in the pattern - append it to the paths we are building.
case syntax.OpLiteral:
for _, collector := range appendingTo {
collector.WriteString(string(rx.Rune))
}
// OpAlternate, i.e. a|b, means we clone all of the pathCollector instances we are currently accumulating paths
// into, and independently recurse through each alternate option.
case syntax.OpAlternate: // i.e |
var totalAppendingTo []*pathCollector
lastIndex := len(rx.Sub) - 1
for index, child := range rx.Sub {
var childAppendingTo []*pathCollector
if index == lastIndex {
// Optimization: last time through this loop, we can simply re-use the existing set of pathCollector
// instances, as we no longer need to preserve them unmodified to make further copies of.
childAppendingTo = appendingTo
} else {
for _, collector := range appendingTo {
newCollector := new(pathCollector)
newCollector.WriteString(collector.String())
newCollector.conditionalSlashAppendedAtLength = collector.conditionalSlashAppendedAtLength
childAppendingTo = append(childAppendingTo, newCollector)
}
}
childAppendingTo, err = collectPathsFromRegexpASTInternal(child, childAppendingTo)
if err != nil {
return nil, err
}
totalAppendingTo = append(totalAppendingTo, childAppendingTo...)
}
appendingTo = totalAppendingTo
// OpQuest, i.e. a?, is much like an alternation between exactly two options, one of which is the empty string.
case syntax.OpQuest:
child := rx.Sub[0]
var childAppendingTo []*pathCollector
for _, collector := range appendingTo {
newCollector := new(pathCollector)
newCollector.WriteString(collector.String())
newCollector.conditionalSlashAppendedAtLength = collector.conditionalSlashAppendedAtLength
childAppendingTo = append(childAppendingTo, newCollector)
}
childAppendingTo, err = collectPathsFromRegexpASTInternal(child, childAppendingTo)
if err != nil {
return nil, err
}
appendingTo = append(appendingTo, childAppendingTo...)
// Many Vault path patterns end with `/?` to accept paths that end with or without a slash. Our current
// convention for generating the OpenAPI is to strip away these slashes. To do that, this very special case
// detects when we just appended a single conditional slash, and records the length of the path at this point,
// so we can later discard this path variant, if nothing else is appended to it later.
if child.Op == syntax.OpLiteral && string(child.Rune) == "/" {
for _, collector := range childAppendingTo {
collector.conditionalSlashAppendedAtLength = collector.Len()
}
}
// OpCapture, i.e. ( ) or (?P<name> ), a capturing group
case syntax.OpCapture:
if rx.Name == "" {
// In Vault, an unnamed capturing group is not actually used for capturing.
// We treat it exactly the same as OpConcat.
for _, child := range rx.Sub {
appendingTo, err = collectPathsFromRegexpASTInternal(child, appendingTo)
if err != nil {
return nil, err
}
}
} else {
// A named capturing group is replaced with the OpenAPI parameter syntax, and the regexp inside the group
// is NOT added to the OpenAPI path.
for _, builder := range appendingTo {
builder.WriteRune('{')
builder.WriteString(rx.Name)
builder.WriteRune('}')
}
}
// Any other kind of operation is a problem, and will trigger an error, resulting in the pattern being left out of
// the OpenAPI entirely - that's better than generating a path which is incorrect.
//
// The Op types we expect to hit the default condition are:
//
// OpCharClass - i.e. [something]
// OpAnyCharNotNL - i.e. .
// OpAnyChar - i.e. (?s:.)
// OpStar - i.e. *
// OpPlus - i.e. +
// OpRepeat - i.e. {N}, {N,M}, etc.
//
// In any of these conditions, there is no sensible translation of the path to OpenAPI syntax. (Note, this only
// applies to these appearing outside of a named capture group, otherwise they are handled in the previous case.)
//
// At the time of writing, the only pattern in the builtin Vault plugins that hits this codepath is the ".*"
// pattern in the KVv2 secrets engine, which is not a valid path, but rather, is a catch-all used to implement
// custom error handling behaviour to guide users who attempt to treat a KVv2 as a KVv1. It is already marked as
// Unpublished, so is withheld from the OpenAPI anyway.
//
// For completeness, one other Op type exists, OpNoMatch, which is never generated by syntax.Parse - only by
// subsequent Simplify in preparation to Compile, which is not used here.
default:
return nil, errUnsupportableRegexpOperationForOpenAPI
}
return appendingTo, nil
}
// schemaType is a subset of the JSON Schema elements used as a target
// for conversions from Vault's standard FieldTypes.
type schemaType struct {
baseType string
items string
format string
pattern string
}
// convertType translates a FieldType into an OpenAPI type.
// In the case of arrays, a subtype is returned as well.
func convertType(t FieldType) schemaType {
ret := schemaType{}
switch t {
case TypeString, TypeHeader:
ret.baseType = "string"
case TypeNameString:
ret.baseType = "string"
ret.pattern = `\w([\w-.]*\w)?`
case TypeLowerCaseString:
ret.baseType = "string"
ret.format = "lowercase"
case TypeInt:
ret.baseType = "integer"
case TypeInt64:
ret.baseType = "integer"
ret.format = "int64"
case TypeDurationSecond, TypeSignedDurationSecond:
ret.baseType = "integer"
ret.format = "seconds"
case TypeBool:
ret.baseType = "boolean"
case TypeMap:
ret.baseType = "object"
ret.format = "map"
case TypeKVPairs:
ret.baseType = "object"
ret.format = "kvpairs"
case TypeSlice:
ret.baseType = "array"
ret.items = "object"
case TypeStringSlice, TypeCommaStringSlice:
ret.baseType = "array"
ret.items = "string"
case TypeCommaIntSlice:
ret.baseType = "array"
ret.items = "integer"
case TypeTime:
ret.baseType = "string"
ret.format = "date-time"
case TypeFloat:
ret.baseType = "number"
ret.format = "float"
default:
log.L().Warn("error parsing field type", "type", t)
ret.format = "unknown"
}
return ret
}
// cleanString prepares s for inclusion in the output
func cleanString(s string) string {
// clean leading/trailing whitespace, and replace whitespace runs into a single space
s = strings.TrimSpace(s)
s = wsRe.ReplaceAllString(s, " ")
return s
}
// splitFields partitions fields into path and body groups
// The input pattern is expected to have been run through expandPattern,
// with paths parameters denotes in {braces}.
func splitFields(allFields map[string]*FieldSchema, pattern string) (pathFields, bodyFields map[string]*FieldSchema) {
pathFields = make(map[string]*FieldSchema)
bodyFields = make(map[string]*FieldSchema)
for _, match := range pathFieldsRe.FindAllStringSubmatch(pattern, -1) {
name := match[1]
pathFields[name] = allFields[name]
}
for name, field := range allFields {
if _, ok := pathFields[name]; !ok {
if field.Query {
pathFields[name] = field
} else {
bodyFields[name] = field
}
}
}
return pathFields, bodyFields
}
// cleanedResponse is identical to logical.Response but with nulls
// removed from from JSON encoding
type cleanedResponse struct {
Secret *logical.Secret `json:"secret,omitempty"`
Auth *logical.Auth `json:"auth,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
Redirect string `json:"redirect,omitempty"`
Warnings []string `json:"warnings,omitempty"`
WrapInfo *wrapping.ResponseWrapInfo `json:"wrap_info,omitempty"`
Headers map[string][]string `json:"headers,omitempty"`
}
func cleanResponse(resp *logical.Response) *cleanedResponse {
return &cleanedResponse{
Secret: resp.Secret,
Auth: resp.Auth,
Data: resp.Data,
Redirect: resp.Redirect,
Warnings: resp.Warnings,
WrapInfo: resp.WrapInfo,
Headers: resp.Headers,
}
}
// CreateOperationIDs generates unique operationIds for all paths/methods.
// The transform will convert path/method into camelcase. e.g.:
//
// /sys/tools/random/{urlbytes} -> postSysToolsRandomUrlbytes
//
// In the unlikely case of a duplicate ids, a numeric suffix is added:
//
// postSysToolsRandomUrlbytes_2
//
// An optional user-provided suffix ("context") may also be appended.
func (d *OASDocument) CreateOperationIDs(context string) {
opIDCount := make(map[string]int)
var paths []string
// traverse paths in a stable order to ensure stable output
for path := range d.Paths {
paths = append(paths, path)
}
sort.Strings(paths)
for _, path := range paths {
pi := d.Paths[path]
for _, method := range []string{"get", "post", "delete"} {
var oasOperation *OASOperation
switch method {
case "get":
oasOperation = pi.Get
case "post":
oasOperation = pi.Post
case "delete":
oasOperation = pi.Delete
}
if oasOperation == nil {
continue
}
// Discard "_mount_path" from any {thing_mount_path} parameters
path = strings.Replace(path, "_mount_path", "", 1)
// Space-split on non-words, title case everything, recombine
opID := nonWordRe.ReplaceAllString(strings.ToLower(path), " ")
opID = strings.Title(opID)
opID = method + strings.ReplaceAll(opID, " ", "")
// deduplicate operationIds. This is a safeguard, since generated IDs should
// already be unique given our current path naming conventions.
opIDCount[opID]++
if opIDCount[opID] > 1 {
opID = fmt.Sprintf("%s_%d", opID, opIDCount[opID])
}
if context != "" {
opID += "_" + context
}
oasOperation.OperationID = opID
}
}
}