package test import ( "fmt" "io" "os" "reflect" "sort" "strings" "testing" ) // platformParam describes one request parameter from platform swagger type platformParam struct { Name string // OpenAPI type: integer, string, boolean, number, array, object or empty if unknown Type string // OpenAPI type of array items, empty if unknown Items string Required bool // Value of "default" from swagger, nil if not set Default interface{} } // extractTypeFromSchema extracts non-null type from "type" (string or array), "oneOf"/"anyOf" or "$ref" (object) func extractTypeFromSchema(schema map[string]interface{}) string { switch typ := schema["type"].(type) { case string: return typ case []interface{}: for _, item := range typ { if s, ok := item.(string); ok && s != "null" { return s } } } for _, key := range []string{"oneOf", "anyOf"} { variants, ok := schema[key].([]interface{}) if !ok { continue } for _, variant := range variants { if variantMap, ok := variant.(map[string]interface{}); ok { if variantType := extractTypeFromSchema(variantMap); variantType != "" && variantType != "null" { return variantType } } } } if _, ok := schema["$ref"]; ok { return "object" } return "" } // extractItemsType extracts type of array items from schema, including arrays declared inside oneOf/anyOf func extractItemsType(schema map[string]interface{}) string { if items, ok := schema["items"].(map[string]interface{}); ok { return extractTypeFromSchema(items) } for _, key := range []string{"oneOf", "anyOf"} { variants, ok := schema[key].([]interface{}) if !ok { continue } for _, variant := range variants { if variantMap, ok := variant.(map[string]interface{}); ok && extractTypeFromSchema(variantMap) == "array" { if items := extractItemsType(variantMap); items != "" { return items } } } } return "" } // getParameters returns request parameters of all methods, content types and query/path parameters of a path func getParameters(input map[string]interface{}) []platformParam { params := make(map[string]platformParam) add := func(name string, schema map[string]interface{}, required bool) { p := platformParam{Name: name, Required: required} if schema != nil { p.Type = extractTypeFromSchema(schema) if p.Type == "array" { p.Items = extractItemsType(schema) } p.Default = schema["default"] } if prev, ok := params[name]; ok { p.Required = p.Required || prev.Required if p.Type == "" { p.Type, p.Items = prev.Type, prev.Items } if p.Default == nil { p.Default = prev.Default } } params[name] = p } for _, method := range []string{"get", "post", "put", "delete", "patch", "head", "options"} { operation, ok := input[method].(map[string]interface{}) if !ok { continue } if requestBody, ok := operation["requestBody"].(map[string]interface{}); ok { content, _ := requestBody["content"].(map[string]interface{}) for _, media := range content { mediaMap, _ := media.(map[string]interface{}) schema, _ := mediaMap["schema"].(map[string]interface{}) if schema == nil { continue } requiredFields := make(map[string]bool) if req, ok := schema["required"].([]interface{}); ok { for _, r := range req { if reqStr, ok := r.(string); ok { requiredFields[reqStr] = true } } } properties, _ := schema["properties"].(map[string]interface{}) for name, prop := range properties { propMap, _ := prop.(map[string]interface{}) add(name, propMap, requiredFields[name]) } } } if parameters, ok := operation["parameters"].([]interface{}); ok { for _, p := range parameters { param, ok := p.(map[string]interface{}) if !ok { continue } name, ok := param["name"].(string) if !ok { continue } schema, _ := param["schema"].(map[string]interface{}) required, _ := param["required"].(bool) add(name, schema, required) } } } result := make([]platformParam, 0, len(params)) for _, p := range params { result = append(result, p) } sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) return result } func getBytesFromJSON(fileName string, t *testing.T) []byte { jsonFile, err := os.Open(fileName) if err != nil { t.Error(err) } defer jsonFile.Close() bytes, err := io.ReadAll(jsonFile) if err != nil { t.Error(err) } return bytes } // getErrorsFromJSON compares platform requests from swagger with the values SDK passes to DecortApiCall* func getErrorsFromJSON(bytes []byte, t *testing.T, cloud string) { var requests map[string]interface{} switch cloud { case "cloudapi": requests = getRequestsMapCloudAPI() case "cloudbroker": requests = getRequestsMapCloudbroker() case "sdn": requests = getRequestsMapSDN() default: t.Fatalf("Wrong cloud provided, expected `cloudapi`, `cloudbroker` or `sdn`, got %s", cloud) } index, err := getSDKIndex() if err != nil { t.Fatalf("can not parse SDK sources: %v", err) } paths, err := getMapFromFile(bytes) if err != nil { t.Fatal(err) } requestPaths := make([]string, 0, len(requests)) for k := range requests { requestPaths = append(requestPaths, k) } sort.Strings(requestPaths) var absentOnPlatform, invalidKeys []string for _, k := range requestPaths { if !strings.HasPrefix(k, "/restmachine/"+cloud+"/") { invalidKeys = append(invalidKeys, k) continue } // exclude deprecated urls from analysis if !validateUrlFromJson(k) { continue } v, ok := paths[k] if !ok { absentOnPlatform = append(absentOnPlatform, k) continue } errs := checkPath(k, getParameters(v.(map[string]interface{})), index) if len(errs) > 0 { t.Errorf("Path %s has following errors: %v", k, errs) } } if len(invalidKeys) > 0 { t.Errorf("Request map for %s has keys that don't start with /restmachine/%s/ and are never checked: %v", cloud, cloud, invalidKeys) } if len(absentOnPlatform) > 0 { t.Errorf("Request map has paths that platform doesn't (remove them from request map): %v", absentOnPlatform) } } // requestVariant is one distinct request SDK sends to a path (e.g. sync and async methods send different wrappers) type requestVariant struct { FuncName string Transport string Fields []sentField } // checkPath compares platform parameters of a path with every distinct request SDK sends to this path func checkPath(path string, params []platformParam, index *sdkIndex) []string { calls := index.callsForPath(path) if len(calls) == 0 { return []string{"SDK call for this path is not found in sources, can not check it"} } type variantKey struct{ dir, typeName, transport string } seen := make(map[variantKey]bool) var variants []requestVariant var errs []string for _, call := range calls { key := variantKey{call.Dir, call.TypeName, call.Transport} if seen[key] { continue } seen[key] = true fields, ok := index.sentFields(call) if !ok { errs = append(errs, fmt.Sprintf("can not resolve type %s sent by %s", call.TypeName, call.FuncName)) continue } variants = append(variants, requestVariant{FuncName: call.FuncName, Transport: call.Transport, Fields: fields}) } return append(errs, compareVariants(params, variants)...) } // compareVariants returns differences between platform parameters and all requests SDK sends to the path func compareVariants(params []platformParam, variants []requestVariant) []string { var errs []string seenErr := make(map[string]bool) add := func(msg string) { if !seenErr[msg] { seenErr[msg] = true errs = append(errs, msg) } } byName := make([]map[string]sentField, len(variants)) for i, v := range variants { byName[i] = make(map[string]sentField, len(v.Fields)) for _, f := range v.Fields { if _, ok := byName[i][f.Name]; ok && v.Transport == transportForm { add(fmt.Sprintf("Golang structure sends field %s more than once", f.Name)) } byName[i][f.Name] = f } } paramsByName := make(map[string]bool, len(params)) for _, p := range params { paramsByName[p.Name] = true var sentBy, notSentBy []string for i, v := range variants { f, ok := byName[i][p.Name] if !ok { notSentBy = append(notSentBy, v.FuncName) continue } sentBy = append(sentBy, v.FuncName) if !checkKind(p.Type, p.Items, f, v.Transport) { add(fmt.Sprintf("Field %s has different type parameters on the platform (%s) and in golang structure (%s)", p.Name, platformTypeString(p), f.Type)) } if msg := checkRequired(p, f); msg != "" { add(msg) } } if len(sentBy) == 0 { add(fmt.Sprintf("Platform has field %s that golang structure doesn't", p.Name)) continue } for _, fn := range notSentBy { if p.Required { add(fmt.Sprintf("Required field %s is not sent by %s", p.Name, fn)) } else if p.Type == "boolean" && p.Default == true { add(fmt.Sprintf("Field %s is not sent by %s, platform default is true", p.Name, fn)) } } } for _, v := range variants { for _, f := range v.Fields { if !paramsByName[f.Name] { add(fmt.Sprintf("Golang structure has field %q (%s) that platform doesn't", f.Name, f.GoName)) } } } return errs } func platformTypeString(p platformParam) string { if p.Type == "array" && p.Items != "" { return "array of " + p.Items } return p.Type } // hasValidateTag checks if `validate` tag contains exactly the given tag func hasValidateTag(validate, tag string) bool { for _, v := range strings.Split(validate, ",") { if strings.TrimSpace(v) == tag { return true } } return false } func isIntKind(k reflect.Kind) bool { switch k { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: return true } return false } // checkRequired returns description of required mismatch between platform and golang field or empty string func checkRequired(p platformParam, f sentField) string { // validators without omitempty fail on zero value, so such field can not be omitted either goRequired := hasValidateTag(f.Validate, "required") || (f.Validate != "" && !hasValidateTag(f.Validate, "omitempty")) // numbers and booleans without omitempty are always sent and zero is a meaningful value for them alwaysSent := false if f.Type != nil && !f.OmitEmpty { kind := f.Type.Kind() alwaysSent = isIntKind(kind) || kind == reflect.Bool || kind == reflect.Float32 || kind == reflect.Float64 } if p.Required && !goRequired && !alwaysSent { return fmt.Sprintf("Field %s is required on the platform but is optional in golang structure", p.Name) } if !p.Required && hasValidateTag(f.Validate, "required") { return fmt.Sprintf("Field %s is optional on the platform but is required in golang structure", p.Name) } return "" } // checkKind checks if type field from platform corresponds to field type in golang structure func checkKind(platformType, items string, f sentField, transport string) bool { typ := f.Type // type can not be resolved on one of the sides if typ == nil || platformType == "" { return true } for typ.Kind() == reflect.Ptr { typ = typ.Elem() } kind := typ.Kind() switch { case isIntKind(kind): return platformType == "integer" case kind == reflect.String: if platformType == "string" { return true } // json-encoded object or array of objects return transport == transportForm && (platformType == "object" || (platformType == "array" && items == "object")) case kind == reflect.Bool: return platformType == "boolean" case kind == reflect.Float32 || kind == reflect.Float64: return platformType == "number" case kind == reflect.Interface: if hasValidateTag(f.Validate, "isBool") { return platformType == "boolean" } return true case kind == reflect.Map || kind == reflect.Struct: return platformType == "object" case kind == reflect.Array || kind == reflect.Slice: if platformType != "array" { return false } elem := typ.Elem() for elem.Kind() == reflect.Ptr { elem = elem.Elem() } // items type is not declared on the platform if items == "" { return true } switch { case elem.Kind() == reflect.String: // list of json-encoded objects return items == "string" || (transport == transportForm && items == "object") case isIntKind(elem.Kind()): return items == "integer" case elem.Kind() == reflect.Bool: return items == "boolean" case elem.Kind() == reflect.Float32 || elem.Kind() == reflect.Float64: return items == "number" case elem.Kind() == reflect.Struct || elem.Kind() == reflect.Map: return items == "object" default: return true } } return true }