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 diff --git a/.gitignore b/.gitignore index 5285d83..f304278 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ api/.idea/ -.idea/ \ No newline at end of file +api/internal/ +.idea/ 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 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 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 diff --git a/api/.dockerignore b/api/.dockerignore index 324792e..7513741 100644 --- a/api/.dockerignore +++ b/api/.dockerignore @@ -1,3 +1,5 @@ config.yml README.md -.idea \ No newline at end of file +.idea +internal +docker-compose.yml \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile index 20fc5d7..774b58c 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,25 +1,29 @@ -FROM golang:1.22-alpine - +#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 . . -COPY config.prod.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 -EXPOSE 8080 +#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 +#Copy migration scripts +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 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/cmd/main.go b/api/cmd/main.go index 7118c46..f5b153e 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -3,20 +3,23 @@ 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" ) -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) @@ -37,7 +40,7 @@ func setupRouter() *gin.Engine { return r } -func loadEnv() { +func loadConfig() { viper.SetConfigFile("config.yml") err := viper.ReadInConfig() if err != nil { @@ -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() + //Load config file + loadConfig() + + //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.deployment.yml b/api/config.deployment.yml new file mode 100644 index 0000000..b91c431 --- /dev/null +++ b/api/config.deployment.yml @@ -0,0 +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.prod.yml b/api/config.prod.yml deleted file mode 100644 index 476480f..0000000 --- a/api/config.prod.yml +++ /dev/null @@ -1,3 +0,0 @@ -## PRODUCTIVE CONFIG -PORT: 8080 -GIN_MODE: release #["release", "debug"] diff --git a/api/config.yml b/api/config.yml index 89eb1cb..cc996b9 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 #This password is of course not production. Just local deployment + HOST: localhost + PORT: 3306 + diff --git a/api/controllers/gameController.go b/api/controllers/gameController.go index 8bcce33..5f50db5 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" @@ -49,6 +50,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{} @@ -63,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()}) @@ -83,6 +94,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) 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 diff --git a/api/go.mod b/api/go.mod index 4c78ded..36cd187 100644 --- a/api/go.mod +++ b/api/go.mod @@ -3,13 +3,16 @@ 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 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 +51,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/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 diff --git a/api/repositories/gameRepository.go b/api/repositories/gameRepository.go index c0655cd..25ef3e4 100644 --- a/api/repositories/gameRepository.go +++ b/api/repositories/gameRepository.go @@ -2,65 +2,124 @@ package repositories import ( "api/models" - "api/shared" + "database/sql" "github.com/google/uuid" ) 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 } 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).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 + } + + return checkResult(stmt.Exec(game.Title, game.StorageLocation, game.Status, game.Url, game.ID)) + } + } 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 + } + + 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. +// Or returns sql.ErrNoRows if the game is not existing. func (g gameRepository) Delete(id uuid.UUID) error { - //TODO implement me + stmt, err := g.db.Prepare("DELETE FROM games WHERE ID = ?") + if err != nil { + 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 } diff --git a/api/scripts/databaseScripts.go b/api/scripts/databaseScripts.go new file mode 100644 index 0000000..4050b15 --- /dev/null +++ b/api/scripts/databaseScripts.go @@ -0,0 +1,154 @@ +package scripts + +import ( + "api/shared" + "database/sql" + "fmt" + "github.com/spf13/viper" + "log" + "os" + "regexp" + "sort" + "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) + } + + //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 fileName[len(fileName)-4:] != ".sql" { + continue + } + + //get its id + migrationId, err := strconv.Atoi(strings.Split(fileName, "_")[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/" + fileName) + if err != nil { + log.Fatal(err) + } + + //Execute the sql script + log.Println("Executing migration: " + fileName) + 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/services/gameService.go b/api/services/gameService.go index ffb31af..deaad06 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, title string) (*models.Game, error) Delete(id uuid.UUID) error } @@ -23,22 +23,20 @@ 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, 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: "", } - return g.repository.Save(game) + return &game, g.repository.Save(&game) } func (g gameService) Delete(id uuid.UUID) error { 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 +} 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 ************************************