This commit is contained in:
2026-09-25 16:32:09 +03:00
parent bf932e5ef9
commit d26e8b2621
33 changed files with 2080 additions and 595 deletions

View File

@@ -5,27 +5,72 @@ import (
"io"
"os"
"reflect"
"sort"
"strings"
"testing"
)
// extractTypeFromSchema extracts type from schema, handling oneOf cases
// When oneOf contains a type and null, it returns the non-null type
// 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 {
// Check for direct type
if paramType, ok := schema["type"].(string); ok {
return paramType
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
}
}
}
// Check for oneOf
if oneOf, ok := schema["oneOf"].([]interface{}); ok {
for _, item := range oneOf {
if itemMap, ok := item.(map[string]interface{}); ok {
if itemType, ok := itemMap["type"].(string); ok {
// Skip null type, return the first non-null type found
if itemType != "null" {
return itemType
}
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
}
}
}
@@ -34,150 +79,84 @@ func extractTypeFromSchema(schema map[string]interface{}) string {
return ""
}
func getParameters(input map[string]interface{}) []interface{} {
var emptySlice []interface{}
methods := []string{"get", "post", "put", "delete", "patch", "head", "options"}
var methodData interface{}
found := false
for _, method := range methods {
if data, ok := input[method]; ok {
methodData = data
found = true
break
// 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
}
if !found {
return emptySlice
}
for _, method := range []string{"get", "post", "put", "delete", "patch", "head", "options"} {
operation, ok := input[method].(map[string]interface{})
if !ok {
continue
}
parameters, ok := methodData.(map[string]interface{})
if !ok {
return emptySlice
}
var result []interface{}
if requestBody, ok := parameters["requestBody"].(map[string]interface{}); ok {
if content, ok := requestBody["content"].(map[string]interface{}); ok {
// Check for application/x-www-form-urlencoded, application/json, or multipart/form-data
var schemaData map[string]interface{}
var found bool
if formData, ok := content["application/x-www-form-urlencoded"].(map[string]interface{}); ok {
if schema, ok := formData["schema"].(map[string]interface{}); ok {
schemaData = schema
found = true
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
}
} else if jsonData, ok := content["application/json"].(map[string]interface{}); ok {
if schema, ok := jsonData["schema"].(map[string]interface{}); ok {
schemaData = schema
found = true
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
}
}
}
} else if multipartData, ok := content["multipart/form-data"].(map[string]interface{}); ok {
if schema, ok := multipartData["schema"].(map[string]interface{}); ok {
schemaData = schema
found = true
properties, _ := schema["properties"].(map[string]interface{})
for name, prop := range properties {
propMap, _ := prop.(map[string]interface{})
add(name, propMap, requiredFields[name])
}
}
}
if found && schemaData != nil {
if properties, ok := schemaData["properties"].(map[string]interface{}); ok {
requiredFields := make(map[string]bool)
if req, ok := schemaData["required"].([]interface{}); ok {
for _, r := range req {
if reqStr, ok := r.(string); ok {
requiredFields[reqStr] = true
}
}
}
for name, prop := range properties {
propMap, ok := prop.(map[string]interface{})
if !ok {
continue
}
newParam := make(map[string]interface{})
newParam["name"] = name
propType := extractTypeFromSchema(propMap)
if propType != "" {
newParam["type"] = propType
}
newParam["required"] = requiredFields[name]
if propType == "array" {
// Try to get items from propMap first
if items, ok := propMap["items"].(map[string]interface{}); ok {
newParam["items"] = items
} else if oneOf, ok := propMap["oneOf"].([]interface{}); ok {
// If items not in propMap, try to get from oneOf array element
for _, item := range oneOf {
if itemMap, ok := item.(map[string]interface{}); ok {
if itemType, ok := itemMap["type"].(string); ok && itemType == "array" {
if items, ok := itemMap["items"].(map[string]interface{}); ok {
newParam["items"] = items
break
}
}
}
}
}
}
result = append(result, newParam)
}
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)
}
}
}
if params, ok := parameters["parameters"].([]interface{}); ok {
for _, p := range params {
param, ok := p.(map[string]interface{})
if !ok {
continue
}
newParam := make(map[string]interface{})
if name, ok := param["name"].(string); ok {
newParam["name"] = name
}
if schema, ok := param["schema"].(map[string]interface{}); ok {
paramType := extractTypeFromSchema(schema)
if paramType != "" {
newParam["type"] = paramType
}
if paramType == "array" {
if items, ok := schema["items"].(map[string]interface{}); ok {
newParam["items"] = items
} else if oneOf, ok := schema["oneOf"].([]interface{}); ok {
for _, item := range oneOf {
if itemMap, ok := item.(map[string]interface{}); ok {
if itemType, ok := itemMap["type"].(string); ok && itemType == "array" {
if items, ok := itemMap["items"].(map[string]interface{}); ok {
newParam["items"] = items
break
}
}
}
}
}
}
}
// Handle required field
if required, ok := param["required"].(bool); ok {
newParam["required"] = required
} else {
newParam["required"] = false
}
result = append(result, newParam)
}
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 })
if len(result) > 0 {
return result
}
return emptySlice
return result
}
func getBytesFromJSON(fileName string, t *testing.T) []byte {
@@ -195,6 +174,7 @@ func getBytesFromJSON(fileName string, t *testing.T) []byte {
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{}
@@ -209,227 +189,272 @@ func getErrorsFromJSON(bytes []byte, t *testing.T, cloud string) {
t.Fatalf("Wrong cloud provided, expected `cloudapi`, `cloudbroker` or `sdn`, got %s", cloud)
}
var dataLogs []string
index, err := getSDKIndex()
if err != nil {
t.Fatalf("can not parse SDK sources: %v", err)
}
paths, err := getMapFromFile(bytes)
if err != nil {
t.Error(err)
t.Fatal(err)
}
i := 0
for k, v := range paths {
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
}
params := getParameters(v.(map[string]interface{}))
structure, ok := requests[k]
v, ok := paths[k]
if !ok {
absentOnPlatform = append(absentOnPlatform, k)
continue
}
i++
typStruct := reflect.TypeOf(structure)
var errs []string
// empty request case
if len(params) == 0 && structure == nil {
continue
}
if len(params) != typStruct.NumField() {
errs = append(errs, fmt.Sprintf("Platform (%d) and golang structure (%d) have different amount of fields.", len(params), typStruct.NumField()))
}
paramMap := make(map[string]bool)
paramRequiredMap := make(map[string]bool)
for _, p := range params {
param, ok := p.(map[string]interface{})
if !ok {
continue
}
name, ok := param["name"].(string)
if ok {
paramMap[name] = true
required, ok := param["required"].(bool)
if ok {
paramRequiredMap[name] = required
} else {
paramRequiredMap[name] = false
}
}
}
for _, p := range params {
param, ok := p.(map[string]interface{})
if !ok {
continue
}
name, ok := param["name"].(string)
if !ok {
name = ""
}
required, ok := param["required"].(bool)
if !ok {
required = false
}
typ, ok := p.(map[string]interface{})["type"].(string)
if !ok {
typ = ""
}
var items string
if p.(map[string]interface{})["items"] != nil {
itemsTemp := p.(map[string]interface{})["items"]
if itemsTemp != nil {
itemsType := itemsTemp.(map[string]interface{})["type"]
if itemsType != nil {
items, ok = itemsType.(string)
if !ok {
items = ""
}
}
}
}
var found bool
for i := 0; i < typStruct.NumField(); i++ {
jsonTag := typStruct.Field(i).Tag.Get("json")
validation, _ := typStruct.Field(i).Tag.Lookup("validate")
if checkName(name, jsonTag) {
if !checkRequired(required, validation, typ) {
errs = append(errs, fmt.Sprintf("Field %s has different required parameters on the platform and in golang structure", name))
}
if !checkKind(typ, items, typStruct.Field(i).Type) {
errs = append(errs, fmt.Sprintf("Field %s has different type parameters on the platform and in golang structure", name))
}
found = true
break
}
}
if !found {
errs = append(errs, fmt.Sprintf("Platform has field %s that golang structure doesn't", name))
}
}
// Check if required fields in Go structure are missing from platform JSON
// or if they exist but are not required on platform
for i := 0; i < typStruct.NumField(); i++ {
jsonTag := typStruct.Field(i).Tag.Get("json")
validation, _ := typStruct.Field(i).Tag.Lookup("validate")
fieldName := strings.Split(jsonTag, ",")[0]
if fieldName == "" || fieldName == "-" {
continue
}
if strings.Contains(validation, "required") {
if !paramMap[fieldName] {
errs = append(errs, fmt.Sprintf("Golang structure has required field %s that platform doesn't", fieldName))
} else {
platformRequired := paramRequiredMap[fieldName]
if !platformRequired {
fieldType := typStruct.Field(i).Type
if fieldType.Kind() == reflect.Bool {
errs = append(errs, fmt.Sprintf("Golang structure has required field %s that platform doesn't", fieldName))
}
}
}
}
}
errs := checkPath(k, getParameters(v.(map[string]interface{})), index)
if len(errs) > 0 {
msg := fmt.Sprintf("Path %s has following errors: %v", k, errs)
t.Error(msg)
dataLogs = append(dataLogs, msg)
t.Errorf("Path %s has following errors: %v", k, errs)
}
}
if len(requests) != i {
msg := fmt.Sprintf("Amount of structure checked (%d) is not the same as amount of platform requests available (%d), please check getRequests func in code.",
i, len(requests))
t.Error(msg)
dataLogs = append(dataLogs, msg)
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)
}
}
// checkName checks if name field from platform has the same value as json tag in golang structure (maybe including omitempty)
func checkName(name, tag string) bool {
return strings.Contains(tag, name)
// 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
}
// checkRequired checks if required field from platform has the same value as validate tag in golang structure
func checkRequired(required bool, validation, fieldType string) bool {
if required && strings.Contains(validation, "required") {
return true
}
if required && (!strings.Contains(validation, "omitempty") && validation != "") {
return true
}
if !required && (validation == "" || strings.Contains(validation, "omitempty")) {
return true
// 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"}
}
if fieldType == "boolean" {
return true // otherwise we have issues with setting false/true
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
}
// checkKind checks if type field from platform has the same value as field type in golang structure
func checkKind(platformType, items string, typ reflect.Type) bool {
//nolint
switch typ.Kind() {
case reflect.Int, reflect.Int32, reflect.Int64, reflect.Int8, reflect.Int16, reflect.Uint, reflect.Uint64, reflect.Uint16, reflect.Uint32, reflect.Uint8:
if platformType == "integer" {
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
}
case reflect.String:
// 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
}
return false
// json-encoded object or array of objects
return transport == transportForm &&
(platformType == "object" || (platformType == "array" && items == "object"))
case reflect.Bool:
if platformType == "boolean" {
return true
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 false
return true
case reflect.Float32, reflect.Float64:
if platformType == "number" {
return true
}
return false
case kind == reflect.Map || kind == reflect.Struct:
return platformType == "object"
case reflect.Array, reflect.Slice:
case kind == reflect.Array || kind == reflect.Slice:
if platformType != "array" {
return false
}
elem := typ.Elem()
switch elem.Kind() {
case reflect.String:
if items == "string" {
return true
}
return false
case reflect.Int, reflect.Int32, reflect.Int64, reflect.Int8, reflect.Int16, reflect.Uint, reflect.Uint64, reflect.Uint16, reflect.Uint32, reflect.Uint8:
if items == "integer" {
return true
}
return false
default: // for cases like dataDisks etc.
return true
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
}
default: // for cases like drivers etc
return true
}
return true
}