Add initial framework of configuration.
feat: configuration file system of app. Note: still work in progress.
This commit is contained in:
@@ -1,3 +1,17 @@
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 10086
|
||||
|
||||
database:
|
||||
driver: sqlite3
|
||||
path: data/mygo.db
|
||||
|
||||
storage:
|
||||
driver: local
|
||||
local:
|
||||
path: data/files
|
||||
|
||||
jwt:
|
||||
secret: change-me-in-production
|
||||
access_ttl: 15m
|
||||
refresh_ttl: 168h
|
||||
|
||||
@@ -1,22 +1,67 @@
|
||||
# Architecture
|
||||
|
||||
## Code Layout
|
||||
## Layered Design
|
||||
|
||||
```
|
||||
main.go — entrypoint, calls cmd.Execute()
|
||||
cmd/ — cobra commands (root + subcommands)
|
||||
internal/ — private application packages
|
||||
docs/ — project documentation
|
||||
Handler (Gin handlers) ← translates HTTP ↔ Service calls
|
||||
↓
|
||||
Service (business logic) ← orchestrates, authorizes, validates
|
||||
↓ ↓
|
||||
Repository (GORM data access) Storage (file I/O)
|
||||
↓ ↓
|
||||
[SQLite / PostgreSQL] [Local FS / S3]
|
||||
```
|
||||
|
||||
## Packages
|
||||
Rules:
|
||||
- Handler has no business logic — parse request, call service, write response.
|
||||
- Service has no HTTP awareness — operates on domain models and interfaces.
|
||||
- Repository abstracts the database; Storage abstracts where bytes live.
|
||||
- `internal/server` is the composition root — wires all dependencies together.
|
||||
|
||||
| Package | Purpose | Status |
|
||||
|---------|---------|--------|
|
||||
| `cmd` | CLI command definitions | ✅ skeleton |
|
||||
| `internal/config` | Configuration loading and parsing | ⬜ planned |
|
||||
| `internal/controllers` | HTTP request handlers | ⬜ planned |
|
||||
| `internal/auth` | Authentication (JWT) | ⬜ planned |
|
||||
| `internal/storage` | File storage abstraction | ⬜ planned |
|
||||
## Package Map
|
||||
|
||||
Add new rows as packages are created.
|
||||
| Layer | Package | Purpose | Status |
|
||||
|-------|---------|---------|--------|
|
||||
| **CLI** | `cmd` | Cobra root command | ✅ skeleton |
|
||||
| | `cmd/serve.go` | `mygo serve` — wire deps, start HTTP | ⬜ plan |
|
||||
| | `cmd/config.go` | `mygo config` — config subcommand | ⬜ plan |
|
||||
| | `cmd/status.go` | `mygo status` — health check | ⬜ plan |
|
||||
| **Config** | `internal/config` | Viper load (YAML + env + flags) | ⬜ plan |
|
||||
| **HTTP** | `internal/server` | Gin engine init, route registration, middleware chain | ⬜ plan |
|
||||
| | `internal/handler` | HTTP handlers (auth, file, admin, webdav...) | ⬜ plan |
|
||||
| | `internal/middleware` | Gin middleware (requestid, logger, cors, auth) | ⬜ plan |
|
||||
| **Business** | `internal/service` | Business logic (auth, file, admin) | ⬜ plan |
|
||||
| | `internal/model` | Domain types (User, File, errors) | ⬜ plan |
|
||||
| **Data** | `internal/repository` | Repository interfaces + GORM implementations | ⬜ plan |
|
||||
| | `internal/storage` | Storage backend interface + local disk impl | ⬜ plan |
|
||||
| **Util** | `internal/auth` | JWT sign/verify, context helpers | ⬜ plan |
|
||||
| | `internal/api` | JSON response writer, error code helpers | ⬜ plan |
|
||||
|
||||
## API Routes (v0)
|
||||
|
||||
```
|
||||
POST /api/v1/auth/register
|
||||
POST /api/v1/auth/login
|
||||
POST /api/v1/auth/refresh
|
||||
|
||||
GET /api/v1/users/me
|
||||
PATCH /api/v1/users/me
|
||||
|
||||
GET /api/v1/files
|
||||
POST /api/v1/files
|
||||
GET /api/v1/files/:id
|
||||
GET /api/v1/files/:id/content
|
||||
PUT /api/v1/files/:id
|
||||
DELETE /api/v1/files/:id
|
||||
|
||||
GET /api/v1/admin/users
|
||||
GET /api/v1/admin/users/:id
|
||||
PUT /api/v1/admin/users/:id
|
||||
DELETE /api/v1/admin/users/:id
|
||||
```
|
||||
|
||||
## Middleware Chain
|
||||
|
||||
Applied globally: requestid → logger → cors
|
||||
|
||||
Applied to protected groups: auth (JWT validation, inject user into gin.Context)
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
# Technical Decisions
|
||||
|
||||
Record significant technical decisions as they are made. Use the format below.
|
||||
## 2026-04-25: v0 Tech Stack & Architecture
|
||||
|
||||
## Template
|
||||
**Context**: Project skeleton was created with only cobra CLI. We needed a concrete tech stack and package layout to begin implementation.
|
||||
|
||||
```
|
||||
## YYYY-MM-DD: Title
|
||||
**Decisions**:
|
||||
|
||||
**Context**: Why this decision was needed.
|
||||
| Area | Choice | Rationale |
|
||||
|------|--------|-----------|
|
||||
| HTTP framework | Gin | Most widely adopted Go web framework, mature middleware ecosystem |
|
||||
| ORM | GORM | SQLite-first dev, PostgreSQL option later; GORM abstracts dialect differences |
|
||||
| Config management | Viper | YAML + env vars + CLI flags three-way merge, built for cobra integration |
|
||||
| Database | SQLite (v0) → PostgreSQL (future) | SQLite zero setup for dev; repo interface isolates the switch |
|
||||
| File storage | Local disk (v0) → S3 (future) | Backend interface (`internal/storage`) hides implementation |
|
||||
| File identity | UUID | Distributed-friendly, no coordination needed; cost is negligible for file metadata |
|
||||
| Token strategy | JWT, refresh token stored in DB | Enables server-side revocation (admin kick, logout-all-devices) |
|
||||
| Pagination | OFFSET/LIMIT | Simple, sufficient for v0; migrate to cursor-based if needed |
|
||||
| API response format | `{code, message, data}` | Consistent envelope across all endpoints |
|
||||
|
||||
**Decision**: What was decided.
|
||||
**Architecture**: Four-layer model — Handler (Gin) → Service (business logic) → Repository (GORM data access) + Storage (file I/O). Each layer depends only on interfaces of the layer below.
|
||||
|
||||
**Consequences**: What this means for the project.
|
||||
```
|
||||
|
||||
## Decisions
|
||||
|
||||
*(None yet. Add entries as decisions are made.)*
|
||||
**Consequences**:
|
||||
- Handler layer has no business logic; Service layer is reusable across REST API, WebDAV, and future Nextcloud API.
|
||||
- Repository interfaces keep DB swappable; future PostgreSQL implementation only needs a new package.
|
||||
- Refresh token in DB adds a `sessions` table and a `repository.SessionRepository` interface.
|
||||
- UUID dependency: `github.com/google/uuid` to be added.
|
||||
- Gin middleware chain: requestid → logger → cors → auth (route-group-scoped).
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
# Roadmap
|
||||
|
||||
See `README.md` for high-level feature list. This file tracks detailed progress.
|
||||
|
||||
## v0
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| CLI config management | ⬜ planned | |
|
||||
| JWT authentication | ⬜ planned | |
|
||||
| File upload/download/manage APIs | ⬜ planned | |
|
||||
| Admin endpoints | ⬜ planned | |
|
||||
| WebDAV | ⬜ planned | |
|
||||
| CLI config management | ⬜ plan | |
|
||||
| JWT authentication | ⬜ plan | access + refresh tokens, refresh token in DB |
|
||||
| File upload/download/manage APIs | ⬜ plan | REST API via Gin |
|
||||
| Admin endpoints | ⬜ plan | user CRUD for superusers |
|
||||
| WebDAV | ⬜ plan | future v0 or v1 |
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
Package-level implementation order (each task includes unit tests):
|
||||
|
||||
1. `internal/config` — Viper loader, config struct
|
||||
2. `internal/model` — domain types, error codes
|
||||
3. `internal/api` — JSON response helpers
|
||||
4. `internal/auth` — JWT utils
|
||||
5. `internal/storage` — backend interface + local fs
|
||||
6. `internal/repository` — interfaces + GORM/SQLite impl
|
||||
7. `internal/service` — auth, file, admin services
|
||||
8. `internal/middleware` — requestid, logger, cors, auth
|
||||
9. `internal/handler` — auth, file, admin handlers
|
||||
10. `internal/server` — Gin wiring, route registration
|
||||
11. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go`
|
||||
12. Integration tests
|
||||
|
||||
## Future
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Image server | ⬜ planned | |
|
||||
| Pastebin & code snippets | ⬜ planned | |
|
||||
| S3 storage backend | ⬜ planned | |
|
||||
| Nextcloud-compatible API | ⬜ planned | |
|
||||
|
||||
Update status after completing related work.
|
||||
| Image server | ⬜ plan | thumbnail generation |
|
||||
| Pastebin & code snippets | ⬜ plan | in sharing context |
|
||||
| S3 storage backend | ⬜ plan | new storage impl |
|
||||
| Nextcloud-compatible API | ⬜ plan | new handler layer on existing services |
|
||||
|
||||
16
go.mod
16
go.mod
@@ -2,9 +2,23 @@ module github.com/dhao2001/mygo
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require github.com/spf13/cobra v1.10.2
|
||||
require (
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
)
|
||||
|
||||
43
go.sum
43
go.sum
@@ -1,11 +1,54 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
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-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
74
internal/config/config.go
Normal file
74
internal/config/config.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Driver string `mapstructure:"driver"`
|
||||
Path string `mapstructure:"path"`
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
Driver string `mapstructure:"driver"`
|
||||
Local LocalStorageConfig `mapstructure:"local"`
|
||||
}
|
||||
|
||||
type LocalStorageConfig struct {
|
||||
Path string `mapstructure:"path"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
AccessTTL time.Duration `mapstructure:"access_ttl"`
|
||||
RefreshTTL time.Duration `mapstructure:"refresh_ttl"`
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
var errs []error
|
||||
|
||||
if c.Server.Port < 1 || c.Server.Port > 65535 {
|
||||
errs = append(errs, fmt.Errorf("server.port: %d out of range [1, 65535]", c.Server.Port))
|
||||
}
|
||||
|
||||
if addr := net.ParseIP(c.Server.Host); c.Server.Host != "" && addr == nil {
|
||||
errs = append(errs, fmt.Errorf("server.host: %q is not a valid IP address", c.Server.Host))
|
||||
}
|
||||
|
||||
if c.Database.Path == "" {
|
||||
errs = append(errs, errors.New("database.path: must not be empty"))
|
||||
}
|
||||
|
||||
if c.Storage.Local.Path == "" {
|
||||
errs = append(errs, errors.New("storage.local.path: must not be empty"))
|
||||
}
|
||||
|
||||
if c.JWT.Secret == "" {
|
||||
errs = append(errs, errors.New("jwt.secret: must not be empty"))
|
||||
}
|
||||
|
||||
if c.JWT.AccessTTL <= 0 {
|
||||
errs = append(errs, fmt.Errorf("jwt.access_ttl: %v must be positive", c.JWT.AccessTTL))
|
||||
}
|
||||
|
||||
if c.JWT.RefreshTTL <= 0 {
|
||||
errs = append(errs, fmt.Errorf("jwt.refresh_ttl: %v must be positive", c.JWT.RefreshTTL))
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
66
internal/config/load.go
Normal file
66
internal/config/load.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func defaults(v *viper.Viper) {
|
||||
v.SetDefault("server.host", "0.0.0.0")
|
||||
v.SetDefault("server.port", 10086)
|
||||
|
||||
v.SetDefault("database.driver", "sqlite3")
|
||||
v.SetDefault("database.path", "data/mygo.db")
|
||||
|
||||
v.SetDefault("storage.driver", "local")
|
||||
v.SetDefault("storage.local.path", "data/files")
|
||||
|
||||
v.SetDefault("jwt.secret", "dev-secret-do-not-use-in-production")
|
||||
v.SetDefault("jwt.access_ttl", "15m")
|
||||
v.SetDefault("jwt.refresh_ttl", "168h")
|
||||
}
|
||||
|
||||
func decodeHook() viper.DecoderConfigOption {
|
||||
return viper.DecodeHook(mapstructure.StringToTimeDurationHookFunc())
|
||||
}
|
||||
|
||||
func New() *viper.Viper {
|
||||
v := viper.New()
|
||||
v.SetEnvPrefix("MYGO")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
defaults(v)
|
||||
return v
|
||||
}
|
||||
|
||||
func Load(v *viper.Viper, cfgFile string) (*Config, error) {
|
||||
if cfgFile != "" {
|
||||
v.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
v.AddConfigPath(".")
|
||||
}
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg, decodeHook()); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate config: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
197
internal/config/load_test.go
Normal file
197
internal/config/load_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
v := New()
|
||||
cfg, err := Load(v, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
got any
|
||||
want any
|
||||
}{
|
||||
{"server.host", cfg.Server.Host, "0.0.0.0"},
|
||||
{"server.port", cfg.Server.Port, 10086},
|
||||
{"database.driver", cfg.Database.Driver, "sqlite3"},
|
||||
{"database.path", cfg.Database.Path, "data/mygo.db"},
|
||||
{"storage.driver", cfg.Storage.Driver, "local"},
|
||||
{"storage.local.path", cfg.Storage.Local.Path, "data/files"},
|
||||
{"jwt.access_ttl", cfg.JWT.AccessTTL, 15 * time.Minute},
|
||||
{"jwt.refresh_ttl", cfg.JWT.RefreshTTL, 168 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("got %v, want %v", tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := `
|
||||
server:
|
||||
host: 127.0.0.1
|
||||
port: 9090
|
||||
|
||||
database:
|
||||
driver: sqlite3
|
||||
path: /tmp/mygo.db
|
||||
|
||||
storage:
|
||||
driver: local
|
||||
local:
|
||||
path: /tmp/mygo-storage
|
||||
|
||||
jwt:
|
||||
secret: test-secret
|
||||
access_ttl: 30m
|
||||
refresh_ttl: 72h
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
v := New()
|
||||
cfg, err := Load(v, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if cfg.Server.Host != "127.0.0.1" {
|
||||
t.Errorf("server.host = %q, want %q", cfg.Server.Host, "127.0.0.1")
|
||||
}
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Errorf("server.port = %d, want %d", cfg.Server.Port, 9090)
|
||||
}
|
||||
if cfg.Database.Path != "/tmp/mygo.db" {
|
||||
t.Errorf("database.path = %q, want %q", cfg.Database.Path, "/tmp/mygo.db")
|
||||
}
|
||||
if cfg.Storage.Local.Path != "/tmp/mygo-storage" {
|
||||
t.Errorf("storage.local.path = %q, want %q", cfg.Storage.Local.Path, "/tmp/mygo-storage")
|
||||
}
|
||||
if cfg.JWT.Secret != "test-secret" {
|
||||
t.Errorf("jwt.secret = %q, want %q", cfg.JWT.Secret, "test-secret")
|
||||
}
|
||||
if cfg.JWT.AccessTTL != 30*time.Minute {
|
||||
t.Errorf("jwt.access_ttl = %v, want %v", cfg.JWT.AccessTTL, 30*time.Minute)
|
||||
}
|
||||
if cfg.JWT.RefreshTTL != 72*time.Hour {
|
||||
t.Errorf("jwt.refresh_ttl = %v, want %v", cfg.JWT.RefreshTTL, 72*time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvOverride(t *testing.T) {
|
||||
t.Setenv("MYGO_SERVER_PORT", "8080")
|
||||
t.Setenv("MYGO_SERVER_HOST", "192.168.1.1")
|
||||
t.Setenv("MYGO_JWT_SECRET", "env-secret")
|
||||
t.Setenv("MYGO_DATABASE_PATH", "/env/path/db.sqlite")
|
||||
|
||||
v := New()
|
||||
cfg, err := Load(v, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("server.port = %d, want %d", cfg.Server.Port, 8080)
|
||||
}
|
||||
if cfg.Server.Host != "192.168.1.1" {
|
||||
t.Errorf("server.host = %q, want %q", cfg.Server.Host, "192.168.1.1")
|
||||
}
|
||||
if cfg.JWT.Secret != "env-secret" {
|
||||
t.Errorf("jwt.secret = %q, want %q", cfg.JWT.Secret, "env-secret")
|
||||
}
|
||||
if cfg.Database.Path != "/env/path/db.sqlite" {
|
||||
t.Errorf("database.path = %q, want %q", cfg.Database.Path, "/env/path/db.sqlite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvOverridesYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := `
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 10086
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("MYGO_SERVER_PORT", "9999")
|
||||
|
||||
v := New()
|
||||
cfg, err := Load(v, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if cfg.Server.Port != 9999 {
|
||||
t.Errorf("server.port = %d, want %d (env should override YAML)", cfg.Server.Port, 9999)
|
||||
}
|
||||
if cfg.Server.Host != "0.0.0.0" {
|
||||
t.Errorf("server.host = %q, want %q (YAML should be used when env is absent)", cfg.Server.Host, "0.0.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePortRange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := `
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 0
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
v := New()
|
||||
_, err := Load(v, path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for port 0, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEmptySecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := `
|
||||
jwt:
|
||||
secret: ""
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
v := New()
|
||||
_, err := Load(v, path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty jwt.secret, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitConfigFileNotFound(t *testing.T) {
|
||||
v := New()
|
||||
_, err := Load(v, "/nonexistent/path/config.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when explicitly specifying a nonexistent config file")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user