Skip to content

replify is a Go library designed to simplify and standardize API response wrapping for RESTful services. It leverages the Decorator Pattern to dynamically add error handling, metadata, pagination, and other response features in a clean and human-readable format.

Notifications You must be signed in to change notification settings

sivaosorg/replify

Repository files navigation

replify

replify is a Go library designed to simplify and standardize API response wrapping for RESTful services. It leverages the Decorator Pattern to dynamically add error handling, metadata, pagination, and other response features in a clean and human-readable format.

Go Version License

Overview

Building RESTful APIs often requires repetitive boilerplate code for standardizing responses. replify eliminates this by providing a fluent, chainable API that ensures consistent response formats across all your endpoints.

What Problems Does It Solve?

  • Inconsistent response formats across different endpoints
  • Repetitive error handling boilerplate in every handler
  • Manual metadata management (request IDs, timestamps, versions)
  • Complex pagination logic scattered throughout the codebase
  • Debugging difficulties in production vs development environments

The Solution

Standardized response structure - One format for all endpoints
Fluent API - Chainable methods for building responses
Built-in pagination - Complete pagination support out of the box
Metadata management - Request IDs, timestamps, API versions, locales
Conditional debugging - Development-only debug information
Error handling - Stack traces, error wrapping, contextual messages
Type safety - Full type safety with Go generics
Zero dependencies - Only uses Go standard library

Features

Core Capabilities

  • 🎯 Standardized JSON Format - Consistent structure across all API responses
  • 🔗 Fluent Builder Pattern - Chain methods to construct complex responses
  • 📄 Pagination Support - Built-in page, per_page, total_items, total_pages, is_last
  • 🔍 Request Tracing - Track requests with unique IDs across microservices
  • 🌍 Internationalization - Locale support for multi-language APIs
  • 🐛 Debug Mode - Conditional debugging information for development
  • Error Handling - Rich error information with stack traces
  • 📊 Metadata - API version, custom fields, timestamps
  • Status Helpers - IsSuccess(), IsClientError(), IsServerError()
  • 🔄 JSON Parsing - Parse JSON strings back to wrapper objects

Requirements

  • Go version 1.23 or higher

Installation

Install Package

# Latest version
go get -u github.com/sivaosorg/replify@latest

# Specific version
go get github.com/sivaosorg/replify@v0.0.1

Import in Code

import "github.com/sivaosorg/replify"

With Go's module support, go [build|run|test] automatically fetches the necessary dependencies when you add the import.

Quick Start

Basic Example

package main

import (
    "fmt"
    "github.com/sivaosorg/replify"
)

func main() {
    // Create a simple success response
    response := replify.New().
        WithStatusCode(200).
        WithMessage("User retrieved successfully").
        WithBody(map[string]string{
            "id":   "123",
            "name": "John Doe",
        })
    
    fmt.Println(response.JSONPretty())
}

Output:

{
    "data": {
        "id": "123",
        "name": "John Doe"
    },
    "headers": {
        "code": 200,
        "text": "OK"
    },
    "message": "User retrieved successfully",
    "meta": {
        "api_version": "v0.0.1",
        "locale": "en_US",
        "request_id": "d7e5ce24b796da94770911db36565bf9",
        "requested_time": "2026-01-29T10:07:05.751501+07:00"
    },
    "status_code": 200,
    "total": 0
}

Standard Response Format

The library produces responses in this standardized format:

