github.com/w3security/vervet/v5@v5.3.1-0.20230618081846-5bd9b5d799dc/spec.go (about)

     1  package vervet
     2  
     3  import (
     4  	"fmt"
     5  	"io/fs"
     6  	"os"
     7  	"path/filepath"
     8  	"sort"
     9  	"time"
    10  
    11  	"github.com/bmatcuk/doublestar/v4"
    12  	"github.com/getkin/kin-openapi/openapi3"
    13  )
    14  
    15  // SpecGlobPattern defines the expected directory structure for the versioned
    16  // OpenAPI specs of a single resource: subdirectories by date, of the form
    17  // YYYY-mm-dd, each containing a spec.yaml file.
    18  const SpecGlobPattern = "**/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/spec.yaml"
    19  
    20  // SpecVersions stores a collection of versioned OpenAPI specs.
    21  type SpecVersions struct {
    22  	versions  VersionSlice
    23  	index     VersionIndex
    24  	documents map[Version]*openapi3.T
    25  }
    26  
    27  // LoadSpecVersions returns SpecVersions loaded from a directory structure
    28  // containing one or more Resource subdirectories.
    29  func LoadSpecVersions(root string) (*SpecVersions, error) {
    30  	epPaths, err := findResources(root)
    31  	if err != nil {
    32  		return nil, err
    33  	}
    34  	return LoadSpecVersionsFileset(epPaths)
    35  }
    36  
    37  // LoadSpecVersionsFileset returns SpecVersions loaded from a set of spec
    38  // files.
    39  func LoadSpecVersionsFileset(epPaths []string) (*SpecVersions, error) {
    40  	resourceMap := map[string][]string{}
    41  	for i := range epPaths {
    42  		resourcePath := filepath.Dir(filepath.Dir(epPaths[i]))
    43  		if resourcePath == "." {
    44  			continue
    45  		}
    46  		resourceMap[resourcePath] = append(resourceMap[resourcePath], epPaths[i])
    47  	}
    48  	resourceNames := []string{}
    49  	for k := range resourceMap {
    50  		resourceNames = append(resourceNames, k)
    51  	}
    52  	sort.Strings(resourceNames)
    53  	var resourceVersions resourceVersionsSlice
    54  	for _, resourcePath := range resourceNames {
    55  		specFiles := resourceMap[resourcePath]
    56  		eps, err := LoadResourceVersionsFileset(specFiles)
    57  		if err != nil {
    58  			return nil, fmt.Errorf("failed to load resource at %q: %w", resourcePath, err)
    59  		}
    60  		resourceVersions = append(resourceVersions, eps)
    61  	}
    62  	if err := resourceVersions.validate(); err != nil {
    63  		return nil, err
    64  	}
    65  	return newSpecVersions(resourceVersions)
    66  }
    67  
    68  // Versions returns the distinct API versions in this collection of OpenAPI
    69  // documents.
    70  func (sv *SpecVersions) Versions() VersionSlice {
    71  	return sv.versions
    72  }
    73  
    74  // At returns the OpenAPI document that matches the given version. If the
    75  // version is not an exact match for an API release, the OpenAPI document
    76  // effective on the given version date for the version stability level is
    77  // returned. Returns ErrNoMatchingVersion if there is no release matching this
    78  // version.
    79  func (sv *SpecVersions) At(v Version) (*openapi3.T, error) {
    80  	resolvedVersion, err := sv.index.Resolve(v)
    81  	if err != nil {
    82  		return nil, err
    83  	}
    84  	doc, ok := sv.documents[resolvedVersion]
    85  	if !ok {
    86  		panic(fmt.Sprintf("missing expected document for version %v", resolvedVersion))
    87  	}
    88  	return doc, nil
    89  }
    90  
    91  func (sv *SpecVersions) resolveOperations() {
    92  	type operationKey struct {
    93  		path, operation string
    94  	}
    95  	type operationVersion struct {
    96  		// src document where the active operation was declared
    97  		src *openapi3.T
    98  		// pathItem where the active operation was declared
    99  		pathItem *openapi3.PathItem
   100  		// operation where the active operation was declared
   101  		operation *openapi3.Operation
   102  		// spec version where the active operation was declared
   103  		version Version
   104  	}
   105  	type operationVersionMap map[operationKey]operationVersion
   106  	activeOpsByStability := map[Stability]operationVersionMap{}
   107  	for _, v := range sv.versions {
   108  		doc := sv.documents[v]
   109  		currentActiveOps, ok := activeOpsByStability[v.Stability]
   110  		if !ok {
   111  			currentActiveOps = operationVersionMap{}
   112  			activeOpsByStability[v.Stability] = currentActiveOps
   113  		}
   114  
   115  		// Operations declared in this spec become active for the next version
   116  		// at this stability.
   117  		nextActiveOps := operationVersionMap{}
   118  		for path, pathItem := range doc.Paths {
   119  			for _, opName := range operationNames {
   120  				op := getOperationByName(pathItem, opName)
   121  				if op != nil {
   122  					nextActiveOps[operationKey{path, opName}] = operationVersion{
   123  						doc, pathItem, op, v,
   124  					}
   125  				}
   126  			}
   127  		}
   128  
   129  		// Operations currently active for this versions's stability get
   130  		// carried forward and remain active.
   131  		for opKey, opValue := range currentActiveOps {
   132  			currentPathItem := doc.Paths[opKey.path]
   133  			if currentPathItem == nil {
   134  				currentPathItem = &openapi3.PathItem{
   135  					Extensions:  opValue.pathItem.Extensions,
   136  					Description: opValue.pathItem.Description,
   137  					Summary:     opValue.pathItem.Summary,
   138  					Servers:     opValue.pathItem.Servers,
   139  					Parameters:  opValue.pathItem.Parameters,
   140  				}
   141  				doc.Paths[opKey.path] = currentPathItem
   142  			}
   143  			currentOp := getOperationByName(currentPathItem, opKey.operation)
   144  			if currentOp == nil {
   145  				// The added operation may reference components from its source
   146  				// document; import those that are missing here.
   147  				mergeComponents(doc, opValue.src, false)
   148  				setOperationByName(currentPathItem, opKey.operation, opValue.operation)
   149  			}
   150  		}
   151  
   152  		// Update currently active operations from any declared in this version.
   153  		for opKey, nextOpValue := range nextActiveOps {
   154  			currentActiveOps[opKey] = nextOpValue
   155  		}
   156  	}
   157  }
   158  
   159  var operationNames = []string{
   160  	"connect", "delete", "get", "head", "options", "patch", "post", "put", "trace",
   161  }
   162  
   163  func getOperationByName(path *openapi3.PathItem, op string) *openapi3.Operation {
   164  	switch op {
   165  	case "connect":
   166  		return path.Connect
   167  	case "delete":
   168  		return path.Delete
   169  	case "get":
   170  		return path.Get
   171  	case "head":
   172  		return path.Head
   173  	case "options":
   174  		return path.Options
   175  	case "patch":
   176  		return path.Patch
   177  	case "post":
   178  		return path.Post
   179  	case "put":
   180  		return path.Put
   181  	case "trace":
   182  		return path.Trace
   183  	default:
   184  		return nil
   185  	}
   186  }
   187  
   188  func setOperationByName(path *openapi3.PathItem, opName string, op *openapi3.Operation) {
   189  	switch opName {
   190  	case "connect":
   191  		path.Connect = op
   192  	case "delete":
   193  		path.Delete = op
   194  	case "get":
   195  		path.Get = op
   196  	case "head":
   197  		path.Head = op
   198  	case "options":
   199  		path.Options = op
   200  	case "patch":
   201  		path.Patch = op
   202  	case "post":
   203  		path.Post = op
   204  	case "put":
   205  		path.Put = op
   206  	case "trace":
   207  		path.Trace = op
   208  	default:
   209  		panic("unsupported operation: " + opName)
   210  	}
   211  }
   212  
   213  var stabilities = []Stability{StabilityExperimental, StabilityBeta, StabilityGA}
   214  
   215  func newSpecVersions(specs resourceVersionsSlice) (*SpecVersions, error) {
   216  	versions := specs.versions()
   217  	var versionDates []time.Time
   218  	for _, v := range versions {
   219  		if len(versionDates) == 0 || versionDates[len(versionDates)-1] != v.Date {
   220  			versionDates = append(versionDates, v.Date)
   221  		}
   222  	}
   223  
   224  	documentVersions := map[Version]*openapi3.T{}
   225  	for _, date := range versionDates {
   226  		for _, stability := range stabilities {
   227  			v := Version{Date: date, Stability: stability}
   228  			doc, err := specs.at(v)
   229  			if err == ErrNoMatchingVersion {
   230  				continue
   231  			} else if err != nil {
   232  				return nil, err
   233  			}
   234  			if doc.Extensions == nil {
   235  				doc.Extensions = map[string]interface{}{}
   236  			}
   237  			doc.Extensions[ExtW3SecurityApiVersion] = v.String()
   238  			documentVersions[v] = doc
   239  		}
   240  	}
   241  	versions = VersionSlice{}
   242  	for v := range documentVersions {
   243  		versions = append(versions, v)
   244  	}
   245  	sort.Sort(versions)
   246  	sv := &SpecVersions{
   247  		versions:  versions,
   248  		index:     NewVersionIndex(versions),
   249  		documents: documentVersions,
   250  	}
   251  	sv.resolveOperations()
   252  	return sv, nil
   253  }
   254  
   255  func findResources(root string) ([]string, error) {
   256  	var paths []string
   257  	err := doublestar.GlobWalk(os.DirFS(root), SpecGlobPattern,
   258  		func(path string, d fs.DirEntry) error {
   259  			paths = append(paths, filepath.Join(root, path))
   260  			return nil
   261  		})
   262  	if err != nil {
   263  		return nil, err
   264  	}
   265  	return paths, nil
   266  }