Versionado de APIs en Go: URL, Headers y Content Negotiation - Estrategias Profesionales

Versionado de APIs en Go: URL, Headers y Content Negotiation - Estrategias Profesionales

Una guía exhaustiva sobre versionado de APIs: qué es, por qué es crítico, comparación de estrategias (URL, Header, Accept), implementación paso a paso en Go, ventajas/desventajas, y casos reales. Código práctico y verificable.

Por Omar Flores

Una de las decisiones más importantes que harás en tu carrera de backend es cómo versionar tus APIs. No es una pregunta técnica menor. Es una decisión arquitectónica que afecta cómo tu negocio puede evolucionar, cómo tus clientes pueden integrar con tu sistema, y cómo tu equipo puede mantener múltiples versiones en producción.

He visto equipos que eligieron mal, y años después estaban atrapados: no podían cambiar su API sin romper a millones de clientes, pero tampoco podían mantener un código base monolítico de múltiples versiones. Estaban paralizado.

He visto otros equipos que eligieron bien desde el inicio, y pudieron evolucionar su API de forma limpia, mantener múltiples versiones con bajo costo, y crecer sin dolor.

La diferencia no es suerte. Es entender las opciones, sus tradeoffs, y elegir la que funciona para tu situación específica.

Este artículo es una guía exhaustiva y práctica sobre versionado de APIs en Go. Explicaré qué es versionado, por qué importa, compararé las tres estrategias principales (URL, Header, Accept), mostraré cómo implementar cada una, y te daré el conocimiento para elegir sabiamente para tu proyecto.


Parte 1: Fundamentos del Versionado

¿Qué es versionado de API?

Versionado es la práctica de mantener múltiples versiones de tu API en producción simultáneamente, permitiendo que clientes antiguos sigan funcionando mientras nuevos clientes adoptan versiones más recientes.

Parece simple. La realidad es compleja.

Por qué es crítico

Imagina que tienes una API que devuelve datos de usuarios:

// API v1
{
  "id": 1,
  "name": "John",
  "email": "john@example.com"
}

Un día, tu equipo de producto dice: “Necesitamos devolver la fecha de creación también”. Fácil, ¿verdad?

// ¿Vamos a cambiar a esto?
{
  "id": 1,
  "name": "John",
  "email": "john@example.com",
  "created_at": "2025-01-15T10:30:00Z"
}

El problema: tienes 10,000 clientes usando tu API. Si cambias el response, algunos de ellos se rompen. No todos esperaban ese nuevo campo. Sus parsers pueden fallar. Sus tests fallan.

Ahora necesitas una forma de:

  1. Servir el response antiguo a clientes que lo esperan
  2. Servir el response nuevo a clientes que lo quieren
  3. Hacer transición de clientes antiguos al nuevo formato
  4. Mantener ambas versiones en producción

Esto es versionado.

El Costo Real del Versionado

Aquí viene la verdad incómoda: versionado de API cuesta dinero. No es gratis.

  • Mantienes más código
  • Tests más complejos
  • Más bugs potenciales
  • Complejidad operacional

El verdadero question es: ¿es el costo de versionado menor que el costo de romper a tus clientes?

Para la mayoría de APIs públicas: sí, definitivamente. El costo de romper a 10,000 clientes es astronómico.

Para APIs internas: depende. Si tienes control de todos los clientes y puedes actualizar el código, quizás no necesites versionado.


Parte 2: Comparación de Estrategias

Hay tres formas principales de versionar una API. Cada una tiene ventajas y desventajas profundas.

Estrategia 1: Versionado en URL

Concepto: La versión está en la ruta:

GET /v1/users/123
GET /v2/users/123

Ventajas:

Clarísimo: Miras la URL, ves la versión inmediatamente
Fácil de cachear: CDNs y proxies entienden naturalmente /v1/ vs /v2/
Fácil de documentar: Cada versión tiene su propia página
Fácil de monitorear: Puedes ver qué versión está siendo usada
Fácil de deprecar: Anuncias “v1 se elimina en 6 meses” y es obvio qué endpoint cambiar

Desventajas:

Duplicación de código: Necesitas routings separados para v1 y v2
Difícil de evolucionar dentro de versión: Si necesitas cambio menor en v1, ¿usas v1.1? Complejidad
Ugly URLs: Algunos puristas dicen que /v1/ es “ugliness”

Cuándo usarla:

  • APIs públicas que necesiten soportar muchos clientes
  • Cuando el cambio es grande (cambios de estructura, no solo campos)
  • Cuando necesites hacer deprecación ordenada y clara

