Personalización de JSON con MarshalJSON y UnmarshalJSON

Cuando trabajas con APIs públicas, a menudo te encuentras con una discrepancia entre cómo quieres representar los datos en tu lógica de negocio y cómo los exige el cliente. Quizás quieres que un estado sea un int para que las comparaciones sean rápidas y eficientes, pero el cliente espera un string como "active" o "pending". O quizás necesitas que una fecha se serialice en un formato DD-MM-YYYY muy específico que no es el estándar ISO 8601.

Para resolver esto, Go nos permite implementar las interfaces json.Marshaler y json.Unmarshaler mediante los métodos MarshalJSON y UnmarshalJSON. Cuando utilizas el paquete encoding/json, el motor de reflexión de Go verifica si el tipo que estás intentando serializar o deserializar implementa estos métodos. Si es así, el paquete delega la responsabilidad a tu implementación personalizada en lugar de intentar usar su lógica por defecto.

Esta es una herramienta de diseño fundamental para desacoplar tu modelo de dominio del contrato de la API. Si no controlas esto, cualquier cambio en tu estructura interna de datos (como cambiar un campo de int a un struct para añadir metadatos) rompería el contrato con todos tus clientes. Sin embargo, si implementas esto incorrectamente, puedes crear bucles infinitos que colapsen la ejecución de tu servicio por un desbordamiento de pila (stack overflow).

Para usar esto de forma segura, debes entender el concepto de tipo alias. Al crear un tipo derivado (ej. type Alias T), heredas la estructura de memoria del tipo original pero no heredas sus métodos. Esto es la clave para romper la recursión infinita al deserializar.

package main

import (
	"encoding/json"
	"fmt"
	"strings"
	"time"
)

// Status representa un enumerado interno usando int.
// En la API, lo queremos como un string legible.
type Status int

const (
	Unknown Status = iota
	Active
	Inactive
)

// MarshalJSON convierte el entero interno en un string para el JSON.
func (s Status) MarshalJSON() ([]byte, error) {
	var sStr string
	switch s {
	case Active:
		sStr = "active"
	case Inactive:
		sStr = "inactive"
	default:
		sStr = "unknown"
	}
	// Envolvemos el string en comillas para que sea un JSON válido.
	return json.Marshal(sStr)
}

// UnmarshalJSON convierte el string del JSON de vuelta a nuestro tipo Status.
func (s *Status) UnmarshalJSON(data []byte) error {
	// El truco: definimos un Alias para evitar la recursión infinita.
	// 'Alias' tiene el mismo layout que Status, pero NO tiene los métodos de Status.
	type Alias Status
	var aux Alias

	if err := json.Unmarshal(data, &aux); err != nil {
		return err
	}

	// Convertimos el valor deserializado al tipo original.
	switch Status(aux) {
	case 0: // Si el valor era el string "unknown" o un entero
		*s = Unknown
	case 1:
		*s = Active
	case 2:
		*s = Inactive
	default:
		*s = Unknown
	}
	// En este caso particular, para simplificar la lógica de strings, 
	// es más robusto parsear el string directamente.
	return nil
}

// Nota: La implementación anterior de UnmarshalJSON es conceptual. 
// Vamos a hacerla robusta tratando el dato como un string real.
func (s *Status) UnmarshalJSON(data []byte) error {
	var sStr string
	if err := json.Unmarshal(data, &sStr); err != nil {
		return err
	}

	switch sStr {
	case "active":
		*s = Active
	case "inactive":
		*s = Inactive
	default:
		*s = Unknown
	}
	return nil
}

// CustomTime es un wrapper sobre time.Time para usar un formato específico.
type CustomTime struct {
	time.Time
}

const customLayout = "2006-01-02" // Formato DD-MM-YYYY

func (ct CustomTime) MarshalJSON() ([]byte, error) {
	// Serializamos la fecha como un string con formato personalizado.
	return []byte(fmt.Sprintf("\"%s\"", ct.Format(customLayout))), nil
}

func (ct *CustomTime) UnmarshalJSON(data []byte) error {
	// Eliminamos las comillas del JSON para obtener el string puro.
	s := strings.Trim(string(data), "\"")
	t, err := time.Parse(customLayout, s)
	if err != nil {
		return err
	}
	ct.Time = t
	return nil
}

type User struct {
	ID        int         `json:"id"`
	Username  string      `json:"username"`
	Status    Status      `json:"status"`
	CreatedAt CustomTime  `json:"created_at"`
}

func main() {
	// 1. Ejemplo de Serialización (Marshal)
	user := User{
		ID:        42,
		Username:  "gopher_pro",
		Status:    Active,
		CreatedAt: CustomTime{time.Date(2023, 10, 27, 0, 0, 0, 0, time.UTC)},
	}

	jsonData, err := json.MarshalIndent(user, "", "  ")
	if err != nil {
		panic(err)
	}

	fmt.Printf("JSON de salida:\n%s\n\n", string(jsonData))

	// 2. Ejemplo de Deserialización (Unmarshal)
	inputJSON := `{"id": 99, "username": "dev_ops", "status": "inactive", "created_at": "2024-12-31"}`
	var decodedUser User
	if err := json.Unmarshal([]byte(inputJSON), &decodedUser); err != nil {
		panic(err)
	}

	fmt.Printf("Objeto decodificado:\n%+v\n", decodedUser)
	fmt.Printf("Status interno: %d\n", decodedUser.Status)
	fmt.Printf("Fecha parseada: %s\n", decodedUser.CreatedAt.Format("02 Jan 2006"))
}

Análisis del código

En el ejemplo, hemos resuelto dos problemas típicos de la industria.

Primero, el tipo Status utiliza un int internamente para optimizar comparaciones y ahorro de memoria, pero el método MarshalJSON transforma ese entero en un string legible mediante un switch. En UnmarshalJSON, el desafío es que, si llamamos a json.Unmarshal(data, s) directamente, el compilador entrará en un bucle infinito porque s es de tipo Status y Unmarshal volverá a buscar el método UnmarshalJSON. La solución es parsear el dato directamente como un string temporal o usar un tipo base, evitando llamar al método que estamos definiendo.

Segundo, el tipo CustomTime encapsula time.Time. Al implementar MarshalJSON, forzamos el formato 2006-01-02 mediante Format. Al deserializar, usamos time.Parse para convertir ese string específico de vuelta a un objeto time.Time real. Fíjate que en UnmarshalJSON operamos sobre ct.Time para asignar el valor al campo embebido.

El error frecuente

El error más peligroso al implementar estas interfaces es la recursión infinita. Si tu implementación de UnmarshalJSON se ve así:

// ESTO CAUSARÁ UN STACK OVERFLOW
func (s *Status) UnmarshalJSON(data []byte) error {
    return json.Unmarshal(data, s) 
}

Al llamar a json.Unmarshal, el motor de JSON mira el tipo s, ve que implementa la interfaz Unmarshaler y vuelve a llamar a tu método. Esto se repite hasta que la memoria de la pila se agota y el programa colapsa. La solución siempre es convertir el puntero a un tipo “limpio” (un alias o un tipo base) que no tenga el método implementado para que la recursión se detenga.

120

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio