From aa8c6e5734461959aa3b5aaa5c8f709d23f0e845 Mon Sep 17 00:00:00 2001 From: Kai Date: Fri, 3 May 2024 20:28:26 +0200 Subject: [PATCH 01/26] adr api database mysql --- adrs/03052024-api-database.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 adrs/03052024-api-database.md diff --git a/adrs/03052024-api-database.md b/adrs/03052024-api-database.md new file mode 100644 index 0000000..0797f2c --- /dev/null +++ b/adrs/03052024-api-database.md @@ -0,0 +1,24 @@ +# AD: Database for the Api +**Decider:** Kai Pietruska + +## Context and Problem Statement +Which database do we want to use to save the metadata of the games? + +## Decision Drivers +We want to use a database to save the metadata of our games. +It should be known to everyone, so that we don't have to learn how to work with it. + + +## Considered Options +- MySQL +- Postgresql +- SQLite +- MongoDB +- Apache Cassandra + +## Decision Outcome +MySQL is for our use case sufficient and easy to use. + + +## Consequences +tbd \ No newline at end of file From 8ee843d4cb1e1b64a343565d200dd295db253866 Mon Sep 17 00:00:00 2001 From: Kai Date: Fri, 3 May 2024 20:41:13 +0200 Subject: [PATCH 02/26] adr api orm gorp --- adrs/03052024-api-orm.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 adrs/03052024-api-orm.md diff --git a/adrs/03052024-api-orm.md b/adrs/03052024-api-orm.md new file mode 100644 index 0000000..099b0be --- /dev/null +++ b/adrs/03052024-api-orm.md @@ -0,0 +1,23 @@ +# AD: Object-Relational Mapper for the api +**Decider:** Kai Pietruska + +## Context and Problem Statement +What ORM do we use to implement our api? + +## Decision Drivers +We want to use a ORM, so that we don't have to deal with raw SQL queries and we can modify our database easily. +It should generate SQL queries from Go code, since we already have our Go code. + +## Considered Options +- GORM +- ent +- gorp + +## Decision Outcome +gorp because it is lightwight and brings all the features we need for our use case. +GORM is nice but brings a lot of overhead and features we don't need. +ent has a steep learning curve. + + +## Consequences +tbd \ No newline at end of file From 76a668783de6f2b7b763ffc5111a201377d94545 Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 7 May 2024 15:55:09 +0200 Subject: [PATCH 03/26] setup database and migrations connect to sql database and run a simple migrations logic . --- api/cmd/main.go | 25 +++++- api/config.yml | 7 ++ api/go.mod | 3 + api/go.sum | 13 +++- api/migrations/0_init.sql | 13 ++++ api/scripts/databaseScripts.go | 136 +++++++++++++++++++++++++++++++++ api/shared/SliceFunctions.go | 10 +++ 7 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 api/migrations/0_init.sql create mode 100644 api/scripts/databaseScripts.go create mode 100644 api/shared/SliceFunctions.go diff --git a/api/cmd/main.go b/api/cmd/main.go index 7118c46..83862c9 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -3,9 +3,12 @@ package main import ( "api/controllers" "api/repositories" + "api/scripts" "api/services" + "database/sql" "fmt" "github.com/gin-gonic/gin" + _ "github.com/go-sql-driver/mysql" "github.com/spf13/viper" "log" "net/http" @@ -45,15 +48,35 @@ func loadEnv() { } } +func setupDatabase() *sql.DB { + //Create database if it is not existing yet. + //We might have to remove this if we use an azure database + scripts.CreateDatabaseIfNotExists(viper.GetString("DATABASE.NAME")) + //Connect to the database + db := scripts.ConnectToDatabase() + //Check if database is online + err := db.Ping() + if err != nil { + log.Fatal(err.Error()) + } + //Check if we have new migrations and apply them + scripts.MigrateDatabase(db) + return db +} + func main() { //Load environment file loadEnv() + //Setup database + db := setupDatabase() + defer db.Close() + //Set Gin-gonic to debug or release mode gin.SetMode(viper.GetString("GIN_MODE")) //Setup Routes - r := setupRouter() + r := setupRouter(db) // Listen and Server in 0.0.0.0:8080 r.Run(fmt.Sprintf(":%d", viper.GetInt("port"))) diff --git a/api/config.yml b/api/config.yml index 89eb1cb..8ef79a8 100644 --- a/api/config.yml +++ b/api/config.yml @@ -1,2 +1,9 @@ PORT: 8080 GIN_MODE: debug #["release", "debug"] +DATABASE: + NAME: api #database name + USER: root #login name + PASSWORD: Root#123 + HOST: localhost + PORT: 3306 + diff --git a/api/go.mod b/api/go.mod index 4c78ded..bdc55ce 100644 --- a/api/go.mod +++ b/api/go.mod @@ -5,11 +5,13 @@ go 1.20 require ( github.com/dranikpg/dto-mapper v0.2.1 github.com/gin-gonic/gin v1.9.1 + github.com/go-sql-driver/mysql v1.8.1 github.com/google/uuid v1.4.0 github.com/spf13/viper v1.18.2 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/bytedance/sonic v1.11.4 // indirect github.com/cloudwego/base64x v0.1.1 // indirect github.com/cloudwego/iasm v0.1.1 // indirect @@ -48,6 +50,7 @@ require ( golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/go.sum b/api/go.sum index d61bd09..d326bfb 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,3 +1,7 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= github.com/bytedance/sonic v1.11.4 h1:8+OMLSSDDm2/qJc6ld5K5Sm62NK9VHcUKk0NzBoMAM4= @@ -32,6 +36,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4= github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= @@ -42,11 +48,15 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= @@ -120,7 +130,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/migrations/0_init.sql b/api/migrations/0_init.sql new file mode 100644 index 0000000..bb816f5 --- /dev/null +++ b/api/migrations/0_init.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS db_state ( + migrations int +); + +CREATE TABLE IF NOT EXISTS games ( + ID varchar(36) NOT NULL primary key, + Title varchar(255), + StorageLocation varchar(255), + Status varchar(255), + Url varchar(255) +); + +INSERT INTO db_state VALUES (0); \ No newline at end of file diff --git a/api/scripts/databaseScripts.go b/api/scripts/databaseScripts.go new file mode 100644 index 0000000..b85c7b2 --- /dev/null +++ b/api/scripts/databaseScripts.go @@ -0,0 +1,136 @@ +package scripts + +import ( + "api/shared" + "database/sql" + "fmt" + "github.com/spf13/viper" + "log" + "os" + "strconv" + "strings" +) + +func ConnectToDatabase() *sql.DB { + // connect to db using standard Go database/sql API + connectionString := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", + viper.GetString("DATABASE.USER"), + viper.GetString("DATABASE.PASSWORD"), + viper.GetString("DATABASE.HOST"), + viper.GetString("DATABASE.PORT"), + viper.GetString("DATABASE.NAME")) + + db, err := sql.Open("mysql", connectionString) + + if err != nil { + //println(connectionString) + log.Fatal(err) + } + + return db +} + +// MigrateDatabase applies the migration scripts from folder migrations, +// if they have not been applied already. +func MigrateDatabase(db *sql.DB) { + log.Println("Starting migrations...") + + //Get the migrations that has been applied + migrations := getMigrationIds(db) + + //Load all migration scripts + files, err := os.ReadDir("migrations") + if err != nil { + log.Fatal(err) + } + + //For each migration script + for _, file := range files { + //If the file is not a .sql file, ignore it + if file.Name()[len(file.Name())-4:] != ".sql" { + continue + } + + //get its id + migrationId, err := strconv.Atoi(strings.Split(file.Name(), "_")[0]) + if err != nil { + log.Fatal(err) + } + //If the migration has not been applied yet + if !shared.IntInSlice(migrationId, migrations) { + + //Load the sql script + content, err := os.ReadFile("migrations/" + file.Name()) + if err != nil { + log.Fatal(err) + } + + //Execute the sql script + log.Println("Executing migration: " + file.Name()) + requests := strings.Split(string(content), ";") + for _, request := range requests { + if len(request) == 0 { + continue + } + + _, err := db.Exec(request) + if err != nil { + log.Fatal(err) + } + } + } + + } + log.Println("Finished migrations") +} + +// getMigrationIds returns the Ids of migrations which have been applied to the database. +func getMigrationIds(db *sql.DB) []int { + var migrations []int + + //Get the current database state + rows, err := db.Query("SELECT migrations FROM db_state") + if err != nil { + //Error 1146 says 'Table 'api.db_state' doesn't exist' + //We can ignore that because we will create the table in the next step. + if strings.Contains(err.Error(), "Error 1146") { + return migrations + } + log.Fatal(err) + } + defer rows.Close() + + for rows.Next() { + var migration int + err = rows.Scan(&migration) + if err != nil { + log.Fatal(err) + } + migrations = append(migrations, migration) + } + + return migrations +} + +func CreateDatabaseIfNotExists(database string) { + // connect to db using standard Go database/sql API + connectionString := fmt.Sprintf("%s:%s@tcp(%s:%s)/", + viper.GetString("DATABASE.USER"), + viper.GetString("DATABASE.PASSWORD"), + viper.GetString("DATABASE.HOST"), + viper.GetString("DATABASE.PORT")) + + db, err := sql.Open("mysql", connectionString) + + if err != nil { + log.Fatal(err) + } + defer db.Close() + + _, err = db.Exec(fmt.Sprintf("Create database if not exists %s", database)) + if err != nil { + log.Fatal(err) + } + + return +} diff --git a/api/shared/SliceFunctions.go b/api/shared/SliceFunctions.go new file mode 100644 index 0000000..88ac30a --- /dev/null +++ b/api/shared/SliceFunctions.go @@ -0,0 +1,10 @@ +package shared + +func IntInSlice(a int, list []int) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} From bc354e254fadc0afafd69bdfaf224e3ae1fef8e5 Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 7 May 2024 15:58:02 +0200 Subject: [PATCH 04/26] Game-Services/Repository interface changes work with pointers . --- api/repositories/gameRepository.go | 4 ++-- api/services/gameService.go | 12 +++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/api/repositories/gameRepository.go b/api/repositories/gameRepository.go index c0655cd..b79e0d7 100644 --- a/api/repositories/gameRepository.go +++ b/api/repositories/gameRepository.go @@ -8,8 +8,8 @@ import ( type IGameRepository interface { FindAll() ([]models.Game, error) - FindByID(id uuid.UUID) (models.Game, error) - Save(game models.Game) (models.Game, error) + FindByID(id uuid.UUID) (*models.Game, error) + Save(game *models.Game) error Delete(id uuid.UUID) error } diff --git a/api/services/gameService.go b/api/services/gameService.go index ffb31af..3c6cfca 100644 --- a/api/services/gameService.go +++ b/api/services/gameService.go @@ -10,8 +10,8 @@ import ( type IGameService interface { FindAll() ([]models.Game, error) - FindByID(id uuid.UUID) (models.Game, error) - Save(file *multipart.FileHeader) (models.Game, error) + FindByID(id uuid.UUID) (*models.Game, error) + Save(file *multipart.FileHeader) (*models.Game, error) Delete(id uuid.UUID) error } @@ -23,11 +23,9 @@ func (g gameService) FindAll() ([]models.Game, error) { return g.repository.FindAll() } -func (g gameService) FindByID(id uuid.UUID) (models.Game, error) { - return g.repository.FindByID(id) -} +func (g gameService) FindByID(id uuid.UUID) (*models.Game, error) { return g.repository.FindByID(id) } -func (g gameService) Save(file *multipart.FileHeader) (models.Game, error) { +func (g gameService) Save(file *multipart.FileHeader) (*models.Game, error) { //TODO save file game := models.Game{ @@ -38,7 +36,7 @@ func (g gameService) Save(file *multipart.FileHeader) (models.Game, error) { Url: "", } - return g.repository.Save(game) + return &game, g.repository.Save(&game) } func (g gameService) Delete(id uuid.UUID) error { From dbe209af05a2f2e301f57aa7f8a45489ca2d1026 Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 7 May 2024 15:59:20 +0200 Subject: [PATCH 05/26] GameRepository implementation read/write from/to database . --- api/cmd/main.go | 4 +- api/repositories/gameRepository.go | 112 +++++++++++++++++++---------- 2 files changed, 78 insertions(+), 38 deletions(-) diff --git a/api/cmd/main.go b/api/cmd/main.go index 83862c9..312f719 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -14,12 +14,12 @@ import ( "net/http" ) -func setupRouter() *gin.Engine { +func setupRouter(db *sql.DB) *gin.Engine { //Setup Gin r := gin.Default() //Setup Repositories - gamesRepository := repositories.GameRepository() + gamesRepository := repositories.GameRepository(db) gamesService := services.GameService(gamesRepository) gamesController := controllers.GameController(gamesService) diff --git a/api/repositories/gameRepository.go b/api/repositories/gameRepository.go index b79e0d7..19a8f4b 100644 --- a/api/repositories/gameRepository.go +++ b/api/repositories/gameRepository.go @@ -2,7 +2,7 @@ package repositories import ( "api/models" - "api/shared" + "database/sql" "github.com/google/uuid" ) @@ -14,53 +14,93 @@ type IGameRepository interface { } type gameRepository struct { - //TODO add database + db *sql.DB } -func GameRepository() IGameRepository { +func GameRepository(db *sql.DB) IGameRepository { return &gameRepository{ - //TODO add database + db: db, } } +// FindAll returns all games from the database or (nil, err) if an error occurred. func (g gameRepository) FindAll() ([]models.Game, error) { - //TODO implement me - return []models.Game{ - { - ID: uuid.New(), - Title: "Mock1", - StorageLocation: "Mock1", - Status: shared.Status_Installed, - Url: "https://localhost:4200", - }, - { - ID: uuid.New(), - Title: "Mock2", - StorageLocation: "Mock2", - Status: shared.Status_Installed, - Url: "https://localhost:4200", - }, - }, nil + query, err := g.db.Query("SELECT * FROM games") + if err != nil { + return nil, err + } + defer query.Close() + + var games []models.Game + for query.Next() { + var game models.Game + err = query.Scan(&game.ID, &game.Title, &game.StorageLocation, &game.Status, &game.Url) + if err != nil { + return nil, err + } + games = append(games, game) + } + + err = query.Err() + if err != nil { + return nil, err + } + + return games, nil } -func (g gameRepository) FindByID(id uuid.UUID) (models.Game, error) { - //TODO implement me - return models.Game{ - ID: id, - Title: "Mock", - StorageLocation: "Mock", - Status: shared.Status_Installed, - Url: "https://localhost:4200", - }, nil +// FindByID finds a game with a specific id or nil if the game has not been found. +func (g gameRepository) FindByID(id uuid.UUID) (*models.Game, error) { + var game models.Game + err := g.db.QueryRow("SELECT * FROM games WHERE ID = ?", id)n.Scan(&game.ID, &game.Title, &game.StorageLocation, &game.Status, &game.Url) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &game, nil } -func (g gameRepository) Save(game models.Game) (models.Game, error) { - //TODO implement me - game.ID = uuid.New() - return game, nil +// Save will update the database entry if the game is already in the database. +// If not it will create an uuid and save it in the database. +func (g gameRepository) Save(game *models.Game) error { + if game.ID != uuid.Nil { + //Check if uuid is already in database + existing, err := g.FindByID(game.ID) + if err != nil { + return err + } + + if existing != nil { + //If yes, update the existing entry + stmt, err := g.db.Prepare("UPDATE games SET Title=?, StorageLocation=?, Status=?, Url=? WHERE ID = ?") + if err != nil { + return err + } + + _, err = stmt.Exec(game.Title, game.StorageLocation, game.Status, game.Url, game.ID) + return err + } + } else { + game.ID = uuid.New() + } + + //If not create a new one + stmt, err := g.db.Prepare("INSERT INTO games (ID, Title, StorageLocation, Status, Url) VALUES (?,?,?,?,?)") + if err != nil { + return err + } + _, err = stmt.Exec(game.ID, game.Title, game.StorageLocation, game.Status, game.Url) + + return err } func (g gameRepository) Delete(id uuid.UUID) error { - //TODO implement me - return nil + stmt, err := g.db.Prepare("DELETE FROM games WHERE ID = ?") + if err != nil { + return err + } + _, err = stmt.Exec(id) + return err } From 10778bdd50c84c40c042294f6a7bf7fa9f2fd0f6 Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 7 May 2024 15:59:39 +0200 Subject: [PATCH 06/26] readme for migrations --- api/migrations/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 api/migrations/README.md diff --git a/api/migrations/README.md b/api/migrations/README.md new file mode 100644 index 0000000..39dbf56 --- /dev/null +++ b/api/migrations/README.md @@ -0,0 +1,8 @@ +## How the migration works +On startup, the system will load all sql scripts from this folder.\ +It will loop them (starting with 0) and checks if it has been applied to the database. +If this is not the case, it will apply it. + +## How to add a migration +If you want to add a migration after 0_init, create one with the name ``1_something``.\ +***The migration script must finish with the sql statement ``INSERT INTO db_state VALUES (1);``; where `1` is the identifier of your migration.*** \ No newline at end of file From 622c91760262082704992b6253aa687a9568e6ef Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 7 May 2024 16:01:01 +0200 Subject: [PATCH 07/26] added internal to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 6fb3d9f..128927d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ api/.idea/ + +api/internal/ From 9b6becc30bf0bb56ed1ed494324de5cdb156c661 Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 7 May 2024 16:01:38 +0200 Subject: [PATCH 08/26] gamecontroller return 404 in FindById --- api/controllers/gameController.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/controllers/gameController.go b/api/controllers/gameController.go index 8bcce33..d9ab40d 100644 --- a/api/controllers/gameController.go +++ b/api/controllers/gameController.go @@ -49,6 +49,10 @@ func (g gameController) GetGameById(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) return } + if game == nil { + c.JSON(http.StatusNotFound, gin.H{"message": "Game Not Found"}) + return + } //Map to dto resultDto := dtos.GetGameByIdResponseBody{} From ed083c2470ad3d713e45796dd56aa9e7e44c3c3b Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 7 May 2024 16:04:32 +0200 Subject: [PATCH 09/26] adr api orm (2) dont use any orm --- adrs/04052024-api-orm.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 adrs/04052024-api-orm.md diff --git a/adrs/04052024-api-orm.md b/adrs/04052024-api-orm.md new file mode 100644 index 0000000..f1e9124 --- /dev/null +++ b/adrs/04052024-api-orm.md @@ -0,0 +1,26 @@ +# AD: Object-Relational Mapper for the api +**Decider:** Kai Pietruska + +## Context and Problem Statement +What ORM do we use to implement our api? + +## Decision Drivers +We want to use a ORM, so that we don't have to deal with raw SQL queries and we can modify our database easily. +It should generate SQL queries from Go code, since we already have our Go code. + +## Considered Options +- GORM +- ent +- gorp + +## Decision Outcome +We don't use any GORM. + +gorp because it is lightwight and brings all the features we need for our use case, theoretically. +In practical it already failed creating the database and it doesn't support UUID prmary keys out of the box. +GORM is nice but brings a lot of overhead and features we don't need. +ent has a steep learning curve. + + +## Consequences +tbd \ No newline at end of file From 16cd5667d0cbe1698aa3640a66dc0a4792dfa262 Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 7 May 2024 16:27:39 +0200 Subject: [PATCH 10/26] fix typo --- api/repositories/gameRepository.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/repositories/gameRepository.go b/api/repositories/gameRepository.go index 19a8f4b..edfac23 100644 --- a/api/repositories/gameRepository.go +++ b/api/repositories/gameRepository.go @@ -52,7 +52,7 @@ func (g gameRepository) FindAll() ([]models.Game, error) { // FindByID finds a game with a specific id or nil if the game has not been found. func (g gameRepository) FindByID(id uuid.UUID) (*models.Game, error) { var game models.Game - err := g.db.QueryRow("SELECT * FROM games WHERE ID = ?", id)n.Scan(&game.ID, &game.Title, &game.StorageLocation, &game.Status, &game.Url) + err := g.db.QueryRow("SELECT * FROM games WHERE ID = ?", id).Scan(&game.ID, &game.Title, &game.StorageLocation, &game.Status, &game.Url) if err != nil { if err == sql.ErrNoRows { return nil, nil From eef0e9c09c03151a58df3450b69d08ab99676e6b Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 7 May 2024 22:13:34 +0200 Subject: [PATCH 11/26] setup docker-compose with database --- api/.dockerignore | 5 ++++- api/README.md | 9 ++++----- api/config.prod.yml | 6 ++++++ api/config.yml | 4 ++-- api/docker-compose.yml | 39 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 api/docker-compose.yml diff --git a/api/.dockerignore b/api/.dockerignore index 324792e..960ca48 100644 --- a/api/.dockerignore +++ b/api/.dockerignore @@ -1,3 +1,6 @@ config.yml README.md -.idea \ No newline at end of file +.idea +internal +docker-compose.yml +tests \ No newline at end of file diff --git a/api/README.md b/api/README.md index d052325..f6ec256 100644 --- a/api/README.md +++ b/api/README.md @@ -1,5 +1,4 @@ -## Build the docker container -``docker build . -t api:latest `` -## Run the docker container -``docker run -p 8080:8080 -i api:latest``\ -The api will be exposed to port 8080, access it with `localhost:8080`. \ No newline at end of file +## TL;DR +`` docker compose build; `` `` docker compose up `` + +The api will be exposed to port 8080, access it with `localhost:8080`. diff --git a/api/config.prod.yml b/api/config.prod.yml index 476480f..b91c431 100644 --- a/api/config.prod.yml +++ b/api/config.prod.yml @@ -1,3 +1,9 @@ ## PRODUCTIVE CONFIG PORT: 8080 GIN_MODE: release #["release", "debug"] +DATABASE: + NAME: api #database name + USER: root #This login name is just for local deployment. TODO set it in productive deployment + PASSWORD: Root#123 #This password is just for local deployment. TODO set it in productive deployment + HOST: mysql + PORT: 3306 diff --git a/api/config.yml b/api/config.yml index 8ef79a8..494d0e1 100644 --- a/api/config.yml +++ b/api/config.yml @@ -3,7 +3,7 @@ GIN_MODE: debug #["release", "debug"] DATABASE: NAME: api #database name USER: root #login name - PASSWORD: Root#123 - HOST: localhost + PASSWORD: Root#123 #This password is of course not production. Just local deployment + HOST: mysql PORT: 3306 diff --git a/api/docker-compose.yml b/api/docker-compose.yml new file mode 100644 index 0000000..1026cf8 --- /dev/null +++ b/api/docker-compose.yml @@ -0,0 +1,39 @@ +#The docker-compose is for local deployment. +#DON'T USE IT IN PRODUCTION IT CONTAINS HARDCODED PASSWORDS +services: + mysql: + image: 'bitnami/mysql:8.3.0' + environment: + - MYSQL_ROOT_USER=root + - MYSQL_ROOT_PASSWORD=Root#123 + - MYSQL_DATABASE=api + ports: + - "3306:3306" + volumes: + - ./internal/database:/bitnami/mysql/data + networks: + - api + healthcheck: + test: mysql --user=root --password=Root#123 -e 'SELECT * FROM mysql.time_zone' + restart: on-failure + #command: # fix from https://github.com/bitnami/containers/issues/44854#issuecomment-1800945882 + # - "/opt/bitnami/mysql/bin/mysqld" + # - "--defaults-file=/opt/bitnami/mysql/conf/my.cnf" + # - "--basedir=/opt/bitnami/mysql" + # - "--datadir=/bitnami/mysql/data" + # - "--socket=/opt/bitnami/mysql/tmp/mysql.sock" + # - "--pid-file=/opt/bitnami/mysql/tmp/mysqld.pid" + api: + build: . + ports: + - "8080:8080" + restart: on-failure + networks: + - api + depends_on: + mysql: + condition: service_healthy + +networks: + api: + driver: bridge \ No newline at end of file From 144c53b56b0163d275127f3a44e0ec7f3d80d252 Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 7 May 2024 22:15:21 +0200 Subject: [PATCH 12/26] rename "config.prod" to "config.deployment" --- api/Dockerfile | 2 +- api/{config.prod.yml => config.deployment.yml} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename api/{config.prod.yml => config.deployment.yml} (100%) diff --git a/api/Dockerfile b/api/Dockerfile index 20fc5d7..b437301 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -9,7 +9,7 @@ RUN go mod download # Add the directories which contain the golang scripts COPY . . -COPY config.prod.yml ./config.yml +COPY config.deployment.yml ./config.yml # Build RUN CGO_ENABLED=0 GOOS=linux go build -C cmd -o /api diff --git a/api/config.prod.yml b/api/config.deployment.yml similarity index 100% rename from api/config.prod.yml rename to api/config.deployment.yml From 1ba3cddd722de2c105593926c6f33409517de6ad Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 7 May 2024 22:30:38 +0200 Subject: [PATCH 13/26] rename "loadEnv" to "loadConfig" --- api/cmd/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/cmd/main.go b/api/cmd/main.go index 312f719..f5b153e 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -40,7 +40,7 @@ func setupRouter(db *sql.DB) *gin.Engine { return r } -func loadEnv() { +func loadConfig() { viper.SetConfigFile("config.yml") err := viper.ReadInConfig() if err != nil { @@ -65,8 +65,8 @@ func setupDatabase() *sql.DB { } func main() { - //Load environment file - loadEnv() + //Load config file + loadConfig() //Setup database db := setupDatabase() From 217618f9e5729d5596bdb33cb6c07bc1a67eca53 Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 8 May 2024 19:40:06 +0200 Subject: [PATCH 14/26] GameRepository.Delete() return err if game not found . . --- api/repositories/gameRepository.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/api/repositories/gameRepository.go b/api/repositories/gameRepository.go index edfac23..72fff6c 100644 --- a/api/repositories/gameRepository.go +++ b/api/repositories/gameRepository.go @@ -3,6 +3,7 @@ package repositories import ( "api/models" "database/sql" + "errors" "github.com/google/uuid" ) @@ -96,11 +97,32 @@ func (g gameRepository) Save(game *models.Game) error { return err } +// Delete removes the entry with a specific id from the games database. +// Or returns sql.ErrNoRows if the game is not existing. func (g gameRepository) Delete(id uuid.UUID) error { stmt, err := g.db.Prepare("DELETE FROM games WHERE ID = ?") if err != nil { return err } - _, err = stmt.Exec(id) - return err + + return checkResult(stmt.Exec(id)) +} + +func checkResult(res sql.Result, err error) error { + if err != nil { + return err + } + + return checkAffectedRows(res) +} + +func checkAffectedRows(res sql.Result) error { + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return sql.ErrNoRows + } + return nil } From 7d70e04379fbcffb4e246cf92f70919dfd86dfbe Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 8 May 2024 19:43:25 +0200 Subject: [PATCH 15/26] GameController.DeleteById() return 404 if game is not existing . --- api/controllers/gameController.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/controllers/gameController.go b/api/controllers/gameController.go index d9ab40d..7cf0df0 100644 --- a/api/controllers/gameController.go +++ b/api/controllers/gameController.go @@ -3,6 +3,7 @@ package controllers import ( "api/dtos" "api/services" + "database/sql" "fmt" "github.com/dranikpg/dto-mapper" "github.com/gin-gonic/gin" @@ -87,6 +88,9 @@ func (g gameController) DeleteGameById(c *gin.Context) { if _uuid != uuid.Nil { err := g.service.Delete(_uuid) if err != nil { //TODO handle different errors + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"message": "Game Not Found"}) + } c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) } else { c.Status(http.StatusNoContent) From 2e22ef59f7a7f6c1f981613034348252b44de648 Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 8 May 2024 19:43:54 +0200 Subject: [PATCH 16/26] spelling --- api/controllers/gameController.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/controllers/gameController.go b/api/controllers/gameController.go index 7cf0df0..e541954 100644 --- a/api/controllers/gameController.go +++ b/api/controllers/gameController.go @@ -51,7 +51,7 @@ func (g gameController) GetGameById(c *gin.Context) { return } if game == nil { - c.JSON(http.StatusNotFound, gin.H{"message": "Game Not Found"}) + c.JSON(http.StatusNotFound, gin.H{"message": "Game not found"}) return } @@ -89,7 +89,7 @@ func (g gameController) DeleteGameById(c *gin.Context) { err := g.service.Delete(_uuid) if err != nil { //TODO handle different errors if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"message": "Game Not Found"}) + c.JSON(http.StatusNotFound, gin.H{"message": "Game not found"}) } c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) } else { From 4fb348066693f32f201351d1edd666a3e8bc4399 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 May 2024 15:32:38 +0200 Subject: [PATCH 17/26] GameRepository.Save() check if operation was successfull --- api/repositories/gameRepository.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/repositories/gameRepository.go b/api/repositories/gameRepository.go index 72fff6c..aa1445e 100644 --- a/api/repositories/gameRepository.go +++ b/api/repositories/gameRepository.go @@ -3,7 +3,6 @@ package repositories import ( "api/models" "database/sql" - "errors" "github.com/google/uuid" ) @@ -92,9 +91,8 @@ func (g gameRepository) Save(game *models.Game) error { if err != nil { return err } - _, err = stmt.Exec(game.ID, game.Title, game.StorageLocation, game.Status, game.Url) - return err + return checkResult(stmt.Exec(game.ID, game.Title, game.StorageLocation, game.Status, game.Url)) } // Delete removes the entry with a specific id from the games database. From 988cd096434378dc52b1158ccf61504e28685afb Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 May 2024 15:51:28 +0200 Subject: [PATCH 18/26] fix gameRepositors.FindAll() returns nil when db is empty --- api/repositories/gameRepository.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/repositories/gameRepository.go b/api/repositories/gameRepository.go index aa1445e..032f3c4 100644 --- a/api/repositories/gameRepository.go +++ b/api/repositories/gameRepository.go @@ -31,7 +31,7 @@ func (g gameRepository) FindAll() ([]models.Game, error) { } defer query.Close() - var games []models.Game + var games = []models.Game{} for query.Next() { var game models.Game err = query.Scan(&game.ID, &game.Title, &game.StorageLocation, &game.Status, &game.Url) From b203dd0ce10f42038efdef3caab9e0f401e784df Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 May 2024 15:53:27 +0200 Subject: [PATCH 19/26] GameRepository unit tests --- api/go.mod | 1 + api/tests/gameRepository_test.go | 424 +++++++++++++++++++++++++++++++ 2 files changed, 425 insertions(+) create mode 100644 api/tests/gameRepository_test.go diff --git a/api/go.mod b/api/go.mod index bdc55ce..36cd187 100644 --- a/api/go.mod +++ b/api/go.mod @@ -3,6 +3,7 @@ module api go 1.20 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/dranikpg/dto-mapper v0.2.1 github.com/gin-gonic/gin v1.9.1 github.com/go-sql-driver/mysql v1.8.1 diff --git a/api/tests/gameRepository_test.go b/api/tests/gameRepository_test.go new file mode 100644 index 0000000..3532710 --- /dev/null +++ b/api/tests/gameRepository_test.go @@ -0,0 +1,424 @@ +package tests + +import ( + "api/models" + "api/repositories" + "database/sql" + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "log" + "regexp" + "testing" +) + +// ************************************ BEGIN DELETE TESTS ************************************ +func Test_Delete_Should_Succeed(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + log.Fatalf(err.Error()) + } + defer db.Close() + + //Define the mock + id := uuid.New() + mock.ExpectPrepare(regexp.QuoteMeta("DELETE FROM games WHERE ID = ?")) + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM games")). + WithArgs(id.String()). + WillReturnResult(sqlmock.NewResult(1, 1)) + + //Run the test + repository := repositories.GameRepository(db) + + err = repository.Delete(id) + if err != nil { + t.Errorf(err.Error()) + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } +} + +func Test_Delete_Not_Existing_Should_Fail(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(err.Error()) + } + defer db.Close() + + //Define the mock + id := uuid.New() + mock.ExpectPrepare(regexp.QuoteMeta("DELETE FROM games WHERE ID = ?")) + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM games")). + WithArgs(id.String()). + WillReturnResult(sqlmock.NewResult(0, 0)) + + //Run the test + repository := repositories.GameRepository(db) + + err = repository.Delete(id) + + if err == nil { + t.Errorf("error was not returned") + } + if err != sql.ErrNoRows { + t.Errorf("wrong error was returned") + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } +} + +//************************************ END DELETE TESTS ************************************ + +// ************************************ BEGIN INSERT TESTS ************************************ +func Test_Create_Game_Without_Id_Should_Succeed_And_SetId(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(err.Error()) + } + defer db.Close() + + //Define the mock + game := models.Game{ + ID: uuid.Nil, + Title: "", + StorageLocation: "", + Status: "", + Url: "", + } + mock.ExpectPrepare(regexp.QuoteMeta("INSERT INTO games")) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO games")). + WithArgs(sqlmock.AnyArg(), game.Title, game.StorageLocation, game.Status, game.Url). + WillReturnResult(sqlmock.NewResult(0, 1)) + + //Run the test + repository := repositories.GameRepository(db) + + err = repository.Save(&game) + if err != nil { + t.Errorf(err.Error()) + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } + + if game.ID == uuid.Nil { + t.Errorf("game id was not created") + } + +} + +func Test_Create_Game_With_Id_Should_Succeed(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(err.Error()) + } + defer db.Close() + + //Define the mock + id := uuid.New() + game := models.Game{ + ID: id, + Title: "", + StorageLocation: "", + Status: "", + Url: "", + } + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM games WHERE ID = ?")). + WithArgs(id).WillReturnError(sql.ErrNoRows) + + mock.ExpectPrepare("INSERT INTO games") + + mock.ExpectExec("INSERT INTO games"). + WithArgs(game.ID, game.Title, game.StorageLocation, game.Status, game.Url). + WillReturnResult(sqlmock.NewResult(0, 1)) + + //Run the test + repository := repositories.GameRepository(db) + + err = repository.Save(&game) + if err != nil { + t.Errorf(err.Error()) + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } + + if game.ID != id { + t.Errorf("game id has been changed") + } + +} + +//************************************ END INSERT TESTS ************************************ + +//************************************ BEGIN UPDATE TESTS ************************************ + +func Test_Save_Existing_Game_Should_Succeed(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(err.Error()) + } + defer db.Close() + + id := uuid.New() + game := models.Game{ + ID: id, + Title: "Mock", + StorageLocation: "Mock", + Status: "Mock", + Url: "Mock", + } + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM games WHERE ID = ?")). + WithArgs(id).WillReturnRows( + sqlmock.NewRows([]string{"id", "title", "storage_location", "status", "url"}). + AddRow(id, "", "", "", ""), + ) + + mock.ExpectPrepare(regexp.QuoteMeta("UPDATE games SET Title=?, StorageLocation=?, Status=?, Url=? WHERE ID = ?")) + + mock.ExpectExec(regexp.QuoteMeta("UPDATE games SET Title=?, StorageLocation=?, Status=?, Url=? WHERE ID = ?")). + WithArgs(game.Title, game.StorageLocation, game.Status, game.Url, game.ID). + WillReturnResult(sqlmock.NewResult(0, 1)) + + //Run the test + repository := repositories.GameRepository(db) + + err = repository.Save(&game) + if err != nil { + t.Errorf(err.Error()) + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } + + if game.ID != id { + t.Errorf("game id has been changed") + } + +} + +//************************************ END UPDATE TESTS ************************************ + +//************************************ BEGIN READ TESTS ************************************ + +func Test_Find_Game_By_Id_Should_Succeed(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(err.Error()) + } + defer db.Close() + + id := uuid.New() + game := models.Game{ + ID: id, + Title: "MockA", + StorageLocation: "MockB", + Status: "MockC", + Url: "MockD", + } + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM games WHERE ID = ?")). + WithArgs(id).WillReturnRows( + sqlmock.NewRows([]string{"id", "title", "storage_location", "status", "url"}). + AddRow(id, game.Title, game.StorageLocation, game.Status, game.Url), + ) + + //Run the test + repository := repositories.GameRepository(db) + + res, err := repository.FindByID(game.ID) + if err != nil { + t.Errorf(err.Error()) + } + + if res == nil { + t.Errorf("game was not returned") + } + + if game.ID != res.ID { + t.Errorf("game id has been changed") + } + + if game.Title != res.Title { + t.Errorf("game title has been changed") + } + + if game.StorageLocation != res.StorageLocation { + t.Errorf("game storage location has been changed") + } + + if game.Status != res.Status { + t.Errorf("game status has been changed") + } + + if game.Url != res.Url { + t.Errorf("game url has been changed") + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } +} + +func Test_Find_Game_By_Id_Should_Return_Nil(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(err.Error()) + } + defer db.Close() + + id := uuid.New() + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM games WHERE ID = ?")). + WithArgs(id).WillReturnError(sql.ErrNoRows) + + //Run the test + repository := repositories.GameRepository(db) + + res, err := repository.FindByID(id) + if err != nil { + t.Errorf(err.Error()) + } + + if res != nil { + t.Errorf("something has been returned, but it should not return anything.") + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } +} + +func Test_Find_Two_Games_Should_Succeed(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(err.Error()) + } + defer db.Close() + + gameA := models.Game{ + ID: uuid.New(), + Title: "AMockA", + StorageLocation: "AMockB", + Status: "AMockC", + Url: "AMockD", + } + + gameB := models.Game{ + ID: uuid.New(), + Title: "BMockA", + StorageLocation: "BMockB", + Status: "BMockC", + Url: "BMockD", + } + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM games")). + WillReturnRows( + sqlmock.NewRows([]string{"id", "title", "storage_location", "status", "url"}). + AddRow(gameA.ID, gameA.Title, gameA.StorageLocation, gameA.Status, gameA.Url). + AddRow(gameB.ID, gameB.Title, gameB.StorageLocation, gameB.Status, gameB.Url), + ) + + //Run the test + repository := repositories.GameRepository(db) + + res, err := repository.FindAll() + if err != nil { + t.Errorf(err.Error()) + } + + if res == nil { + t.Errorf("game was not returned") + } + + if len(res) != 2 { + t.Errorf("FindAll should return two games") + } + + if gameA.ID != res[0].ID { + t.Errorf("game id has been changed") + } + + if gameA.Title != res[0].Title { + t.Errorf("game title has been changed") + } + + if gameA.StorageLocation != res[0].StorageLocation { + t.Errorf("game storage location has been changed") + } + + if gameA.Status != res[0].Status { + t.Errorf("game status has been changed") + } + + if gameA.Url != res[0].Url { + t.Errorf("game url has been changed") + } + + if gameB.ID != res[1].ID { + t.Errorf("game id has been changed") + } + + if gameB.Title != res[1].Title { + t.Errorf("game title has been changed") + } + + if gameB.StorageLocation != res[1].StorageLocation { + t.Errorf("game storage location has been changed") + } + + if gameB.Status != res[1].Status { + t.Errorf("game status has been changed") + } + + if gameB.Url != res[1].Url { + t.Errorf("game url has been changed") + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } +} + +func Test_Find_Games_When_Database_Is_Empty_Should_Succeed(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(err.Error()) + } + defer db.Close() + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM games")). + WillReturnRows( + sqlmock.NewRows([]string{"id", "title", "storage_location", "status", "url"}), + ) + + //Run the test + repository := repositories.GameRepository(db) + + res, err := repository.FindAll() + if err != nil { + t.Errorf(err.Error()) + } + + if res == nil { + t.Errorf("nil was returned but empty list was expected") + } + + if len(res) != 0 { + t.Errorf("FindAll should return an empty list, but it was not empty") + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } +} + +//************************************ END READ TESTS ************************************ From 8cc2ad1d9c9da55073ad5184ba07add50fae1693 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 May 2024 16:08:52 +0200 Subject: [PATCH 20/26] updated migrationScript sort list of files & use regex to check filename --- api/scripts/databaseScripts.go | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/api/scripts/databaseScripts.go b/api/scripts/databaseScripts.go index b85c7b2..4050b15 100644 --- a/api/scripts/databaseScripts.go +++ b/api/scripts/databaseScripts.go @@ -7,6 +7,8 @@ import ( "github.com/spf13/viper" "log" "os" + "regexp" + "sort" "strconv" "strings" ) @@ -44,15 +46,31 @@ func MigrateDatabase(db *sql.DB) { log.Fatal(err) } - //For each migration script + //Sort the list of fileNames + var fileNames []string for _, file := range files { + fileNames = append(fileNames, file.Name()) + } + sort.Strings(fileNames) + + //For each migration script + for _, fileName := range fileNames { + //Check if its a valid filename + match, err := regexp.MatchString(fileName, `\d*_.*[.]sql`) + if err != nil { + log.Fatal(err) + } + if !match { + continue + } + //If the file is not a .sql file, ignore it - if file.Name()[len(file.Name())-4:] != ".sql" { + if fileName[len(fileName)-4:] != ".sql" { continue } //get its id - migrationId, err := strconv.Atoi(strings.Split(file.Name(), "_")[0]) + migrationId, err := strconv.Atoi(strings.Split(fileName, "_")[0]) if err != nil { log.Fatal(err) } @@ -60,13 +78,13 @@ func MigrateDatabase(db *sql.DB) { if !shared.IntInSlice(migrationId, migrations) { //Load the sql script - content, err := os.ReadFile("migrations/" + file.Name()) + content, err := os.ReadFile("migrations/" + fileName) if err != nil { log.Fatal(err) } //Execute the sql script - log.Println("Executing migration: " + file.Name()) + log.Println("Executing migration: " + fileName) requests := strings.Split(string(content), ";") for _, request := range requests { if len(request) == 0 { From d3fcb1850e8d718f669089d9ee0c20422d9c56b3 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 May 2024 16:20:06 +0200 Subject: [PATCH 21/26] fix broke config.yml --- api/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/config.yml b/api/config.yml index 494d0e1..cc996b9 100644 --- a/api/config.yml +++ b/api/config.yml @@ -4,6 +4,6 @@ DATABASE: NAME: api #database name USER: root #login name PASSWORD: Root#123 #This password is of course not production. Just local deployment - HOST: mysql + HOST: localhost PORT: 3306 From af1048288082d45c69b8324395debb6261315910 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 May 2024 16:35:27 +0200 Subject: [PATCH 22/26] check result of update query --- api/repositories/gameRepository.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/repositories/gameRepository.go b/api/repositories/gameRepository.go index 032f3c4..25ef3e4 100644 --- a/api/repositories/gameRepository.go +++ b/api/repositories/gameRepository.go @@ -79,8 +79,7 @@ func (g gameRepository) Save(game *models.Game) error { return err } - _, err = stmt.Exec(game.Title, game.StorageLocation, game.Status, game.Url, game.ID) - return err + return checkResult(stmt.Exec(game.Title, game.StorageLocation, game.Status, game.Url, game.ID)) } } else { game.ID = uuid.New() From 6650e6ea6adfce65ae7f5f173e7f4f0c2f15df3e Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 May 2024 18:47:00 +0200 Subject: [PATCH 23/26] added api image to workflow --- .github/workflows/build-test-scan-push-images.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-test-scan-push-images.yml b/.github/workflows/build-test-scan-push-images.yml index 4e47fc5..0c38d53 100644 --- a/.github/workflows/build-test-scan-push-images.yml +++ b/.github/workflows/build-test-scan-push-images.yml @@ -24,6 +24,8 @@ jobs: # Add your images here (name of the component + directory that contains Dockerfile) - name: frontend directory: frontend + - name: api + directory: api runs-on: ubuntu-latest permissions: contents: write From 8a26dce9ac8ad0da510bf63afeae7a51e1273360 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 May 2024 21:29:41 +0200 Subject: [PATCH 24/26] multistage dockerfile (build, release) --- api/Dockerfile | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index b437301..bcc45a4 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22-alpine +FROM golang:1.22-alpine as build # Set destination for COPY WORKDIR /app @@ -9,16 +9,18 @@ RUN go mod download # Add the directories which contain the golang scripts COPY . . -COPY config.deployment.yml ./config.yml # Build RUN CGO_ENABLED=0 GOOS=linux go build -C cmd -o /api -# Optional: -# To bind to a TCP port, runtime parameters must be supplied to the docker command. -# But we can document in the Dockerfile what ports -# the application is going to listen on by default. -# https://docs.docker.com/reference/dockerfile/#expose + +FROM scratch as release +#Copy config +COPY config.deployment.yml ./config.yml +#Copy migration scripts +COPY migrations migrations +#Copy build result to next stage +COPY --from=build /api /api EXPOSE 8080 # Run From e13111826df82eb26d56e4514bb67c00eec6ad88 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 May 2024 21:37:37 +0200 Subject: [PATCH 25/26] added test stage to dockerfile --- api/.dockerignore | 3 +-- api/Dockerfile | 12 +++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/api/.dockerignore b/api/.dockerignore index 960ca48..7513741 100644 --- a/api/.dockerignore +++ b/api/.dockerignore @@ -2,5 +2,4 @@ config.yml README.md .idea internal -docker-compose.yml -tests \ No newline at end of file +docker-compose.yml \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile index bcc45a4..774b58c 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,19 +1,22 @@ +#Stage 1: Compile and build FROM golang:1.22-alpine as build - # Set destination for COPY WORKDIR /app - # Download Go modules COPY go.mod go.sum ./ RUN go mod download - # Add the directories which contain the golang scripts COPY . . - # Build RUN CGO_ENABLED=0 GOOS=linux go build -C cmd -o /api +#Stage 2a: Run tests +FROM golang:1.22-alpine as test +WORKDIR /app +COPY . . +CMD ["go", "test", "./tests"] +#Stage 2b: Run Api FROM scratch as release #Copy config COPY config.deployment.yml ./config.yml @@ -22,6 +25,5 @@ COPY migrations migrations #Copy build result to next stage COPY --from=build /api /api EXPOSE 8080 - # Run CMD ["/api"] \ No newline at end of file From aa49abfaf1cc1c8ea41eacda45759d83d580d852 Mon Sep 17 00:00:00 2001 From: Kai Date: Sun, 12 May 2024 14:19:33 +0200 Subject: [PATCH 26/26] Added required "title" Parameter to upload game --- api/controllers/gameController.go | 8 +++++++- api/services/gameService.go | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/api/controllers/gameController.go b/api/controllers/gameController.go index e541954..5f50db5 100644 --- a/api/controllers/gameController.go +++ b/api/controllers/gameController.go @@ -68,12 +68,18 @@ func (g gameController) GetGameById(c *gin.Context) { } func (g gameController) UploadGame(c *gin.Context) { + + title := c.Query("title") + if len(title) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"message": "Title is required"}) + } + file, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) } - _, err = g.service.Save(file) + _, err = g.service.Save(file, title) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) diff --git a/api/services/gameService.go b/api/services/gameService.go index 3c6cfca..deaad06 100644 --- a/api/services/gameService.go +++ b/api/services/gameService.go @@ -11,7 +11,7 @@ import ( type IGameService interface { FindAll() ([]models.Game, error) FindByID(id uuid.UUID) (*models.Game, error) - Save(file *multipart.FileHeader) (*models.Game, error) + Save(file *multipart.FileHeader, title string) (*models.Game, error) Delete(id uuid.UUID) error } @@ -25,12 +25,12 @@ func (g gameService) FindAll() ([]models.Game, error) { func (g gameService) FindByID(id uuid.UUID) (*models.Game, error) { return g.repository.FindByID(id) } -func (g gameService) Save(file *multipart.FileHeader) (*models.Game, error) { +func (g gameService) Save(file *multipart.FileHeader, title string) (*models.Game, error) { //TODO save file game := models.Game{ ID: uuid.New(), - Title: file.Filename, + Title: title, StorageLocation: "", Status: shared.Status_New, Url: "",