{
  "status_code": 200,
  "message": "Resource retrieved successfully",
  "path": "/api/v1/users",
  "data": [ // abstract data (can be array or object)
    {
      "id": "user_01J6G7W9K2M4X7V5P8B3Q2Z1NS",
      "username": "jdoe_dev",
      "email": "j.doe@example.com",
      "role": "administrator",
      "status": "active",
      "created_at": "2025-01-15T08:30:00Z",
      "last_login": "2026-02-26T14:15:22Z"
    },
    {
      "id": "user_01J6G7W9K2M4X7V5P8B3Q2Z1NT",
      "username": "s_smith",
      "email": "sarah.smith@example.com",
      "role": "editor",
      "status": "active",
      "created_at": "2025-02-01T10:15:00Z",
      "last_login": "2026-02-25T09:45:10Z"
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 2,
    "total_items": 120,
    "total_pages": 60,
    "is_last": false
  },
  "meta": {
    "request_id": "req_80eafc6a1655ec5a06595d155f1e6951",
    "api_version": "v1.0.4",
    "locale": "en_US",
    "requested_time": "2026-02-26T17:30:28.983Z",
    "custom_fields": { // custom fields
      "trace_id": "80eafc6a1655ec5a06595d155f1e6951",
      "origin_region": "us-east-1"
    }
  },
  "debug": { // custom fields
    "trace_session_id": "4919e84fc26881e9fe790f5d07465db4",
    "execution_time_ms": 42
  }
}

Field Descriptions

Field Type Description
data interface{} The primary data payload of the response
status_code int HTTP status code for the response
message string Human-readable message providing context
total int Total number of items (used in non-paginated responses)
path string Request path for which the response is generated
meta object Metadata about the API response
meta.request_id string Unique identifier for the request, useful for debugging
meta.api_version string API version used for the request
meta.locale string Locale used for the request (e.g., "en_US")
meta.requested_time string Timestamp when the request was made (ISO 8601)
meta.custom_fields object Additional custom metadata fields
pagination object Pagination details, if applicable
pagination.page int Current page number
pagination.per_page int Number of items per page
pagination.total_items int Total number of items available
pagination.total_pages int Total number of pages
pagination.is_last bool Indicates whether this is the last page
debug object Debugging information (useful for development)

Usage

1. Creating Basic Responses

Success Response

response := replify.New().
    WithStatusCode(200).
    WithMessage("Operation successful").
    WithBody(data)

Error Response

response := replify.New().
    WithStatusCode(400).
    WithError("Invalid input: email is required").
    WithMessage("Validation failed")

Response with Metadata

response := replify.New().
    WithStatusCode(200).
    WithBody(users).
    WithRequestID("req-123-456").
    WithApiVersion("v1.0.0").
    WithLocale("en_US").
    WithPath("/api/v1/users")

2. Pagination

Creating Pagination

pagination := replify.Pages().
    WithPage(1).
    WithPerPage(20).
    WithTotalItems(150).
    WithTotalPages(8).
    WithIsLast(false)

response := replify.New().
    WithStatusCode(200).
    WithBody(users).
    WithPagination(pagination).
    WithTotal(20)

3. Debugging Information

response := replify.New().
    WithStatusCode(500).
    WithError("Database connection failed").
    WithDebuggingKV("query", "SELECT * FROM users").
    WithDebuggingKV("error_code", "CONN_TIMEOUT").
    WithDebuggingKV("retry_count", 3)

4. Complete Example

package main

import (
    "fmt"
    "github.com/sivaosorg/replify"
    "github.com/sivaosorg/replify/pkg/randn"
)

func main() {
    // Create pagination
    p := replify.Pages().
        WithIsLast(true).
        WithPage(1000).
        WithTotalItems(120).
        WithTotalPages(34).
        WithPerPage(2)
    
    // Create response
    w := replify.New().
        WithStatusCode(200).
        WithTotal(1).
        WithMessagef("How are you? %v", "I'm good").
        WithDebuggingKV("refer", 1234).
        WithDebuggingKVf("___abc", "trace sessions_id: %v", randn.CryptoID()).
        WithBody("response body here").
        WithPath("/api/v1/users").
        WithCustomFieldKVf("fields", "userID: %v", 103).
        WithPagination(p)
    
    if !w.Available() {
        return
    }
    
    // Access response properties
    fmt.Println(w.JSON())
    fmt.Println(w.StatusCode())
    fmt.Println(w.StatusText())
    fmt.Println(w.Message())
    fmt.Println(w.Body())
    fmt.Println(w.IsSuccess())
    fmt.Println(w.Respond())
    
    // Check metadata
    fmt.Println(w.Meta().IsCustomPresent())
    fmt.Println(w.Meta().IsApiVersionPresent())
    fmt.Println(w.Meta().IsRequestIDPresent())
    fmt.Println(w.Meta().IsRequestedTimePresent())
}

5. Parsing JSON to Response

package main

import (
    "fmt"
    "log"
    "time"
    "github.com/sivaosorg/replify"
)

func main() {
    jsonStr := `{
        "data": "response body here",
        "debug": {
          "___abc": "trace sessions_id: 4919e84fc26881e9fe790f5d07465db4",
          "refer": 1234
        },
        "message": "How do you do? I'm good",
        "meta": {
          "api_version": "v0.0.1",
          "custom_fields": {
            "fields": "userID: 103"
          },
          "locale": "en_US",
          "request_id": "80eafc6a1655ec5a06595d155f1e6951",
          "requested_time": "2024-12-14T20:24:23.983839+07:00"
        },
        "pagination": {
          "is_last": true,
          "page": 1000,
          "per_page": 2,
          "total_items": 120,
          "total_pages": 34
        },
        "path": "/api/v1/users",
        "status_code": 200,
        "total": 1
    }`
    
    t := time.Now()
    w, err := replify.UnwrapJSON(jsonStr)
    diff := time.Since(t)
    
    if err != nil {
        log.Fatalf("Error parsing JSON: %v", err)
    }
    
    fmt.Printf("Exe time: %+v\n", diff.String())
    fmt.Printf("%+v\n", w.OnDebugging("___abc"))
    fmt.Printf("%+v\n", w.JSONPretty())
}

Practical Examples

Example 1: RESTful CRUD API

package main

import (
    "encoding/json"
    "net/http"
    "github.com/sivaosorg/replify"
)

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

// GET /users/:id
func GetUser(w http.ResponseWriter, r *http.Request) {
    id := getIDFromPath(r)
    user, err := findUserByID(id)
    
    var response *replify.R
    if err != nil {
        response = replify.New().
            WithStatusCode(404).
            WithError(err.Error()).
            WithMessage("User not found").
            WithRequestID(r.Header.Get("X-Request-ID"))
    } else {
        response = replify.New().
            WithStatusCode(200).
            WithBody(user).
            WithMessage("User retrieved successfully").
            WithRequestID(r.Header.Get("X-Request-ID"))
    }
    
    respondJSON(w, response)
}

// POST /users
func CreateUser(w http.ResponseWriter, r *http.Request) {
    var user User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        response := replify.New().
            WithStatusCode(400).
            WithError(err.Error()).
            WithMessage("Invalid request body")
        respondJSON(w, response)
        return
    }
    
    if err := validateUser(user); err != nil {
        response := replify.New().
            WithStatusCode(422).
            WithError(err.Error()).
            WithMessage("Validation failed")
        respondJSON(w, response)
        return
    }
    
    createdUser, err := createUser(user)
    if err != nil {
        response := replify.New().
            WithStatusCode(500).
            WithErrorAck(err).
            WithMessage("Failed to create user")
        respondJSON(w, response)
        return
    }
    
    response := replify.New().
        WithStatusCode(201).
        WithBody(createdUser).
        WithMessage("User created successfully")
    respondJSON(w, response)
}

func respondJSON(w http.ResponseWriter, response *replify.R) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(response.StatusCode())
    w.Write([]byte(response.JSON()))
}