Estrategia 2: Versionado en Header

Concepto: La versión está en un header HTTP:

GET /users/123
Header: API-Version: 1

vs

GET /users/123
Header: API-Version: 2

Ventajas:

URLs limpias: El endpoint es siempre /users/123
Menos duplicación: Rutas compartidas, lógica diferente dentro del handler
Flexible: Puedes cambiar versión sin actualizar URLs en clientes
Estándar HTTP: Los headers son exactamente para esto

Desventajas:

No es evidente: Tienes que leer headers para ver qué versión se está usando
Difícil de cachear: CDNs no entienden bien headers custom (aunque puedes configurar)
Menos visible: En logs, si no miras headers, no sabes qué versión se usó
Testing complicado: Necesitas recordar agregar el header en cada test

Cuándo usarla:

  • APIs internas o privadas
  • Cuando los cambios son menores
  • Cuando necesitas máxima flexibilidad

Estrategia 3: Content Negotiation (Accept Header)

Concepto: Usas el header Accept estándar:

GET /users/123
Accept: application/vnd.company.v1+json

vs

GET /users/123
Accept: application/vnd.company.v2+json

Ventajas:

Estándar REST puro: Esto es lo que sugieren los expertos
URLs limpas: Como Strategy 2
Profesional: Demuestra que entiendes HTTP
Flexible: Puedes versionear por media type, no solo versión

Desventajas:

Complejo de implementar: Parsear content negotiation no es trivial
Difícil de testear: Tienes que entender MIME types
Poco común en web: Muchos desarrolladores no lo entienden
Sobrequinería: Para muchas APIs, es overengineering

Cuándo usarla:

  • APIs que necesiten soportar múltiples formatos (JSON, XML, etc)
  • Cuando quieres ser puro REST
  • Cuando documentación y estándares son críticos

Parte 3: Implementación en Go - Estrategia 1 (URL)

Vamos a implementar versionado en URL. Es la más común y la que verás en la mayoría de APIs.

3.1 Setup Inicial

mkdir api-versioning
cd api-versioning
go mod init github.com/tuusuario/api-versioning

mkdir -p {cmd/api,internal/{domain,usecase,adapter/http}}

3.2 Definir el Dominio

Crea internal/domain/user.go:

package domain

import "time"

type User struct {
	ID        int       `json:"id"`
	Name      string    `json:"name"`
	Email     string    `json:"email"`
	CreatedAt time.Time `json:"created_at,omitempty"`
	UpdatedAt time.Time `json:"updated_at,omitempty"`
	Role      string    `json:"role,omitempty"`
}

// GetUserV1 es lo que devolvemos en v1 (sin timestamps ni rol)
type UserV1 struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

// GetUserV2 es lo que devolvemos en v2 (con timestamps)
type UserV2 struct {
	ID        int       `json:"id"`
	Name      string    `json:"name"`
	Email     string    `json:"email"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

// GetUserV3 es lo que devolvemos en v3 (con rol también)
type UserV3 struct {
	ID        int       `json:"id"`
	Name      string    `json:"name"`
	Email     string    `json:"email"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
	Role      string    `json:"role"`
}

// ToV1 convierte User a UserV1
func (u *User) ToV1() UserV1 {
	return UserV1{
		ID:    u.ID,
		Name:  u.Name,
		Email: u.Email,
	}
}

// ToV2 convierte User a UserV2
func (u *User) ToV2() UserV2 {
	return UserV2{
		ID:        u.ID,
		Name:      u.Name,
		Email:     u.Email,
		CreatedAt: u.CreatedAt,
		UpdatedAt: u.UpdatedAt,
	}
}

// ToV3 convierte User a UserV3
func (u *User) ToV3() UserV3 {
	return UserV3{
		ID:        u.ID,
		Name:      u.Name,
		Email:     u.Email,
		CreatedAt: u.CreatedAt,
		UpdatedAt: u.UpdatedAt,
		Role:      u.Role,
	}
}

¿Qué está pasando?

  • Tenemos una entidad interna User con TODOS los campos
  • Para cada versión, creamos un DTO (Data Transfer Object) específico
  • Métodos de conversión transforman la entidad interna al formato que cada versión espera

Esto es una práctica estándar en versionado: la base de datos y la lógica interna usan la “versión más nueva”, pero devolvemos lo que cada cliente espera.

3.3 Crear el Repository

Crea internal/adapter/http/repository.go:

