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
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
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})
|
||||
}
|
||||
32
internal/server/router.go
Normal file
32
internal/server/router.go
Normal file
@@ -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
|
||||
}
|
||||
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