Example 2: Paginated List API

func ListUsers(w http.ResponseWriter, r *http.Request) {
    // Parse query parameters
    page := getQueryInt(r, "page", 1)
    perPage := getQueryInt(r, "per_page", 10)
    search := r.URL.Query().Get("search")
    
    // Fetch users with pagination
    users, total, err := db.FindUsers(search, page, perPage)
    if err != nil {
        response := replify.New().
            WithStatusCode(500).
            WithErrorAck(err).
            WithMessage("Failed to fetch users").
            WithDebuggingKV("search", search).
            WithDebuggingKV("page", page)
        respondJSON(w, response)
        return
    }
    
    // Calculate pagination metadata
    totalPages := (total + perPage - 1) / perPage
    isLast := page >= totalPages
    
    pagination := replify.Pages().
        WithPage(page).
        WithPerPage(perPage).
        WithTotalItems(total).
        WithTotalPages(totalPages).
        WithIsLast(isLast)
    
    response := replify.New().
        WithStatusCode(200).
        WithBody(users).
        WithPagination(pagination).
        WithTotal(len(users)).
        WithMessage("Users retrieved successfully").
        WithPath(r.URL.Path).
        WithRequestID(r.Header.Get("X-Request-ID"))
    
    respondJSON(w, response)
}

Example 3: Error Handling with Stack Traces

func ProcessOrder(w http.ResponseWriter, r *http.Request) {
    order, err := processOrderLogic(r)
    
    response := replify.New()
    
    if err != nil {
        response.
            WithStatusCode(500).
            WithErrorAck(err).
            WithMessage("Order processing failed")
        
        // Add debug info in development
        if os.Getenv("ENV") == "development" {
            response.
                WithDebuggingKV("timestamp", time.Now()).
                WithDebuggingKV("stack_trace", err.Error()).
                WithDebuggingKV("order_data", order)
        }
    } else {
        response.
            WithStatusCode(200).
            WithBody(order).
            WithMessage("Order processed successfully")
    }
    
    respondJSON(w, response)
}

API Reference

Wrapper Type (R)

type R struct {
    *wrapper
}

The R type is a high-level abstraction providing a simplified interface for handling API responses.

Core Functions

Function Description
New() *wrapper Creates a new response wrapper
Pages() *pagination Creates a new pagination object
UnwrapJSON(jsonStr string) (*wrapper, error) Parses JSON string to wrapper

Configuration Methods

Response Configuration

Method Description
WithStatusCode(code int) Sets HTTP status code
WithBody(v interface{}) Sets response body/data
WithMessage(message string) Sets response message
WithMessagef(format string, args...) Sets formatted message
WithError(message string) Sets error message
WithErrorf(format string, args...) Sets formatted error
WithErrorAck(err error) Sets error with stack trace
AppendError(err error, message string) Wraps error with context
AppendErrorf(err error, format string, args...) Wraps error with formatted context
WithPath(v string) Sets request path
WithPathf(v string, args...) Sets formatted request path
WithTotal(total int) Sets total items count

Metadata Methods

Method Description
WithRequestID(v string) Sets request ID
WithRequestIDf(format string, args...) Sets formatted request ID
WithApiVersion(v string) Sets API version
WithApiVersionf(format string, args...) Sets formatted API version
WithLocale(v string) Sets locale (e.g., "en_US")
WithRequestedTime(v time.Time) Sets request timestamp
WithCustomFieldKV(key string, value interface{}) Adds custom metadata field
WithCustomFieldKVf(key, format string, args...) Adds formatted custom field
WithCustomFields(values map[string]interface{}) Sets multiple custom fields
WithMeta(v *meta) Sets entire metadata object
WithHeader(v *header) Sets the header

Pagination Methods

Method Description
WithPagination(v *pagination) Sets pagination object
WithPage(v int) Sets current page number
WithPerPage(v int) Sets items per page
WithTotalItems(v int) Sets total items count
WithTotalPages(v int) Sets total pages count
WithIsLast(v bool) Sets if current page is last

Debugging Methods

Method Description
WithDebugging(v map[string]interface{}) Sets debug information map
WithDebuggingKV(key string, value interface{}) Adds single debug key-value
WithDebuggingKVf(key, format string, args...) Adds formatted debug value

Query Methods

Method Returns Description
Available() bool Checks if wrapper is non-nil
StatusCode() int Gets HTTP status code
StatusText() string Gets status text (e.g., "OK")
Body() interface{} Gets response body
Message() string Gets response message
Error() string Gets error message
Cause() error Gets underlying error cause
Total() int Gets total items
Meta() *meta Gets metadata object
Header() *header Gets header object
Pagination() *pagination Gets pagination object
Debugging() map[string]interface{} Gets debug information
OnDebugging(key string) interface{} Gets specific debug value

Conditional Check Methods

Method Returns Description
IsSuccess() bool Checks if status is 2xx
IsClientError() bool Checks if status is 4xx
IsServerError() bool Checks if status is 5xx
IsRedirection() bool Checks if status is 3xx
IsError() bool Checks if error exists or status is 4xx/5xx
IsErrorPresent() bool Checks if error field exists
IsBodyPresent() bool Checks if body exists
IsPagingPresent() bool Checks if pagination exists
IsMetaPresent() bool Checks if metadata exists
IsHeaderPresent() bool Checks if header exists
IsDebuggingPresent() bool Checks if debug info exists
IsDebuggingKeyPresent(key string) bool Checks if specific debug key exists
IsLastPage() bool Checks if current page is last
IsStatusCodePresent() bool Checks if valid status code exists
IsTotalPresent() bool Checks if total count exists

Serialization Methods