package http

import (
	"sync"
	"time"

	"github.com/tuusuario/api-versioning/internal/domain"
)

type UserRepository struct {
	mu    sync.RWMutex
	users map[int]*domain.User
}

func NewUserRepository() *UserRepository {
	repo := &UserRepository{
		users: make(map[int]*domain.User),
	}

	// Seed con datos de ejemplo
	repo.users[1] = &domain.User{
		ID:        1,
		Name:      "Alice Johnson",
		Email:     "alice@example.com",
		CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC),
		UpdatedAt: time.Date(2025, 1, 10, 14, 20, 0, 0, time.UTC),
		Role:      "admin",
	}

	repo.users[2] = &domain.User{
		ID:        2,
		Name:      "Bob Smith",
		Email:     "bob@example.com",
		CreatedAt: time.Date(2024, 3, 20, 9, 15, 0, 0, time.UTC),
		UpdatedAt: time.Date(2025, 1, 5, 11, 45, 0, 0, time.UTC),
		Role:      "user",
	}

	repo.users[3] = &domain.User{
		ID:        3,
		Name:      "Carol White",
		Email:     "carol@example.com",
		CreatedAt: time.Date(2024, 6, 10, 13, 0, 0, 0, time.UTC),
		UpdatedAt: time.Date(2025, 1, 8, 16, 30, 0, 0, time.UTC),
		Role:      "moderator",
	}

	return repo
}

func (r *UserRepository) GetByID(id int) (*domain.User, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	user, exists := r.users[id]
	if !exists {
		return nil, ErrUserNotFound
	}

	return user, nil
}

func (r *UserRepository) GetAll() []*domain.User {
	r.mu.RLock()
	defer r.mu.RUnlock()

	users := make([]*domain.User, 0, len(r.users))
	for _, u := range r.users {
		users = append(users, u)
	}

	return users
}

var ErrUserNotFound = error(nil) // Reemplazar con error real

3.4 Handlers con Versionado en URL

Crea internal/adapter/http/handlers.go:

package http

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
	"strings"

	"github.com/tuusuario/api-versioning/internal/domain"
)

type APIResponse struct {
	Success bool        `json:"success"`
	Data    interface{} `json:"data,omitempty"`
	Error   string      `json:"error,omitempty"`
	Version string      `json:"version"`
}

type Handler struct {
	userRepo *UserRepository
}

func NewHandler(userRepo *UserRepository) *Handler {
	return &Handler{
		userRepo: userRepo,
	}
}

// GetUser maneja GET /v{version}/users/{id}
// El router extrae la versión y el ID del path
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request, version string, id int) {
	user, err := h.userRepo.GetByID(id)
	if err != nil {
		h.sendError(w, http.StatusNotFound, "User not found", version)
		return
	}

	// Aquí es donde el versionado importa:
	// Devolvemos diferentes respuestas según la versión solicitada

	switch version {
	case "v1":
		h.sendJSON(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    user.ToV1(),
			Version: "v1",
		})

	case "v2":
		h.sendJSON(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    user.ToV2(),
			Version: "v2",
		})

	case "v3":
		h.sendJSON(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    user.ToV3(),
			Version: "v3",
		})

	default:
		h.sendError(w, http.StatusBadRequest, fmt.Sprintf("Version %s not supported", version), version)
	}
}

// GetUsers maneja GET /v{version}/users
func (h *Handler) GetUsers(w http.ResponseWriter, r *http.Request, version string) {
	users := h.userRepo.GetAll()

	switch version {
	case "v1":
		v1Users := make([]domain.UserV1, len(users))
		for i, u := range users {
			v1Users[i] = u.ToV1()
		}
		h.sendJSON(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    v1Users,
			Version: "v1",
		})

	case "v2":
		v2Users := make([]domain.UserV2, len(users))
		for i, u := range users {
			v2Users[i] = u.ToV2()
		}
		h.sendJSON(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    v2Users,
			Version: "v2",
		})

	case "v3":
		v3Users := make([]domain.UserV3, len(users))
		for i, u := range users {
			v3Users[i] = u.ToV3()
		}
		h.sendJSON(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    v3Users,
			Version: "v3",
		})

	default:
		h.sendError(w, http.StatusBadRequest, fmt.Sprintf("Version %s not supported", version), version)
	}
}

// Helpers

