github.com/snyk/vervet/v3@v3.7.0/resource.go (about)

     1  package vervet
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"path/filepath"
     9  	"sort"
    10  	"time"
    11  
    12  	"github.com/getkin/kin-openapi/openapi3"
    13  )
    14  
    15  const (
    16  	// ExtSnykApiStability is used to annotate a top-level endpoint version spec with its API release stability level.
    17  	ExtSnykApiStability = "x-snyk-api-stability"
    18  
    19  	// ExtSnykApiResource is used to annotate a path in a compiled OpenAPI spec with its source resource name.
    20  	ExtSnykApiResource = "x-snyk-api-resource"
    21  
    22  	// ExtSnykApiVersion is used to annotate a path in a compiled OpenAPI spec with its resolved release version.
    23  	ExtSnykApiVersion = "x-snyk-api-version"
    24  
    25  	// ExtSnykApiReleases is used to annotate a path in a compiled OpenAPI spec
    26  	// with all the release versions containing a change in the path info. This
    27  	// is useful for navigating changes in a particular path across versions.
    28  	ExtSnykApiReleases = "x-snyk-api-releases"
    29  
    30  	// ExtSnykDeprecatedBy is used to annotate a path in a resource version
    31  	// spec with the subsequent version that deprecates it. This may be used
    32  	// by linters, service middleware and API documentation to indicate which
    33  	// version deprecates a given version.
    34  	ExtSnykDeprecatedBy = "x-snyk-deprecated-by"
    35  
    36  	// ExtSnykSunsetEligible is used to annotate a path in a resource version
    37  	// spec which is deprecated, with the sunset eligible date: the date after
    38  	// which the resource version may be removed and no longer available.
    39  	ExtSnykSunsetEligible = "x-snyk-sunset-eligible"
    40  )
    41  
    42  // Resource defines a specific version of a resource, corresponding to a
    43  // standalone OpenAPI specification document that defines its operations,
    44  // schema, etc. While a resource spec may declare multiple paths, they should
    45  // all describe operations on a single conceptual resource.
    46  type Resource struct {
    47  	*Document
    48  	Name         string
    49  	Version      Version
    50  	sourcePrefix string
    51  }
    52  
    53  type extensionNotFoundError struct {
    54  	extension string
    55  }
    56  
    57  // Error implements error.
    58  func (e *extensionNotFoundError) Error() string {
    59  	return fmt.Sprintf("extension \"%s\" not found", e.extension)
    60  }
    61  
    62  // Is returns whether an error matches this error instance.
    63  func (e *extensionNotFoundError) Is(err error) bool {
    64  	_, ok := err.(*extensionNotFoundError)
    65  	return ok
    66  }
    67  
    68  // Validate returns whether the Resource is valid. The OpenAPI specification
    69  // must be valid, and must declare at least one path.
    70  func (e *Resource) Validate(ctx context.Context) error {
    71  	// Validate the OpenAPI spec
    72  	err := e.Document.Validate(ctx)
    73  	if err != nil {
    74  		return err
    75  	}
    76  	// Resource path checks. There should be at least one path per resource.
    77  	if len(e.Paths) < 1 {
    78  		return fmt.Errorf("spec contains no paths")
    79  	}
    80  	return nil
    81  }
    82  
    83  // ResourceVersions defines a collection of multiple versions of an Resource.
    84  type ResourceVersions struct {
    85  	versions resourceVersionSlice
    86  }
    87  
    88  // Name returns the resource name for a collection of resource versions.
    89  func (e *ResourceVersions) Name() string {
    90  	for i := range e.versions {
    91  		return e.versions[i].Name
    92  	}
    93  	return ""
    94  }
    95  
    96  // Versions returns a slice containing each Version defined for this endpoint.
    97  func (e *ResourceVersions) Versions() []Version {
    98  	result := make([]Version, len(e.versions))
    99  	for i := range e.versions {
   100  		result[i] = e.versions[i].Version
   101  	}
   102  	return result
   103  }
   104  
   105  // ErrNoMatchingVersion indicates the requested endpoint version cannot be
   106  // satisfied by the declared Resource versions that are available.
   107  var ErrNoMatchingVersion = fmt.Errorf("no matching version")
   108  
   109  // At returns the Resource matching a version string. The endpoint returned
   110  // will be the latest available version with a stability equal to or greater
   111  // than the requested version, or ErrNoMatchingVersion if no matching version
   112  // is available.
   113  func (e *ResourceVersions) At(vs string) (*Resource, error) {
   114  	if vs == "" {
   115  		vs = time.Now().UTC().Format("2006-01-02")
   116  	}
   117  	v, err := ParseVersion(vs)
   118  	if err != nil {
   119  		return nil, fmt.Errorf("invalid version %q: %w", vs, err)
   120  	}
   121  	for i := len(e.versions) - 1; i >= 0; i-- {
   122  		ev := e.versions[i].Version
   123  		if dateCmp, stabilityCmp := ev.compareDateStability(v); dateCmp <= 0 && stabilityCmp >= 0 {
   124  			return e.versions[i], nil
   125  		}
   126  	}
   127  	return nil, ErrNoMatchingVersion
   128  }
   129  
   130  type resourceVersionSlice []*Resource
   131  
   132  // Less implements sort.Interface.
   133  func (e resourceVersionSlice) Less(i, j int) bool {
   134  	return e[i].Version.Compare(&e[j].Version) < 0
   135  }
   136  
   137  // Len implements sort.Interface.
   138  func (e resourceVersionSlice) Len() int { return len(e) }
   139  
   140  // Swap implements sort.Interface.
   141  func (e resourceVersionSlice) Swap(i, j int) { e[i], e[j] = e[j], e[i] }
   142  
   143  // LoadResourceVersions returns a ResourceVersions slice parsed from a
   144  // directory structure of resource specs. This directory will be of the form:
   145  //
   146  //     endpoint/
   147  //     +- 2021-01-01
   148  //        +- spec.yaml
   149  //     +- 2021-06-21
   150  //        +- spec.yaml
   151  //     +- 2021-07-14
   152  //        +- spec.yaml
   153  //
   154  // The endpoint version stability level is defined by the
   155  // ExtSnykApiStability extension value at the top-level of the OpenAPI
   156  // document.
   157  func LoadResourceVersions(epPath string) (*ResourceVersions, error) {
   158  	specYamls, err := filepath.Glob(epPath + "/*/spec.yaml")
   159  	if err != nil {
   160  		return nil, err
   161  	}
   162  	return LoadResourceVersionsFileset(specYamls)
   163  }
   164  
   165  // LoadResourceVersionFileset returns a ResourceVersions slice parsed from the
   166  // directory structure described above for LoadResourceVersions.
   167  func LoadResourceVersionsFileset(specYamls []string) (*ResourceVersions, error) {
   168  	var resourceVersions ResourceVersions
   169  	var err error
   170  	type operationKey struct {
   171  		path, operation string
   172  	}
   173  	opReleases := map[operationKey]VersionSlice{}
   174  
   175  	for i := range specYamls {
   176  		specYamls[i], err = filepath.Abs(specYamls[i])
   177  		if err != nil {
   178  			return nil, fmt.Errorf("failed to canonicalize %q: %w", specYamls[i], err)
   179  		}
   180  		versionDir := filepath.Dir(specYamls[i])
   181  		versionBase := filepath.Base(versionDir)
   182  		rc, err := loadResource(specYamls[i], versionBase)
   183  		if err != nil {
   184  			return nil, err
   185  		}
   186  		if rc == nil {
   187  			continue
   188  		}
   189  		rc.sourcePrefix = specYamls[i]
   190  		err = rc.Validate(context.TODO())
   191  		if err != nil {
   192  			return nil, err
   193  		}
   194  		// Map release versions per operation
   195  		for path, pathItem := range rc.Paths {
   196  			for _, opName := range operationNames {
   197  				op := getOperationByName(pathItem, opName)
   198  				if op != nil {
   199  					op.ExtensionProps.Extensions[ExtSnykApiVersion] = rc.Version.String()
   200  					opKey := operationKey{path, opName}
   201  					opReleases[opKey] = append(opReleases[opKey], rc.Version)
   202  				}
   203  			}
   204  		}
   205  		resourceVersions.versions = append(resourceVersions.versions, rc)
   206  	}
   207  	// Sort release versions per path
   208  	for _, releases := range opReleases {
   209  		sort.Sort(releases)
   210  	}
   211  	// Sort the resources themselves by version
   212  	sort.Sort(resourceVersionSlice(resourceVersions.versions))
   213  	// Annotate each path in each resource version with the other change
   214  	// versions affecting the path. This supports navigation across versions.
   215  	for _, rc := range resourceVersions.versions {
   216  		for path, pathItem := range rc.Paths {
   217  			for _, opName := range operationNames {
   218  				op := getOperationByName(pathItem, opName)
   219  				if op == nil {
   220  					continue
   221  				}
   222  				// Annotate operation with other release versions available for this path
   223  				releases := opReleases[operationKey{path, opName}]
   224  				op.ExtensionProps.Extensions[ExtSnykApiReleases] = releases.Strings()
   225  				// Annotate operation with deprecated-by and sunset information
   226  				if deprecatedBy, ok := releases.Deprecates(rc.Version); ok {
   227  					op.ExtensionProps.Extensions[ExtSnykDeprecatedBy] = deprecatedBy.String()
   228  					if sunset, ok := rc.Version.Sunset(deprecatedBy); ok {
   229  						op.ExtensionProps.Extensions[ExtSnykSunsetEligible] = sunset.Format("2006-01-02")
   230  					}
   231  				}
   232  			}
   233  		}
   234  	}
   235  	return &resourceVersions, nil
   236  }
   237  
   238  // ExtensionString returns the string value of an OpenAPI extension.
   239  func ExtensionString(extProps openapi3.ExtensionProps, key string) (string, error) {
   240  	switch m := extProps.Extensions[key].(type) {
   241  	case json.RawMessage:
   242  		var s string
   243  		err := json.Unmarshal(extProps.Extensions[key].(json.RawMessage), &s)
   244  		return s, err
   245  	case string:
   246  		return m, nil
   247  	default:
   248  		if m == nil {
   249  			return "", &extensionNotFoundError{key}
   250  		}
   251  		return "", fmt.Errorf("unexpected extension %v type %T", m, m)
   252  	}
   253  }
   254  
   255  // IsExtensionNotFound returns bool whether error from ExtensionString is not found versus unexpected.
   256  func IsExtensionNotFound(err error) bool {
   257  	return errors.Is(err, &extensionNotFoundError{})
   258  }
   259  
   260  func loadResource(specPath string, versionStr string) (*Resource, error) {
   261  	name := filepath.Base(filepath.Dir(filepath.Dir(specPath)))
   262  	doc, err := NewDocumentFile(specPath)
   263  	if err != nil {
   264  		return nil, fmt.Errorf("failed to load spec from %q: %w", specPath, err)
   265  	}
   266  
   267  	stabilityStr, err := ExtensionString(doc.T.ExtensionProps, ExtSnykApiStability)
   268  	if err != nil {
   269  		return nil, err
   270  	}
   271  	if stabilityStr != "ga" {
   272  		versionStr = versionStr + "~" + stabilityStr
   273  	}
   274  	version, err := ParseVersion(versionStr)
   275  	if err != nil {
   276  		return nil, fmt.Errorf("invalid version %q", versionStr)
   277  	}
   278  
   279  	if len(doc.Paths) == 0 {
   280  		return nil, nil
   281  	}
   282  
   283  	// Expand x-snyk-include-headers extensions
   284  	err = IncludeHeaders(doc)
   285  	if err != nil {
   286  		return nil, fmt.Errorf("failed to load x-snyk-include-headers extensions: %w", err)
   287  	}
   288  
   289  	// Localize all references, so we emit a completely self-contained OpenAPI document.
   290  	err = Localize(doc)
   291  	if err != nil {
   292  		return nil, fmt.Errorf("failed to localize refs: %w", err)
   293  	}
   294  
   295  	ep := &Resource{Name: name, Document: doc, Version: *version}
   296  	for path := range doc.T.Paths {
   297  		doc.T.Paths[path].ExtensionProps.Extensions[ExtSnykApiResource] = name
   298  	}
   299  	return ep, nil
   300  }
   301  
   302  // Localize rewrites all references in an OpenAPI document to local references.
   303  func Localize(doc *Document) error {
   304  	doc.InternalizeRefs(context.Background(), nil)
   305  	return doc.ResolveRefs()
   306  }