From 7fb125ea87648b03fbeae9bf2f753aa4378d759f Mon Sep 17 00:00:00 2001 From: Huxley Date: Mon, 27 Apr 2026 23:06:06 +0800 Subject: [PATCH] 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 --- cmd/serve.go | 43 ++++++++++++++++ docs/architecture.md | 26 +++++++--- docs/decisions.md | 26 +++++++++- docs/roadmap.md | 24 +++++---- go.mod | 31 +++++++++++- go.sum | 92 ++++++++++++++++++++++++++++++---- internal/api/response.go | 24 +++++++++ internal/api/response_test.go | 36 +++++++++++++ internal/app/version.go | 4 ++ internal/app/webapp.go | 19 +++++++ internal/app/webapp_test.go | 20 ++++++++ internal/handler/version.go | 27 ++++++++++ internal/server/router.go | 32 ++++++++++++ internal/server/server.go | 49 ++++++++++++++++++ internal/server/server_test.go | 48 ++++++++++++++++++ 15 files changed, 469 insertions(+), 32 deletions(-) create mode 100644 cmd/serve.go create mode 100644 internal/api/response.go create mode 100644 internal/api/response_test.go create mode 100644 internal/app/version.go create mode 100644 internal/app/webapp.go create mode 100644 internal/app/webapp_test.go create mode 100644 internal/handler/version.go create mode 100644 internal/server/router.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 0000000..c1cccb9 --- /dev/null +++ b/cmd/serve.go @@ -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) +} diff --git a/docs/architecture.md b/docs/architecture.md index c5865ed..01a3796 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,23 +23,26 @@ Rules: | Layer | Package | Purpose | Status | |-------|---------|---------|--------| | **CLI** | `cmd` | Cobra root command | ✅ skeleton | -| | `cmd/serve.go` | `mygo serve` — wire deps, start HTTP | ⬜ plan | +| | `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) | ⬜ 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 | +| **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` | JSON response writer, error code 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 @@ -62,6 +65,15 @@ DELETE /api/v1/admin/users/:id ## Middleware Chain -Applied globally: requestid → logger → cors +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, `/api/v1` route groups, and HTTP server lifecycle. +- `RunWithGracefulShutdown` stops accepting new requests on termination and gives in-flight requests time to finish. diff --git a/docs/decisions.md b/docs/decisions.md index 03430f1..9537a1f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -16,7 +16,7 @@ | 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 | +| API response format | Direct JSON success bodies + unified error body | HTTP status codes carry request outcome; error body carries human-readable details | **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. @@ -25,4 +25,26 @@ - 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). +- Gin middleware chain: default logger/recovery → cors → auth (route-group-scoped). + +## 2026-04-27: Web API Foundation + +**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)` | Register public and protected route groups in one readable router file. | +| 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/version`, 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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4ff6b2d..8e94315 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,6 +6,7 @@ |---------|--------|-------| | 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 | @@ -15,17 +16,18 @@ 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 +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 diff --git a/go.mod b/go.mod index b432f5c..42283aa 100644 --- a/go.mod +++ b/go.mod @@ -3,22 +3,49 @@ module github.com/dhao2001/mygo go 1.26.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/sys v0.29.0 // indirect - golang.org/x/text v0.28.0 // 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 ) diff --git a/go.sum b/go.sum index de19da9..bac833e 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,71 @@ +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/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/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= @@ -37,18 +82,45 @@ 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/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= +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-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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= diff --git a/internal/api/response.go b/internal/api/response.go new file mode 100644 index 0000000..478fcb7 --- /dev/null +++ b/internal/api/response.go @@ -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, + }, + }) +} diff --git a/internal/api/response_test.go b/internal/api/response_test.go new file mode 100644 index 0000000..487e567 --- /dev/null +++ b/internal/api/response_test.go @@ -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") + } +} diff --git a/internal/app/version.go b/internal/app/version.go new file mode 100644 index 0000000..abf2c75 --- /dev/null +++ b/internal/app/version.go @@ -0,0 +1,4 @@ +package app + +// Version is the application version. Release builds can override it with ldflags. +var AppVersion = "dev" diff --git a/internal/app/webapp.go b/internal/app/webapp.go new file mode 100644 index 0000000..01bc2ba --- /dev/null +++ b/internal/app/webapp.go @@ -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, + } +} diff --git a/internal/app/webapp_test.go b/internal/app/webapp_test.go new file mode 100644 index 0000000..96e23f9 --- /dev/null +++ b/internal/app/webapp_test.go @@ -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) + } +} diff --git a/internal/handler/version.go b/internal/handler/version.go new file mode 100644 index 0000000..320e749 --- /dev/null +++ b/internal/handler/version.go @@ -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}) +} diff --git a/internal/server/router.go b/internal/server/router.go new file mode 100644 index 0000000..23301b0 --- /dev/null +++ b/internal/server/router.go @@ -0,0 +1,32 @@ +package server + +import ( + "github.com/gin-gonic/gin" + + "github.com/dhao2001/mygo/internal/app" + "github.com/dhao2001/mygo/internal/handler" +) + +// NewRouter builds the Gin router and registers API routes. +func NewRouter(webApp *app.WebApp) *gin.Engine { + router := gin.Default() + + rootGroup := router.Group("/api/v1") + + publicGroup := rootGroup.Group("") + setupVersionRoutes(publicGroup, handler.NewVersionHandler(webApp.Version)) + + authGroup := rootGroup.Group("") + setupProtectedRoutes(authGroup) + + return router +} + +func setupVersionRoutes(rg *gin.RouterGroup, h *handler.VersionHandler) { + rg.GET("/version", h.Get) +} + +func setupProtectedRoutes(rg *gin.RouterGroup) { + // Protected routes will be registered after auth middleware exists. + _ = rg +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..257cd38 --- /dev/null +++ b/internal/server/server.go @@ -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) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..d6d11c3 --- /dev/null +++ b/internal/server/server_test.go @@ -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) + } +}