Method Returns Description
JSON() string Returns compact JSON string
JSONPretty() string Returns pretty-printed JSON
Respond() map[string]interface{} Returns map representation
Reply() R Returns R wrapper

HTTP Status Codes Reference

Common API Scenarios

Scenario HTTP Status Codes Example
Successful Resource Retrieval 200 OK, 304 Not Modified GET /users/123 - Returns user data
Resource Creation 201 Created POST /users - Creates a new user
Asynchronous Processing 202 Accepted POST /large-file - File upload starts
Validation Errors 400 Bad Request POST /users - Missing required field
Authentication Issues 401 Unauthorized, 403 Forbidden Invalid credentials or permissions
Rate Limiting 429 Too Many Requests Exceeded API request limits
Missing Resource 404 Not Found GET /users/999 - User not found
Server Failures 500 Internal Server Error, 503 Service Unavailable Database failure or maintenance
Version Conflicts 409 Conflict Outdated version causing conflict

Detailed Status Codes

Success (2xx)

Code Status Use Case
200 OK Successful GET, PUT, PATCH
201 Created Successful POST (resource created)
202 Accepted Async processing started
204 No Content Successful DELETE
206 Partial Content Video streaming, range requests

Redirection (3xx)

Code Status Use Case
301 Moved Permanently Resource permanently moved
302 Found Temporary redirect
304 Not Modified Cached content still valid
307 Temporary Redirect POST redirect maintaining method
308 Permanent Redirect Permanent redirect maintaining method

Client Errors (4xx)

Code Status Use Case
400 Bad Request Invalid request format/data
401 Unauthorized Missing/invalid authentication
403 Forbidden Insufficient permissions
404 Not Found Resource doesn't exist
409 Conflict Resource conflict (duplicate)
413 Payload Too Large Request body too large
415 Unsupported Media Type Invalid content type
422 Unprocessable Entity Validation errors
429 Too Many Requests Rate limiting

Server Errors (5xx)

Code Status Use Case
500 Internal Server Error Unexpected server error
501 Not Implemented Feature not implemented
502 Bad Gateway Upstream service error
503 Service Unavailable Service down/maintenance
504 Gateway Timeout Upstream timeout

Best Practices

✅ Do's

  1. Always set status codes

    response := replify.New().
        WithStatusCode(200).
        WithBody(data)
  2. Use request IDs for tracing

    response := replify.New().
        WithRequestID(r.Header.Get("X-Request-ID")).
        WithBody(data)
  3. Include API version

    response := replify.New().
        WithApiVersion("v1.0.0").
        WithBody(data)
  4. Use WithErrorAck for stack traces

    response := replify.New().
        WithStatusCode(500).
        WithErrorAck(err)
  5. Check response status before processing

    if response.IsSuccess() {
        processData(response.Body())
    }
  6. Use pagination for list endpoints

    pagination := replify.Pages().
        WithPage(page).
        WithPerPage(perPage).
        WithTotalItems(total)

❌ Don'ts

  1. Don't forget to set status codes

    // ❌ Bad
    response := replify.New().WithBody(data)
    
    // ✅ Good
    response := replify.New().WithStatusCode(200).WithBody(data)
  2. Don't expose sensitive debug info in production

    // ❌ Bad
    response := replify.New().
        WithDebuggingKV("database_password", dbPass)
    
    // ✅ Good
    if os.Getenv("ENV") == "development" {
        response.WithDebuggingKV("query", sqlQuery)
    }
  3. Don't use generic error messages

    // ❌ Bad
    WithError("Error occurred")
    
    // ✅ Good
    WithError("Failed to create user: email already exists")
  4. Don't ignore error checking

    // ❌ Bad
    wrapper, _ := replify.UnwrapJSON(jsonStr)
    
    // ✅ Good
    wrapper, err := replify.UnwrapJSON(jsonStr)
    if err != nil {
        log.Printf("Failed to parse JSON: %v", err)
    }

Use Cases

✅ When to Use

  • RESTful API Development - Standardizing API responses
  • Microservices - Consistent responses across services
  • API Versioning - Including version metadata
  • Error Standardization - Consistent error formats
  • Pagination - APIs returning paginated results
  • Multi-tenant APIs - Including tenant/locale information
  • Request Tracing - Tracking requests across services
  • Development Debugging - Conditional debug information

❌ When Not to Use

  • GraphQL APIs - GraphQL has its own response format
  • gRPC Services - Protocol Buffers define the structure
  • WebSocket APIs - Real-time bidirectional communication
  • Simple CLIs - Overkill for command-line tools
  • Internal Services - Where custom formats are required
  • High-Performance - Direct JSON encoding may be faster

fj Usage Guide

fj (Fast JSON) is the JSON path-extraction engine embedded in replify. It lets you read, query, and transform values from a JSON document without unmarshalling the entire structure into Go types. It lives in pkg/fj and is exposed through the wrapper type in parser.go.

Purpose in the replify Architecture

When a wrapper carries a JSON body, fj powers every field-level query on that body. Instead of decoding the whole payload into a map[string]any or a concrete struct, fj walks the raw string just far enough to locate the requested path. This keeps allocations low and throughput high on hot request paths.

HTTP Request → wrapper.WithBody(data) → wrapper.QueryJSONBody("user.name")
                                                 ↓
                                    fj.Get(jsonString, "user.name")
                                                 ↓
                                          fj.Context  ← single value, no full decode

When to Use fj Instead of encoding/json

Scenario Recommended approach
Extract one or a few fields from a large response body fj / QueryJSONBody
Validate that the body is well-formed JSON fj.IsValidJSON / ValidJSONBody
Search leaf values or keys across an unknown schema fj.Search / SearchJSONBody*
Apply streaming transforms (pretty-print, minify, etc.) fj transformers
Bind the full payload into a typed struct encoding/json or json-iterator
Write or modify JSON encoding/json
JSON schema validation a dedicated schema library

