github.com/snyk/vervet/v3@v3.7.0/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 sv.documents[i].ExtensionProps.Extensions[ExtSnykApiVersion] = versions[i].String() 255 } 256 err := sv.resolveOperations() 257 if err != nil { 258 return nil, err 259 } 260 return sv, nil 261 } 262 263 func findResources(root string) ([]string, error) { 264 var paths []string 265 err := doublestar.GlobWalk(os.DirFS(root), SpecGlobPattern, 266 func(path string, d fs.DirEntry) error { 267 paths = append(paths, filepath.Join(root, path)) 268 return nil 269 }) 270 if err != nil { 271 return nil, err 272 } 273 return paths, nil 274 }