fix(apps): update single prop in config

This commit is contained in:
Denis Gukov 2024-07-08 23:56:59 +05:00
parent 10d7f5045e
commit 413bb8bc0c
8 changed files with 243 additions and 49 deletions

View File

@ -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)
}

42
api/apps_test.go Normal file
View File

@ -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)
}

View File

@ -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()

View File

@ -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
}

View File

@ -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)
//}
}

View File

@ -1,6 +1,6 @@
package util
type AppConfig struct {
type App struct {
Active bool `json:"active"`
Order int `json:"order"`
Title string `json:"title"`

View File

@ -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,
}
}

View File

@ -48,19 +48,12 @@
class="mt-4"
:footer-props="{ itemsPerPageOptions: [20] }"
>
<template v-slot:item.external="{ item }">
<v-icon v-if="item.external">mdi-checkbox-marked</v-icon>
<v-icon v-else>mdi-checkbox-blank-outline</v-icon>
</template>
<template v-slot:item.alert="{ item }">
<v-icon v-if="item.alert">mdi-checkbox-marked</v-icon>
<v-icon v-else>mdi-checkbox-blank-outline</v-icon>
</template>
<template v-slot:item.admin="{ item }">
<v-icon v-if="item.admin">mdi-checkbox-marked</v-icon>
<v-icon v-else>mdi-checkbox-blank-outline</v-icon>
<template v-slot:item.active="{ item }">
<v-switch
v-model="item.active"
inset
@change="setActive(item.id, item.active)"
></v-switch>
</template>
<template v-slot:item.actions="{ item }">
@ -91,6 +84,7 @@ import EventBus from '@/event-bus';
import YesNoDialog from '@/components/YesNoDialog.vue';
import ItemListPageBase from '@/components/ItemListPageBase';
import EditDialog from '@/components/EditDialog.vue';
import axios from 'axios';
export default {
mixins: [ItemListPageBase],
@ -104,11 +98,14 @@ export default {
getHeaders() {
return [{
text: '',
value: 'active',
}, {
text: 'ID',
value: 'id',
}, {
text: this.$i18n.t('title'),
value: 'Title',
text: this.$i18n.t('name'),
width: '100%',
value: 'title',
}, {
text: this.$i18n.t('actions'),
value: 'actions',
@ -131,6 +128,17 @@ export default {
getEventName() {
return 'i-app';
},
async setActive(appId, active) {
await axios({
method: 'post',
url: `/api/apps/${appId}/active`,
responseType: 'json',
data: {
active,
},
});
},
},
};
</script>