Path Syntax Quick Reference

user.name              field access
roles.0                array index
roles.#                array length
roles.#.name           collect field from every element
roles.#(role=="admin") first element where role == "admin"
roles.#(role=="admin")# all elements where role == "admin"
{id,name}              multi-selector → new object
[id,name]              multi-selector → new array
name.@uppercase        built-in transformer
name.@word:upper       transformer with argument
..title                recursive descent (JSON Lines / deep scan)

Dots and wildcards in key names can be escaped with a backslash (\).

Core API

Direct fj usage

import "github.com/sivaosorg/replify/pkg/fj"

json := `{
    "user": {"name": "Alice", "age": 30, "active": true},
    "roles": ["admin", "editor"],
    "scores": [95, 87, 92]
}`

// Single path
name := fj.Get(json, "user.name").String()    // "Alice"
age  := fj.Get(json, "user.age").Int64()      // 30
ok   := fj.Get(json, "user.active").Bool()    // true
n    := fj.Get(json, "roles.#").Int()         // 2 (array length)

// Multiple paths in one pass
results := fj.GetMulti(json, "user.name", "user.age", "roles.#")
// results[0].String() == "Alice", results[1].Int64() == 30, results[2].Int() == 2

// Check presence before use
if ctx := fj.Get(json, "user.email"); ctx.Exists() {
    fmt.Println(ctx.String())
}

// Parse a document once, query multiple times (avoids re-parsing)
doc := fj.Parse(json)
fmt.Println(doc.Get("user.name").String())
fmt.Println(doc.Get("roles.0").String())

Zero-copy byte-slice access

GetBytes is preferred when you already hold a []byte. It uses unsafe pointer operations internally to avoid an extra string allocation:

rawBytes := []byte(`{"id":42,"status":"active"}`)

id     := fj.GetBytes(rawBytes, "id").Int()        // 42
status := fj.GetBytes(rawBytes, "status").String() // "active"

// Multiple paths from bytes
res := fj.GetBytesMulti(rawBytes, "id", "status")

Memory note: fj.Context.Raw() returns a substring view of the original string without copying. Do not hold a reference to the Context after the source string has been released; the backing memory will be reclaimed.

Wrapper Integration (parser.go)

The wrapper type exposes all fj operations without requiring you to import pkg/fj directly in most cases:

	response := replify.New().
		WithStatusCode(200).
		WithBody(map[string]any{
			"user": map[string]any{"name": "Alice", "role": "admin"},
			"items": []map[string]any{
				{"id": 1, "price": 9.99},
				{"id": 2, "price": 4.50},
			},
		})

	// Single path query
	name := response.QueryJSONBody("user.name").String() // "Alice"

	// Multiple paths in one call (one JSON serialization)
	fields := response.QueryJSONBodyMulti("user.name", "user.role")
	fmt.Println(fields[0].String(), fields[1].String()) // Alice admin

	// Parse the body once and chain subsequent queries
	ctx := response.JSONBodyParser()
	fmt.Println(ctx.Get("user.name").String())
	fmt.Println(ctx.Get("items.#").Int()) // array length

	// Validate the body
	if !response.ValidJSONBody() {
		log.Println("body is not valid JSON")
		return
	}

	// Aggregate helpers
	total := response.SumJSONBody("items.#.price")  // 14.49
	min, _ := response.MinJSONBody("items.#.price") // 4.50
	max, _ := response.MaxJSONBody("items.#.price") // 9.99
	avg, _ := response.AvgJSONBody("items.#.price") // 7.245

	fmt.Println(name)
	fmt.Println(fields[0].String(), fields[1].String())
	fmt.Println(ctx.Get("user.name").String())
	fmt.Println(ctx.Get("items.#").Int())
	fmt.Println(total)
	fmt.Println(min)
	fmt.Println(max)
	fmt.Println(avg)

Performance tip: QueryJSONBody serializes the body on every call. For repeated queries on the same body, call JSONBodyParser() once and reuse the returned fj.Context.

Context Value Extraction

A fj.Context is returned by every query. Always call .Exists() before using the value if the path might be absent.

ctx := fj.Get(json, "optional.field")

ctx.Exists()   // false when path is missing
ctx.Kind()     // fj.Null | fj.String | fj.Number | fj.True | fj.False | fj.JSON
ctx.String()   // string representation
ctx.Bool()     // bool
ctx.Int()      // int
ctx.Int64()    // int64
ctx.Float64()  // float64
ctx.Raw()      // raw JSON token (no allocation)
ctx.IsArray()  // true when kind == JSON and raw starts with '['
ctx.IsObject() // true when kind == JSON and raw starts with '{'
ctx.IsError()  // true if parsing produced an error
ctx.Cause()    // error string, or "" if no error

// Iterate array values
ctx.Foreach(func(key, val fj.Context) bool {
    fmt.Println(val.String())
    return true // return false to stop
})

Transformers

Transformers are applied with the @ prefix inside a path expression and receive the current JSON value as input. An optional argument is passed after a : separator.

path.@transformerName
path.@transformerName:argument
path.@transformerName:{"key":"value"}

Core transformers

