package test import ( "go/ast" "go/parser" "go/token" "os" "path/filepath" "reflect" "strconv" "strings" "sync" ) // transport defines which struct tags are used to serialize request on the wire const ( transportForm = "form" transportJSON = "json" ) // sentField describes one request parameter exactly as SDK puts it on the wire type sentField struct { // Name of the parameter on the wire Name string // Name of the go field (for messages) GoName string // Go type of the field, nil if it can not be resolved Type reflect.Type // Field is not sent when it has zero value OmitEmpty bool // Value of `validate` tag Validate string } // sdkCall describes one DecortApiCall* invocation found in SDK sources type sdkCall struct { // Directory of package the call was found in Dir string // Name of the type passed as params (may be unexported wrapper), empty for nil params TypeName string Transport string // Name of function the call was found in FuncName string } // sdkIndex stores parsed SDK packages: struct declarations and API calls by url type sdkIndex struct { // dir -> type name -> type expression types map[string]map[string]ast.Expr // url (e.g. "/cloudapi/compute/start") -> calls calls map[string][]sdkCall } var ( sdkIndexOnce sync.Once sdkIndexCached *sdkIndex sdkIndexErr error ) // getSDKIndex parses SDK sources in ../../pkg once per test run func getSDKIndex() (*sdkIndex, error) { sdkIndexOnce.Do(func() { sdkIndexCached, sdkIndexErr = buildSDKIndex("../../pkg") }) return sdkIndexCached, sdkIndexErr } func buildSDKIndex(root string) (*sdkIndex, error) { idx := newSDKIndex() fset := token.NewFileSet() err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { return nil } file, err := parser.ParseFile(fset, path, nil, 0) if err != nil { return err } idx.addFile(filepath.Dir(path), file) return nil }) if err != nil { return nil, err } return idx, nil } func newSDKIndex() *sdkIndex { return &sdkIndex{ types: make(map[string]map[string]ast.Expr), calls: make(map[string][]sdkCall), } } // addFile collects type declarations and API calls of one file func (idx *sdkIndex) addFile(dir string, file *ast.File) { if idx.types[dir] == nil { idx.types[dir] = make(map[string]ast.Expr) } for _, decl := range file.Decls { switch d := decl.(type) { case *ast.GenDecl: for _, spec := range d.Specs { if ts, ok := spec.(*ast.TypeSpec); ok { idx.types[dir][ts.Name.Name] = ts.Type } } case *ast.FuncDecl: if d.Body != nil { idx.addFuncCalls(dir, d) } } } } // addFuncCalls finds DecortApiCall* invocations in function body and resolves url and type of params func (idx *sdkIndex) addFuncCalls(dir string, fn *ast.FuncDecl) { // variable name -> type name varTypes := make(map[string]string) for _, field := range fn.Type.Params.List { for _, name := range field.Names { varTypes[name.Name] = typeNameOf(field.Type) } } // variable name -> last assigned string literal strVars := make(map[string]string) ast.Inspect(fn.Body, func(n ast.Node) bool { switch node := n.(type) { case *ast.AssignStmt: for i, lhs := range node.Lhs { ident, ok := lhs.(*ast.Ident) if !ok || i >= len(node.Rhs) { continue } if s, ok := stringLiteral(node.Rhs[i]); ok { strVars[ident.Name] = s } if name := typeNameOfValue(node.Rhs[i]); name != "" { varTypes[ident.Name] = name } } case *ast.ValueSpec: for i, name := range node.Names { if node.Type != nil { varTypes[name.Name] = typeNameOf(node.Type) } if i < len(node.Values) { if s, ok := stringLiteral(node.Values[i]); ok { strVars[name.Name] = s } if tn := typeNameOfValue(node.Values[i]); tn != "" { varTypes[name.Name] = tn } } } case *ast.CallExpr: sel, ok := node.Fun.(*ast.SelectorExpr) if !ok { return true } var urlArg, paramsArg ast.Expr transport := transportForm switch sel.Sel.Name { case "DecortApiCall", "DecortApiCallMP": if len(node.Args) != 4 { return true } urlArg, paramsArg = node.Args[2], node.Args[3] case "DecortApiCallCtype": if len(node.Args) != 5 { return true } urlArg, paramsArg = node.Args[2], node.Args[4] if ctype, ok := node.Args[3].(*ast.SelectorExpr); ok && ctype.Sel.Name == "MIMEJSON" { transport = transportJSON } default: return true } url, ok := stringLiteral(urlArg) if !ok { ident, isIdent := urlArg.(*ast.Ident) if !isIdent { return true } if url, ok = strVars[ident.Name]; !ok { return true } } call := sdkCall{Dir: dir, Transport: transport, FuncName: fn.Name.Name} switch p := paramsArg.(type) { case *ast.Ident: if p.Name != "nil" { call.TypeName = varTypes[p.Name] } default: call.TypeName = typeNameOfValue(paramsArg) } idx.calls[url] = append(idx.calls[url], call) } return true }) } func stringLiteral(e ast.Expr) (string, bool) { lit, ok := e.(*ast.BasicLit) if !ok || lit.Kind != token.STRING { return "", false } s, err := strconv.Unquote(lit.Value) return s, err == nil } // typeNameOfValue returns type name of expressions like T{...} or &T{...} func typeNameOfValue(e ast.Expr) string { switch v := e.(type) { case *ast.CompositeLit: return typeNameOf(v.Type) case *ast.UnaryExpr: return typeNameOfValue(v.X) } return "" } // typeNameOf returns name of local named type (T or *T), empty string otherwise func typeNameOf(e ast.Expr) string { switch v := e.(type) { case *ast.Ident: return v.Name case *ast.StarExpr: return typeNameOf(v.X) } return "" } // callsForPath returns SDK calls for platform path like "/restmachine/cloudapi/compute/start" func (idx *sdkIndex) callsForPath(path string) []sdkCall { return idx.calls[strings.TrimPrefix(path, "/restmachine")] } // sentFields returns flattened list of parameters SDK puts on the wire for the call func (idx *sdkIndex) sentFields(call sdkCall) ([]sentField, bool) { if call.TypeName == "" { return nil, true } expr, ok := idx.types[call.Dir][call.TypeName] if !ok { return nil, false } st, ok := expr.(*ast.StructType) if !ok { return nil, false } fields := idx.structFields(call.Dir, st, call.Transport, map[string]bool{call.TypeName: true}) // serialized wrapper field inherits `validate` of the skipped (url:"-") request field with the same json name if call.Transport == transportForm { skipped := idx.skippedFormFields(call.Dir, st, map[string]bool{call.TypeName: true}) for i := range fields { if fields[i].Validate != "" { continue } if validate, ok := skipped[strings.TrimSpace(fields[i].Name)]; ok { fields[i].Validate = validate } } } return fields, true } // skippedFormFields returns json name -> validate tag of fields that are not sent in form requests (url:"-") func (idx *sdkIndex) skippedFormFields(dir string, st *ast.StructType, visited map[string]bool) map[string]string { result := make(map[string]string) for _, field := range st.Fields.List { var tag reflect.StructTag if field.Tag != nil { if s, err := strconv.Unquote(field.Tag.Value); err == nil { tag = reflect.StructTag(s) } } urlTag, hasURLTag := tag.Lookup("url") if len(field.Names) == 0 && !hasURLTag { name := typeNameOf(field.Type) if embedded, ok := idx.types[dir][name].(*ast.StructType); ok && !visited[name] { visited[name] = true for k, v := range idx.skippedFormFields(dir, embedded, visited) { result[k] = v } delete(visited, name) } continue } if urlTag != "-" { continue } jsonName := strings.TrimSpace(strings.Split(tag.Get("json"), ",")[0]) if jsonName != "" && jsonName != "-" { result[jsonName] = tag.Get("validate") } } return result } func (idx *sdkIndex) structFields(dir string, st *ast.StructType, transport string, visited map[string]bool) []sentField { var result []sentField for _, field := range st.Fields.List { var tag reflect.StructTag if field.Tag != nil { if s, err := strconv.Unquote(field.Tag.Value); err == nil { tag = reflect.StructTag(s) } } wireTag, hasWireTag := tag.Lookup(transportTagKey(transport)) // embedded struct without tag is flattened by both encoding/json and go-querystring if len(field.Names) == 0 && !hasWireTag { name := typeNameOf(field.Type) if embedded, ok := idx.types[dir][name].(*ast.StructType); ok && !visited[name] { visited[name] = true result = append(result, idx.structFields(dir, embedded, transport, visited)...) delete(visited, name) } continue } goNames := make([]string, 0, len(field.Names)) for _, n := range field.Names { goNames = append(goNames, n.Name) } if len(goNames) == 0 { goNames = append(goNames, typeNameOf(field.Type)) } for _, goName := range goNames { if !ast.IsExported(goName) { continue } name, omitEmpty, skip := parseWireTag(wireTag, goName) if skip { continue } result = append(result, sentField{ Name: name, GoName: goName, Type: idx.resolveType(dir, field.Type, map[string]bool{}), OmitEmpty: omitEmpty, Validate: tag.Get("validate"), }) } } return result } func transportTagKey(transport string) string { if transport == transportJSON { return "json" } return "url" } // parseWireTag returns wire name of the field, omitempty flag and whether the field is skipped (tag "-") func parseWireTag(tag, goName string) (string, bool, bool) { parts := strings.Split(tag, ",") // keep tag name as is (including spaces) so that typos in tags are reported name := parts[0] if name == "-" && len(parts) == 1 { return "", false, true } if name == "" { name = goName } var omitEmpty bool for _, opt := range parts[1:] { if strings.TrimSpace(opt) == "omitempty" { omitEmpty = true } } return name, omitEmpty, false } var ( emptyInterfaceType = reflect.TypeOf((*interface{})(nil)).Elem() structPlaceholder = reflect.TypeOf(struct{}{}) basicTypes = map[string]reflect.Type{ "bool": reflect.TypeOf(false), "string": reflect.TypeOf(""), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0)), "any": emptyInterfaceType, } ) // resolveType converts type expression to reflect.Type for kind comparison (nil for types from other packages) func (idx *sdkIndex) resolveType(dir string, e ast.Expr, visited map[string]bool) reflect.Type { switch v := e.(type) { case *ast.Ident: if t, ok := basicTypes[v.Name]; ok { return t } if visited[v.Name] { return nil } underlying, ok := idx.types[dir][v.Name] if !ok { return nil } visited[v.Name] = true return idx.resolveType(dir, underlying, visited) case *ast.StarExpr: return idx.resolveType(dir, v.X, visited) case *ast.StructType: return structPlaceholder case *ast.InterfaceType: return emptyInterfaceType case *ast.ArrayType: elem := idx.resolveType(dir, v.Elt, visited) if elem == nil { elem = emptyInterfaceType } return reflect.SliceOf(elem) case *ast.MapType: return reflect.MapOf(reflect.TypeOf(""), emptyInterfaceType) } return nil }