func (h *Handler) sendJSON(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

func (h *Handler) sendError(w http.ResponseWriter, status int, message, version string) {
	h.sendJSON(w, status, APIResponse{
		Success: false,
		Error:   message,
		Version: version,
	})
}

3.5 Router Personalizado para Versiones

Crea internal/adapter/http/router.go:

package http

import (
	"net/http"
	"strconv"
	"strings"
)

type Router struct {
	handler *Handler
	mux     *http.ServeMux
}

func NewRouter(handler *Handler) *Router {
	r := &Router{
		handler: handler,
		mux:     http.NewServeMux(),
	}

	// Registra rutas con versiones
	r.mux.HandleFunc("GET /v{version}/users/{id}", r.handleGetUser)
	r.mux.HandleFunc("GET /v{version}/users", r.handleGetUsers)

	return r
}

// handleGetUser extrae versión e ID del path y llama al handler
func (r *Router) handleGetUser(w http.ResponseWriter, req *http.Request) {
	// Extrae versión del path: /v1/users/123 → "v1"
	version := req.PathValue("version")
	if version == "" {
		http.Error(w, "Version not provided", http.StatusBadRequest)
		return
	}

	// Extrae ID del path
	idStr := req.PathValue("id")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "Invalid user ID", http.StatusBadRequest)
		return
	}

	r.handler.GetUser(w, req, "v"+version, id)
}

// handleGetUsers extrae versión del path
func (r *Router) handleGetUsers(w http.ResponseWriter, req *http.Request) {
	version := req.PathValue("version")
	if version == "" {
		http.Error(w, "Version not provided", http.StatusBadRequest)
		return
	}

	r.handler.GetUsers(w, req, "v"+version)
}

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	r.mux.ServeHTTP(w, req)
}

3.6 Main: Ponerlo Todo Junto

Crea cmd/api/main.go:

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/tuusuario/api-versioning/internal/adapter/http"
)

func main() {
	// Inicializa el repositorio con datos de ejemplo
	userRepo := http.NewUserRepository()

	// Crea el handler
	handler := http.NewHandler(userRepo)

	// Crea el router con versionado
	router := http.NewRouter(handler)

	// Inicia el servidor
	port := ":8080"
	fmt.Printf("🚀 API versioning iniciada en http://localhost%s\n", port)
	fmt.Println("\nEjemplos de rutas:")
	fmt.Println("  v1: curl http://localhost:8080/v1/users/1")
	fmt.Println("  v2: curl http://localhost:8080/v2/users/1")
	fmt.Println("  v3: curl http://localhost:8080/v3/users/1")
	fmt.Println("  list v1: curl http://localhost:8080/v1/users")
	fmt.Println("  list v2: curl http://localhost:8080/v2/users")
	fmt.Println()

	if err := http.ListenAndServe(port, router); err != nil {
		log.Fatal(err)
	}
}

3.7 Prueba el Versionado

go run ./cmd/api

En otra terminal:

# v1: Sin timestamps ni rol
curl http://localhost:8080/v1/users/1 | jq .

# Deberías ver:
# {
#   "success": true,
#   "data": {
#     "id": 1,
#     "name": "Alice Johnson",
#     "email": "alice@example.com"
#   },
#   "version": "v1"
# }

# v2: Con timestamps
curl http://localhost:8080/v2/users/1 | jq .

# Deberías ver:
# {
#   "success": true,
#   "data": {
#     "id": 1,
#     "name": "Alice Johnson",
#     "email": "alice@example.com",
#     "created_at": "2024-01-15T10:30:00Z",
#     "updated_at": "2025-01-10T14:20:00Z"
#   },
#   "version": "v2"
# }

# v3: Con timestamps Y rol
curl http://localhost:8080/v3/users/1 | jq .

# Deberías ver:
# {
#   "success": true,
#   "data": {
#     "id": 1,
#     "name": "Alice Johnson",
#     "email": "alice@example.com",
#     "created_at": "2024-01-15T10:30:00Z",
#     "updated_at": "2025-01-10T14:20:00Z",
#     "role": "admin"
#   },
#   "version": "v3"
# }

El versionado en URL funciona.


Parte 4: Implementación - Estrategia 2 (Header)

Ahora vamos a mostrar cómo implementar el mismo sistema usando headers en lugar de URLs.

4.1 Middleware para Extracción de Versión

Crea internal/adapter/http/version_middleware.go:

package http

import (
	"context"
	"net/http"
)

// contextKeyVersion es una clave para almacenar versión en el contexto
type contextKey string

const contextKeyVersion contextKey = "api-version"