Transformer Alias(es) Input Description
@pretty any Pretty-print (indented) JSON. Accepts optional {"sort_keys":true,"indent":"\t","prefix":"","width":80}.
@minify @ugly any Compact single-line JSON (all whitespace removed).
@valid any Returns "true" / "false" — whether the input is valid JSON.
@this any Identity — returns the input unchanged.
@reverse array | object Reverses element order (array) or key order (object).
@flatten array Shallow-flatten nested arrays. Pass {"deep":true} to recurse.
@join array of objects Merge an array of objects into one object. Pass {"preserve":true} to keep duplicate keys.
@keys object Return a JSON array of the object's keys.
@values object Return a JSON array of the object's values.
@group object of arrays Zip object-of-arrays into an array-of-objects.
@search any @search:path — collect all values reachable at path anywhere in the tree.
@json string Parse the string as JSON and return the value.
@string any Encode the value as a JSON string literal.

String transformers

Transformer Alias(es) Description
@uppercase @upper Convert all characters to upper-case.
@lowercase @lower Convert all characters to lower-case.
@flip Reverse the characters of the string.
@trim Strip leading/trailing whitespace.
@snakecase @snake, @snakeCase Convert to snake_case.
@camelcase @camel, @camelCase Convert to camelCase.
@kebabcase @kebab, @kebabCase Convert to kebab-case.
@replace @replace:{"target":"old","replacement":"new"} — replace first occurrence.
@replaceAll @replaceAll:{"target":"old","replacement":"new"} — replace all occurrences.
@hex Hex-encode the value.
@bin Binary-encode the value.
@insertAt @insertAt:{"index":5,"insert":"XYZ"} — insert a substring at position.
@wc Return the word-count of a string as an integer.
@padLeft @padLeft:{"padding":"*","length":10} — left-pad to a fixed width.
@padRight @padRight:{"padding":"*","length":10} — right-pad to a fixed width.

Object transformers

Transformer Description
@project Pick and/or rename fields from an object. Arg: {"pick":["f1","f2"],"rename":{"f1":"newName"}}. Omit pick to keep all fields; omit rename for no renaming.
@default Inject fallback values for fields that are absent or null. Arg: {"field":"defaultValue",...}. Existing non-null fields are never overwritten.

Array transformers

Transformer Description
@filter Keep only elements matching a condition. Arg: {"key":"field","op":"eq","value":val}. Operators: eq (default), ne, gt, gte, lt, lte, contains.
@pluck Extract a named field (supports dot-notation paths) from every element. Arg: field path string, e.g. @pluck:name or @pluck:addr.city.
@first Return the first element of the array, or null if empty.
@last Return the last element of the array, or null if empty.
@count Return the number of elements (array) or key-value pairs (object) as an integer. Scalars return 0.
@sum Sum all numeric values in the array; non-numeric elements are skipped. Returns 0 for empty arrays.
@min Return the minimum numeric value in the array. Returns null when no numbers are present.
@max Return the maximum numeric value in the array. Returns null when no numbers are present.

Value normalization transformers

Transformer Description
@coerce Convert a scalar to a target type. Arg: {"to":"string"}, {"to":"number"}, or {"to":"bool"}. Objects and arrays are returned unchanged.

Examples

json := `{
    "user": {"name": "Alice", "role": null, "age": 30, "city": "NY"},
    "scores": [95, 87, 92, 78],
    "users": [
        {"name": "Alice", "active": true,  "addr": {"city": "NY"}},
        {"name": "Bob",   "active": false, "addr": {"city": "LA"}},
        {"name": "Carol", "active": true,  "addr": {"city": "NY"}}
    ]
}`

// ── Core ─────────────────────────────────────────────────────────────────────
fj.Get(json, "@pretty").String()             // indented JSON
fj.Get(json, "@minify").String()             // compact JSON
fj.Get(json, "user.@keys").String()          // ["name","role","age","city"]
fj.Get(json, "user.@values").String()        // ["Alice",null,30,"NY"]
fj.Get(json, "user.@valid").String()         // "true"

// ── String ───────────────────────────────────────────────────────────────────
fj.Get(json, "user.name.@uppercase").String()   // "ALICE"
fj.Get(json, "user.name.@reverse").String()     // "ecilA"
fj.Get(json, "user.name.@snakecase").String()   // "alice"
fj.Get(json, "user.city.@padLeft:{\"padding\":\"0\",\"length\":6}").String() // "000 NY"

// ── Object ───────────────────────────────────────────────────────────────────

// Project: keep only name and age, rename age → years
fj.Get(json, `user.@project:{"pick":["name","age"],"rename":{"age":"years"}}`).Raw()
// → {"name":"Alice","years":30}

// Default: fill in missing / null fields
fj.Get(json, `user.@default:{"role":"viewer","active":true}`).Raw()
// → {"name":"Alice","role":"viewer","age":30,"city":"NY","active":true}

// ── Array ────────────────────────────────────────────────────────────────────

// Filter: keep only active users
fj.Get(json, `users.@filter:{"key":"active","value":true}`).Raw()
// → [{"name":"Alice","active":true,...},{"name":"Carol","active":true,...}]

// Pluck: extract the city from every user's address
fj.Get(json, `users.@pluck:addr.city`).Raw()
// → ["NY","LA","NY"]

// Aggregation helpers
fj.Get(json, "scores.@first").Raw()    // 95
fj.Get(json, "scores.@last").Raw()     // 78
fj.Get(json, "scores.@count").Raw()    // 4
fj.Get(json, "scores.@sum").Raw()      // 352
fj.Get(json, "scores.@min").Raw()      // 78
fj.Get(json, "scores.@max").Raw()      // 95

// ── Coerce ───────────────────────────────────────────────────────────────────
fj.Get(`42`,   `@coerce:{"to":"string"}`).Raw()  // "42"
fj.Get(`"99"`, `@coerce:{"to":"number"}`).Raw()  // 99
fj.Get(`1`,    `@coerce:{"to":"bool"}`).Raw()    // true

Composing transformers

Transformers can be chained using the | pipe operator or dot notation:

