Files
decort-golang-sdk/tests/platform_upgrade/utils_requests.go
2026-09-25 16:32:09 +03:00

461 lines
12 KiB
Go

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
}