Compare commits

..

4 Commits

Author SHA1 Message Date
d4d7495ffb Refactor router setup to split routes by auth/protected boundary 2026-04-27 23:30:17 +08:00
7fb125ea87 Implement web API foundation
Add application container, Gin router, graceful shutdown handler,
and version endpoint. This establishes the skeleton for the WebDisk
HTTP API as described in the architecture.

- Add internal/app/WebApp for runtime dependencies and version
- Add internal/server/router with GET /api/v1/version route
- Add graceful shutdown runner with signal handling in cmd/serve
- Add internal/api/ErrorResponse for standard HTTP error body
- Update roadmap, architecture, and decisions documentation
2026-04-27 23:06:06 +08:00
c0c34eb914 Change JWT TTL config from duration to string for flexibility
- The mapstructure library is no longer needed for direct duration
  parsing since we now store TTLs as string durations (e.g., "15m",
  "168h") and parse them on demand via helper methods.
- This allows more flexible duration formats in configuration and moves
  the parsing responsibility to the JWT config struct itself.
2026-04-27 17:15:16 +08:00
54f0deadbc Add initial framework of configuration.
feat: configuration file system of app.

Note: still work in progress.
2026-04-27 16:55:58 +08:00
21 changed files with 981 additions and 38 deletions

43
cmd/serve.go Normal file
View 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)
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
View File

@@ -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
View File

@@ -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
View 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,
},
})
}

View 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
View 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
View 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,
}
}

View 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
View 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
View 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, &notFound) {
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
}

View 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)
}
}

View 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
View 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
}

View 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.
}

View 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
View 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)
}
}

View 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)
}
}