// First filter the array, then count the remaining elements
fj.Get(json, `users.@filter:{"key":"active","value":true}|@count`).Raw()
// → 2

// Pluck names, then reverse the resulting array
fj.Get(json, `users.@pluck:name|@reverse`).Raw()
// → ["Carol","Bob","Alice"]

Complex real-world examples

The following scenarios demonstrate how to combine multiple transformers into a single expression to process realistic JSON payloads.


Example 1 — E-commerce product catalog: filter, aggregate, and shape

catalog := `{
    "products": [
        {"id":"p1","name":"Laptop Pro",    "category":"electronics","price":1299.99,"stock":5},
        {"id":"p2","name":"USB-C Hub",     "category":"electronics","price":49.99,  "stock":120},
        {"id":"p3","name":"Desk Chair",    "category":"furniture",  "price":349.00, "stock":0},
        {"id":"p4","name":"Standing Desk", "category":"furniture",  "price":699.00, "stock":3},
        {"id":"p5","name":"Webcam HD",     "category":"electronics","price":89.99,  "stock":45}
    ]
}`

// All in-stock electronics names
fj.Get(catalog, `products.@filter:{"key":"category","value":"electronics"}|@filter:{"key":"stock","op":"gt","value":0}|@pluck:name`).Raw()
// → ["Laptop Pro","USB-C Hub","Webcam HD"]

// Count of in-stock products
fj.Get(catalog, `products.@filter:{"key":"stock","op":"gt","value":0}|@count`).Raw()
// → 4

// Price range of in-stock products
fj.Get(catalog, `products.@filter:{"key":"stock","op":"gt","value":0}|@pluck:price|@min`).Raw()
// → 49.99
fj.Get(catalog, `products.@filter:{"key":"stock","op":"gt","value":0}|@pluck:price|@max`).Raw()
// → 1299.99

// Project the first in-stock product as a display card (pick and rename fields)
first := fj.Get(catalog, `products.@filter:{"key":"stock","op":"gt","value":0}|@first`).Raw()
fj.Get(first, `@project:{"pick":["name","price"],"rename":{"name":"title","price":"cost"}}`).Raw()
// → {"title":"Laptop Pro","cost":1299.99}

Example 2 — API response normalization: fill defaults then project and rename

// Raw user record from an external API with null / absent fields
rawUser := `{"id":"u1","name":"Alice","role":null,"verified":null}`

// One-shot normalization: fill nulls → keep only safe fields → rename id for the frontend
fj.Get(rawUser, `@default:{"role":"viewer","verified":false}|@project:{"pick":["id","name","role","verified"],"rename":{"id":"userId"}}`).Raw()
// → {"userId":"u1","name":"Alice","role":"viewer","verified":false}

Example 3 — Log processing: filter, count, and retrieve the latest entry

logs := `[
    {"level":"error","msg":"Connection refused","ts":1700001},
    {"level":"info", "msg":"Server started",    "ts":1700002},
    {"level":"error","msg":"Timeout exceeded",  "ts":1700003},
    {"level":"warn", "msg":"High memory",       "ts":1700004}
]`

// How many errors?
fj.Get(logs, `@filter:{"key":"level","value":"error"}|@count`).Raw()
// → 2

// All error messages
fj.Get(logs, `@filter:{"key":"level","value":"error"}|@pluck:msg`).Raw()
// → ["Connection refused","Timeout exceeded"]

// Most recent error entry (last in the filtered array)
fj.Get(logs, `@filter:{"key":"level","value":"error"}|@last`).Raw()
// → {"level":"error","msg":"Timeout exceeded","ts":1700003}

Example 4 — Nested data aggregation: filter → pluck → flatten → sum

teamData := `{
    "teams": [
        {"name":"Alpha","active":true, "monthly_revenue":[10000,12000,11000]},
        {"name":"Beta", "active":false,"monthly_revenue":[8000,9000,8500]},
        {"name":"Gamma","active":true, "monthly_revenue":[15000,16000,14000]}
    ]
}`

// Total revenue across all active teams, flattening the per-team monthly arrays first
fj.Get(teamData, `teams.@filter:{"key":"active","value":true}|@pluck:monthly_revenue|@flatten|@sum`).Raw()
// → 78000   (Alpha: 33000 + Gamma: 45000)

Example 5 — URL-slug generation from a display name

// Multi-word title with duplicate internal spaces → URL-safe kebab-case slug
fj.Get(`"My   Blog Post Title"`, `@trim|@lowercase|@kebabcase`).Raw()
// → "my-blog-post-title"

// Author name to lowercase slug
fj.Get(`"John Doe"`, `@lowercase|@replace:{"target":" ","replacement":"-"}`).Raw()
// → "john-doe"

Example 6 — Config merging and introspection

// Merge two partial config objects; later values overwrite earlier ones for duplicate keys
overrides := `[{"host":"localhost","port":5432},{"port":5433,"ssl":true}]`

merged := fj.Get(overrides, `@join`).Raw()
// → {"host":"localhost","port":5433,"ssl":true}

// Inspect which keys are present after the merge
fj.Get(merged, `@keys`).Raw()
// → ["host","port","ssl"]

// Count the merged keys
fj.Get(merged, `@count`).Raw()
// → 3

// Project only the connection-relevant subset and rename for the driver
fj.Get(merged, `@project:{"pick":["host","port"],"rename":{"port":"dbPort"}}`).Raw()
// → {"host":"localhost","dbPort":5433}

Example 7 — Leaderboard: zip parallel arrays, filter, and pluck

// Two parallel arrays zipped via @group into an array-of-objects, then filtered and plucked
leaderboard := `{"player":["Alice","Bob","Carol","Dave"],"score":[98,72,85,91]}`

