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 }