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