// VersionMiddleware extrae la versión del header API-Version
// y la almacena en el contexto de la request
func VersionMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Lee el header API-Version
		version := r.Header.Get("API-Version")
		if version == "" {
			// Default a v1 si no se especifica
			version = "v1"
		}

		// Valida que sea una versión soportada
		switch version {
		case "v1", "v2", "v3":
			// OK
		default:
			http.Error(w, "Unsupported API version: "+version, http.StatusBadRequest)
			return
		}

		// Almacena la versión en el contexto
		ctx := context.WithValue(r.Context(), contextKeyVersion, version)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// GetVersionFromContext extrae la versión del contexto
func GetVersionFromContext(r *http.Request) string {
	version, ok := r.Context().Value(contextKeyVersion).(string)
	if !ok {
		return "v1"
	}
	return version
}

4.2 Handlers Actualizados

Crea internal/adapter/http/handlers_header.go:

package http

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"

	"github.com/tuusuario/api-versioning/internal/domain"
)

type HandlerHeader struct {
	userRepo *UserRepository
}

func NewHandlerHeader(userRepo *UserRepository) *HandlerHeader {
	return &HandlerHeader{
		userRepo: userRepo,
	}
}

// GetUser maneja GET /users/{id}
// La versión viene del header API-Version
func (h *HandlerHeader) GetUser(w http.ResponseWriter, r *http.Request) {
	// Extrae versión del contexto (puesto ahí por el middleware)
	version := GetVersionFromContext(r)

	// Extrae ID del path
	idStr := r.PathValue("id")
	if idStr == "" {
		http.Error(w, "ID not provided", http.StatusBadRequest)
		return
	}

	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "Invalid user ID", http.StatusBadRequest)
		return
	}

	user, err := h.userRepo.GetByID(id)
	if err != nil {
		h.sendError(w, http.StatusNotFound, "User not found", version)
		return
	}

	// Devuelve según la versión
	switch version {
	case "v1":
		h.sendJSON(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    user.ToV1(),
			Version: "v1",
		})
	case "v2":
		h.sendJSON(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    user.ToV2(),
			Version: "v2",
		})
	case "v3":
		h.sendJSON(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    user.ToV3(),
			Version: "v3",
		})
	}
}

// GetUsers maneja GET /users
func (h *HandlerHeader) GetUsers(w http.ResponseWriter, r *http.Request) {
	version := GetVersionFromContext(r)
	users := h.userRepo.GetAll()

	switch version {
	case "v1":
		v1Users := make([]domain.UserV1, len(users))
		for i, u := range users {
			v1Users[i] = u.ToV1()
		}
		h.sendJSON(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    v1Users,
			Version: "v1",
		})
	case "v2":
		v2Users := make([]domain.UserV2, len(users))
		for i, u := range users {
			v2Users[i] = u.ToV2()
		}
		h.sendJSON(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    v2Users,
			Version: "v2",
		})
	case "v3":
		v3Users := make([]domain.UserV3, len(users))
		for i, u := range users {
			v3Users[i] = u.ToV3()
		}
		h.sendJSON(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    v3Users,
			Version: "v3",
		})
	}
}

func (h *HandlerHeader) sendJSON(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

func (h *HandlerHeader) sendError(w http.ResponseWriter, status int, message, version string) {
	h.sendJSON(w, status, APIResponse{
		Success: false,
		Error:   message,
		Version: version,
	})
}

4.3 Router con Middleware

Crea internal/adapter/http/router_header.go:

package http

import "net/http"

type RouterHeader struct {
	handler *HandlerHeader
	mux     *http.ServeMux
}

func NewRouterHeader(handler *HandlerHeader) *RouterHeader {
	r := &RouterHeader{
		handler: handler,
		mux:     http.NewServeMux(),
	}

	// Rutas simples sin versión en la URL
	r.mux.HandleFunc("GET /users/{id}", r.handler.GetUser)
	r.mux.HandleFunc("GET /users", r.handler.GetUsers)

	return r
}

func (r *RouterHeader) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	// Aplica el middleware de versión a TODAS las requests
	middleware := VersionMiddleware(r.mux)
	middleware.ServeHTTP(w, req)
}

4.4 Main Alternativo

Para probar con headers, crea cmd/api-header/main.go:

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/tuusuario/api-versioning/internal/adapter/http"
)

