Skip to content

Commit 6cad36e

Browse files
authored
Merge pull request #3146 from mdelapenya/testcontainer-recipe
feat: add recipe for local-development with testcontainers
2 parents 182c1a5 + d255e5f commit 6cad36e

31 files changed

+1622
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Here you can find the most **delicious** recipes to cook delicious meals using o
5757
- [I18n](./i18n/README.md) - Internationalization support.
5858
- [JWT](./jwt/README.md) - Using JSON Web Tokens (JWT) for authentication.
5959
- [Kubernetes](./k8s/README.md) - Deploying applications to Kubernetes.
60+
- [Local Development with Testcontainers](./local-development-testcontainers/README.md) - Local development with Testcontainers.
6061
- [Memgraph](./memgraph/README.md) - Using Memgraph.
6162
- [MinIO](./minio/README.md) - A simple application for uploading and downloading files from MinIO.
6263
- [MongoDB](./mongodb/README.md) - Connecting to a MongoDB database.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Config file for [Air](https://github.com/air-verse/air) in TOML format
2+
3+
# Working directory
4+
# . or absolute path, please note that the directories following must be under root.
5+
root = "."
6+
tmp_dir = "tmp"
7+
8+
[build]
9+
# Just plain old shell command. You could use `make` as well.
10+
cmd = "go build -tags dev -o ./todo-api ./main.go"
11+
# Binary file yields from `cmd`.
12+
bin = "todo-api"
13+
# Customize binary.
14+
full_bin = "APP_ENV=dev APP_USER=air godotenv -f .env ./todo-api"
15+
# Watch these filename extensions.
16+
include_ext = ["go", "tpl", "tmpl", "html", "mustache", "hbs", "pug"]
17+
# Ignore these filename extensions or directories.
18+
exclude_dir = ["tmp", "vendor", "node_modules"]
19+
# Watch these directories if you specified.
20+
include_dir = []
21+
# Exclude files.
22+
exclude_file = []
23+
# This log file places in your tmp_dir.
24+
log = "air.log"
25+
# It's not necessary to trigger build each time file changes if it's too frequent.
26+
delay = 1000 # ms
27+
# Stop running old binary when build errors occur.
28+
stop_on_error = true
29+
# Send Interrupt signal before killing process (windows does not support this feature)
30+
send_interrupt = true
31+
# Delay after sending Interrupt signal
32+
kill_delay = 500 # ms
33+
34+
[log]
35+
# Show log time
36+
time = false
37+
38+
[color]
39+
# Customize each part's color. If no color found, use the raw app log.
40+
main = "magenta"
41+
watcher = "cyan"
42+
build = "yellow"
43+
runner = "green"
44+
45+
[misc]
46+
# Delete tmp directory on exit
47+
clean_on_exit = true
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
PORT=8000
2+
TOKEN_KEY=laksdjflkasjfwj92jfslj2qu0-9apsoifjk
3+
TESTCONTAINERS_RYUK_DISABLED=true
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
todo-api
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
2+
#build stage
3+
FROM golang:alpine AS builder
4+
WORKDIR /go/src/app
5+
COPY . .
6+
RUN go get -d -v . && go build -ldflags="-s -w" main.go
7+
8+
#final stage
9+
FROM alpine:latest
10+
LABEL maintainer=mdelapenya version=0.0.1
11+
COPY --from=builder /go/src/app/main /main
12+
EXPOSE 4000
13+
ENTRYPOINT /main
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
title: Todo App + Auth + GORM + Testcontainers
3+
keywords: [todo app, gorm, authentication, testcontainers, postgres]
4+
description: A Todo application with authentication using GORM and Postgres.
5+
---
6+
7+
# Todo App with Auth using GORM and Testcontainers
8+
9+
[![Github](https://img.shields.io/static/v1?label=&message=Github&color=2ea44f&style=for-the-badge&logo=github)](https://github.com/gofiber/recipes/tree/master/todo-app-testcontainers-postgres) [![StackBlitz](https://img.shields.io/static/v1?label=&message=StackBlitz&color=2ea44f&style=for-the-badge&logo=StackBlitz)](https://stackblitz.com/github.com/gofiber/recipes/tree/master/todo-app-testcontainers-postgres)
10+
11+
This project demonstrates a Todo application with authentication using GORM and Testcontainers.
12+
13+
The database is a Postgres instance created using the GoFiber's [Testcontainers Service module](https://github.com/gofiber/contrib/testcontainers). The instance is reused across multiple runs of the application, allowing to develop locally without having to wait for the database to be ready.
14+
15+
When using the `air` command to run the application, the database is automatically started alongside the Fiber application, and it's automatically stopped when the air command is interrupted.
16+
17+
## Prerequisites
18+
19+
Ensure you have the following installed and available in your `GOPATH`:
20+
21+
- Golang
22+
- [Air](https://github.com/air-verse/air) for hot reloading
23+
24+
## Installation
25+
26+
1. Clone the repository:
27+
```sh
28+
git clone https://github.com/gofiber/recipes.git
29+
cd recipes/todo-app-testcontainers-postgres
30+
```
31+
32+
2. Install dependencies:
33+
```sh
34+
go get
35+
```
36+
37+
## Running the Application
38+
39+
1. Start the application:
40+
```sh
41+
air
42+
```
43+
44+
## Environment Variables
45+
46+
Create a `.env` file in the root directory and add the following variables:
47+
48+
```shell
49+
# PORT returns the server listening port
50+
# default: 8000
51+
PORT=
52+
53+
# DB returns the name of the sqlite database
54+
# default: postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable
55+
56+
# TOKENKEY returns the jwt token secret
57+
TOKENKEY=
58+
59+
# TOKENEXP returns the jwt token expiration duration.
60+
# Should be time.ParseDuration string. Source: https://golang.org/pkg/time/#ParseDuration
61+
# default: 10h
62+
TOKENEXP=
63+
64+
# TESTCONTAINERS_RYUK_DISABLED disables the Ryuk container, to avoid removing the database container when the application is stopped.
65+
# default: true
66+
TESTCONTAINERS_RYUK_DISABLED=true
67+
```
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package dal
2+
3+
import (
4+
"gorm.io/gorm"
5+
)
6+
7+
// Todo struct defines the Todo Model
8+
type Todo struct {
9+
gorm.Model
10+
Task string `gorm:"not null"`
11+
Completed bool `gorm:"default:false"`
12+
User *uint64 `gorm:"index,not null"`
13+
// this is a pointer because int == 0,
14+
}
15+
16+
// CreateTodo create a todo entry in the todo's table
17+
func CreateTodo(db *gorm.DB, todo *Todo) *gorm.DB {
18+
return db.Create(todo)
19+
}
20+
21+
// FindTodo finds a todo with given condition
22+
func FindTodo(db *gorm.DB, dest interface{}, conds ...interface{}) *gorm.DB {
23+
return db.Model(&Todo{}).Take(dest, conds...)
24+
}
25+
26+
// FindTodoByUser finds a todo with given todo and user identifier
27+
func FindTodoByUser(db *gorm.DB, dest interface{}, todoIden interface{}, userIden interface{}) *gorm.DB {
28+
return FindTodo(db, dest, "todos.id = ? AND todos.user = ?", todoIden, userIden)
29+
}
30+
31+
// FindTodosByUser finds the todos with user's identifier given
32+
func FindTodosByUser(db *gorm.DB, dest interface{}, userIden interface{}) *gorm.DB {
33+
return db.Model(&Todo{}).Find(dest, "todos.user = ?", userIden)
34+
}
35+
36+
// DeleteTodo deletes a todo from todos' table with the given todo and user identifier
37+
func DeleteTodo(db *gorm.DB, todoIden interface{}, userIden interface{}) *gorm.DB {
38+
return db.Unscoped().Delete(&Todo{}, "todos.id = ? AND todos.user = ?", todoIden, userIden)
39+
}
40+
41+
// UpdateTodo allows to update the todo with the given todoID and userID
42+
func UpdateTodo(db *gorm.DB, todoIden interface{}, userIden interface{}, data interface{}) *gorm.DB {
43+
return db.Model(&Todo{}).Where("todos.id = ? AND todos.user = ?", todoIden, userIden).Updates(data)
44+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package dal_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
tc "github.com/testcontainers/testcontainers-go"
9+
"github.com/testcontainers/testcontainers-go/modules/postgres"
10+
11+
"local-development/testcontainers/app/dal"
12+
"local-development/testcontainers/config/database"
13+
)
14+
15+
func TestTodos(t *testing.T) {
16+
pgCtr, err := postgres.Run(
17+
context.Background(),
18+
"postgres:16",
19+
postgres.WithDatabase("todos"),
20+
postgres.WithUsername("todos"),
21+
postgres.WithPassword("todos"),
22+
postgres.BasicWaitStrategies(),
23+
)
24+
tc.CleanupContainer(t, pgCtr)
25+
require.NoError(t, err)
26+
27+
connString, err := pgCtr.ConnectionString(context.Background())
28+
require.NoError(t, err)
29+
30+
db, err := database.New(connString)
31+
require.NoError(t, err)
32+
33+
err = db.AutoMigrate(&dal.User{}, &dal.Todo{})
34+
require.NoError(t, err)
35+
36+
user := &dal.User{
37+
Name: "John Doe",
38+
39+
Password: "password",
40+
}
41+
result := dal.CreateUser(db, user)
42+
require.NoError(t, result.Error)
43+
44+
// Make sure that gorm.Model.ID is uint64, which could happen
45+
// if the machine compiling the code has multiple versions of gorm.
46+
uid := uint64(user.ID)
47+
48+
result = result.Scan(&user)
49+
require.NoError(t, result.Error)
50+
51+
t.Run("create", func(t *testing.T) {
52+
result := dal.CreateTodo(db, &dal.Todo{
53+
Task: "Buy groceries",
54+
User: &uid,
55+
})
56+
require.NoError(t, result.Error)
57+
58+
todo1 := &dal.Todo{}
59+
result = result.Scan(todo1)
60+
require.NoError(t, result.Error)
61+
62+
// create a second todo
63+
result = dal.CreateTodo(db, &dal.Todo{
64+
Task: "Clean the swimming pool",
65+
User: &uid,
66+
})
67+
require.NoError(t, result.Error)
68+
69+
t.Run("find", func(t *testing.T) {
70+
result := dal.FindTodo(db, &dal.Todo{}, "todos.task = ?", "Buy groceries")
71+
require.NoError(t, result.Error)
72+
require.Equal(t, int64(1), result.RowsAffected)
73+
74+
t.Run("todos-by-user", func(t *testing.T) {
75+
result := dal.FindTodosByUser(db, &[]dal.Todo{}, uid)
76+
require.NoError(t, result.Error)
77+
require.Equal(t, int64(2), result.RowsAffected)
78+
})
79+
80+
t.Run("todo-by-user", func(t *testing.T) {
81+
result := dal.FindTodoByUser(db, &[]dal.Todo{}, todo1.ID, uid)
82+
require.NoError(t, result.Error)
83+
require.Equal(t, int64(1), result.RowsAffected)
84+
85+
todo := &dal.Todo{}
86+
result = result.Scan(todo)
87+
require.NoError(t, result.Error)
88+
require.Equal(t, todo1.ID, todo.ID)
89+
})
90+
})
91+
92+
t.Run("update", func(t *testing.T) {
93+
result := dal.FindTodo(db, &dal.Todo{}, "todos.task = ?", "Buy a new car")
94+
require.Error(t, result.Error)
95+
require.Zero(t, result.RowsAffected)
96+
97+
result = dal.UpdateTodo(db, todo1.ID, &uid, &dal.Todo{Task: "Buy a new car"})
98+
require.NoError(t, result.Error)
99+
100+
result = dal.FindTodo(db, &dal.Todo{}, "todos.task = ?", "Buy a new car")
101+
require.NoError(t, result.Error)
102+
require.Equal(t, int64(1), result.RowsAffected)
103+
})
104+
105+
t.Run("delete", func(t *testing.T) {
106+
result := dal.CreateTodo(db, &dal.Todo{
107+
Task: "do the house cleaning",
108+
User: &uid,
109+
})
110+
require.NoError(t, result.Error)
111+
112+
todo := &dal.Todo{}
113+
result = result.Scan(todo)
114+
require.NoError(t, result.Error)
115+
116+
result = dal.DeleteTodo(db, todo.ID, &uid)
117+
require.NoError(t, result.Error)
118+
require.Equal(t, int64(1), result.RowsAffected)
119+
})
120+
})
121+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package dal
2+
3+
import (
4+
"gorm.io/gorm"
5+
)
6+
7+
// User struct defines the user
8+
type User struct {
9+
gorm.Model
10+
Name string
11+
Email string `gorm:"uniqueIndex;not null"`
12+
Password string `gorm:"not null"`
13+
Todos []Todo `gorm:"foreignKey:User"`
14+
}
15+
16+
// CreateUser create a user entry in the user's table
17+
func CreateUser(db *gorm.DB, user *User) *gorm.DB {
18+
return db.Create(user)
19+
}
20+
21+
// FindUser searches the user's table with the condition given
22+
func FindUser(db *gorm.DB, dest interface{}, conds ...interface{}) *gorm.DB {
23+
return db.Model(&User{}).Take(dest, conds...)
24+
}
25+
26+
// FindUserByEmail searches the user's table with the email given
27+
func FindUserByEmail(db *gorm.DB, dest interface{}, email string) *gorm.DB {
28+
return FindUser(db, dest, "email = ?", email)
29+
}

0 commit comments

Comments
 (0)