diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c874351..749e0cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,17 +23,17 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Download Go modules - run: go mod tidy && go mod download + run: cd backend && go mod tidy && go mod download - name: Build - run: go build -v ./... - - - name: Run tests - run: go test -v ./... + run: cd backend && go build -v ./... - name: Install golangci-lint run: | - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.0.0 + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.6.2 - name: Run golangci-lint - run: golangci-lint run ./... + run: cd backend && golangci-lint run ./... + + - name: Run tests + run: cd backend && go test -v ./... diff --git a/.env.example b/backend/.env.example similarity index 100% rename from .env.example rename to backend/.env.example diff --git a/.gitignore b/backend/.gitignore similarity index 100% rename from .gitignore rename to backend/.gitignore diff --git a/.golangci.yml b/backend/.golangci.yml similarity index 80% rename from .golangci.yml rename to backend/.golangci.yml index 867e052..31ddcb5 100644 --- a/.golangci.yml +++ b/backend/.golangci.yml @@ -26,6 +26,11 @@ linters: - tagliatelle - varnamelen - wsl + - wsl_v5 + - noinlineerr + - gochecknoglobals + - mnd + - perfsprint settings: gocritic: enabled-tags: @@ -34,6 +39,8 @@ linters: - opinionated - performance - style + disabled-checks: + - whyNoLint # Require explanations for nolint directives govet: enable-all: true misspell: @@ -41,6 +48,7 @@ linters: staticcheck: checks: - all + - -ST1000 exclusions: generated: lax rules: @@ -66,15 +74,9 @@ linters: issues: max-issues-per-linter: 0 max-same-issues: 0 -formatters: - enable: - - gci - - gofmt - - gofumpt - - goimports - exclusions: - generated: lax - paths: - - third_party$ - - builtin$ - - examples$ + exclude-rules: + - linters: + - staticcheck + - linters: + - staticcheck + text: "ST1020:" diff --git a/.mockery.yml b/backend/.mockery.yml similarity index 100% rename from .mockery.yml rename to backend/.mockery.yml diff --git a/Makefile b/backend/Makefile similarity index 79% rename from Makefile rename to backend/Makefile index 4896ce9..1e0ece9 100644 --- a/Makefile +++ b/backend/Makefile @@ -18,8 +18,15 @@ generate: test: @go test -timeout 30s -run ./... +.PHONY: coverage coverage: @go test ./... -coverprofile=coverage.out - + +.PHONY: lint lint: - @golangci-lint run ./... \ No newline at end of file + @golangci-lint run ./... --fix + +.PHONY: fmt +format: + @gofmt -w . + @gofumpt -w . \ No newline at end of file diff --git a/docker-compose.yml b/backend/docker-compose.yml similarity index 100% rename from docker-compose.yml rename to backend/docker-compose.yml diff --git a/docs/docs.go b/backend/docs/docs.go similarity index 100% rename from docs/docs.go rename to backend/docs/docs.go diff --git a/docs/swagger.json b/backend/docs/swagger.json similarity index 100% rename from docs/swagger.json rename to backend/docs/swagger.json diff --git a/docs/swagger.yaml b/backend/docs/swagger.yaml similarity index 100% rename from docs/swagger.yaml rename to backend/docs/swagger.yaml diff --git a/domain/entities/helpers/decode_short_url.go b/backend/domain/entities/helpers/decode_short_url.go similarity index 83% rename from domain/entities/helpers/decode_short_url.go rename to backend/domain/entities/helpers/decode_short_url.go index 95ce063..99c5850 100644 --- a/domain/entities/helpers/decode_short_url.go +++ b/backend/domain/entities/helpers/decode_short_url.go @@ -4,8 +4,9 @@ import ( "strings" ) -func Base62Decode(shortURL string, salt string) int64 { +func Base62Decode(shortURL, salt string) int64 { alphabet := getShuffledAlphabet(salt) + var decoded int64 for _, char := range shortURL { @@ -13,7 +14,9 @@ func Base62Decode(shortURL string, salt string) int64 { if index == -1 { return 0 } + decoded = decoded*base62 + int64(index) } + return decoded } diff --git a/domain/entities/helpers/encode_url.go b/backend/domain/entities/helpers/encode_url.go similarity index 99% rename from domain/entities/helpers/encode_url.go rename to backend/domain/entities/helpers/encode_url.go index b72c3fe..5f8ab11 100644 --- a/domain/entities/helpers/encode_url.go +++ b/backend/domain/entities/helpers/encode_url.go @@ -13,7 +13,9 @@ const ( func Base62Encode(id int64, salt string) string { alphabet := getShuffledAlphabet(salt) + var encoded string + num := id if num == 0 { @@ -24,11 +26,13 @@ func Base62Encode(id int64, salt string) string { num /= base62 } } + if len(encoded) < minEncodedLen { encoded = strings.Repeat(string(alphabet[0]), minEncodedLen-len(encoded)) + encoded } else if len(encoded) > minEncodedLen { encoded = encoded[:minEncodedLen] } + return encoded } @@ -49,5 +53,6 @@ func getShuffledAlphabet(salt string) string { j := int(hashBytes[i%len(hashBytes)]) % (i + 1) shuffled[i], shuffled[j] = shuffled[j], shuffled[i] } + return string(shuffled) } diff --git a/domain/entities/helpers/encode_url_test.go b/backend/domain/entities/helpers/encode_url_test.go similarity index 74% rename from domain/entities/helpers/encode_url_test.go rename to backend/domain/entities/helpers/encode_url_test.go index 14d7ac0..627d89c 100644 --- a/domain/entities/helpers/encode_url_test.go +++ b/backend/domain/entities/helpers/encode_url_test.go @@ -11,18 +11,18 @@ func Test_Helper_Base62Encode(t *testing.T) { salt := "salt" tests := []struct { - id int64 want string + id int64 }{ - {1, "wwwE"}, - {2, "wwwf"}, - {3, "www2"}, - {4, "wwwQ"}, - {5, "wwwS"}, - {6, "wwwV"}, - {7, "wwwa"}, - {8, "wwwm"}, - {9, "wwwD"}, + {"wwwE", 1}, + {"wwwf", 2}, + {"www2", 3}, + {"wwwQ", 4}, + {"wwwS", 5}, + {"wwwV", 6}, + {"wwwa", 7}, + {"wwwm", 8}, + {"wwwD", 9}, } for _, test := range tests { diff --git a/domain/entities/url.go b/backend/domain/entities/url.go similarity index 100% rename from domain/entities/url.go rename to backend/domain/entities/url.go index df94f6b..7d958b8 100644 --- a/domain/entities/url.go +++ b/backend/domain/entities/url.go @@ -5,7 +5,7 @@ import ( ) type URL struct { + CreatedAt time.Time ShortCode string LongURL string - CreatedAt time.Time } diff --git a/domain/entities/usecases/create_url.go b/backend/domain/entities/usecases/create_url.go similarity index 100% rename from domain/entities/usecases/create_url.go rename to backend/domain/entities/usecases/create_url.go diff --git a/domain/entities/usecases/create_url_test.go b/backend/domain/entities/usecases/create_url_test.go similarity index 99% rename from domain/entities/usecases/create_url_test.go rename to backend/domain/entities/usecases/create_url_test.go index 0be3c52..5cc1829 100644 --- a/domain/entities/usecases/create_url_test.go +++ b/backend/domain/entities/usecases/create_url_test.go @@ -2,16 +2,15 @@ package usecases_test import ( "context" - - "lnk/domain/entities/usecases" - "lnk/extensions/gocqltesting" - "lnk/extensions/redis/mocks" - "lnk/gateways/gocql/repositories" "testing" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "go.uber.org/zap" + "lnk/domain/entities/usecases" + "lnk/extensions/gocqltesting" + "lnk/extensions/redis/mocks" + "lnk/gateways/gocql/repositories" ) func Test_UseCase_CreateURL(t *testing.T) { diff --git a/domain/entities/usecases/get_long_test.go b/backend/domain/entities/usecases/get_long_test.go similarity index 99% rename from domain/entities/usecases/get_long_test.go rename to backend/domain/entities/usecases/get_long_test.go index 3d1f786..49a2657 100644 --- a/domain/entities/usecases/get_long_test.go +++ b/backend/domain/entities/usecases/get_long_test.go @@ -2,15 +2,15 @@ package usecases_test import ( "context" - "lnk/domain/entities/usecases" - "lnk/extensions/gocqltesting" - "lnk/extensions/redis/mocks" - "lnk/gateways/gocql/repositories" "testing" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "go.uber.org/zap" + "lnk/domain/entities/usecases" + "lnk/extensions/gocqltesting" + "lnk/extensions/redis/mocks" + "lnk/gateways/gocql/repositories" ) func Test_UseCase_GetLongURL(t *testing.T) { @@ -18,6 +18,7 @@ func Test_UseCase_GetLongURL(t *testing.T) { session, err := gocqltesting.NewDB(t, t.Name()) require.NoError(t, err) + url := "https://www.google.com" ctx := context.Background() diff --git a/domain/entities/usecases/get_long_url.go b/backend/domain/entities/usecases/get_long_url.go similarity index 100% rename from domain/entities/usecases/get_long_url.go rename to backend/domain/entities/usecases/get_long_url.go diff --git a/domain/entities/usecases/setup_test.go b/backend/domain/entities/usecases/setup_test.go similarity index 99% rename from domain/entities/usecases/setup_test.go rename to backend/domain/entities/usecases/setup_test.go index e3271ea..01eafb4 100644 --- a/domain/entities/usecases/setup_test.go +++ b/backend/domain/entities/usecases/setup_test.go @@ -1,11 +1,12 @@ package usecases_test import ( - gocqltesting "lnk/extensions/gocqltesting" - "lnk/gateways/gocql/migrations" "log" "os" "testing" + + gocqltesting "lnk/extensions/gocqltesting" + "lnk/gateways/gocql/migrations" ) func TestMain(m *testing.M) { diff --git a/domain/entities/usecases/usecase.go b/backend/domain/entities/usecases/usecase.go similarity index 100% rename from domain/entities/usecases/usecase.go rename to backend/domain/entities/usecases/usecase.go index ede4226..67ee560 100644 --- a/domain/entities/usecases/usecase.go +++ b/backend/domain/entities/usecases/usecase.go @@ -2,10 +2,10 @@ package usecases import ( "errors" - "lnk/extensions/redis" - "lnk/gateways/gocql/repositories" "go.uber.org/zap" + "lnk/extensions/redis" + "lnk/gateways/gocql/repositories" ) var ErrURLNotFound = errors.New("URL not found") diff --git a/extensions/config/config.go b/backend/extensions/config/config.go similarity index 99% rename from extensions/config/config.go rename to backend/extensions/config/config.go index e5894d6..5960023 100644 --- a/extensions/config/config.go +++ b/backend/extensions/config/config.go @@ -5,7 +5,6 @@ import ( "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" - "lnk/extensions/logger" "lnk/extensions/redis" "lnk/gateways/gocql" @@ -13,9 +12,9 @@ import ( type Config struct { App App + Logger logger.Config Gocql gocql.Config Redis redis.Config - Logger logger.Config } type App struct { @@ -30,12 +29,15 @@ func LoadConfig() (*Config, error) { if err != nil { return nil, fmt.Errorf("failed to load .env file: %w", err) } + config := &Config{} if err := envconfig.Process("", config); err != nil { return nil, fmt.Errorf("failed to process app config: %w", err) } + if err := envconfig.Process("", &config.Gocql); err != nil { return nil, fmt.Errorf("failed to process gocql config: %w", err) } + return config, nil } diff --git a/extensions/gocqltesting/db.go b/backend/extensions/gocqltesting/db.go similarity index 87% rename from extensions/gocqltesting/db.go rename to backend/extensions/gocqltesting/db.go index c87fbbf..a51af36 100644 --- a/extensions/gocqltesting/db.go +++ b/backend/extensions/gocqltesting/db.go @@ -17,7 +17,7 @@ const ( timeout = 10 * time.Second ) -var nonAlphaRegex = regexp.MustCompile(`[\W]`) +var nonAlphaRegex = regexp.MustCompile(`\W`) // NewDB creates a new isolated test keyspace by copying the schema from the template keyspace. // This is much faster than running migrations for each test. The keyspace is automatically @@ -34,6 +34,7 @@ func NewDB(t *testing.T, dbName string) (*cassandra.Session, error) { if dbName == "" { return nil, errors.New("dbName cannot be an empty string") } + dbName = nonAlphaRegex.ReplaceAllString(strings.ToLower(dbName), "_") dropQuery := "DROP KEYSPACE IF EXISTS " + dbName @@ -74,6 +75,7 @@ func NewDB(t *testing.T, dbName string) (*cassandra.Session, error) { t.Cleanup(func() { testSession.Close() + _ = session.Query("DROP KEYSPACE IF EXISTS " + dbName).Exec() }) @@ -89,7 +91,8 @@ func copySchemaFromTemplate(session *cassandra.Session, targetKeyspace string) e } for _, tableName := range tableNames { - if err := copyTable(session, targetKeyspace, tableName); err != nil { + err := copyTable(session, targetKeyspace, tableName) + if err != nil { return err } } @@ -104,15 +107,21 @@ func getTableNames(session *cassandra.Session) ([]string, error) { WHERE keyspace_name = ? `, _templateKeyspace).Iter() - var tableNames []string - var tableName string + var ( + tableNames []string + tableName string + ) + for iter.Scan(&tableName) { if tableName == "schema_migrations" { continue } + tableNames = append(tableNames, tableName) } - if err := iter.Close(); err != nil { + + err := iter.Close() + if err != nil { return nil, fmt.Errorf("failed to get tables: %w", err) } @@ -146,9 +155,11 @@ func getColumnInfos(session *cassandra.Session, tableName string) ([]columnInfo, WHERE keyspace_name = ? AND table_name = ? `, _templateKeyspace, tableName).Iter() - var columnInfos []columnInfo - var columnName, columnType, columnKind string - var position int + var ( + columnInfos []columnInfo + columnName, columnType, columnKind string + position int + ) for colIter.Scan(&columnName, &columnType, &columnKind, &position) { columnInfos = append(columnInfos, columnInfo{ @@ -158,7 +169,9 @@ func getColumnInfos(session *cassandra.Session, tableName string) ([]columnInfo, position: position, }) } - if err := colIter.Close(); err != nil { + + err := colIter.Close() + if err != nil { return nil, fmt.Errorf("failed to get columns for table %s: %w", tableName, err) } @@ -170,9 +183,12 @@ func getColumnInfos(session *cassandra.Session, tableName string) ([]columnInfo, } func createTable(session *cassandra.Session, targetKeyspace, tableName string, columnInfos []columnInfo) error { - var columns []string - var partitionKeys []string - var clusteringKeys []string + columns := make([]string, 0, len(columnInfos)) + + var ( + partitionKeys []string + clusteringKeys []string + ) for _, col := range columnInfos { columns = append(columns, fmt.Sprintf("%s %s", col.name, col.typ)) @@ -186,7 +202,8 @@ func createTable(session *cassandra.Session, targetKeyspace, tableName string, c createStmt := buildCreateTableStatement(targetKeyspace, tableName, columns, partitionKeys, clusteringKeys) - if err := session.Query(createStmt).Exec(); err != nil { + err := session.Query(createStmt).Exec() + if err != nil { return fmt.Errorf("failed to create table %s: %w", tableName, err) } @@ -204,10 +221,12 @@ func buildCreateTableStatement(targetKeyspace, tableName string, columns, partit } else { createStmt += "(" + strings.Join(partitionKeys, ", ") + ")" } + if len(clusteringKeys) > 0 { createStmt += ", " + strings.Join(clusteringKeys, ", ") } } + createStmt += "))" return createStmt @@ -230,12 +249,16 @@ func copyIndexes(session *cassandra.Session, targetKeyspace, tableName string) e if columnName != "" { createIndexStmt := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s.%s (%s)", indexName, targetKeyspace, tableName, columnName) - if err := session.Query(createIndexStmt).Exec(); err != nil { + + err := session.Query(createIndexStmt).Exec() + if err != nil { _ = err } } } - if err := idxIter.Close(); err != nil { + + err := idxIter.Close() + if err != nil { _ = err } @@ -246,11 +269,13 @@ func copyIndexes(session *cassandra.Session, targetKeyspace, tableName string) e // Pattern: table_column_idx -> column func extractColumnFromIndexName(indexName, tableName string) string { prefix := tableName + "_" - if strings.HasPrefix(indexName, prefix) { - suffix := strings.TrimPrefix(indexName, prefix) - suffix = strings.TrimSuffix(suffix, "_idx") - suffix = strings.TrimSuffix(suffix, "_index") - return suffix + suffix, ok := strings.CutPrefix(indexName, prefix) + if !ok { + return "" } - return "" + + suffix = strings.TrimSuffix(suffix, "_idx") + suffix = strings.TrimSuffix(suffix, "_index") + + return suffix } diff --git a/extensions/gocqltesting/docker.go b/backend/extensions/gocqltesting/docker.go similarity index 93% rename from extensions/gocqltesting/docker.go rename to backend/extensions/gocqltesting/docker.go index 83dd34a..8fe155b 100644 --- a/extensions/gocqltesting/docker.go +++ b/backend/extensions/gocqltesting/docker.go @@ -34,12 +34,11 @@ const ( ) var ( - //nolint:gochecknoglobals // These are intentionally global for test infrastructure coordination - dbPort string - setupMutex sync.Mutex - containerInitialized bool - concurrentSession *cassandra.Session - templateReady bool + dbPort string //nolint:gochecknoglobals + setupMutex sync.Mutex //nolint:gochecknoglobals + containerInitialized bool //nolint:gochecknoglobals + concurrentSession *cassandra.Session //nolint:gochecknoglobals + templateReady bool //nolint:gochecknoglobals ) type Migrations struct { @@ -47,10 +46,10 @@ type Migrations struct { } type DockerContainerConfig struct { - ReuseContainer bool + Migrations *Migrations Version string ContainerName string - Migrations *Migrations + ReuseContainer bool } // StartDockerContainer starts a Cassandra Docker container and sets up a template keyspace with migrations. @@ -77,6 +76,7 @@ func StartDockerContainer(cfg DockerContainerConfig) (teardownFn func(), err err } teardownFn = createTeardownFn(cfg, dockerResource) + return teardownFn, nil } @@ -102,6 +102,7 @@ func initializeDockerPool(cfg DockerContainerConfig) (*dockertest.Pool, *dockert } dbPort = dockerResource.GetPort("9042/tcp") + return dockerPool, dockerResource, nil } @@ -120,17 +121,20 @@ func waitForCassandra() error { } time.Sleep(backoff) + if backoff < maxBackoff { backoff *= 2 } } time.Sleep(postReadyDelay) + return nil } func initializeSession(cfg DockerContainerConfig) error { var err error + concurrentSession, err = newDB(getCassandraConnString(dbPort, "master")) if err != nil { return fmt.Errorf("failed to create session: %w", err) @@ -139,10 +143,12 @@ func initializeSession(cfg DockerContainerConfig) error { containerInitialized = true if !templateReady { - if err := setupTemplateDatabase(concurrentSession, cfg.Migrations.FS); err != nil { + err := setupTemplateDatabase(concurrentSession, cfg.Migrations.FS) + if err != nil { concurrentSession.Close() return err } + templateReady = true } @@ -156,6 +162,7 @@ func createTeardownFn(cfg DockerContainerConfig, dockerResource *dockertest.Reso concurrentSession.Close() concurrentSession = nil } + _ = dockerResource.Close() containerInitialized = false templateReady = false @@ -169,6 +176,7 @@ func setupTemplateDatabase(conn *cassandra.Session, migrationsFs fs.FS) error { dbKeyspace := _templateKeyspace var dbCount int + err := conn.Query("SELECT COUNT(*) FROM system_schema.keyspaces WHERE keyspace_name = ?", dbKeyspace).Scan(&dbCount) if err != nil { return fmt.Errorf("error checking template keyspace: %w", err) @@ -184,8 +192,10 @@ func setupTemplateDatabase(conn *cassandra.Session, migrationsFs fs.FS) error { "CREATE KEYSPACE %s WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}", dbKeyspace, ) - if err := conn.Query(createQuery).Exec(); err != nil { - return fmt.Errorf("error creating template keyspace: %w", err) + + execErr := conn.Query(createQuery).Exec() + if execErr != nil { + return fmt.Errorf("error creating template keyspace: %w", execErr) } time.Sleep(time.Millisecond * retryIntervalMs) @@ -208,11 +218,13 @@ func getDockerGocqlResource(dockerPool *dockertest.Pool, cfg DockerContainerConf if cfg.ContainerName != "" { containerName = cfg.ContainerName } + container, _ := dockerPool.Client.InspectContainer(containerName) if container != nil && container.State.Running { resource := &dockertest.Resource{Container: container} return resource, nil } + if container != nil && !container.State.Running { _ = dockerPool.RemoveContainerByName(containerName) } @@ -233,7 +245,6 @@ func getDockerGocqlResource(dockerPool *dockertest.Pool, cfg DockerContainerConf Name: "no", } }) - if err == nil { return resource, nil } @@ -263,10 +274,12 @@ func pingCassandraFn(port string) func() error { } defer conn.Close() + err = conn.Query("SELECT now() FROM system.local").Exec() if err != nil { return fmt.Errorf("query failed: %w", err) } + return nil } } @@ -294,6 +307,7 @@ func newDB(_ string) (*cassandra.Session, error) { if err != nil { return nil, fmt.Errorf("failed to create session: %w", err) } + return session, nil } diff --git a/extensions/logger/config.go b/backend/extensions/logger/config.go similarity index 100% rename from extensions/logger/config.go rename to backend/extensions/logger/config.go diff --git a/extensions/logger/logger.go b/backend/extensions/logger/logger.go similarity index 99% rename from extensions/logger/logger.go rename to backend/extensions/logger/logger.go index 7100b29..5960988 100644 --- a/extensions/logger/logger.go +++ b/backend/extensions/logger/logger.go @@ -12,6 +12,7 @@ func NewLogger(config Config) (*zap.Logger, error) { if err != nil { return nil, fmt.Errorf("failed to create development logger: %w", err) } + return logger, nil } @@ -19,5 +20,6 @@ func NewLogger(config Config) (*zap.Logger, error) { if err != nil { return nil, fmt.Errorf("failed to create production logger: %w", err) } + return logger, nil } diff --git a/extensions/redis/config.go b/backend/extensions/redis/config.go similarity index 100% rename from extensions/redis/config.go rename to backend/extensions/redis/config.go index 888d716..d159c5f 100644 --- a/extensions/redis/config.go +++ b/backend/extensions/redis/config.go @@ -2,9 +2,9 @@ package redis type Config struct { Host string `envconfig:"REDIS_HOST" required:"true"` - Port int `envconfig:"REDIS_PORT" required:"true"` Password string `envconfig:"REDIS_PASSWORD" required:"true"` - DB int `envconfig:"REDIS_DB" required:"true"` CounterKey string `envconfig:"COUNTER_KEY" required:"true"` + Port int `envconfig:"REDIS_PORT" required:"true"` + DB int `envconfig:"REDIS_DB" required:"true"` CounterStartVal int `envconfig:"COUNTER_START_VAL" required:"true"` } diff --git a/extensions/redis/mocks/redis_mock.go b/backend/extensions/redis/mocks/redis_mock.go similarity index 100% rename from extensions/redis/mocks/redis_mock.go rename to backend/extensions/redis/mocks/redis_mock.go diff --git a/extensions/redis/redis.go b/backend/extensions/redis/redis.go similarity index 93% rename from extensions/redis/redis.go rename to backend/extensions/redis/redis.go index 7afb399..b55eb00 100644 --- a/extensions/redis/redis.go +++ b/backend/extensions/redis/redis.go @@ -28,6 +28,7 @@ func (r *redisAdapter) Incr(ctx context.Context, key string) (int64, error) { if err != nil { return 0, fmt.Errorf("failed to increment Redis key %s: %w", key, err) } + return result, nil } @@ -51,7 +52,8 @@ func SetupRedis(ctx context.Context, config *Config, logger *zap.Logger) (*redis return client, nil } -// for security reasons, we will set the initial counter value to 14000000 - 1 +// SetInitialCounterValue sets the initial counter value for security reasons. +// For security reasons, we will set the initial counter value to 14000000 - 1 // and then increment it by 1 each time a new short URL is created. This is to prevent // the counter from being guessed by the public. func SetInitialCounterValue(ctx context.Context, client *redis.Client, config *Config, logger *zap.Logger) (bool, error) { diff --git a/gateways/gocql/config.go b/backend/gateways/gocql/config.go similarity index 100% rename from gateways/gocql/config.go rename to backend/gateways/gocql/config.go index 9eabb44..912b4b4 100644 --- a/gateways/gocql/config.go +++ b/backend/gateways/gocql/config.go @@ -2,9 +2,9 @@ package gocql type Config struct { Host string `envconfig:"CASSANDRA_HOST" required:"true"` - Port int `envconfig:"CASSANDRA_PORT" required:"true"` Username string `envconfig:"CASSANDRA_USERNAME" required:"true"` Password string `envconfig:"CASSANDRA_PASSWORD" required:"true"` Keyspace string `envconfig:"CASSANDRA_KEYSPACE" required:"true"` + Port int `envconfig:"CASSANDRA_PORT" required:"true"` AutoMigrate bool `envconfig:"CASSANDRA_AUTO_MIGRATE" default:"false"` } diff --git a/gateways/gocql/migrations/000001_create_urls_table.down.sql b/backend/gateways/gocql/migrations/000001_create_urls_table.down.sql similarity index 100% rename from gateways/gocql/migrations/000001_create_urls_table.down.sql rename to backend/gateways/gocql/migrations/000001_create_urls_table.down.sql diff --git a/gateways/gocql/migrations/000001_create_urls_table.up.sql b/backend/gateways/gocql/migrations/000001_create_urls_table.up.sql similarity index 100% rename from gateways/gocql/migrations/000001_create_urls_table.up.sql rename to backend/gateways/gocql/migrations/000001_create_urls_table.up.sql diff --git a/gateways/gocql/migrations/migration.go b/backend/gateways/gocql/migrations/migration.go similarity index 100% rename from gateways/gocql/migrations/migration.go rename to backend/gateways/gocql/migrations/migration.go diff --git a/gateways/gocql/repositories/repository.go b/backend/gateways/gocql/repositories/repository.go similarity index 100% rename from gateways/gocql/repositories/repository.go rename to backend/gateways/gocql/repositories/repository.go diff --git a/gateways/gocql/repositories/url_repository.go b/backend/gateways/gocql/repositories/url_repository.go similarity index 99% rename from gateways/gocql/repositories/url_repository.go rename to backend/gateways/gocql/repositories/url_repository.go index bad4a93..7c19d3f 100644 --- a/gateways/gocql/repositories/url_repository.go +++ b/backend/gateways/gocql/repositories/url_repository.go @@ -7,7 +7,6 @@ import ( "time" "github.com/gocql/gocql" - "lnk/domain/entities" ) @@ -27,6 +26,7 @@ func (r *Repository) CreateURL(ctx context.Context, url *entities.URL) error { func (r *Repository) GetURLByShortCode(shortCode string) (*entities.URL, error) { var url entities.URL + err := r.session.Query( "SELECT short_code, long_url, created_at FROM urls WHERE short_code = ?", shortCode, @@ -35,7 +35,9 @@ func (r *Repository) GetURLByShortCode(shortCode string) (*entities.URL, error) if errors.Is(err, gocql.ErrNotFound) { return nil, gocql.ErrNotFound } + return nil, fmt.Errorf("failed to get URL by short code: %w", err) } + return &url, nil } diff --git a/gateways/gocql/setup.go b/backend/gateways/gocql/setup.go similarity index 90% rename from gateways/gocql/setup.go rename to backend/gateways/gocql/setup.go index 8420475..9e81c6c 100644 --- a/gateways/gocql/setup.go +++ b/backend/gateways/gocql/setup.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net" + "strconv" "time" gocql "github.com/apache/cassandra-gocql-driver/v2" @@ -11,13 +12,12 @@ import ( _ "github.com/golang-migrate/migrate/v4/database/cassandra" "github.com/golang-migrate/migrate/v4/source/iofs" "go.uber.org/zap" - "lnk/gateways/gocql/migrations" ) const ( - connectTimeout = 30 * time.Second - timeout = 10 * time.Second + connectTimeout = 30 * time.Second + timeout = 10 * time.Second shutdownTimeout = 5 * time.Second ) @@ -40,21 +40,26 @@ func SetupDatabase(config *Config, logger *zap.Logger) (*gocql.Session, error) { "CREATE KEYSPACE IF NOT EXISTS %s WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}", config.Keyspace, ) - if err := session.Query(createKeyspaceQuery).Exec(); err != nil { - logger.Warn("Failed to create keyspace (may already exist)", zap.Error(err)) + + execErr := session.Query(createKeyspaceQuery).Exec() + if execErr != nil { + logger.Warn("Failed to create keyspace (may already exist)", zap.Error(execErr)) } cluster.Keyspace = config.Keyspace + sessionWithKeyspace, err := cluster.CreateSession() if err != nil { session.Close() return nil, fmt.Errorf("failed to create session with keyspace: %w", err) } + session.Close() session = sessionWithKeyspace if config.AutoMigrate { - if err := runMigrations(config, logger); err != nil { + err := runMigrations(config, logger) + if err != nil { return nil, fmt.Errorf("failed to run migrations: %w", err) } } @@ -71,13 +76,16 @@ func createSessionWithRetry(cluster *gocql.ClusterConfig) (*gocql.Session, error ) var lastErr error + maxAttemptsVar := maxAttempts for range maxAttemptsVar { session, err := cluster.CreateSession() if err == nil { return session, nil } + lastErr = err + time.Sleep(backoff) } @@ -91,7 +99,7 @@ func runMigrations(config *Config, logger *zap.Logger) error { return fmt.Errorf("failed to load embedded migrations: %w", err) } - hostPort := net.JoinHostPort(config.Host, fmt.Sprintf("%d", config.Port)) + hostPort := net.JoinHostPort(config.Host, strconv.Itoa(config.Port)) migrationURL := fmt.Sprintf("cassandra://%s/%s?x-multi-statement=true", hostPort, config.Keyspace) m, err := migrate.NewWithSourceInstance("iofs", sourceDriver, migrationURL) @@ -101,6 +109,7 @@ func runMigrations(config *Config, logger *zap.Logger) error { } logger.Info("Running migrations") + defer func() { _, err := m.Close() if err != nil { @@ -113,7 +122,9 @@ func runMigrations(config *Config, logger *zap.Logger) error { logger.Info("No migration changes to apply") return nil } + return fmt.Errorf("failed to run migrations: %w", err) } + return nil } diff --git a/gateways/http/handlers/handler.go b/backend/gateways/http/handlers/handler.go similarity index 99% rename from gateways/http/handlers/handler.go rename to backend/gateways/http/handlers/handler.go index 195836e..bbee332 100644 --- a/gateways/http/handlers/handler.go +++ b/backend/gateways/http/handlers/handler.go @@ -3,12 +3,11 @@ package handlers import ( "net/http" - "lnk/domain/entities/usecases" - "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" "go.uber.org/zap" + "lnk/domain/entities/usecases" ) type Handlers struct { diff --git a/gateways/http/handlers/urls_handler.go b/backend/gateways/http/handlers/urls_handler.go similarity index 95% rename from gateways/http/handlers/urls_handler.go rename to backend/gateways/http/handlers/urls_handler.go index 50b996c..e62fa2e 100644 --- a/gateways/http/handlers/urls_handler.go +++ b/backend/gateways/http/handlers/urls_handler.go @@ -4,10 +4,9 @@ import ( "errors" "net/http" - "lnk/domain/entities/usecases" - "github.com/gin-gonic/gin" "go.uber.org/zap" + "lnk/domain/entities/usecases" ) type CreateURLRequest struct { @@ -40,6 +39,8 @@ func NewURLsHandler(logger *zap.Logger, useCase *usecases.UseCase) *URLsHandler } } +// CreateURL creates a short URL from a long URL. +// // @Summary Create a short URL // @Description Create a short URL from a long URL // @Tags urls @@ -69,6 +70,8 @@ func (h *URLsHandler) CreateURL(c *gin.Context) { }) } +// GetURL retrieves the original URL from a short URL. +// // @Summary Get original URL by short URL // @Description Get the original URL from a short URL // @Tags urls @@ -90,6 +93,7 @@ func (h *URLsHandler) GetURL(c *gin.Context) { } c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()}) + return } diff --git a/gateways/http/middleware/middleware.go b/backend/gateways/http/middleware/middleware.go similarity index 95% rename from gateways/http/middleware/middleware.go rename to backend/gateways/http/middleware/middleware.go index 68714a2..167d8f2 100644 --- a/gateways/http/middleware/middleware.go +++ b/backend/gateways/http/middleware/middleware.go @@ -35,7 +35,7 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc { } func Recovery(logger *zap.Logger) gin.HandlerFunc { - return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { + return gin.CustomRecovery(func(c *gin.Context, recovered any) { logger.Error("Panic recovered", zap.Any("error", recovered), zap.String("path", c.Request.URL.Path), diff --git a/gateways/http/router.go b/backend/gateways/http/router.go similarity index 99% rename from gateways/http/router.go rename to backend/gateways/http/router.go index b8ffc37..cc2c2aa 100644 --- a/gateways/http/router.go +++ b/backend/gateways/http/router.go @@ -1,19 +1,18 @@ package http import ( + "github.com/gin-gonic/gin" + "go.uber.org/zap" _ "lnk/docs" "lnk/gateways/http/handlers" "lnk/gateways/http/middleware" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" ) type RouterConfig struct { Logger *zap.Logger + Handlers *handlers.Handlers GinMode string Env string - Handlers *handlers.Handlers } func NewRouter(cfg RouterConfig) *gin.Engine { @@ -29,4 +28,3 @@ func NewRouter(cfg RouterConfig) *gin.Engine { return router } - diff --git a/gateways/http/server.go b/backend/gateways/http/server.go similarity index 85% rename from gateways/http/server.go rename to backend/gateways/http/server.go index 8350a76..c16cb18 100644 --- a/gateways/http/server.go +++ b/backend/gateways/http/server.go @@ -10,6 +10,10 @@ import ( "go.uber.org/zap" ) +const ( + readHeaderTimeout = 5 * time.Second +) + // @title LNK URL Shortener API // @version 1.0 // @description A URL shortener service API @@ -21,15 +25,15 @@ import ( type Server struct { logger *zap.Logger - port string srv *http.Server router *gin.Engine + port string } type Config struct { Logger *zap.Logger - Port string Router *gin.Engine + Port string } func NewServer(logger *zap.Logger, port string, router *gin.Engine) *Server { @@ -47,11 +51,12 @@ func (s *Server) Start() error { s.srv = &http.Server{ Addr: addr, Handler: s.router, - ReadHeaderTimeout: 5 * time.Second, + ReadHeaderTimeout: readHeaderTimeout, } go func() { - if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + err := s.srv.ListenAndServe() + if err != nil && err != http.ErrServerClosed { s.logger.Fatal("Failed to start server", zap.Error(err)) } }() @@ -67,13 +72,16 @@ func (s *Server) Shutdown(ctx context.Context) error { s.logger.Info("Shutting down HTTP server") const shutdownTimeout = 5 * time.Second + shutdownCtx, cancel := context.WithTimeout(ctx, shutdownTimeout) defer cancel() - if err := s.srv.Shutdown(shutdownCtx); err != nil { + err := s.srv.Shutdown(shutdownCtx) + if err != nil { return fmt.Errorf("server shutdown failed: %w", err) } s.logger.Info("HTTP server stopped") + return nil } diff --git a/go.mod b/backend/go.mod similarity index 100% rename from go.mod rename to backend/go.mod diff --git a/go.sum b/backend/go.sum similarity index 100% rename from go.sum rename to backend/go.sum diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..2e31b83 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + redis "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "lnk/domain/entities/usecases" + "lnk/extensions/config" + "lnk/extensions/logger" + redisPackage "lnk/extensions/redis" + gocqlPackage "lnk/gateways/gocql" + "lnk/gateways/gocql/repositories" + httpServer "lnk/gateways/http" + "lnk/gateways/http/handlers" +) + +func main() { + cfg, appLogger := setupConfigAndLogger() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + session := setupDatabase(cfg, appLogger) + defer session.Close() + + redisClient := setupRedis(ctx, cfg, appLogger) + + defer func() { + err := redisClient.Close() + if err != nil { + appLogger.Error("Failed to close Redis client", zap.Error(err)) + } + }() + + initializeCounter(ctx, redisClient, cfg, appLogger) + + useCase := createUseCase(cfg, appLogger, session, redisClient) + server := createAndStartServer(cfg, appLogger, useCase) + + shutdownServer(ctx, appLogger, server) +} + +func setupConfigAndLogger() (*config.Config, *zap.Logger) { + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + appLogger, err := logger.NewLogger(cfg.Logger) + if err != nil { + log.Fatalf("Failed to create logger: %v", err) + } + + appLogger.Info("Starting application") + + return cfg, appLogger +} + +func setupDatabase(cfg *config.Config, appLogger *zap.Logger) *gocql.Session { + session, err := gocqlPackage.SetupDatabase(&cfg.Gocql, appLogger) + if err != nil { + appLogger.Fatal("Failed to setup database", zap.Error(err)) + } + + return session +} + +func setupRedis(ctx context.Context, cfg *config.Config, appLogger *zap.Logger) *redis.Client { + redisClient, err := redisPackage.SetupRedis(ctx, &cfg.Redis, appLogger) + if err != nil { + appLogger.Fatal("Failed to setup Redis", zap.Error(err)) + } + + return redisClient +} + +func initializeCounter(ctx context.Context, redisClient *redis.Client, cfg *config.Config, appLogger *zap.Logger) { + setInitialCounter, err := redisPackage.SetInitialCounterValue(ctx, redisClient, &cfg.Redis, appLogger) + if err != nil && !setInitialCounter { + appLogger.Fatal("Failed to set initial counter", zap.Error(err)) + } +} + +func createUseCase(cfg *config.Config, appLogger *zap.Logger, session *gocql.Session, redisClient *redis.Client) *usecases.UseCase { + repository := repositories.NewRepository(appLogger, session) + redisAdapter := redisPackage.NewRedisAdapter(redisClient) + + return usecases.NewUseCase(usecases.NewUseCaseParams{ + Logger: appLogger, + Repository: repository, + Redis: redisAdapter, + Salt: cfg.App.Base62Salt, + CounterKey: cfg.Redis.CounterKey, + }) +} + +func createAndStartServer(cfg *config.Config, appLogger *zap.Logger, useCase *usecases.UseCase) *httpServer.Server { + httpHandlers := handlers.NewHandlers(appLogger, useCase) + + router := httpServer.NewRouter(httpServer.RouterConfig{ + Logger: appLogger, + GinMode: cfg.App.GinMode, + Env: cfg.App.ENV, + Handlers: httpHandlers, + }) + + server := httpServer.NewServer(appLogger, cfg.App.Port, router) + + err := server.Start() + if err != nil { + appLogger.Fatal("Failed to start HTTP server", zap.Error(err)) + } + + return server +} + +func shutdownServer(ctx context.Context, appLogger *zap.Logger, server *httpServer.Server) { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + <-sigChan + appLogger.Info("Received shutdown signal") + + const shutdownTimeout = 10 * time.Second + + shutdownCtx, shutdownCancel := context.WithTimeout(ctx, shutdownTimeout) + defer shutdownCancel() + + err := server.Shutdown(shutdownCtx) + if err != nil { + appLogger.Error("Error during server shutdown", zap.Error(err)) + } + + appLogger.Info("Application stopped") +} diff --git a/main.go b/main.go deleted file mode 100644 index c2b4395..0000000 --- a/main.go +++ /dev/null @@ -1,135 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - "os/signal" - "syscall" - "time" - - "lnk/domain/entities/usecases" - "lnk/extensions/config" - "lnk/extensions/logger" - redisPackage "lnk/extensions/redis" - gocqlPackage "lnk/gateways/gocql" - "lnk/gateways/gocql/repositories" - httpServer "lnk/gateways/http" - "lnk/gateways/http/handlers" - - redis "github.com/redis/go-redis/v9" - - gocql "github.com/apache/cassandra-gocql-driver/v2" - "go.uber.org/zap" -) - -func main() { - cfg, logger := setupConfigAndLogger() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - session := setupDatabase(cfg, logger) - defer session.Close() - - redisClient := setupRedis(ctx, cfg, logger) - defer func() { - if err := redisClient.Close(); err != nil { - logger.Error("Failed to close Redis client", zap.Error(err)) - } - }() - - initializeCounter(ctx, redisClient, cfg, logger) - - useCase := createUseCase(cfg, logger, session, redisClient) - server := createAndStartServer(cfg, logger, useCase) - - shutdownServer(ctx, logger, server) -} - -func setupConfigAndLogger() (*config.Config, *zap.Logger) { - cfg, err := config.LoadConfig() - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - logger, err := logger.NewLogger(cfg.Logger) - if err != nil { - log.Fatalf("Failed to create logger: %v", err) - } - - logger.Info("Starting application") - return cfg, logger -} - -func setupDatabase(cfg *config.Config, logger *zap.Logger) *gocql.Session { - session, err := gocqlPackage.SetupDatabase(&cfg.Gocql, logger) - if err != nil { - logger.Fatal("Failed to setup database", zap.Error(err)) - } - return session -} - -func setupRedis(ctx context.Context, cfg *config.Config, logger *zap.Logger) *redis.Client { - redisClient, err := redisPackage.SetupRedis(ctx, &cfg.Redis, logger) - if err != nil { - logger.Fatal("Failed to setup Redis", zap.Error(err)) - } - return redisClient -} - -func initializeCounter(ctx context.Context, redisClient *redis.Client, cfg *config.Config, logger *zap.Logger) { - setInitialCounter, err := redisPackage.SetInitialCounterValue(ctx, redisClient, &cfg.Redis, logger) - if err != nil && !setInitialCounter { - logger.Fatal("Failed to set initial counter", zap.Error(err)) - } -} - -func createUseCase(cfg *config.Config, logger *zap.Logger, session *gocql.Session, redisClient *redis.Client) *usecases.UseCase { - repository := repositories.NewRepository(logger, session) - redisAdapter := redisPackage.NewRedisAdapter(redisClient) - - return usecases.NewUseCase(usecases.NewUseCaseParams{ - Logger: logger, - Repository: repository, - Redis: redisAdapter, - Salt: cfg.App.Base62Salt, - CounterKey: cfg.Redis.CounterKey, - }) -} - -func createAndStartServer(cfg *config.Config, logger *zap.Logger, useCase *usecases.UseCase) *httpServer.Server { - httpHandlers := handlers.NewHandlers(logger, useCase) - - router := httpServer.NewRouter(httpServer.RouterConfig{ - Logger: logger, - GinMode: cfg.App.GinMode, - Env: cfg.App.ENV, - Handlers: httpHandlers, - }) - - server := httpServer.NewServer(logger, cfg.App.Port, router) - if err := server.Start(); err != nil { - logger.Fatal("Failed to start HTTP server", zap.Error(err)) - } - - return server -} - -func shutdownServer(ctx context.Context, logger *zap.Logger, server *httpServer.Server) { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - <-sigChan - logger.Info("Received shutdown signal") - - const shutdownTimeout = 10 * time.Second - shutdownCtx, shutdownCancel := context.WithTimeout(ctx, shutdownTimeout) - defer shutdownCancel() - - if err := server.Shutdown(shutdownCtx); err != nil { - logger.Error("Error during server shutdown", zap.Error(err)) - } - - logger.Info("Application stopped") -}