From a50ed989f40e8baacd5af554c73403a417a1a987 Mon Sep 17 00:00:00 2001 From: Primadi Setiawan Date: Sun, 7 Dec 2025 02:00:15 +0700 Subject: [PATCH] - add @Service annotation - add func Init() error as post creation of service --- core/annotation/codegen.go | 424 ++++++++++++-- core/annotation/complex_processor.go | 59 +- .../examples/annotations/README.md | 543 ++++++++++++++++++ .../examples/annotations/config.example.yaml | 92 +++ .../examples/annotations/domain.go | 26 + .../examples/annotations/init_example.go | 53 ++ .../annotations/mixed_services_example.go | 123 ++++ .../router_service_with_config_example.go | 225 ++++++++ .../annotations/router_with_init_example.go | 70 +++ .../examples/annotations/service_example.go | 89 +++ .../annotations/zz_cache.lokstra.json | 57 ++ .../annotations/zz_generated.lokstra.go | 473 +++++++++++++++ .../06-service-annotation.md | 425 ++++++++++++++ .../07-inject-annotation.md | 410 +++++++++++++ .../08-inject-cfg-annotation.md | 438 ++++++++++++++ docs/AI-AGENT-GUIDE.md | 96 +++- docs/QUICK-REFERENCE.md | 90 +++ go.mod | 8 +- go.sum | 16 +- 19 files changed, 3646 insertions(+), 71 deletions(-) create mode 100644 docs/00-introduction/examples/annotations/README.md create mode 100644 docs/00-introduction/examples/annotations/config.example.yaml create mode 100644 docs/00-introduction/examples/annotations/domain.go create mode 100644 docs/00-introduction/examples/annotations/init_example.go create mode 100644 docs/00-introduction/examples/annotations/mixed_services_example.go create mode 100644 docs/00-introduction/examples/annotations/router_service_with_config_example.go create mode 100644 docs/00-introduction/examples/annotations/router_with_init_example.go create mode 100644 docs/00-introduction/examples/annotations/service_example.go create mode 100644 docs/00-introduction/examples/annotations/zz_cache.lokstra.json create mode 100644 docs/00-introduction/examples/annotations/zz_generated.lokstra.go create mode 100644 docs/02-framework-guide/06-service-annotation.md create mode 100644 docs/02-framework-guide/07-inject-annotation.md create mode 100644 docs/02-framework-guide/08-inject-cfg-annotation.md diff --git a/core/annotation/codegen.go b/core/annotation/codegen.go index 5e7caf9d..adca6b58 100644 --- a/core/annotation/codegen.go +++ b/core/annotation/codegen.go @@ -196,44 +196,61 @@ func processFileForCodeGen(file *FileToProcess, ctx *RouterServiceContext) error return err } - // Find @RouterService annotations + // Find @RouterService and @Service annotations for _, ann := range file.Annotations { - if ann.Name != "RouterService" { + if ann.Name != "RouterService" && ann.Name != "Service" { continue } - // Read RouterService args - args, err := ann.ReadArgs("name", "prefix", "middlewares") - if err != nil { - return fmt.Errorf("@RouterService on line %d: %w", ann.Line, err) - } + isService := ann.Name == "Service" - serviceName, _ := args["name"].(string) - prefix, _ := args["prefix"].(string) - middlewares := extractStringArray(args["middlewares"]) + // Read common args + var serviceName string + var prefix string + var middlewares []string + + if isService { + // @Service only needs name + args, err := ann.ReadArgs("name") + if err != nil { + return fmt.Errorf("@Service on line %d: %w", ann.Line, err) + } + serviceName, _ = args["name"].(string) + } else { + // @RouterService needs name, prefix, middlewares + args, err := ann.ReadArgs("name", "prefix", "middlewares") + if err != nil { + return fmt.Errorf("@RouterService on line %d: %w", ann.Line, err) + } + serviceName, _ = args["name"].(string) + prefix, _ = args["prefix"].(string) + middlewares = extractStringArray(args["middlewares"]) + } if serviceName == "" { - return fmt.Errorf("@RouterService on line %d: 'name' is required", ann.Line) + return fmt.Errorf("@%s on line %d: 'name' is required", ann.Name, ann.Line) } - // VALIDATE: @RouterService must be placed above a struct declaration + // VALIDATE: must be placed above a struct declaration if !isStructDeclaration(astFile, ann.TargetName) { - return fmt.Errorf("@RouterService on line %d: must be placed directly above a struct declaration, found '%s' instead (file: %s)", - ann.Line, ann.TargetName, file.Filename) + return fmt.Errorf("@%s on line %d: must be placed directly above a struct declaration, found '%s' instead (file: %s)", + ann.Name, ann.Line, ann.TargetName, file.Filename) } // Create service generation entry service := &ServiceGeneration{ - ServiceName: serviceName, - Prefix: prefix, - Middlewares: middlewares, - Routes: make(map[string]string), - RouteMiddlewares: make(map[string][]string), - Methods: make(map[string]*MethodSignature), - Dependencies: make(map[string]*DependencyInfo), - Imports: make(map[string]string), - StructName: ann.TargetName, - SourceFile: file.Filename, + ServiceName: serviceName, + Prefix: prefix, + Middlewares: middlewares, + Routes: make(map[string]string), + RouteMiddlewares: make(map[string][]string), + Methods: make(map[string]*MethodSignature), + Dependencies: make(map[string]*DependencyInfo), + ConfigDependencies: make(map[string]*ConfigInfo), + Imports: make(map[string]string), + StructName: ann.TargetName, + SourceFile: file.Filename, + IsService: isService, } // Extract imports from source file @@ -261,6 +278,14 @@ func processFileForCodeGen(file *FileToProcess, ctx *RouterServiceContext) error return err } + // Find @InjectCfg annotations for config dependencies + if err := extractConfigDependencies(file, service); err != nil { + return err + } + + // Check if struct has Init() error method + checkInitMethod(file, service) + ctx.GeneratedCode.Services[serviceName] = service } @@ -495,8 +520,10 @@ func extractDependencies(file *FileToProcess, service *ServiceGeneration) error return err } - // Find struct fields - fieldTypes := make(map[string]string) // fieldName -> fieldType + // Find struct fields for THIS struct only + fieldTypes := make(map[string]string) // fieldName -> fieldType + structFieldNames := make(map[string]bool) // fieldName -> true (for THIS struct only) + for _, decl := range astFile.Decls { genDecl, ok := decl.(*ast.GenDecl) if !ok || genDecl.Tok != token.TYPE { @@ -522,18 +549,25 @@ func extractDependencies(file *FileToProcess, service *ServiceGeneration) error fieldName := field.Names[0].Name fieldType := exprToString(field.Type) fieldTypes[fieldName] = fieldType + structFieldNames[fieldName] = true } } } - // Now process @Inject annotations + // Now process @Inject annotations - ONLY for fields belonging to THIS struct for _, ann := range file.Annotations { if ann.Name != "Inject" && ann.Name != "inject" { continue } + // Skip if annotation target is not a field of THIS struct + if !structFieldNames[ann.TargetName] { + continue + } + + // Supported formats: // @Inject "user-repository" - // or @Inject service="user-repository" + // @Inject service="user-repository" args, err := ann.ReadArgs("service") if err != nil { return fmt.Errorf("@Inject on line %d: %w", ann.Line, err) @@ -559,6 +593,163 @@ func extractDependencies(file *FileToProcess, service *ServiceGeneration) error return nil } +// checkInitMethod checks if struct has Init() error method +func checkInitMethod(file *FileToProcess, service *ServiceGeneration) { + fset := token.NewFileSet() + astFile, err := parser.ParseFile(fset, file.FullPath, nil, parser.ParseComments) + if err != nil { + return + } + + // Look for method: func (receiver *StructName) Init() error + for _, decl := range astFile.Decls { + funcDecl, ok := decl.(*ast.FuncDecl) + if !ok || funcDecl.Recv == nil || len(funcDecl.Recv.List) == 0 { + continue + } + + // Check if method name is Init + if funcDecl.Name.Name != "Init" { + continue + } + + // Check receiver type + recvType := funcDecl.Recv.List[0].Type + var receiverName string + switch t := recvType.(type) { + case *ast.StarExpr: + if ident, ok := t.X.(*ast.Ident); ok { + receiverName = ident.Name + } + case *ast.Ident: + receiverName = t.Name + } + + if receiverName != service.StructName { + continue + } + + // Check signature: no params, returns error + if funcDecl.Type.Params.NumFields() != 0 { + continue + } + + if funcDecl.Type.Results == nil || funcDecl.Type.Results.NumFields() != 1 { + continue + } + + // Check return type is error + returnType := funcDecl.Type.Results.List[0].Type + if ident, ok := returnType.(*ast.Ident); ok && ident.Name == "error" { + service.HasInitMethod = true + return + } + } +} + +// extractConfigDependencies finds all @InjectCfg annotations and field info +func extractConfigDependencies(file *FileToProcess, service *ServiceGeneration) error { + // Parse file to get field types + fset := token.NewFileSet() + astFile, err := parser.ParseFile(fset, file.FullPath, nil, parser.ParseComments) + if err != nil { + return err + } + + // Find struct fields for THIS struct only + fieldTypes := make(map[string]string) // fieldName -> fieldType + structFieldNames := make(map[string]bool) // fieldName -> true (for THIS struct only) + + for _, decl := range astFile.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok || typeSpec.Name.Name != service.StructName { + continue + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + + // Extract field names and types + for _, field := range structType.Fields.List { + if len(field.Names) == 0 { + continue + } + fieldName := field.Names[0].Name + fieldType := exprToString(field.Type) + fieldTypes[fieldName] = fieldType + structFieldNames[fieldName] = true + } + } + } + + // Now process @InjectCfg annotations - ONLY for fields belonging to THIS struct + for _, ann := range file.Annotations { + if ann.Name != "InjectCfg" && ann.Name != "injectcfg" { + continue + } + + // Skip if annotation target is not a field of THIS struct + if !structFieldNames[ann.TargetName] { + continue + } + + // Supported formats: + // @InjectCfg "app.jwt-secret" + // @InjectCfg key="app.jwt-secret" + // @InjectCfg key="app.jwt-secret", default="secret" + // @InjectCfg "app.timeout", "30" (positional: key, default) + args, err := ann.ReadArgs("key", "default") + if err != nil { + return fmt.Errorf("@InjectCfg on line %d: %w", ann.Line, err) + } + + var configKey string + if key, ok := args["key"].(string); ok { + configKey = key + } + + // Parse default value (optional) - can be string, int, bool, or float + defaultValue := "" + if def, ok := args["default"]; ok && def != nil { + // Convert any type to string + switch v := def.(type) { + case string: + defaultValue = v + case int: + defaultValue = fmt.Sprintf("%d", v) + case bool: + defaultValue = fmt.Sprintf("%t", v) + case float64: + defaultValue = fmt.Sprintf("%g", v) + default: + defaultValue = fmt.Sprintf("%v", v) + } + } + + if configKey != "" && ann.TargetName != "" { + // ann.TargetName is field name + fieldType := fieldTypes[ann.TargetName] + + service.ConfigDependencies[configKey] = &ConfigInfo{ + ConfigKey: configKey, + FieldName: ann.TargetName, + FieldType: fieldType, + DefaultValue: defaultValue, + } + } + } + + return nil +} + // writeGenFile writes the zz_generated.lokstra.go file func writeGenFile(path string, ctx *RouterServiceContext, existingImports map[string]string) error { // Get package name from existing files @@ -605,6 +796,12 @@ func writeGenFile(path string, ctx *RouterServiceContext, existingImports map[st for _, dep := range service.Dependencies { collectPackagesFromType(dep.FieldType, usedPackages) } + // From config dependencies - check if time.Duration is used + for _, cfg := range service.ConfigDependencies { + if cfg.FieldType == "time.Duration" { + usedPackages["time"] = true + } + } } // Also collect packages from preserved sections (unchanged files) @@ -653,6 +850,11 @@ func writeGenFile(path string, ctx *RouterServiceContext, existingImports map[st } } + // Third, add "time" if time.Duration is used + if usedPackages["time"] { + allImports["time"] = "time" + } + // Generate code var buf bytes.Buffer if err := genTemplate.Execute(&buf, map[string]any{ @@ -795,6 +997,52 @@ func extractStringArray(val any) []string { return []string{} } +// convertDurationToGo converts duration string like "24h" to Go duration expression "24*time.Hour" +func convertDurationToGo(durationStr string) string { + if durationStr == "" { + return "0" + } + + // Parse duration value and unit + var value string + var unit string + + // Find where the number ends and unit begins + for i, ch := range durationStr { + if ch >= '0' && ch <= '9' || ch == '.' { + continue + } + value = durationStr[:i] + unit = durationStr[i:] + break + } + + if value == "" || unit == "" { + return "0" + } + + // Map unit to Go time constant + var goUnit string + switch unit { + case "ns": + goUnit = "time.Nanosecond" + case "us", "µs": + goUnit = "time.Microsecond" + case "ms": + goUnit = "time.Millisecond" + case "s": + goUnit = "time.Second" + case "m": + goUnit = "time.Minute" + case "h": + goUnit = "time.Hour" + default: + return "0" + } + + return fmt.Sprintf("%s*%s", value, goUnit) +} + // genTemplate is the template for zz_generated.lokstra.go var genTemplate = template.Must(template.New("gen").Funcs(template.FuncMap{ "hasReturnValue": hasReturnValue, @@ -804,6 +1052,39 @@ var genTemplate = template.Must(template.New("gen").Funcs(template.FuncMap{ "trimPrefix": strings.TrimPrefix, "trimSuffix": strings.TrimSuffix, "notEmpty": func(s string) bool { return strings.TrimSpace(s) != "" }, + + "getDefaultValue": func(fieldType, defaultValue string) string { + if defaultValue != "" { + // For string type, add quotes if not already quoted + if fieldType == "string" { + if !strings.HasPrefix(defaultValue, `"`) { + return fmt.Sprintf(`"%s"`, defaultValue) + } + return defaultValue + } + + // For duration type, parse and convert to proper Go syntax + if fieldType == "time.Duration" { + // defaultValue like "24h", "5m", "30s" + // Need to convert to Go duration expression + return convertDurationToGo(defaultValue) + } // For int/bool/float, return as-is + return defaultValue + } + // Generate type-specific zero values + switch fieldType { + case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64": + return "0" + case "bool": + return "false" + case "float32", "float64": + return "0.0" + case "time.Duration": + return "0" + default: + return `""` + } + }, "sortedKeys": func(m any) []string { keys := make([]string, 0) switch v := m.(type) { @@ -815,13 +1096,21 @@ var genTemplate = template.Must(template.New("gen").Funcs(template.FuncMap{ for k := range v { keys = append(keys, k) } + case map[string]*DependencyInfo: + for k := range v { + keys = append(keys, k) + } + case map[string]*ConfigInfo: + for k := range v { + keys = append(keys, k) + } } sort.Strings(keys) return keys }, }).Parse(`// AUTO-GENERATED CODE - DO NOT EDIT // Generated by lokstra-annotation from annotations in this folder -// Annotations: @RouterService, @Inject, @Route +// Annotations: @RouterService, @Service, @Inject, @InjectCfg, @Route package {{.Package}} @@ -844,7 +1133,50 @@ func init() { // ============================================================ // FILE: {{$service.SourceFile}} // ============================================================ - +{{if $service.IsService}} +// Register{{$service.StructName}} registers the {{$service.ServiceName}} with the registry +// Auto-generated from annotations: +// - @Service name={{quote $service.ServiceName}} +{{- if $service.Dependencies}} +// - @Inject annotations +{{- end}} +{{- if $service.ConfigDependencies}} +// - @InjectCfg annotations +{{- end}} +func Register{{$service.StructName}}() { + lokstra_registry.RegisterLazyService({{quote $service.ServiceName}}, func(deps map[string]any, cfg map[string]any) any { + svc := &{{$service.StructName}}{ +{{- range $key := sortedKeys $service.Dependencies }} +{{- $dep := index $service.Dependencies $key }} + {{$dep.FieldName}}: deps[{{quote $dep.ServiceName}}].({{$dep.FieldType}}), +{{- end }} +{{- range $key := sortedKeys $service.ConfigDependencies }} +{{- $cfg := index $service.ConfigDependencies $key }} + {{$cfg.FieldName}}: cfg[{{quote $cfg.ConfigKey}}].({{$cfg.FieldType}}), +{{- end }} + } +{{- if $service.HasInitMethod }} + + // Call Init() for post-initialization + if err := svc.Init(); err != nil { + panic("failed to initialize {{$service.ServiceName}}: " + err.Error()) + } +{{- end }} + + return svc + }, map[string]any{ +{{- if or $service.Dependencies $service.ConfigDependencies }} +{{- if $service.Dependencies }} + "depends-on": []string{ {{range $key := sortedKeys $service.Dependencies}}{{$dep := index $service.Dependencies $key}}{{quote $dep.ServiceName}}, {{end}}}, +{{- end }} +{{- range $key := sortedKeys $service.ConfigDependencies }} +{{- $cfg := index $service.ConfigDependencies $key }} + {{quote $cfg.ConfigKey}}: lokstra_registry.GetConfig({{quote $cfg.ConfigKey}}, {{getDefaultValue $cfg.FieldType $cfg.DefaultValue}}), +{{- end }} +{{- end }} + }) +} +{{else}} // {{$service.RemoteTypeName}} implements {{$service.InterfaceName}} with HTTP proxy // Auto-generated from {{$service.StructName}} interface methods type {{$service.RemoteTypeName}} struct { @@ -878,11 +1210,25 @@ func New{{$service.RemoteTypeName}}(proxyService *proxy.Service) *{{$service.Rem {{- end}} {{end}} func {{$service.StructName}}Factory(deps map[string]any, config map[string]any) any { - return &{{$service.StructName}}{ -{{- range $svcName, $dep := $service.Dependencies }} + svc := &{{$service.StructName}}{ +{{- range $key := sortedKeys $service.Dependencies }} +{{- $dep := index $service.Dependencies $key }} {{$dep.FieldName}}: deps[{{quote $dep.ServiceName}}].({{$dep.FieldType}}), +{{- end }} +{{- range $key := sortedKeys $service.ConfigDependencies }} +{{- $cfg := index $service.ConfigDependencies $key }} + {{$cfg.FieldName}}: config[{{quote $cfg.ConfigKey}}].({{$cfg.FieldType}}), {{- end }} } +{{- if $service.HasInitMethod }} + + // Call Init() for post-initialization + if err := svc.Init(); err != nil { + panic("failed to initialize {{$service.ServiceName}}: " + err.Error()) + } +{{- end }} + + return svc } // {{$service.RemoteTypeName}}Factory creates a remote HTTP client for {{$service.InterfaceName}} @@ -898,7 +1244,12 @@ func {{$service.RemoteTypeName}}Factory(deps, config map[string]any) any { // Register{{$service.StructName}} registers the {{$service.ServiceName}} with the registry // Auto-generated from annotations: // - @RouterService name={{quote $service.ServiceName}}, prefix={{quote $service.Prefix}} +{{- if $service.Dependencies}} // - @Inject annotations +{{- end}} +{{- if $service.ConfigDependencies}} +// - @InjectCfg annotations +{{- end}} // - @Route annotations on methods func Register{{$service.StructName}}() { // Register service type with router configuration @@ -927,10 +1278,17 @@ func Register{{$service.StructName}}() { lokstra_registry.RegisterLazyService({{quote $service.ServiceName}}, "{{$service.ServiceName}}-factory", map[string]any{ - "depends-on": []string{ {{range $svcName, $dep := $service.Dependencies}}{{if ne $svcName ""}}{{quote $dep.ServiceName}}, {{end}}{{end}} }, +{{- if $service.Dependencies }} + "depends-on": []string{ {{range $key := sortedKeys $service.Dependencies}}{{$dep := index $service.Dependencies $key}}{{quote $dep.ServiceName}}, {{end}}}, +{{- end }} +{{- range $key := sortedKeys $service.ConfigDependencies }} +{{- $cfg := index $service.ConfigDependencies $key }} + {{quote $cfg.ConfigKey}}: lokstra_registry.GetConfig({{quote $cfg.ConfigKey}}, {{getDefaultValue $cfg.FieldType $cfg.DefaultValue}}), +{{- end }} }) } {{end}} +{{end}} {{range $filename, $code := .PreservedSections}}{{$code}}{{end}}`)) func hasReturnValue(route string) bool { diff --git a/core/annotation/complex_processor.go b/core/annotation/complex_processor.go index 356e7110..171a4747 100644 --- a/core/annotation/complex_processor.go +++ b/core/annotation/complex_processor.go @@ -283,18 +283,21 @@ type GeneratedCode struct { // ServiceGeneration holds generation data for one service type ServiceGeneration struct { - ServiceName string - Prefix string - Middlewares []string - Routes map[string]string // methodName -> "METHOD /path" - RouteMiddlewares map[string][]string // methodName -> []middleware (per-route middleware) - Methods map[string]*MethodSignature // methodName -> signature - Dependencies map[string]*DependencyInfo // serviceName -> field info - Imports map[string]string // alias -> import path (e.g., "domain" -> ".../.../domain") - StructName string - InterfaceName string - RemoteTypeName string - SourceFile string + ServiceName string + Prefix string + Middlewares []string + Routes map[string]string // methodName -> "METHOD /path" + RouteMiddlewares map[string][]string // methodName -> []middleware (per-route middleware) + Methods map[string]*MethodSignature // methodName -> signature + Dependencies map[string]*DependencyInfo // serviceName -> field info + ConfigDependencies map[string]*ConfigInfo // configKey -> config field info (for @InjectCfg) + Imports map[string]string // alias -> import path (e.g., "domain" -> ".../.../domain") + StructName string + InterfaceName string + RemoteTypeName string + SourceFile string + IsService bool // true if @Service, false if @RouterService + HasInitMethod bool // true if Init() error method exists } // DependencyInfo holds field injection information @@ -304,6 +307,14 @@ type DependencyInfo struct { FieldType string // e.g., "domain.UserRepository" (interface type) } +// ConfigInfo holds config injection information for @InjectCfg +type ConfigInfo struct { + ConfigKey string // e.g., "auth.jwt-secret" + FieldName string // e.g., "jwtSecret" + FieldType string // e.g., "string", "int", "bool", "time.Duration" + DefaultValue string // Default value as string (will be converted based on type) +} + // MethodSignature holds method signature information type MethodSignature struct { Name string @@ -370,14 +381,14 @@ func scanFolderFiles(folderPath string, cache *FolderCache) ([]*FileToProcess, [ fullPath := filepath.Join(folderPath, file.Name()) - // Quick check: does file contain @RouterService? - hasRouterService, err := fileContainsRouterService(fullPath) + // Quick check: does file contain @RouterService or @Service? + hasAnnotations, err := fileContainsServiceAnnotations(fullPath) if err != nil { // Cleanup before returning error cleanupFolder(folderPath) return nil, nil, nil, err } - if !hasRouterService { + if !hasAnnotations { continue } @@ -430,11 +441,11 @@ func scanFolderFiles(folderPath string, cache *FolderCache) ([]*FileToProcess, [ return skipped, updated, deleted, nil } -// fileContainsRouterService quickly checks if file contains @RouterService annotation. +// fileContainsServiceAnnotations quickly checks if file contains @RouterService or @Service annotation. // Uses same parsing logic as ParseFileAnnotations for consistency. -// Only matches when @RouterService is at the start of comment content (after // and spaces). +// Only matches when annotation is at the start of comment content (after // and spaces). // Ignores TAB-indented annotations (Go code examples in documentation). -func fileContainsRouterService(path string) (bool, error) { +func fileContainsServiceAnnotations(path string) (bool, error) { file, err := os.Open(path) if err != nil { return false, err @@ -466,7 +477,8 @@ func fileContainsRouterService(path string) (bool, error) { // Check for multiple spaces or single TAB trimmedAfter := bytes.TrimLeft(afterComment, " \t") - if bytes.HasPrefix(trimmedAfter, []byte("@RouterService")) { + // Check for @RouterService or @Service + if bytes.HasPrefix(trimmedAfter, []byte("@RouterService")) || bytes.HasPrefix(trimmedAfter, []byte("@Service")) { leadingWhitespace := afterComment[:len(afterComment)-len(trimmedAfter)] // Allow single space only (normal comment: "// @RouterService") @@ -482,7 +494,7 @@ func fileContainsRouterService(path string) (bool, error) { // Also check trimmed version for backward compatibility after = bytes.TrimSpace(after) - if bytes.HasPrefix(after, []byte("@RouterService")) { + if bytes.HasPrefix(after, []byte("@RouterService")) || bytes.HasPrefix(after, []byte("@Service")) { // Double-check: make sure it's not TAB-indented commentPos := bytes.Index(line, []byte("//")) if commentPos != -1 && commentPos+2 < len(line) { @@ -506,6 +518,13 @@ func fileContainsRouterService(path string) (bool, error) { } return false, scanner.Err() +} + +// fileContainsRouterService quickly checks if file contains @RouterService annotation. +// Deprecated: Use fileContainsServiceAnnotations instead. +// Kept for backward compatibility with tests. +func fileContainsRouterService(path string) (bool, error) { + return fileContainsServiceAnnotations(path) } // TestFileContainsRouterService is exported for testing purposes only. // It wraps the internal fileContainsRouterService function. func TestFileContainsRouterService(path string) (bool, error) { diff --git a/docs/00-introduction/examples/annotations/README.md b/docs/00-introduction/examples/annotations/README.md new file mode 100644 index 00000000..336a83f9 --- /dev/null +++ b/docs/00-introduction/examples/annotations/README.md @@ -0,0 +1,543 @@ +# Lokstra Annotations Examples + +This folder contains complete examples of using Lokstra annotations for dependency injection and code generation. + +## Files + +### 1. `service_example.go` - @Service Examples + +Pure services without HTTP endpoints, demonstrating: + +**AuthService:** +- ✅ Required dependencies: `@Inject "user-repository"`, `@Inject "cache-service"` +- ✅ String config: `@InjectCfg "auth.jwt-secret"` +- ✅ Duration config: `@InjectCfg key="auth.token-expiry", default="24h"` +- ✅ Int config: `@InjectCfg key="auth.max-attempts", default=5` +- ✅ Bool config: `@InjectCfg key="auth.debug-mode", default=false` + +**NotificationService:** +- ✅ String config: `@InjectCfg "smtp.host"` +- ✅ Int config with default: `@InjectCfg key="smtp.port", default=587` +- ✅ String config with default: `@InjectCfg key="smtp.from-email", default="noreply@example.com"` +- ✅ Bool config with default: `@InjectCfg key="notification.enabled", default=true` + +### 2. `router_service_with_config_example.go` - @RouterService Example + +HTTP service with routes, demonstrating: + +**UserAPIService:** +- ✅ HTTP routes: `@Route "GET /{id}"`, `@Route "POST /"`, etc. +- ✅ Per-route middleware: `middlewares=["auth"]`, `middlewares=["auth", "admin"]` +- ✅ Required dependencies: `@Inject "user-repository"`, `@Inject "cache-service"` +- ✅ Multiple config injections for API settings, rate limiting, pagination +- ✅ Bool, int, duration, string configs + +### 3. `init_example.go` - Init() Method Pattern + +Service with post-initialization setup: + +**CacheManager:** +- ✅ Config injection: `@InjectCfg key="cache.max-size", default=1000` +- ✅ `Init() error` method called after dependency injection +- ✅ Internal state initialization (maps, slices) +- ✅ Configuration validation +- ✅ Pre-loading data + +### 4. `router_with_init_example.go` - RouterService with Init() + +HTTP service with initialization: + +**ProductAPIService:** +- ✅ Dependency injection: `@Inject "product-repository"` +- ✅ Config injection: `@InjectCfg key="api.products.max-items", default=100` +- ✅ `Init() error` method for cache setup +- ✅ HTTP routes with internal state + +### 5. `config.example.yaml` - Configuration + +Example configuration file showing: +- Config values for all injected configurations +- Service definitions (dependencies) +- Deployment configuration for development and production + +## How to Use + +### 1. Copy Examples to Your Project + +```bash +cp service_example.go your-project/application/ +cp router_service_with_config_example.go your-project/application/ +cp config.example.yaml your-project/config.yaml +``` + +### 2. Generate Code + +```bash +# Automatic (recommended) +go run . # lokstra.Bootstrap() auto-generates + +# Manual +lokstra autogen ./application + +# Force rebuild +go run . --generate-only +``` + +### 3. Generated Code + +After running code generation, you'll get `zz_generated.lokstra.go` with: + +**For @Service:** +```go +func RegisterAuthService() { + lokstra_registry.RegisterLazyService("auth-service", func(deps map[string]any, cfg map[string]any) any { + svc := &AuthService{ + Cache: deps["cache-service"].(CacheService), + UserRepo: deps["user-repository"].(UserRepository), + DebugMode: cfg["auth.debug-mode"].(bool), + JwtSecret: cfg["auth.jwt-secret"].(string), + MaxAttempts: cfg["auth.max-attempts"].(int), + TokenExpiry: cfg["auth.token-expiry"].(time.Duration), + } + + return svc + }, map[string]any{ + "depends-on": []string{ "cache-service", "user-repository", }, + "auth.debug-mode": lokstra_registry.GetConfig("auth.debug-mode", false), + "auth.jwt-secret": lokstra_registry.GetConfig("auth.jwt-secret", ""), + "auth.max-attempts": lokstra_registry.GetConfig("auth.max-attempts", 5), + "auth.token-expiry": lokstra_registry.GetConfig("auth.token-expiry", 24*time.Hour), + }) +} +``` + +**For @Service with Init():** +```go +func RegisterCacheManager() { + lokstra_registry.RegisterLazyService("cache-manager", func(deps map[string]any, cfg map[string]any) any { + svc := &CacheManager{ + MaxSize: cfg["cache.max-size"].(int), + TTLSeconds: cfg["cache.ttl-seconds"].(int), + } + + // Call Init() for post-initialization + if err := svc.Init(); err != nil { + panic("failed to initialize cache-manager: " + err.Error()) + } + + return svc + }, map[string]any{ + "cache.max-size": lokstra_registry.GetConfig("cache.max-size", 1000), + "cache.ttl-seconds": lokstra_registry.GetConfig("cache.ttl-seconds", 300), + }) +} +``` + +**For @RouterService:** +```go +func UserAPIServiceFactory(deps map[string]any, config map[string]any) any { + svc := &UserAPIService{ + Cache: deps["cache-service"].(CacheService), + UserRepo: deps["user-repository"].(UserRepository), + RateLimitEnabled: config["api.rate-limit.enabled"].(bool), + MaxRequests: config["api.rate-limit.max-requests"].(int), + // ... all other fields + } + + return svc +} + +func RegisterUserAPIService() { + lokstra_registry.RegisterRouterServiceType("user-api-service-factory", + UserAPIServiceFactory, + UserAPIServiceRemoteFactory, + &deploy.ServiceTypeConfig{ + PathPrefix: "/api/v1/users", + Middlewares: []string{"recovery", "request-logger"}, + RouteOverrides: map[string]deploy.RouteConfig{ + "GetByID": {Path: "GET /{id}"}, + "Create": {Path: "POST /", Middlewares: []string{"auth"}}, + // ... all routes + }, + }, + ) + + lokstra_registry.RegisterLazyService("user-api-service", + "user-api-service-factory", + map[string]any{ + "depends-on": []string{ "cache-service", "user-repository", }, + "api.rate-limit.enabled": lokstra_registry.GetConfig("api.rate-limit.enabled", true), + // ... all config values + }) +} +``` + +## Annotation Reference + +### @Service +```go +// @Service name="service-name" +type ServiceName struct { } +``` +- Used for pure services (no HTTP) +- Auto-generates `RegisterLazyService` +- All dependencies are **mandatory** (panic if not found) + +### @RouterService +```go +// @RouterService name="service-name", prefix="/api/path", middlewares=["mw1"] +type ServiceName struct { } +``` +- Used for HTTP services +- Auto-generates routes, factory, and registration +- All dependencies are **mandatory** (panic if not found) + +### @Inject +```go +// Inject service dependency +// @Inject "service-name" +Field ServiceType +``` +- Injects service dependencies +- Works with both `@Service` and `@RouterService` +- **All dependencies are mandatory** - framework panics if not found +- Generates: `deps["service-name"].(ServiceType)` + +### @InjectCfg +```go +// Required config +// @InjectCfg "config.key" +Field string + +// With default (unquoted for non-string types) +// @InjectCfg key="config.key", default=100 +Field int + +// @InjectCfg key="config.key", default=true +Field bool + +// @InjectCfg key="config.key", default="24h" +Field time.Duration +``` +- Injects configuration from `config.yaml` +- Works with both `@Service` and `@RouterService` +- Auto-detects type: `string`, `int`, `bool`, `float64`, `time.Duration` +- Default values are type-specific (no quotes for int/bool/float) + +### @Route +```go +// @Route "GET /path/{id}" +func (s *Service) Method(p *Params) (*Result, error) + +// With per-route middleware +// @Route "POST /path", middlewares=["auth", "admin"] +func (s *Service) Method(p *Params) (*Result, error) +``` +- Only for `@RouterService` +- Defines HTTP endpoints + +### Init() Method (Optional) + +```go +// @Service name="my-service" +type MyService struct { + // @InjectCfg key="max-size", default=100 + MaxSize int + + // Internal state (not injected) + cache map[string]any +} + +// Called automatically after dependency injection +func (s *MyService) Init() error { + // Initialize internal state + s.cache = make(map[string]any, s.MaxSize) + + // Validate configuration + if s.MaxSize <= 0 { + return fmt.Errorf("max size must be positive") + } + + // Pre-load data, setup connections, etc. + log.Println("Service initialized") + return nil +} +``` + +**Init() Requirements:** +- Method name must be exactly `Init` +- Signature: `func (s *Service) Init() error` +- No parameters +- Returns `error` +- Called after all dependencies and configs are injected +- If returns error, service creation panics + +**Use Init() for:** +- ✅ Initialize maps, slices, channels +- ✅ Validate injected configuration +- ✅ Pre-load data or cache +- ✅ Setup complex internal state +- ✅ Establish connections (after config available) + +## Testing + +1. **Start the application:** + ```bash + go run . + ``` + +2. **Test endpoints:** + ```bash + # Get user + curl http://localhost:8080/api/v1/users/123 + + # List users + curl http://localhost:8080/api/v1/users + + # Create user (requires auth) + curl -X POST http://localhost:8080/api/v1/users \ + -H "Content-Type: application/json" \ + -d '{"name":"John","email":"john@example.com"}' + ``` + +## Key Features Demonstrated + +✅ **Dependency Injection**: Mandatory service dependencies (panic if missing) +✅ **Configuration Injection**: Type-safe config from YAML +✅ **Auto Type Detection**: Config types inferred from field type +✅ **Default Values**: Sensible defaults when config missing +✅ **Init() Method**: Post-initialization setup and validation +✅ **HTTP Routes**: Automatic REST API generation +✅ **Per-Route Middleware**: Fine-grained access control +✅ **Service Separation**: `@Service` for logic, `@RouterService` for HTTP + +## Design Patterns + +### 1. Simple Service (Config Only) +```go +// @Service name="email-service" +type EmailService struct { + // @InjectCfg "smtp.host" + SMTPHost string +} +``` + +### 2. Service with Dependencies +```go +// @Service name="auth-service" +type AuthService struct { + // @Inject "user-repository" + UserRepo UserRepository + + // @InjectCfg "auth.jwt-secret" + JwtSecret string +} +``` + +### 3. Service with Init() +```go +// @Service name="cache-manager" +type CacheManager struct { + // @InjectCfg key="cache.max-size", default=1000 + MaxSize int + + cache map[string]any +} + +func (c *CacheManager) Init() error { + c.cache = make(map[string]any, c.MaxSize) + return nil +} +``` + +### 4. HTTP Service +```go +// @RouterService name="user-api", prefix="/api/users" +type UserAPIService struct { + // @Inject "user-repository" + UserRepo UserRepository +} + +// @Route "GET /{id}" +func (s *UserAPIService) Get(p *GetParams) (*User, error) { } +``` + +## Next Steps + +1. Read the [Full Documentation](https://primadi.github.io/lokstra/) +2. See [AI Agent Guide](../../AI-AGENT-GUIDE.md) for best practices +3. Check [Quick Reference](../../QUICK-REFERENCE.md) for common patterns +4. Explore [Full Framework Examples](../full-framework/) for larger projects + +Example configuration file showing: +- Config values for all injected configurations +- Service definitions (dependencies) +- Deployment configuration for development and production + +## How to Use + +### 1. Copy Examples to Your Project + +```bash +cp service_example.go your-project/application/ +cp router_service_with_config_example.go your-project/application/ +cp config.example.yaml your-project/config.yaml +``` + +### 2. Generate Code + +```bash +# Automatic (recommended) +go run . # lokstra.Bootstrap() auto-generates + +# Manual +lokstra autogen ./application + +# Force rebuild +go run . --generate-only +``` + +### 3. Generated Code + +After running code generation, you'll get `zz_generated.lokstra.go` with: + +**For @Service:** +```go +func RegisterAuthService() { + lokstra_registry.RegisterLazyService("auth-service", func(deps map[string]any, cfg map[string]any) any { + return &AuthService{ + UserRepo: lokstra_registry.GetService[UserRepository]("user-repository"), + Cache: // Optional - nil if not found + JwtSecret: lokstra_registry.GetConfig("auth.jwt-secret", ""), + TokenExpiry: lokstra_registry.GetConfigDuration("auth.token-expiry", 24*time.Hour), + MaxAttempts: lokstra_registry.GetConfigInt("auth.max-attempts", 5), + DebugMode: lokstra_registry.GetConfigBool("auth.debug-mode", false), + } + }, nil) +} +``` + +**For @RouterService:** +```go +func UserAPIServiceFactory(deps map[string]any, config map[string]any) any { + return &UserAPIService{ + UserRepo: deps["user-repository"].(domain.UserRepository), + Cache: // Optional - nil if not found + RateLimitEnabled: lokstra_registry.GetConfigBool("api.rate-limit.enabled", true), + MaxRequests: lokstra_registry.GetConfigInt("api.rate-limit.max-requests", 100), + // ... all other configs + } +} + +func RegisterUserAPIService() { + lokstra_registry.RegisterRouterServiceType("user-api-service-factory", + UserAPIServiceFactory, + UserAPIServiceRemoteFactory, + &deploy.ServiceTypeConfig{ + PathPrefix: "/api/v1/users", + Middlewares: []string{"recovery", "request-logger"}, + RouteOverrides: map[string]deploy.RouteConfig{ + "GetByID": {Path: "GET /{id}"}, + "Create": {Path: "POST /", Middlewares: []string{"auth"}}, + // ... all routes + }, + }, + ) +} +``` + +## Annotation Reference + +### @Service +```go +// @Service name="service-name" +type ServiceName struct { } +``` +- Used for pure services (no HTTP) +- Auto-generates `RegisterLazyService` + +### @RouterService +```go +// @RouterService name="service-name", prefix="/api/path", middlewares=["mw1"] +type ServiceName struct { } +``` +- Used for HTTP services +- Auto-generates routes, factory, and registration + +### @Inject +```go +// Required +// @Inject "service-name" +Field ServiceType + +// Optional +// @Inject service="service-name", optional=true +Field ServiceType // nil if not found +``` +- Injects service dependencies +- Works with both `@Service` and `@RouterService` + +### @InjectCfg +```go +// Required +// @InjectCfg "config.key" +Field string + +// With default +// @InjectCfg key="config.key", default="value" +Field string +``` +- Injects configuration from `config.yaml` +- Works with both `@Service` and `@RouterService` +- Auto-detects type: `string`, `int`, `bool`, `float64`, `time.Duration` + +### @Route +```go +// @Route "GET /path/{id}" +func (s *Service) Method(p *Params) (*Result, error) + +// With per-route middleware +// @Route "POST /path", middlewares=["auth", "admin"] +func (s *Service) Method(p *Params) (*Result, error) +``` +- Only for `@RouterService` +- Defines HTTP endpoints + +## Testing + +1. **Start the application:** + ```bash + go run . + ``` + +2. **Test endpoints:** + ```bash + # Get user + curl http://localhost:8080/api/v1/users/123 + + # List users + curl http://localhost:8080/api/v1/users + + # Create user (requires auth) + curl -X POST http://localhost:8080/api/v1/users \ + -H "Content-Type: application/json" \ + -d '{"name":"John","email":"john@example.com"}' + ``` + +## Key Features Demonstrated + +✅ **Dependency Injection**: Required and optional service dependencies +✅ **Configuration Injection**: Type-safe config from YAML +✅ **Optional Dependencies**: Graceful degradation (e.g., cache) +✅ **Auto Type Detection**: Config types inferred from field type +✅ **Default Values**: Sensible defaults when config missing +✅ **HTTP Routes**: Automatic REST API generation +✅ **Per-Route Middleware**: Fine-grained access control +✅ **Service Separation**: `@Service` for logic, `@RouterService` for HTTP + +## Next Steps + +1. Read the [Full Documentation](https://primadi.github.io/lokstra/) +2. See [AI Agent Guide](../../AI-AGENT-GUIDE.md) for best practices +3. Check [Quick Reference](../../QUICK-REFERENCE.md) for common patterns +4. Explore [Full Framework Examples](../full-framework/) for larger projects diff --git a/docs/00-introduction/examples/annotations/config.example.yaml b/docs/00-introduction/examples/annotations/config.example.yaml new file mode 100644 index 00000000..098749f2 --- /dev/null +++ b/docs/00-introduction/examples/annotations/config.example.yaml @@ -0,0 +1,92 @@ +# Example configuration for @Service and @RouterService with @InjectCfg + +# Configuration for @Service examples (service_example.go) +configs: + # Auth service configuration + auth: + jwt-secret: "your-super-secret-jwt-key-change-in-production" + token-expiry: "48h" # 48 hours + max-attempts: 3 + debug-mode: false + + # SMTP configuration for notification service + smtp: + host: "smtp.gmail.com" + port: 587 + from-email: "noreply@myapp.com" + + # Notification settings + notification: + enabled: true + + # API configuration for @RouterService (router_service_with_config_example.go) + api: + jwt-secret: "api-jwt-secret-key" + token-expiry: "24h" + + # Rate limiting + rate-limit: + enabled: true + max-requests: 1000 + window: "1m" + + # Pagination + pagination: + default-page-size: 20 + max-page-size: 100 + + # Response options + response: + include-metadata: true + +# Service definitions +service-definitions: + # Repository layer + user-repository: + type: user-repository-factory + + # Optional cache service + cache-service: + type: redis-cache-factory + depends-on: [] + + # @Service examples + auth-service: + type: auth-service # Auto-registered by @Service annotation + + notification-service: + type: notification-service # Auto-registered by @Service annotation + + # @RouterService example + user-api-service: + type: user-api-service-factory # Auto-registered by @RouterService annotation + depends-on: + - user-repository + +# Deployment configuration +deployments: + development: + servers: + api: + addr: ":8080" + published-services: + - user-api-service + middlewares: + recovery: {} + request-logger: + enabled: true + cors: + allowed-origins: ["*"] + + production: + servers: + api: + addr: ":8080" + published-services: + - user-api-service + middlewares: + recovery: {} + request-logger: + enabled: true + cors: + allowed-origins: ["https://myapp.com"] diff --git a/docs/00-introduction/examples/annotations/domain.go b/docs/00-introduction/examples/annotations/domain.go new file mode 100644 index 00000000..709946a4 --- /dev/null +++ b/docs/00-introduction/examples/annotations/domain.go @@ -0,0 +1,26 @@ +package application + +import "time" + +// Shared domain types for annotation examples + +type User struct { + ID string + Email string + Name string +} + +type UserRepository interface { + GetByID(id string) (*User, error) + GetByEmail(email string) (*User, error) + List() ([]*User, error) + Create(user *User) (*User, error) + Update(user *User) (*User, error) + Delete(id string) error +} + +type CacheService interface { + Get(key string) (any, error) + Set(key string, value any, ttl time.Duration) error + Delete(key string) error +} diff --git a/docs/00-introduction/examples/annotations/init_example.go b/docs/00-introduction/examples/annotations/init_example.go new file mode 100644 index 00000000..881d7d2a --- /dev/null +++ b/docs/00-introduction/examples/annotations/init_example.go @@ -0,0 +1,53 @@ +package application + +import ( + "fmt" + "log" +) + +// Example with Init() method + +// @Service name="cache-manager" +type CacheManager struct { + // @InjectCfg key="cache.max-size", default=1000 + MaxSize int + + // @InjectCfg key="cache.ttl-seconds", default=300 + TTLSeconds int + + // Internal state (not injected) + cache map[string]any +} + +// Init is called after dependency injection +func (c *CacheManager) Init() error { + // Initialize internal state + c.cache = make(map[string]any, c.MaxSize) + + // Validation + if c.MaxSize <= 0 { + return fmt.Errorf("cache max size must be positive, got %d", c.MaxSize) + } + + if c.TTLSeconds <= 0 { + return fmt.Errorf("cache TTL must be positive, got %d", c.TTLSeconds) + } + + log.Printf("✅ CacheManager initialized: max_size=%d, ttl=%ds", c.MaxSize, c.TTLSeconds) + return nil +} + +func (c *CacheManager) Set(key string, value any) { + if c.cache == nil { + c.cache = make(map[string]any) + } + c.cache[key] = value +} + +func (c *CacheManager) Get(key string) (any, bool) { + if c.cache == nil { + return nil, false + } + val, ok := c.cache[key] + return val, ok +} diff --git a/docs/00-introduction/examples/annotations/mixed_services_example.go b/docs/00-introduction/examples/annotations/mixed_services_example.go new file mode 100644 index 00000000..76e24494 --- /dev/null +++ b/docs/00-introduction/examples/annotations/mixed_services_example.go @@ -0,0 +1,123 @@ +package application + +import ( + "time" +) + +// Example file with mixed @Service and @RouterService annotations + +// ============================================================ +// Pure Service (No HTTP) +// ============================================================ + +// @Service name="email-service" +type EmailService struct { + // @InjectCfg key="smtp.host" + SMTPHost string + + // @InjectCfg key="smtp.port", default=587 + SMTPPort int + + // @InjectCfg key="smtp.username" + SMTPUsername string + + // @InjectCfg key="smtp.password" + SMTPPassword string + + // @InjectCfg key="email.from", default="noreply@example.com" + FromEmail string +} + +func (s *EmailService) SendEmail(to, subject, body string) error { + // Send email implementation + println("Sending email from", s.FromEmail, "to", to, "via", s.SMTPHost) + return nil +} + +// ============================================================ +// Another Pure Service +// ============================================================ + +// @Service name="background-job-service" +type BackgroundJobService struct { + // @Inject "email-service" + EmailService *EmailService + + // @InjectCfg key="jobs.max-workers", default=10 + MaxWorkers int + + // @InjectCfg key="jobs.retry-limit", default=3 + RetryLimit int + + // @InjectCfg key="jobs.retry-delay", default="5s" + RetryDelay time.Duration +} + +func (s *BackgroundJobService) ProcessJob(jobID string) error { + // Process job and send notification + return s.EmailService.SendEmail("admin@example.com", "Job Complete", "Job "+jobID+" completed") +} + +// ============================================================ +// HTTP Service (RouterService) +// ============================================================ + +// @RouterService name="admin-api-service", prefix="/api/admin", middlewares=["recovery", "auth", "admin"] +type AdminAPIService struct { + // @Inject "background-job-service" + JobService *BackgroundJobService + + // @Inject service="email-service" + EmailService *EmailService + + // @InjectCfg key="admin.allow-job-restart", default=true + AllowJobRestart bool + + // @InjectCfg key="admin.max-jobs-per-page", default=50 + MaxJobsPerPage int +} + +// @Route "POST /jobs/{id}/restart" +func (s *AdminAPIService) RestartJob(req *RestartJobRequest) (*JobResponse, error) { + if !s.AllowJobRestart { + return nil, nil + } + + err := s.JobService.ProcessJob(req.JobID) + if err != nil { + return nil, err + } + + return &JobResponse{ + JobID: req.JobID, + Status: "restarted", + }, nil +} + +// @Route "GET /jobs" +func (s *AdminAPIService) ListJobs(req *ListJobsRequest) (*JobListResponse, error) { + // List jobs with pagination + return &JobListResponse{ + Jobs: []*JobResponse{}, + PageSize: s.MaxJobsPerPage, + }, nil +} + +// Request/Response DTOs for AdminAPIService +type RestartJobRequest struct { + JobID string `path:"id"` +} + +type ListJobsRequest struct { + Page int `query:"page"` +} + +type JobResponse struct { + JobID string `json:"job_id"` + Status string `json:"status"` +} + +type JobListResponse struct { + Jobs []*JobResponse `json:"jobs"` + PageSize int `json:"page_size"` +} diff --git a/docs/00-introduction/examples/annotations/router_service_with_config_example.go b/docs/00-introduction/examples/annotations/router_service_with_config_example.go new file mode 100644 index 00000000..fc28d3e7 --- /dev/null +++ b/docs/00-introduction/examples/annotations/router_service_with_config_example.go @@ -0,0 +1,225 @@ +package application + +import ( + "fmt" + "time" +) + +// Request DTOs +type GetUserRequest struct { + ID string `path:"id"` +} + +type ListUsersRequest struct { + Page int `query:"page"` + PageSize int `query:"page_size"` +} + +type CreateUserRequest struct { + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required,email"` +} + +type UpdateUserRequest struct { + ID string `path:"id"` + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required,email"` +} + +type DeleteUserRequest struct { + ID string `path:"id"` +} + +// Response DTOs +type UserResponse struct { + User *User `json:"user"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type UserListResponse struct { + Users []*User `json:"users"` + PageSize int `json:"page_size"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// Example: @RouterService with @Inject (optional), @InjectCfg, and @Route + +// @RouterService name="user-api-service", prefix="/api/v1/users", middlewares=["recovery", "request-logger"] +type UserAPIService struct { + // Required dependency + // @Inject "user-repository" + UserRepo UserRepository + + // Cache dependency for performance + // @Inject "cache-service" + Cache CacheService + + // Configuration: API rate limiting + // @InjectCfg key="api.rate-limit.enabled", default=true + RateLimitEnabled bool + + // @InjectCfg key="api.rate-limit.max-requests", default=100 + MaxRequests int + + // @InjectCfg "api.rate-limit.window", "1m" + RateLimitWindow time.Duration + + // Configuration: Pagination + // @InjectCfg key="api.pagination.default-page-size", default="20" + DefaultPageSize int + + // @InjectCfg key="api.pagination.max-page-size", default="100" + MaxPageSize int + + // Configuration: Response + // @InjectCfg key="api.response.include-metadata", default="true" + IncludeMetadata bool + + // Configuration: Authentication + // @InjectCfg "api.jwt-secret" + JwtSecret string + + // @InjectCfg key="api.token-expiry", default="24h" + TokenExpiry time.Duration +} + +// @Route "GET /{id}" +func (s *UserAPIService) GetByID(p *GetUserRequest) (*UserResponse, error) { + // Check cache if available + if s.Cache != nil { + cacheKey := "user:" + p.ID + if cached, err := s.Cache.Get(cacheKey); err == nil { + return cached.(*UserResponse), nil + } + } + + user, err := s.UserRepo.GetByID(p.ID) + if err != nil { + return nil, err + } + + response := &UserResponse{ + User: user, + Metadata: s.buildMetadata(), + } + + // Cache if available + if s.Cache != nil { + _ = s.Cache.Set("user:"+p.ID, response, 5*time.Minute) + } + + return response, nil +} + +// @Route "GET /" +func (s *UserAPIService) List(p *ListUsersRequest) (*UserListResponse, error) { + // Apply default page size + if p.PageSize == 0 { + p.PageSize = s.DefaultPageSize + } + + // Enforce max page size + if p.PageSize > s.MaxPageSize { + p.PageSize = s.MaxPageSize + } + + users, err := s.UserRepo.List() + if err != nil { + return nil, err + } + + response := &UserListResponse{ + Users: users, + PageSize: p.PageSize, + } + + if s.IncludeMetadata { + response.Metadata = s.buildMetadata() + } + + return response, nil +} + +// @Route "POST /", ["auth"] +func (s *UserAPIService) Create(p *CreateUserRequest) (*UserResponse, error) { + // Check rate limit + if s.RateLimitEnabled { + // Rate limiting logic here + _ = s.MaxRequests + _ = s.RateLimitWindow + } + + user := &User{ + Name: p.Name, + Email: p.Email, + } + + created, err := s.UserRepo.Create(user) + if err != nil { + return nil, err + } + + response := &UserResponse{ + User: created, + Metadata: s.buildMetadata(), + } + + return response, nil +} + +// @Route "PUT /{id}", ["auth"] +func (s *UserAPIService) Update(p *UpdateUserRequest) (*UserResponse, error) { + user := &User{ + ID: p.ID, + Name: p.Name, + Email: p.Email, + } + + updated, err := s.UserRepo.Update(user) + if err != nil { + return nil, err + } + + // Invalidate cache if available + if s.Cache != nil { + _ = s.Cache.Delete("user:" + p.ID) + } + + response := &UserResponse{ + User: updated, + Metadata: s.buildMetadata(), + } + + return response, nil +} + +// @Route "DELETE /{id}", ["auth", "admin"] +func (s *UserAPIService) Delete(p *DeleteUserRequest) error { + err := s.UserRepo.Delete(p.ID) + if err != nil { + return err + } + + // Invalidate cache if available + if s.Cache != nil { + _ = s.Cache.Delete("user:" + p.ID) + } + + return nil +} + +func (s *UserAPIService) buildMetadata() map[string]any { + if !s.IncludeMetadata { + return nil + } + + return map[string]any{ + "timestamp": time.Now().Unix(), + "rate_limit_window": s.RateLimitWindow.String(), + "max_page_size": fmt.Sprintf("%d", s.MaxPageSize), + } +} + +func Register() { + // Package auto-loaded by code generation +} diff --git a/docs/00-introduction/examples/annotations/router_with_init_example.go b/docs/00-introduction/examples/annotations/router_with_init_example.go new file mode 100644 index 00000000..78e01595 --- /dev/null +++ b/docs/00-introduction/examples/annotations/router_with_init_example.go @@ -0,0 +1,70 @@ +package application + +import ( + "fmt" + "log" +) + +// Example RouterService with Init() method + +// @RouterService name="product-api", prefix="/api/products" +type ProductAPIService struct { + // @Inject "product-repository" + ProductRepo ProductRepository + + // @InjectCfg key="api.products.max-items", default=100 + MaxItems int + + // Internal cache (initialized in Init()) + categoryCache map[string][]string +} + +// Init is called after dependency injection +func (s *ProductAPIService) Init() error { + // Initialize internal cache + s.categoryCache = make(map[string][]string) + + // Validate configuration + if s.MaxItems <= 0 { + return fmt.Errorf("max items must be positive, got %d", s.MaxItems) + } + + // Pre-load data if needed + log.Printf("✅ ProductAPIService initialized: max_items=%d", s.MaxItems) + return nil +} + +// @Route "GET /" +func (s *ProductAPIService) ListProducts(req *ListProductsRequest) (*ProductListResponse, error) { + // Use cache + _ = s.categoryCache + return &ProductListResponse{}, nil +} + +// @Route "GET /{id}" +func (s *ProductAPIService) GetProduct(req *GetProductRequest) (*ProductResponse, error) { + return &ProductResponse{}, nil +} + +// DTOs +type ListProductsRequest struct { + Category string `query:"category"` +} + +type GetProductRequest struct { + ID string `path:"id"` +} + +type ProductResponse struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type ProductListResponse struct { + Products []*ProductResponse `json:"products"` +} + +// Domain interface +type ProductRepository interface { + FindAll() ([]*ProductResponse, error) +} diff --git a/docs/00-introduction/examples/annotations/service_example.go b/docs/00-introduction/examples/annotations/service_example.go new file mode 100644 index 00000000..2b082856 --- /dev/null +++ b/docs/00-introduction/examples/annotations/service_example.go @@ -0,0 +1,89 @@ +package application + +import ( + "time" +) + +// Example: @Service annotation with @Inject and @InjectCfg + +// @Service name="auth-service" +type AuthService struct { + // @Inject "user-repository" + UserRepo UserRepository + + // @Inject "cache-service" + Cache CacheService + + // @InjectCfg "auth.jwt-secret" + JwtSecret string + + // @InjectCfg key="auth.token-expiry", default="24h" + TokenExpiry time.Duration + + // @InjectCfg key="auth.max-attempts", default=5 + MaxAttempts int + + // @InjectCfg key="auth.debug-mode", default="false" + DebugMode bool +} + +// Login authenticates user and returns JWT token +func (s *AuthService) Login(email, password string) (string, error) { + // Check cache first if available + if s.Cache != nil { + if cachedUser, err := s.Cache.Get("user:" + email); err == nil { + // Use cached user + _ = cachedUser + } + } + + user, err := s.UserRepo.GetByEmail(email) + if err != nil { + return "", err + } + + // Verify password (simplified) + if user.Email != email { + return "", nil + } + + // Generate JWT token with configured expiry + token := "jwt_token_for_" + user.ID + "_expires_in_" + s.TokenExpiry.String() + + // Cache user if cache is available + if s.Cache != nil { + _ = s.Cache.Set("user:"+email, user, s.TokenExpiry) + } + + if s.DebugMode { + println("Debug: Login successful for", email) + } + + return token, nil +} + +// @Service name="notification-service" +type NotificationService struct { + // @InjectCfg "smtp.host" + SMTPHost string + + // @InjectCfg key="smtp.port", default="587" + SMTPPort int + + // @InjectCfg key="smtp.from-email", default="noreply@example.com" + FromEmail string + + // @InjectCfg key="notification.enabled", default="true" + Enabled bool +} + +// SendEmail sends notification email +func (s *NotificationService) SendEmail(to, subject, body string) error { + if !s.Enabled { + return nil // Skip if disabled + } + + // Send email via SMTP + println("Sending email from", s.FromEmail, "to", to, "via", s.SMTPHost+":"+string(rune(s.SMTPPort))) + return nil +} diff --git a/docs/00-introduction/examples/annotations/zz_cache.lokstra.json b/docs/00-introduction/examples/annotations/zz_cache.lokstra.json new file mode 100644 index 00000000..48a8f86c --- /dev/null +++ b/docs/00-introduction/examples/annotations/zz_cache.lokstra.json @@ -0,0 +1,57 @@ +{ + "version": 1, + "files": { + "init_example.go": { + "filename": "init_example.go", + "checksum": "696a7b4b95565d9f3716cd685310671eb138cf19f86772a58f06e40fe794b61e", + "annotations": 3, + "last_scan": "2025-12-07T01:49:00.1149164+07:00", + "generated": [ + "zz_generated.lokstra.go" + ], + "generated_mod_time": "2025-12-07T01:49:00.1149164+07:00" + }, + "mixed_services_example.go": { + "filename": "mixed_services_example.go", + "checksum": "7e27d4c1dd03132ad25c2d48a3d9aee10848963897d8e436b950ebcc184f902a", + "annotations": 18, + "last_scan": "2025-12-07T01:49:00.1149164+07:00", + "generated": [ + "zz_generated.lokstra.go" + ], + "generated_mod_time": "2025-12-07T01:49:00.1149164+07:00" + }, + "router_service_with_config_example.go": { + "filename": "router_service_with_config_example.go", + "checksum": "8615e7b99db57225d9c442e2bcfcdddaceae1d38be692dec2daaf7be3cdfcbe0", + "annotations": 16, + "last_scan": "2025-12-07T01:49:00.1149164+07:00", + "generated": [ + "zz_generated.lokstra.go" + ], + "generated_mod_time": "2025-12-07T01:49:00.1149164+07:00" + }, + "router_with_init_example.go": { + "filename": "router_with_init_example.go", + "checksum": "0618ae863b04e0d7f1759085749465572e9e41fff1f8c489afaa6de43e69564f", + "annotations": 5, + "last_scan": "2025-12-07T01:49:00.1149164+07:00", + "generated": [ + "zz_generated.lokstra.go" + ], + "generated_mod_time": "2025-12-07T01:49:00.1149164+07:00" + }, + "service_example.go": { + "filename": "service_example.go", + "checksum": "e1830383b1cab4f69bca409897d06039121f08185bbcbbdb1c869f7e20943e68", + "annotations": 12, + "last_scan": "2025-12-07T01:49:00.1149164+07:00", + "generated": [ + "zz_generated.lokstra.go" + ], + "generated_mod_time": "2025-12-07T01:49:00.1149164+07:00" + } + }, + "updated_at": "2025-12-07T01:49:00.1149164+07:00", + "generated_checksum": "f900aa878c75bf0f7595877fe58eec0a989cd7a7450fd66ebfcd2cec3a15f507" +} \ No newline at end of file diff --git a/docs/00-introduction/examples/annotations/zz_generated.lokstra.go b/docs/00-introduction/examples/annotations/zz_generated.lokstra.go new file mode 100644 index 00000000..72e1fd44 --- /dev/null +++ b/docs/00-introduction/examples/annotations/zz_generated.lokstra.go @@ -0,0 +1,473 @@ +// AUTO-GENERATED CODE - DO NOT EDIT +// Generated by lokstra-annotation from annotations in this folder +// Annotations: @RouterService, @Service, @Inject, @InjectCfg, @Route + +package application + +import ( + "github.com/primadi/lokstra/core/deploy" + "github.com/primadi/lokstra/core/proxy" + "github.com/primadi/lokstra/lokstra_registry" + time "time" +) + +// Auto-register on package import +func init() { + RegisterAdminAPIService() + RegisterAuthService() + RegisterBackgroundJobService() + RegisterCacheManager() + RegisterEmailService() + RegisterNotificationService() + RegisterProductAPIService() + RegisterUserAPIService() +} + +// ============================================================ +// FILE: mixed_services_example.go +// ============================================================ + +// AdminAPIServiceRemote implements AdminAPIServiceInterface with HTTP proxy +// Auto-generated from AdminAPIService interface methods +type AdminAPIServiceRemote struct { + proxyService *proxy.Service +} + +// NewAdminAPIServiceRemote creates a new remote admin-api-service proxy +func NewAdminAPIServiceRemote(proxyService *proxy.Service) *AdminAPIServiceRemote { + return &AdminAPIServiceRemote{ + proxyService: proxyService, + } +} + +// ListJobs via HTTP +// Generated from: @Route "GET /jobs" +func (s *AdminAPIServiceRemote) ListJobs(p *ListJobsRequest) (*JobListResponse, error) { + return proxy.CallWithData[*JobListResponse](s.proxyService, "ListJobs", p) +} + +// RestartJob via HTTP +// Generated from: @Route "POST /jobs/{id}/restart" +func (s *AdminAPIServiceRemote) RestartJob(p *RestartJobRequest) (*JobResponse, error) { + return proxy.CallWithData[*JobResponse](s.proxyService, "RestartJob", p) +} + + +func AdminAPIServiceFactory(deps map[string]any, config map[string]any) any { + svc := &AdminAPIService{ + JobService: deps["background-job-service"].(*BackgroundJobService), + EmailService: deps["email-service"].(*EmailService), + AllowJobRestart: config["admin.allow-job-restart"].(bool), + MaxJobsPerPage: config["admin.max-jobs-per-page"].(int), + } + + return svc +} + +// AdminAPIServiceRemoteFactory creates a remote HTTP client for AdminAPIServiceInterface +// Auto-generated from @RouterService annotation +func AdminAPIServiceRemoteFactory(deps, config map[string]any) any { + proxyService, ok := config["remote"].(*proxy.Service) + if !ok { + panic("remote factory requires 'remote' (proxy.Service) in config") + } + return NewAdminAPIServiceRemote(proxyService) +} + +// RegisterAdminAPIService registers the admin-api-service with the registry +// Auto-generated from annotations: +// - @RouterService name="admin-api-service", prefix="/api/admin" +// - @Inject annotations +// - @InjectCfg annotations +// - @Route annotations on methods +func RegisterAdminAPIService() { + // Register service type with router configuration + lokstra_registry.RegisterRouterServiceType("admin-api-service-factory", + AdminAPIServiceFactory, + AdminAPIServiceRemoteFactory, + &deploy.ServiceTypeConfig{ + PathPrefix: "/api/admin", + Middlewares: []string{ "recovery", "auth", "admin" }, + RouteOverrides: map[string]deploy.RouteConfig{ + "ListJobs": { + Path: "GET /jobs", + }, + "RestartJob": { + Path: "POST /jobs/{id}/restart", + }, + }, + }, + ) + + // Register lazy service with auto-detected dependencies + lokstra_registry.RegisterLazyService("admin-api-service", + "admin-api-service-factory", + map[string]any{ + "depends-on": []string{ "background-job-service", "email-service", }, + "admin.allow-job-restart": lokstra_registry.GetConfig("admin.allow-job-restart", true), + "admin.max-jobs-per-page": lokstra_registry.GetConfig("admin.max-jobs-per-page", 50), + }) +} + + +// ============================================================ +// FILE: service_example.go +// ============================================================ + +// RegisterAuthService registers the auth-service with the registry +// Auto-generated from annotations: +// - @Service name="auth-service" +// - @Inject annotations +// - @InjectCfg annotations +func RegisterAuthService() { + lokstra_registry.RegisterLazyService("auth-service", func(deps map[string]any, cfg map[string]any) any { + svc := &AuthService{ + Cache: deps["cache-service"].(CacheService), + UserRepo: deps["user-repository"].(UserRepository), + DebugMode: cfg["auth.debug-mode"].(bool), + JwtSecret: cfg["auth.jwt-secret"].(string), + MaxAttempts: cfg["auth.max-attempts"].(int), + TokenExpiry: cfg["auth.token-expiry"].(time.Duration), + } + + return svc + }, map[string]any{ + "depends-on": []string{ "cache-service", "user-repository", }, + "auth.debug-mode": lokstra_registry.GetConfig("auth.debug-mode", false), + "auth.jwt-secret": lokstra_registry.GetConfig("auth.jwt-secret", ""), + "auth.max-attempts": lokstra_registry.GetConfig("auth.max-attempts", 5), + "auth.token-expiry": lokstra_registry.GetConfig("auth.token-expiry", 24*time.Hour), + }) +} + + +// ============================================================ +// FILE: mixed_services_example.go +// ============================================================ + +// RegisterBackgroundJobService registers the background-job-service with the registry +// Auto-generated from annotations: +// - @Service name="background-job-service" +// - @Inject annotations +// - @InjectCfg annotations +func RegisterBackgroundJobService() { + lokstra_registry.RegisterLazyService("background-job-service", func(deps map[string]any, cfg map[string]any) any { + svc := &BackgroundJobService{ + EmailService: deps["email-service"].(*EmailService), + MaxWorkers: cfg["jobs.max-workers"].(int), + RetryDelay: cfg["jobs.retry-delay"].(time.Duration), + RetryLimit: cfg["jobs.retry-limit"].(int), + } + + return svc + }, map[string]any{ + "depends-on": []string{ "email-service", }, + "jobs.max-workers": lokstra_registry.GetConfig("jobs.max-workers", 10), + "jobs.retry-delay": lokstra_registry.GetConfig("jobs.retry-delay", 5*time.Second), + "jobs.retry-limit": lokstra_registry.GetConfig("jobs.retry-limit", 3), + }) +} + + +// ============================================================ +// FILE: init_example.go +// ============================================================ + +// RegisterCacheManager registers the cache-manager with the registry +// Auto-generated from annotations: +// - @Service name="cache-manager" +// - @InjectCfg annotations +func RegisterCacheManager() { + lokstra_registry.RegisterLazyService("cache-manager", func(deps map[string]any, cfg map[string]any) any { + svc := &CacheManager{ + MaxSize: cfg["cache.max-size"].(int), + TTLSeconds: cfg["cache.ttl-seconds"].(int), + } + + // Call Init() for post-initialization + if err := svc.Init(); err != nil { + panic("failed to initialize cache-manager: " + err.Error()) + } + + return svc + }, map[string]any{ + "cache.max-size": lokstra_registry.GetConfig("cache.max-size", 1000), + "cache.ttl-seconds": lokstra_registry.GetConfig("cache.ttl-seconds", 300), + }) +} + + +// ============================================================ +// FILE: mixed_services_example.go +// ============================================================ + +// RegisterEmailService registers the email-service with the registry +// Auto-generated from annotations: +// - @Service name="email-service" +// - @InjectCfg annotations +func RegisterEmailService() { + lokstra_registry.RegisterLazyService("email-service", func(deps map[string]any, cfg map[string]any) any { + svc := &EmailService{ + FromEmail: cfg["email.from"].(string), + SMTPHost: cfg["smtp.host"].(string), + SMTPPassword: cfg["smtp.password"].(string), + SMTPPort: cfg["smtp.port"].(int), + SMTPUsername: cfg["smtp.username"].(string), + } + + return svc + }, map[string]any{ + "email.from": lokstra_registry.GetConfig("email.from", "noreply@example.com"), + "smtp.host": lokstra_registry.GetConfig("smtp.host", ""), + "smtp.password": lokstra_registry.GetConfig("smtp.password", ""), + "smtp.port": lokstra_registry.GetConfig("smtp.port", 587), + "smtp.username": lokstra_registry.GetConfig("smtp.username", ""), + }) +} + + +// ============================================================ +// FILE: service_example.go +// ============================================================ + +// RegisterNotificationService registers the notification-service with the registry +// Auto-generated from annotations: +// - @Service name="notification-service" +// - @InjectCfg annotations +func RegisterNotificationService() { + lokstra_registry.RegisterLazyService("notification-service", func(deps map[string]any, cfg map[string]any) any { + svc := &NotificationService{ + Enabled: cfg["notification.enabled"].(bool), + FromEmail: cfg["smtp.from-email"].(string), + SMTPHost: cfg["smtp.host"].(string), + SMTPPort: cfg["smtp.port"].(int), + } + + return svc + }, map[string]any{ + "notification.enabled": lokstra_registry.GetConfig("notification.enabled", true), + "smtp.from-email": lokstra_registry.GetConfig("smtp.from-email", "noreply@example.com"), + "smtp.host": lokstra_registry.GetConfig("smtp.host", ""), + "smtp.port": lokstra_registry.GetConfig("smtp.port", 587), + }) +} + + +// ============================================================ +// FILE: router_with_init_example.go +// ============================================================ + +// ProductAPIServiceRemote implements ProductAPIServiceInterface with HTTP proxy +// Auto-generated from ProductAPIService interface methods +type ProductAPIServiceRemote struct { + proxyService *proxy.Service +} + +// NewProductAPIServiceRemote creates a new remote product-api proxy +func NewProductAPIServiceRemote(proxyService *proxy.Service) *ProductAPIServiceRemote { + return &ProductAPIServiceRemote{ + proxyService: proxyService, + } +} + +// GetProduct via HTTP +// Generated from: @Route "GET /{id}" +func (s *ProductAPIServiceRemote) GetProduct(p *GetProductRequest) (*ProductResponse, error) { + return proxy.CallWithData[*ProductResponse](s.proxyService, "GetProduct", p) +} + +// ListProducts via HTTP +// Generated from: @Route "GET /" +func (s *ProductAPIServiceRemote) ListProducts(p *ListProductsRequest) (*ProductListResponse, error) { + return proxy.CallWithData[*ProductListResponse](s.proxyService, "ListProducts", p) +} + + +func ProductAPIServiceFactory(deps map[string]any, config map[string]any) any { + svc := &ProductAPIService{ + ProductRepo: deps["product-repository"].(ProductRepository), + MaxItems: config["api.products.max-items"].(int), + } + + // Call Init() for post-initialization + if err := svc.Init(); err != nil { + panic("failed to initialize product-api: " + err.Error()) + } + + return svc +} + +// ProductAPIServiceRemoteFactory creates a remote HTTP client for ProductAPIServiceInterface +// Auto-generated from @RouterService annotation +func ProductAPIServiceRemoteFactory(deps, config map[string]any) any { + proxyService, ok := config["remote"].(*proxy.Service) + if !ok { + panic("remote factory requires 'remote' (proxy.Service) in config") + } + return NewProductAPIServiceRemote(proxyService) +} + +// RegisterProductAPIService registers the product-api with the registry +// Auto-generated from annotations: +// - @RouterService name="product-api", prefix="/api/products" +// - @Inject annotations +// - @InjectCfg annotations +// - @Route annotations on methods +func RegisterProductAPIService() { + // Register service type with router configuration + lokstra_registry.RegisterRouterServiceType("product-api-factory", + ProductAPIServiceFactory, + ProductAPIServiceRemoteFactory, + &deploy.ServiceTypeConfig{ + PathPrefix: "/api/products", + Middlewares: []string{ }, + RouteOverrides: map[string]deploy.RouteConfig{ + "GetProduct": { + Path: "GET /{id}", + }, + "ListProducts": { + Path: "GET /", + }, + }, + }, + ) + + // Register lazy service with auto-detected dependencies + lokstra_registry.RegisterLazyService("product-api", + "product-api-factory", + map[string]any{ + "depends-on": []string{ "product-repository", }, + "api.products.max-items": lokstra_registry.GetConfig("api.products.max-items", 100), + }) +} + + +// ============================================================ +// FILE: router_service_with_config_example.go +// ============================================================ + +// UserAPIServiceRemote implements UserAPIServiceInterface with HTTP proxy +// Auto-generated from UserAPIService interface methods +type UserAPIServiceRemote struct { + proxyService *proxy.Service +} + +// NewUserAPIServiceRemote creates a new remote user-api-service proxy +func NewUserAPIServiceRemote(proxyService *proxy.Service) *UserAPIServiceRemote { + return &UserAPIServiceRemote{ + proxyService: proxyService, + } +} + +// Create via HTTP +// Generated from: @Route "POST /" +func (s *UserAPIServiceRemote) Create(p *CreateUserRequest) (*UserResponse, error) { + return proxy.CallWithData[*UserResponse](s.proxyService, "Create", p) +} + +// Delete via HTTP +// Generated from: @Route "DELETE /{id}" +func (s *UserAPIServiceRemote) Delete(p *DeleteUserRequest) error { + return proxy.Call(s.proxyService, "Delete", p) +} + +// GetByID via HTTP +// Generated from: @Route "GET /{id}" +func (s *UserAPIServiceRemote) GetByID(p *GetUserRequest) (*UserResponse, error) { + return proxy.CallWithData[*UserResponse](s.proxyService, "GetByID", p) +} + +// List via HTTP +// Generated from: @Route "GET /" +func (s *UserAPIServiceRemote) List(p *ListUsersRequest) (*UserListResponse, error) { + return proxy.CallWithData[*UserListResponse](s.proxyService, "List", p) +} + +// Update via HTTP +// Generated from: @Route "PUT /{id}" +func (s *UserAPIServiceRemote) Update(p *UpdateUserRequest) (*UserResponse, error) { + return proxy.CallWithData[*UserResponse](s.proxyService, "Update", p) +} + + +func UserAPIServiceFactory(deps map[string]any, config map[string]any) any { + svc := &UserAPIService{ + Cache: deps["cache-service"].(CacheService), + UserRepo: deps["user-repository"].(UserRepository), + JwtSecret: config["api.jwt-secret"].(string), + DefaultPageSize: config["api.pagination.default-page-size"].(int), + MaxPageSize: config["api.pagination.max-page-size"].(int), + RateLimitEnabled: config["api.rate-limit.enabled"].(bool), + MaxRequests: config["api.rate-limit.max-requests"].(int), + RateLimitWindow: config["api.rate-limit.window"].(time.Duration), + IncludeMetadata: config["api.response.include-metadata"].(bool), + TokenExpiry: config["api.token-expiry"].(time.Duration), + } + + return svc +} + +// UserAPIServiceRemoteFactory creates a remote HTTP client for UserAPIServiceInterface +// Auto-generated from @RouterService annotation +func UserAPIServiceRemoteFactory(deps, config map[string]any) any { + proxyService, ok := config["remote"].(*proxy.Service) + if !ok { + panic("remote factory requires 'remote' (proxy.Service) in config") + } + return NewUserAPIServiceRemote(proxyService) +} + +// RegisterUserAPIService registers the user-api-service with the registry +// Auto-generated from annotations: +// - @RouterService name="user-api-service", prefix="/api/v1/users" +// - @Inject annotations +// - @InjectCfg annotations +// - @Route annotations on methods +func RegisterUserAPIService() { + // Register service type with router configuration + lokstra_registry.RegisterRouterServiceType("user-api-service-factory", + UserAPIServiceFactory, + UserAPIServiceRemoteFactory, + &deploy.ServiceTypeConfig{ + PathPrefix: "/api/v1/users", + Middlewares: []string{ "recovery", "request-logger" }, + RouteOverrides: map[string]deploy.RouteConfig{ + "Create": { + Path: "POST /", + Middlewares: []string{ "auth" }, + }, + "Delete": { + Path: "DELETE /{id}", + Middlewares: []string{ "auth", "admin" }, + }, + "GetByID": { + Path: "GET /{id}", + }, + "List": { + Path: "GET /", + }, + "Update": { + Path: "PUT /{id}", + Middlewares: []string{ "auth" }, + }, + }, + }, + ) + + // Register lazy service with auto-detected dependencies + lokstra_registry.RegisterLazyService("user-api-service", + "user-api-service-factory", + map[string]any{ + "depends-on": []string{ "cache-service", "user-repository", }, + "api.jwt-secret": lokstra_registry.GetConfig("api.jwt-secret", ""), + "api.pagination.default-page-size": lokstra_registry.GetConfig("api.pagination.default-page-size", 20), + "api.pagination.max-page-size": lokstra_registry.GetConfig("api.pagination.max-page-size", 100), + "api.rate-limit.enabled": lokstra_registry.GetConfig("api.rate-limit.enabled", true), + "api.rate-limit.max-requests": lokstra_registry.GetConfig("api.rate-limit.max-requests", 100), + "api.rate-limit.window": lokstra_registry.GetConfig("api.rate-limit.window", 1*time.Minute), + "api.response.include-metadata": lokstra_registry.GetConfig("api.response.include-metadata", true), + "api.token-expiry": lokstra_registry.GetConfig("api.token-expiry", 24*time.Hour), + }) +} + + diff --git a/docs/02-framework-guide/06-service-annotation.md b/docs/02-framework-guide/06-service-annotation.md new file mode 100644 index 00000000..127614a4 --- /dev/null +++ b/docs/02-framework-guide/06-service-annotation.md @@ -0,0 +1,425 @@ +--- +layout: default +title: "@Service Annotation" +parent: Framework Guide +nav_order: 6 +--- + +# @Service Annotation + +## Overview + +The `@Service` annotation is used to register **pure service classes** (non-HTTP services) with automatic dependency injection and configuration injection. This is ideal for: + +- Business logic services +- Infrastructure services (email, SMS, etc.) +- Helper/utility services +- Background workers +- Any service that doesn't expose HTTP endpoints + +**Key Differences:** +- `@RouterService` → HTTP handlers (controllers) with routes +- `@Service` → Pure services without HTTP endpoints + +## Basic Syntax + +```go +// @Service name="service-name" +type MyService struct { + // Fields with dependency injection +} +``` + +## Features + +### 1. Service Registration + +**Positional argument:** +```go +// @Service "auth-service" +type AuthService struct { + // ... +} +``` + +**Named argument:** +```go +// @Service name="auth-service" +type AuthService struct { + // ... +} +``` + +### 2. Dependency Injection with @Inject + +**Basic dependency:** +```go +// @Service name="auth-service" +type AuthService struct { + // @Inject "user-repository" + UserRepo UserRepository + + // @Inject service="token-service" + TokenSvc TokenService +} +``` + +**Optional dependency:** +```go +// @Service name="auth-service" +type AuthService struct { + // @Inject service="cache-service", optional=true + Cache CacheService // nil if cache-service not found +} +``` + +**Generated code:** +```go +func RegisterAuthService() { + lokstra_registry.RegisterLazyService("auth-service", func(deps map[string]any, cfg map[string]any) any { + return &AuthService{ + UserRepo: lokstra_registry.GetService[UserRepository]("user-repository"), + TokenSvc: lokstra_registry.GetService[TokenService]("token-service"), + Cache: func() CacheService { + if svc, ok := deps["cache-service"]; ok { + return svc.(CacheService) + } + return nil + }(), + } + }, nil) +} +``` + +### 3. Configuration Injection with @InjectCfg + +**Basic config:** +```go +// @Service name="auth-service" +type AuthService struct { + // @InjectCfg "auth.jwt-secret" + JwtSecret string + + // @InjectCfg key="auth.token-expiry" + TokenExpiry time.Duration +} +``` + +**With default values:** +```go +// @Service name="email-service" +type EmailService struct { + // @InjectCfg key="smtp.host", default="localhost" + SMTPHost string + + // @InjectCfg key="smtp.port", default="587" + SMTPPort int + + // @InjectCfg key="smtp.enabled", default="true" + Enabled bool +} +``` + +**Generated code:** +```go +func RegisterEmailService() { + lokstra_registry.RegisterLazyService("email-service", func(deps map[string]any, cfg map[string]any) any { + return &EmailService{ + SMTPHost: lokstra_registry.GetConfig("smtp.host", "localhost"), + SMTPPort: lokstra_registry.GetConfigInt("smtp.port", 587), + Enabled: lokstra_registry.GetConfigBool("smtp.enabled", true), + } + }, nil) +} +``` + +**Supported types (auto-detected):** +- `string` → `GetConfig` +- `int`, `int8`, `int16`, `int32`, `int64`, `uint*` → `GetConfigInt` +- `bool` → `GetConfigBool` +- `float32`, `float64` → `GetConfigFloat` +- `time.Duration` → `GetConfigDuration` + +## Complete Example + +### Service Definition + +```go +package application + +import ( + "time" + "myapp/domain" +) + +// @Service name="auth-service" +type AuthService struct { + // Required dependency + // @Inject "user-repository" + UserRepo domain.UserRepository + + // Optional dependency (nil if not found) + // @Inject service="cache-service", optional=true + Cache domain.CacheService + + // Required config (no default) + // @InjectCfg "auth.jwt-secret" + JwtSecret string + + // Config with defaults + // @InjectCfg key="auth.token-expiry", default="24h" + TokenExpiry time.Duration + + // @InjectCfg key="auth.max-attempts", default="5" + MaxAttempts int + + // @InjectCfg key="auth.debug-mode", default="false" + DebugMode bool +} + +func (s *AuthService) Login(email, password string) (string, error) { + // Check cache if available + if s.Cache != nil { + if cachedUser, err := s.Cache.Get("user:" + email); err == nil { + // Use cached user + } + } + + user, err := s.UserRepo.GetByEmail(email) + if err != nil { + return "", err + } + + // Verify password, generate token, etc. + token := s.generateToken(user.ID, s.TokenExpiry) + + if s.DebugMode { + println("Login successful:", email) + } + + return token, nil +} +``` + +### Configuration (config.yaml) + +```yaml +configs: + auth: + jwt-secret: "your-secret-key-here" + token-expiry: "48h" + max-attempts: 3 + debug-mode: false + smtp: + host: "smtp.gmail.com" + port: 587 + enabled: true + +service-definitions: + user-repository: + type: user-repository-factory + + cache-service: + type: redis-cache-factory + + auth-service: + # Auto-registered via @Service annotation + # Dependencies: user-repository, cache-service (optional) +``` + +### Generated Code + +Running `lokstra.Bootstrap()` generates: + +```go +// zz_generated.lokstra.go +package application + +import ( + "github.com/primadi/lokstra/lokstra_registry" + "myapp/domain" +) + +func init() { + RegisterAuthService() +} + +// RegisterAuthService registers the auth-service with the registry +// Auto-generated from annotations: +// - @Service name="auth-service" +// - @Inject annotations +// - @InjectCfg annotations +func RegisterAuthService() { + lokstra_registry.RegisterLazyService("auth-service", func(deps map[string]any, cfg map[string]any) any { + return &AuthService{ + UserRepo: lokstra_registry.GetService[domain.UserRepository]("user-repository"), + Cache: func() domain.CacheService { + if svc, ok := deps["cache-service"]; ok { + return svc.(domain.CacheService) + } + return nil + }(), + JwtSecret: lokstra_registry.GetConfig("auth.jwt-secret", ""), + TokenExpiry: lokstra_registry.GetConfigDuration("auth.token-expiry", 24*time.Hour), + MaxAttempts: lokstra_registry.GetConfigInt("auth.max-attempts", 5), + DebugMode: lokstra_registry.GetConfigBool("auth.debug-mode", false), + } + }, nil) +} +``` + +## Main Application + +```go +package main + +import ( + "github.com/primadi/lokstra" + "github.com/primadi/lokstra/lokstra_registry" + + // Import packages with @Service annotations + _ "myapp/application" + _ "myapp/infrastructure" +) + +func main() { + // Auto-generates code when @Service changes detected + lokstra.Bootstrap() + + // Start server from config + lokstra_registry.RunServerFromConfig() +} +``` + +## Best Practices + +### 1. Use @Service for Pure Services + +✅ **Good:** +```go +// @Service name="email-service" +type EmailService struct { + // Pure service, no HTTP endpoints +} + +// @Service name="payment-processor" +type PaymentProcessor struct { + // Business logic only +} +``` + +❌ **Bad:** +```go +// Don't use @Service for HTTP controllers +// Use @RouterService instead +``` + +### 2. Separate Configuration Concerns + +✅ **Good:** +```go +// @Service name="sms-service" +type SMSService struct { + // @InjectCfg key="sms.api-key" + APIKey string + + // @InjectCfg key="sms.endpoint", default="https://api.sms.com" + Endpoint string +} +``` + +### 3. Use Optional for Nice-to-Have Dependencies + +✅ **Good:** +```go +// @Service name="user-service" +type UserService struct { + // Required + // @Inject "user-repository" + Repo UserRepository + + // Optional - degrade gracefully + // @Inject service="cache", optional=true + Cache CacheService + + // Optional - feature flag + // @Inject service="analytics", optional=true + Analytics AnalyticsService +} + +func (s *UserService) GetUser(id string) (*User, error) { + // Check cache if available + if s.Cache != nil { + if user, err := s.Cache.Get(id); err == nil { + return user.(*User), nil + } + } + + user, err := s.Repo.GetByID(id) + + // Track analytics if available + if s.Analytics != nil { + s.Analytics.Track("user.get", id) + } + + return user, err +} +``` + +### 4. Type-Safe Config Injection + +✅ **Good:** +```go +// @Service name="config-service" +type ConfigService struct { + // @InjectCfg key="server.port", default="8080" + Port int // Auto-uses GetConfigInt + + // @InjectCfg key="cache.ttl", default="5m" + CacheTTL time.Duration // Auto-uses GetConfigDuration + + // @InjectCfg key="debug", default="false" + Debug bool // Auto-uses GetConfigBool +} +``` + +## Comparison: @Service vs @RouterService + +| Feature | @Service | @RouterService | +|---------|----------|----------------| +| HTTP Routes | ❌ No | ✅ Yes (@Route) | +| Dependency Injection | ✅ @Inject | ✅ @Inject | +| Config Injection | ✅ @InjectCfg | ✅ @InjectCfg | +| Optional Dependencies | ✅ Yes | ✅ Yes | +| Use Case | Business logic, utilities | HTTP controllers | +| Generated Code | `RegisterLazyService` | `RegisterRouterServiceType` | + +## Code Generation + +### Manual Generation + +```bash +lokstra autogen . +``` + +### Automatic Generation (Recommended) + +```go +func main() { + lokstra.Bootstrap() // Auto-generates when changes detected + // ... +} +``` + +### Force Rebuild + +```bash +go run . --generate-only +``` + +## See Also + +- [@RouterService](05-router-service-annotation.md) - For HTTP endpoints +- [@Inject](07-inject-annotation.md) - Dependency injection details +- [@InjectCfg](08-inject-cfg-annotation.md) - Configuration injection +- [Service Registry](09-service-registry.md) - Manual service registration diff --git a/docs/02-framework-guide/07-inject-annotation.md b/docs/02-framework-guide/07-inject-annotation.md new file mode 100644 index 00000000..ee3efa98 --- /dev/null +++ b/docs/02-framework-guide/07-inject-annotation.md @@ -0,0 +1,410 @@ +--- +layout: default +title: "@Inject Annotation" +parent: Framework Guide +nav_order: 7 +--- + +# @Inject Annotation + +## Overview + +The `@Inject` annotation marks struct fields for automatic dependency injection. It works with both `@Service` and `@RouterService` annotations. + +## Basic Syntax + +```go +// @Inject "service-name" +FieldName ServiceType + +// or with named parameter +// @Inject service="service-name" +FieldName ServiceType + +// Optional dependency +// @Inject service="service-name", optional=true +FieldName ServiceType +``` + +## Supported Formats + +### 1. Positional Arguments + +```go +// @Inject "user-repository" +UserRepo UserRepository + +// @Inject "user-repository", false (required) +UserRepo UserRepository + +// @Inject "cache-service", true (optional) +Cache CacheService +``` + +### 2. Named Arguments + +```go +// @Inject service="user-repository" +UserRepo UserRepository + +// @Inject service="cache-service", optional=true +Cache CacheService + +// @Inject service="user-service", optional=false +UserSvc UserService +``` + +## Required Dependencies (Default) + +By default, all dependencies are **required**. If the service is not found, the application will panic at startup. + +```go +// @Service name="order-service" +type OrderService struct { + // @Inject "user-repository" + UserRepo UserRepository // REQUIRED - panic if not found + + // @Inject service="product-repository" + ProductRepo ProductRepository // REQUIRED +} +``` + +**Generated code:** +```go +func RegisterOrderService() { + lokstra_registry.RegisterLazyService("order-service", func(deps map[string]any, cfg map[string]any) any { + return &OrderService{ + UserRepo: lokstra_registry.GetService[UserRepository]("user-repository"), + ProductRepo: lokstra_registry.GetService[ProductRepository]("product-repository"), + } + }, nil) +} +``` + +## Optional Dependencies + +Mark dependencies as optional when they're nice-to-have but not critical: + +```go +// @Service name="user-service" +type UserService struct { + // Required + // @Inject "user-repository" + UserRepo UserRepository + + // Optional - gracefully degrades if not available + // @Inject service="cache-service", optional=true + Cache CacheService + + // Optional - feature is disabled if not available + // @Inject service="analytics-service", optional=true + Analytics AnalyticsService +} +``` + +**Generated code:** +```go +func RegisterUserService() { + lokstra_registry.RegisterLazyService("user-service", func(deps map[string]any, cfg map[string]any) any { + return &UserService{ + UserRepo: lokstra_registry.GetService[UserRepository]("user-repository"), + Cache: func() CacheService { + if svc, ok := deps["cache-service"]; ok { + return svc.(CacheService) + } + return nil + }(), + Analytics: func() AnalyticsService { + if svc, ok := deps["analytics-service"]; ok { + return svc.(AnalyticsService) + } + return nil + }(), + } + }, nil) +} +``` + +**Usage in service:** +```go +func (s *UserService) GetUser(id string) (*User, error) { + // Check cache if available + if s.Cache != nil { + if user, err := s.Cache.Get(id); err == nil { + return user.(*User), nil + } + } + + user, err := s.UserRepo.GetByID(id) + if err != nil { + return nil, err + } + + // Track analytics if available + if s.Analytics != nil { + s.Analytics.Track("user.get", id) + } + + return user, nil +} +``` + +## Examples + +### Basic Dependency Injection + +```go +// @Service name="auth-service" +type AuthService struct { + // @Inject "user-repository" + UserRepo UserRepository + + // @Inject "token-service" + TokenSvc TokenService +} + +func (s *AuthService) Login(email, password string) (string, error) { + user, err := s.UserRepo.GetByEmail(email) + if err != nil { + return "", err + } + + token, err := s.TokenSvc.Generate(user.ID) + return token, err +} +``` + +### Optional Dependencies for Fallback Behavior + +```go +// @Service name="notification-service" +type NotificationService struct { + // Primary notification channel (required) + // @Inject "email-service" + Email EmailService + + // Backup channel (optional) + // @Inject service="sms-service", optional=true + SMS SMSService + + // Monitoring (optional) + // @Inject service="metrics-service", optional=true + Metrics MetricsService +} + +func (s *NotificationService) Send(userID, message string) error { + // Try email first + err := s.Email.Send(userID, message) + + // If email fails and SMS available, try SMS + if err != nil && s.SMS != nil { + err = s.SMS.Send(userID, message) + } + + // Track metrics if available + if s.Metrics != nil { + s.Metrics.IncrementCounter("notifications.sent") + } + + return err +} +``` + +### Feature Flags with Optional Services + +```go +// @Service name="payment-service" +type PaymentService struct { + // Core payment processor (required) + // @Inject "payment-gateway" + Gateway PaymentGateway + + // Fraud detection (optional - enable if available) + // @Inject service="fraud-detector", optional=true + FraudDetector FraudDetector + + // Loyalty points (optional - feature flag) + // @Inject service="loyalty-service", optional=true + Loyalty LoyaltyService +} + +func (s *PaymentService) ProcessPayment(amount float64, userID string) error { + // Check fraud if detector is available + if s.FraudDetector != nil { + if isFraud, _ := s.FraudDetector.Check(userID, amount); isFraud { + return errors.New("transaction flagged as fraudulent") + } + } + + err := s.Gateway.Charge(amount) + if err != nil { + return err + } + + // Award loyalty points if service is available + if s.Loyalty != nil { + s.Loyalty.AwardPoints(userID, amount*0.01) + } + + return nil +} +``` + +## With @RouterService + +```go +// @RouterService name="user-service", prefix="/api/users" +type UserServiceImpl struct { + // @Inject "user-repository" + UserRepo domain.UserRepository + + // @Inject service="cache-service", optional=true + Cache CacheService +} + +// @Route "GET /{id}" +func (s *UserServiceImpl) GetByID(p *GetUserRequest) (*User, error) { + if s.Cache != nil { + // Use cache if available + } + return s.UserRepo.GetByID(p.ID) +} +``` + +## Best Practices + +### 1. Use Optional for Degradable Features + +✅ **Good:** +```go +// @Service name="user-service" +type UserService struct { + // Core functionality - required + // @Inject "user-repository" + Repo UserRepository + + // Performance optimization - optional + // @Inject service="cache", optional=true + Cache CacheService + + // Observability - optional + // @Inject service="metrics", optional=true + Metrics MetricsService +} +``` + +### 2. Always Check Optional Dependencies + +✅ **Good:** +```go +func (s *Service) DoWork() { + if s.Cache != nil { + // Use cache + } + // Continue without cache +} +``` + +❌ **Bad:** +```go +func (s *Service) DoWork() { + s.Cache.Get("key") // Panic if Cache is nil! +} +``` + +### 3. Document Why Dependencies Are Optional + +```go +// @Service name="order-service" +type OrderService struct { + // @Inject "order-repository" + OrderRepo OrderRepository + + // Optional: Email notifications can fail without breaking orders + // @Inject service="email-service", optional=true + EmailSvc EmailService + + // Optional: Payment processing falls back to manual processing + // @Inject service="payment-gateway", optional=true + PaymentGW PaymentGateway +} +``` + +### 4. Use Required for Critical Dependencies + +❌ **Bad:** +```go +// @Service name="auth-service" +type AuthService struct { + // DON'T make critical services optional! + // @Inject service="user-repository", optional=true + UserRepo UserRepository // Auth can't work without users! +} +``` + +✅ **Good:** +```go +// @Service name="auth-service" +type AuthService struct { + // Critical - required + // @Inject "user-repository" + UserRepo UserRepository + + // Nice-to-have - optional + // @Inject service="rate-limiter", optional=true + RateLimiter RateLimiter +} +``` + +## Dependency Resolution Order + +When services depend on each other, Lokstra automatically resolves them in the correct order: + +```go +// @Service name="a-service" +type ServiceA struct { + // @Inject "b-service" + B ServiceB +} + +// @Service name="b-service" +type ServiceB struct { + // @Inject "c-service" + C ServiceC +} + +// @Service name="c-service" +type ServiceC struct { + // No dependencies +} +``` + +**Resolution order:** C → B → A + +## Circular Dependency Detection + +Lokstra detects circular dependencies at startup: + +```go +// ❌ This will fail at startup +// @Service name="a" +type A struct { + // @Inject "b" + B B +} + +// @Service name="b" +type B struct { + // @Inject "a" // Circular! + A A +} +``` + +**Error:** `circular dependency detected: a → b → a` + +## See Also + +- [@Service](06-service-annotation.md) - Service registration +- [@RouterService](05-router-service-annotation.md) - HTTP services +- [@InjectCfg](08-inject-cfg-annotation.md) - Configuration injection +- [Service Registry](09-service-registry.md) - Manual service registration diff --git a/docs/02-framework-guide/08-inject-cfg-annotation.md b/docs/02-framework-guide/08-inject-cfg-annotation.md new file mode 100644 index 00000000..2b89c34d --- /dev/null +++ b/docs/02-framework-guide/08-inject-cfg-annotation.md @@ -0,0 +1,438 @@ +--- +layout: default +title: "@InjectCfg Annotation" +parent: Framework Guide +nav_order: 8 +--- + +# @InjectCfg Annotation + +## Overview + +The `@InjectCfg` annotation injects configuration values from `config.yaml` into service fields. It provides type-safe configuration injection with automatic type detection and optional default values. + +## Basic Syntax + +```go +// @InjectCfg "config.key" +FieldName FieldType + +// or with default value +// @InjectCfg key="config.key", default="value" +FieldName FieldType +``` + +## Supported Formats + +### 1. Positional Arguments + +```go +// @InjectCfg "smtp.host" +SMTPHost string + +// @InjectCfg "smtp.host", "localhost" +SMTPHost string +``` + +### 2. Named Arguments + +```go +// @InjectCfg key="smtp.host" +SMTPHost string + +// @InjectCfg key="smtp.host", default="localhost" +SMTPHost string +``` + +## Supported Types + +The framework automatically detects the field type and uses the appropriate `GetConfig*` function: + +| Go Type | Generated Function | Example Default | +|---------|-------------------|-----------------| +| `string` | `GetConfig` | `""` or custom | +| `int`, `int8`, `int16`, `int32`, `int64` | `GetConfigInt` | `0` or custom | +| `uint`, `uint8`, `uint16`, `uint32`, `uint64` | `GetConfigInt` | `0` or custom | +| `bool` | `GetConfigBool` | `false` or custom | +| `float32`, `float64` | `GetConfigFloat` | `0.0` or custom | +| `time.Duration` | `GetConfigDuration` | `0` or custom | + +## Examples + +### String Configuration + +```go +// @Service name="email-service" +type EmailService struct { + // No default - required in config + // @InjectCfg "smtp.host" + SMTPHost string + + // With default + // @InjectCfg key="smtp.from", default="noreply@example.com" + FromEmail string +} +``` + +**Generated:** +```go +SMTPHost: lokstra_registry.GetConfig("smtp.host", ""), +FromEmail: lokstra_registry.GetConfig("smtp.from", "noreply@example.com"), +``` + +**Config (config.yaml):** +```yaml +configs: + smtp: + host: "smtp.gmail.com" + # from: uses default "noreply@example.com" +``` + +### Integer Configuration + +```go +// @Service name="rate-limiter" +type RateLimiter struct { + // @InjectCfg key="rate.max-requests", default="100" + MaxRequests int + + // @InjectCfg key="rate.window-seconds", default="60" + WindowSeconds int64 +} +``` + +**Generated:** +```go +MaxRequests: lokstra_registry.GetConfigInt("rate.max-requests", 100), +WindowSeconds: lokstra_registry.GetConfigInt("rate.window-seconds", 60), +``` + +### Boolean Configuration + +```go +// @Service name="feature-flags" +type FeatureFlags struct { + // @InjectCfg key="features.new-ui", default="false" + EnableNewUI bool + + // @InjectCfg key="features.debug-mode", default="false" + DebugMode bool +} +``` + +**Generated:** +```go +EnableNewUI: lokstra_registry.GetConfigBool("features.new-ui", false), +DebugMode: lokstra_registry.GetConfigBool("features.debug-mode", false), +``` + +### Duration Configuration + +```go +// @Service name="cache-service" +type CacheService struct { + // @InjectCfg key="cache.ttl", default="5m" + TTL time.Duration + + // @InjectCfg key="cache.cleanup-interval", default="1h" + CleanupInterval time.Duration +} +``` + +**Generated:** +```go +TTL: lokstra_registry.GetConfigDuration("cache.ttl", 5*time.Minute), +CleanupInterval: lokstra_registry.GetConfigDuration("cache.cleanup-interval", 1*time.Hour), +``` + +### Float Configuration + +```go +// @Service name="payment-service" +type PaymentService struct { + // @InjectCfg key="payment.fee-percentage", default="2.5" + FeePercentage float64 + + // @InjectCfg key="payment.min-amount", default="10.0" + MinAmount float32 +} +``` + +**Generated:** +```go +FeePercentage: lokstra_registry.GetConfigFloat("payment.fee-percentage", 2.5), +MinAmount: lokstra_registry.GetConfigFloat("payment.min-amount", 10.0), +``` + +## Complete Example + +### Service with Mixed Config Types + +```go +package application + +import "time" + +// @Service name="app-config" +type AppConfig struct { + // String configs + // @InjectCfg key="app.name", default="MyApp" + AppName string + + // @InjectCfg "app.version" + Version string // Required, no default + + // Integer configs + // @InjectCfg key="server.port", default="8080" + ServerPort int + + // @InjectCfg key="server.max-connections", default="1000" + MaxConnections int64 + + // Boolean configs + // @InjectCfg key="server.enable-gzip", default="true" + EnableGzip bool + + // @InjectCfg key="server.debug", default="false" + Debug bool + + // Duration configs + // @InjectCfg key="server.read-timeout", default="30s" + ReadTimeout time.Duration + + // @InjectCfg key="server.write-timeout", default="30s" + WriteTimeout time.Duration + + // Float configs + // @InjectCfg key="cache.eviction-ratio", default="0.1" + CacheEvictionRatio float64 +} + +func (c *AppConfig) GetServerAddress() string { + return fmt.Sprintf(":%d", c.ServerPort) +} +``` + +### Configuration File + +```yaml +# config.yaml +configs: + app: + name: "ProductionApp" + version: "1.2.3" # Required + + server: + port: 9000 + max-connections: 5000 + enable-gzip: true + debug: false + read-timeout: "60s" + write-timeout: "60s" + + cache: + eviction-ratio: 0.25 +``` + +### Generated Code + +```go +// zz_generated.lokstra.go +func RegisterAppConfig() { + lokstra_registry.RegisterLazyService("app-config", func(deps map[string]any, cfg map[string]any) any { + return &AppConfig{ + AppName: lokstra_registry.GetConfig("app.name", "MyApp"), + Version: lokstra_registry.GetConfig("app.version", ""), + ServerPort: lokstra_registry.GetConfigInt("server.port", 8080), + MaxConnections: lokstra_registry.GetConfigInt("server.max-connections", 1000), + EnableGzip: lokstra_registry.GetConfigBool("server.enable-gzip", true), + Debug: lokstra_registry.GetConfigBool("server.debug", false), + ReadTimeout: lokstra_registry.GetConfigDuration("server.read-timeout", 30*time.Second), + WriteTimeout: lokstra_registry.GetConfigDuration("server.write-timeout", 30*time.Second), + CacheEvictionRatio: lokstra_registry.GetConfigFloat("cache.eviction-ratio", 0.1), + } + }, nil) +} +``` + +## Default Value Behavior + +### With Default Value + +If config key is missing in `config.yaml`, uses the default: + +```go +// @InjectCfg key="smtp.host", default="localhost" +SMTPHost string // "localhost" if not in config +``` + +### Without Default Value + +If config key is missing, uses type's zero value: + +```go +// @InjectCfg "smtp.host" +SMTPHost string // "" if not in config + +// @InjectCfg "server.port" +Port int // 0 if not in config + +// @InjectCfg "debug" +Debug bool // false if not in config +``` + +## Best Practices + +### 1. Use Meaningful Config Keys + +✅ **Good:** +```go +// @InjectCfg key="database.connection-timeout", default="30s" +DBTimeout time.Duration + +// @InjectCfg key="auth.jwt-secret" +JWTSecret string +``` + +❌ **Bad:** +```go +// @InjectCfg "timeout" // Too vague +Timeout time.Duration + +// @InjectCfg "secret" // Not descriptive +Secret string +``` + +### 2. Provide Sensible Defaults + +✅ **Good:** +```go +// @InjectCfg key="server.port", default="8080" +Port int + +// @InjectCfg key="cache.ttl", default="5m" +CacheTTL time.Duration +``` + +### 3. Group Related Configs + +**config.yaml:** +```yaml +configs: + database: + host: "localhost" + port: 5432 + timeout: "30s" + + smtp: + host: "smtp.gmail.com" + port: 587 + from: "noreply@example.com" + + features: + enable-caching: true + enable-logging: true + debug-mode: false +``` + +**Service:** +```go +// @Service name="db-service" +type DBService struct { + // @InjectCfg key="database.host", default="localhost" + Host string + + // @InjectCfg key="database.port", default="5432" + Port int + + // @InjectCfg key="database.timeout", default="30s" + Timeout time.Duration +} +``` + +### 4. Mark Required Configs + +```go +// @Service name="payment-service" +type PaymentService struct { + // REQUIRED - no default + // @InjectCfg "payment.api-key" + APIKey string + + // Optional - has default + // @InjectCfg key="payment.timeout", default="60s" + Timeout time.Duration +} +``` + +## Combining @Inject and @InjectCfg + +```go +// @Service name="notification-service" +type NotificationService struct { + // Service dependencies + // @Inject "user-repository" + UserRepo UserRepository + + // @Inject service="email-service", optional=true + EmailSvc EmailService + + // Configuration + // @InjectCfg key="notifications.enabled", default="true" + Enabled bool + + // @InjectCfg key="notifications.batch-size", default="100" + BatchSize int + + // @InjectCfg key="notifications.retry-attempts", default="3" + RetryAttempts int +} + +func (s *NotificationService) SendNotification(userID, message string) error { + if !s.Enabled { + return nil // Notifications disabled + } + + user, err := s.UserRepo.GetByID(userID) + if err != nil { + return err + } + + if s.EmailSvc != nil { + return s.EmailSvc.Send(user.Email, message) + } + + return nil +} +``` + +## Environment-Specific Configuration + +Use different config files per environment: + +**config.development.yaml:** +```yaml +configs: + smtp: + host: "localhost" + port: 1025 # MailHog + features: + debug-mode: true +``` + +**config.production.yaml:** +```yaml +configs: + smtp: + host: "smtp.sendgrid.net" + port: 587 + features: + debug-mode: false +``` + +## See Also + +- [@Service](06-service-annotation.md) - Service registration +- [@Inject](07-inject-annotation.md) - Dependency injection +- [Configuration Management](10-configuration.md) - Config file structure +- [Service Registry](09-service-registry.md) - Manual configuration diff --git a/docs/AI-AGENT-GUIDE.md b/docs/AI-AGENT-GUIDE.md index 4ef28e8c..13ff5ae1 100644 --- a/docs/AI-AGENT-GUIDE.md +++ b/docs/AI-AGENT-GUIDE.md @@ -797,9 +797,9 @@ LOKSTRA_DEPLOYMENT=production go run . ## Annotation System -### @RouterService Annotation +### @RouterService Annotation (HTTP Controllers) -Generate REST routers automatically from service methods. +Generate REST routers automatically from service methods. Use for services that expose HTTP endpoints. ```go package application @@ -813,6 +813,9 @@ import ( type UserServiceImpl struct { // @Inject "user-repository" UserRepo domain.UserRepository + + // @Inject service="cache-service", optional=true + Cache domain.CacheService } // @Route "GET /users/{id}" @@ -854,12 +857,79 @@ func Register() { } ``` +### @Service Annotation (Pure Services) + +For services without HTTP endpoints (business logic, utilities, infrastructure): + +```go +package application + +import ( + "time" + "myapp/domain" +) + +// @Service name="auth-service" +type AuthService struct { + // Required dependency + // @Inject "user-repository" + UserRepo domain.UserRepository + + // Optional dependency (nil if not found) + // @Inject service="cache-service", optional=true + Cache domain.CacheService + + // Configuration injection (type-safe) + // @InjectCfg "auth.jwt-secret" + JwtSecret string + + // @InjectCfg key="auth.token-expiry", default="24h" + TokenExpiry time.Duration + + // @InjectCfg key="auth.max-attempts", default="5" + MaxAttempts int + + // @InjectCfg key="auth.debug-mode", default="false" + DebugMode bool +} + +func (s *AuthService) Login(email, password string) (string, error) { + // Check cache if available + if s.Cache != nil { + // Use cache + } + + user, err := s.UserRepo.GetByEmail(email) + if err != nil { + return "", err + } + + token := s.generateToken(user.ID, s.TokenExpiry) + + if s.DebugMode { + println("Login successful:", email) + } + + return token, nil +} +``` + +**Config (config.yaml):** +```yaml +configs: + auth: + jwt-secret: "your-secret-key" + token-expiry: "48h" + max-attempts: 3 + debug-mode: false +``` + ### Generate Code from Annotations **Recommended: Automatic with Bootstrap** ```go func main() { - lokstra.Bootstrap() // Auto-generates when @RouterService changes detected + lokstra.Bootstrap() // Auto-generates when @Service/@RouterService changes detected // App code... } ``` @@ -870,7 +940,7 @@ func main() { lokstra autogen . # Or from specific folder -lokstra autegen ./modules/user/application +lokstra autogen ./modules/user/application # Force rebuild all (useful before deployment) go run . --generate-only @@ -882,8 +952,10 @@ go run . --generate-only | Annotation | Purpose | Example | |------------|---------|---------| -| `@RouterService` | Define service router | `@RouterService name="user-service", prefix="/api"` | -| `@Inject` | Dependency injection | `@Inject "user-repository"` | +| `@RouterService` | HTTP service with routes | `@RouterService name="user-service", prefix="/api"` | +| `@Service` | Pure service (no HTTP) | `@Service name="auth-service"` | +| `@Inject` | Dependency injection | `@Inject "user-repository"` or `@Inject service="cache", optional=true` | +| `@InjectCfg` | Config injection | `@InjectCfg "jwt-secret"` or `@InjectCfg key="timeout", default="30s"` | | `@Route` | HTTP route mapping | `@Route "GET /users/{id}"` | **@RouterService Parameters:** @@ -892,6 +964,18 @@ go run . --generate-only - **Supports variables**: `prefix="${api-prefix}"` resolves from config - `middlewares`: Middleware list (optional) +**@Service Parameters:** +- `name`: Service name (required, positional or named) + +**@Inject Parameters:** +- `service`: Service name (required, positional or named) +- `optional`: Boolean, default `false` - set to `true` for optional dependencies + +**@InjectCfg Parameters:** +- `key`: Config key (required, positional or named) +- `default`: Default value (optional) +- Type auto-detected: `string`, `int`, `bool`, `float64`, `time.Duration` + **@Route Parameters:** - HTTP method + path pattern - Supports path parameters: `{id}`, `{userId}`, etc. diff --git a/docs/QUICK-REFERENCE.md b/docs/QUICK-REFERENCE.md index d427a96a..b0b2f669 100644 --- a/docs/QUICK-REFERENCE.md +++ b/docs/QUICK-REFERENCE.md @@ -162,6 +162,96 @@ configs: value: v2 ``` +### Pure Service with @Service (Recommended) + +For non-HTTP services (business logic, utilities, infrastructure): + +```go +// @Service name="auth-service" +type AuthService struct { + // Required dependency + // @Inject "user-repository" + UserRepo UserRepository + + // Optional dependency + // @Inject service="cache-service", optional=true + Cache CacheService + + // Configuration injection + // @InjectCfg "auth.jwt-secret" + JwtSecret string + + // @InjectCfg key="auth.token-expiry", default="24h" + TokenExpiry time.Duration + + // @InjectCfg key="auth.max-attempts", default="5" + MaxAttempts int +} + +func (s *AuthService) Login(email, password string) (string, error) { + // Use cache if available + if s.Cache != nil { + // Check cache + } + + user, err := s.UserRepo.GetByEmail(email) + if err != nil { + return "", err + } + + token := s.generateToken(user.ID, s.TokenExpiry) + return token, nil +} +``` + +**Config (config.yaml):** +```yaml +configs: + auth: + jwt-secret: "your-secret-key" + token-expiry: "48h" + max-attempts: 3 +``` + +**@Service supports:** +- `@Inject` - Service dependencies (required or optional) +- `@InjectCfg` - Configuration injection (auto-typed) +- No HTTP routes (use `@RouterService` for that) + +**Generated code:** +```go +func RegisterAuthService() { + lokstra_registry.RegisterLazyService("auth-service", func(deps map[string]any, cfg map[string]any) any { + return &AuthService{ + UserRepo: lokstra_registry.GetService[UserRepository]("user-repository"), + Cache: // Optional - returns nil if not found + JwtSecret: lokstra_registry.GetConfig("auth.jwt-secret", ""), + TokenExpiry: lokstra_registry.GetConfigDuration("auth.token-expiry", 24*time.Hour), + MaxAttempts: lokstra_registry.GetConfigInt("auth.max-attempts", 5), + } + }, nil) +} +``` + +### Annotation Summary + +| Annotation | Purpose | Where | +|------------|---------|-------| +| `@RouterService` | HTTP service with routes | Above struct | +| `@Service` | Pure service (no HTTP) | Above struct | +| `@Route` | HTTP endpoint | Above method (RouterService only) | +| `@Inject` | Dependency injection | Above field | +| `@InjectCfg` | Config injection | Above field | + +**@Inject parameters:** +- `service` (positional or named) - service name +- `optional` - `true`/`false` (default: `false`) + +**@InjectCfg parameters:** +- `key` (positional or named) - config key +- `default` - default value (optional) +- Type auto-detected: `string`, `int`, `bool`, `float64`, `time.Duration` + ### Manual Service Factory (Advanced) ```go diff --git a/go.mod b/go.mod index 2e92407b..f88006d2 100644 --- a/go.mod +++ b/go.mod @@ -17,11 +17,11 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.1 // indirect + github.com/klauspost/compress v1.18.2 // indirect github.com/kr/text v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.3 // indirect + github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect @@ -43,8 +43,8 @@ require ( github.com/jackc/pgx/v5 v5.7.6 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/quic-go/quic-go v0.57.0 - github.com/redis/go-redis/v9 v9.17.0 + github.com/quic-go/quic-go v0.57.1 + github.com/redis/go-redis/v9 v9.17.2 github.com/shopspring/decimal v1.4.0 github.com/valyala/fasthttp v1.68.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 0a0e4ec6..ba38ca3e 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= -github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -51,16 +51,16 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= -github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE= -github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= -github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM= -github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= +github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= +github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= +github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=