func main() {
	userRepo := http.NewUserRepository()
	handler := http.NewHandlerHeader(userRepo)
	router := http.NewRouterHeader(handler)

	port := ":8081"
	fmt.Printf("🚀 API con Header Versioning en http://localhost%s\n", port)
	fmt.Println("\nEjemplos:")
	fmt.Println("  v1: curl http://localhost:8081/users/1 -H 'API-Version: v1'")
	fmt.Println("  v2: curl http://localhost:8081/users/1 -H 'API-Version: v2'")
	fmt.Println("  v3: curl http://localhost:8081/users/1 -H 'API-Version: v3'")
	fmt.Println("  default (v1): curl http://localhost:8081/users/1")
	fmt.Println()

	if err := http.ListenAndServe(port, router); err != nil {
		log.Fatal(err)
	}
}

4.5 Prueba el Header Versioning

go run ./cmd/api-header/main.go

En otra terminal:

# Sin header (default a v1)
curl http://localhost:8081/users/1 | jq .

# v1 explícito
curl http://localhost:8081/users/1 -H "API-Version: v1" | jq .

# v2
curl http://localhost:8081/users/1 -H "API-Version: v2" | jq .

# v3
curl http://localhost:8081/users/1 -H "API-Version: v3" | jq .

# Versión no soportada
curl http://localhost:8081/users/1 -H "API-Version: v99"

Parte 5: Implementación - Estrategia 3 (Accept Header / Content Negotiation)

Finalmente, mostremos la forma “pura” de REST usando Content Negotiation.

5.1 Middleware de Content Negotiation

Crea internal/adapter/http/content_negotiation.go:

package http

import (
	"context"
	"net/http"
	"strings"
)

const contextKeyAPIVersion contextKey = "api-version"

// ContentNegotiationMiddleware parsea el header Accept
// para extraer la versión de la API
//
// Soporta formatos como:
// - application/vnd.myapi.v1+json
// - application/vnd.myapi.v2+json
// - application/json (default a v1)
func ContentNegotiationMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		version := parseAPIVersion(r.Header.Get("Accept"))

		// Valida versión
		switch version {
		case "v1", "v2", "v3":
			// OK
		default:
			http.Error(w, "Unsupported API version: "+version, http.StatusNotAcceptable)
			return
		}

		ctx := context.WithValue(r.Context(), contextKeyAPIVersion, version)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// parseAPIVersion extrae la versión del header Accept
func parseAPIVersion(acceptHeader string) string {
	if acceptHeader == "" {
		return "v1"
	}

	// Parsea el header Accept (puede tener múltiples valores)
	parts := strings.Split(acceptHeader, ",")
	for _, part := range parts {
		part = strings.TrimSpace(part)

		// Busca el patrón vnd.myapi.v{numero}+json
		if strings.Contains(part, "vnd.myapi") {
			// Extrae la versión
			if strings.Contains(part, "v1") {
				return "v1"
			}
			if strings.Contains(part, "v2") {
				return "v2"
			}
			if strings.Contains(part, "v3") {
				return "v3"
			}
		}

		// Si solo es application/json, default a v1
		if strings.HasPrefix(part, "application/json") {
			return "v1"
		}
	}

	return "v1"
}

// GetAPIVersionFromContext extrae la versión del contexto
func GetAPIVersionFromContext(r *http.Request) string {
	version, ok := r.Context().Value(contextKeyAPIVersion).(string)
	if !ok {
		return "v1"
	}
	return version
}

5.2 Handlers para Content Negotiation

Crea internal/adapter/http/handlers_content_neg.go:

package http

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"

	"github.com/tuusuario/api-versioning/internal/domain"
)

type HandlerContentNeg struct {
	userRepo *UserRepository
}

func NewHandlerContentNeg(userRepo *UserRepository) *HandlerContentNeg {
	return &HandlerContentNeg{
		userRepo: userRepo,
	}
}

// GetUser maneja GET /users/{id}
// La versión viene del header Accept
func (h *HandlerContentNeg) GetUser(w http.ResponseWriter, r *http.Request) {
	version := GetAPIVersionFromContext(r)

	idStr := r.PathValue("id")
	if idStr == "" {
		h.sendError(w, http.StatusBadRequest, "ID not provided", version)
		return
	}

	id, err := strconv.Atoi(idStr)
	if err != nil {
		h.sendError(w, http.StatusBadRequest, "Invalid user ID", version)
		return
	}

	user, err := h.userRepo.GetByID(id)
	if err != nil {
		h.sendError(w, http.StatusNotFound, "User not found", version)
		return
	}

	// Aquí usamos el header Accept para determinar qué devolver
	switch version {
	case "v1":
		h.sendJSONWithContentType(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    user.ToV1(),
			Version: "v1",
		}, "application/vnd.myapi.v1+json")

	case "v2":
		h.sendJSONWithContentType(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    user.ToV2(),
			Version: "v2",
		}, "application/vnd.myapi.v2+json")

	case "v3":
		h.sendJSONWithContentType(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    user.ToV3(),
			Version: "v3",
		}, "application/vnd.myapi.v3+json")
	}
}

