From ae5a1f7877a0d06d6ebabf64970fe1c191195d6b Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 7 Dec 2025 20:49:46 +0200 Subject: [PATCH] chore: single table design configurations --- cmd/app/internal/database/factory.go | 54 ++++++ cmd/app/internal/database/factory_test.go | 202 ++++++++++++++++++++++ deployments/app/cdk.go | 127 +++++++++++++- 3 files changed, 376 insertions(+), 7 deletions(-) create mode 100644 cmd/app/internal/database/factory.go create mode 100644 cmd/app/internal/database/factory_test.go diff --git a/cmd/app/internal/database/factory.go b/cmd/app/internal/database/factory.go new file mode 100644 index 0000000..132de7f --- /dev/null +++ b/cmd/app/internal/database/factory.go @@ -0,0 +1,54 @@ +package database + +import ( + "os" + + "github.com/hackmajoris/glad/pkg/config" + "github.com/hackmajoris/glad/pkg/logger" +) + +// Repository combines all repository interfaces for unified access +type Repository interface { + UserRepository + SkillRepository +} + +// NewRepository creates the appropriate repository implementation based on configuration +func NewRepository(cfg *config.Config) Repository { + log := logger.WithComponent("database") + + // Determine if we should use mock or real DynamoDB + if shouldUseMockRepository(cfg) { + log.Info("Creating Mock repository for development/testing") + return NewMockRepository() + } + + log.Info("Creating DynamoDB repository for production/Lambda") + return NewDynamoDBRepository() +} + +// shouldUseMockRepository determines if we should use mock repository +func shouldUseMockRepository(cfg *config.Config) bool { + // 1. If AWS_LAMBDA_FUNCTION_NAME exists, we're definitely in Lambda - use DynamoDB + if os.Getenv("AWS_LAMBDA_FUNCTION_NAME") != "" { + return false + } + + // 2. If ENVIRONMENT is explicitly set to production, use DynamoDB + if os.Getenv("ENVIRONMENT") == "production" { + return false + } + + // 3. If LocalServer environment is development or test, use mock + if cfg.IsDevelopment() { + return true + } + + // 4. If DB_MOCK is explicitly set to true, use mock (useful for testing) + if os.Getenv("DB_MOCK") == "true" { + return true + } + + // 5. Default to DynamoDB for production + return false +} diff --git a/cmd/app/internal/database/factory_test.go b/cmd/app/internal/database/factory_test.go new file mode 100644 index 0000000..eb03ba3 --- /dev/null +++ b/cmd/app/internal/database/factory_test.go @@ -0,0 +1,202 @@ +package database + +import ( + "os" + "testing" + "time" + + "github.com/hackmajoris/glad/pkg/config" +) + +func TestNewRepository_EnvironmentDetection(t *testing.T) { + tests := []struct { + name string + setupEnv func() + cleanupEnv func() + configEnv string + expectMock bool + description string + }{ + { + name: "Lambda environment should use DynamoDB", + setupEnv: func() { + err := os.Setenv("AWS_LAMBDA_FUNCTION_NAME", "glad-api") + if err != nil { + return + } + }, + cleanupEnv: func() { + err := os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + if err != nil { + return + } + }, + configEnv: "development", + expectMock: false, + description: "When AWS_LAMBDA_FUNCTION_NAME is set, should use DynamoDB regardless of config", + }, + { + name: "Explicit production environment should use DynamoDB", + setupEnv: func() { + err := os.Setenv("ENVIRONMENT", "production") + if err != nil { + return + } + }, + cleanupEnv: func() { + err := os.Unsetenv("ENVIRONMENT") + if err != nil { + return + } + }, + configEnv: "development", + expectMock: false, + description: "When ENVIRONMENT=production, should use DynamoDB", + }, + { + name: "Development config should use Mock", + setupEnv: func() { + // No special env vars set + }, + cleanupEnv: func() { + // Nothing to clean + }, + configEnv: "development", + expectMock: true, + description: "When LocalServer.Environment=development, should use Mock", + }, + { + name: "Explicit DB_MOCK=true should use Mock", + setupEnv: func() { + err := os.Setenv("DB_MOCK", "true") + if err != nil { + return + } + }, + cleanupEnv: func() { + err := os.Unsetenv("DB_MOCK") + if err != nil { + return + } + }, + configEnv: "production", + expectMock: true, + description: "When DB_MOCK=true, should use Mock even in production config", + }, + { + name: "Default production should use DynamoDB", + setupEnv: func() { + // No special env vars set + }, + cleanupEnv: func() { + // Nothing to clean + }, + configEnv: "production", + expectMock: false, + description: "When LocalServer.Environment=production and no overrides, should use DynamoDB", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup environment + tt.setupEnv() + defer tt.cleanupEnv() + + // Create config with specified environment + cfg := &config.Config{ + JWT: config.JWTConfig{ + Secret: "test-secret", + Expiry: 24 * time.Hour, + }, + LocalServer: config.ServerConfig{ + Environment: tt.configEnv, + Port: 8080, + }, + } + + // Test the factory + repo := NewRepository(cfg) + + // Check if we got the expected repository type + _, isMock := repo.(*MockRepository) + _, isDynamoDB := repo.(*DynamoDBRepository) + + if tt.expectMock { + if !isMock { + t.Errorf("Expected MockRepository, but got %T. %s", repo, tt.description) + } + if isDynamoDB { + t.Errorf("Got DynamoDBRepository when expecting MockRepository. %s", tt.description) + } + } else { + if isMock { + t.Errorf("Got MockRepository when expecting DynamoDBRepository. %s", tt.description) + } + if !isDynamoDB { + t.Errorf("Expected DynamoDBRepository, but got %T. %s", repo, tt.description) + } + } + }) + } +} + +func TestShouldUseMockRepository(t *testing.T) { + tests := []struct { + name string + setupEnv func() + cleanupEnv func() + configEnv string + expected bool + description string + }{ + { + name: "AWS Lambda function name present", + setupEnv: func() { + err := os.Setenv("AWS_LAMBDA_FUNCTION_NAME", "my-function") + if err != nil { + return + } + }, + cleanupEnv: func() { + err := os.Unsetenv("AWS_LAMBDA_FUNCTION_NAME") + if err != nil { + return + } + }, + configEnv: "development", + expected: false, + description: "Should return false when AWS_LAMBDA_FUNCTION_NAME is set", + }, + { + name: "Development config", + setupEnv: func() {}, + cleanupEnv: func() {}, + configEnv: "development", + expected: true, + description: "Should return true for development config", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup + tt.setupEnv() + defer tt.cleanupEnv() + + cfg := &config.Config{ + LocalServer: config.ServerConfig{ + Environment: tt.configEnv, + }, + } + + // Test + result := shouldUseMockRepository(cfg) + + // Verify + if result != tt.expected { + t.Errorf("Expected %v, got %v. %s", tt.expected, result, tt.description) + } + }) + } +} diff --git a/deployments/app/cdk.go b/deployments/app/cdk.go index 7ff53e5..eebdbe3 100644 --- a/deployments/app/cdk.go +++ b/deployments/app/cdk.go @@ -24,8 +24,10 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw } stack := awscdk.NewStack(scope, &id, &sprops) + ENVIRONMENT := "production" // todo: will be parametrised + // Add environment tag - awscdk.Tags_Of(stack).Add(jsii.String("Environment"), jsii.String("production"), nil) + awscdk.Tags_Of(stack).Add(jsii.String("Environment"), jsii.String(ENVIRONMENT), nil) // The code that defines your stack goes here @@ -34,15 +36,74 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw // VisibilityTimeout: awscdk.Duration_Seconds(jsii.Number(300)), // }) - // Create DynamoTable + // Create DynamoDB Single Table + // This table uses single-table design pattern to store multiple entity types + // Entities: User, UserSkill (and future: Project, Settings, etc.) + // Key structure: + // - User: PK=USER#, SK=PROFILE + // - UserSkill: PK=USER#, SK=SKILL# + + entitiesTable := awsdynamodb.NewTableV2(stack, jsii.String(id+"-entities-table"), &awsdynamodb.TablePropsV2{ + TableName: jsii.String("glad-entities"), - userTable := awsdynamodb.NewTableV2(stack, jsii.String(id+"-users-table"), &awsdynamodb.TablePropsV2{ - TableName: jsii.String("users"), + // Partition Key: PK (stores entity identifier) PartitionKey: &awsdynamodb.Attribute{ - Name: jsii.String("username"), + Name: jsii.String("PK"), + Type: awsdynamodb.AttributeType_STRING, + }, + + // Sort Key: SK (stores entity type and sub-identifier) + SortKey: &awsdynamodb.Attribute{ + Name: jsii.String("SK"), Type: awsdynamodb.AttributeType_STRING, }, - RemovalPolicy: awscdk.RemovalPolicy_DESTROY, // For dev environments + + // GSI1: For cross-entity queries (e.g., find all users with a skill) + GlobalSecondaryIndexes: &[]*awsdynamodb.GlobalSecondaryIndexPropsV2{ + { + IndexName: jsii.String("GSI1"), + PartitionKey: &awsdynamodb.Attribute{ + Name: jsii.String("GSI1PK"), + Type: awsdynamodb.AttributeType_STRING, + }, + SortKey: &awsdynamodb.Attribute{ + Name: jsii.String("GSI1SK"), + Type: awsdynamodb.AttributeType_STRING, + }, + // INCLUDE projection for cost optimization + // Only includes essential attributes needed for queries + ProjectionType: awsdynamodb.ProjectionType_INCLUDE, + NonKeyAttributes: jsii.Strings( + "EntityType", + "Username", + "SkillName", + "ProficiencyLevel", + "Name", + ), + }, + }, + + // Enable point-in-time recovery for data protection + PointInTimeRecovery: jsii.Bool(true), + + // Enable DynamoDB Streams for event-driven architecture + DynamoStream: awsdynamodb.StreamViewType_NEW_AND_OLD_IMAGES, + + // Remove table on stack deletion (for dev/testing) + RemovalPolicy: awscdk.RemovalPolicy_DESTROY, + + // Additional tags + Tags: &[]*awscdk.CfnTag{ + + { + Key: jsii.String("Purpose"), + Value: jsii.String("Single-Table-Design"), + }, + { + Key: jsii.String("DataModel"), + Value: jsii.String("Multi-Entity"), + }, + }, }) // Create Lambda @@ -52,7 +113,10 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw Handler: jsii.String("main"), }) - userTable.GrantReadWriteData(myFunc) + myFunc.AddEnvironment(jsii.String("environment"), jsii.String(ENVIRONMENT), nil) + + // Grant Lambda read/write access to DynamoDB table + entitiesTable.GrantReadWriteData(myFunc) api := awsapigateway.NewRestApi(stack, jsii.String(id+"-api-gateway"), &awsapigateway.RestApiProps{ RestApiName: jsii.String("glad-api gateway"), @@ -95,6 +159,55 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw AuthorizationType: awsapigateway.AuthorizationType_NONE, }) + // Add /me GET endpoint for current user + meResource := api.Root().AddResource(jsii.String("me"), nil) + meResource.AddMethod(jsii.String("GET"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // Skill Management Endpoints + // Pattern: /users/{username}/skills + usersSkillsResource := usersResource.AddResource(jsii.String("{username}"), nil) + skillsResource := usersSkillsResource.AddResource(jsii.String("skills"), nil) + + // POST /users/{username}/skills - Add a skill + skillsResource.AddMethod(jsii.String("POST"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // GET /users/{username}/skills - List all skills for user + skillsResource.AddMethod(jsii.String("GET"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // Specific skill endpoints + // Pattern: /users/{username}/skills/{skillName} + skillResource := skillsResource.AddResource(jsii.String("{skillName}"), nil) + + // GET /users/{username}/skills/{skillName} - Get specific skill + skillResource.AddMethod(jsii.String("GET"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // PUT /users/{username}/skills/{skillName} - Update skill + skillResource.AddMethod(jsii.String("PUT"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // DELETE /users/{username}/skills/{skillName} - Delete skill + skillResource.AddMethod(jsii.String("DELETE"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + + // Global skill query endpoint + // GET /skills/{skillName}/users - Find all users with a skill + skillsGlobalResource := api.Root().AddResource(jsii.String("skills"), nil) + skillNameResource := skillsGlobalResource.AddResource(jsii.String("{skillName}"), nil) + usersWithSkillResource := skillNameResource.AddResource(jsii.String("users"), nil) + usersWithSkillResource.AddMethod(jsii.String("GET"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + // Create UsagePlan AFTER all methods are defined awsapigateway.NewUsagePlan(stack, jsii.String(id+"-api-gateway-usage-plan"), &awsapigateway.UsagePlanProps{ Name: jsii.String(id + "-api-gateway-usage-plan"),