From 98c9ce139f40f80064cc856c2f8ae241fd677b8e Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 28 Apr 2026 14:59:02 +0800 Subject: [PATCH] feat(http): chi router skeleton + Google-style error response --- go.mod | 1 + go.sum | 2 + internal/transport/http/response.go | 52 ++++++++++++++++++++++++ internal/transport/http/response_test.go | 49 ++++++++++++++++++++++ internal/transport/http/router.go | 50 +++++++++++++++++++++++ 5 files changed, 154 insertions(+) create mode 100644 internal/transport/http/response.go create mode 100644 internal/transport/http/response_test.go create mode 100644 internal/transport/http/router.go diff --git a/go.mod b/go.mod index 63ad965..5a81053 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/yan1h/agent-coding-workflow go 1.25.0 require ( + github.com/go-chi/chi/v5 v5.2.5 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.9.2 github.com/spf13/viper v1.21.0 diff --git a/go.sum b/go.sum index 01c90ad..82cb088 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/internal/transport/http/response.go b/internal/transport/http/response.go new file mode 100644 index 0000000..99a3c67 --- /dev/null +++ b/internal/transport/http/response.go @@ -0,0 +1,52 @@ +// Package httpx provides the HTTP transport layer: a Chi-based router +// skeleton and Google API style JSON response helpers (WriteJSON, +// WriteError). It deliberately uses package name "httpx" to avoid +// shadowing the standard library "net/http" package at call sites. +package httpx + +import ( + "encoding/json" + "net/http" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// WriteJSON writes body as JSON with the given HTTP status. The +// Content-Type header is set to application/json before WriteHeader is +// called. Encode errors are intentionally ignored: by the time the +// status has been written the connection is committed, and the caller +// has no meaningful recovery path. +func WriteJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +// errorResponse is the Google API style flat error body. +type errorResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Fields map[string]any `json:"fields,omitempty"` + RequestID string `json:"request_id,omitempty"` +} + +// WriteError translates err into a JSON error response. *errs.AppError +// values are rendered with their declared Code/Message/Fields and the +// HTTP status mapped via errs.HTTPStatus. Any other error is reported +// as a generic 500 internal error so internal details don't leak. +func WriteError(w http.ResponseWriter, requestID string, err error) { + if ae, ok := errs.As(err); ok { + WriteJSON(w, errs.HTTPStatus(ae.Code), errorResponse{ + Code: string(ae.Code), + Message: ae.Message, + Fields: ae.Fields, + RequestID: requestID, + }) + return + } + WriteJSON(w, http.StatusInternalServerError, errorResponse{ + Code: string(errs.CodeInternal), + Message: "internal server error", + RequestID: requestID, + }) +} diff --git a/internal/transport/http/response_test.go b/internal/transport/http/response_test.go new file mode 100644 index 0000000..5c6f55e --- /dev/null +++ b/internal/transport/http/response_test.go @@ -0,0 +1,49 @@ +package httpx + +import ( + "encoding/json" + "errors" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +func TestWriteJSON(t *testing.T) { + rec := httptest.NewRecorder() + WriteJSON(rec, 200, map[string]any{"x": 1}) + require.Equal(t, 200, rec.Code) + require.Equal(t, "application/json; charset=utf-8", rec.Header().Get("Content-Type")) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.EqualValues(t, 1, body["x"]) +} + +func TestWriteError_AppError(t *testing.T) { + rec := httptest.NewRecorder() + e := errs.New(errs.CodeInvalidInput, "bad").WithFields(map[string]any{"name": "required"}) + WriteError(rec, "req-1", e) + require.Equal(t, 400, rec.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, "invalid_input", body["code"]) + require.Equal(t, "bad", body["message"]) + require.Equal(t, "req-1", body["request_id"]) + fields := body["fields"].(map[string]any) + require.Equal(t, "required", fields["name"]) +} + +func TestWriteError_GenericError_BecomesInternal(t *testing.T) { + rec := httptest.NewRecorder() + WriteError(rec, "req-2", errors.New("boom")) + require.Equal(t, 500, rec.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, "internal", body["code"]) + require.Equal(t, "internal server error", body["message"]) +} diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go new file mode 100644 index 0000000..9ba08b9 --- /dev/null +++ b/internal/transport/http/router.go @@ -0,0 +1,50 @@ +package httpx + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +// RouterDeps bundles the dependencies needed to assemble the router. +// MVP exposes only liveness/readiness probe hooks; later phases (auth +// resolver, module handlers, etc.) will add fields here rather than +// growing NewRouter's signature. +type RouterDeps struct { + // HealthCheck is invoked from GET /healthz. Returning a non-nil + // error renders 503 with the error message as the body. If nil, + // /healthz unconditionally returns 200 "ok". + HealthCheck func(*http.Request) error + // ReadyCheck is invoked from GET /readyz with the same contract. + ReadyCheck func(*http.Request) error +} + +// NewRouter builds the application's chi.Router with the standard +// liveness/readiness endpoints attached. The return type is the +// chi.Router interface (not *chi.Mux) so call sites depend only on +// behaviour shared with future routing layers. +func NewRouter(deps RouterDeps) chi.Router { + r := chi.NewRouter() + + r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) { + if deps.HealthCheck != nil { + if err := deps.HealthCheck(req); err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + } + _, _ = w.Write([]byte("ok")) + }) + + r.Get("/readyz", func(w http.ResponseWriter, req *http.Request) { + if deps.ReadyCheck != nil { + if err := deps.ReadyCheck(req); err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + } + _, _ = w.Write([]byte("ok")) + }) + + return r +}