// Package config gère le chargement des configurations YAML. package config import ( "fmt" "os" "path/filepath" "strings" "sync" "gopkg.in/yaml.v3" ) // AppConfig représente la configuration d'une application cliente. type AppConfig struct { App string `yaml:"app"` Version string `yaml:"version"` BasePath string `yaml:"base_path"` Hosts []string `yaml:"hosts"` Database Database `yaml:"database"` Auth Auth `yaml:"auth"` Routes []Route `yaml:"routes"` Queries *Queries // Chargé depuis config/apps/{app}/queries/ Schema *Schema // Chargé depuis config/apps/{app}/schema.yaml } // Queries stocke les requêtes SQL par domaine. type Queries struct { Auth map[string]any `yaml:",inline"` Projects map[string]any `yaml:",inline"` Tasks map[string]any `yaml:",inline"` Tags map[string]any `yaml:",inline"` Statuses map[string]any `yaml:",inline"` files map[string]map[string]any // domaine -> clé -> requête } // Get retourne une requête par domaine et clé. func (q *Queries) Get(domain, key string) string { if q == nil || q.files == nil { return "" } if domainMap, ok := q.files[domain]; ok { if val, ok := domainMap[key]; ok { if s, ok := val.(string); ok { return strings.TrimSpace(s) } } } return "" } // FileCount retourne le nombre de fichiers de queries chargés. func (q *Queries) FileCount() int { if q == nil || q.files == nil { return 0 } return len(q.files) } // GetMap retourne une map de requêtes (ex: login_data). func (q *Queries) GetMap(domain, key string) map[string]string { if q == nil || q.files == nil { return nil } if domainMap, ok := q.files[domain]; ok { if val, ok := domainMap[key]; ok { if m, ok := val.(map[string]any); ok { result := make(map[string]string) for k, v := range m { if s, ok := v.(string); ok { result[k] = strings.TrimSpace(s) } } return result } } } return nil } // QueryConfig représente une requête paramétrable. type QueryConfig struct { Query string Filters map[string]string Order string } // GetQuery retourne une QueryConfig pour un domaine et une clé. func (q *Queries) GetQuery(domain, key string) *QueryConfig { if q == nil || q.files == nil { return nil } domainMap, ok := q.files[domain] if !ok { return nil } val, ok := domainMap[key] if !ok { return nil } m, ok := val.(map[string]any) if !ok { // Ancienne syntaxe : juste une string if s, ok := val.(string); ok { return &QueryConfig{Query: strings.TrimSpace(s)} } return nil } qc := &QueryConfig{ Filters: make(map[string]string), } if query, ok := m["query"].(string); ok { qc.Query = strings.TrimSpace(query) } if order, ok := m["order"].(string); ok { qc.Order = strings.TrimSpace(order) } if filters, ok := m["filters"].(map[string]any); ok { for k, v := range filters { if s, ok := v.(string); ok { qc.Filters[k] = strings.TrimSpace(s) } } } return qc } // CUDConfig représente une config pour Create/Update/Delete. type CUDConfig struct { Table string Fields []string Filters map[string]string } // GetCUD retourne une CUDConfig pour un domaine et une clé (create/update/delete). func (q *Queries) GetCUD(domain, key string) *CUDConfig { if q == nil || q.files == nil { return nil } domainMap, ok := q.files[domain] if !ok { return nil } val, ok := domainMap[key] if !ok { return nil } m, ok := val.(map[string]any) if !ok { return nil } cud := &CUDConfig{ Filters: make(map[string]string), } if table, ok := m["table"].(string); ok { cud.Table = table } if fields, ok := m["fields"].([]any); ok { for _, f := range fields { if s, ok := f.(string); ok { cud.Fields = append(cud.Fields, s) } } } if filters, ok := m["filters"].(map[string]any); ok { for k, v := range filters { if s, ok := v.(string); ok { cud.Filters[k] = strings.TrimSpace(s) } } } return cud } // GetFilter retourne le filtre pour un rôle donné. func (cud *CUDConfig) GetFilter(role string) string { if cud == nil { return "" } if f, ok := cud.Filters[role]; ok { return f } if f, ok := cud.Filters["default"]; ok { return f } return "" } // Build construit la requête SQL finale avec filtres et ordre. // role: rôle de l'utilisateur (ou "default") // params: map de placeholders à remplacer (:user_id, :id, etc.) func (qc *QueryConfig) Build(role string, params map[string]any) (string, []any) { if qc == nil { return "", nil } query := qc.Query // Déterminer le filtre à appliquer filter := "" if f, ok := qc.Filters[role]; ok { filter = f } else if f, ok := qc.Filters["default"]; ok { filter = f } // Construire la requête hasWhere := strings.Contains(strings.ToUpper(query), " WHERE ") if filter != "" { if hasWhere { query += " AND " + filter } else { query += " WHERE " + filter } } if qc.Order != "" { query += " ORDER BY " + qc.Order } // Remplacer les placeholders :name par ? et collecter les args var args []any for { idx := strings.Index(query, ":") if idx == -1 { break } // Trouver la fin du placeholder end := idx + 1 for end < len(query) && (query[end] == '_' || (query[end] >= 'a' && query[end] <= 'z') || (query[end] >= 'A' && query[end] <= 'Z') || (query[end] >= '0' && query[end] <= '9')) { end++ } placeholder := query[idx+1 : end] if val, ok := params[placeholder]; ok { args = append(args, val) query = query[:idx] + "?" + query[end:] } else { // Placeholder non trouvé, on laisse tel quel (peut être une erreur) break } } return query, args } // Auth contient la configuration d'authentification. type Auth struct { JWTSecretFile string `yaml:"jwt_secret_file"` JWTExpiry string `yaml:"jwt_expiry"` jwtSecret string // Chargé depuis le fichier } // JWTSecret retourne le secret JWT (chargé depuis le fichier). func (a *Auth) JWTSecret() string { return a.jwtSecret } // Database contient la configuration de connexion à la base de données. type Database struct { Host string `yaml:"host"` Port int `yaml:"port"` User string `yaml:"user"` PasswordFile string `yaml:"password_file"` Name string `yaml:"name"` password string // Chargé depuis le fichier } // Password retourne le mot de passe (chargé depuis le fichier). func (d *Database) Password() string { return d.password } // DSN retourne la chaîne de connexion MySQL/MariaDB. func (d *Database) DSN() string { return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4", d.User, d.password, d.Host, d.Port, d.Name) } // Route représente une route API. type Route struct { Path string `yaml:"path"` Method string `yaml:"method"` Scenario string `yaml:"scenario"` Auth *bool `yaml:"auth,omitempty"` } // Registry stocke les configurations des applications. type Registry struct { configDir string apps map[string]*AppConfig // Par app_id byHost map[string]*AppConfig // Par hostname mu sync.RWMutex } // NewRegistry crée un nouveau registre de configurations. func NewRegistry(configDir string) *Registry { return &Registry{ configDir: configDir, apps: make(map[string]*AppConfig), byHost: make(map[string]*AppConfig), } } // Load charge toutes les configurations depuis le répertoire apps. func (r *Registry) Load() error { r.mu.Lock() defer r.mu.Unlock() appsDir := filepath.Join(r.configDir, "apps") entries, err := os.ReadDir(appsDir) if err != nil { return fmt.Errorf("read apps dir: %w", err) } for _, entry := range entries { if !entry.IsDir() { continue } appID := entry.Name() appConfigPath := filepath.Join(appsDir, appID, "app.yaml") // Vérifier que app.yaml existe if _, err := os.Stat(appConfigPath); os.IsNotExist(err) { continue } cfg, err := r.loadAppConfig(appConfigPath, appID) if err != nil { return fmt.Errorf("load %s: %w", appID, err) } r.apps[cfg.App] = cfg for _, host := range cfg.Hosts { r.byHost[host] = cfg } } return nil } // loadAppConfig charge une configuration d'application depuis un fichier YAML. func (r *Registry) loadAppConfig(path string, appID string) (*AppConfig, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var cfg AppConfig if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, err } // Charger le mot de passe DB depuis le fichier if cfg.Database.PasswordFile != "" { passData, err := os.ReadFile(cfg.Database.PasswordFile) if err != nil { return nil, fmt.Errorf("read db password file: %w", err) } cfg.Database.password = strings.TrimSpace(string(passData)) } // Charger le secret JWT depuis le fichier if cfg.Auth.JWTSecretFile != "" { secretData, err := os.ReadFile(cfg.Auth.JWTSecretFile) if err != nil { return nil, fmt.Errorf("read jwt secret file: %w", err) } cfg.Auth.jwtSecret = strings.TrimSpace(string(secretData)) } // Port DB par défaut if cfg.Database.Port == 0 { cfg.Database.Port = 3306 } // Expiry JWT par défaut if cfg.Auth.JWTExpiry == "" { cfg.Auth.JWTExpiry = "24h" } // Charger les requêtes depuis config/apps/{app}/queries/ cfg.Queries = r.loadQueries(appID) // Charger le schema depuis config/apps/{app}/schema.yaml cfg.Schema = loadSchema(r.configDir, appID) return &cfg, nil } // loadQueries charge les fichiers de requêtes pour une application. func (r *Registry) loadQueries(appID string) *Queries { queriesDir := filepath.Join(r.configDir, "apps", appID, "queries") entries, err := os.ReadDir(queriesDir) if err != nil { return nil // Pas de répertoire queries, c'est OK } q := &Queries{ files: make(map[string]map[string]any), } for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { continue } domain := strings.TrimSuffix(entry.Name(), ".yaml") path := filepath.Join(queriesDir, entry.Name()) data, err := os.ReadFile(path) if err != nil { continue } var content map[string]any if err := yaml.Unmarshal(data, &content); err != nil { continue } q.files[domain] = content } return q } // GetByApp retourne la configuration d'une application par son ID. func (r *Registry) GetByApp(appID string) (*AppConfig, bool) { r.mu.RLock() defer r.mu.RUnlock() cfg, ok := r.apps[appID] return cfg, ok } // GetByHost retourne la configuration d'une application par son hostname. func (r *Registry) GetByHost(host string) (*AppConfig, bool) { r.mu.RLock() defer r.mu.RUnlock() cfg, ok := r.byHost[host] return cfg, ok } // Apps retourne la liste des IDs d'applications chargées. func (r *Registry) Apps() []string { r.mu.RLock() defer r.mu.RUnlock() apps := make([]string, 0, len(r.apps)) for app := range r.apps { apps = append(apps, app) } return apps }