Compare commits
4 Commits
e8a0c48658
...
d4d7495ffb
| Author | SHA1 | Date | |
|---|---|---|---|
| d4d7495ffb | |||
| 7fb125ea87 | |||
| c0c34eb914 | |||
| 54f0deadbc |
43
cmd/serve.go
Normal file
43
cmd/serve.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/app"
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/server"
|
||||
)
|
||||
|
||||
var serveConfigFile string
|
||||
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the MyGO HTTP server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
v := config.New()
|
||||
cfg, err := config.Load(v, serveConfigFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
webApp := app.NewWebApp(cfg)
|
||||
router := server.NewRouter(webApp)
|
||||
addr := server.Address(webApp.Config.Server)
|
||||
|
||||
return server.RunWithGracefulShutdown(ctx, addr, router)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
serveCmd.Flags().StringVar(&serveConfigFile, "config", "", "config file path")
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
}
|
||||
@@ -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,79 @@
|
||||
# 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 | ✅ skeleton |
|
||||
| | `cmd/config.go` | `mygo config` — config subcommand | ⬜ plan |
|
||||
| | `cmd/status.go` | `mygo status` — health check | ⬜ plan |
|
||||
| **Config** | `internal/config` | Viper load (YAML + env + flags) | ✅ skeleton |
|
||||
| **App** | `internal/app` | Runtime dependency container and build metadata | ✅ skeleton |
|
||||
| **HTTP** | `internal/server` | Gin router init, route registration, graceful shutdown | ✅ skeleton |
|
||||
| | `internal/handler` | HTTP handlers (auth, file, admin, webdav...) | ✅ skeleton |
|
||||
| | `internal/middleware` | Gin middleware (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` | Error body helpers | ✅ skeleton |
|
||||
|
||||
## API Routes (v0)
|
||||
|
||||
```
|
||||
GET /api/v1/version
|
||||
|
||||
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 by `gin.Default()`: logger → recovery
|
||||
|
||||
Planned globally: cors
|
||||
|
||||
Applied to protected groups: auth (JWT validation, inject user into gin.Context)
|
||||
|
||||
## Server Responsibilities
|
||||
|
||||
- `cmd/serve.go` loads config, creates `app.WebApp`, builds the router, and starts the HTTP server.
|
||||
- `app.WebApp` carries runtime dependencies and build metadata needed to assemble handlers.
|
||||
- `internal/server` owns Gin router setup (`router.go`), route registration split into `routes_public.go` and `routes_protected.go`, and HTTP server lifecycle.
|
||||
- `RunWithGracefulShutdown` stops accepting new requests on termination and gives in-flight requests time to finish.
|
||||
|
||||
@@ -1,19 +1,50 @@
|
||||
# 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 | Direct JSON success bodies + unified error body | HTTP status codes carry request outcome; error body carries human-readable details |
|
||||
|
||||
**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.
|
||||
```
|
||||
**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: default logger/recovery → cors → auth (route-group-scoped).
|
||||
|
||||
## Decisions
|
||||
## 2026-04-27: Web API Foundation
|
||||
|
||||
*(None yet. Add entries as decisions are made.)*
|
||||
**Context**: The project needed the first HTTP slice that can validate Gin wiring and provide a stable shape for future auth, file, and admin APIs.
|
||||
|
||||
**Decisions**:
|
||||
|
||||
| Area | Choice | Guidance |
|
||||
|------|--------|----------|
|
||||
| API versioning | All REST routes under `/api/v1` | Keep future REST handlers under the versioned group. |
|
||||
| Initial public endpoint | `GET /api/v1/version` | Returns build metadata only; health/readiness endpoints need a separate security review. |
|
||||
| Success responses | Direct JSON resource bodies | Use HTTP status codes as the request outcome signal. |
|
||||
| Error responses | `{"error":{"message":"..."}}` | Add machine-readable error codes only when clients need stable branching behavior. |
|
||||
| App composition | `internal/app.WebApp` | `cmd/serve.go` creates the app from config and build metadata, then passes it to router setup. |
|
||||
| Router setup | `internal/server.NewRouter(*app.WebApp)` | Public routes (`routes_public.go`) and protected routes (`routes_protected.go`) split by auth boundary; `WebApp` serves as the unified dependency container. |
|
||||
| Server lifecycle | `RunWithGracefulShutdown` | Preserve graceful shutdown while keeping command startup linear. |
|
||||
| Default middleware | `gin.Default()` | Use default logger/recovery for the skeleton; add CORS/auth explicitly when their policies exist. |
|
||||
|
||||
**Consequences**:
|
||||
- Version is build metadata from `internal/app/version.go`, not a config-file field.
|
||||
- `app.WebApp` is the place to add future services, repositories, storage, and app metadata incrementally.
|
||||
- Request ID middleware is not part of the current foundation; add it only with a logging/tracing/error-correlation design.
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
# 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 |
|
||||
| Web API foundation | ✅ skeleton | WebApp composition, Gin router, graceful shutdown, `GET /api/v1/version` |
|
||||
| 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/app` — runtime dependency container ✅ skeleton
|
||||
3. `internal/model` — domain types, error codes
|
||||
4. `internal/api` — error response helpers ✅ skeleton
|
||||
5. `internal/auth` — JWT utils
|
||||
6. `internal/storage` — backend interface + local fs
|
||||
7. `internal/repository` — interfaces + GORM/SQLite impl
|
||||
8. `internal/service` — auth, file, admin services
|
||||
9. `internal/middleware` — logger, cors, auth
|
||||
10. `internal/handler` — auth, file, admin handlers ✅ version skeleton
|
||||
11. `internal/server` — Gin router, route registration, graceful shutdown ✅ skeleton
|
||||
12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` ✅ serve skeleton
|
||||
13. 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 |
|
||||
|
||||
43
go.mod
43
go.mod
@@ -2,9 +2,50 @@ module github.com/dhao2001/mygo
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require github.com/spf13/cobra v1.10.2
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // 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
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
|
||||
115
go.sum
115
go.sum
@@ -1,11 +1,126 @@
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
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/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
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/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=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
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/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
24
internal/api/response.go
Normal file
24
internal/api/response.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ErrorResponse is the standard JSON body for HTTP API errors.
|
||||
type ErrorResponse struct {
|
||||
Error ErrorBody `json:"error"`
|
||||
}
|
||||
|
||||
// ErrorBody contains human-readable error details.
|
||||
type ErrorBody struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error writes a JSON error response.
|
||||
func Error(c *gin.Context, status int, message string) {
|
||||
c.JSON(status, ErrorResponse{
|
||||
Error: ErrorBody{
|
||||
Message: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
36
internal/api/response_test.go
Normal file
36
internal/api/response_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/error", func(c *gin.Context) {
|
||||
Error(c, http.StatusBadRequest, "invalid request")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/error", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var body ErrorResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if body.Error.Message != "invalid request" {
|
||||
t.Errorf("message = %q, want %q", body.Error.Message, "invalid request")
|
||||
}
|
||||
}
|
||||
4
internal/app/version.go
Normal file
4
internal/app/version.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package app
|
||||
|
||||
// Version is the application version. Release builds can override it with ldflags.
|
||||
var AppVersion = "dev"
|
||||
19
internal/app/webapp.go
Normal file
19
internal/app/webapp.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
)
|
||||
|
||||
// WebApp contains application-wide runtime dependencies and metadata.
|
||||
type WebApp struct {
|
||||
Config *config.Config
|
||||
Version string
|
||||
}
|
||||
|
||||
// NewWebApp creates the application dependency container for the HTTP server.
|
||||
func NewWebApp(cfg *config.Config) *WebApp {
|
||||
return &WebApp{
|
||||
Config: cfg,
|
||||
Version: AppVersion,
|
||||
}
|
||||
}
|
||||
20
internal/app/webapp_test.go
Normal file
20
internal/app/webapp_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
)
|
||||
|
||||
func TestNewWebApp(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
|
||||
webApp := NewWebApp(cfg)
|
||||
|
||||
if webApp.Config != cfg {
|
||||
t.Fatal("Config was not assigned")
|
||||
}
|
||||
if webApp.Version != AppVersion {
|
||||
t.Errorf("Version = %q, want %q", webApp.Version, AppVersion)
|
||||
}
|
||||
}
|
||||
84
internal/config/config.go
Normal file
84
internal/config/config.go
Normal file
@@ -0,0 +1,84 @@
|
||||
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 string `mapstructure:"access_ttl"`
|
||||
RefreshTTL string `mapstructure:"refresh_ttl"`
|
||||
}
|
||||
|
||||
func (j JWTConfig) AccessDuration() time.Duration {
|
||||
d, _ := time.ParseDuration(j.AccessTTL)
|
||||
return d
|
||||
}
|
||||
|
||||
func (j JWTConfig) RefreshDuration() time.Duration {
|
||||
d, _ := time.ParseDuration(j.RefreshTTL)
|
||||
return d
|
||||
}
|
||||
|
||||
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 _, err := time.ParseDuration(c.JWT.AccessTTL); err != nil {
|
||||
errs = append(errs, fmt.Errorf("jwt.access_ttl: %w", err))
|
||||
}
|
||||
|
||||
if _, err := time.ParseDuration(c.JWT.RefreshTTL); err != nil {
|
||||
errs = append(errs, fmt.Errorf("jwt.refresh_ttl: %w", err))
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
61
internal/config/load.go
Normal file
61
internal/config/load.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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 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); 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
|
||||
}
|
||||
211
internal/config/load_test.go
Normal file
211
internal/config/load_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
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, "15m"},
|
||||
{"jwt.refresh_ttl", cfg.JWT.RefreshTTL, "168h"},
|
||||
}
|
||||
|
||||
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 != "30m" {
|
||||
t.Errorf("jwt.access_ttl = %q, want %q", cfg.JWT.AccessTTL, "30m")
|
||||
}
|
||||
if cfg.JWT.RefreshTTL != "72h" {
|
||||
t.Errorf("jwt.refresh_ttl = %q, want %q", cfg.JWT.RefreshTTL, "72h")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTConfigAccessDuration(t *testing.T) {
|
||||
j := JWTConfig{AccessTTL: "15m"}
|
||||
if got := j.AccessDuration(); got != 15*time.Minute {
|
||||
t.Errorf("AccessDuration() = %v, want %v", got, 15*time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTConfigRefreshDuration(t *testing.T) {
|
||||
j := JWTConfig{RefreshTTL: "168h"}
|
||||
if got := j.RefreshDuration(); got != 168*time.Hour {
|
||||
t.Errorf("RefreshDuration() = %v, want %v", got, 168*time.Hour)
|
||||
}
|
||||
}
|
||||
27
internal/handler/version.go
Normal file
27
internal/handler/version.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// VersionResponse is the response body for the version endpoint.
|
||||
type VersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// VersionHandler serves application version metadata.
|
||||
type VersionHandler struct {
|
||||
version string
|
||||
}
|
||||
|
||||
// NewVersionHandler creates a version handler.
|
||||
func NewVersionHandler(appVersion string) *VersionHandler {
|
||||
return &VersionHandler{version: appVersion}
|
||||
}
|
||||
|
||||
// Get writes the current application version.
|
||||
func (h *VersionHandler) Get(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, VersionResponse{Version: h.version})
|
||||
}
|
||||
19
internal/server/router.go
Normal file
19
internal/server/router.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/app"
|
||||
)
|
||||
|
||||
// NewRouter builds the Gin router and registers API routes.
|
||||
func NewRouter(webApp *app.WebApp) *gin.Engine {
|
||||
router := gin.Default()
|
||||
|
||||
v1 := router.Group("/api/v1")
|
||||
|
||||
setupPublicRoutes(v1, webApp)
|
||||
setupProtectedRoutes(v1, webApp)
|
||||
|
||||
return router
|
||||
}
|
||||
12
internal/server/routes_protected.go
Normal file
12
internal/server/routes_protected.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/app"
|
||||
)
|
||||
|
||||
func setupProtectedRoutes(rg *gin.RouterGroup, _ *app.WebApp) {
|
||||
_ = rg
|
||||
// Protected routes will be registered after auth middleware is implemented.
|
||||
}
|
||||
13
internal/server/routes_public.go
Normal file
13
internal/server/routes_public.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/app"
|
||||
"github.com/dhao2001/mygo/internal/handler"
|
||||
)
|
||||
|
||||
func setupPublicRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
|
||||
versionHandler := handler.NewVersionHandler(webApp.Version)
|
||||
rg.GET("/version", versionHandler.Get)
|
||||
}
|
||||
49
internal/server/server.go
Normal file
49
internal/server/server.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
)
|
||||
|
||||
// Address returns the HTTP listen address for a server config.
|
||||
func Address(cfg config.ServerConfig) string {
|
||||
return fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
}
|
||||
|
||||
// RunWithGracefulShutdown starts an HTTP server and shuts it down when ctx is canceled.
|
||||
func RunWithGracefulShutdown(ctx context.Context, addr string, handler http.Handler) error {
|
||||
if handler == nil {
|
||||
return errors.New("handler: must not be nil")
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- httpServer.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("shutdown server: %w", err)
|
||||
}
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("listen and serve: %w", err)
|
||||
}
|
||||
}
|
||||
48
internal/server/server_test.go
Normal file
48
internal/server/server_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/app"
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
)
|
||||
|
||||
func TestVersionRoute(t *testing.T) {
|
||||
webApp := app.NewWebApp(&config.Config{})
|
||||
router := NewRouter(webApp)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
|
||||
t.Fatalf("content-type = %q, want %q", got, "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if body.Version != app.AppVersion {
|
||||
t.Errorf("version = %q, want %q", body.Version, app.AppVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddress(t *testing.T) {
|
||||
got := Address(config.ServerConfig{Host: "127.0.0.1", Port: 8080})
|
||||
want := "127.0.0.1:8080"
|
||||
if got != want {
|
||||
t.Errorf("Address() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user