diff --git a/response.go b/response.go new file mode 100644 index 0000000..2a77e18 --- /dev/null +++ b/response.go @@ -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, + }, + } +} diff --git a/response_test.go b/response_test.go new file mode 100644 index 0000000..34b5683 --- /dev/null +++ b/response_test.go @@ -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"]) + } +}