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:
parent
889391a3a7
commit
7835837e38
2 changed files with 276 additions and 0 deletions
71
response.go
Normal file
71
response.go
Normal 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
205
response_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue