mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-11-03 03:58:01 +00:00
Framework and API changes to support OpenAPI (#5546)
This commit is contained in:
@@ -14,7 +14,6 @@ import (
|
|||||||
|
|
||||||
"github.com/hashicorp/errwrap"
|
"github.com/hashicorp/errwrap"
|
||||||
log "github.com/hashicorp/go-hclog"
|
log "github.com/hashicorp/go-hclog"
|
||||||
|
|
||||||
"github.com/hashicorp/go-multierror"
|
"github.com/hashicorp/go-multierror"
|
||||||
"github.com/hashicorp/vault/helper/errutil"
|
"github.com/hashicorp/vault/helper/errutil"
|
||||||
"github.com/hashicorp/vault/helper/license"
|
"github.com/hashicorp/vault/helper/license"
|
||||||
@@ -202,15 +201,22 @@ func (b *Backend) HandleRequest(ctx context.Context, req *logical.Request) (*log
|
|||||||
raw[k] = v
|
raw[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the callback for this operation
|
// Look up the callback for this operation, preferring the
|
||||||
|
// path.Operations definition if present.
|
||||||
var callback OperationFunc
|
var callback OperationFunc
|
||||||
var ok bool
|
|
||||||
if path.Callbacks != nil {
|
if path.Operations != nil {
|
||||||
callback, ok = path.Callbacks[req.Operation]
|
if op, ok := path.Operations[req.Operation]; ok {
|
||||||
|
callback = op.Handler()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback = path.Callbacks[req.Operation]
|
||||||
}
|
}
|
||||||
|
ok := callback != nil
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
if req.Operation == logical.HelpOperation {
|
if req.Operation == logical.HelpOperation {
|
||||||
callback = path.helpCallback()
|
callback = path.helpCallback(b)
|
||||||
ok = true
|
ok = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +235,6 @@ func (b *Backend) HandleRequest(ctx context.Context, req *logical.Request) (*log
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the callback with the request and the data
|
|
||||||
return callback(ctx, req, &fd)
|
return callback(ctx, req, &fd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +375,13 @@ func (b *Backend) handleRootHelp() (*logical.Response, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return logical.HelpResponse(help, nil), nil
|
// Build OpenAPI response for the entire backend
|
||||||
|
doc := NewOASDocument()
|
||||||
|
if err := documentPaths(b, doc); err != nil {
|
||||||
|
b.Logger().Warn("error generating OpenAPI", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logical.HelpResponse(help, nil, doc), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Backend) handleRevokeRenew(ctx context.Context, req *logical.Request) (*logical.Response, error) {
|
func (b *Backend) handleRevokeRenew(ctx context.Context, req *logical.Request) (*logical.Response, error) {
|
||||||
@@ -492,6 +503,8 @@ type FieldSchema struct {
|
|||||||
Type FieldType
|
Type FieldType
|
||||||
Default interface{}
|
Default interface{}
|
||||||
Description string
|
Description string
|
||||||
|
Required bool
|
||||||
|
Deprecated bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultOrZero returns the default value if it is set, or otherwise
|
// DefaultOrZero returns the default value if it is set, or otherwise
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ package framework
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/hashicorp/vault/logical"
|
"github.com/hashicorp/vault/logical"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,10 +52,17 @@ func TestBackendHandleRequest(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
handler := func(ctx context.Context, req *logical.Request, data *FieldData) (*logical.Response, error) {
|
||||||
|
return &logical.Response{
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"amount": data.Get("amount"),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
b := &Backend{
|
b := &Backend{
|
||||||
Paths: []*Path{
|
Paths: []*Path{
|
||||||
&Path{
|
{
|
||||||
Pattern: "foo/bar",
|
Pattern: "foo/bar",
|
||||||
Fields: map[string]*FieldSchema{
|
Fields: map[string]*FieldSchema{
|
||||||
"value": &FieldSchema{Type: TypeInt},
|
"value": &FieldSchema{Type: TypeInt},
|
||||||
@@ -64,19 +71,46 @@ func TestBackendHandleRequest(t *testing.T) {
|
|||||||
logical.ReadOperation: callback,
|
logical.ReadOperation: callback,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Pattern: "foo/baz/handler",
|
||||||
|
Fields: map[string]*FieldSchema{
|
||||||
|
"amount": &FieldSchema{Type: TypeInt},
|
||||||
|
},
|
||||||
|
Operations: map[logical.Operation]OperationHandler{
|
||||||
|
logical.ReadOperation: &PathOperation{Callback: handler},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Pattern: "foo/both/handler",
|
||||||
|
Fields: map[string]*FieldSchema{
|
||||||
|
"amount": &FieldSchema{Type: TypeInt},
|
||||||
|
},
|
||||||
|
Callbacks: map[logical.Operation]OperationFunc{
|
||||||
|
logical.ReadOperation: callback,
|
||||||
|
},
|
||||||
|
Operations: map[logical.Operation]OperationHandler{
|
||||||
|
logical.ReadOperation: &PathOperation{Callback: handler},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
for _, path := range []string{"foo/bar", "foo/baz/handler", "foo/both/handler"} {
|
||||||
Operation: logical.ReadOperation,
|
key := "value"
|
||||||
Path: "foo/bar",
|
if strings.Contains(path, "handler") {
|
||||||
Data: map[string]interface{}{"value": "42"},
|
key = "amount"
|
||||||
})
|
}
|
||||||
if err != nil {
|
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
||||||
t.Fatalf("err: %s", err)
|
Operation: logical.ReadOperation,
|
||||||
}
|
Path: path,
|
||||||
if resp.Data["value"] != 42 {
|
Data: map[string]interface{}{key: "42"},
|
||||||
t.Fatalf("bad: %#v", resp)
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %s", err)
|
||||||
|
}
|
||||||
|
if resp.Data[key] != 42 {
|
||||||
|
t.Fatalf("bad: %#v", resp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
607
logical/framework/openapi.go
Normal file
607
logical/framework/openapi.go
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
package framework
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/hashicorp/go-hclog"
|
||||||
|
"github.com/hashicorp/vault/helper/wrapping"
|
||||||
|
"github.com/hashicorp/vault/logical"
|
||||||
|
"github.com/hashicorp/vault/version"
|
||||||
|
"github.com/mitchellh/mapstructure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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() *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.GetVersion().Version,
|
||||||
|
License: OASLicense{
|
||||||
|
Name: "Mozilla Public License 2.0",
|
||||||
|
URL: "https://www.mozilla.org/en-US/MPL/2.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Paths: make(map[string]*OASPathItem),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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-create-supported,omitempty" mapstructure:"x-vault-create-supported"`
|
||||||
|
|
||||||
|
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"`
|
||||||
|
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"`
|
||||||
|
Content OASContent `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OASContent map[string]*OASMediaTypeObject
|
||||||
|
|
||||||
|
type OASMediaTypeObject struct {
|
||||||
|
Schema *OASSchema `json:"schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OASSchema struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Properties map[string]*OASSchema `json:"properties,omitempty"`
|
||||||
|
Items *OASSchema `json:"items,omitempty"`
|
||||||
|
Format string `json:"format,omitempty"`
|
||||||
|
Pattern string `json:"pattern,omitempty"`
|
||||||
|
Example interface{} `json:"example,omitempty"`
|
||||||
|
Deprecated bool `json:"deprecated,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 optional and named parameters in paths, and string cleanup.
|
||||||
|
// Predefined here to avoid substantial recompilation.
|
||||||
|
|
||||||
|
// Capture optional path elements in ungreedy (?U) fashion
|
||||||
|
// Both "(leases/)?renew" and "(/(?P<name>.+))?" formats are detected
|
||||||
|
var optRe = regexp.MustCompile(`(?U)\([^(]*\)\?|\(/\(\?P<[^(]*\)\)\?`)
|
||||||
|
|
||||||
|
var reqdRe = regexp.MustCompile(`\(?\?P<(\w+)>[^)]*\)?`) // Capture required parameters, e.g. "(?P<name>regex)"
|
||||||
|
var altRe = regexp.MustCompile(`\((.*)\|(.*)\)`) // Capture alternation elements, e.g. "(raw/?$|raw/(?P<path>.+))"
|
||||||
|
var pathFieldsRe = regexp.MustCompile(`{(\w+)}`) // Capture OpenAPI-style named parameters, e.g. "lookup/{urltoken}",
|
||||||
|
var cleanCharsRe = regexp.MustCompile("[()^$?]") // Set of regex characters that will be stripped during cleaning
|
||||||
|
var cleanSuffixRe = regexp.MustCompile(`/\?\$?$`) // Path suffix patterns that will be stripped during cleaning
|
||||||
|
var 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, doc *OASDocument) error {
|
||||||
|
for _, p := range backend.Paths {
|
||||||
|
if err := documentPath(p, backend.SpecialPaths(), 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, 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 process independently.
|
||||||
|
paths := expandPattern(p.Pattern)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Header parameters are part of the Parameters group but with
|
||||||
|
// a dedicated "header" location, a header parameter is not required.
|
||||||
|
if field.Type == TypeHeader {
|
||||||
|
location = "header"
|
||||||
|
required = false
|
||||||
|
}
|
||||||
|
|
||||||
|
t := convertType(field.Type)
|
||||||
|
p := OASParameter{
|
||||||
|
Name: name,
|
||||||
|
Description: cleanString(field.Description),
|
||||||
|
In: location,
|
||||||
|
Schema: &OASSchema{
|
||||||
|
Type: t.baseType,
|
||||||
|
Pattern: t.pattern,
|
||||||
|
},
|
||||||
|
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 {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, field := range bodyFields {
|
||||||
|
openapiField := convertType(field.Type)
|
||||||
|
p := OASSchema{
|
||||||
|
Type: openapiField.baseType,
|
||||||
|
Description: cleanString(field.Description),
|
||||||
|
Format: openapiField.format,
|
||||||
|
Pattern: openapiField.pattern,
|
||||||
|
Deprecated: field.Deprecated,
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
op.RequestBody = &OASRequestBody{
|
||||||
|
Content: OASContent{
|
||||||
|
"application/json": &OASMediaTypeObject{
|
||||||
|
Schema: s,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIST is represented as GET with a `list` query parameter
|
||||||
|
if opType == logical.ListOperation || (opType == logical.ReadOperation && operations[logical.ListOperation] != nil) {
|
||||||
|
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, err := cleanResponse(resp.Example)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only one example per media type is allowed, so first one wins
|
||||||
|
if _, ok := content[mediaType]; !ok {
|
||||||
|
content[mediaType] = &OASMediaTypeObject{
|
||||||
|
Schema: &OASSchema{
|
||||||
|
Example: cr,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
// GenericNameRegex adds a regex that complicates our parsing. It is much easier to
|
||||||
|
// detect and remove it now than to compensate for in the other regexes.
|
||||||
|
//
|
||||||
|
// example: (?P<foo>\\w(([\\w-.]+)?\\w)?) -> (?P<foo>)
|
||||||
|
base := GenericNameRegex("")
|
||||||
|
start := strings.Index(base, ">")
|
||||||
|
end := strings.LastIndex(base, ")")
|
||||||
|
regexToRemove := ""
|
||||||
|
if start != -1 && end != -1 && end > start {
|
||||||
|
regexToRemove = base[start+1 : end]
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern = strings.Replace(pattern, regexToRemove, "", -1)
|
||||||
|
|
||||||
|
// Initialize paths with the original pattern or the halves of an
|
||||||
|
// alternation, which is also present in some patterns.
|
||||||
|
matches := altRe.FindAllStringSubmatch(pattern, -1)
|
||||||
|
if len(matches) > 0 {
|
||||||
|
paths = []string{matches[0][1], matches[0][2]}
|
||||||
|
} else {
|
||||||
|
paths = []string{pattern}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand all optional regex elements into two paths. This approach is really only useful up to 2 optional
|
||||||
|
// groups, but we probably don't want to deal with the exponential increase beyond that anyway.
|
||||||
|
for i := 0; i < len(paths); i++ {
|
||||||
|
p := paths[i]
|
||||||
|
|
||||||
|
// match is a 2-element slice that will have a start and end index
|
||||||
|
// for the left-most match of a regex of form: (lease/)?
|
||||||
|
match := optRe.FindStringIndex(p)
|
||||||
|
|
||||||
|
if match != nil {
|
||||||
|
// create a path that includes the optional element but without
|
||||||
|
// parenthesis or the '?' character.
|
||||||
|
paths[i] = p[:match[0]] + p[match[0]+1:match[1]-2] + p[match[1]:]
|
||||||
|
|
||||||
|
// create a path that excludes the optional element.
|
||||||
|
paths = append(paths, p[:match[0]]+p[match[1]:])
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace named parameters (?P<foo>) with {foo}
|
||||||
|
var replacedPaths []string
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
result := reqdRe.FindAllStringSubmatch(path, -1)
|
||||||
|
if result != nil {
|
||||||
|
for _, p := range result {
|
||||||
|
par := p[1]
|
||||||
|
path = strings.Replace(path, p[0], fmt.Sprintf("{%s}", par), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Final cleanup
|
||||||
|
path = cleanSuffixRe.ReplaceAllString(path, "")
|
||||||
|
path = cleanCharsRe.ReplaceAllString(path, "")
|
||||||
|
replacedPaths = append(replacedPaths, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return replacedPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = "number"
|
||||||
|
case TypeDurationSecond:
|
||||||
|
ret.baseType = "number"
|
||||||
|
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 = "number"
|
||||||
|
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 {
|
||||||
|
// Header fields are in "parameters" with other path fields
|
||||||
|
if field.Type == TypeHeader {
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanResponse(resp *logical.Response) (*cleanedResponse, error) {
|
||||||
|
var r cleanedResponse
|
||||||
|
|
||||||
|
if err := mapstructure.Decode(resp, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
468
logical/framework/openapi_test.go
Normal file
468
logical/framework/openapi_test.go
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
package framework
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-test/deep"
|
||||||
|
"github.com/hashicorp/vault/helper/jsonutil"
|
||||||
|
"github.com/hashicorp/vault/logical"
|
||||||
|
"github.com/hashicorp/vault/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenAPI_Regex(t *testing.T) {
|
||||||
|
t.Run("Required", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
captures []string
|
||||||
|
}{
|
||||||
|
{`/foo/bar/(?P<val>.*)`, []string{"val"}},
|
||||||
|
{`/foo/bar/` + GenericNameRegex("val"), []string{"val"}},
|
||||||
|
{`/foo/bar/` + GenericNameRegex("first") + "/b/" + GenericNameRegex("second"), []string{"first", "second"}},
|
||||||
|
{`/foo/bar`, []string{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
result := reqdRe.FindAllStringSubmatch(test.input, -1)
|
||||||
|
if len(result) != len(test.captures) {
|
||||||
|
t.Fatalf("Capture error (%s): expected %d matches, actual: %d", test.input, len(test.captures), len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(result); i++ {
|
||||||
|
if result[i][1] != test.captures[i] {
|
||||||
|
t.Fatalf("Capture error (%s): expected %s, actual: %s", test.input, test.captures[i], result[i][1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("Optional", func(t *testing.T) {
|
||||||
|
input := "foo/(maybe/)?bar"
|
||||||
|
expStart := len("foo/")
|
||||||
|
expEnd := len(input) - len("bar")
|
||||||
|
|
||||||
|
match := optRe.FindStringIndex(input)
|
||||||
|
if diff := deep.Equal(match, []int{expStart, expEnd}); diff != nil {
|
||||||
|
t.Fatal(diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
input = "/foo/maybe/bar"
|
||||||
|
match = optRe.FindStringIndex(input)
|
||||||
|
if match != nil {
|
||||||
|
t.Fatalf("Expected nil match (%s), got %+v", input, match)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("Alternation", func(t *testing.T) {
|
||||||
|
input := `(raw/?$|raw/(?P<path>.+))`
|
||||||
|
|
||||||
|
matches := altRe.FindAllStringSubmatch(input, -1)
|
||||||
|
exp1 := "raw/?$"
|
||||||
|
exp2 := "raw/(?P<path>.+)"
|
||||||
|
if matches[0][1] != exp1 || matches[0][2] != exp2 {
|
||||||
|
t.Fatalf("Capture error. Expected %s and %s, got %v", exp1, exp2, matches[0][1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
input = `/foo/bar/` + GenericNameRegex("val")
|
||||||
|
|
||||||
|
matches = altRe.FindAllStringSubmatch(input, -1)
|
||||||
|
if matches != nil {
|
||||||
|
t.Fatalf("Expected nil match (%s), got %+v", input, matches)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("Path fields", func(t *testing.T) {
|
||||||
|
input := `/foo/bar/{inner}/baz/{outer}`
|
||||||
|
|
||||||
|
matches := pathFieldsRe.FindAllStringSubmatch(input, -1)
|
||||||
|
|
||||||
|
exp1 := "inner"
|
||||||
|
exp2 := "outer"
|
||||||
|
if matches[0][1] != exp1 || matches[1][1] != exp2 {
|
||||||
|
t.Fatalf("Capture error. Expected %s and %s, got %v", exp1, exp2, matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
input = `/foo/bar/inner/baz/outer`
|
||||||
|
matches = pathFieldsRe.FindAllStringSubmatch(input, -1)
|
||||||
|
|
||||||
|
if matches != nil {
|
||||||
|
t.Fatalf("Expected nil match (%s), got %+v", input, matches)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("Filtering", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
regex *regexp.Regexp
|
||||||
|
output string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
input: `ab?cde^fg(hi?j$k`,
|
||||||
|
regex: cleanCharsRe,
|
||||||
|
output: "abcdefghijk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `abcde/?`,
|
||||||
|
regex: cleanSuffixRe,
|
||||||
|
output: "abcde",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `abcde/?$`,
|
||||||
|
regex: cleanSuffixRe,
|
||||||
|
output: "abcde",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `abcde`,
|
||||||
|
regex: wsRe,
|
||||||
|
output: "abcde",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: ` a b cd e `,
|
||||||
|
regex: wsRe,
|
||||||
|
output: "abcde",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
result := test.regex.ReplaceAllString(test.input, "")
|
||||||
|
if result != test.output {
|
||||||
|
t.Fatalf("Clean Regex error (%s). Expected %s, got %s", test.input, test.output, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAPI_ExpandPattern(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in_pattern string
|
||||||
|
out_pathlets []string
|
||||||
|
}{
|
||||||
|
{"rekey/backup", []string{"rekey/backup"}},
|
||||||
|
{"rekey/backup$", []string{"rekey/backup"}},
|
||||||
|
{"auth/(?P<path>.+?)/tune$", []string{"auth/{path}/tune"}},
|
||||||
|
{"auth/(?P<path>.+?)/tune/(?P<more>.*?)$", []string{"auth/{path}/tune/{more}"}},
|
||||||
|
{"tools/hash(/(?P<urlalgorithm>.+))?", []string{
|
||||||
|
"tools/hash",
|
||||||
|
"tools/hash/{urlalgorithm}",
|
||||||
|
}},
|
||||||
|
{"(leases/)?renew(/(?P<url_lease_id>.+))?", []string{
|
||||||
|
"leases/renew",
|
||||||
|
"leases/renew/{url_lease_id}",
|
||||||
|
"renew",
|
||||||
|
"renew/{url_lease_id}",
|
||||||
|
}},
|
||||||
|
{`config/ui/headers/` + GenericNameRegex("header"), []string{"config/ui/headers/{header}"}},
|
||||||
|
{`leases/lookup/(?P<prefix>.+?)?`, []string{
|
||||||
|
"leases/lookup/",
|
||||||
|
"leases/lookup/{prefix}",
|
||||||
|
}},
|
||||||
|
{`(raw/?$|raw/(?P<path>.+))`, []string{
|
||||||
|
"raw",
|
||||||
|
"raw/{path}",
|
||||||
|
}},
|
||||||
|
{"lookup" + OptionalParamRegex("urltoken"), []string{
|
||||||
|
"lookup",
|
||||||
|
"lookup/{urltoken}",
|
||||||
|
}},
|
||||||
|
{"roles/?$", []string{
|
||||||
|
"roles",
|
||||||
|
}},
|
||||||
|
{"roles/?", []string{
|
||||||
|
"roles",
|
||||||
|
}},
|
||||||
|
{"accessors/$", []string{
|
||||||
|
"accessors/",
|
||||||
|
}},
|
||||||
|
{"verify/" + GenericNameRegex("name") + OptionalParamRegex("urlalgorithm"), []string{
|
||||||
|
"verify/{name}",
|
||||||
|
"verify/{name}/{urlalgorithm}",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
out := expandPattern(test.in_pattern)
|
||||||
|
sort.Strings(out)
|
||||||
|
if !reflect.DeepEqual(out, test.out_pathlets) {
|
||||||
|
t.Fatalf("Test %d: Expected %v got %v", i, test.out_pathlets, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAPI_SplitFields(t *testing.T) {
|
||||||
|
fields := map[string]*FieldSchema{
|
||||||
|
"a": {Description: "path"},
|
||||||
|
"b": {Description: "body"},
|
||||||
|
"c": {Description: "body"},
|
||||||
|
"d": {Description: "body"},
|
||||||
|
"e": {Description: "path"},
|
||||||
|
}
|
||||||
|
|
||||||
|
pathFields, bodyFields := splitFields(fields, "some/{a}/path/{e}")
|
||||||
|
|
||||||
|
lp := len(pathFields)
|
||||||
|
lb := len(bodyFields)
|
||||||
|
l := len(fields)
|
||||||
|
if lp+lb != l {
|
||||||
|
t.Fatalf("split length error: %d + %d != %d", lp, lb, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, field := range pathFields {
|
||||||
|
if field.Description != "path" {
|
||||||
|
t.Fatalf("expected field %s to be in 'path', found in %s", name, field.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, field := range bodyFields {
|
||||||
|
if field.Description != "body" {
|
||||||
|
t.Fatalf("expected field %s to be in 'body', found in %s", name, field.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAPI_SpecialPaths(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
pattern string
|
||||||
|
rootPaths []string
|
||||||
|
root bool
|
||||||
|
unauthPaths []string
|
||||||
|
unauth bool
|
||||||
|
}{
|
||||||
|
{"foo", []string{}, false, []string{"foo"}, true},
|
||||||
|
{"foo", []string{"foo"}, true, []string{"bar"}, false},
|
||||||
|
{"foo/bar", []string{"foo"}, false, []string{"foo/*"}, true},
|
||||||
|
{"foo/bar", []string{"foo/*"}, true, []string{"foo"}, false},
|
||||||
|
{"foo/", []string{"foo/*"}, true, []string{"a", "b", "foo/"}, true},
|
||||||
|
{"foo", []string{"foo*"}, true, []string{"a", "fo*"}, true},
|
||||||
|
{"foo/bar", []string{"a", "b", "foo/*"}, true, []string{"foo/baz/*"}, false},
|
||||||
|
}
|
||||||
|
for i, test := range tests {
|
||||||
|
doc := NewOASDocument()
|
||||||
|
path := Path{
|
||||||
|
Pattern: test.pattern,
|
||||||
|
}
|
||||||
|
sp := &logical.Paths{
|
||||||
|
Root: test.rootPaths,
|
||||||
|
Unauthenticated: test.unauthPaths,
|
||||||
|
}
|
||||||
|
documentPath(&path, sp, logical.TypeLogical, doc)
|
||||||
|
result := test.root
|
||||||
|
if doc.Paths["/"+test.pattern].Sudo != result {
|
||||||
|
t.Fatalf("Test (root) %d: Expected %v got %v", i, test.root, result)
|
||||||
|
}
|
||||||
|
result = test.unauth
|
||||||
|
if doc.Paths["/"+test.pattern].Unauthenticated != result {
|
||||||
|
t.Fatalf("Test (unauth) %d: Expected %v got %v", i, test.unauth, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAPI_Paths(t *testing.T) {
|
||||||
|
origDepth := deep.MaxDepth
|
||||||
|
defer func() { deep.MaxDepth = origDepth }()
|
||||||
|
deep.MaxDepth = 20
|
||||||
|
|
||||||
|
t.Run("Legacy callbacks", func(t *testing.T) {
|
||||||
|
p := &Path{
|
||||||
|
Pattern: "lookup/" + GenericNameRegex("id"),
|
||||||
|
|
||||||
|
Fields: map[string]*FieldSchema{
|
||||||
|
"id": &FieldSchema{
|
||||||
|
Type: TypeString,
|
||||||
|
Description: "My id parameter",
|
||||||
|
},
|
||||||
|
"token": &FieldSchema{
|
||||||
|
Type: TypeString,
|
||||||
|
Description: "My token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Callbacks: map[logical.Operation]OperationFunc{
|
||||||
|
logical.ReadOperation: nil,
|
||||||
|
logical.UpdateOperation: nil,
|
||||||
|
},
|
||||||
|
|
||||||
|
HelpSynopsis: "Synopsis",
|
||||||
|
HelpDescription: "Description",
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := &logical.Paths{
|
||||||
|
Root: []string{},
|
||||||
|
Unauthenticated: []string{},
|
||||||
|
}
|
||||||
|
testPath(t, p, sp, expected("legacy"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Operations", func(t *testing.T) {
|
||||||
|
p := &Path{
|
||||||
|
Pattern: "foo/" + GenericNameRegex("id"),
|
||||||
|
Fields: map[string]*FieldSchema{
|
||||||
|
"id": {
|
||||||
|
Type: TypeString,
|
||||||
|
Description: "id path parameter",
|
||||||
|
},
|
||||||
|
"flavors": {
|
||||||
|
Type: TypeCommaStringSlice,
|
||||||
|
Description: "the flavors",
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
Type: TypeNameString,
|
||||||
|
Description: "the name",
|
||||||
|
},
|
||||||
|
"x-abc-token": {
|
||||||
|
Type: TypeHeader,
|
||||||
|
Description: "a header value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
HelpSynopsis: "Synopsis",
|
||||||
|
HelpDescription: "Description",
|
||||||
|
Operations: map[logical.Operation]OperationHandler{
|
||||||
|
logical.ReadOperation: &PathOperation{
|
||||||
|
Summary: "My Summary",
|
||||||
|
Description: "My Description",
|
||||||
|
},
|
||||||
|
logical.UpdateOperation: &PathOperation{
|
||||||
|
Summary: "Update Summary",
|
||||||
|
Description: "Update Description",
|
||||||
|
},
|
||||||
|
logical.CreateOperation: &PathOperation{
|
||||||
|
Summary: "Create Summary",
|
||||||
|
Description: "Create Description",
|
||||||
|
},
|
||||||
|
logical.ListOperation: &PathOperation{
|
||||||
|
Summary: "List Summary",
|
||||||
|
Description: "List Description",
|
||||||
|
},
|
||||||
|
logical.DeleteOperation: &PathOperation{
|
||||||
|
Summary: "This shouldn't show up",
|
||||||
|
Unpublished: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := &logical.Paths{
|
||||||
|
Root: []string{"foo*"},
|
||||||
|
}
|
||||||
|
testPath(t, p, sp, expected("operations"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Responses", func(t *testing.T) {
|
||||||
|
p := &Path{
|
||||||
|
Pattern: "foo",
|
||||||
|
HelpSynopsis: "Synopsis",
|
||||||
|
HelpDescription: "Description",
|
||||||
|
Operations: map[logical.Operation]OperationHandler{
|
||||||
|
logical.ReadOperation: &PathOperation{
|
||||||
|
Summary: "My Summary",
|
||||||
|
Description: "My Description",
|
||||||
|
Responses: map[int][]Response{
|
||||||
|
202: {{
|
||||||
|
Description: "Amazing",
|
||||||
|
Example: &logical.Response{
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"amount": 42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logical.DeleteOperation: &PathOperation{
|
||||||
|
Summary: "Delete stuff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := &logical.Paths{
|
||||||
|
Unauthenticated: []string{"x", "y", "foo"},
|
||||||
|
}
|
||||||
|
|
||||||
|
testPath(t, p, sp, expected("responses"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAPI_CustomDecoder(t *testing.T) {
|
||||||
|
p := &Path{
|
||||||
|
Pattern: "foo",
|
||||||
|
HelpSynopsis: "Synopsis",
|
||||||
|
Operations: map[logical.Operation]OperationHandler{
|
||||||
|
logical.ReadOperation: &PathOperation{
|
||||||
|
Summary: "My Summary",
|
||||||
|
Responses: map[int][]Response{
|
||||||
|
100: {{
|
||||||
|
Description: "OK",
|
||||||
|
Example: &logical.Response{},
|
||||||
|
}},
|
||||||
|
200: {{
|
||||||
|
Description: "Good",
|
||||||
|
Example: &logical.Response{},
|
||||||
|
}},
|
||||||
|
599: {{
|
||||||
|
Description: "Bad",
|
||||||
|
Example: &logical.Response{},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
docOrig := NewOASDocument()
|
||||||
|
documentPath(p, nil, logical.TypeLogical, docOrig)
|
||||||
|
|
||||||
|
docJSON, err := json.Marshal(docOrig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var intermediate map[string]interface{}
|
||||||
|
if err := jsonutil.DecodeJSON(docJSON, &intermediate); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
docNew, err := NewOASDocumentFromMap(intermediate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if diff := deep.Equal(docOrig, docNew); diff != nil {
|
||||||
|
t.Fatal(diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPath(t *testing.T, path *Path, sp *logical.Paths, expectedJSON string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
doc := NewOASDocument()
|
||||||
|
documentPath(path, sp, logical.TypeLogical, doc)
|
||||||
|
|
||||||
|
docJSON, err := json.MarshalIndent(doc, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare json by first decoding, then comparing with a deep equality check.
|
||||||
|
var expected, actual interface{}
|
||||||
|
if err := jsonutil.DecodeJSON(docJSON, &actual); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := jsonutil.DecodeJSON([]byte(expectedJSON), &expected); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if diff := deep.Equal(actual, expected); diff != nil {
|
||||||
|
//fmt.Println(string(docJSON)) // uncomment to debug generated JSON (very helpful when fixing tests)
|
||||||
|
t.Fatal(diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expected(name string) string {
|
||||||
|
data, err := ioutil.ReadFile(filepath.Join("testdata", name+".json"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.Replace(string(data), "<vault_version>", version.GetVersion().Version, 1)
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
@@ -29,6 +29,12 @@ func OptionalParamRegex(name string) string {
|
|||||||
return fmt.Sprintf("(/(?P<%s>.+))?", name)
|
return fmt.Sprintf("(/(?P<%s>.+))?", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper which returns a regex string for capturing an entire endpoint path
|
||||||
|
// as the given name.
|
||||||
|
func MatchAllRegex(name string) string {
|
||||||
|
return fmt.Sprintf(`(?P<%s>.*)`, name)
|
||||||
|
}
|
||||||
|
|
||||||
// PathAppend is a helper for appending lists of paths into a single
|
// PathAppend is a helper for appending lists of paths into a single
|
||||||
// list.
|
// list.
|
||||||
func PathAppend(paths ...[]*Path) []*Path {
|
func PathAppend(paths ...[]*Path) []*Path {
|
||||||
@@ -58,6 +64,13 @@ type Path struct {
|
|||||||
// whereas all fields are available in the Write operation.
|
// whereas all fields are available in the Write operation.
|
||||||
Fields map[string]*FieldSchema
|
Fields map[string]*FieldSchema
|
||||||
|
|
||||||
|
// Operations is the set of operations supported and the associated OperationsHandler.
|
||||||
|
//
|
||||||
|
// If both Create and Update operations are present, documentation and examples from
|
||||||
|
// the Update definition will be used. Similarly if both Read and List are present,
|
||||||
|
// Read will be used for documentation.
|
||||||
|
Operations map[logical.Operation]OperationHandler
|
||||||
|
|
||||||
// Callbacks are the set of callbacks that are called for a given
|
// Callbacks are the set of callbacks that are called for a given
|
||||||
// operation. If a callback for a specific operation is not present,
|
// operation. If a callback for a specific operation is not present,
|
||||||
// then logical.ErrUnsupportedOperation is automatically generated.
|
// then logical.ErrUnsupportedOperation is automatically generated.
|
||||||
@@ -66,6 +79,8 @@ type Path struct {
|
|||||||
// automatically handle if the Help field is set. If both the Help
|
// automatically handle if the Help field is set. If both the Help
|
||||||
// field is set and there is a callback registered here, then the
|
// field is set and there is a callback registered here, then the
|
||||||
// callback will be called.
|
// callback will be called.
|
||||||
|
//
|
||||||
|
// Deprecated: Operations should be used instead and will take priority if present.
|
||||||
Callbacks map[logical.Operation]OperationFunc
|
Callbacks map[logical.Operation]OperationFunc
|
||||||
|
|
||||||
// ExistenceCheck, if implemented, is used to query whether a given
|
// ExistenceCheck, if implemented, is used to query whether a given
|
||||||
@@ -80,6 +95,10 @@ type Path struct {
|
|||||||
// enabled for the set of paths
|
// enabled for the set of paths
|
||||||
FeatureRequired license.Features
|
FeatureRequired license.Features
|
||||||
|
|
||||||
|
// Deprecated denotes that this path is considered deprecated. This may
|
||||||
|
// be reflected in help and documentation.
|
||||||
|
Deprecated bool
|
||||||
|
|
||||||
// Help is text describing how to use this path. This will be used
|
// Help is text describing how to use this path. This will be used
|
||||||
// to auto-generate the help operation. The Path will automatically
|
// to auto-generate the help operation. The Path will automatically
|
||||||
// generate a parameter listing and URL structure based on the
|
// generate a parameter listing and URL structure based on the
|
||||||
@@ -95,7 +114,86 @@ type Path struct {
|
|||||||
HelpDescription string
|
HelpDescription string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Path) helpCallback() OperationFunc {
|
// OperationHandler defines and describes a specific operation handler.
|
||||||
|
type OperationHandler interface {
|
||||||
|
Handler() OperationFunc
|
||||||
|
Properties() OperationProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperationProperties describes an operation for documentation, help text,
|
||||||
|
// and other clients. A Summary should always be provided, whereas other
|
||||||
|
// fields can be populated as needed.
|
||||||
|
type OperationProperties struct {
|
||||||
|
// Summary is a brief (usually one line) description of the operation.
|
||||||
|
Summary string
|
||||||
|
|
||||||
|
// Description is extended documentation of the operation and may contain
|
||||||
|
// Markdown-formatted text markup.
|
||||||
|
Description string
|
||||||
|
|
||||||
|
// Examples provides samples of the expected request data. The most
|
||||||
|
// relevant example should be first in the list, as it will be shown in
|
||||||
|
// documentation that supports only a single example.
|
||||||
|
Examples []RequestExample
|
||||||
|
|
||||||
|
// Responses provides a list of response description for a given response
|
||||||
|
// code. The most relevant response should be first in the list, as it will
|
||||||
|
// be shown in documentation that only allows a single example.
|
||||||
|
Responses map[int][]Response
|
||||||
|
|
||||||
|
// Unpublished indicates that this operation should not appear in public
|
||||||
|
// documentation or help text. The operation may still have documentation
|
||||||
|
// attached that can be used internally.
|
||||||
|
Unpublished bool
|
||||||
|
|
||||||
|
// Deprecated indicates that this operation should be avoided.
|
||||||
|
Deprecated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestExample is example of request data.
|
||||||
|
type RequestExample struct {
|
||||||
|
Description string // optional description of the request
|
||||||
|
Data map[string]interface{} // map version of sample JSON request data
|
||||||
|
|
||||||
|
// Optional example response to the sample request. This approach is considered
|
||||||
|
// provisional for now, and this field may be changed or removed.
|
||||||
|
Response *Response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response describes and optional demonstrations an operation response.
|
||||||
|
type Response struct {
|
||||||
|
Description string // summary of the the response and should always be provided
|
||||||
|
MediaType string // media type of the response, defaulting to "application/json" if empty
|
||||||
|
Example *logical.Response // example response data
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathOperation is a concrete implementation of OperationHandler.
|
||||||
|
type PathOperation struct {
|
||||||
|
Callback OperationFunc
|
||||||
|
Summary string
|
||||||
|
Description string
|
||||||
|
Examples []RequestExample
|
||||||
|
Responses map[int][]Response
|
||||||
|
Unpublished bool
|
||||||
|
Deprecated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PathOperation) Handler() OperationFunc {
|
||||||
|
return p.Callback
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PathOperation) Properties() OperationProperties {
|
||||||
|
return OperationProperties{
|
||||||
|
Summary: strings.TrimSpace(p.Summary),
|
||||||
|
Description: strings.TrimSpace(p.Description),
|
||||||
|
Responses: p.Responses,
|
||||||
|
Examples: p.Examples,
|
||||||
|
Unpublished: p.Unpublished,
|
||||||
|
Deprecated: p.Deprecated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Path) helpCallback(b *Backend) OperationFunc {
|
||||||
return func(ctx context.Context, req *logical.Request, data *FieldData) (*logical.Response, error) {
|
return func(ctx context.Context, req *logical.Request, data *FieldData) (*logical.Response, error) {
|
||||||
var tplData pathTemplateData
|
var tplData pathTemplateData
|
||||||
tplData.Request = req.Path
|
tplData.Request = req.Path
|
||||||
@@ -137,7 +235,13 @@ func (p *Path) helpCallback() OperationFunc {
|
|||||||
return nil, errwrap.Wrapf("error executing template: {{err}}", err)
|
return nil, errwrap.Wrapf("error executing template: {{err}}", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return logical.HelpResponse(help, nil), nil
|
// Build OpenAPI response for this path
|
||||||
|
doc := NewOASDocument()
|
||||||
|
if err := documentPath(p, b.SpecialPaths(), b.BackendType, doc); err != nil {
|
||||||
|
b.Logger().Warn("error generating OpenAPI", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logical.HelpResponse(help, nil, doc), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
98
logical/framework/path_test.go
Normal file
98
logical/framework/path_test.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package framework
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-test/deep"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPath_Regex(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
pattern string
|
||||||
|
input string
|
||||||
|
pathMatch bool
|
||||||
|
captures map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
pattern: "a/b/" + GenericNameRegex("val"),
|
||||||
|
input: "a/b/foo",
|
||||||
|
pathMatch: true,
|
||||||
|
captures: map[string]string{"val": "foo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: "a/b/" + GenericNameRegex("val"),
|
||||||
|
input: "a/b/foo/more",
|
||||||
|
pathMatch: false,
|
||||||
|
captures: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: "a/b/" + GenericNameRegex("val"),
|
||||||
|
input: "a/b/abc-.123",
|
||||||
|
pathMatch: true,
|
||||||
|
captures: map[string]string{"val": "abc-.123"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: "a/b/" + GenericNameRegex("val") + "/c/d",
|
||||||
|
input: "a/b/foo/c/d",
|
||||||
|
pathMatch: true,
|
||||||
|
captures: map[string]string{"val": "foo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: "a/b/" + GenericNameRegex("val") + "/c/d",
|
||||||
|
input: "a/b/foo/c/d/e",
|
||||||
|
pathMatch: false,
|
||||||
|
captures: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: "a/b" + OptionalParamRegex("val"),
|
||||||
|
input: "a/b",
|
||||||
|
pathMatch: true,
|
||||||
|
captures: map[string]string{"val": ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: "a/b" + OptionalParamRegex("val"),
|
||||||
|
input: "a/b/foo",
|
||||||
|
pathMatch: true,
|
||||||
|
captures: map[string]string{"val": "foo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: "foo/" + MatchAllRegex("val"),
|
||||||
|
input: "foos/ball",
|
||||||
|
pathMatch: false,
|
||||||
|
captures: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: "foos/" + MatchAllRegex("val"),
|
||||||
|
input: "foos/ball",
|
||||||
|
pathMatch: true,
|
||||||
|
captures: map[string]string{"val": "ball"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: "foos/ball/" + MatchAllRegex("val"),
|
||||||
|
input: "foos/ball/with/more/stuff/at_the/end",
|
||||||
|
pathMatch: true,
|
||||||
|
captures: map[string]string{"val": "with/more/stuff/at_the/end"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: MatchAllRegex("val"),
|
||||||
|
input: "foos/ball/with/more/stuff/at_the/end",
|
||||||
|
pathMatch: true,
|
||||||
|
captures: map[string]string{"val": "foos/ball/with/more/stuff/at_the/end"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
b := Backend{
|
||||||
|
Paths: []*Path{{Pattern: test.pattern}},
|
||||||
|
}
|
||||||
|
path, captures := b.route(test.input)
|
||||||
|
pathMatch := path != nil
|
||||||
|
if pathMatch != test.pathMatch {
|
||||||
|
t.Fatalf("[%d] unexpected path match result (%s): expected %t, actual %t", i, test.pattern, test.pathMatch, pathMatch)
|
||||||
|
}
|
||||||
|
if diff := deep.Equal(captures, test.captures); diff != nil {
|
||||||
|
t.Fatal(diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
62
logical/framework/testdata/legacy.json
vendored
Normal file
62
logical/framework/testdata/legacy.json
vendored
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.2",
|
||||||
|
"info": {
|
||||||
|
"title": "HashiCorp Vault API",
|
||||||
|
"description": "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.",
|
||||||
|
"version": "<vault_version>",
|
||||||
|
"license": {
|
||||||
|
"name": "Mozilla Public License 2.0",
|
||||||
|
"url": "https://www.mozilla.org/en-US/MPL/2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/lookup/{id}": {
|
||||||
|
"description": "Synopsis",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"description": "My id parameter",
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"get": {
|
||||||
|
"summary": "Synopsis",
|
||||||
|
"tags": ["secrets"],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"summary": "Synopsis",
|
||||||
|
"tags": ["secrets"],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"token": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "My token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
91
logical/framework/testdata/operations.json
vendored
Normal file
91
logical/framework/testdata/operations.json
vendored
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.2",
|
||||||
|
"info": {
|
||||||
|
"title": "HashiCorp Vault API",
|
||||||
|
"description": "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.",
|
||||||
|
"version": "<vault_version>",
|
||||||
|
"license": {
|
||||||
|
"name": "Mozilla Public License 2.0",
|
||||||
|
"url": "https://www.mozilla.org/en-US/MPL/2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/foo/{id}": {
|
||||||
|
"description": "Synopsis",
|
||||||
|
"x-vault-create-supported": true,
|
||||||
|
"x-vault-sudo": true,
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"description": "id path parameter",
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "x-abc-token",
|
||||||
|
"description": "a header value",
|
||||||
|
"in": "header",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"get": {
|
||||||
|
"tags": ["secrets"],
|
||||||
|
"summary": "My Summary",
|
||||||
|
"description": "My Description",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "list",
|
||||||
|
"description": "Return a list if `true`",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": ["secrets"],
|
||||||
|
"summary": "Update Summary",
|
||||||
|
"description": "Update Description",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"flavors": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "the flavors",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "the name",
|
||||||
|
"pattern": "\\w([\\w-.]*\\w)?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
logical/framework/testdata/responses.json
vendored
Normal file
49
logical/framework/testdata/responses.json
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.2",
|
||||||
|
"info": {
|
||||||
|
"title": "HashiCorp Vault API",
|
||||||
|
"description": "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.",
|
||||||
|
"version": "<vault_version>",
|
||||||
|
"license": {
|
||||||
|
"name": "Mozilla Public License 2.0",
|
||||||
|
"url": "https://www.mozilla.org/en-US/MPL/2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/foo": {
|
||||||
|
"description": "Synopsis",
|
||||||
|
"x-vault-unauthenticated": true,
|
||||||
|
"delete": {
|
||||||
|
"tags": ["secrets"],
|
||||||
|
"summary": "Delete stuff",
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "empty body"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"tags": ["secrets"],
|
||||||
|
"summary": "My Summary",
|
||||||
|
"description": "My Description",
|
||||||
|
"responses": {
|
||||||
|
"202": {
|
||||||
|
"description": "Amazing",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"example": {
|
||||||
|
"data": {
|
||||||
|
"amount": 42
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -89,11 +89,12 @@ func (r *Response) Error() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HelpResponse is used to format a help response
|
// HelpResponse is used to format a help response
|
||||||
func HelpResponse(text string, seeAlso []string) *Response {
|
func HelpResponse(text string, seeAlso []string, oapiDoc interface{}) *Response {
|
||||||
return &Response{
|
return &Response{
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"help": text,
|
"help": text,
|
||||||
"see_also": seeAlso,
|
"see_also": seeAlso,
|
||||||
|
"openapi": oapiDoc,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
scripts/gen_openapi.sh
Executable file
69
scripts/gen_openapi.sh
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Generate an OpenAPI document for all backends.
|
||||||
|
#
|
||||||
|
# Assumptions:
|
||||||
|
#
|
||||||
|
# 1. Vault has been checked out at an appropriate version and built
|
||||||
|
# 2. vault executable is in your path
|
||||||
|
# 3. Vault isn't alredy running
|
||||||
|
|
||||||
|
echo "Starting Vault..."
|
||||||
|
if pgrep -x "vault" > /dev/null
|
||||||
|
then
|
||||||
|
echo "Vault is already running. Aborting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
vault server -dev -dev-root-token-id=root &
|
||||||
|
sleep 2
|
||||||
|
VAULT_PID=$!
|
||||||
|
|
||||||
|
echo "Mounting all builtin backends..."
|
||||||
|
|
||||||
|
# auth backends
|
||||||
|
vault auth enable alicloud
|
||||||
|
vault auth enable app-id
|
||||||
|
vault auth enable approle
|
||||||
|
vault auth enable aws
|
||||||
|
vault auth enable azure
|
||||||
|
vault auth enable centrify
|
||||||
|
vault auth enable cert
|
||||||
|
vault auth enable gcp
|
||||||
|
vault auth enable github
|
||||||
|
vault auth enable jwt
|
||||||
|
vault auth enable kubernetes
|
||||||
|
vault auth enable ldap
|
||||||
|
vault auth enable okta
|
||||||
|
vault auth enable radius
|
||||||
|
vault auth enable userpass
|
||||||
|
|
||||||
|
# secrets backends
|
||||||
|
vault secrets enable ad
|
||||||
|
vault secrets enable alicloud
|
||||||
|
vault secrets enable aws
|
||||||
|
vault secrets enable azure
|
||||||
|
vault secrets enable cassandra
|
||||||
|
vault secrets enable consul
|
||||||
|
vault secrets enable database
|
||||||
|
vault secrets enable gcp
|
||||||
|
vault secrets enable kv
|
||||||
|
vault secrets enable mongodb
|
||||||
|
vault secrets enable mssql
|
||||||
|
vault secrets enable mysql
|
||||||
|
vault secrets enable nomad
|
||||||
|
vault secrets enable pki
|
||||||
|
vault secrets enable postgresql
|
||||||
|
vault secrets enable rabbitmq
|
||||||
|
vault secrets enable ssh
|
||||||
|
vault secrets enable totp
|
||||||
|
vault secrets enable transit
|
||||||
|
|
||||||
|
curl -H "X-Vault-Token: root" "http://127.0.0.1:8200/v1/sys/internal/specs/openapi" > openapi.json
|
||||||
|
|
||||||
|
kill $VAULT_PID
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
echo "\nopenapi.json generated."
|
||||||
@@ -108,6 +108,7 @@ func NewSystemBackend(core *Core, logger log.Logger) *SystemBackend {
|
|||||||
"wrapping/lookup",
|
"wrapping/lookup",
|
||||||
"wrapping/pubkey",
|
"wrapping/pubkey",
|
||||||
"replication/status",
|
"replication/status",
|
||||||
|
"internal/specs/openapi",
|
||||||
"internal/ui/mounts",
|
"internal/ui/mounts",
|
||||||
"internal/ui/mounts/*",
|
"internal/ui/mounts/*",
|
||||||
"internal/ui/namespaces",
|
"internal/ui/namespaces",
|
||||||
@@ -141,7 +142,7 @@ func NewSystemBackend(core *Core, logger log.Logger) *SystemBackend {
|
|||||||
b.Backend.Paths = append(b.Backend.Paths, b.wrappingPaths()...)
|
b.Backend.Paths = append(b.Backend.Paths, b.wrappingPaths()...)
|
||||||
b.Backend.Paths = append(b.Backend.Paths, b.toolsPaths()...)
|
b.Backend.Paths = append(b.Backend.Paths, b.toolsPaths()...)
|
||||||
b.Backend.Paths = append(b.Backend.Paths, b.capabilitiesPaths()...)
|
b.Backend.Paths = append(b.Backend.Paths, b.capabilitiesPaths()...)
|
||||||
b.Backend.Paths = append(b.Backend.Paths, b.internalUIPaths()...)
|
b.Backend.Paths = append(b.Backend.Paths, b.internalPaths()...)
|
||||||
b.Backend.Paths = append(b.Backend.Paths, b.remountPath())
|
b.Backend.Paths = append(b.Backend.Paths, b.remountPath())
|
||||||
|
|
||||||
if core.rawEnabled {
|
if core.rawEnabled {
|
||||||
@@ -2958,6 +2959,110 @@ func (b *SystemBackend) pathInternalUIResultantACL(ctx context.Context, req *log
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *SystemBackend) pathInternalOpenAPI(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
||||||
|
|
||||||
|
// Limit output to authorized paths
|
||||||
|
resp, err := b.pathInternalUIMountsRead(ctx, req, d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up target document and convert to map[string]interface{} which is what will
|
||||||
|
// be received from plugin backends.
|
||||||
|
doc := framework.NewOASDocument()
|
||||||
|
|
||||||
|
procMountGroup := func(group, mountPrefix string) error {
|
||||||
|
for mount := range resp.Data[group].(map[string]interface{}) {
|
||||||
|
backend := b.Core.router.MatchingBackend(ctx, mountPrefix+mount)
|
||||||
|
|
||||||
|
if backend == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &logical.Request{
|
||||||
|
Operation: logical.HelpOperation,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := backend.HandleRequest(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var backendDoc *framework.OASDocument
|
||||||
|
|
||||||
|
// Normalize response type, which will be different if received
|
||||||
|
// from an external plugin.
|
||||||
|
switch v := resp.Data["openapi"].(type) {
|
||||||
|
case *framework.OASDocument:
|
||||||
|
backendDoc = v
|
||||||
|
case map[string]interface{}:
|
||||||
|
backendDoc, err = framework.NewOASDocumentFromMap(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare to add tags to default builtins that are
|
||||||
|
// type "unknown" and won't already be tagged.
|
||||||
|
var tag string
|
||||||
|
switch mountPrefix + mount {
|
||||||
|
case "cubbyhole/", "secret/":
|
||||||
|
tag = "secrets"
|
||||||
|
case "sys/":
|
||||||
|
tag = "system"
|
||||||
|
case "auth/token/":
|
||||||
|
tag = "auth"
|
||||||
|
case "identity/":
|
||||||
|
tag = "identity"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge backend paths with existing document
|
||||||
|
for path, obj := range backendDoc.Paths {
|
||||||
|
path := strings.TrimPrefix(path, "/")
|
||||||
|
|
||||||
|
// Add tags to all of the operations if necessary
|
||||||
|
if tag != "" {
|
||||||
|
for _, op := range []*framework.OASOperation{obj.Get, obj.Post, obj.Delete} {
|
||||||
|
// TODO: a special override for identity is used used here because the backend
|
||||||
|
// is currently categorized as "secret", which will likely change. Also of interest
|
||||||
|
// is removing all tag handling here and providing the mount information to OpenAPI.
|
||||||
|
if op != nil && (len(op.Tags) == 0 || tag == "identity") {
|
||||||
|
op.Tags = []string{tag}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.Paths["/"+mountPrefix+mount+path] = obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := procMountGroup("secret", ""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := procMountGroup("auth", "auth/"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := json.Marshal(doc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = &logical.Response{
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
logical.HTTPStatusCode: 200,
|
||||||
|
logical.HTTPRawBody: buf,
|
||||||
|
logical.HTTPContentType: "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
func sanitizeMountPath(path string) string {
|
func sanitizeMountPath(path string) string {
|
||||||
if !strings.HasSuffix(path, "/") {
|
if !strings.HasSuffix(path, "/") {
|
||||||
path += "/"
|
path += "/"
|
||||||
|
|||||||
@@ -423,8 +423,14 @@ func (b *SystemBackend) toolsPaths() []*framework.Path {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *SystemBackend) internalUIPaths() []*framework.Path {
|
func (b *SystemBackend) internalPaths() []*framework.Path {
|
||||||
return []*framework.Path{
|
return []*framework.Path{
|
||||||
|
{
|
||||||
|
Pattern: "internal/specs/openapi",
|
||||||
|
Callbacks: map[logical.Operation]framework.OperationFunc{
|
||||||
|
logical.ReadOperation: b.pathInternalOpenAPI,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Pattern: "internal/ui/mounts",
|
Pattern: "internal/ui/mounts",
|
||||||
Callbacks: map[logical.Operation]framework.OperationFunc{
|
Callbacks: map[logical.Operation]framework.OperationFunc{
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ import (
|
|||||||
hclog "github.com/hashicorp/go-hclog"
|
hclog "github.com/hashicorp/go-hclog"
|
||||||
"github.com/hashicorp/vault/audit"
|
"github.com/hashicorp/vault/audit"
|
||||||
"github.com/hashicorp/vault/helper/builtinplugins"
|
"github.com/hashicorp/vault/helper/builtinplugins"
|
||||||
|
"github.com/hashicorp/vault/helper/jsonutil"
|
||||||
"github.com/hashicorp/vault/helper/namespace"
|
"github.com/hashicorp/vault/helper/namespace"
|
||||||
"github.com/hashicorp/vault/helper/salt"
|
"github.com/hashicorp/vault/helper/salt"
|
||||||
"github.com/hashicorp/vault/logical"
|
"github.com/hashicorp/vault/logical"
|
||||||
|
"github.com/hashicorp/vault/logical/framework"
|
||||||
|
"github.com/hashicorp/vault/version"
|
||||||
"github.com/mitchellh/mapstructure"
|
"github.com/mitchellh/mapstructure"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2447,3 +2450,102 @@ func TestSystemBackend_InternalUIMount(t *testing.T) {
|
|||||||
t.Fatal("expected permission denied error")
|
t.Fatal("expected permission denied error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSystemBackend_OpenAPI(t *testing.T) {
|
||||||
|
_, b, rootToken := testCoreSystemBackend(t)
|
||||||
|
var oapi map[string]interface{}
|
||||||
|
|
||||||
|
// Ensure no paths are reported if there is no token
|
||||||
|
req := logical.TestRequest(t, logical.ReadOperation, "internal/specs/openapi")
|
||||||
|
resp, err := b.HandleRequest(namespace.RootContext(nil), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := resp.Data["http_raw_body"].([]byte)
|
||||||
|
err = jsonutil.DecodeJSON(body, &oapi)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
exp := map[string]interface{}{
|
||||||
|
"openapi": framework.OASVersion,
|
||||||
|
"info": map[string]interface{}{
|
||||||
|
"title": "HashiCorp Vault API",
|
||||||
|
"description": "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.",
|
||||||
|
"version": version.GetVersion().Version,
|
||||||
|
"license": map[string]interface{}{
|
||||||
|
"name": "Mozilla Public License 2.0",
|
||||||
|
"url": "https://www.mozilla.org/en-US/MPL/2.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"paths": map[string]interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if diff := deep.Equal(oapi, exp); diff != nil {
|
||||||
|
t.Fatal(diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that default paths are present with a root token
|
||||||
|
req = logical.TestRequest(t, logical.ReadOperation, "internal/specs/openapi")
|
||||||
|
req.ClientToken = rootToken
|
||||||
|
resp, err = b.HandleRequest(namespace.RootContext(nil), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body = resp.Data["http_raw_body"].([]byte)
|
||||||
|
err = jsonutil.DecodeJSON(body, &oapi)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := framework.NewOASDocumentFromMap(oapi)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pathSamples := []struct {
|
||||||
|
path string
|
||||||
|
tag string
|
||||||
|
}{
|
||||||
|
{"/auth/token/lookup", "auth"},
|
||||||
|
{"/cubbyhole/.*", "secrets"}, // TODO update after sys docs update
|
||||||
|
{"/identity/group/id", "identity"},
|
||||||
|
{"/secret/.*", "secrets"}, // TODO update after sys docs update
|
||||||
|
{"/sys/policy", "system"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range pathSamples {
|
||||||
|
if doc.Paths[path.path] == nil {
|
||||||
|
t.Fatalf("didn't find expected path '%s'.", path)
|
||||||
|
}
|
||||||
|
tag := doc.Paths[path.path].Get.Tags[0]
|
||||||
|
if tag != path.tag {
|
||||||
|
t.Fatalf("path: %s; expected tag: %s, actual: %s", path.path, tag, path.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple sanity check of response size (which is much larger than most
|
||||||
|
// Vault responses), mainly to catch mass omission of expected path data.
|
||||||
|
minLen := 70000
|
||||||
|
if len(body) < minLen {
|
||||||
|
t.Fatalf("response size too small; expected: min %d, actual: %d", minLen, len(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test path-help response
|
||||||
|
req = logical.TestRequest(t, logical.HelpOperation, "rotate")
|
||||||
|
req.ClientToken = rootToken
|
||||||
|
resp, err = b.HandleRequest(namespace.RootContext(nil), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
doc = resp.Data["openapi"].(*framework.OASDocument)
|
||||||
|
if len(doc.Paths) != 1 {
|
||||||
|
t.Fatalf("expected 1 path, actual: %d", len(doc.Paths))
|
||||||
|
}
|
||||||
|
|
||||||
|
if doc.Paths["/rotate"] == nil {
|
||||||
|
t.Fatalf("expected to find path '/rotate'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user