// GetUsers maneja GET /users
func (h *HandlerContentNeg) GetUsers(w http.ResponseWriter, r *http.Request) {
	version := GetAPIVersionFromContext(r)
	users := h.userRepo.GetAll()

	switch version {
	case "v1":
		v1Users := make([]domain.UserV1, len(users))
		for i, u := range users {
			v1Users[i] = u.ToV1()
		}
		h.sendJSONWithContentType(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    v1Users,
			Version: "v1",
		}, "application/vnd.myapi.v1+json")

	case "v2":
		v2Users := make([]domain.UserV2, len(users))
		for i, u := range users {
			v2Users[i] = u.ToV2()
		}
		h.sendJSONWithContentType(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    v2Users,
			Version: "v2",
		}, "application/vnd.myapi.v2+json")

	case "v3":
		v3Users := make([]domain.UserV3, len(users))
		for i, u := range users {
			v3Users[i] = u.ToV3()
		}
		h.sendJSONWithContentType(w, http.StatusOK, APIResponse{
			Success: true,
			Data:    v3Users,
			Version: "v3",
		}, "application/vnd.myapi.v3+json")
	}
}

func (h *HandlerContentNeg) sendJSONWithContentType(w http.ResponseWriter, status int, data interface{}, contentType string) {
	w.Header().Set("Content-Type", contentType)
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

func (h *HandlerContentNeg) sendError(w http.ResponseWriter, status int, message, version string) {
	contentType := fmt.Sprintf("application/vnd.myapi.%s+json", version)
	h.sendJSONWithContentType(w, status, APIResponse{
		Success: false,
		Error:   message,
		Version: version,
	}, contentType)
}

5.3 Router y Main

Crea cmd/api-content-neg/main.go:

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/tuusuario/api-versioning/internal/adapter/http"
)

func main() {
	userRepo := http.NewUserRepository()
	handler := http.NewHandlerContentNeg(userRepo)

	// Router simple
	mux := http.NewServeMux()
	mux.HandleFunc("GET /users/{id}", handler.GetUser)
	mux.HandleFunc("GET /users", handler.GetUsers)

	// Envuelve con content negotiation middleware
	server := http.ContentNegotiationMiddleware(mux)

	port := ":8082"
	fmt.Printf("🚀 API con Content Negotiation en http://localhost%s\n", port)
	fmt.Println("\nEjemplos:")
	fmt.Println("  v1: curl http://localhost:8082/users/1 -H 'Accept: application/vnd.myapi.v1+json'")
	fmt.Println("  v2: curl http://localhost:8082/users/1 -H 'Accept: application/vnd.myapi.v2+json'")
	fmt.Println("  v3: curl http://localhost:8082/users/1 -H 'Accept: application/vnd.myapi.v3+json'")
	fmt.Println("  default: curl http://localhost:8082/users/1 -H 'Accept: application/json'")
	fmt.Println()

	if err := http.ListenAndServe(port, server); err != nil {
		log.Fatal(err)
	}
}

5.4 Prueba

go run ./cmd/api-content-neg/main.go

En otra terminal:

# v1 con content negotiation
curl http://localhost:8082/users/1 \
  -H "Accept: application/vnd.myapi.v1+json" | jq .

# v2
curl http://localhost:8082/users/1 \
  -H "Accept: application/vnd.myapi.v2+json" | jq .

# v3
curl http://localhost:8082/users/1 \
  -H "Accept: application/vnd.myapi.v3+json" | jq .

# Default (v1) con application/json
curl http://localhost:8082/users/1 \
  -H "Accept: application/json" | jq .

Parte 6: Comparación en Acción

Ahora que hemos implementado las tres estrategias, comparemos:

6.1 Claridad de Versión

URL Versioning:

curl http://localhost:8080/v2/users/1
# Obvio: estoy usando v2

Header Versioning:

curl http://localhost:8081/users/1 -H "API-Version: v2"
# Tengo que leer el header para saber que es v2

Content Negotiation:

curl http://localhost:8082/users/1 -H "Accept: application/vnd.myapi.v2+json"
# Tengo que entender MIME types

