From b60877e36753c73855bfe0e264bac9d65ec77a80 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 28 Mar 2026 10:24:01 +0100 Subject: [PATCH] feat(packages): add shared-go package with httputil and envutil Shared Go utilities for all ManaCore Go services: - httputil: WriteJSON, WriteError, DecodeJSON - envutil: Get, GetInt, GetBool, GetSlice - 8 tests, all passing Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/shared-go/envutil/envutil.go | 44 +++++++++++++ packages/shared-go/envutil/envutil_test.go | 63 ++++++++++++++++++ packages/shared-go/go.mod | 3 + packages/shared-go/httputil/httputil.go | 36 +++++++++++ packages/shared-go/httputil/httputil_test.go | 68 ++++++++++++++++++++ 5 files changed, 214 insertions(+) create mode 100644 packages/shared-go/envutil/envutil.go create mode 100644 packages/shared-go/envutil/envutil_test.go create mode 100644 packages/shared-go/go.mod create mode 100644 packages/shared-go/httputil/httputil.go create mode 100644 packages/shared-go/httputil/httputil_test.go diff --git a/packages/shared-go/envutil/envutil.go b/packages/shared-go/envutil/envutil.go new file mode 100644 index 000000000..6e0cced10 --- /dev/null +++ b/packages/shared-go/envutil/envutil.go @@ -0,0 +1,44 @@ +// Package envutil provides shared environment variable helpers for ManaCore Go services. +package envutil + +import ( + "os" + "strconv" + "strings" +) + +// Get returns an environment variable value or a fallback default. +func Get(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// GetInt returns an environment variable as int or a fallback default. +func GetInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if i, err := strconv.Atoi(v); err == nil { + return i + } + } + return fallback +} + +// GetBool returns an environment variable as bool or a fallback default. +func GetBool(key string, fallback bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return fallback +} + +// GetSlice returns an environment variable as a comma-separated slice or a fallback default. +func GetSlice(key string, fallback []string) []string { + if v := os.Getenv(key); v != "" { + return strings.Split(v, ",") + } + return fallback +} diff --git a/packages/shared-go/envutil/envutil_test.go b/packages/shared-go/envutil/envutil_test.go new file mode 100644 index 000000000..e98191cf8 --- /dev/null +++ b/packages/shared-go/envutil/envutil_test.go @@ -0,0 +1,63 @@ +package envutil + +import ( + "os" + "testing" +) + +func TestGet(t *testing.T) { + os.Setenv("TEST_GET_VAR", "hello") + defer os.Unsetenv("TEST_GET_VAR") + + if v := Get("TEST_GET_VAR", "default"); v != "hello" { + t.Errorf("expected 'hello', got '%s'", v) + } + if v := Get("TEST_GET_MISSING", "default"); v != "default" { + t.Errorf("expected 'default', got '%s'", v) + } +} + +func TestGetInt(t *testing.T) { + os.Setenv("TEST_INT_VAR", "42") + defer os.Unsetenv("TEST_INT_VAR") + + if v := GetInt("TEST_INT_VAR", 0); v != 42 { + t.Errorf("expected 42, got %d", v) + } + if v := GetInt("TEST_INT_MISSING", 99); v != 99 { + t.Errorf("expected 99, got %d", v) + } + + os.Setenv("TEST_INT_INVALID", "abc") + defer os.Unsetenv("TEST_INT_INVALID") + if v := GetInt("TEST_INT_INVALID", 7); v != 7 { + t.Errorf("expected fallback 7 for invalid, got %d", v) + } +} + +func TestGetBool(t *testing.T) { + os.Setenv("TEST_BOOL_VAR", "true") + defer os.Unsetenv("TEST_BOOL_VAR") + + if v := GetBool("TEST_BOOL_VAR", false); !v { + t.Error("expected true") + } + if v := GetBool("TEST_BOOL_MISSING", true); !v { + t.Error("expected default true") + } +} + +func TestGetSlice(t *testing.T) { + os.Setenv("TEST_SLICE_VAR", "a,b,c") + defer os.Unsetenv("TEST_SLICE_VAR") + + v := GetSlice("TEST_SLICE_VAR", nil) + if len(v) != 3 || v[0] != "a" || v[2] != "c" { + t.Errorf("expected [a,b,c], got %v", v) + } + + v = GetSlice("TEST_SLICE_MISSING", []string{"x"}) + if len(v) != 1 || v[0] != "x" { + t.Errorf("expected [x], got %v", v) + } +} diff --git a/packages/shared-go/go.mod b/packages/shared-go/go.mod new file mode 100644 index 000000000..68f247a94 --- /dev/null +++ b/packages/shared-go/go.mod @@ -0,0 +1,3 @@ +module github.com/manacore/shared-go + +go 1.25.0 diff --git a/packages/shared-go/httputil/httputil.go b/packages/shared-go/httputil/httputil.go new file mode 100644 index 000000000..4208c690b --- /dev/null +++ b/packages/shared-go/httputil/httputil.go @@ -0,0 +1,36 @@ +// Package httputil provides shared HTTP handler utilities for ManaCore Go services. +package httputil + +import ( + "encoding/json" + "net/http" + "time" +) + +// WriteJSON writes a JSON response with the given status code. +func WriteJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +// WriteError writes a standardized error JSON response. +func WriteError(w http.ResponseWriter, status int, message string) { + WriteJSON(w, status, map[string]any{ + "success": false, + "error": map[string]any{ + "statusCode": status, + "message": message, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, + }) +} + +// DecodeJSON decodes a JSON request body with a size limit. +func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any, maxBytes int64) bool { + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBytes)).Decode(dst); err != nil { + WriteError(w, http.StatusBadRequest, "invalid request body") + return false + } + return true +} diff --git a/packages/shared-go/httputil/httputil_test.go b/packages/shared-go/httputil/httputil_test.go new file mode 100644 index 000000000..0df181f76 --- /dev/null +++ b/packages/shared-go/httputil/httputil_test.go @@ -0,0 +1,68 @@ +package httputil + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWriteJSON(t *testing.T) { + w := httptest.NewRecorder() + WriteJSON(w, http.StatusOK, map[string]string{"key": "value"}) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("expected application/json, got %s", ct) + } + if !strings.Contains(w.Body.String(), `"key":"value"`) { + t.Errorf("unexpected body: %s", w.Body.String()) + } +} + +func TestWriteError(t *testing.T) { + w := httptest.NewRecorder() + WriteError(w, http.StatusBadRequest, "test error") + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, `"message":"test error"`) { + t.Errorf("unexpected body: %s", body) + } + if !strings.Contains(body, `"success":false`) { + t.Errorf("missing success:false in body: %s", body) + } +} + +func TestDecodeJSON(t *testing.T) { + var dst struct { + Name string `json:"name"` + } + + r := httptest.NewRequest("POST", "/", strings.NewReader(`{"name":"test"}`)) + w := httptest.NewRecorder() + + if !DecodeJSON(w, r, &dst, 1<<20) { + t.Error("expected decode to succeed") + } + if dst.Name != "test" { + t.Errorf("expected 'test', got '%s'", dst.Name) + } +} + +func TestDecodeJSON_Invalid(t *testing.T) { + var dst struct{} + r := httptest.NewRequest("POST", "/", strings.NewReader(`invalid`)) + w := httptest.NewRecorder() + + if DecodeJSON(w, r, &dst, 1<<20) { + t.Error("expected decode to fail") + } + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +}