Skip to content

Commit 4fa3b57

Browse files
Copilotdkhalife
andauthored
Add MySQL database support with YAML and environment configuration (#214)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dkhalife <1736645+dkhalife@users.noreply.github.com> Co-authored-by: Dany Khalife <dev@dkhalife.com>
1 parent 96f1d9e commit 4fa3b57

File tree

9 files changed

+249
-12
lines changed

9 files changed

+249
-12
lines changed

README.md

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,17 +80,64 @@ Make sure to replace `/path/to/host` with your preferred root directory for conf
8080

8181
## ⚙️ Configuration
8282

83-
In the [config](./config/) directory are a couple of starter configuration files for prod and dev environments. The server expects a config.yaml in the config directory and will load settings from it when started.
83+
In the [config](./apiserver/config/) directory are a couple of starter configuration files for prod and dev environments. The server expects a config.yaml in the config directory and will load settings from it when started.
8484

85-
**Note:** You can set `email.host`, `email.port`, `email.email`, `email.password` and `jwt.secret` using environment variables `TW_EMAIL_HOST`, `TW_EMAIL_PORT`, `TW_EMAIL_SENDER`, `TW_EMAIL_PASSWORD` and `TW_JWT_SECRET` for improved security and flexibility. The server will fail to start if `jwt.secret` is left as `"secret"`, so be sure to set `TW_JWT_SECRET` or edit `config.yaml`.
85+
**Note:** You can set `email.host`, `email.port`, `email.email`, `email.password`, `jwt.secret`, and database credentials using environment variables for improved security and flexibility. The server will fail to start if `jwt.secret` is left as `"secret"`, so be sure to set `TW_JWT_SECRET` or edit `config.yaml`.
86+
87+
### Database Configuration
88+
89+
Task Wizard supports both SQLite and MySQL databases. By default, it uses SQLite.
90+
91+
#### SQLite (default)
92+
93+
To use SQLite, set `database.type` to `sqlite` (or leave it unset) in your `config.yaml`:
94+
95+
```yaml
96+
database:
97+
type: sqlite
98+
path: /config/task-wizard.db
99+
migration: true
100+
```
101+
102+
#### MySQL
103+
104+
To use MySQL, configure the database section:
105+
106+
```yaml
107+
database:
108+
type: mysql
109+
host: localhost
110+
port: 3306
111+
database: taskwizard
112+
username: taskuser
113+
password: taskpass
114+
migration: true
115+
```
116+
117+
You can also use environment variables for database configuration:
118+
119+
- `TW_DATABASE_TYPE` - Database type (sqlite or mysql)
120+
- `TW_DATABASE_HOST` - Database host
121+
- `TW_DATABASE_PORT` - Database port
122+
- `TW_DATABASE_NAME` - Database name
123+
- `TW_DATABASE_USERNAME` - Database username
124+
- `TW_DATABASE_PASSWORD` - Database password
125+
126+
### Configuration Reference
86127

87128
The configuration files are yaml mappings with the following values:
88129

89130
| Configuration Entry | Default Value | Description |
90131
|------------------------------------------|-----------------------------------------------------|-----------------------------------------------------------------------------|
91132
| `name` | `"prod"` | The name of the environment configuration. |
133+
| `database.type` | `sqlite` | Database type: `sqlite` or `mysql`. |
92134
| `database.migration` | `true` | Indicates if database migration should be performed. |
93-
| `database.path` | `/config/task-wizard.db` | The path at which to store the SQLite database. |
135+
| `database.path` | `/config/task-wizard.db` | The path at which to store the SQLite database (SQLite only). |
136+
| `database.host` | (empty) | Database host (MySQL only). |
137+
| `database.port` | `3306` | Database port (MySQL only). |
138+
| `database.database` | (empty) | Database name (MySQL only). |
139+
| `database.username` | (empty) | Database username (MySQL only). |
140+
| `database.password` | (empty) | Database password (MySQL only). |
94141
| `jwt.secret` | `"secret"` | The secret key used for signing JWT tokens. **Make sure to change that or set `TW_JWT_SECRET`.** |
95142
| `jwt.session_time` | `168h` | The duration for which a JWT session is valid. |
96143
| `jwt.max_refresh` | `168h` | The maximum duration for refreshing a JWT session. |

apiserver/config/config.dev.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
database:
2+
type: sqlite
23
migration: true
34
path: task-wizard.db
45
jwt:

apiserver/config/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ type Config struct {
1616
}
1717

1818
type DatabaseConfig struct {
19+
Type string `mapstructure:"type" yaml:"type" default:"sqlite"`
1920
FilePath string `mapstructure:"path" yaml:"path" default:"/config/task-wizard.db"`
21+
Host string `mapstructure:"host" yaml:"host"`
22+
Port int `mapstructure:"port" yaml:"port" default:"3306"`
23+
Database string `mapstructure:"database" yaml:"database"`
24+
Username string `mapstructure:"username" yaml:"username"`
25+
Password string `mapstructure:"password" yaml:"password"`
2026
Migration bool `mapstructure:"migration" yaml:"migration"`
2127
}
2228

@@ -79,6 +85,12 @@ func LoadConfig(configFile string) *Config {
7985
_ = viper.BindEnv("email.port", "TW_EMAIL_PORT")
8086
_ = viper.BindEnv("email.email", "TW_EMAIL_SENDER")
8187
_ = viper.BindEnv("email.password", "TW_EMAIL_PASSWORD")
88+
_ = viper.BindEnv("database.type", "TW_DATABASE_TYPE")
89+
_ = viper.BindEnv("database.host", "TW_DATABASE_HOST")
90+
_ = viper.BindEnv("database.port", "TW_DATABASE_PORT")
91+
_ = viper.BindEnv("database.database", "TW_DATABASE_NAME")
92+
_ = viper.BindEnv("database.username", "TW_DATABASE_USERNAME")
93+
_ = viper.BindEnv("database.password", "TW_DATABASE_PASSWORD")
8294

8395
err := viper.ReadInConfig()
8496
if err != nil {

apiserver/config/config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
database:
2+
type: sqlite
23
migration: true
34
path: /config/task-wizard.db
45
jwt:

apiserver/config/config_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func TestLoadConfig_Success(t *testing.T) {
2424
read_timeout: 10s
2525
write_timeout: 10s
2626
database:
27+
type: sqlite
2728
path: test.db
2829
migration: true
2930
jwt:
@@ -53,6 +54,7 @@ email:
5354
assert.Equal(t, 10*time.Second, cfg.Server.ReadTimeout)
5455
assert.Equal(t, 10*time.Second, cfg.Server.WriteTimeout)
5556

57+
assert.Equal(t, "sqlite", cfg.Database.Type)
5658
assert.Equal(t, "test.db", cfg.Database.FilePath)
5759
assert.Equal(t, true, cfg.Database.Migration)
5860

@@ -157,3 +159,79 @@ func TestLoadConfig_CLIOverridesEnv(t *testing.T) {
157159
assert.Equal(t, 2222, cfg.Server.Port)
158160
os.Unsetenv("TW_CONFIG_FILE")
159161
}
162+
163+
func TestLoadConfig_DatabaseEnvOverride(t *testing.T) {
164+
_ = os.MkdirAll("./config", 0755)
165+
f, err := os.Create("./config/config.yaml")
166+
assert.NoError(t, err)
167+
defer os.Remove("./config/config.yaml")
168+
defer f.Close()
169+
170+
_, err = f.WriteString(`database:
171+
type: sqlite
172+
path: /config/task-wizard.db
173+
server:
174+
port: 1234
175+
jwt:
176+
secret: testsecret
177+
`)
178+
assert.NoError(t, err)
179+
180+
os.Setenv("TW_DATABASE_TYPE", "mysql")
181+
os.Setenv("TW_DATABASE_HOST", "localhost")
182+
os.Setenv("TW_DATABASE_PORT", "3307")
183+
os.Setenv("TW_DATABASE_NAME", "taskwizard")
184+
os.Setenv("TW_DATABASE_USERNAME", "dbuser")
185+
os.Setenv("TW_DATABASE_PASSWORD", "dbpass")
186+
187+
viper.Reset()
188+
cfg := LoadConfig("./config/config.yaml")
189+
190+
assert.Equal(t, "mysql", cfg.Database.Type)
191+
assert.Equal(t, "localhost", cfg.Database.Host)
192+
assert.Equal(t, 3307, cfg.Database.Port)
193+
assert.Equal(t, "taskwizard", cfg.Database.Database)
194+
assert.Equal(t, "dbuser", cfg.Database.Username)
195+
assert.Equal(t, "dbpass", cfg.Database.Password)
196+
197+
os.Unsetenv("TW_DATABASE_TYPE")
198+
os.Unsetenv("TW_DATABASE_HOST")
199+
os.Unsetenv("TW_DATABASE_PORT")
200+
os.Unsetenv("TW_DATABASE_NAME")
201+
os.Unsetenv("TW_DATABASE_USERNAME")
202+
os.Unsetenv("TW_DATABASE_PASSWORD")
203+
}
204+
205+
func TestLoadConfig_MySQLConfig(t *testing.T) {
206+
_ = os.MkdirAll("./config", 0755)
207+
f, err := os.Create("./config/config.yaml")
208+
assert.NoError(t, err)
209+
defer os.Remove("./config/config.yaml")
210+
defer f.Close()
211+
212+
_, err = f.WriteString(`database:
213+
type: mysql
214+
host: mysql.example.com
215+
port: 3306
216+
database: taskwizard
217+
username: testuser
218+
password: testpass
219+
migration: true
220+
server:
221+
port: 1234
222+
jwt:
223+
secret: testsecret
224+
`)
225+
assert.NoError(t, err)
226+
227+
viper.Reset()
228+
cfg := LoadConfig("./config/config.yaml")
229+
230+
assert.Equal(t, "mysql", cfg.Database.Type)
231+
assert.Equal(t, "mysql.example.com", cfg.Database.Host)
232+
assert.Equal(t, 3306, cfg.Database.Port)
233+
assert.Equal(t, "taskwizard", cfg.Database.Database)
234+
assert.Equal(t, "testuser", cfg.Database.Username)
235+
assert.Equal(t, "testpass", cfg.Database.Password)
236+
assert.Equal(t, true, cfg.Database.Migration)
237+
}

apiserver/go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,20 @@ require (
1313
go.uber.org/fx v1.22.0
1414
go.uber.org/zap v1.26.0
1515
golang.org/x/crypto v0.41.0
16-
gorm.io/gorm v1.25.10
16+
gorm.io/gorm v1.30.0
1717
)
1818

1919
require (
2020
github.com/stretchr/testify v1.10.0
2121
github.com/wneessen/go-mail v0.7.1
22+
gorm.io/driver/mysql v1.6.0
2223
gorm.io/driver/sqlite v1.5.7
2324
)
2425

2526
require (
27+
filippo.io/edwards25519 v1.1.0 // indirect
2628
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
29+
github.com/go-sql-driver/mysql v1.8.1 // indirect
2730
github.com/google/go-cmp v0.6.0 // indirect
2831
github.com/mattn/go-sqlite3 v1.14.22 // indirect
2932
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect

apiserver/go.sum

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
2+
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
13
github.com/appleboy/gin-jwt/v2 v2.9.2 h1:GeS3lm9mb9HMmj7+GNjYUtpp3V1DAQ1TkUFa5poiZ7Y=
24
github.com/appleboy/gin-jwt/v2 v2.9.2/go.mod h1:mxGjKt9Lrx9Xusy1SrnmsCJMZG6UJwmdHN9bN27/QDw=
35
github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4=
@@ -40,6 +42,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
4042
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
4143
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
4244
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
45+
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
46+
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
4347
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
4448
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
4549
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
@@ -183,10 +187,12 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
183187
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
184188
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
185189
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
190+
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
191+
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
186192
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
187193
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
188-
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
189-
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
194+
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
195+
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
190196
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
191197
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
192198
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=

apiserver/internal/utils/database/database.go

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package database
22

33
import (
4+
"fmt"
45
"log"
56
"os"
67
"strings"
@@ -9,6 +10,7 @@ import (
910
"dkhalife.com/tasks/core/config"
1011
"dkhalife.com/tasks/core/internal/services/logging"
1112
"github.com/glebarez/sqlite"
13+
"gorm.io/driver/mysql"
1214
"gorm.io/gorm"
1315
gormLogger "gorm.io/gorm/logger"
1416
)
@@ -38,16 +40,49 @@ func NewDatabase(cfg *config.Config) (*gorm.DB, error) {
3840
},
3941
)
4042

41-
db, err := gorm.Open(sqlite.Open(cfg.Database.FilePath), &gorm.Config{Logger: logger})
42-
if err != nil {
43-
return nil, err
43+
var dialector gorm.Dialector
44+
dbType := strings.ToLower(cfg.Database.Type)
45+
46+
switch dbType {
47+
case "mysql":
48+
// Validate required fields for MySQL
49+
if cfg.Database.Host == "" {
50+
return nil, fmt.Errorf("database.host is required for %s", dbType)
51+
}
52+
if cfg.Database.Database == "" {
53+
return nil, fmt.Errorf("database.database is required for %s", dbType)
54+
}
55+
if cfg.Database.Username == "" {
56+
return nil, fmt.Errorf("database.username is required for %s", dbType)
57+
}
58+
59+
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
60+
cfg.Database.Username,
61+
cfg.Database.Password,
62+
cfg.Database.Host,
63+
cfg.Database.Port,
64+
cfg.Database.Database,
65+
)
66+
dialector = mysql.Open(dsn)
67+
case "sqlite", "":
68+
dialector = sqlite.Open(cfg.Database.FilePath)
69+
default:
70+
return nil, fmt.Errorf("unsupported database type: %s (supported: sqlite, mysql)", cfg.Database.Type)
4471
}
4572

46-
if err := db.Exec("PRAGMA journal_mode=WAL;").Error; err != nil {
73+
db, err := gorm.Open(dialector, &gorm.Config{Logger: logger})
74+
if err != nil {
4775
return nil, err
4876
}
49-
if err := db.Exec("PRAGMA busy_timeout=5000;").Error; err != nil {
50-
return nil, err
77+
78+
// Only apply SQLite-specific settings if using SQLite
79+
if dbType == "sqlite" || dbType == "" {
80+
if err := db.Exec("PRAGMA journal_mode=WAL;").Error; err != nil {
81+
return nil, err
82+
}
83+
if err := db.Exec("PRAGMA busy_timeout=5000;").Error; err != nil {
84+
return nil, err
85+
}
5186
}
5287

5388
return db, nil

apiserver/internal/utils/database/database_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,57 @@ func (s *DatabaseTestSuite) TestNewDatabaseConnection() {
4646
s.Require().NoError(err)
4747
s.NoError(sqlDB.Ping())
4848
}
49+
50+
func (s *DatabaseTestSuite) TestNewDatabase_MySQLMissingHost() {
51+
cfg := &config.Config{
52+
Database: config.DatabaseConfig{
53+
Type: "mysql",
54+
Database: "testdb",
55+
Username: "testuser",
56+
},
57+
}
58+
59+
_, err := NewDatabase(cfg)
60+
s.Require().Error(err)
61+
s.Contains(err.Error(), "database.host is required")
62+
}
63+
64+
func (s *DatabaseTestSuite) TestNewDatabase_MySQLMissingDatabase() {
65+
cfg := &config.Config{
66+
Database: config.DatabaseConfig{
67+
Type: "mysql",
68+
Host: "localhost",
69+
Username: "testuser",
70+
},
71+
}
72+
73+
_, err := NewDatabase(cfg)
74+
s.Require().Error(err)
75+
s.Contains(err.Error(), "database.database is required")
76+
}
77+
78+
func (s *DatabaseTestSuite) TestNewDatabase_MySQLMissingUsername() {
79+
cfg := &config.Config{
80+
Database: config.DatabaseConfig{
81+
Type: "mysql",
82+
Host: "localhost",
83+
Database: "testdb",
84+
},
85+
}
86+
87+
_, err := NewDatabase(cfg)
88+
s.Require().Error(err)
89+
s.Contains(err.Error(), "database.username is required")
90+
}
91+
92+
func (s *DatabaseTestSuite) TestNewDatabase_UnsupportedType() {
93+
cfg := &config.Config{
94+
Database: config.DatabaseConfig{
95+
Type: "postgresql",
96+
},
97+
}
98+
99+
_, err := NewDatabase(cfg)
100+
s.Require().Error(err)
101+
s.Contains(err.Error(), "unsupported database type")
102+
}

0 commit comments

Comments
 (0)