Ganador: URL Versioning - Es inmediatamente evidente.

6.2 Caching y CDN

URL Versioning:

GET /v1/users/1 → CDN cachea como /v1/users/1
GET /v2/users/1 → CDN cachea como /v2/users/1

CDNs entienden automáticamente que son recursos diferentes.

Header Versioning:

GET /users/1 + Header: API-Version: v1
GET /users/1 + Header: API-Version: v2

El mismo URL con diferentes headers. CDNs no entienden esto por defecto (necesitas configurar Vary headers).

Content Negotiation:

GET /users/1 + Accept: application/vnd.myapi.v1+json
GET /users/1 + Accept: application/vnd.myapi.v2+json

Similar problema al header versioning.

Ganador: URL Versioning - Caching automático.

6.3 Flexibilidad Dentro de Versión

URL Versioning:

Si necesitas un cambio menor en v2 (digamos, agregar un campo optional), ¿qué haces?

  • Opción A: Cambias v2 (rompes clientes)
  • Opción B: Creas v2.1 (complejidad)
  • Opción C: Tienes que pensar bien antes de lanzar v2

Header Versioning:

API-Version: v2.1
API-Version: v2.1.2

Puedes tener subversiones. Flexible.

Content Negotiation:

Similar a header versioning.

Ganador: Header/Content Negotiation - Más flexible para cambios menores.

6.4 Estándares REST

Si sigues estrictamente la especificación REST, el que mejor se adhiere es Content Negotiation, porque usa mechanisms estándar de HTTP (Accept headers y MIME types).

Pero la realidad: la mayoría de la industria usa URL versioning. Es pragmático.


Parte 7: Mejores Prácticas de Versionado

7.1 Deprecation Strategy

Cuando lanzas una versión, necesitas un plan para deprecar la antigua:

Año 1:
  - v1 en producción y soportada
  - v2 lanzada

Año 1.5:
  - v1 deprecated (anunciado)
  - v2 en producción y soportada

Año 2:
  - v1 removida
  - v2 en producción y soportada
  - v3 lanzada

Política sugerida:

  • Soporta versión anterior + actual
  • Anuncia deprecation 6 meses antes de remover
  • Proporciona herramientas de migración
  • Registra qué cliente usa qué versión (para contactarlos)

7.2 Versionado Semántico

Si usas URL versioning con números: /v1/, /v2/, considera usar semántico:

  • v1.0 → cambio incompatible
  • v1.1 → cambio compatible (nuevo campo)
  • v1.1.2 → bug fix

Go usa esto con módulos (github.com/user/lib v1.2.3).

7.3 Testing Multi-Versión

Tus tests deben cubrir TODAS las versiones soportadas:

func TestGetUserAllVersions(t *testing.T) {
	tests := []struct {
		version string
		expected interface{}
	}{
		{"v1", UserV1{...}},
		{"v2", UserV2{...}},
		{"v3", UserV3{...}},
	}

	for _, tt := range tests {
		t.Run(tt.version, func(t *testing.T) {
			// Test para esa versión
		})
	}
}

7.4 Logging de Versión

Siempre registra qué versión se usó:

log.Printf("Request to %s version=%s user_id=%d", r.URL.Path, version, userID)

Así puedes:

  • Ver qué versión está siendo usada
  • Saber cuándo deprecar (cuando v1 se usa casi nunca)
  • Debugging de issues específicos de versión

7.5 Documentación Clara

Para CADA versión, documenta:

## API v1 (DEPRECATED)

Use v2 instead. v1 will be removed on 2026-01-01.

## API v2 (CURRENT)

Current recommended version. Full support.

### Endpoints

- GET /v2/users/{id}
- POST /v2/users
- ...

### Response Format

{
"id": number,
"name": string,
"email": string
}

## API v3 (BETA)

New version. Not recommended for production yet.

Conclusión: Cuál Elegir

EstrategiaCuándo UsarlaEjemplo
URLAPIs públicas, cambios grandes, caching importanteStripe, GitHub, Twitter
HeaderAPIs internas, cambios frecuentesAlgunas APIs empresariales
Content NegotiationAPIs complejas con múltiples formatosAlgunos services de Google

Mi recomendación personal: Para el 90% de APIs, URL versioning es la mejor opción. Es clara, cacheable, fácil de entender, y es lo que usa la industria.

Úsalo. Tus usuarios (y tu equipo futuro) lo agradecerán.

Tags

#golang #api-design #versioning #rest #production