diff --git a/internal/adapters/cli/app.go b/internal/adapters/cli/app.go index 80a0bce..37758d3 100644 --- a/internal/adapters/cli/app.go +++ b/internal/adapters/cli/app.go @@ -87,6 +87,16 @@ var showAppCmd = &cobra.Command{ app, err := appService.ShowApp(context.Background(), region, appKey) if err != nil { + // Handle domain-specific errors with appropriate user feedback + if domain.IsNotFoundError(err) { + fmt.Printf("Application %s/%s (version %s) not found in region %s\n", + appKey.Organization, appKey.Name, appKey.Version, region) + os.Exit(1) + } + if domain.IsValidationError(err) { + fmt.Printf("Validation error: %v\n", err) + os.Exit(1) + } fmt.Printf("Error showing app: %v\n", err) os.Exit(1) } diff --git a/internal/adapters/edgeconnect/appinstance.go b/internal/adapters/edgeconnect/appinstance.go index c89bea5..67e6bae 100644 --- a/internal/adapters/edgeconnect/appinstance.go +++ b/internal/adapters/edgeconnect/appinstance.go @@ -59,8 +59,8 @@ func (c *Client) ShowAppInstance(ctx context.Context, region string, appInstKey defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("app instance %s/%s: %w", - appInstKey.Organization, appInstKey.Name, ErrResourceNotFound) + return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, + "app instance not found") } if resp.StatusCode >= 400 { @@ -74,8 +74,8 @@ func (c *Client) ShowAppInstance(ctx context.Context, region string, appInstKey } if len(appInstances) == 0 { - return nil, fmt.Errorf("app instance %s/%s in region %s: %w", - appInstKey.Organization, appInstKey.Name, region, ErrResourceNotFound) + return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, + "app instance not found") } domainAppInst := toDomainAppInstance(&appInstances[0]) diff --git a/internal/adapters/edgeconnect/appinstance_test.go b/internal/adapters/edgeconnect/appinstance_test.go index 27bfeae..d7addc6 100644 --- a/internal/adapters/edgeconnect/appinstance_test.go +++ b/internal/adapters/edgeconnect/appinstance_test.go @@ -195,7 +195,7 @@ func TestShowAppInstance(t *testing.T) { if tt.expectError { assert.Error(t, err) if tt.expectNotFound { - assert.Contains(t, err.Error(), "resource not found") + assert.True(t, domain.IsNotFoundError(err)) } } else { require.NoError(t, err) diff --git a/internal/adapters/edgeconnect/apps.go b/internal/adapters/edgeconnect/apps.go index 4b49df7..09d0566 100644 --- a/internal/adapters/edgeconnect/apps.go +++ b/internal/adapters/edgeconnect/apps.go @@ -14,10 +14,7 @@ import ( sdkhttp "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/internal/http" ) -var ( - // ErrResourceNotFound indicates the requested resource was not found - ErrResourceNotFound = fmt.Errorf("resource not found") -) +// Note: We now use domain.DomainError for structured error handling instead of simple errors // CreateApp creates a new application in the specified region // Maps to POST /auth/ctrl/CreateApp @@ -66,8 +63,8 @@ func (c *Client) ShowApp(ctx context.Context, region string, appKey domain.AppKe defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("app %s/%s version %s in region %s: %w", - appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound) + return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, + "application not found") } if resp.StatusCode >= 400 { @@ -81,8 +78,8 @@ func (c *Client) ShowApp(ctx context.Context, region string, appKey domain.AppKe } if len(apps) == 0 { - return nil, fmt.Errorf("app %s/%s version %s in region %s: %w", - appKey.Organization, appKey.Name, appKey.Version, region, ErrResourceNotFound) + return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, + "application not found") } domainApp := toDomainApp(&apps[0]) diff --git a/internal/adapters/edgeconnect/apps_test.go b/internal/adapters/edgeconnect/apps_test.go index 2441c7c..d986162 100644 --- a/internal/adapters/edgeconnect/apps_test.go +++ b/internal/adapters/edgeconnect/apps_test.go @@ -176,7 +176,7 @@ func TestShowApp(t *testing.T) { if tt.expectError { assert.Error(t, err) if tt.expectNotFound { - assert.Contains(t, err.Error(), "resource not found") + assert.True(t, domain.IsNotFoundError(err)) } } else { require.NoError(t, err) diff --git a/internal/adapters/edgeconnect/cloudlet.go b/internal/adapters/edgeconnect/cloudlet.go index e208754..b972858 100644 --- a/internal/adapters/edgeconnect/cloudlet.go +++ b/internal/adapters/edgeconnect/cloudlet.go @@ -60,8 +60,8 @@ func (c *Client) ShowCloudlet(ctx context.Context, region string, cloudletKey do defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("cloudlet %s/%s in region %s: %w", - cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, + "cloudlet not found") } if resp.StatusCode >= 400 { @@ -75,8 +75,8 @@ func (c *Client) ShowCloudlet(ctx context.Context, region string, cloudletKey do } if len(cloudlets) == 0 { - return nil, fmt.Errorf("cloudlet %s/%s in region %s: %w", - cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, + "cloudlet not found") } domainCloudlet := toDomainCloudlet(&cloudlets[0]) @@ -172,8 +172,8 @@ func (c *Client) GetCloudletManifest(ctx context.Context, cloudletKey domain.Clo defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("cloudlet manifest %s/%s in region %s: %w", - cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "GetCloudletManifest", cloudletKey, region, + "cloudlet manifest not found") } if resp.StatusCode >= 400 { @@ -211,8 +211,8 @@ func (c *Client) GetCloudletResourceUsage(ctx context.Context, cloudletKey domai defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("cloudlet resource usage %s/%s in region %s: %w", - cloudletKey.Organization, cloudletKey.Name, region, ErrResourceNotFound) + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "GetCloudletResourceUsage", cloudletKey, region, + "cloudlet resource usage not found") } if resp.StatusCode >= 400 { diff --git a/internal/adapters/edgeconnect/cloudlet_test.go b/internal/adapters/edgeconnect/cloudlet_test.go index 057baa8..16dfe27 100644 --- a/internal/adapters/edgeconnect/cloudlet_test.go +++ b/internal/adapters/edgeconnect/cloudlet_test.go @@ -175,7 +175,7 @@ func TestShowCloudlet(t *testing.T) { if tt.expectError { assert.Error(t, err) if tt.expectNotFound { - assert.Contains(t, err.Error(), "resource not found") + assert.True(t, domain.IsNotFoundError(err)) } } else { require.NoError(t, err) @@ -351,7 +351,7 @@ func TestGetCloudletManifest(t *testing.T) { if tt.expectError { assert.Error(t, err) if tt.expectNotFound { - assert.Contains(t, err.Error(), "resource not found") + assert.True(t, domain.IsNotFoundError(err)) } } else { require.NoError(t, err) @@ -423,7 +423,7 @@ func TestGetCloudletResourceUsage(t *testing.T) { if tt.expectError { assert.Error(t, err) if tt.expectNotFound { - assert.Contains(t, err.Error(), "resource not found") + assert.True(t, domain.IsNotFoundError(err)) } } else { require.NoError(t, err) diff --git a/internal/core/domain/errors.go b/internal/core/domain/errors.go new file mode 100644 index 0000000..1194e72 --- /dev/null +++ b/internal/core/domain/errors.go @@ -0,0 +1,309 @@ +// Package domain contains domain-specific error types for the EdgeConnect client +package domain + +import ( + "errors" + "fmt" + "strings" +) + +// ErrorCode represents different types of domain errors +type ErrorCode int + +const ( + // Resource errors + ErrResourceNotFound ErrorCode = iota + ErrResourceAlreadyExists + ErrResourceConflict + + // Validation errors + ErrValidationFailed + ErrInvalidConfiguration + ErrInvalidInput + + // Business logic errors + ErrQuotaExceeded + ErrInsufficientPermissions + ErrOperationNotAllowed + + // Infrastructure errors + ErrNetworkError + ErrAuthenticationFailed + ErrServiceUnavailable + ErrTimeout + + // Internal errors + ErrInternalError + ErrUnknownError +) + +// String returns a human-readable string representation of the error code +func (e ErrorCode) String() string { + switch e { + case ErrResourceNotFound: + return "RESOURCE_NOT_FOUND" + case ErrResourceAlreadyExists: + return "RESOURCE_ALREADY_EXISTS" + case ErrResourceConflict: + return "RESOURCE_CONFLICT" + case ErrValidationFailed: + return "VALIDATION_FAILED" + case ErrInvalidConfiguration: + return "INVALID_CONFIGURATION" + case ErrInvalidInput: + return "INVALID_INPUT" + case ErrQuotaExceeded: + return "QUOTA_EXCEEDED" + case ErrInsufficientPermissions: + return "INSUFFICIENT_PERMISSIONS" + case ErrOperationNotAllowed: + return "OPERATION_NOT_ALLOWED" + case ErrNetworkError: + return "NETWORK_ERROR" + case ErrAuthenticationFailed: + return "AUTHENTICATION_FAILED" + case ErrServiceUnavailable: + return "SERVICE_UNAVAILABLE" + case ErrTimeout: + return "TIMEOUT" + case ErrInternalError: + return "INTERNAL_ERROR" + case ErrUnknownError: + return "UNKNOWN_ERROR" + default: + return "UNDEFINED_ERROR" + } +} + +// DomainError represents a domain-specific error with detailed context +type DomainError struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` + Cause error `json:"-"` + Context map[string]interface{} `json:"context,omitempty"` + Resource *ResourceIdentifier `json:"resource,omitempty"` + Operation string `json:"operation,omitempty"` + Retryable bool `json:"retryable"` +} + +// ResourceIdentifier provides context about the resource involved in the error +type ResourceIdentifier struct { + Type string `json:"type"` + Name string `json:"name"` + Organization string `json:"organization,omitempty"` + Region string `json:"region,omitempty"` + Version string `json:"version,omitempty"` +} + +// Error implements the error interface +func (e *DomainError) Error() string { + var parts []string + + if e.Operation != "" { + parts = append(parts, fmt.Sprintf("operation %s failed", e.Operation)) + } + + if e.Resource != nil { + parts = append(parts, fmt.Sprintf("resource %s", e.resourceString())) + } + + parts = append(parts, e.Message) + + if e.Details != "" { + parts = append(parts, e.Details) + } + + result := strings.Join(parts, ": ") + + if e.Cause != nil { + result = fmt.Sprintf("%s (caused by: %v)", result, e.Cause) + } + + return result +} + +// Unwrap returns the underlying cause for error wrapping +func (e *DomainError) Unwrap() error { + return e.Cause +} + +// Is checks if the error matches a specific error code +func (e *DomainError) Is(target error) bool { + if de, ok := target.(*DomainError); ok { + return e.Code == de.Code + } + return false +} + +// IsRetryable indicates whether the operation should be retried +func (e *DomainError) IsRetryable() bool { + return e.Retryable +} + +// WithContext adds context information to the error +func (e *DomainError) WithContext(key string, value interface{}) *DomainError { + if e.Context == nil { + e.Context = make(map[string]interface{}) + } + e.Context[key] = value + return e +} + +// WithDetails adds additional details to the error +func (e *DomainError) WithDetails(details string) *DomainError { + e.Details = details + return e +} + +func (e *DomainError) resourceString() string { + if e.Resource == nil { + return "" + } + + parts := []string{e.Resource.Type} + + if e.Resource.Organization != "" && e.Resource.Name != "" { + parts = append(parts, fmt.Sprintf("%s/%s", e.Resource.Organization, e.Resource.Name)) + } else if e.Resource.Name != "" { + parts = append(parts, e.Resource.Name) + } + + if e.Resource.Version != "" { + parts = append(parts, fmt.Sprintf("version %s", e.Resource.Version)) + } + + if e.Resource.Region != "" { + parts = append(parts, fmt.Sprintf("in region %s", e.Resource.Region)) + } + + return strings.Join(parts, " ") +} + +// Error creation helpers + +// NewDomainError creates a new domain error with the specified code and message +func NewDomainError(code ErrorCode, message string) *DomainError { + return &DomainError{ + Code: code, + Message: message, + Retryable: isRetryableByDefault(code), + } +} + +// NewDomainErrorWithCause creates a new domain error with an underlying cause +func NewDomainErrorWithCause(code ErrorCode, message string, cause error) *DomainError { + return &DomainError{ + Code: code, + Message: message, + Cause: cause, + Retryable: isRetryableByDefault(code), + } +} + +// NewResourceError creates a domain error for resource-related operations +func NewResourceError(code ErrorCode, operation string, resource *ResourceIdentifier, message string) *DomainError { + return &DomainError{ + Code: code, + Message: message, + Operation: operation, + Resource: resource, + Retryable: isRetryableByDefault(code), + } +} + +func isRetryableByDefault(code ErrorCode) bool { + switch code { + case ErrNetworkError, ErrServiceUnavailable, ErrTimeout, ErrInternalError: + return true + default: + return false + } +} + +// Predefined errors for common scenarios + +var ( + // Resource errors + ErrAppNotFound = NewDomainError(ErrResourceNotFound, "application not found") + ErrAppExists = NewDomainError(ErrResourceAlreadyExists, "application already exists") + ErrInstanceNotFound = NewDomainError(ErrResourceNotFound, "app instance not found") + ErrInstanceExists = NewDomainError(ErrResourceAlreadyExists, "app instance already exists") + ErrCloudletNotFound = NewDomainError(ErrResourceNotFound, "cloudlet not found") + + // Validation errors + ErrInvalidAppKey = NewDomainError(ErrValidationFailed, "invalid application key") + ErrInvalidInstanceKey = NewDomainError(ErrValidationFailed, "invalid app instance key") + ErrInvalidCloudletKey = NewDomainError(ErrValidationFailed, "invalid cloudlet key") + ErrMissingRegion = NewDomainError(ErrValidationFailed, "region is required") + + // Business logic errors + ErrDeploymentFailed = NewDomainError(ErrOperationNotAllowed, "deployment failed") + ErrRollbackFailed = NewDomainError(ErrOperationNotAllowed, "rollback failed") + ErrPlanningFailed = NewDomainError(ErrOperationNotAllowed, "deployment planning failed") +) + +// Helper functions for creating specific error scenarios + +// NewAppError creates an error related to application operations +func NewAppError(code ErrorCode, operation string, appKey AppKey, region string, message string) *DomainError { + resource := &ResourceIdentifier{ + Type: "app", + Organization: appKey.Organization, + Name: appKey.Name, + Version: appKey.Version, + Region: region, + } + return NewResourceError(code, operation, resource, message) +} + +// NewInstanceError creates an error related to app instance operations +func NewInstanceError(code ErrorCode, operation string, instanceKey AppInstanceKey, region string, message string) *DomainError { + resource := &ResourceIdentifier{ + Type: "app-instance", + Organization: instanceKey.Organization, + Name: instanceKey.Name, + Region: region, + } + return NewResourceError(code, operation, resource, message) +} + +// NewCloudletError creates an error related to cloudlet operations +func NewCloudletError(code ErrorCode, operation string, cloudletKey CloudletKey, region string, message string) *DomainError { + resource := &ResourceIdentifier{ + Type: "cloudlet", + Organization: cloudletKey.Organization, + Name: cloudletKey.Name, + Region: region, + } + return NewResourceError(code, operation, resource, message) +} + +// Error checking utilities + +// IsNotFoundError checks if an error indicates a resource was not found +func IsNotFoundError(err error) bool { + var de *DomainError + return errors.As(err, &de) && de.Code == ErrResourceNotFound +} + +// IsValidationError checks if an error is a validation error +func IsValidationError(err error) bool { + var de *DomainError + return errors.As(err, &de) && (de.Code == ErrValidationFailed || de.Code == ErrInvalidInput || de.Code == ErrInvalidConfiguration) +} + +// IsRetryableError checks if an error is retryable +func IsRetryableError(err error) bool { + var de *DomainError + if errors.As(err, &de) { + return de.IsRetryable() + } + return false +} + +// IsAuthenticationError checks if an error is authentication-related +func IsAuthenticationError(err error) bool { + var de *DomainError + return errors.As(err, &de) && de.Code == ErrAuthenticationFailed +} \ No newline at end of file diff --git a/internal/core/domain/errors_test.go b/internal/core/domain/errors_test.go new file mode 100644 index 0000000..fcc6f6d --- /dev/null +++ b/internal/core/domain/errors_test.go @@ -0,0 +1,207 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestDomainError_Creation(t *testing.T) { + tests := []struct { + name string + code ErrorCode + message string + expected string + }{ + { + name: "simple error", + code: ErrResourceNotFound, + message: "test resource not found", + expected: "test resource not found", + }, + { + name: "validation error", + code: ErrValidationFailed, + message: "invalid input", + expected: "invalid input", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := NewDomainError(tt.code, tt.message) + if err.Error() != tt.expected { + t.Errorf("Expected error message %q, got %q", tt.expected, err.Error()) + } + if err.Code != tt.code { + t.Errorf("Expected error code %v, got %v", tt.code, err.Code) + } + }) + } +} + +func TestDomainError_WithContext(t *testing.T) { + err := NewDomainError(ErrResourceNotFound, "test error") + err = err.WithContext("user_id", "123") + err = err.WithContext("operation", "create") + + if len(err.Context) != 2 { + t.Errorf("Expected 2 context items, got %d", len(err.Context)) + } + + if err.Context["user_id"] != "123" { + t.Errorf("Expected user_id to be '123', got %v", err.Context["user_id"]) + } +} + +func TestDomainError_WithDetails(t *testing.T) { + err := NewDomainError(ErrValidationFailed, "validation failed") + err = err.WithDetails("name field is required") + + expectedError := "validation failed: name field is required" + if err.Error() != expectedError { + t.Errorf("Expected error %q, got %q", expectedError, err.Error()) + } +} + +func TestDomainError_WithCause(t *testing.T) { + cause := errors.New("network timeout") + err := NewDomainErrorWithCause(ErrNetworkError, "operation failed", cause) + + if err.Cause != cause { + t.Error("Expected cause to be preserved") + } + + if !errors.Is(err, cause) { + t.Error("Expected error to wrap the cause") + } +} + +func TestAppError_Creation(t *testing.T) { + appKey := AppKey{ + Organization: "test-org", + Name: "test-app", + Version: "1.0.0", + } + + err := NewAppError(ErrResourceNotFound, "ShowApp", appKey, "US", "not found") + + expected := "operation ShowApp failed: resource app test-org/test-app version 1.0.0 in region US: not found" + if err.Error() != expected { + t.Errorf("Expected error %q, got %q", expected, err.Error()) + } + + if err.Resource.Type != "app" { + t.Errorf("Expected resource type 'app', got %q", err.Resource.Type) + } +} + +func TestInstanceError_Creation(t *testing.T) { + instanceKey := AppInstanceKey{ + Organization: "test-org", + Name: "test-instance", + CloudletKey: CloudletKey{ + Organization: "cloudlet-org", + Name: "cloudlet-name", + }, + } + + err := NewInstanceError(ErrResourceNotFound, "ShowAppInstance", instanceKey, "US", "not found") + + if err.Resource.Type != "app-instance" { + t.Errorf("Expected resource type 'app-instance', got %q", err.Resource.Type) + } + + if err.Operation != "ShowAppInstance" { + t.Errorf("Expected operation 'ShowAppInstance', got %q", err.Operation) + } +} + +func TestErrorChecking_Functions(t *testing.T) { + tests := []struct { + name string + err error + checkFn func(error) bool + expected bool + }{ + { + name: "IsNotFoundError with not found error", + err: NewDomainError(ErrResourceNotFound, "not found"), + checkFn: IsNotFoundError, + expected: true, + }, + { + name: "IsNotFoundError with validation error", + err: NewDomainError(ErrValidationFailed, "invalid"), + checkFn: IsNotFoundError, + expected: false, + }, + { + name: "IsValidationError with validation error", + err: NewDomainError(ErrValidationFailed, "invalid"), + checkFn: IsValidationError, + expected: true, + }, + { + name: "IsRetryableError with network error", + err: NewDomainError(ErrNetworkError, "connection failed"), + checkFn: IsRetryableError, + expected: true, + }, + { + name: "IsRetryableError with validation error", + err: NewDomainError(ErrValidationFailed, "invalid"), + checkFn: IsRetryableError, + expected: false, + }, + { + name: "IsAuthenticationError with auth error", + err: NewDomainError(ErrAuthenticationFailed, "unauthorized"), + checkFn: IsAuthenticationError, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.checkFn(tt.err) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestErrorCode_String(t *testing.T) { + tests := []struct { + code ErrorCode + expected string + }{ + {ErrResourceNotFound, "RESOURCE_NOT_FOUND"}, + {ErrValidationFailed, "VALIDATION_FAILED"}, + {ErrNetworkError, "NETWORK_ERROR"}, + {ErrUnknownError, "UNKNOWN_ERROR"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if tt.code.String() != tt.expected { + t.Errorf("Expected %q, got %q", tt.expected, tt.code.String()) + } + }) + } +} + +func TestPredefinedErrors(t *testing.T) { + // Test that predefined errors have correct codes + if ErrAppNotFound.Code != ErrResourceNotFound { + t.Error("ErrAppNotFound should have ErrResourceNotFound code") + } + + if ErrInvalidAppKey.Code != ErrValidationFailed { + t.Error("ErrInvalidAppKey should have ErrValidationFailed code") + } + + if ErrDeploymentFailed.Code != ErrOperationNotAllowed { + t.Error("ErrDeploymentFailed should have ErrOperationNotAllowed code") + } +} \ No newline at end of file diff --git a/internal/core/services/app_service.go b/internal/core/services/app_service.go index f3782be..46a93a8 100644 --- a/internal/core/services/app_service.go +++ b/internal/core/services/app_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "strings" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driving" @@ -16,21 +17,140 @@ func NewAppService(appRepo driven.AppRepository) driving.AppService { } func (s *appService) CreateApp(ctx context.Context, region string, app *domain.App) error { - return s.appRepo.CreateApp(ctx, region, app) + // Validate inputs before delegating to repository + if err := s.validateApp(app); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appRepo.CreateApp(ctx, region, app); err != nil { + // Map repository errors to domain errors with context + if domain.IsNotFoundError(err) { + return domain.NewAppError(domain.ErrResourceConflict, "CreateApp", app.Key, region, + "app may already exist or have conflicting configuration") + } + return domain.NewAppError(domain.ErrInternalError, "CreateApp", app.Key, region, + "failed to create application").WithDetails(err.Error()) + } + + return nil } func (s *appService) ShowApp(ctx context.Context, region string, appKey domain.AppKey) (*domain.App, error) { - return s.appRepo.ShowApp(ctx, region, appKey) + if err := s.validateAppKey(appKey); err != nil { + return nil, err + } + + if region == "" { + return nil, domain.ErrMissingRegion + } + + app, err := s.appRepo.ShowApp(ctx, region, appKey) + if err != nil { + if domain.IsNotFoundError(err) { + return nil, domain.NewAppError(domain.ErrResourceNotFound, "ShowApp", appKey, region, + "application does not exist") + } + return nil, domain.NewAppError(domain.ErrInternalError, "ShowApp", appKey, region, + "failed to retrieve application").WithDetails(err.Error()) + } + + return app, nil } func (s *appService) ShowApps(ctx context.Context, region string, appKey domain.AppKey) ([]domain.App, error) { - return s.appRepo.ShowApps(ctx, region, appKey) + if region == "" { + return nil, domain.ErrMissingRegion + } + + apps, err := s.appRepo.ShowApps(ctx, region, appKey) + if err != nil { + return nil, domain.NewAppError(domain.ErrInternalError, "ShowApps", appKey, region, + "failed to list applications").WithDetails(err.Error()) + } + + return apps, nil } func (s *appService) DeleteApp(ctx context.Context, region string, appKey domain.AppKey) error { - return s.appRepo.DeleteApp(ctx, region, appKey) + if err := s.validateAppKey(appKey); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appRepo.DeleteApp(ctx, region, appKey); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewAppError(domain.ErrResourceNotFound, "DeleteApp", appKey, region, + "application does not exist") + } + return domain.NewAppError(domain.ErrInternalError, "DeleteApp", appKey, region, + "failed to delete application").WithDetails(err.Error()) + } + + return nil } func (s *appService) UpdateApp(ctx context.Context, region string, app *domain.App) error { - return s.appRepo.UpdateApp(ctx, region, app) + if err := s.validateApp(app); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appRepo.UpdateApp(ctx, region, app); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewAppError(domain.ErrResourceNotFound, "UpdateApp", app.Key, region, + "application does not exist") + } + return domain.NewAppError(domain.ErrInternalError, "UpdateApp", app.Key, region, + "failed to update application").WithDetails(err.Error()) + } + + return nil +} + +// validateApp performs business logic validation on an app +func (s *appService) validateApp(app *domain.App) error { + if app == nil { + return domain.NewDomainError(domain.ErrValidationFailed, "application cannot be nil") + } + + if err := s.validateAppKey(app.Key); err != nil { + return err + } + + if strings.TrimSpace(app.ImagePath) == "" { + return domain.NewDomainError(domain.ErrValidationFailed, "image path is required") + } + + if strings.TrimSpace(app.Deployment) == "" { + return domain.NewDomainError(domain.ErrValidationFailed, "deployment type is required") + } + + return nil +} + +// validateAppKey validates an application key +func (s *appService) validateAppKey(key domain.AppKey) error { + if strings.TrimSpace(key.Organization) == "" { + return domain.ErrInvalidAppKey.WithDetails("organization is required") + } + + if strings.TrimSpace(key.Name) == "" { + return domain.ErrInvalidAppKey.WithDetails("name is required") + } + + if strings.TrimSpace(key.Version) == "" { + return domain.ErrInvalidAppKey.WithDetails("version is required") + } + + return nil } diff --git a/internal/core/services/cloudlet_service.go b/internal/core/services/cloudlet_service.go index 0d014f4..38c65af 100644 --- a/internal/core/services/cloudlet_service.go +++ b/internal/core/services/cloudlet_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "strings" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driving" @@ -16,17 +17,105 @@ func NewCloudletService(cloudletRepo driven.CloudletRepository) driving.Cloudlet } func (s *cloudletService) CreateCloudlet(ctx context.Context, region string, cloudlet *domain.Cloudlet) error { - return s.cloudletRepo.CreateCloudlet(ctx, region, cloudlet) + if err := s.validateCloudlet(cloudlet); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.cloudletRepo.CreateCloudlet(ctx, region, cloudlet); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewCloudletError(domain.ErrResourceConflict, "CreateCloudlet", cloudlet.Key, region, + "cloudlet may already exist or have conflicting configuration") + } + return domain.NewCloudletError(domain.ErrInternalError, "CreateCloudlet", cloudlet.Key, region, + "failed to create cloudlet").WithDetails(err.Error()) + } + + return nil } func (s *cloudletService) ShowCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) (*domain.Cloudlet, error) { - return s.cloudletRepo.ShowCloudlet(ctx, region, cloudletKey) + if err := s.validateCloudletKey(cloudletKey); err != nil { + return nil, err + } + + if region == "" { + return nil, domain.ErrMissingRegion + } + + cloudlet, err := s.cloudletRepo.ShowCloudlet(ctx, region, cloudletKey) + if err != nil { + if domain.IsNotFoundError(err) { + return nil, domain.NewCloudletError(domain.ErrResourceNotFound, "ShowCloudlet", cloudletKey, region, + "cloudlet does not exist") + } + return nil, domain.NewCloudletError(domain.ErrInternalError, "ShowCloudlet", cloudletKey, region, + "failed to retrieve cloudlet").WithDetails(err.Error()) + } + + return cloudlet, nil } func (s *cloudletService) ShowCloudlets(ctx context.Context, region string, cloudletKey domain.CloudletKey) ([]domain.Cloudlet, error) { - return s.cloudletRepo.ShowCloudlets(ctx, region, cloudletKey) + if region == "" { + return nil, domain.ErrMissingRegion + } + + cloudlets, err := s.cloudletRepo.ShowCloudlets(ctx, region, cloudletKey) + if err != nil { + return nil, domain.NewCloudletError(domain.ErrInternalError, "ShowCloudlets", cloudletKey, region, + "failed to list cloudlets").WithDetails(err.Error()) + } + + return cloudlets, nil } func (s *cloudletService) DeleteCloudlet(ctx context.Context, region string, cloudletKey domain.CloudletKey) error { - return s.cloudletRepo.DeleteCloudlet(ctx, region, cloudletKey) + if err := s.validateCloudletKey(cloudletKey); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.cloudletRepo.DeleteCloudlet(ctx, region, cloudletKey); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewCloudletError(domain.ErrResourceNotFound, "DeleteCloudlet", cloudletKey, region, + "cloudlet does not exist") + } + return domain.NewCloudletError(domain.ErrInternalError, "DeleteCloudlet", cloudletKey, region, + "failed to delete cloudlet").WithDetails(err.Error()) + } + + return nil +} + +// validateCloudlet performs business logic validation on a cloudlet +func (s *cloudletService) validateCloudlet(cloudlet *domain.Cloudlet) error { + if cloudlet == nil { + return domain.NewDomainError(domain.ErrValidationFailed, "cloudlet cannot be nil") + } + + if err := s.validateCloudletKey(cloudlet.Key); err != nil { + return err + } + + return nil +} + +// validateCloudletKey validates a cloudlet key +func (s *cloudletService) validateCloudletKey(key domain.CloudletKey) error { + if strings.TrimSpace(key.Organization) == "" { + return domain.ErrInvalidCloudletKey.WithDetails("organization is required") + } + + if strings.TrimSpace(key.Name) == "" { + return domain.ErrInvalidCloudletKey.WithDetails("name is required") + } + + return nil } diff --git a/internal/core/services/instance_service.go b/internal/core/services/instance_service.go index 8788500..a9abfb6 100644 --- a/internal/core/services/instance_service.go +++ b/internal/core/services/instance_service.go @@ -2,6 +2,7 @@ package services import ( "context" + "strings" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/domain" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driven" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/core/ports/driving" @@ -16,25 +17,161 @@ func NewAppInstanceService(appInstanceRepo driven.AppInstanceRepository) driving } func (s *appInstanceService) CreateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { - return s.appInstanceRepo.CreateAppInstance(ctx, region, appInst) + if err := s.validateAppInstance(appInst); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appInstanceRepo.CreateAppInstance(ctx, region, appInst); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewInstanceError(domain.ErrResourceConflict, "CreateAppInstance", appInst.Key, region, + "app instance may already exist or have conflicting configuration") + } + return domain.NewInstanceError(domain.ErrInternalError, "CreateAppInstance", appInst.Key, region, + "failed to create app instance").WithDetails(err.Error()) + } + + return nil } func (s *appInstanceService) ShowAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) (*domain.AppInstance, error) { - return s.appInstanceRepo.ShowAppInstance(ctx, region, appInstKey) + if err := s.validateAppInstanceKey(appInstKey); err != nil { + return nil, err + } + + if region == "" { + return nil, domain.ErrMissingRegion + } + + instance, err := s.appInstanceRepo.ShowAppInstance(ctx, region, appInstKey) + if err != nil { + if domain.IsNotFoundError(err) { + return nil, domain.NewInstanceError(domain.ErrResourceNotFound, "ShowAppInstance", appInstKey, region, + "app instance does not exist") + } + return nil, domain.NewInstanceError(domain.ErrInternalError, "ShowAppInstance", appInstKey, region, + "failed to retrieve app instance").WithDetails(err.Error()) + } + + return instance, nil } func (s *appInstanceService) ShowAppInstances(ctx context.Context, region string, appInstKey domain.AppInstanceKey) ([]domain.AppInstance, error) { - return s.appInstanceRepo.ShowAppInstances(ctx, region, appInstKey) + if region == "" { + return nil, domain.ErrMissingRegion + } + + instances, err := s.appInstanceRepo.ShowAppInstances(ctx, region, appInstKey) + if err != nil { + return nil, domain.NewInstanceError(domain.ErrInternalError, "ShowAppInstances", appInstKey, region, + "failed to list app instances").WithDetails(err.Error()) + } + + return instances, nil } func (s *appInstanceService) DeleteAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { - return s.appInstanceRepo.DeleteAppInstance(ctx, region, appInstKey) + if err := s.validateAppInstanceKey(appInstKey); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appInstanceRepo.DeleteAppInstance(ctx, region, appInstKey); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewInstanceError(domain.ErrResourceNotFound, "DeleteAppInstance", appInstKey, region, + "app instance does not exist") + } + return domain.NewInstanceError(domain.ErrInternalError, "DeleteAppInstance", appInstKey, region, + "failed to delete app instance").WithDetails(err.Error()) + } + + return nil } func (s *appInstanceService) UpdateAppInstance(ctx context.Context, region string, appInst *domain.AppInstance) error { - return s.appInstanceRepo.UpdateAppInstance(ctx, region, appInst) + if err := s.validateAppInstance(appInst); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appInstanceRepo.UpdateAppInstance(ctx, region, appInst); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewInstanceError(domain.ErrResourceNotFound, "UpdateAppInstance", appInst.Key, region, + "app instance does not exist") + } + return domain.NewInstanceError(domain.ErrInternalError, "UpdateAppInstance", appInst.Key, region, + "failed to update app instance").WithDetails(err.Error()) + } + + return nil } func (s *appInstanceService) RefreshAppInstance(ctx context.Context, region string, appInstKey domain.AppInstanceKey) error { - return s.appInstanceRepo.RefreshAppInstance(ctx, region, appInstKey) + if err := s.validateAppInstanceKey(appInstKey); err != nil { + return err + } + + if region == "" { + return domain.ErrMissingRegion + } + + if err := s.appInstanceRepo.RefreshAppInstance(ctx, region, appInstKey); err != nil { + if domain.IsNotFoundError(err) { + return domain.NewInstanceError(domain.ErrResourceNotFound, "RefreshAppInstance", appInstKey, region, + "app instance does not exist") + } + return domain.NewInstanceError(domain.ErrInternalError, "RefreshAppInstance", appInstKey, region, + "failed to refresh app instance").WithDetails(err.Error()) + } + + return nil +} + +// validateAppInstance performs business logic validation on an app instance +func (s *appInstanceService) validateAppInstance(appInst *domain.AppInstance) error { + if appInst == nil { + return domain.NewDomainError(domain.ErrValidationFailed, "app instance cannot be nil") + } + + if err := s.validateAppInstanceKey(appInst.Key); err != nil { + return err + } + + // Validate flavor if present + if strings.TrimSpace(appInst.Flavor.Name) == "" { + return domain.NewDomainError(domain.ErrValidationFailed, "flavor name is required") + } + + return nil +} + +// validateAppInstanceKey validates an app instance key +func (s *appInstanceService) validateAppInstanceKey(key domain.AppInstanceKey) error { + if strings.TrimSpace(key.Organization) == "" { + return domain.ErrInvalidInstanceKey.WithDetails("organization is required") + } + + if strings.TrimSpace(key.Name) == "" { + return domain.ErrInvalidInstanceKey.WithDetails("name is required") + } + + // Validate embedded cloudlet key + if strings.TrimSpace(key.CloudletKey.Organization) == "" { + return domain.ErrInvalidInstanceKey.WithDetails("cloudlet organization is required") + } + + if strings.TrimSpace(key.CloudletKey.Name) == "" { + return domain.ErrInvalidInstanceKey.WithDetails("cloudlet name is required") + } + + return nil } diff --git a/sdk/examples/comprehensive/main.go b/sdk/examples/comprehensive/main.go index 4eeafb5..23ee8a4 100644 --- a/sdk/examples/comprehensive/main.go +++ b/sdk/examples/comprehensive/main.go @@ -367,7 +367,7 @@ func runComprehensiveWorkflow(ctx context.Context, c *edgeconnect.Client, config Version: appKey.Version, } _, err = c.ShowApp(ctx, config.Region, domainAppKey) - if err != nil && fmt.Sprintf("%v", err) == edgeconnect.ErrResourceNotFound.Error() { + if err != nil && domain.IsNotFoundError(err) { fmt.Printf("✅ Cleanup verified - app no longer exists\n") } else if err != nil { fmt.Printf("✅ Cleanup appears successful (verification returned: %v)\n", err) diff --git a/sdk/examples/deploy_app.go b/sdk/examples/deploy_app.go index b870a39..e539ca8 100644 --- a/sdk/examples/deploy_app.go +++ b/sdk/examples/deploy_app.go @@ -9,7 +9,6 @@ import ( "log" "net/http" "os" - "strings" "time" "edp.buildth.ing/DevFW-CICD/edge-connect-client/internal/adapters/edgeconnect" @@ -146,7 +145,7 @@ func demonstrateAppLifecycle(ctx context.Context, edgeClient *edgeconnect.Client } _, err = edgeClient.ShowApp(ctx, region, domainAppKey) if err != nil { - if strings.Contains(fmt.Sprintf("%v", err), edgeconnect.ErrResourceNotFound.Error()) { + if domain.IsNotFoundError(err) { fmt.Printf("✅ App successfully deleted (not found)\n") } else { return fmt.Errorf("unexpected error verifying deletion: %w", err)