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 }