You've already forked agentic-coding-workflow
feat(http): chi router skeleton + Google-style error response
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user