diff --git a/api/apps.go b/api/apps.go index 4b383cae..8070cc94 100644 --- a/api/apps.go +++ b/api/apps.go @@ -2,13 +2,62 @@ package api import ( "errors" + "fmt" "github.com/ansible-semaphore/semaphore/api/helpers" "github.com/ansible-semaphore/semaphore/db" "github.com/ansible-semaphore/semaphore/util" "github.com/gorilla/context" "net/http" + "reflect" + "strconv" + "strings" ) +func structToFlatMap(obj interface{}) map[string]interface{} { + result := make(map[string]interface{}) + val := reflect.ValueOf(obj) + typ := reflect.TypeOf(obj) + + if typ.Kind() == reflect.Ptr { + val = val.Elem() + typ = typ.Elem() + } + + if typ.Kind() != reflect.Struct { + return result + } + + // Iterate over the struct fields + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + jsonTag := fieldType.Tag.Get("json") + + // Use the json tag if it is set, otherwise use the field name + fieldName := jsonTag + if fieldName == "" || fieldName == "-" { + fieldName = fieldType.Name + } else { + // Handle the case where the json tag might have options like `json:"name,omitempty"` + fieldName = strings.Split(fieldName, ",")[0] + } + + // Check if the field is a struct itself + if field.Kind() == reflect.Struct { + // Convert nested struct to map + nestedMap := structToFlatMap(field.Interface()) + // Add nested map to result with a prefixed key + for k, v := range nestedMap { + result[fieldName+"."+k] = v + } + } else { + result[fieldName] = field.Interface() + } + } + + return result +} + func validateAppID(str string) error { return nil } @@ -85,8 +134,55 @@ func deleteApp(w http.ResponseWriter, r *http.Request) { } func setApp(w http.ResponseWriter, r *http.Request) { + appID := context.Get(r, "app_id").(string) + + store := helpers.Store(r) + + var app util.App + + if !helpers.Bind(w, r, &app) { + return + } + + options := structToFlatMap(app) + + for k, v := range options { + if err := store.SetOption("apps."+appID+"."+k, fmt.Sprintf("%v", v)); err != nil { + helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusNoContent) } func setAppActive(w http.ResponseWriter, r *http.Request) { + appID := context.Get(r, "app_id").(string) + store := helpers.Store(r) + + var body struct { + Active bool `json:"active"` + } + + if !helpers.Bind(w, r, &body) { + return + } + + key := "apps." + appID + ".active" + val := strconv.FormatBool(body.Active) + + if err := store.SetOption(key, val); err != nil { + helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError) + return + } + + opts := make(map[string]string) + opts[key] = val + + options := db.ConvertFlatToNested(opts) + + _ = db.AssignMapToStruct(options, util.Config) + + w.WriteHeader(http.StatusNoContent) } diff --git a/api/apps_test.go b/api/apps_test.go new file mode 100644 index 00000000..dbec0c89 --- /dev/null +++ b/api/apps_test.go @@ -0,0 +1,42 @@ +package api + +import ( + "fmt" + "testing" +) + +func TestStructToMap(t *testing.T) { + type Address struct { + City string `json:"city"` + State string `json:"state"` + } + + type Person struct { + Name string `json:"name"` + Age int `json:"age"` + Email string `json:"email"` + Active bool `json:"active"` + Address Address `json:"address"` + } + + // Create an instance of the struct + p := Person{ + Name: "John Doe", + Age: 30, + Email: "johndoe@example.com", + Active: true, + Address: Address{ + City: "New York", + State: "NY", + }, + } + + // Convert the struct to a flat map + flatMap := structToFlatMap(&p) + + if flatMap["address.city"] != "New York" { + t.Fail() + } + // Print the map + fmt.Println(flatMap) +} diff --git a/api/router.go b/api/router.go index 03eeb93e..183118c3 100644 --- a/api/router.go +++ b/api/router.go @@ -102,7 +102,6 @@ func Route() *mux.Router { authenticatedWS.Path("/ws").HandlerFunc(sockets.Handler).Methods("GET", "HEAD") authenticatedAPI := r.PathPrefix(webPath + "api").Subrouter() - authenticatedAPI.Use(StoreMiddleware, JSONMiddleware, authentication) authenticatedAPI.Path("/info").HandlerFunc(getSystemInfo).Methods("GET", "HEAD") @@ -129,10 +128,11 @@ func Route() *mux.Router { adminAPI.Path("/options").HandlerFunc(getOptions).Methods("GET", "HEAD") adminAPI.Path("/options").HandlerFunc(setOption).Methods("POST", "HEAD") - appsAPI := adminAPI.Path("/apps").Subrouter() + appsAPI := adminAPI.PathPrefix("/apps").Subrouter() appsAPI.Use(appMiddleware) appsAPI.Path("/{app_id}").HandlerFunc(getApp).Methods("GET", "HEAD") appsAPI.Path("/{app_id}").HandlerFunc(setApp).Methods("POST", "HEAD") + appsAPI.Path("/{app_id}/active").HandlerFunc(setAppActive).Methods("POST", "HEAD") appsAPI.Path("/{app_id}").HandlerFunc(deleteApp).Methods("POST", "HEAD") userAPI := authenticatedAPI.Path("/users/{user_id}").Subrouter() diff --git a/db/config.go b/db/config.go index 574bf32a..06c62969 100644 --- a/db/config.go +++ b/db/config.go @@ -7,7 +7,7 @@ import ( "strings" ) -func convertFlatToNested(flatMap map[string]string) map[string]interface{} { +func ConvertFlatToNested(flatMap map[string]string) map[string]interface{} { nestedMap := make(map[string]interface{}) for key, value := range flatMap { @@ -29,11 +29,27 @@ func convertFlatToNested(flatMap map[string]string) map[string]interface{} { return nestedMap } -func assignMapToStruct[P *S, S any](m map[string]interface{}, s P) error { +func AssignMapToStruct[P *S, S any](m map[string]interface{}, s P) error { v := reflect.ValueOf(s).Elem() return assignMapToStructRecursive(m, v) } +func cloneStruct(origValue reflect.Value) reflect.Value { + // Create a new instance of the same type as the original struct + cloneValue := reflect.New(origValue.Type()).Elem() + + // Iterate over the fields of the struct + for i := 0; i < origValue.NumField(); i++ { + // Get the field value + fieldValue := origValue.Field(i) + // Set the field value in the clone + cloneValue.Field(i).Set(fieldValue) + } + + // Return the cloned struct + return cloneValue +} + func assignMapToStructRecursive(m map[string]interface{}, structValue reflect.Value) error { structType := structValue.Type() @@ -71,11 +87,18 @@ func assignMapToStructRecursive(m map[string]interface{}, structValue reflect.Va if val.Kind() != reflect.Map { return fmt.Errorf("expected map for field %s but got %T", field.Name, value) } - //mapValue := reflect.MakeMap(fieldValue.Type()) + for _, key := range val.MapKeys() { mapElemValue := val.MapIndex(key) mapElemType := fieldValue.Type().Elem() - mapElem := reflect.New(mapElemType).Elem() + + srcVal := fieldValue.MapIndex(key) + var mapElem reflect.Value + if srcVal.IsValid() { + mapElem = cloneStruct(srcVal) + } else { + mapElem = reflect.New(mapElemType).Elem() + } if mapElemType.Kind() == reflect.Struct { if err := assignMapToStructRecursive(mapElemValue.Interface().(map[string]interface{}), mapElem); err != nil { @@ -98,7 +121,7 @@ func assignMapToStructRecursive(m map[string]interface{}, structValue reflect.Va fieldValue.SetMapIndex(key, mapElem) } - //fieldValue.Set(mapValue) + default: // Handle simple types if val.Type().ConvertibleTo(fieldValue.Type()) { @@ -128,13 +151,13 @@ func FillConfigFromDB(store Store) (err error) { return } - options := convertFlatToNested(opts) + options := ConvertFlatToNested(opts) if options["apps"] == nil { options["apps"] = make(map[string]interface{}) } - err = assignMapToStruct(options, util.Config) + err = AssignMapToStruct(options, util.Config) return } diff --git a/db/config_test.go b/db/config_test.go index 2cb578b9..74bb8bba 100644 --- a/db/config_test.go +++ b/db/config_test.go @@ -8,43 +8,68 @@ func TestConfig_assignMapToStruct(t *testing.T) { City string `json:"city"` } + type Detail struct { + Value string `json:"value"` + Description string `json:"description"` + } + type User struct { - Name string `json:"name"` - Age int `json:"age"` - Email string `json:"email"` - Address Address `json:"address"` - Details map[string]string `json:"details"` + //Name string `json:"name"` + //Age int `json:"age"` + //Email string `json:"email"` + //Address Address `json:"address"` + Details map[string]Detail `json:"details"` } johnData := map[string]interface{}{ - "name": "John Doe", - "age": 30, - "email": "john.doe@example.com", - "address": map[string]interface{}{ - "street": "123 Main St", - "city": "Anytown", - }, - "details": map[string]string{ - "occupation": "engineer", - "hobby": "hiking", + //"name": "John Doe", + //"age": 30, + //"email": "john.doe@example.com", + //"address": map[string]interface{}{ + // "street": "123 Main St", + // "city": "Anytown", + //}, + "details": map[string]interface{}{ + //"occupation": map[string]interface{}{ + // "value": "engineer", + // "description": "Works with computers", + //}, + //"hobby": map[string]interface{}{ + // "value": "hiking", + // "description": "Enjoys the outdoors", + //}, + "interests": map[string]interface{}{ + "description": "Ho ho ho", + }, }, } var john User - john.Details = make(map[string]string) - john.Details["interests"] = "politics" + john.Details = make(map[string]Detail) + john.Details["interests"] = Detail{ + Value: "politics", + Description: "Follows current events", + } - err := assignMapToStruct(johnData, &john) + err := AssignMapToStruct(johnData, &john) if err != nil { t.Fatal(err) } - if john.Name != "John Doe" { - t.Errorf("Expected name to be John Doe but got %s", john.Name) + //if john.Name != "John Doe" { + // t.Errorf("Expected name to be John Doe but got %s", john.Name) + //} + + if john.Details["interests"].Description != "Ho ho ho" { + t.Errorf("Expected interests description to be 'Ho ho ho' but got %s", john.Details["interests"].Description) } - if john.Details["interests"] != "politics" { - t.Errorf("Expected interests to be politics but got %s", john.Details["interests"]) + if john.Details["interests"].Value != "politics" { + t.Errorf("Expected interests to be politics but got '%s'", john.Details["interests"].Value) } + + //if john.Details["occupation"].Value != "engineer" { + // t.Errorf("Expected occupation to be engineer but got %s", john.Details["occupation"].Value) + //} } diff --git a/util/App.go b/util/App.go index 4801a613..b7e20478 100644 --- a/util/App.go +++ b/util/App.go @@ -1,6 +1,6 @@ package util -type AppConfig struct { +type App struct { Active bool `json:"active"` Order int `json:"order"` Title string `json:"title"` diff --git a/util/config.go b/util/config.go index 85f09659..0009c29f 100644 --- a/util/config.go +++ b/util/config.go @@ -193,7 +193,7 @@ type ConfigType struct { IntegrationAlias string `json:"global_integration_alias" env:"SEMAPHORE_INTEGRATION_ALIAS"` - Apps map[string]AppConfig `json:"apps" env:"SEMAPHORE_APPS"` + Apps map[string]App `json:"apps" env:"SEMAPHORE_APPS"` } // Config exposes the application configuration storage for use in the application @@ -806,7 +806,7 @@ func CheckDefaultApps() { continue } - Config.Apps[app] = AppConfig{ + Config.Apps[app] = App{ Active: true, } } diff --git a/web/src/views/Apps.vue b/web/src/views/Apps.vue index 3550e28b..c33fa8d1 100644 --- a/web/src/views/Apps.vue +++ b/web/src/views/Apps.vue @@ -48,19 +48,12 @@ class="mt-4" :footer-props="{ itemsPerPageOptions: [20] }" > - - - - -