feat: add response envelope with OK, Fail, Paginated helpers

Generic Response[T] envelope with Success, Data, Error, and Meta fields.
Includes OK, Fail, FailWithDetails, and Paginated constructor functions.
JSON marshalling correctly omits empty fields via omitempty tags.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-20 15:44:17 +00:00
parent 889391a3a7
commit 7835837e38
2 changed files with 276 additions and 0 deletions

71
response.go Normal file
View file

@ -0,0 +1,71 @@
// SPDX-License-Identifier: EUPL-1.2
package api
// Response is the standard envelope for all API responses.
type Response[T any] struct {
Success bool `json:"success"`
Data T `json:"data,omitempty"`
Error *Error `json:"error,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
// Error describes a failed API request.
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
}
// Meta carries pagination and request metadata.
type Meta struct {
RequestID string `json:"request_id,omitempty"`
Duration string `json:"duration,omitempty"`
Page int `json:"page,omitempty"`
PerPage int `json:"per_page,omitempty"`
Total int `json:"total,omitempty"`
}
// OK wraps data in a successful response envelope.
func OK[T any](data T) Response[T] {
return Response[T]{
Success: true,
Data: data,
}
}
// Fail creates an error response with the given code and message.
func Fail(code, message string) Response[any] {
return Response[any]{
Success: false,
Error: &Error{
Code: code,
Message: message,
},
}
}
// FailWithDetails creates an error response with additional detail payload.
func FailWithDetails(code, message string, details any) Response[any] {
return Response[any]{
Success: false,
Error: &Error{
Code: code,
Message: message,
Details: details,
},
}
}
// Paginated wraps data in a successful response with pagination metadata.
func Paginated[T any](data T, page, perPage, total int) Response[T] {
return Response[T]{
Success: true,
Data: data,
Meta: &Meta{
Page: page,
PerPage: perPage,
Total: total,
},
}
}

205
response_test.go Normal file
View file

@ -0,0 +1,205 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"testing"
api "forge.lthn.ai/core/go-api"
)
// ── OK ──────────────────────────────────────────────────────────────────
func TestOK_Good(t *testing.T) {
r := api.OK("hello")
if !r.Success {
t.Fatal("expected Success=true")
}
if r.Data != "hello" {
t.Fatalf("expected Data=%q, got %q", "hello", r.Data)
}
if r.Error != nil {
t.Fatal("expected Error to be nil")
}
if r.Meta != nil {
t.Fatal("expected Meta to be nil")
}
}
func TestOK_Good_StructData(t *testing.T) {
type user struct {
Name string `json:"name"`
}
r := api.OK(user{Name: "Ada"})
if !r.Success {
t.Fatal("expected Success=true")
}
if r.Data.Name != "Ada" {
t.Fatalf("expected Data.Name=%q, got %q", "Ada", r.Data.Name)
}
}
func TestOK_Good_JSONOmitsErrorAndMeta(t *testing.T) {
r := api.OK("data")
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if _, ok := raw["error"]; ok {
t.Fatal("expected 'error' field to be omitted from JSON")
}
if _, ok := raw["meta"]; ok {
t.Fatal("expected 'meta' field to be omitted from JSON")
}
if _, ok := raw["success"]; !ok {
t.Fatal("expected 'success' field to be present")
}
if _, ok := raw["data"]; !ok {
t.Fatal("expected 'data' field to be present")
}
}
// ── Fail ────────────────────────────────────────────────────────────────
func TestFail_Good(t *testing.T) {
r := api.Fail("NOT_FOUND", "resource not found")
if r.Success {
t.Fatal("expected Success=false")
}
if r.Error == nil {
t.Fatal("expected Error to be non-nil")
}
if r.Error.Code != "NOT_FOUND" {
t.Fatalf("expected Code=%q, got %q", "NOT_FOUND", r.Error.Code)
}
if r.Error.Message != "resource not found" {
t.Fatalf("expected Message=%q, got %q", "resource not found", r.Error.Message)
}
if r.Error.Details != nil {
t.Fatal("expected Details to be nil")
}
}
func TestFail_Good_JSONOmitsData(t *testing.T) {
r := api.Fail("ERR", "something went wrong")
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if _, ok := raw["data"]; ok {
t.Fatal("expected 'data' field to be omitted from JSON")
}
if _, ok := raw["error"]; !ok {
t.Fatal("expected 'error' field to be present")
}
}
// ── FailWithDetails ─────────────────────────────────────────────────────
func TestFailWithDetails_Good(t *testing.T) {
details := map[string]string{"field": "email", "reason": "invalid format"}
r := api.FailWithDetails("VALIDATION", "validation failed", details)
if r.Success {
t.Fatal("expected Success=false")
}
if r.Error == nil {
t.Fatal("expected Error to be non-nil")
}
if r.Error.Code != "VALIDATION" {
t.Fatalf("expected Code=%q, got %q", "VALIDATION", r.Error.Code)
}
if r.Error.Details == nil {
t.Fatal("expected Details to be non-nil")
}
}
func TestFailWithDetails_Good_JSONIncludesDetails(t *testing.T) {
r := api.FailWithDetails("ERR", "bad", "extra info")
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
errObj, ok := raw["error"].(map[string]any)
if !ok {
t.Fatal("expected 'error' to be an object")
}
if _, ok := errObj["details"]; !ok {
t.Fatal("expected 'details' field to be present in error")
}
}
// ── Paginated ───────────────────────────────────────────────────────────
func TestPaginated_Good(t *testing.T) {
items := []string{"a", "b", "c"}
r := api.Paginated(items, 2, 25, 100)
if !r.Success {
t.Fatal("expected Success=true")
}
if len(r.Data) != 3 {
t.Fatalf("expected 3 items, got %d", len(r.Data))
}
if r.Meta == nil {
t.Fatal("expected Meta to be non-nil")
}
if r.Meta.Page != 2 {
t.Fatalf("expected Page=2, got %d", r.Meta.Page)
}
if r.Meta.PerPage != 25 {
t.Fatalf("expected PerPage=25, got %d", r.Meta.PerPage)
}
if r.Meta.Total != 100 {
t.Fatalf("expected Total=100, got %d", r.Meta.Total)
}
}
func TestPaginated_Good_JSONIncludesMeta(t *testing.T) {
r := api.Paginated([]int{1}, 1, 10, 50)
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if _, ok := raw["meta"]; !ok {
t.Fatal("expected 'meta' field to be present")
}
meta := raw["meta"].(map[string]any)
if meta["page"].(float64) != 1 {
t.Fatalf("expected page=1, got %v", meta["page"])
}
if meta["per_page"].(float64) != 10 {
t.Fatalf("expected per_page=10, got %v", meta["per_page"])
}
if meta["total"].(float64) != 50 {
t.Fatalf("expected total=50, got %v", meta["total"])
}
}