// Zip the parallel arrays into objects
grouped := fj.Get(leaderboard, `@group`).Raw()
// → [{"player":"Alice","score":98},{"player":"Bob","score":72},
//    {"player":"Carol","score":85},{"player":"Dave","score":91}]

// Players with a score of 85 or above
fj.Get(grouped, `@filter:{"key":"score","op":"gte","value":85}|@pluck:player`).Raw()
// → ["Alice","Carol","Dave"]

// Top player's full record
fj.Get(grouped, `@filter:{"key":"score","op":"gte","value":95}|@first`).Raw()
// → {"player":"Alice","score":98}

Registering custom transformers

func init() {
    fj.AddTransformer("redact", fj.TransformerFunc(func(json, arg string) string {
        return `"[REDACTED]"`
    }))
}

// Usage in path
fj.Get(json, "user.password.@redact").String() // "[REDACTED]"

Transformers can be disabled globally with fj.DisableTransformers = true.

Search and Scan Helpers

// Full-tree substring search across all leaf values
hits := response.SearchJSONBody("admin")

// Wildcard scan of leaf values
hits = response.SearchJSONBodyMatch("err*")

// Find all values stored under specific key names
emails := response.SearchJSONBodyByKey("email")

// Find all values under keys matching a wildcard
hits = response.SearchJSONBodyByKeyPattern("user*")

// Substring / wildcard check at a specific path
response.JSONBodyContains("user.role", "admin")
response.JSONBodyContainsMatch("user.email", "*@example.com")

// Return the dot-notation path where a value first appears
path := response.FindJSONBodyPath("alice@example.com")

// All paths where value matches a pattern
paths := response.FindJSONBodyPathsMatch("err*")

Data Manipulation Helpers

import "github.com/sivaosorg/replify/pkg/fj"

// Count elements at a path
n := response.CountJSONBody("items")

// Filter array elements by predicate
active := response.FilterJSONBody("users", func(ctx fj.Context) bool {
    return ctx.Get("active").Bool()
})

// First match
admin := response.FirstJSONBody("users", func(ctx fj.Context) bool {
    return ctx.Get("role").String() == "admin"
})

// Deduplicate (first-occurrence order preserved)
tags := response.DistinctJSONBody("tags")

// Project fields from an array of objects
rows := response.PluckJSONBody("users", "id", "email")

// Group by a key field
byRole := response.GroupByJSONBody("users", "role")

// Sort array by a field (numeric or string comparison)
sorted := response.SortJSONBody("products", "price", true)

Limitations

  • Read-only: fj cannot write or modify JSON. Use encoding/json for serialization.
  • No schema validation: For strict schema enforcement use a dedicated library.
  • No struct binding: fj returns Context values, not typed Go structs. Use encoding/json when binding is required.
  • Raw() lifetime: The raw string returned by Context.Raw() is a zero-copy view into the source JSON string. It must not outlive the original string.
  • UnsafeBytes: The byte slice returned by fj.UnsafeBytes shares memory with the source string. Never mutate it, as this violates Go's string immutability guarantees and can cause undefined behavior.
  • Malformed input: fj does not validate JSON before parsing. Pass untrusted input through fj.IsValidJSON or ValidJSONBody() first.
  • Transformers are global: AddTransformer writes to a package-level registry. Register all transformers during program initialization (e.g., in init() functions) before concurrent access begins to avoid data races.

Best Practices

  1. Check existence before use

    if ctx := response.QueryJSONBody("optional.key"); ctx.Exists() {
        process(ctx.String())
    }
  2. Parse once, query many times

    doc := response.JSONBodyParser()
    id    := doc.Get("user.id").String()
    email := doc.Get("user.email").String()
    role  := doc.Get("user.role").String()
  3. Prefer GetBytes for byte-slice payloads

    // ✅ avoids string conversion allocation
    ctx := fj.GetBytes(rawBytes, "user.name")
    
    // ❌ unnecessary allocation
    ctx = fj.Get(string(rawBytes), "user.name")
  4. Validate untrusted input first

    if !response.ValidJSONBody() {
        return errors.New("invalid JSON body")
    }
  5. Register custom transformers in init()

    func init() {
        fj.AddTransformer("mask", func(json, arg string) string {
            return `"***"`
        })
    }
  6. Never mutate UnsafeBytes output

    b := fj.UnsafeBytes(someString)
    // ✅ read-only access
    _ = b[0]
    // ❌ mutating b corrupts the original string

Contributing

To contribute to this project, follow these steps:

  1. Clone the repository

    git clone --depth 1 https://github.com/sivaosorg/replify.git
  2. Navigate to the project directory

    cd replify
  3. Prepare the project environment

    go mod tidy
  4. Make your changes

    • Follow Go best practices
    • Add tests for new features
    • Update documentation
  5. Run tests

    go test ./...
  6. Submit a pull request

License

This project is licensed under the MIT License - see the LICENSE file for details.

Related Packages

Part of the replify ecosystem:

  • replify - API response wrapping library (this package)
  • conv - Type conversion utilities
  • coll - Type-safe collection utilities
  • common - Reflection-based utilities
  • encoding - JSON encoding utilities
  • hashy - Deterministic hashing
  • match - Wildcard pattern matching
  • msort - Map sorting utilities
  • randn - Random data generation
  • ref - Pointer utilities
  • strutil - String utilities
  • truncate - String truncation utilities

Support

Acknowledgments

Built with ❤️ for the Go community.

About

replify is a Go library designed to simplify and standardize API response wrapping for RESTful services. It leverages the Decorator Pattern to dynamically add error handling, metadata, pagination, and other response features in a clean and human-readable format.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages