diff --git a/bootstrap.go b/bootstrap.go index b0eba787..3fd4779b 100644 --- a/bootstrap.go +++ b/bootstrap.go @@ -289,6 +289,12 @@ func LoadConfig(filePath string) error { return lokstra_registry.LoadConfig(filePath) } +// SetupNamedDbPools sets up database pools from loaded config. +// Must be called AFTER LoadConfig() if you use named-db-pools in config. +func SetupNamedDbPools() error { + return lokstra_registry.SetupNamedDbPools() +} + // InitAndRunServer initializes and runs the server based on loaded configuration. func InitAndRunServer() error { return lokstra_registry.InitAndRunServer() diff --git a/core/annotation/complex_processor.go b/core/annotation/complex_processor.go index a97ab3ab..664112d4 100644 --- a/core/annotation/complex_processor.go +++ b/core/annotation/complex_processor.go @@ -90,9 +90,12 @@ func ProcessComplexAnnotations(rootPath []string, maxWorkers int, if _, err := os.Stat(genPath); err == nil { // Get package import path for this folder if pkgPath := getPackageImportPath(folder); pkgPath != "" { - packageMutex.Lock() - packagesWithServices = append(packagesWithServices, pkgPath) - packageMutex.Unlock() + // Skip package main to avoid circular import + if !isMainPackage(folder) { + packageMutex.Lock() + packagesWithServices = append(packagesWithServices, pkgPath) + packageMutex.Unlock() + } } } } @@ -696,6 +699,57 @@ func findMainGoFolder(startPath string) string { } } +// isMainPackage checks if a folder contains package main by reading .go files +func isMainPackage(folderPath string) bool { + // Read all .go files in folder (excluding test and generated files) + files, err := os.ReadDir(folderPath) + if err != nil { + return false + } + + for _, file := range files { + if file.IsDir() { + continue + } + + fileName := file.Name() + if !strings.HasSuffix(fileName, ".go") { + continue + } + + // Skip test files and generated files + if strings.HasSuffix(fileName, "_test.go") || + strings.HasPrefix(fileName, "zz_") { + continue + } + + // Read file and check package declaration + filePath := filepath.Join(folderPath, fileName) + data, err := os.ReadFile(filePath) + if err != nil { + continue + } + + // Parse package declaration (first non-comment line) + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "/*") { + continue + } + + // Found first non-comment line + if strings.HasPrefix(line, "package ") { + pkgName := strings.TrimSpace(strings.TrimPrefix(line, "package")) + return pkgName == "main" + } + break + } + } + + return false +} + // generateImportFile creates zz_lokstra_imports.go in the same folder as main.go func generateImportFile(startPath string, packages []string) error { // Find main.go folder diff --git a/core/deploy/cfg_deps_test.go b/core/deploy/cfg_deps_test.go index b1e10d70..20b4b438 100644 --- a/core/deploy/cfg_deps_test.go +++ b/core/deploy/cfg_deps_test.go @@ -49,17 +49,19 @@ func TestConfigBasedDependencyInjection(t *testing.T) { // Register logger lokstra_registry.RegisterService("logger", "test-logger") - // Register user service with cfg: prefix dependency + // Set GLOBAL config (not service config!) + lokstra_registry.SetConfig("store.implementation", "postgres-store") + + // Register user service with @ prefix for config-based dependency lokstra_registry.RegisterLazyService("user-service", func(deps map[string]any, config map[string]any) any { return &UserService{ - Store: deps["cfg:store.implementation"].(Store), + Store: deps["cfg"].(Store), Logger: deps["logger"].(string), } }, map[string]any{ - "depends-on": []string{"cfg:store.implementation", "logger"}, - "store.implementation": "postgres-store", // Config specifies postgres + "depends-on": []string{"cfg:@store.implementation", "logger"}, }, ) @@ -96,17 +98,19 @@ func TestConfigBasedDependencyInjection_SwitchImplementation(t *testing.T) { // Register logger lokstra_registry.RegisterService("logger-2", "logger2") + // Set GLOBAL config to use MySQL + lokstra_registry.SetConfig("store.implementation", "mysql-store-2") + // Register user service with MySQL this time lokstra_registry.RegisterLazyService("user-service-2", func(deps map[string]any, config map[string]any) any { return &UserService{ - Store: deps["cfg:store.implementation"].(Store), + Store: deps["cfg"].(Store), Logger: deps["logger-2"].(string), } }, map[string]any{ - "depends-on": []string{"cfg:store.implementation", "logger-2"}, - "store.implementation": "mysql-store-2", // Switch to MySQL! + "depends-on": []string{"cfg:@store.implementation", "logger-2"}, }, ) @@ -132,30 +136,24 @@ func TestConfigBasedDependency_MissingConfig(t *testing.T) { return &PostgresStore{name: "test"} }, nil) - // Register service WITHOUT config for cfg: dependency + // DO NOT set global config for "missing.config" + + // Register service with @ dependency that won't be found lokstra_registry.RegisterLazyService("bad-service", func(deps map[string]any, config map[string]any) any { return &UserService{ - Store: deps["cfg:missing.config"].(Store), + Store: deps["cfg"].(Store), } }, map[string]any{ - "depends-on": []string{"cfg:missing.config"}, - // Missing: "missing.config" key! + "depends-on": []string{"cfg:@missing.config"}, }, ) - // Should panic when trying to resolve + // Should panic when trying to resolve - config key not found defer func() { if r := recover(); r == nil { t.Error("expected panic when config key is missing") - } else { - // Check error message mentions config requirement - if msg, ok := r.(string); ok { - if !contains(msg, "config-based dependency") { - t.Errorf("expected error about config-based dependency, got: %v", r) - } - } } }() @@ -166,16 +164,18 @@ func TestConfigBasedDependency_EmptyConfig(t *testing.T) { // Clear registry _ = deploy.Global() - // Register service with EMPTY config value + // Set global config with EMPTY value + lokstra_registry.SetConfig("empty.config", "") + + // Register service with @ dependency pointing to empty config lokstra_registry.RegisterLazyService("bad-service-2", func(deps map[string]any, config map[string]any) any { return &UserService{ - Store: deps["cfg:empty.config"].(Store), + Store: deps["cfg"].(Store), } }, map[string]any{ - "depends-on": []string{"cfg:empty.config"}, - "empty.config": "", // Empty string! + "depends-on": []string{"cfg:@empty.config"}, }, ) @@ -188,16 +188,3 @@ func TestConfigBasedDependency_EmptyConfig(t *testing.T) { lokstra_registry.MustGetService[*UserService]("bad-service-2") } - -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr))) -} - -func containsMiddle(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/core/deploy/loader/builder.go b/core/deploy/loader/builder.go index 0737f018..01b31cd7 100644 --- a/core/deploy/loader/builder.go +++ b/core/deploy/loader/builder.go @@ -48,6 +48,26 @@ func getRouterDef(defs map[string]*schema.RouterDef, name string) (*schema.Route return rtr, ok } +func getDeploymentDef(config *schema.DeployConfig, name string) (*schema.DeploymentDefMap, bool) { + // Try lowercase first + if dep, ok := config.Deployments[strings.ToLower(name)]; ok { + return dep, true + } + // Fallback to original case + dep, ok := config.Deployments[name] + return dep, ok +} + +func getServerDef(deployment *schema.DeploymentDefMap, name string) (*schema.ServerDefMap, bool) { + // Try lowercase first + if srv, ok := deployment.Servers[strings.ToLower(name)]; ok { + return srv, true + } + // Fallback to original case + srv, ok := deployment.Servers[name] + return srv, ok +} + // flattenAndStoreConfigs flattens configs and stores them to registry.resolvedConfigs // This populates the config registry that GetConfig() reads from func flattenAndStoreConfigs(registry *deploy.GlobalRegistry, configs map[string]any, prefix string) { @@ -70,6 +90,55 @@ func flattenAndStoreConfigs(registry *deploy.GlobalRegistry, configs map[string] } } +// normalizeShorthandServers converts top-level 'servers' field to 'default' deployment +// This provides a shorthand syntax for single-deployment configs: +// +// servers: +// api: +// base-url: http://localhost +// addr: ":8080" +// routers: [email-router] +// +// Becomes: +// +// deployments: +// default: +// servers: +// api: +// base-url: http://localhost +// addr: ":8080" +// routers: [email-router] +// +// This makes current server "api" equivalent to "default.api" +func normalizeShorthandServers(config *schema.DeployConfig) { + // Skip if no top-level servers defined + if len(config.Servers) == 0 { + return + } + + // Initialize Deployments if needed + if config.Deployments == nil { + config.Deployments = make(map[string]*schema.DeploymentDefMap) + } + + // Check if 'default' deployment already exists + if _, exists := config.Deployments["default"]; exists { + // Merge servers into existing 'default' deployment + for serverName, serverDef := range config.Servers { + config.Deployments["default"].Servers[serverName] = serverDef + } + } else { + // Create new 'default' deployment with the servers + config.Deployments["default"] = &schema.DeploymentDefMap{ + ConfigOverrides: make(map[string]any), + Servers: config.Servers, + } + } + + // Clear top-level servers (moved to deployment) + config.Servers = nil +} + // normalizeServerDefinitions converts server-level helper fields to a new app // This allows shorthand syntax: addr/routers/published-services at server level // for the common case of 1 server = 1 app @@ -169,26 +238,16 @@ func NormalizeInlineDefinitionsForServer( config *schema.DeployConfig, deploymentName, serverName string, ) error { - // Case-insensitive lookup: try lowercase version first - lowerDeploymentName := strings.ToLower(deploymentName) - depDef, ok := config.Deployments[lowerDeploymentName] + // Case-insensitive deployment lookup + depDef, ok := getDeploymentDef(config, deploymentName) if !ok { - // Fallback to original case for backward compatibility - depDef, ok = config.Deployments[deploymentName] - if !ok { - return fmt.Errorf("deployment %s not found", deploymentName) - } + return fmt.Errorf("deployment %s not found", deploymentName) } // Case-insensitive server lookup - lowerServerName := strings.ToLower(serverName) - serverDef, ok := depDef.Servers[lowerServerName] + serverDef, ok := getServerDef(depDef, serverName) if !ok { - // Fallback to original case for backward compatibility - serverDef, ok = depDef.Servers[serverName] - if !ok { - return fmt.Errorf("server %s not found in deployment %s", serverName, deploymentName) - } + return fmt.Errorf("server %s not found in deployment %s", serverName, deploymentName) } // Initialize global maps if nil @@ -407,35 +466,25 @@ func StoreDefinitionsToRegistry(registry *deploy.GlobalRegistry, config *schema. // Middlewares will be registered in RegisterDefinitionsForRuntime // For now, we don't need to store them - they're in config.MiddlewareDefinitions - // Store service definitions to registry as deferred (no runtime registration yet) - // This allows GetDeferredServiceDef to work during RegisterDefinitionsForRuntime + // Store service definitions as unresolved lazy service entries + // Factory will be resolved later in RegisterDefinitionsForRuntime for name, svc := range config.ServiceDefinitions { svc.Name = name - // Convert ServiceDef to config map for registerDeferredService - svcConfig := svc.Config - if svcConfig == nil { - svcConfig = make(map[string]any) - } - // Add depends-on to config (required by registerDeferredService) - if len(svc.DependsOn) > 0 { - svcConfig["depends-on"] = svc.DependsOn + // Create unresolved LazyServiceEntry (FactoryType not yet resolved to actual function) + // This allows services to be registered before service type factories are available + deps := make(map[string]string) + for _, depStr := range svc.DependsOn { + // Parse "paramName:serviceName" or just "serviceName" + parts := strings.SplitN(depStr, ":", 2) + if len(parts) == 2 { + deps[parts[0]] = parts[1] + } else { + deps[depStr] = depStr + } } - // Call internal method via reflection or expose public method - // For now, we'll use a workaround: store directly via RegisterLazyService with nil factory - // Actually, let's use the fact that registry has serviceDefs field - // But that's private... so we need to expose a public method - // - // WORKAROUND: Call RegisterLazyService with mode=Skip so it doesn't error on duplicates - // But we DON'T want to register yet, just store definition - // - // BETTER: Don't store now - GetDeferredServiceDef will be called in RegisterDefinitionsForRuntime - // But the problem is GetDeferredServiceDef reads from serviceDefs which is populated by registerDeferredService - // And registerDeferredService is private! - // - // SOLUTION: We need to expose a public method in registry to store deferred service definitions - // OR: We can skip storing here and directly use config.ServiceDefinitions in RegisterDefinitionsForRuntime + registry.RegisterLazyServiceUnresolved(name, svc.Type, deps, svc.Config) } // Store router definitions to registry (deferred) @@ -491,26 +540,16 @@ func collectAllServiceDependencies(config *schema.DeployConfig, publishedService // This is called in RunCurrentServer AFTER normalization // It registers middlewares, services (with remote/local logic), and auto-generates routers for published services func RegisterDefinitionsForRuntime(registry *deploy.GlobalRegistry, config *schema.DeployConfig, deploymentName, serverName string, serverTopo *deploy.ServerTopology) error { - // Case-insensitive lookup: try lowercase version first - lowerDeploymentName := strings.ToLower(deploymentName) - depDef, ok := config.Deployments[lowerDeploymentName] + // Case-insensitive deployment lookup + depDef, ok := getDeploymentDef(config, deploymentName) if !ok { - // Fallback to original case for backward compatibility - depDef, ok = config.Deployments[deploymentName] - if !ok { - return fmt.Errorf("deployment %s not found", deploymentName) - } + return fmt.Errorf("deployment %s not found", deploymentName) } // Case-insensitive server lookup - lowerServerName := strings.ToLower(serverName) - serverDef, ok := depDef.Servers[lowerServerName] + serverDef, ok := getServerDef(depDef, serverName) if !ok { - // Fallback to original case for backward compatibility - serverDef, ok = depDef.Servers[serverName] - if !ok { - return fmt.Errorf("server %s not found in deployment %s", serverName, deploymentName) - } + return fmt.Errorf("server %s not found in deployment %s", serverName, deploymentName) } // Register middlewares @@ -527,7 +566,7 @@ func RegisterDefinitionsForRuntime(registry *deploy.GlobalRegistry, config *sche // Collect all services needed for this server (published services + their dependencies) servicesToRegister := collectAllServiceDependencies(config, serverTopo.Services) - // Register service definitions with remote/local logic + // Register/Resolve service definitions with remote/local logic // Iterate through all services (published + dependencies) for _, serviceName := range servicesToRegister { svc, exists := getServiceDef(config.ServiceDefinitions, serviceName) @@ -544,33 +583,48 @@ func RegisterDefinitionsForRuntime(registry *deploy.GlobalRegistry, config *sche // Register REMOTE service registry.AutoRegisterRemoteService(serviceName, svc, remoteURL) } else { - // Register LOCAL service with dependency resolution - // Convert DependsOn to deps map - deps := make(map[string]string) - for _, depStr := range svc.DependsOn { - // Parse "paramName:serviceName" or just "serviceName" - // Note: serviceName can be "@config.key" for config-based resolution - parts := strings.SplitN(depStr, ":", 2) - if len(parts) == 2 { - deps[parts[0]] = parts[1] - } else { - deps[depStr] = depStr + // Check if already stored as unresolved entry from StoreDefinitionsToRegistry + if existingEntry := registry.GetLazyServiceEntry(serviceName); existingEntry != nil && !existingEntry.IsResolved() { + // Resolve existing unresolved entry + serviceType := svc.Type + factory := registry.GetServiceFactory(serviceType, true) // true = local factory + if factory == nil { + return fmt.Errorf("service factory %s (local) not registered for service %s", serviceType, serviceName) } - } - // Get service type factory (LOCAL) - serviceType := svc.Type - factory := registry.GetServiceFactory(serviceType, true) // true = local factory - if factory == nil { - return fmt.Errorf("service factory %s (local) not registered for service %s", serviceType, serviceName) - } + // Resolve the entry by setting the factory + existingEntry.ResolveFactory(func(resolvedDeps, cfg map[string]any) any { + return factory(resolvedDeps, cfg) + }) + } else { + // Not in unresolved registry - register as new LOCAL service with dependency resolution + // Convert DependsOn to deps map + deps := make(map[string]string) + for _, depStr := range svc.DependsOn { + // Parse "paramName:serviceName" or just "serviceName" + // Note: serviceName can be "@config.key" for config-based resolution + parts := strings.SplitN(depStr, ":", 2) + if len(parts) == 2 { + deps[parts[0]] = parts[1] + } else { + deps[depStr] = depStr + } + } + + // Get service type factory (LOCAL) + serviceType := svc.Type + factory := registry.GetServiceFactory(serviceType, true) // true = local factory + if factory == nil { + return fmt.Errorf("service factory %s (local) not registered for service %s", serviceType, serviceName) + } - // Register as lazy service with wrapper factory - // Use Skip mode to allow idempotent calls - registry.RegisterLazyServiceWithDeps(serviceName, func(resolvedDeps, cfg map[string]any) any { - // Call original factory with resolved dependencies (eager injection) - return factory(resolvedDeps, cfg) - }, deps, svc.Config, deploy.WithRegistrationMode(deploy.LazyServiceSkip)) + // Register as lazy service with wrapper factory + // Use Skip mode to allow idempotent calls + registry.RegisterLazyServiceWithDeps(serviceName, func(resolvedDeps, cfg map[string]any) any { + // Call original factory with resolved dependencies (eager injection) + return factory(resolvedDeps, cfg) + }, deps, svc.Config, deploy.WithRegistrationMode(deploy.LazyServiceSkip)) + } } } @@ -582,6 +636,17 @@ func RegisterDefinitionsForRuntime(registry *deploy.GlobalRegistry, config *sche } } + // IMPORTANT: Force instantiate all published services BEFORE creating routers + // This ensures all service dependencies are resolved before router creation + for serviceName := range publishedServicesMap { + _, ok := registry.GetServiceAny(serviceName) + if !ok { + log.Printf("⚠️ Warning: Published service '%s' failed to instantiate (dependencies may be missing)", serviceName) + } else { + log.Printf("✅ Instantiated published service: %s", serviceName) + } + } + // Auto-generate router definitions for published services // Also update Apps.Routers to use normalized router names // Priority: service.router > router-definitions > metadata > auto-generate @@ -706,17 +771,65 @@ func RegisterDefinitionsForRuntime(registry *deploy.GlobalRegistry, config *sche continue } - // Get service instance - serviceInstance, ok := registry.GetServiceAny(serviceName) + // Force instantiate service by calling GetService with type assertion + // This will trigger dependency resolution and instantiation + // We use a generic approach since we don't know the service type at compile time + var serviceInstance any + var ok bool + + // Try to get service instance (this should trigger instantiation if lazy) + serviceInstance, ok = registry.GetServiceAny(serviceName) if !ok || serviceInstance == nil { - // Service not yet instantiated - this shouldn't happen but handle gracefully - log.Printf("⚠️ Warning: Service '%s' not instantiated, skipping router creation", serviceName) + // Service failed to instantiate - log detailed error + log.Printf("⚠️ Warning: Service '%s' failed to instantiate (dependencies may not be ready), creating lazy router factory instead", serviceName) + + // Create a lazy router factory that will try again when GetRouter is called + registry.RegisterRouterFactory(routerName, func() router.Router { + // Try to get service instance again + svcInst, ok := registry.GetServiceAny(serviceName) + if !ok || svcInst == nil { + panic(fmt.Sprintf("Service '%s' still not instantiated when router '%s' requested", serviceName, routerName)) + } + + // Build router from service + routerDef := registry.GetRouterDef(routerName) + finalPrefix := metadata.PathPrefix + if routerDef != nil && routerDef.PathPrefix != "" { + finalPrefix = routerDef.PathPrefix + } + + opts := &router.ServiceRouterOptions{ + Prefix: finalPrefix, + Middlewares: metadata.MiddlewareNames, + RouteOverrides: make(map[string]router.RouteMeta), + } + + for methodName, routeMeta := range metadata.RouteOverrides { + opts.RouteOverrides[methodName] = router.RouteMeta{ + HTTPMethod: routeMeta.Method, + Path: routeMeta.Path, + } + } + + return router.NewFromService(svcInst, opts) + }) + + log.Printf("🔧 Registered lazy router factory for '%s' (will instantiate service on-demand)", routerName) continue } - // Build ServiceRouterOptions from metadata + // Get RouterDef (may have PathPrefix from router-definitions YAML) + routerDef := registry.GetRouterDef(routerName) + + // Determine final PathPrefix (priority: RouterDef > Metadata) + finalPrefix := metadata.PathPrefix + if routerDef != nil && routerDef.PathPrefix != "" { + finalPrefix = routerDef.PathPrefix + } + + // Build ServiceRouterOptions from metadata + RouterDef opts := &router.ServiceRouterOptions{ - Prefix: metadata.PathPrefix, + Prefix: finalPrefix, // Use final prefix (YAML overrides annotation) Middlewares: metadata.MiddlewareNames, RouteOverrides: make(map[string]router.RouteMeta), } @@ -734,7 +847,7 @@ func RegisterDefinitionsForRuntime(registry *deploy.GlobalRegistry, config *sche // Register router instance registry.RegisterRouter(routerName, r) - log.Printf("🔧 Auto-created router '%s' from service '%s' (type: %s)", routerName, serviceName, serviceDef.Type) + log.Printf("🔧 Auto-created router '%s' from service '%s' (type: %s, prefix: %s)", routerName, serviceName, serviceDef.Type, finalPrefix) } return nil @@ -753,8 +866,8 @@ func LoadAndBuild(configPaths []string) error { // Store original config for inline definitions normalization registry.StoreDeployConfig(config) - // Normalize server definitions (convert helper fields to apps) - normalizeServerDefinitions(config) + // NOTE: normalizeServerDefinitions already called in LoadConfig STEP 9 + // No need to call again here // Store definitions to registry (NO runtime registration, just store data) // Runtime registration will happen in RunCurrentServer @@ -794,12 +907,13 @@ func LoadAndBuild(configPaths []string) error { // Build server topologies for serverName, serverDef := range depDef.Servers { serverTopo := &deploy.ServerTopology{ - Name: serverName, - DeploymentName: deploymentName, - BaseURL: serverDef.BaseURL, - Services: make([]string, 0), - RemoteServices: make(map[string]string), - Apps: make([]*deploy.AppTopology, 0, len(serverDef.Apps)), + Name: serverName, + DeploymentName: deploymentName, + BaseURL: serverDef.BaseURL, + ConfigOverrides: serverDef.ConfigOverrides, + Services: make([]string, 0), + RemoteServices: make(map[string]string), + Apps: make([]*deploy.AppTopology, 0, len(serverDef.Apps)), } // Collect SERVER-LEVEL services (published services only) @@ -854,16 +968,19 @@ func LoadAndBuild(configPaths []string) error { } // Auto-discover and setup named DB pools - if err := setupNamedDbPools(registry, config); err != nil { - return fmt.Errorf("failed to setup named DB pools: %w", err) - } + // if err := SetupNamedDbPools(registry, config); err != nil { + // return fmt.Errorf("failed to setup named DB pools: %w", err) + // } return nil } -// setupNamedDbPools auto-discovers and sets up named DB pools from config +// SetupNamedDbPools auto-discovers and sets up named DB pools from config // Requires dbpool-manager service to be already registered -func setupNamedDbPools(registry *deploy.GlobalRegistry, config *schema.DeployConfig) error { +func SetupNamedDbPools() error { + registry := deploy.Global() + config := registry.GetDeployConfig() + // Check if named-db-pools section exists if len(config.NamedDbPools) == 0 { internal.AutoCreateDbPoolManager() diff --git a/core/deploy/loader/loader.go b/core/deploy/loader/loader.go index 420fab73..4e0126ad 100644 --- a/core/deploy/loader/loader.go +++ b/core/deploy/loader/loader.go @@ -25,14 +25,15 @@ func LoadConfig(paths ...string) (*schema.DeployConfig, error) { var merged *schema.DeployConfig basePath := utils.GetBasePath() - // Load and merge each file + + // STEP 1: Load and merge all files (RAW, no resolution yet) for _, path := range paths { // If path is already absolute, use it directly; otherwise join with basePath normPath := path if !filepath.IsAbs(path) { normPath = filepath.Join(basePath, path) } - config, err := loadSingleFile(normPath) + config, err := loadSingleFileRaw(normPath) if err != nil { return nil, fmt.Errorf("failed to load %s: %w", path, err) } @@ -44,29 +45,29 @@ func LoadConfig(paths ...string) (*schema.DeployConfig, error) { } } - // Normalize server definitions (convert helper fields to apps) BEFORE validation - normalizeServerDefinitions(merged) + // STEP 2: Normalize shorthand servers (must be before getting server key) + normalizeShorthandServers(merged) - // Validate merged config - if err := ValidateConfig(merged); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) + // STEP 3: Resolve configs.server to know which deployment/server to use + if serverKeyRaw, ok := merged.Configs["server"]; ok { + if serverKeyStr, ok := serverKeyRaw.(string); ok { + resolved := resolver.ResolveSingleValue(serverKeyStr) + merged.Configs["server"] = resolved + } } - return merged, nil -} + // STEP 4: Apply config overrides (deployment → server) + applyConfigOverrides(merged) -// loadSingleFile loads and parses a single YAML file -func loadSingleFile(path string) (*schema.DeployConfig, error) { - data, err := os.ReadFile(path) + // STEP 5: Marshal back to YAML for 2-phase resolution + dataWithOverrides, err := yaml.Marshal(merged) if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) + return nil, fmt.Errorf("failed to marshal merged config: %w", err) } - // STEP 1: Resolve all ${...} EXCEPT ${@cfg:...} at YAML byte level - // This resolves: ${ENV_VAR}, ${@env:VAR}, ${@aws-secret:key}, etc. - step1Data := resolver.ResolveYAMLBytesStep1(data) + // STEP 6: Resolve all ${...} EXCEPT ${@cfg:...} + step1Data := resolver.ResolveYAMLBytesStep1(dataWithOverrides) - // Decode to get configs first (needed for step 2) var tempConfig schema.DeployConfig decoder := yaml.NewDecoder(bytes.NewReader(step1Data)) decoder.KnownFields(true) @@ -74,8 +75,7 @@ func loadSingleFile(path string) (*schema.DeployConfig, error) { return nil, fmt.Errorf("failed to parse YAML (step 1): %w", err) } - // STEP 2: Resolve ${@cfg:...} using configs from step 1 - // Re-marshal to YAML, resolve @cfg, then unmarshal again + // STEP 7: Resolve ${@cfg:...} using configs from step 1 step1Bytes, err := yaml.Marshal(&tempConfig) if err != nil { return nil, fmt.Errorf("failed to marshal config for step 2: %w", err) @@ -83,17 +83,91 @@ func loadSingleFile(path string) (*schema.DeployConfig, error) { step2Data := resolver.ResolveYAMLBytesStep2(step1Bytes, tempConfig.Configs) - // Final decode with all values resolved - var config schema.DeployConfig + // STEP 8: Final decode with all values resolved + var finalConfig schema.DeployConfig decoder2 := yaml.NewDecoder(bytes.NewReader(step2Data)) decoder2.KnownFields(true) - if err := decoder2.Decode(&config); err != nil { + if err := decoder2.Decode(&finalConfig); err != nil { return nil, fmt.Errorf("failed to parse YAML (step 2): %w", err) } + // STEP 9: Normalize server definitions (convert helper fields to apps) + normalizeServerDefinitions(&finalConfig) + + // STEP 10: Validate final config + if err := ValidateConfig(&finalConfig); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + return &finalConfig, nil +} + +// loadSingleFileRaw loads a single YAML file WITHOUT any resolution +// Just parse the raw YAML structure +func loadSingleFileRaw(path string) (*schema.DeployConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + var config schema.DeployConfig + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&config); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + return &config, nil } +// applyConfigOverrides applies deployment and server config overrides to configs +func applyConfigOverrides(config *schema.DeployConfig) { + if config.Configs == nil { + return + } + + // Get target server from configs.server + serverKey, ok := config.Configs["server"].(string) + if !ok || serverKey == "" { + return + } + + // Parse deployment.server + var deploymentName, serverName string + parts := strings.Split(serverKey, ".") + if len(parts) == 2 { + deploymentName = strings.ToLower(parts[0]) + serverName = strings.ToLower(parts[1]) + } else if len(parts) == 1 { + deploymentName = "default" + serverName = strings.ToLower(parts[0]) + } else { + return + } + + // Find deployment + depDef, ok := config.Deployments[deploymentName] + if !ok { + return + } + + // Apply deployment-level overrides + for key, value := range depDef.ConfigOverrides { + config.Configs[key] = value + } + + // Find server + serverDef, ok := depDef.Servers[serverName] + if !ok { + return + } + + // Apply server-level overrides (highest priority) + for key, value := range serverDef.ConfigOverrides { + config.Configs[key] = value + } +} + // mergeConfigs merges two configurations (target <- source) // Source values override target values func mergeConfigs(target, source *schema.DeployConfig) *schema.DeployConfig { diff --git a/core/deploy/loader/resolver/provider_resolver.go b/core/deploy/loader/resolver/provider_resolver.go index 7bdf7ffb..490d0650 100644 --- a/core/deploy/loader/resolver/provider_resolver.go +++ b/core/deploy/loader/resolver/provider_resolver.go @@ -5,6 +5,51 @@ import ( "strings" ) +// ResolveSingleValue resolves a single value (not YAML content) +// Used for resolving configs.server before applying overrides +// Only resolves ${ENV:...} and ${@provider:...}, NOT ${@cfg:...} +func ResolveSingleValue(value string) string { + if !strings.Contains(value, "${") { + return value + } + + // Find and replace all ${...} placeholders (except ${@cfg:...}) + result := value + pos := 0 + for { + start := strings.Index(result[pos:], "${") + if start == -1 { + break + } + start += pos + + end := strings.Index(result[start:], "}") + if end == -1 { + break + } + end += start + + placeholder := result[start+2 : end] + + // Skip @cfg placeholders + if strings.HasPrefix(placeholder, "@cfg:") { + pos = end + 1 + continue + } + + // Resolve using provider registry + resolved := resolvePlaceholder(placeholder) + + // Replace placeholder with resolved value + result = result[:start] + resolved + result[end+1:] + + // Update position to after the resolved value + pos = start + len(resolved) + } + + return result +} + // resolveYAMLBytesStep1 resolves all ${...} placeholders EXCEPT ${@cfg:...} // This is STEP 1 of 2-step resolution process // Resolves: ${ENV_VAR}, ${@env:VAR}, ${@aws-secret:key}, ${@vault:path}, etc. @@ -13,11 +58,14 @@ func ResolveYAMLBytesStep1(data []byte) []byte { content := string(data) // Find and replace all ${...} placeholders (except ${@cfg:...}) + // Track position to avoid re-processing + pos := 0 for { - start := strings.Index(content, "${") + start := strings.Index(content[pos:], "${") if start == -1 { break } + start += pos end := strings.Index(content[start:], "}") if end == -1 { @@ -30,12 +78,8 @@ func ResolveYAMLBytesStep1(data []byte) []byte { // Skip @cfg placeholders (will be resolved in step 2) if strings.HasPrefix(placeholder, "@cfg:") { - // Continue searching after this placeholder - nextStart := strings.Index(content[end+1:], "${") - if nextStart == -1 { - break - } - content = content[:end+1] + content[end+1:] + // Move position past this placeholder and continue + pos = end + 1 continue } @@ -44,6 +88,9 @@ func ResolveYAMLBytesStep1(data []byte) []byte { // Replace placeholder with resolved value content = content[:start] + resolved + content[end+1:] + + // Update position to after the resolved value + pos = start + len(resolved) } return []byte(content) @@ -54,7 +101,7 @@ func ResolveYAMLBytesStep1(data []byte) []byte { // // Format: ${@cfg:KEY} or ${@cfg:KEY:default} // -// IMPORTANT: Config keys CANNOT contain ':' character (use '.' for nesting) +// Supports nested keys using dot notation: email_smtp.host // // Examples: // @@ -91,17 +138,18 @@ func ResolveYAMLBytesStep2(data []byte, configs map[string]any) []byte { configKey = key } - // Lookup in configs (case-insensitive) + // Lookup in configs - support nested keys with dot notation var resolved string - if val, ok := configs[strings.ToLower(configKey)]; ok { - resolved = fmt.Sprintf("%v", val) - } else if val, ok := configs[configKey]; ok { + val := getNestedConfig(configs, configKey) + if val != nil { resolved = fmt.Sprintf("%v", val) } else if defaultValue != "" { + // User provided explicit default value resolved = defaultValue } else { - // Not found - keep original for debugging - resolved = "${@cfg:" + key + "}" + // Key not found and no explicit default - use empty string + // This is safer than panic, allows app to continue + resolved = "" } // Replace placeholder with resolved value @@ -111,6 +159,74 @@ func ResolveYAMLBytesStep2(data []byte, configs map[string]any) []byte { return []byte(content) } +// getNestedConfig retrieves a value from nested config map using dot notation +// Example: "email_smtp.host" -> configs["email_smtp"]["host"] +func getNestedConfig(configs map[string]any, key string) any { + parts := strings.Split(key, ".") + + var current any = configs + for i, part := range parts { + // Try case-insensitive lookup at current level + m, ok := current.(map[string]any) + if !ok { + return nil + } + + // Try exact match first + val, found := m[part] + if !found { + // Try lowercase + val, found = m[strings.ToLower(part)] + if !found { + // Debug: show what keys are available + if i == 0 { + fmt.Printf(" Available keys at root: %v\n", getMapKeys(m)) + } + return nil + } + } + + current = val + } + + return current +} + +// getMapKeys returns all keys from a map (for debugging) +func getMapKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +// getConfigKeysDebug recursively lists all available config keys for debugging +// func getConfigKeysDebug(configs map[string]any, prefix string) []string { +// var keys []string +// for k, v := range configs { +// fullKey := k +// if prefix != "" { +// fullKey = prefix + "." + k +// } +// keys = append(keys, fullKey) + +// // If value is a map, recurse +// if nested, ok := v.(map[string]any); ok { +// nestedKeys := getConfigKeysDebug(nested, fullKey) +// keys = append(keys, nestedKeys...) +// } +// } +// return keys +// } + +// func min(a, b int) int { +// if a < b { +// return a +// } +// return b +// } + // resolvePlaceholder resolves a placeholder using provider registry // Formats supported: // - VAR_NAME -> @env provider (default) diff --git a/core/deploy/registry.go b/core/deploy/registry.go index 742cb62b..3ddb6de6 100644 --- a/core/deploy/registry.go +++ b/core/deploy/registry.go @@ -12,7 +12,6 @@ import ( "github.com/primadi/lokstra/core/proxy" "github.com/primadi/lokstra/core/request" "github.com/primadi/lokstra/core/router" - "github.com/primadi/lokstra/core/service" "github.com/primadi/lokstra/internal/registry" ) @@ -37,14 +36,14 @@ type GlobalRegistry struct { lazyServiceFactories sync.Map // map[string]*LazyServiceEntry lazyServiceOnce sync.Map // map[string]*sync.Once - // Deferred service definitions (string factory type - instantiated on access) - serviceDefs sync.Map // map[string]*deferredServiceDef + // Lazy router factories (for deferred router creation) + lazyRouterFactories sync.Map // map[string]func() router.Router // Definitions (YAML or code-defined) routers map[string]*schema.RouterDef // Note: routerOverrides removed - overrides are now inline in RouterDef // Note: middlewares map removed - use middlewareEntries sync.Map (unified API) - // Note: services map removed - use serviceDefs sync.Map (unified API - Opsi 2) + // Note: serviceDefs removed - unified with lazyServiceFactories (2-phase resolution) // Note: configs map removed - use resolvedConfigs only (simplified) // Config values (runtime and YAML-loaded configs) @@ -66,13 +65,6 @@ type GlobalRegistry struct { // deferredServiceDef holds service definition for deferred instantiation // Used when RegisterLazyService is called with string factory type // Instantiation is deferred until first access, allowing auto-detect LOCAL/REMOTE -type deferredServiceDef struct { - Name string - FactoryType string - DependsOn []string - Config map[string]any -} - // ServiceFactoryEntry holds local and remote factory functions plus metadata type ServiceFactoryEntry struct { Local ServiceFactory @@ -81,10 +73,28 @@ type ServiceFactoryEntry struct { } // LazyServiceEntry holds a lazy service factory and its config +// Supports 2-phase resolution: unresolved (FactoryType) → resolved (Factory) type LazyServiceEntry struct { + // Phase 1: Set by LoadConfig (unresolved) + FactoryType string // Service factory type (e.g., "email_smtp") + + // Phase 2: Set by RegisterDefinitionsForRuntime (resolved) Factory func(deps, config map[string]any) any - Config map[string]any - Deps map[string]string // Dependency mapping: key in factory -> service name in registry + + Config map[string]any + Deps map[string]string // Dependency mapping: key in factory -> service name in registry + resolved bool // Has factory been resolved from FactoryType? +} + +// IsResolved returns true if the factory has been resolved from FactoryType +func (e *LazyServiceEntry) IsResolved() bool { + return e.resolved +} + +// ResolveFactory sets the factory function and marks the entry as resolved +func (e *LazyServiceEntry) ResolveFactory(factory func(deps, config map[string]any) any) { + e.Factory = factory + e.resolved = true } // ServiceMetadata holds metadata for service auto-generation @@ -125,12 +135,13 @@ type DeploymentTopology struct { // ServerTopology holds server-level topology // Services and RemoteServices are at SERVER level (shared across all apps) type ServerTopology struct { - Name string - DeploymentName string - BaseURL string - Services []string // Service names (server-level, shared) - RemoteServices map[string]string // serviceName -> remoteBaseURL (empty string if local) - Apps []*AppTopology + Name string + DeploymentName string + BaseURL string + ConfigOverrides map[string]any // Server-level config overrides (highest priority) + Services []string // Service names (server-level, shared) + RemoteServices map[string]string // serviceName -> remoteBaseURL (empty string if local) + Apps []*AppTopology } // AppTopology holds app-level topology @@ -173,7 +184,7 @@ func NewGlobalRegistry() *GlobalRegistry { middlewareFactories: make(map[string]MiddlewareFactory), routers: make(map[string]*schema.RouterDef), resolvedConfigs: make(map[string]any), - // Topology maps, serviceDefs, and middlewareEntries use sync.Map, no initialization needed + // Topology maps and middlewareEntries use sync.Map, no initialization needed } } @@ -615,18 +626,182 @@ func (g *GlobalRegistry) SimpleResolver(input string) string { // ===== RUNTIME INSTANCE REGISTRATION ===== // RegisterRouter registers a router instance +// If a RouterDef with the same name exists and has a PathPrefix, it will be applied func (g *GlobalRegistry) RegisterRouter(name string, r router.Router) { if _, exists := g.routerInstances.Load(name); exists { panic(fmt.Sprintf("router %s already registered", name)) } + + // Check if RouterDef exists with PathPrefix + if routerDef := g.GetRouterDef(name); routerDef != nil { + if routerDef.PathPrefix != "" { + // Apply PathPrefix from RouterDef (YAML router-definitions) + LogDebug("🔧 Applying PathPrefix '%s' to router '%s' from router-definitions", routerDef.PathPrefix, name) + r = r.SetPathPrefix(routerDef.PathPrefix) + } + + // Apply PathRewrites if defined + if len(routerDef.PathRewrites) > 0 { + rewrites := make(map[string]string) + for _, rewrite := range routerDef.PathRewrites { + rewrites[rewrite.Pattern] = rewrite.Replacement + } + r = r.SetPathRewrites(rewrites) + } + } + + LogDebug("🔧 RegisterRouter: storing router '%s' at %p (type=%T)", name, r, r) g.routerInstances.Store(name, r) } +// RegisterRouterFactory registers a lazy router factory that will be instantiated +// when the runtime is ready (after all services are resolved). +// This allows router registration to depend on services that need runtime resolution. +// +// Example: +// +// lokstra_registry.RegisterRouterFactory("email-router", func() lokstra.Router { +// emailService := lokstra_registry.GetService[EmailService]("email-api-service") +// return emailService.GetRouter() +// }) +func (g *GlobalRegistry) RegisterRouterFactory(name string, factory func() router.Router) { + LogDebug("🔧 RegisterRouterFactory: registering lazy router '%s'", name) + g.lazyRouterFactories.Store(name, factory) +} + +// instantiateLazyRouters creates router instances from registered factories +// func (g *GlobalRegistry) InstantiateLazyRouters() { +// LogDebug("🔧 InstantiateLazyRouters: starting lazy router instantiation") +// count := 0 +// g.lazyRouterFactories.Range(func(nameAny, factoryAny any) bool { +// name := nameAny.(string) +// factory := factoryAny.(func() router.Router) + +// // Skip if already instantiated +// if _, exists := g.routerInstances.Load(name); exists { +// LogDebug("🔧 Lazy router '%s': already instantiated, skipping", name) +// return true +// } + +// LogDebug("🔧 Instantiating lazy router: '%s'", name) +// r := factory() +// LogDebug("🔧 Lazy router '%s': factory returned %T, registering", name, r) +// g.RegisterRouter(name, r) +// count++ +// return true +// }) +// LogDebug("🔧 InstantiateLazyRouters: completed, instantiated %d routers", count) +// } + // GetRouter retrieves a router instance by name +// If not found in routerInstances, checks lazyRouterFactories and instantiates if needed func (g *GlobalRegistry) GetRouter(name string) router.Router { + // Check if already instantiated if v, ok := g.routerInstances.Load(name); ok { - return v.(router.Router) + r := v.(router.Router) + LogDebug("🔍 GetRouter('%s'): found router %p (type=%T)", name, r, r) + return r + } + + // Check lazy router factories and instantiate if found + if factoryAny, ok := g.lazyRouterFactories.Load(name); ok { + factory := factoryAny.(func() router.Router) + LogDebug("🔍 GetRouter('%s'): found lazy factory, instantiating...", name) + r := factory() + LogDebug("🔍 GetRouter('%s'): lazy factory returned %T, registering", name, r) + g.RegisterRouter(name, r) + return r + } + + // Check if this is a service router (format: "serviceName-router") + // If so, try to instantiate the service first + if strings.HasSuffix(name, "-router") { + serviceName := strings.TrimSuffix(name, "-router") + + // Check if service exists (lazy or instance) + if g.HasService(serviceName) { + // Try to instantiate service (this will trigger router creation in the service factory) + LogDebug("🔍 GetRouter('%s'): service '%s' exists, attempting to instantiate...", name, serviceName) + + // Get service instance (this will instantiate if lazy) + serviceInstance, ok := g.GetServiceAny(serviceName) + LogDebug("🔍 GetRouter('%s'): GetServiceAny returned ok=%v, instance=%v", name, ok, serviceInstance != nil) + + if ok && serviceInstance != nil { + LogDebug("🔍 GetRouter('%s'): service '%s' instantiated successfully, checking router again...", name, serviceName) + + // Check if router was created during service instantiation + if v, ok := g.routerInstances.Load(name); ok { + r := v.(router.Router) + LogDebug("🔍 GetRouter('%s'): router created during service instantiation", name) + return r + } + + // Service instantiated but router not found - may need to create manually + LogDebug("🔍 GetRouter('%s'): service instantiated but router not auto-created, checking metadata...", name) + + // Get service definition + serviceDef := g.GetDeferredServiceDef(serviceName) + if serviceDef == nil { + LogDebug("🔍 GetRouter('%s'): service definition not found", name) + return nil + } + + // Get service metadata + metadata := g.GetServiceMetadata(serviceDef.Type) + if metadata == nil { + LogDebug("🔍 GetRouter('%s'): service metadata not found", name) + return nil + } + + // Check if service has router config + hasRouterConfig := len(metadata.RouteOverrides) > 0 || metadata.PathPrefix != "" + if !hasRouterConfig { + LogDebug("🔍 GetRouter('%s'): service has no router configuration", name) + return nil + } + + // Create router from service using autogen + LogDebug("🔍 GetRouter('%s'): creating router from service instance", name) + + // Get RouterDef if exists + routerDef := g.GetRouterDef(name) + finalPrefix := metadata.PathPrefix + if routerDef != nil && routerDef.PathPrefix != "" { + finalPrefix = routerDef.PathPrefix + } + + // Build ServiceRouterOptions + // Convert RouteMetadata to RouteMeta + routeOverrides := make(map[string]router.RouteMeta) + for methodName, routeMeta := range metadata.RouteOverrides { + middlewares := make([]any, len(routeMeta.Middlewares)) + for i, mw := range routeMeta.Middlewares { + middlewares[i] = mw + } + + routeOverrides[methodName] = router.RouteMeta{ + HTTPMethod: routeMeta.Method, + Path: routeMeta.Path, + Middlewares: middlewares, + } + } + + opts := &router.ServiceRouterOptions{ + Prefix: finalPrefix, + RouteOverrides: routeOverrides, + } + + // Create router using NewFromService + r := router.NewFromService(serviceInstance, opts) + g.RegisterRouter(name, r) + LogDebug("🔍 GetRouter('%s'): router created and registered", name) + return r + } + } } + + LogDebug("🔍 GetRouter('%s'): NOT FOUND", name) return nil } @@ -714,8 +889,31 @@ func (g *GlobalRegistry) RegisterService(name string, service any) { // }, // map[string]any{"addr": "localhost:6379"}) func (g *GlobalRegistry) RegisterLazyService(name string, factory any, config map[string]any) { - // Delegate to RegisterLazyServiceWithDeps with nil deps - g.RegisterLazyServiceWithDeps(name, factory, nil, config) + // Extract depends-on from config if present + var deps map[string]string + if depsRaw, ok := config["depends-on"]; ok { + var dependsOn []string + switch depsVal := depsRaw.(type) { + case []string: + dependsOn = depsVal + case []any: + // Handle YAML unmarshaling []any + dependsOn = make([]string, len(depsVal)) + for i, d := range depsVal { + dependsOn[i] = d.(string) + } + } + + // Create deps map: key = service name, value = service name + if len(dependsOn) > 0 { + deps = make(map[string]string, len(dependsOn)) + for _, dep := range dependsOn { + deps[dep] = dep + } + LogDebug("📦 RegisterLazyService '%s': extracted %d dependencies from config: %v", name, len(deps), dependsOn) + } + } // Delegate to RegisterLazyServiceWithDeps + g.RegisterLazyServiceWithDeps(name, factory, deps, config) } // registerDeferredService stores a service definition using a factory type name. @@ -737,15 +935,25 @@ func (g *GlobalRegistry) registerDeferredService(name, factoryType string, confi } } - // Store deferred definition - def := &deferredServiceDef{ - Name: name, + // Create unresolved lazy service entry (Phase 1: store FactoryType string) + // Will be resolved to actual Factory function in RegisterDefinitionsForRuntime + depsMap := make(map[string]string) + for _, dep := range dependsOn { + depsMap[dep] = dep + } + + entry := &LazyServiceEntry{ FactoryType: factoryType, - DependsOn: dependsOn, + Factory: nil, // Unresolved - will be set in Phase 2 Config: config, + Deps: depsMap, + resolved: false, } - g.serviceDefs.Store(name, def) + g.lazyServiceFactories.Store(name, entry) + // NOTE: Do NOT create sync.Once here! + // sync.Once will be created in GetServiceAny when entry is resolved + // This prevents premature instantiation before factory is available } // LazyServiceRegistrationMode defines how to handle duplicate registrations @@ -863,13 +1071,32 @@ func (g *GlobalRegistry) RegisterLazyServiceWithDeps(name string, factory any, d deps = make(map[string]string) switch depsArray := depsRaw.(type) { case []string: - for _, serviceName := range depsArray { - deps[serviceName] = serviceName + for _, depStr := range depsArray { + // Parse "paramName:serviceName" or just "serviceName" + // serviceName can be "@config.key" for config-based resolution + parts := strings.SplitN(depStr, ":", 2) + if len(parts) == 2 { + paramName := parts[0] + serviceName := parts[1] + deps[paramName] = serviceName + } else { + // No explicit param name - use service name as key + deps[depStr] = depStr + } } case []any: for _, d := range depsArray { - if serviceName, ok := d.(string); ok { - deps[serviceName] = serviceName + if depStr, ok := d.(string); ok { + // Parse "paramName:serviceName" or just "serviceName" + parts := strings.SplitN(depStr, ":", 2) + if len(parts) == 2 { + paramName := parts[0] + serviceName := parts[1] + deps[paramName] = serviceName + } else { + // No explicit param name - use service name as key + deps[depStr] = depStr + } } } } @@ -877,15 +1104,45 @@ func (g *GlobalRegistry) RegisterLazyServiceWithDeps(name string, factory any, d } entry := &LazyServiceEntry{ - Factory: normFactory, - Config: config, - Deps: deps, // Store dependency mapping + Factory: normFactory, + Config: config, + Deps: deps, // Store dependency mapping + resolved: true, // Already has Factory function } + LogDebug("📦 RegisterLazyServiceWithDeps '%s': stored with %d dependencies: %v", name, len(deps), deps) g.lazyServiceFactories.Store(name, entry) g.lazyServiceOnce.Store(name, &sync.Once{}) } +// RegisterLazyServiceUnresolved stores an unresolved lazy service entry +// This is called during config loading when we only have the factory type name +// The actual factory function will be resolved later in RegisterDefinitionsForRuntime +func (g *GlobalRegistry) RegisterLazyServiceUnresolved(name, factoryType string, deps map[string]string, config map[string]any) { + if name == "" || factoryType == "" { + panic(fmt.Sprintf("service name and factory type must not be empty (name=%s, type=%s)", name, factoryType)) + } + + entry := &LazyServiceEntry{ + FactoryType: factoryType, + Factory: nil, // Will be resolved later + Config: config, + Deps: deps, + resolved: false, // Mark as unresolved + } + + g.lazyServiceFactories.Store(name, entry) + g.lazyServiceOnce.Store(name, &sync.Once{}) +} + +// GetLazyServiceEntry retrieves a lazy service entry by name (for resolution checking) +func (g *GlobalRegistry) GetLazyServiceEntry(name string) *LazyServiceEntry { + if entryAny, ok := g.lazyServiceFactories.Load(name); ok { + return entryAny.(*LazyServiceEntry) + } + return nil +} + // GetServiceAny retrieves a service instance by name as any // If not found in eager registry, checks lazy registry and instantiates // If still not found, checks service-definitions and auto-creates lazy service @@ -895,21 +1152,27 @@ func (g *GlobalRegistry) GetServiceAny(name string) (any, bool) { // getServiceAnyWithStack is internal version with circular dependency detection func (g *GlobalRegistry) getServiceAnyWithStack(name string, resolutionStack []string) (any, bool) { + LogDebug("🔍 GetServiceAny('%s'): starting resolution, stack=%v", name, resolutionStack) + // Handle @ prefix - resolve actual service name from config // Example: "@store.order-repository" reads config key "store.order-repository" // and gets the actual service name to inject if after, ok := strings.CutPrefix(name, "@"); ok { + LogDebug("🔍 GetServiceAny('%s'): has @ prefix, resolving from config key '%s'", name, after) configKey := after configValue, ok := g.GetConfig(configKey) if !ok { + LogDebug("🔍 GetServiceAny('%s'): config key '%s' NOT FOUND", name, configKey) return nil, false } actualServiceName, ok := configValue.(string) if !ok || actualServiceName == "" { + LogDebug("🔍 GetServiceAny('%s'): config value is not string or empty: %v", name, configValue) return nil, false } + LogDebug("🔍 GetServiceAny('%s'): resolved to actual service '%s'", name, actualServiceName) // Recursively resolve the actual service (add to stack to detect circular deps) return g.getServiceAnyWithStack(actualServiceName, append(resolutionStack, name)) } @@ -925,6 +1188,7 @@ func (g *GlobalRegistry) getServiceAnyWithStack(name string, resolutionStack []s // Check eager registry first if svc, ok := g.serviceInstances.Load(name); ok { + LogDebug("🔍 GetServiceAny('%s'): found in eager registry (already instantiated)", name) return svc, true } @@ -934,29 +1198,67 @@ func (g *GlobalRegistry) getServiceAnyWithStack(name string, resolutionStack []s // Check lazy registry and create if needed onceAny, hasOnce := g.lazyServiceOnce.Load(name) if !hasOnce { - // Not in lazy registry - check if in deferred service definitions - if defAny, exists := g.serviceDefs.Load(name); exists { - deferredDef := defAny.(*deferredServiceDef) - - // Convert deferred definition to schema.ServiceDef for auto-registration - serviceDef := &schema.ServiceDef{ - Name: deferredDef.Name, - Type: deferredDef.FactoryType, - DependsOn: deferredDef.DependsOn, - Config: deferredDef.Config, + LogDebug("🔍 GetServiceAny('%s'): NOT in lazyServiceOnce, checking lazyServiceFactories...", name) + + // Not in lazy registry - check if in lazyServiceFactories with unresolved entry + if entryAny, exists := g.lazyServiceFactories.Load(name); exists { + LogDebug("🔍 GetServiceAny('%s'): found in lazyServiceFactories", name) + entry := entryAny.(*LazyServiceEntry) + + // If unresolved (Phase 1 - from registerDeferredService), resolve it now + if !entry.resolved { + LogDebug("🔍 GetServiceAny('%s'): entry UNRESOLVED, resolving factory type '%s'...", name, entry.FactoryType) + // Get factory for the service type + factory := g.GetServiceFactory(entry.FactoryType, true) // true = local factory + if factory == nil { + LogDebug("🔍 GetServiceAny('%s'): factory '%s' NOT FOUND!", name, entry.FactoryType) + panic(fmt.Sprintf("service factory '%s' not registered for service '%s'", entry.FactoryType, name)) + } + + // Resolve the factory (modifies the entry in-place since it's a pointer) + entry.Factory = factory + entry.resolved = true } - // Auto-create lazy service from definition - g.autoRegisterLazyService(name, serviceDef) + // Create sync.Once if not exists (handles case where entry was resolved externally) + if _, hasOnceAlready := g.lazyServiceOnce.Load(name); !hasOnceAlready { + LogDebug("🔍 GetServiceAny('%s'): creating sync.Once (entry was resolved=%v)", name, entry.resolved) + g.lazyServiceOnce.Store(name, &sync.Once{}) + } - // Now try again + // Now proceed with instantiation below onceAny, hasOnce = g.lazyServiceOnce.Load(name) - if !hasOnce { - return nil, false - } } else { + // Not in lazy registry - try auto-registration from serviceFactories + // Convention: service name = factory type (e.g., "email-smtp" service uses "email-smtp" factory) + g.mu.RLock() + factoryEntry, hasFactory := g.serviceFactories[name] + g.mu.RUnlock() + + if hasFactory && factoryEntry.Local != nil { + // Auto-register as lazy service with default config + LogDebug("🔧 Auto-registering service '%s' from factory type '%s' (default config)", name, name) + entry := &LazyServiceEntry{ + FactoryType: name, + Factory: factoryEntry.Local, + Config: make(map[string]any), // Empty config + Deps: make(map[string]string), + resolved: true, + } + + g.lazyServiceFactories.Store(name, entry) + g.lazyServiceOnce.Store(name, &sync.Once{}) + + onceAny, hasOnce = g.lazyServiceOnce.Load(name) + } + } + + if !hasOnce { + LogDebug("🔍 GetServiceAny('%s'): NOT FOUND in any registry, returning false", name) return nil, false } + } else { + LogDebug("🔍 GetServiceAny('%s'): found in lazyServiceOnce, will instantiate", name) } once := onceAny.(*sync.Once) @@ -972,20 +1274,36 @@ func (g *GlobalRegistry) getServiceAnyWithStack(name string, resolutionStack []s entry := entryAny.(*LazyServiceEntry) + // If unresolved inside once.Do, resolve now (handles race condition) + if !entry.resolved { + factory := g.GetServiceFactory(entry.FactoryType, true) + if factory == nil { + panic(fmt.Sprintf("service factory '%s' not registered for service '%s'", entry.FactoryType, name)) + } + entry.Factory = factory + entry.resolved = true + } + // Resolve dependencies if specified var resolvedDeps map[string]any if len(entry.Deps) > 0 { + LogDebug("📦 Service '%s': resolving %d dependencies: %v", name, len(entry.Deps), entry.Deps) resolvedDeps = make(map[string]any, len(entry.Deps)) for factoryKey, serviceName := range entry.Deps { // Recursively resolve dependency with circular detection // @ prefix is handled automatically by getServiceAnyWithStack + LogDebug("📦 Service '%s': resolving dependency '%s' -> '%s'", name, factoryKey, serviceName) depSvc, ok := g.getServiceAnyWithStack(serviceName, newStack) if !ok { panic(fmt.Sprintf("lazy service %s: dependency %s not found", name, serviceName)) } + LogDebug("📦 Service '%s': dependency '%s' resolved to: %T", name, factoryKey, depSvc) // Use factoryKey (may include @ prefix) as key for factory lookup resolvedDeps[factoryKey] = depSvc } + LogDebug("📦 Service '%s': all dependencies resolved, calling factory", name) + } else { + LogDebug("📦 Service '%s': no dependencies, calling factory directly", name) } // Call factory with resolved deps or nil @@ -996,6 +1314,7 @@ func (g *GlobalRegistry) getServiceAnyWithStack(name string, resolutionStack []s LogDebug("📦 Creating service instance: '%s'", name) } instance := entry.Factory(resolvedDeps, entry.Config) + LogDebug("📦 Service '%s' created: instance=%p, type=%T", name, instance, instance) g.serviceInstances.Store(name, instance) }) @@ -1005,18 +1324,13 @@ func (g *GlobalRegistry) getServiceAnyWithStack(name string, resolutionStack []s } // HasService checks if a service is registered in the lazy service registry -// or defined in the deferred service definitions (from YAML or code). +// or instantiated in the eager registry. func (g *GlobalRegistry) HasService(name string) bool { - // Check if already instantiated in lazy registry + // Check if defined in lazy registry (resolved or unresolved) if _, ok := g.lazyServiceFactories.Load(name); ok { return true } - // Check if defined but not yet instantiated - if _, ok := g.serviceDefs.Load(name); ok { - return true - } - // Check if instantiated in eager registry if _, ok := g.serviceInstances.Load(name); ok { return true @@ -1029,40 +1343,53 @@ func (g *GlobalRegistry) HasService(name string) bool { // into config.ServiceDefinitions. This allows services registered via code to be // available in config for dependency resolution and topology checks. func (g *GlobalRegistry) MergeRegistryServicesToConfig(config *schema.DeployConfig) { - g.serviceDefs.Range(func(key, value any) bool { + g.lazyServiceFactories.Range(func(key, value any) bool { serviceName := key.(string) - deferredDef := value.(*deferredServiceDef) + entry := value.(*LazyServiceEntry) // Skip if already exists in config (YAML takes priority) if _, exists := config.ServiceDefinitions[serviceName]; exists { return true // continue iteration } + // Convert Deps map to DependsOn slice + dependsOn := make([]string, 0, len(entry.Deps)) + for dep := range entry.Deps { + dependsOn = append(dependsOn, dep) + } + // Add to config.ServiceDefinitions config.ServiceDefinitions[serviceName] = &schema.ServiceDef{ - Name: deferredDef.Name, - Type: deferredDef.FactoryType, - DependsOn: deferredDef.DependsOn, - Config: deferredDef.Config, + Name: serviceName, + Type: entry.FactoryType, + DependsOn: dependsOn, + Config: entry.Config, } return true // continue iteration }) } -// GetDeferredServiceDef retrieves a deferred service definition by name. -// Returns the definition if found in serviceDefs, or nil if not found. +// GetDeferredServiceDef retrieves a service definition by name. +// Returns the definition if found in lazyServiceFactories, or nil if not found. // This is primarily used by wrapper functions that need access to service metadata. func (g *GlobalRegistry) GetDeferredServiceDef(name string) *schema.ServiceDef { LogDebug("[GetDeferredServiceDef] looking for '%s'", name) - if defAny, ok := g.serviceDefs.Load(name); ok { - deferredDef := defAny.(*deferredServiceDef) - LogDebug("[GetDeferredServiceDef] FOUND '%s': Type=%s", name, deferredDef.FactoryType) + if entryAny, ok := g.lazyServiceFactories.Load(name); ok { + entry := entryAny.(*LazyServiceEntry) + LogDebug("[GetDeferredServiceDef] FOUND '%s': Type=%s", name, entry.FactoryType) + + // Convert Deps map to DependsOn slice + dependsOn := make([]string, 0, len(entry.Deps)) + for dep := range entry.Deps { + dependsOn = append(dependsOn, dep) + } + return &schema.ServiceDef{ - Name: deferredDef.Name, - Type: deferredDef.FactoryType, - DependsOn: deferredDef.DependsOn, - Config: deferredDef.Config, + Name: name, + Type: entry.FactoryType, + DependsOn: dependsOn, + Config: entry.Config, } } LogDebug("[GetDeferredServiceDef] NOT FOUND '%s'", name) @@ -1072,82 +1399,82 @@ func (g *GlobalRegistry) GetDeferredServiceDef(name string) *schema.ServiceDef { // autoRegisterLazyService auto-registers a service from service-definitions as a lazy service // This enables zero-config pattern - services are created on-demand from YAML definitions // Logic: Check if published on another server → REMOTE, else → LOCAL from service-definitions -func (g *GlobalRegistry) autoRegisterLazyService(name string, def *schema.ServiceDef) { - // Get current deployment context - currentKey := g.GetCurrentCompositeKey() - LogDebug("[autoRegisterLazyService] service '%s', currentKey='%s'", name, currentKey) - if currentKey == "" { - // No current context - default to LOCAL - LogDebug("[autoRegisterLazyService] No currentKey - registering '%s' as LOCAL", name) - g.autoRegisterLocalService(name, def) - return - } - - // Get current server topology - currentServerTopo, ok := g.GetServerTopology(currentKey) - if !ok { - // No topology found - default to LOCAL - LogDebug("[autoRegisterLazyService] No topology found for '%s' - registering '%s' as LOCAL", currentKey, name) - g.autoRegisterLocalService(name, def) - return - } - - // Check if service is published on another server (REMOTE) - remoteBaseURL, isRemote := currentServerTopo.RemoteServices[name] - LogDebug("[autoRegisterLazyService] service '%s': isRemote=%v, remoteBaseURL='%s'", name, isRemote, remoteBaseURL) - if isRemote { - // Register as REMOTE service (HTTP proxy) - LogDebug("[autoRegisterLazyService] Registering '%s' as REMOTE -> %s", name, remoteBaseURL) - g.AutoRegisterRemoteService(name, def, remoteBaseURL) - return - } - - // Not remote - register as LOCAL - LogDebug("[autoRegisterLazyService] Registering '%s' as LOCAL", name) - g.autoRegisterLocalService(name, def) -} +// func (g *GlobalRegistry) autoRegisterLazyService(name string, def *schema.ServiceDef) { +// // Get current deployment context +// currentKey := g.GetCurrentCompositeKey() +// LogDebug("[autoRegisterLazyService] service '%s', currentKey='%s'", name, currentKey) +// if currentKey == "" { +// // No current context - default to LOCAL +// LogDebug("[autoRegisterLazyService] No currentKey - registering '%s' as LOCAL", name) +// g.autoRegisterLocalService(name, def) +// return +// } + +// // Get current server topology +// currentServerTopo, ok := g.GetServerTopology(currentKey) +// if !ok { +// // No topology found - default to LOCAL +// LogDebug("[autoRegisterLazyService] No topology found for '%s' - registering '%s' as LOCAL", currentKey, name) +// g.autoRegisterLocalService(name, def) +// return +// } + +// // Check if service is published on another server (REMOTE) +// remoteBaseURL, isRemote := currentServerTopo.RemoteServices[name] +// LogDebug("[autoRegisterLazyService] service '%s': isRemote=%v, remoteBaseURL='%s'", name, isRemote, remoteBaseURL) +// if isRemote { +// // Register as REMOTE service (HTTP proxy) +// LogDebug("[autoRegisterLazyService] Registering '%s' as REMOTE -> %s", name, remoteBaseURL) +// g.AutoRegisterRemoteService(name, def, remoteBaseURL) +// return +// } + +// // Not remote - register as LOCAL +// LogDebug("[autoRegisterLazyService] Registering '%s' as LOCAL", name) +// g.autoRegisterLocalService(name, def) +// } // autoRegisterLocalService registers a service as LOCAL (from factory) -func (g *GlobalRegistry) autoRegisterLocalService(name string, def *schema.ServiceDef) { - // Get factory - factory := g.GetServiceFactory(def.Type, true) // true = local factory - if factory == nil { - panic(fmt.Sprintf("service factory '%s' not registered for service '%s'", def.Type, name)) - } - - // Parse dependencies from DependsOn field - deps := make(map[string]string) - if len(def.DependsOn) > 0 { - for _, depStr := range def.DependsOn { - // Format: "paramName:serviceName" or just "serviceName" - parts := strings.Split(depStr, ":") - if len(parts) == 2 { - paramName := parts[0] - serviceName := parts[1] - deps[paramName] = serviceName - } else { - // No explicit param name - use service name as key - deps[depStr] = depStr - } - } - } - - // Register as lazy service with wrapper factory - // Factory expects service.Cached for dependencies, so we wrap resolved deps - g.RegisterLazyServiceWithDeps(name, func(resolvedDeps, cfg map[string]any) any { - // Wrap resolved dependencies as service.Cached - // This allows factories to use service.Cast[T](deps["key"]) - lazyDeps := make(map[string]any) - for key, depSvc := range resolvedDeps { - depSvcCopy := depSvc // Capture for closure - lazyDeps[key] = service.LazyLoadWith(func() any { return depSvcCopy }) - } - - // Call original factory - LogDebug("📦 Creating service instance: '%s' (type: %s)", name, def.Type) - return factory(lazyDeps, cfg) - }, deps, def.Config) -} +// func (g *GlobalRegistry) autoRegisterLocalService(name string, def *schema.ServiceDef) { +// // Get factory +// factory := g.GetServiceFactory(def.Type, true) // true = local factory +// if factory == nil { +// panic(fmt.Sprintf("service factory '%s' not registered for service '%s'", def.Type, name)) +// } + +// // Parse dependencies from DependsOn field +// deps := make(map[string]string) +// if len(def.DependsOn) > 0 { +// for _, depStr := range def.DependsOn { +// // Format: "paramName:serviceName" or just "serviceName" +// parts := strings.Split(depStr, ":") +// if len(parts) == 2 { +// paramName := parts[0] +// serviceName := parts[1] +// deps[paramName] = serviceName +// } else { +// // No explicit param name - use service name as key +// deps[depStr] = depStr +// } +// } +// } + +// // Register as lazy service with wrapper factory +// // Factory expects service.Cached for dependencies, so we wrap resolved deps +// g.RegisterLazyServiceWithDeps(name, func(resolvedDeps, cfg map[string]any) any { +// // Wrap resolved dependencies as service.Cached +// // This allows factories to use service.Cast[T](deps["key"]) +// lazyDeps := make(map[string]any) +// for key, depSvc := range resolvedDeps { +// depSvcCopy := depSvc // Capture for closure +// lazyDeps[key] = service.LazyLoadWith(func() any { return depSvcCopy }) +// } + +// // Call original factory +// LogDebug("📦 Creating service instance: '%s' (type: %s)", name, def.Type) +// return factory(lazyDeps, cfg) +// }, deps, def.Config) +// } // AutoRegisterRemoteService registers a service as REMOTE (HTTP proxy) func (g *GlobalRegistry) AutoRegisterRemoteService(name string, def *schema.ServiceDef, remoteBaseURL string) { @@ -1220,6 +1547,7 @@ type DeploymentConfig interface { // ServerConfig interface for deployment registration type ServerConfig interface { GetBaseURL() string + GetConfigOverrides() map[string]any GetApps() []AppConfig GetAddr() string GetRouters() []string @@ -1350,12 +1678,13 @@ func (g *GlobalRegistry) RegisterDeployment(deploymentName string, config Deploy // Build server topologies for serverName, serverConfig := range config.GetServers() { serverTopo := &ServerTopology{ - Name: serverName, - DeploymentName: deploymentName, - BaseURL: serverConfig.GetBaseURL(), - Services: make([]string, 0), - RemoteServices: make(map[string]string), - Apps: make([]*AppTopology, 0), + Name: serverName, + DeploymentName: deploymentName, + BaseURL: serverConfig.GetBaseURL(), + ConfigOverrides: serverConfig.GetConfigOverrides(), + Services: make([]string, 0), + RemoteServices: make(map[string]string), + Apps: make([]*AppTopology, 0), } // Collect apps (from Apps slice + shorthand fields) @@ -1451,6 +1780,9 @@ func (a *shorthandAppConfig) GetAddr() string { return a.addr } func (a *shorthandAppConfig) GetRouters() []string { return a.routers } func (a *shorthandAppConfig) GetPublishedServices() []string { return a.publishedServices } +// Implement ServerConfig interface for code-based config (not used in shorthandAppConfig) +func (a *shorthandAppConfig) GetConfigOverrides() map[string]any { return nil } + var FirstServer string // StoreDeploymentTopology stores deployment topology in global registry (case-insensitive) diff --git a/core/deploy/schema/lokstra.schema.json b/core/deploy/schema/lokstra.schema.json index 63a8d255..72e67f9e 100644 --- a/core/deploy/schema/lokstra.schema.json +++ b/core/deploy/schema/lokstra.schema.json @@ -90,7 +90,7 @@ "type": { "type": "string", "description": "Service factory type identifier", - "pattern": "^[a-z][a-z0-9-]*$" + "pattern": "^[a-z][a-z0-9_-]*$" }, "depends-on": { "type": "array", @@ -224,6 +224,77 @@ }, "additionalProperties": false }, + "serverDefinition": { + "type": "object", + "required": ["base-url"], + "description": "Server definition", + "properties": { + "base-url": { + "type": "string", + "description": "Server base URL", + "oneOf": [ + { "pattern": "^https?://" }, + { "pattern": "^\\$\\{[^}]+\\}$" } + ] + }, + "config-overrides": { + "type": "object", + "description": "Server-level configuration overrides (highest priority)", + "additionalProperties": true + }, + "middleware-definitions": { + "type": "object", + "description": "Server-level middleware definitions", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/middlewareDefinition" } + } + }, + "service-definitions": { + "type": "object", + "description": "Server-level service definitions", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/serviceDefinition" } + } + }, + "router-definitions": { + "type": "object", + "description": "Server-level router definitions", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/routerDefinition" } + } + }, + "apps": { + "type": "array", + "description": "Applications on this server", + "items": { "$ref": "#/definitions/appDefinition" } + }, + "addr": { + "type": "string", + "description": "Shorthand: Application address", + "oneOf": [ + { "pattern": "^(:[0-9]+|[0-9.]+:[0-9]+|unix:.+)$" }, + { "pattern": "^\\$\\{.*\\}$" } + ] + }, + "routers": { + "type": "array", + "description": "Shorthand: Routers", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9.-]*$" + } + }, + "published-services": { + "type": "array", + "description": "Shorthand: Services to auto-generate routers for", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9.-]*$" + } + } + }, + "additionalProperties": false + }, "dbPoolConfig": { "type": "object", "description": "Database pool configuration", @@ -379,77 +450,20 @@ "type": "object", "description": "Server definitions", "patternProperties": { - "^[a-z][a-z0-9-]*$": { - "type": "object", - "required": ["base-url"], - "properties": { - "base-url": { - "type": "string", - "description": "Server base URL", - "oneOf": [ - { "pattern": "^https?://" }, - { "pattern": "^\\$\\{[^}]+\\}$" } - ] - }, - "middleware-definitions": { - "type": "object", - "description": "Server-level middleware definitions", - "patternProperties": { - "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/middlewareDefinition" } - } - }, - "service-definitions": { - "type": "object", - "description": "Server-level service definitions", - "patternProperties": { - "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/serviceDefinition" } - } - }, - "router-definitions": { - "type": "object", - "description": "Server-level router definitions", - "patternProperties": { - "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/routerDefinition" } - } - }, - "apps": { - "type": "array", - "description": "Applications on this server", - "items": { "$ref": "#/definitions/appDefinition" } - }, - "addr": { - "type": "string", - "description": "Shorthand: Application address", - "oneOf": [ - { "pattern": "^(:[0-9]+|[0-9.]+:[0-9]+|unix:.+)$" }, - { "pattern": "^\\$\\{.*\\}$" } - ] - }, - "routers": { - "type": "array", - "description": "Shorthand: Routers", - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9.-]*$" - } - }, - "published-services": { - "type": "array", - "description": "Shorthand: Services to auto-generate routers for", - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9.-]*$" - } - } - }, - "additionalProperties": false - } + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/serverDefinition" } } } }, "additionalProperties": false } } + }, + "servers": { + "type": "object", + "description": "Shorthand: Top-level servers (auto-creates 'default' deployment). Use this when you only need one deployment.", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/serverDefinition" } + } } }, "additionalProperties": false diff --git a/core/deploy/schema/schema.go b/core/deploy/schema/schema.go index 839b681e..deaa297c 100644 --- a/core/deploy/schema/schema.go +++ b/core/deploy/schema/schema.go @@ -19,6 +19,10 @@ type DeployConfig struct { ServiceDefinitions map[string]*ServiceDef `yaml:"service-definitions" json:"service-definitions"` RouterDefinitions map[string]*RouterDef `yaml:"router-definitions,omitempty" json:"router-definitions,omitempty"` // Renamed from Routers Deployments map[string]*DeploymentDefMap `yaml:"deployments" json:"deployments"` + + // Shorthand: top-level servers (auto-creates 'default' deployment) + // Use this when you only need one deployment + Servers map[string]*ServerDefMap `yaml:"servers,omitempty" json:"servers,omitempty"` } // DbPoolConfig defines configuration for a named database pool @@ -87,6 +91,9 @@ type DeploymentDefMap struct { type ServerDefMap struct { BaseURL string `yaml:"base-url" json:"base-url"` + // Server-level config overrides (highest priority) + ConfigOverrides map[string]any `yaml:"config-overrides,omitempty" json:"config-overrides,omitempty"` + // Inline definitions at server level (will be normalized to {deployment}.{server}.{name}) InlineMiddlewares map[string]*MiddlewareDef `yaml:"middleware-definitions,omitempty" json:"middleware-definitions,omitempty"` InlineServices map[string]*ServiceDef `yaml:"service-definitions,omitempty" json:"service-definitions,omitempty"` diff --git a/core/request/request_helper.go b/core/request/request_helper.go index 0e95ab61..b8c7acfb 100644 --- a/core/request/request_helper.go +++ b/core/request/request_helper.go @@ -555,10 +555,13 @@ func (h *RequestHelper) BindAll(v any) error { if err := h.bindHeaderField(fieldMeta, rv, header); err != nil { return err } - default: //case "path": + case "path": if err := h.bindPathField(fieldMeta, rv); err != nil { return err } + // Skip json fields - they will be handled by BindBody + case "json": + continue } } diff --git a/docs/02-framework-guide/08-database-pools.md b/docs/02-framework-guide/08-database-pools.md new file mode 100644 index 00000000..5b90aaea --- /dev/null +++ b/docs/02-framework-guide/08-database-pools.md @@ -0,0 +1,251 @@ +--- +title: Database Pools +layout: default +parent: Framework Guide +nav_order: 8 +--- + +# Database Pools + +Lokstra provides built-in support for database connection pooling with automatic configuration from YAML files. + +## Setup Database Pools + +### 1. Define DB Pools in Config + +**config.yaml:** +```yaml +named-db-pools: + main-db: + dsn: "postgres://user:pass@localhost:5432/mydb?sslmode=disable" + min_conns: 2 + max_conns: 10 + max_idle_time: "30m" + max_lifetime: "1h" + schema: "public" + + analytics-db: + host: localhost + port: 5432 + database: analytics + username: analytics_user + password: secret + sslmode: disable + min_conns: 2 + max_conns: 20 + schema: "analytics" +``` + +### 2. Explicit Setup (Recommended) + +```go +func main() { + lokstra.Bootstrap() + + // 1. Load config only + if err := lokstra.LoadConfig("config.yaml"); err != nil { + log.Fatal(err) + } + + // 2. Setup DB pools explicitly (if needed) + if err := lokstra.SetupNamedDbPools(); err != nil { + log.Fatal(err) + } + + // 3. Run server + lokstra_registry.InitAndRunServer() +} +``` + +### 3. Auto Setup (Legacy - Backward Compatible) + +```go +func main() { + lokstra.Bootstrap() + + // Auto-loads config + setup DB pools + run server + lokstra_registry.RunServerFromConfig("config.yaml") +} +``` + +## Inject DB Pool into Service + +### Using @Inject Annotation + +```go +// @Service "user-repository" +type UserRepository struct { + // @Inject "main-db" + DB serviceapi.DbPool +} + +func (r *UserRepository) GetUser(id string) (*User, error) { + var user User + err := r.DB.QueryRow(context.Background(), + "SELECT id, name, email FROM users WHERE id = $1", id, + ).Scan(&user.ID, &user.Name, &user.Email) + return &user, err +} +``` + +### Using Manual Injection + +```go +func UserRepositoryFactory(deps map[string]any, config map[string]any) any { + return &UserRepository{ + DB: deps["main-db"].(serviceapi.DbPool), + } +} + +// In register.go +lokstra_registry.RegisterServiceType("user-repository-factory", + UserRepositoryFactory, nil) +``` + +**config.yaml:** +```yaml +service-definitions: + user-repository: + type: user-repository-factory + depends-on: + - DB:main-db # Inject DB pool named "main-db" +``` + +## DSN Configuration + +### Option 1: Direct DSN + +```yaml +named-db-pools: + mydb: + dsn: "postgres://user:pass@localhost:5432/mydb?sslmode=disable" +``` + +### Option 2: Component-Based (Recommended) + +```yaml +named-db-pools: + mydb: + host: ${DB_HOST:localhost} + port: ${DB_PORT:5432} + database: ${DB_NAME:mydb} + username: ${DB_USER:user} + password: ${DB_PASS:secret} + sslmode: ${DB_SSLMODE:disable} +``` + +## Pool Parameters + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `min_conns` | 2 | Minimum connections in pool | +| `max_conns` | 10 | Maximum connections in pool | +| `max_idle_time` | 30m | Max time a connection can be idle | +| `max_lifetime` | 1h | Max lifetime of a connection | +| `schema` | public | Default PostgreSQL schema | + +## Best Practices + +### 1. Separate Config from Code + +✅ **Good:** +```go +// Load config first, setup DB later +lokstra.LoadConfig("config.yaml") +lokstra.SetupNamedDbPools() +``` + +❌ **Bad:** +```go +// Auto-setup couples config loading with infrastructure +lokstra_registry.RunServerFromConfig("config.yaml") +``` + +### 2. Use Named Pools for Different Purposes + +```yaml +named-db-pools: + transactional-db: # For OLTP workloads + max_conns: 10 + + analytics-db: # For OLAP workloads + max_conns: 50 + + cache-db: # For caching + max_conns: 5 +``` + +### 3. Environment-Specific Configuration + +```yaml +named-db-pools: + main-db: + host: ${DB_HOST:localhost} + port: ${DB_PORT:5432} + database: ${DB_NAME} # Required in production + username: ${DB_USER} # Required in production + password: ${DB_PASS} # Required in production + sslmode: ${DB_SSLMODE:require} +``` + +**Development:** +```bash +export DB_NAME=myapp_dev +export DB_USER=dev_user +export DB_PASS=dev_pass +export DB_SSLMODE=disable +``` + +**Production:** +```bash +export DB_NAME=myapp_prod +export DB_USER=prod_user +export DB_PASS=secure_password +export DB_SSLMODE=require +``` + +## Testing Without DB + +```go +func TestUserService(t *testing.T) { + // Load config without setting up DB pools + lokstra.LoadConfig("config.yaml") + + // Mock DB pool + mockDB := &MockDbPool{} + lokstra_registry.RegisterService("main-db", mockDB) + + // Test service + service := lokstra_registry.GetService[*UserService]("user-service") + // ... +} +``` + +## Multiple Databases + +```go +// @Service "reporting-service" +type ReportingService struct { + // @Inject "transactional-db" + TransactionalDB serviceapi.DbPool + + // @Inject "analytics-db" + AnalyticsDB serviceapi.DbPool +} + +func (s *ReportingService) GenerateReport() (*Report, error) { + // Read from transactional DB + users, _ := s.TransactionalDB.Query(...) + + // Read from analytics DB + metrics, _ := s.AnalyticsDB.Query(...) + + return &Report{Users: users, Metrics: metrics}, nil +} +``` + +## See Also + +- [Service Registration](./03-services.md) +- [Dependency Injection](./04-dependency-injection.md) +- [Configuration Management](./05-configuration.md) diff --git a/docs/schema/lokstra.schema.json b/docs/schema/lokstra.schema.json index 55ca4696..0f2adb26 100644 --- a/docs/schema/lokstra.schema.json +++ b/docs/schema/lokstra.schema.json @@ -4,13 +4,306 @@ "title": "Lokstra Deployment Configuration", "description": "Schema for Lokstra deployment configuration files", "type": "object", + "definitions": { + "middlewareDefinition": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "Middleware factory type identifier", + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "config": { + "type": "object", + "description": "Middleware-specific configuration", + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "routerDefinition": { + "type": "object", + "description": "Router definition for auto-generated routes", + "properties": { + "path-prefix": { + "type": "string", + "description": "Path prefix for all routes" + }, + "path-rewrites": { + "type": "array", + "description": "Regex-based path rewrites", + "items": { + "type": "object", + "required": ["pattern", "replacement"], + "properties": { + "pattern": { "type": "string" }, + "replacement": { "type": "string" } + }, + "additionalProperties": false + } + }, + "middlewares": { + "type": "array", + "description": "Router-level middleware names", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9.-]*$" + } + }, + "hidden": { + "type": "array", + "description": "Methods to hide from router", + "items": { "type": "string" } + }, + "custom": { + "type": "array", + "description": "Custom route definitions", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "method": { + "type": "string", + "pattern": "^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)$" + }, + "path": { "type": "string" }, + "middlewares": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9.-]*$" + } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "serviceDefinition": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "Service factory type identifier", + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "depends-on": { + "type": "array", + "description": "List of service dependencies", + "items": { + "type": "string", + "pattern": "^([a-zA-Z][a-zA-Z0-9]*:)?[a-z][a-z0-9.-]*$" + } + }, + "router": { "$ref": "#/definitions/routerDefinition" }, + "config": { + "type": "object", + "description": "Service-specific configuration", + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "reverseProxyRewrite": { + "type": "object", + "required": ["from", "to"], + "properties": { + "from": { + "type": "string", + "description": "Pattern to match in path (regex supported)" + }, + "to": { + "type": "string", + "description": "Replacement pattern" + } + }, + "additionalProperties": false + }, + "reverseProxyDefinition": { + "type": "object", + "required": ["prefix", "target"], + "properties": { + "prefix": { + "type": "string", + "description": "URL prefix to match (e.g., '/api')" + }, + "strip-prefix": { + "type": "boolean", + "description": "Whether to strip the prefix before forwarding", + "default": false + }, + "target": { + "type": "string", + "description": "Target backend URL (e.g., 'http://api-server:8080')", + "pattern": "^https?://" + }, + "rewrite": { + "$ref": "#/definitions/reverseProxyRewrite" + } + }, + "additionalProperties": false + }, + "mountSpaDefinition": { + "type": "object", + "required": ["prefix", "dir"], + "properties": { + "prefix": { + "type": "string", + "description": "URL prefix (e.g., '/app', '/')" + }, + "dir": { + "type": "string", + "description": "Directory path containing SPA files (e.g., './dist', './build')" + } + }, + "additionalProperties": false + }, + "mountStaticDefinition": { + "type": "object", + "required": ["prefix", "dir"], + "properties": { + "prefix": { + "type": "string", + "description": "URL prefix (e.g., '/static', '/assets')" + }, + "dir": { + "type": "string", + "description": "Directory path containing static files (e.g., './public', './static')" + } + }, + "additionalProperties": false + }, + "appDefinition": { + "type": "object", + "required": ["addr"], + "properties": { + "addr": { + "type": "string", + "description": "Application address", + "oneOf": [ + { "pattern": "^(:[0-9]+|[0-9.]+:[0-9]+|unix:.+)$" }, + { "pattern": "^\\$\\{.*\\}$" } + ] + }, + "routers": { + "type": "array", + "description": "Routers to include", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9.-]*$" + } + }, + "published-services": { + "type": "array", + "description": "Services to auto-generate routers for", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9.-]*$" + } + }, + "reverse-proxies": { + "type": "array", + "description": "Reverse proxy configurations", + "items": { "$ref": "#/definitions/reverseProxyDefinition" } + }, + "mount-spa": { + "type": "array", + "description": "SPA mount configurations", + "items": { "$ref": "#/definitions/mountSpaDefinition" } + }, + "mount-static": { + "type": "array", + "description": "Static file mount configurations", + "items": { "$ref": "#/definitions/mountStaticDefinition" } + } + }, + "additionalProperties": false + }, + "dbPoolConfig": { + "type": "object", + "description": "Database pool configuration", + "properties": { + "dsn": { + "type": "string", + "description": "PostgreSQL connection string (DSN)" + }, + "host": { + "type": "string", + "description": "Database host" + }, + "port": { + "type": "integer", + "description": "Database port", + "default": 5432, + "minimum": 1, + "maximum": 65535 + }, + "database": { + "type": "string", + "description": "Database name" + }, + "username": { + "type": "string", + "description": "Database username" + }, + "password": { + "type": "string", + "description": "Database password" + }, + "schema": { + "type": "string", + "description": "Database schema", + "default": "public" + }, + "min-conns": { + "type": "integer", + "description": "Minimum number of connections in the pool", + "default": 2, + "minimum": 0 + }, + "max-conns": { + "type": "integer", + "description": "Maximum number of connections in the pool", + "default": 10, + "minimum": 1 + }, + "max-idle-time": { + "type": "string", + "description": "Maximum idle time for connections (duration string, e.g., '30m')", + "default": "30m", + "pattern": "^[0-9]+(ns|us|µs|ms|s|m|h)$" + }, + "max-lifetime": { + "type": "string", + "description": "Maximum lifetime for connections (duration string, e.g., '1h')", + "default": "1h", + "pattern": "^[0-9]+(ns|us|µs|ms|s|m|h)$" + }, + "sslmode": { + "type": "string", + "description": "SSL mode for connection", + "enum": ["disable", "allow", "prefer", "require", "verify-ca", "verify-full"], + "default": "disable" + } + }, + "oneOf": [ + { "required": ["dsn"] }, + { "required": ["host", "database"] } + ], + "additionalProperties": false + } + }, "properties": { "configs": { "type": "object", "description": "Global configuration definitions", "patternProperties": { "^[A-Z][A-Z0-9_]*$": { - "description": "Configuration value (string, number, boolean, or object)", "oneOf": [ { "type": "string" }, { "type": "number" }, @@ -21,211 +314,66 @@ } } }, - "middleware-definitions": { + "named-db-pools": { "type": "object", - "description": "Middleware definitions with types and configurations", + "description": "Named database pool configurations", "patternProperties": { - "^[a-z][a-z0-9-]*$": { - "type": "object", - "required": ["type"], - "properties": { - "type": { - "type": "string", - "description": "Middleware factory type identifier", - "pattern": "^[a-z][a-z0-9_-]*$" - }, - "config": { - "type": "object", - "description": "Middleware-specific configuration", - "additionalProperties": true - } - }, - "additionalProperties": false - } + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/dbPoolConfig" } } }, - "service-definitions": { + "middleware-definitions": { "type": "object", - "description": "Service definitions", + "description": "Middleware definitions", "patternProperties": { - "^[a-z][a-z0-9-]*$": { - "type": "object", - "required": ["type"], - "properties": { - "type": { - "type": "string", - "description": "Service factory type identifier", - "pattern": "^[a-z][a-z0-9-]*$" - }, - "depends-on": { - "type": "array", - "description": "List of service dependencies", - "items": { - "type": "string", - "description": "Dependency in format 'paramName:serviceName' or just 'serviceName'", - "pattern": "^([a-zA-Z][a-zA-Z0-9]*:)?[a-z][a-z0-9-]*$" - } - }, - "config": { - "type": "object", - "description": "Service-specific configuration", - "additionalProperties": true - } - }, - "additionalProperties": false - } + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/middlewareDefinition" } } }, - "router-definitions": { + "service-definitions": { "type": "object", - "description": "Router definitions (auto-generated from services)", + "description": "Service definitions", "patternProperties": { - "^[a-z][a-z0-9-]*$": { - "type": "object", - "required": ["service"], - "properties": { - "service": { - "type": "string", - "description": "Service name to create router from", - "pattern": "^[a-z][a-z0-9-]*$" - }, - "overrides": { - "type": "string", - "description": "Reference to router-overrides definition name", - "pattern": "^[a-z][a-z0-9-]*$" - } - }, - "additionalProperties": false - } + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/serviceDefinition" } } }, - "router-overrides": { + "router-definitions": { "type": "object", - "description": "Router override definitions", + "description": "Router definitions", "patternProperties": { - "^[a-z][a-z0-9-]*$": { - "type": "object", - "properties": { - "path-prefix": { - "type": "string", - "description": "Path prefix for all routes (e.g., '/api/v1')" - }, - "middlewares": { - "type": "array", - "description": "Router-level middleware names", - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" - } - }, - "hidden": { - "type": "array", - "description": "Method names to hide from router", - "items": { - "type": "string", - "pattern": "^[A-Z][a-zA-Z0-9]*$" - } - }, - "custom": { - "type": "array", - "description": "Custom route definitions", - "items": { - "type": "object", - "required": ["name"], - "properties": { - "name": { - "type": "string", - "description": "Method name", - "pattern": "^[A-Z][a-zA-Z0-9]*$" - }, - "method": { - "type": "string", - "description": "HTTP method override (GET, POST, PUT, DELETE, PATCH)", - "enum": ["GET", "POST", "PUT", "DELETE", "PATCH"] - }, - "path": { - "type": "string", - "description": "Path override (e.g., '/custom/{id}')" - }, - "middlewares": { - "type": "array", - "description": "Route-specific middleware names", - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" - } - } - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - } + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/routerDefinition" } } }, - "external-service-definitions": { + "deployments": { "type": "object", - "description": "External service definitions (services outside this deployment). For external services, you typically need to override everything since their API structure may differ from internal conventions.", + "description": "Deployment configurations", "patternProperties": { "^[a-z][a-z0-9-]*$": { "type": "object", - "required": ["url"], "properties": { - "url": { - "type": "string", - "description": "Base URL of external service (supports resolver: ${VAR:default} or direct URL)", - "oneOf": [ - { "pattern": "^https?://" }, - { "pattern": "^\\$\\{.*\\}$" } - ] - }, - "type": { - "type": "string", - "description": "Factory type (optional) - Auto-creates service wrapper. Required if external service will be used in published-services for auto-router generation. Not needed if only accessed via GetRemoteService.", - "pattern": "^[a-z][a-z0-9-]*$" - }, - "config": { + "config-overrides": { "type": "object", - "description": "Additional configuration for the factory (optional)", + "description": "Configuration overrides", "additionalProperties": true }, - "resource": { - "type": "string", - "description": "Resource name (singular) - Override of RegisterServiceType metadata", - "pattern": "^[a-z][a-z-]*$" - }, - "resource-plural": { - "type": "string", - "description": "Plural form of resource name - Override", - "pattern": "^[a-z][a-z-]*$" + "middleware-definitions": { + "type": "object", + "description": "Deployment-level middleware definitions", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/middlewareDefinition" } + } }, - "convention": { - "type": "string", - "description": "Convention type (rest, rpc, graphql) - Override of RegisterServiceType metadata", - "pattern": "^[a-z][a-z-]*$" + "service-definitions": { + "type": "object", + "description": "Deployment-level service definitions", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/serviceDefinition" } + } }, - "overrides": { - "type": "string", - "description": "Reference to router-overrides definition for full path customization", - "pattern": "^[a-z][a-z0-9-]*$" - } - }, - "additionalProperties": false - } - } - }, - "deployments": { - "type": "object", - "description": "Deployment configurations", - "patternProperties": { - "^[a-z][a-z0-9-]*$": { - "type": "object", - "properties": { - "config-overrides": { + "router-definitions": { "type": "object", - "description": "Configuration overrides for this deployment", - "additionalProperties": true + "description": "Deployment-level router definitions", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/routerDefinition" } + } }, "servers": { "type": "object", @@ -237,50 +385,41 @@ "properties": { "base-url": { "type": "string", - "description": "Server base URL (supports resolver: ${VAR:default} or direct URL)", + "description": "Server base URL", "oneOf": [ { "pattern": "^https?://" }, { "pattern": "^\\$\\{[^}]+\\}$" } ] }, + "middleware-definitions": { + "type": "object", + "description": "Server-level middleware definitions", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/middlewareDefinition" } + } + }, + "service-definitions": { + "type": "object", + "description": "Server-level service definitions", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/serviceDefinition" } + } + }, + "router-definitions": { + "type": "object", + "description": "Server-level router definitions", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/routerDefinition" } + } + }, "apps": { "type": "array", - "description": "Applications on this server (optional if using shorthand fields)", - "items": { - "type": "object", - "required": ["addr"], - "properties": { - "addr": { - "type": "string", - "description": "Application address (supports resolver: ${VAR:default} or direct address like ':8080', '127.0.0.1:8080', 'unix:/tmp/app.sock')", - "oneOf": [ - { "pattern": "^(:[0-9]+|[0-9.]+:[0-9]+|unix:.+)$" }, - { "pattern": "^\\$\\{.*\\}$" } - ] - }, - "routers": { - "type": "array", - "description": "Routers to include in this app", - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" - } - }, - "published-services": { - "type": "array", - "description": "Services to auto-generate routers for", - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" - } - } - }, - "additionalProperties": false - } + "description": "Applications on this server", + "items": { "$ref": "#/definitions/appDefinition" } }, "addr": { "type": "string", - "description": "Shorthand: Application address (supports resolver: ${VAR:default} or direct address)", + "description": "Shorthand: Application address", "oneOf": [ { "pattern": "^(:[0-9]+|[0-9.]+:[0-9]+|unix:.+)$" }, { "pattern": "^\\$\\{.*\\}$" } @@ -288,18 +427,18 @@ }, "routers": { "type": "array", - "description": "Shorthand: Routers for the shorthand app", + "description": "Shorthand: Routers", "items": { "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" + "pattern": "^[a-z][a-z0-9.-]*$" } }, "published-services": { "type": "array", - "description": "Shorthand: Services to auto-generate routers for in the shorthand app", + "description": "Shorthand: Services to auto-generate routers for", "items": { "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" + "pattern": "^[a-z][a-z0-9.-]*$" } } }, diff --git a/lokstra.go b/lokstra.go index 7595de2c..b0012bdb 100644 --- a/lokstra.go +++ b/lokstra.go @@ -3,6 +3,7 @@ package lokstra import ( "github.com/primadi/lokstra/api_client" "github.com/primadi/lokstra/core/app" + "github.com/primadi/lokstra/core/deploy" "github.com/primadi/lokstra/core/request" "github.com/primadi/lokstra/core/router" "github.com/primadi/lokstra/core/server" @@ -42,3 +43,15 @@ func FetchAndCast[T any](client *api_client.ClientRouter, path string, opts ...api_client.FetchOption) (T, error) { return api_client.FetchAndCast[T](client, path, opts...) } + +type LogLevel = deploy.LogLevel + +const LogLevelDebug = deploy.LogLevelDebug +const LogLevelInfo = deploy.LogLevelInfo +const LogLevelWarn = deploy.LogLevelWarn +const LogLevelError = deploy.LogLevelError +const LogLevelSilent = deploy.LogLevelSilent + +func SetLogLevel(level LogLevel) { + deploy.SetLogLevel(level) +} diff --git a/lokstra_registry/deployment.go b/lokstra_registry/deployment.go index b9c32daf..1886625b 100644 --- a/lokstra_registry/deployment.go +++ b/lokstra_registry/deployment.go @@ -30,7 +30,14 @@ func getFirstServerCompositeKey() string { // SetCurrentServer sets the current server using composite key: "deploymentName.serverName" // If compositeKey is empty, it will automatically use the first deployment and server available -// Example: SetCurrentServer("order-service.order-api") +// +// Shorthand support: If compositeKey has no dot (e.g., "api"), it's treated as "default.api" +// This works when using top-level 'servers' in config.yaml instead of 'deployments' +// +// Examples: +// - SetCurrentServer("order-service.order-api") - explicit deployment.server +// - SetCurrentServer("api") - shorthand for "default.api" +// - SetCurrentServer("") - auto-select first available server func SetCurrentServer(compositeKey string) error { // If compositeKey is empty, get the first deployment and server if compositeKey == "" { @@ -42,9 +49,14 @@ func SetCurrentServer(compositeKey string) error { log.Printf("🎯 Auto-selected first server: %s", compositeKey) } + // Shorthand support: "api" → "default.api" parts := strings.Split(compositeKey, ".") - if len(parts) != 2 { - return fmt.Errorf("invalid server key format, expected 'deployment.server', got: %s", compositeKey) + if len(parts) == 1 { + // No dot - assume shorthand for "default.{serverName}" + compositeKey = "default." + compositeKey + log.Printf("🎯 Using shorthand: %s", compositeKey) + } else if len(parts) != 2 { + return fmt.Errorf("invalid server key format, expected 'deployment.server' or 'server', got: %s", compositeKey) } // Validate that server topology exists in Global registry @@ -157,13 +169,16 @@ func RunCurrentServer(timeout time.Duration) error { return fmt.Errorf("server topology '%s' not found in global registry", currentCompositeKey) } + // Extract deployment and server names from composite key + deploymentName := GetCurrentDeploymentName() + serverName := GetCurrentServerName() + + // NOTE: Config overrides already applied in LoadConfig and stored via flattenAndStoreConfigs + // No need to apply again here - this was redundant + // Get original config for inline definitions normalization config := registry.GetDeployConfig() if config != nil { - // Extract deployment and server names from composite key - deploymentName := GetCurrentDeploymentName() - serverName := GetCurrentServerName() - // Perform lazy normalization of inline definitions for this server only // This updates the config structure (moves inline definitions to global with normalized names) err := loader.NormalizeInlineDefinitionsForServer(config, deploymentName, serverName) @@ -181,13 +196,6 @@ func RunCurrentServer(timeout time.Duration) error { log.Printf("📝 Normalized and registered definitions for server %s.%s", deploymentName, serverName) } - // NOTE: registerLazyServicesForServer is NO LONGER NEEDED - // All service registration (including remote/local logic) is now handled in RegisterDefinitionsForRuntime - - // Extract deployment and server names for handler configurations - deploymentName := GetCurrentDeploymentName() - serverName := GetCurrentServerName() - // Get apps from topology if len(serverTopo.Apps) == 0 { return fmt.Errorf("server '%s' has no apps configured", serverName) diff --git a/lokstra_registry/deployment_config.go b/lokstra_registry/deployment_config.go index f65c2a2e..796e12f4 100644 --- a/lokstra_registry/deployment_config.go +++ b/lokstra_registry/deployment_config.go @@ -28,8 +28,9 @@ func (d *DeploymentConfig) GetServers() map[string]deploy.ServerConfig { // ServerConfig defines a server in a deployment type ServerConfig struct { - BaseURL string - Apps []*AppConfig + BaseURL string + ConfigOverrides map[string]any // Server-level config overrides + Apps []*AppConfig // Shorthand: If only one app, you can define it directly here // If set, a new app will be created and prepended to Apps array @@ -43,6 +44,14 @@ func (s *ServerConfig) GetBaseURL() string { return s.BaseURL } +// GetConfigOverrides implements deploy.ServerConfig interface +func (s *ServerConfig) GetConfigOverrides() map[string]any { + if s.ConfigOverrides == nil { + return make(map[string]any) + } + return s.ConfigOverrides +} + // GetApps implements deploy.ServerConfig interface func (s *ServerConfig) GetApps() []deploy.AppConfig { result := make([]deploy.AppConfig, len(s.Apps)) diff --git a/lokstra_registry/helper.go b/lokstra_registry/helper.go index 7207f680..f124aac5 100644 --- a/lokstra_registry/helper.go +++ b/lokstra_registry/helper.go @@ -126,6 +126,22 @@ func LoadConfigFromFolder(configFolder string) error { return LoadConfig(files...) } +// SetupNamedDbPools sets up database pools from loaded config. +// Must be called AFTER LoadConfig() if you use named-db-pools in config. +// Call this explicitly only if you need DB pools. +// +// Example: +// +// if err := lokstra_registry.LoadConfig("config.yaml"); err != nil { +// log.Fatal(err) +// } +// if err := lokstra_registry.SetupNamedDbPools(); err != nil { +// log.Fatal(err) +// } +func SetupNamedDbPools() error { + return loader.SetupNamedDbPools() +} + // InitAndRunServer initializes and runs the server based on loaded config. // Must be called after LoadConfig() and service/middleware registration. // diff --git a/lokstra_registry/registry.go b/lokstra_registry/registry.go index e613fedf..30268329 100644 --- a/lokstra_registry/registry.go +++ b/lokstra_registry/registry.go @@ -158,6 +158,20 @@ func RegisterRouter(name string, r router.Router) { deploy.Global().RegisterRouter(name, r) } +// RegisterRouterFactory registers a lazy router factory that will be instantiated +// when the runtime is ready (after all services are resolved). +// This allows router registration to depend on services that need runtime resolution. +// +// Example: +// +// lokstra_registry.RegisterRouterFactory("email-router", func() lokstra.Router { +// emailService := lokstra_registry.GetService[EmailService]("email-api-service") +// return emailService.GetRouter() +// }) +func RegisterRouterFactory(name string, factory func() router.Router) { + deploy.Global().RegisterRouterFactory(name, factory) +} + // GetRouter retrieves a router instance from the runtime registry func GetRouter(name string) router.Router { return deploy.Global().GetRouter(name) diff --git a/project_templates/02_app_framework/03_enterprise_router_service/config/deployment.yaml b/project_templates/02_app_framework/03_enterprise_router_service/config/deployment.yaml index 94c0fc2f..1a078afb 100644 --- a/project_templates/02_app_framework/03_enterprise_router_service/config/deployment.yaml +++ b/project_templates/02_app_framework/03_enterprise_router_service/config/deployment.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=https://primadi.github.io/lokstra/schema/lokstra.schema.json configs: - server: ${SERVER} + server: ${SERVER:development.api-server} store: order-repository: order-repository diff --git a/project_templates/02_app_framework/03_enterprise_router_service/main.go b/project_templates/02_app_framework/03_enterprise_router_service/main.go index 5fec3423..ca3098f1 100644 --- a/project_templates/02_app_framework/03_enterprise_router_service/main.go +++ b/project_templates/02_app_framework/03_enterprise_router_service/main.go @@ -12,6 +12,8 @@ import ( // This flow separates config loading from service registration, // allowing services to access config during registration. func main() { + // lokstra.SetLogLevel(lokstra.LogLevelDebug) + lokstra.Bootstrap() fmt.Println("") diff --git a/project_templates/02_app_framework/03_enterprise_router_service/modules/order/application/zz_cache.lokstra.json b/project_templates/02_app_framework/03_enterprise_router_service/modules/order/application/zz_cache.lokstra.json index c7958781..a77afe86 100644 --- a/project_templates/02_app_framework/03_enterprise_router_service/modules/order/application/zz_cache.lokstra.json +++ b/project_templates/02_app_framework/03_enterprise_router_service/modules/order/application/zz_cache.lokstra.json @@ -5,13 +5,13 @@ "filename": "order_service.go", "checksum": "0e9de2b32a33d7477e9d083b5474ef63469da374814b2ba6c6e0a0b5f67eee47", "annotations": 9, - "last_scan": "2025-12-08T17:00:13.3787891+07:00", + "last_scan": "2025-12-10T01:55:27.3979337+07:00", "generated": [ "zz_generated.lokstra.go" ], - "generated_mod_time": "2025-12-08T17:00:13.3787891+07:00" + "generated_mod_time": "2025-12-10T01:55:27.3979337+07:00" } }, - "updated_at": "2025-12-08T17:03:03.6111408+07:00", + "updated_at": "2025-12-10T02:20:52.4570006+07:00", "generated_checksum": "3ae8ac2a982d5877f5c7da1f9297cf5364ea09253728c7980f9c8cc42f1415b0" } \ No newline at end of file diff --git a/project_templates/02_app_framework/03_enterprise_router_service/modules/user/application/zz_cache.lokstra.json b/project_templates/02_app_framework/03_enterprise_router_service/modules/user/application/zz_cache.lokstra.json index 9b65763f..d41af70a 100644 --- a/project_templates/02_app_framework/03_enterprise_router_service/modules/user/application/zz_cache.lokstra.json +++ b/project_templates/02_app_framework/03_enterprise_router_service/modules/user/application/zz_cache.lokstra.json @@ -5,13 +5,13 @@ "filename": "user_service.go", "checksum": "05c3a8ec83619efd7c01f0efc3bc3d324063658f7ef8eba9a23021aec2b9e212", "annotations": 9, - "last_scan": "2025-12-08T17:00:13.3551821+07:00", + "last_scan": "2025-12-10T01:55:27.3794788+07:00", "generated": [ "zz_generated.lokstra.go" ], - "generated_mod_time": "2025-12-08T17:00:13.3551821+07:00" + "generated_mod_time": "2025-12-10T01:55:27.3794788+07:00" } }, - "updated_at": "2025-12-08T17:03:03.6111408+07:00", + "updated_at": "2025-12-10T02:20:52.4570006+07:00", "generated_checksum": "973b227564b3985a83fcf5c230f0ce4ce737c7bc4a83b195d4449ddb14021d30" } \ No newline at end of file diff --git a/services/email_smtp/_example/README.md b/services/email_smtp/example/README.md similarity index 52% rename from services/email_smtp/_example/README.md rename to services/email_smtp/example/README.md index 742a3cb2..dfe17386 100644 --- a/services/email_smtp/_example/README.md +++ b/services/email_smtp/example/README.md @@ -1,6 +1,103 @@ # Email SMTP Service - Standalone Example -This folder contains a complete standalone example demonstrating the Email SMTP service. +This example demonstrates how to use the Email SMTP service in a **standalone** application (without full deployment framework). + +## Key Differences: Standalone vs Framework Mode + +### Framework Mode (Production) + +```go +func main() { + lokstra.Bootstrap() + + // Load config + lokstra.LoadConfig("config.yaml") + + // Services auto-registered when server runs + lokstra_registry.InitAndRunServer() +} +``` + +**Config (config.yaml):** +```yaml +configs: + server: development.api + +service-definitions: + email_sender: + type: email-smtp + config: + host: smtp.example.com + # ... + +deployments: + development: + servers: + api: + addr: ":8080" + published-services: [email_sender] # Auto-registered! +``` + +### Framework Mode (This Example - Recommended) + +```go +func main() { + lokstra.Bootstrap() + + // Register service types + email_smtp.Register() + lokstra_registry.RegisterServiceType("email-api-service", EmailAPIServiceFactory) + + // Load config and run (auto-registers services from deployments) + lokstra.LoadConfigFromFolder("configs") + lokstra_registry.InitAndRunServer() +} +``` + +**Config (configs/email_smtp.yaml):** +```yaml +configs: + server: development.api + +service-definitions: + email-sender: # Service instance name + type: email-smtp # Service type (from email_smtp.Register()) + config: + host: smtp.example.com + # ... + + email-api-service: # Router service + type: email-api-service + depends-on: + - EmailSender:email-sender # Inject email-sender as EmailSender field + +deployments: + development: + servers: + api: + addr: ":8080" + routers: [email-router] + published-services: [email-sender, email-api-service] # Auto-registered! +``` + +**How it works:** +1. ✅ `LoadConfig()` loads YAML and stores service definitions +2. ✅ `InitAndRunServer()` reads `deployments.development.api.published-services` +3. ✅ Auto-registers `email-sender` and `email-api-service` with dependency injection +4. ✅ Mounts `email-router` to server +5. ✅ Server starts on `:8080` + +## Why Manual Registration? + +**Important:** `LoadConfig()` **DOES NOT** automatically register services. It only: +1. ✅ Loads YAML files +2. ✅ Stores config values +3. ✅ Stores service **definitions** (metadata) +4. ❌ Does NOT instantiate services + +**Service registration happens during `RunServer()`** when deployment topology is analyzed. + +For standalone apps (no deployment), you must **manually register** services. ## Quick Start diff --git a/services/email_smtp/_example/TEST_COMMANDS.md b/services/email_smtp/example/TEST_COMMANDS.md similarity index 100% rename from services/email_smtp/_example/TEST_COMMANDS.md rename to services/email_smtp/example/TEST_COMMANDS.md diff --git a/services/email_smtp/example/configs/email_smtp.yaml b/services/email_smtp/example/configs/email_smtp.yaml new file mode 100644 index 00000000..1e6d9b00 --- /dev/null +++ b/services/email_smtp/example/configs/email_smtp.yaml @@ -0,0 +1,51 @@ +# yaml-language-server: $schema=https://primadi.github.io/lokstra/schema/lokstra.schema.json + +# using https://ethereal.email/messages + +configs: + server: ${SERVER:api} # Shorthand: 'api' = 'default.api' + shutdown_timeout: 30s # Graceful shutdown timeout + email_smtp: + host: ${EMAIL_HOST:smtp.ethereal.email} # SMTP server host + port: ${EMAIL_PORT:587} # SMTP server port + username: ${EMAIL_USERNAME:friedrich.greenholt@ethereal.email} # SMTP username + password: ${EMAIL_PASSWORD:1PGbyeXyvEEFUMAqhs} # SMTP password + from_email: ${EMAIL_FROM_ADDRESS:admin@example.com} # Default from email + from_name: ${EMAIL_FROM_NAME:no-reply} # Default from name + +service-definitions: + email_smtp: + type: email_smtp + config: + host: ${@cfg:email_smtp.host} # Reference from configs! + port: ${@cfg:email_smtp.port} + username: ${@cfg:email_smtp.username} + password: ${@cfg:email_smtp.password} + from_email: ${@cfg:email_smtp.from_email} + from_name: ${@cfg:email_smtp.from_name} + # host: ${EMAIL_HOST:smtp.ethereal.email} # SMTP server host + # port: ${EMAIL_PORT:587} # SMTP server port + # username: ${EMAIL_USERNAME:friedrich.greenholt@ethereal.email} # SMTP username + # password: ${EMAIL_PASSWORD:1PGbyeXyvEEFUMAqhs} # SMTP password + # from_email: ${EMAIL_FROM_ADDRESS:admin@example.com} # Default from email + # from_name: ${EMAIL_FROM_NAME:no-reply} # Default from name + + use_tls: ${EMAIL_USE_TLS:false} # Use TLS encryption + skip_verify: ${EMAIL_SKIP_VERIFY:false} # Skip TLS certificate verification + use_starttls: ${EMAIL_USE_STARTTLS:false} # Use STARTTLS + auth_method: ${EMAIL_AUTH_METHOD:plain} # Auth method: "plain", "login", "crammd5" + pool_size: ${EMAIL_POOL_SIZE:5} # Connection pool size + max_batch_size: ${EMAIL_MAX_BATCH_SIZE:50} # Maximum batch size + + +router-definitions: + email-router: + path-prefix: /api + +# Shorthand: top-level servers (auto-creates 'default' deployment) +# No need to write deployments.development.servers when you only have one deployment +servers: + api: + base-url: http://localhost + addr: ":9090" + routers: [email-router] \ No newline at end of file diff --git a/services/email_smtp/_example/example_standalone.go b/services/email_smtp/example/email_service.go similarity index 61% rename from services/email_smtp/_example/example_standalone.go rename to services/email_smtp/example/email_service.go index 27c947cc..6d3375e6 100644 --- a/services/email_smtp/_example/example_standalone.go +++ b/services/email_smtp/example/email_service.go @@ -1,20 +1,23 @@ package main -// This is a standalone example showing how to use the email SMTP service -// Run with: go run example_standalone.go -// Make sure MailHog is running on localhost:1025 for testing - import ( "context" "fmt" - "time" + "net/http" "github.com/primadi/lokstra" "github.com/primadi/lokstra/core/request" + "github.com/primadi/lokstra/lokstra_registry" "github.com/primadi/lokstra/serviceapi" - "github.com/primadi/lokstra/services/email_smtp" ) +// EmailService provides email API endpoints +// @Service "email-api-service" +type EmailService struct { + // @Inject "email_smtp" + EmailSender serviceapi.EmailSender +} + type SendEmailRequest struct { To string `json:"to" validate:"required,email"` Subject string `json:"subject" validate:"required"` @@ -22,28 +25,20 @@ type SendEmailRequest struct { IsHTML bool `json:"is_html"` } -type SendBatchRequest struct { - Emails []struct { - To string `json:"to" validate:"required,email"` - Subject string `json:"subject" validate:"required"` - Message string `json:"message" validate:"required"` - } `json:"emails" validate:"required,min=1"` +type BatchEmailItem struct { + To string `json:"to" validate:"required,email"` + Subject string `json:"subject" validate:"required"` + Message string `json:"message" validate:"required"` + IsHTML bool `json:"is_html"` } -func main() { - fmt.Println("Starting Email SMTP Service Example...") - - // Create email sender service - // For testing, use MailHog: docker run -p 1025:1025 -p 8025:8025 mailhog/mailhog - emailSender := email_smtp.Service(&email_smtp.Config{ - Host: "localhost", - Port: 1025, // MailHog SMTP port - FromEmail: "noreply@example.com", - FromName: "Email Service Demo", - }) +type SendBatchRequest struct { + Emails []BatchEmailItem `json:"emails" validate:"required,min=1,dive"` +} - // Create router - r := lokstra.NewRouter("api") +// GetRouter returns router with all email endpoints +func (s *EmailService) GetRouter() lokstra.Router { + r := lokstra.NewRouter("email-api") // Endpoint: Send single email r.POST("/send-email", func(ctx *request.Context, req *SendEmailRequest) error { @@ -58,9 +53,9 @@ func main() { msg.Body = req.Message } - err := emailSender.Send(context.Background(), msg) + err := s.EmailSender.Send(context.Background(), msg) if err != nil { - return ctx.Api.InternalServerError(err.Error()) + return ctx.Api.InternalError(err.Error()) } return ctx.Api.Ok(map[string]string{ @@ -115,9 +110,9 @@ func main() { HTMLBody: htmlBody, } - err := emailSender.Send(context.Background(), msg) + err := s.EmailSender.Send(context.Background(), msg) if err != nil { - return ctx.Api.InternalServerError(err.Error()) + return ctx.Api.InternalError(err.Error()) } return ctx.Api.Ok(map[string]any{ @@ -143,9 +138,9 @@ func main() { Body: req.Message, } - err := emailSender.Send(context.Background(), msg) + err := s.EmailSender.Send(context.Background(), msg) if err != nil { - return ctx.Api.InternalServerError(err.Error()) + return ctx.Api.InternalError(err.Error()) } return ctx.Api.Ok(map[string]any{ @@ -157,26 +152,40 @@ func main() { }) }) - // Endpoint: Send batch emails + // Endpoint: Send batch emails (with goroutine for better performance) r.POST("/send-batch", func(ctx *request.Context, req *SendBatchRequest) error { - messages := make([]*serviceapi.EmailMessage, len(req.Emails)) - for i, email := range req.Emails { - messages[i] = &serviceapi.EmailMessage{ - To: []string{email.To}, - Subject: email.Subject, - Body: email.Message, + total := len(req.Emails) + + // Send batch emails in goroutine for faster response + go func() { + messages := make([]*serviceapi.EmailMessage, total) + for i, email := range req.Emails { + msg := &serviceapi.EmailMessage{ + To: []string{email.To}, + Subject: email.Subject, + } + + if email.IsHTML { + msg.HTMLBody = email.Message + } else { + msg.Body = email.Message + } + + messages[i] = msg } - } - err := emailSender.SendBatch(context.Background(), messages) - if err != nil { - return ctx.Api.InternalServerError(err.Error()) - } + if err := s.EmailSender.SendBatch(context.Background(), messages); err != nil { + fmt.Printf("❌ Batch email failed: %v\n", err) + } else { + fmt.Printf("✅ Successfully sent %d batch emails\n", total) + } + }() - return ctx.Api.Ok(map[string]any{ - "status": "success", - "message": "Batch emails sent successfully", - "total_sent": len(messages), + // Return 202 Accepted for async operation + return ctx.Resp.WithStatus(http.StatusAccepted).Json(map[string]any{ + "status": "queued", + "message": fmt.Sprintf("%d emails queued for sending", total), + "total": total, }) }) @@ -202,9 +211,9 @@ func main() { }, } - err := emailSender.Send(context.Background(), msg) + err := s.EmailSender.Send(context.Background(), msg) if err != nil { - return ctx.Api.InternalServerError(err.Error()) + return ctx.Api.InternalError(err.Error()) } return ctx.Api.Ok(map[string]any{ @@ -222,22 +231,12 @@ func main() { } }) - // Create and run app - app := lokstra.NewApp("email-demo", ":8080", r) - - fmt.Println("\n===========================================") - fmt.Println("Email SMTP Service is running on :8080") - fmt.Println("===========================================") - fmt.Println("\nAvailable endpoints:") - fmt.Println(" POST /api/send-email - Send simple email") - fmt.Println(" POST /api/send-welcome - Send welcome email (HTML)") - fmt.Println(" POST /api/send-with-cc - Send email with CC/BCC") - fmt.Println(" POST /api/send-batch - Send batch emails") - fmt.Println(" POST /api/send-with-attachment - Send email with attachment") - fmt.Println(" GET /api/health - Health check") - fmt.Println("\nNote: Make sure MailHog is running on localhost:1025") - fmt.Println("View emails at: http://localhost:8025") - fmt.Println("===========================================\n") - - app.Run(30 * time.Second) + r.GET("/info", func() map[string]string { + return map[string]string{ + "host": lokstra_registry.GetConfig("email_smtp.host", ""), + "version": "1.0.0", + } + }) + + return r } diff --git a/services/email_smtp/example/main.go b/services/email_smtp/example/main.go new file mode 100644 index 00000000..dd046ed7 --- /dev/null +++ b/services/email_smtp/example/main.go @@ -0,0 +1,50 @@ +package main + +// This is a standalone example showing how to use the email SMTP service +// Run with: go run example_standalone.go +// Make sure MailHog is running on localhost:1025 for testing + +import ( + "fmt" + "log" + + "github.com/primadi/lokstra" + "github.com/primadi/lokstra/lokstra_registry" + "github.com/primadi/lokstra/services/email_smtp" +) + +func main() { + // lokstra.SetLogLevel(lokstra.LogLevelDebug) + + lokstra.Bootstrap() + + fmt.Println("\n===========================================") + fmt.Println("Email SMTP Service Example") + fmt.Println("===========================================") + fmt.Println("\nAvailable endpoints:") + fmt.Println(" POST /api/send-email - Send simple email") + fmt.Println(" POST /api/send-welcome - Send welcome email (HTML)") + fmt.Println(" POST /api/send-with-cc - Send email with CC/BCC") + fmt.Println(" POST /api/send-batch - Send batch emails") + fmt.Println(" POST /api/send-with-attachment - Send email with attachment") + fmt.Println(" GET /api/health - Health check") + fmt.Println("===========================================") + + // Load config and run server (auto-registers services from deployments) + if err := lokstra.LoadConfigFromFolder("configs"); err != nil { + log.Fatal(err) + } + + // Register email_smtp service type + email_smtp.Register() + + // Register lazy router factory (will be instantiated after runtime resolution) + lokstra_registry.RegisterRouterFactory("email-router", func() lokstra.Router { + emailService := lokstra_registry.GetService[*EmailService]("email-api-service") + return emailService.GetRouter() + }) + + if err := lokstra_registry.InitAndRunServer(); err != nil { + log.Fatal(err) + } +} diff --git a/services/email_smtp/example/test.http b/services/email_smtp/example/test.http new file mode 100644 index 00000000..31509bfa --- /dev/null +++ b/services/email_smtp/example/test.http @@ -0,0 +1,138 @@ +### Email SMTP Service - HTTP Tests +### Base URL +@baseUrl = http://localhost:9090/api + +### 0. Info +GET {{baseUrl}}/info + +### 1. Health Check +GET {{baseUrl}}/health + +### 2. Send Simple Email +POST {{baseUrl}}/send-email +Content-Type: application/json + +{ + "to": "user@example.com", + "subject": "Test Email", + "message": "This is a test email sent from the Email SMTP Service.", + "is_html": false +} + +### 3. Send Simple HTML Email +POST {{baseUrl}}/send-email +Content-Type: application/json + +{ + "to": "user@example.com", + "subject": "Test HTML Email", + "message": "
This is a HTML email.
", + "is_html": true +} + +### 4. Send Welcome Email +POST {{baseUrl}}/send-welcome +Content-Type: application/json + +{ + "email": "newuser@example.com", + "name": "John Doe" +} + +### 5. Send Email with CC and BCC +POST {{baseUrl}}/send-with-cc +Content-Type: application/json + +{ + "to": ["recipient1@example.com", "recipient2@example.com"], + "cc": ["cc1@example.com", "cc2@example.com"], + "bcc": ["bcc1@example.com"], + "subject": "Email with CC and BCC", + "message": "This email is sent to multiple recipients with CC and BCC." +} + +### 6. Send Batch Emails +POST {{baseUrl}}/send-batch +Content-Type: application/json + +{ + "emails": [ + { + "to": "user1@example.com", + "subject": "Batch Email 1", + "message": "Hello User 1, this is your personalized message." + }, + { + "to": "user2@example.com", + "subject": "Batch Email 2", + "message": "Hello User 2, this is your personalized message." + }, + { + "to": "user3@example.com", + "subject": "Batch Email 3", + "message": "Hello User 3, this is your personalized message." + } + ] +} + +### 7. Send Email with Attachment +POST {{baseUrl}}/send-with-attachment +Content-Type: application/json + +{ + "to": "recipient@example.com", + "subject": "Email with Attachment", + "message": "Please find the attached file." +} + +### 8. Test Validation Error - Missing Required Field +POST {{baseUrl}}/send-email +Content-Type: application/json + +{ + "to": "user@example.com", + "subject": "Test" + // Missing "message" field +} + +### 9. Test Validation Error - Invalid Email +POST {{baseUrl}}/send-email +Content-Type: application/json + +{ + "to": "invalid-email", + "subject": "Test Email", + "message": "This should fail validation." +} + +### 10. Send Multiple Recipients (Plain Text) +POST {{baseUrl}}/send-with-cc +Content-Type: application/json + +{ + "to": ["team1@example.com", "team2@example.com", "team3@example.com"], + "subject": "Team Update", + "message": "Hi team,\n\nThis is an important update for everyone.\n\nBest regards,\nManagement" +} + +### 11. Send Newsletter Style Email +POST {{baseUrl}}/send-email +Content-Type: application/json + +{ + "to": "subscriber@example.com", + "subject": "Monthly Newsletter - December 2025", + "message": "Check out our latest updates!
Click the link below to reset your password:
Reset PasswordIf you didn't request this, please ignore this email.
", + "is_html": true +} diff --git a/services/email_smtp/example/zz_cache.lokstra.json b/services/email_smtp/example/zz_cache.lokstra.json new file mode 100644 index 00000000..2c3a72e6 --- /dev/null +++ b/services/email_smtp/example/zz_cache.lokstra.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "files": { + "email_service.go": { + "filename": "email_service.go", + "checksum": "716100fc8d6cb71638a21bd2f041dea435a0d2650524674615da6f48ada48d0a", + "annotations": 2, + "last_scan": "2025-12-10T00:57:39.636288+07:00", + "generated": [ + "zz_generated.lokstra.go" + ], + "generated_mod_time": "2025-12-10T00:57:39.636288+07:00" + } + }, + "updated_at": "2025-12-10T02:21:23.8105112+07:00", + "generated_checksum": "4c13f9c613fb22927021405ebde338c0196d30513178f207b9cd1edcc336cbb9" +} \ No newline at end of file diff --git a/services/email_smtp/example/zz_generated.lokstra.go b/services/email_smtp/example/zz_generated.lokstra.go new file mode 100644 index 00000000..20d3e4b2 --- /dev/null +++ b/services/email_smtp/example/zz_generated.lokstra.go @@ -0,0 +1,37 @@ +// AUTO-GENERATED CODE - DO NOT EDIT +// Generated by lokstra-annotation from annotations in this folder +// Annotations: @RouterService, @Service, @Inject, @InjectCfgValue, @Route + +package main + +import ( + "github.com/primadi/lokstra/lokstra_registry" + serviceapi "github.com/primadi/lokstra/serviceapi" +) + +// Auto-register on package import +func init() { + RegisterEmailService() +} + +// ============================================================ +// FILE: email_service.go +// ============================================================ + +// RegisterEmailService registers the email-api-service with the registry +// Auto-generated from annotations: +// - @Service name="email-api-service" +// - @Inject annotations +func RegisterEmailService() { + lokstra_registry.RegisterLazyService("email-api-service", func(deps map[string]any, cfg map[string]any) any { + svc := &EmailService{ + EmailSender: deps["email_smtp"].(serviceapi.EmailSender), + } + + return svc + }, map[string]any{ + "depends-on": []string{ "email_smtp", }, + }) +} + +