Tutoriales

Diseño de una API de color: Endpoints REST para datos de color

10 min de lectura

Una API de color parece un nicho hasta que piensas en cuántas herramientas consumen datos de color: herramientas de diseño que necesitan conversión, verificadores de accesibilidad que validan el contraste, componentes de selector de color que necesitan búsqueda de colores con nombre, y generadores de paletas que producen armonías. Si estás construyendo alguna de estas, o proporcionando datos de color a otros desarrolladores, necesitas una API REST bien diseñada.

Esta guía cubre toda la superficie de diseño: decisiones de esquema, estructura de endpoints, formatos de respuesta, manejo de errores y limitación de velocidad — desde la perspectiva de alguien que ha construido los endpoints que impulsan el Convertidor de Color y el Verificador de Contraste en ColorFYI.


Diseño del esquema API para datos de color

El objeto de color principal

Un color tiene múltiples representaciones — hex, RGB, HSL, CMYK, OKLCH — y múltiples campos de metadatos. El objeto de respuesta canónico debe incluirlos todos, permitiendo a los clientes usar el formato que necesiten sin realizar solicitudes adicionales:

{
  "hex": "#FF5733",
  "rgb": {
    "r": 255,
    "g": 87,
    "b": 51,
    "css": "rgb(255, 87, 51)"
  },
  "hsl": {
    "h": 11,
    "s": 100,
    "l": 60,
    "css": "hsl(11, 100%, 60%)"
  },
  "hsv": {
    "h": 11,
    "s": 80,
    "v": 100
  },
  "cmyk": {
    "c": 0,
    "m": 66,
    "y": 80,
    "k": 0
  },
  "oklch": {
    "l": 0.63,
    "c": 0.19,
    "h": 27.5,
    "css": "oklch(0.63 0.19 27.5)"
  },
  "luminance": 0.215,
  "is_dark": false,
  "name": "Coral Red",
  "name_source": "color_name_database",
  "name_distance": 0.04
}

Incluir is_dark (derivado de la luminancia) evita que los clientes implementen el cálculo de luminancia WCAG por su cuenta. Incluir name y name_distance permite a los clientes mostrar el color con nombre más cercano sin una llamada API adicional.

Convenciones de nomenclatura

Usa snake_case para las claves JSON (consistente con la mayoría de las APIs REST). Anida los campos relacionados en objetos (rgb, hsl, cmyk) en lugar de aplanarlos (rgb_r, rgb_g, rgb_b). Los objetos anidados son más legibles, más fáciles de desestructurar en JavaScript y se mapean limpiamente a interfaces tipadas:

interface ColorResponse {
  hex: string;
  rgb: { r: number; g: number; b: number; css: string };
  hsl: { h: number; s: number; l: number; css: string };
  oklch: { l: number; c: number; h: number; css: string };
  luminance: number;
  is_dark: boolean;
  name: string | null;
  name_source: string | null;
  name_distance: number | null;
}

Normalización de hex

Acepta colores hex en múltiples formatos — con o sin #, de 3 o 6 dígitos, en mayúsculas o minúsculas — y normalízalos en la capa de la API antes de cualquier procesamiento:

# Ejemplo Python/Django
import re

def normalize_hex(raw: str) -> str:
    """Normaliza la entrada hex a 6 dígitos en mayúsculas sin #."""
    clean = raw.strip().lstrip('#').upper()
    if not re.match(r'^[0-9A-F]{3}$|^[0-9A-F]{6}$', clean):
        raise ValueError(f"Invalid hex color: {raw!r}")
    # Expande de 3 dígitos a 6 dígitos
    if len(clean) == 3:
        clean = ''.join(c * 2 for c in clean)
    return clean

Devuelve la forma normalizada en la respuesta — esto elimina la ambigüedad y hace el almacenamiento en caché posterior más predecible.


Endpoints de conversión Hex/RGB/HSL

GET /api/color/{hex}

El endpoint primario devuelve el objeto de color completo para un código hex dado. Esto es lo que usa el Convertidor de Color para poblar todos los campos de formato simultáneamente:

GET /api/color/FF5733
GET /api/color/%23FF5733   (# codificado en URL)
GET /api/color/f57          (3 dígitos, minúsculas)

Los tres deberían devolver la misma respuesta para #FF5733.

Respuesta:

{
  "hex": "#FF5733",
  "rgb": { "r": 255, "g": 87, "b": 51, "css": "rgb(255, 87, 51)" },
  "hsl": { "h": 11, "s": 100, "l": 60, "css": "hsl(11, 100%, 60%)" },
  "cmyk": { "c": 0, "m": 66, "y": 80, "k": 0 },
  "oklch": { "l": 0.63, "c": 0.19, "h": 27.5, "css": "oklch(0.63 0.19 27.5)" },
  "luminance": 0.215,
  "is_dark": false,
  "name": "Coral Red",
  "name_source": "css_extended",
  "name_distance": 0.08
}

POST /api/convert

Para convertir entradas arbitrarias que pueden no ser hex — nombres de color CSS, triples RGB, valores HSL:

POST /api/convert
Content-Type: application/json

{
  "input": "rgb(255, 87, 51)",
  "from": "rgb"
}

Respuesta: El mismo objeto de color completo.

Acepta valores from de: hex, rgb, hsl, cmyk, oklch, name. Valida que el formato de entrada coincida con el tipo from declarado y devuelve un error 422 con un mensaje claro en caso de discrepancia.

Ejemplo de ViewSet en Django

# apps/api/views.py
from django.http import JsonResponse
from django.views import View
from apps.colors.engine import ColorEngine

class ColorDetailView(View):
    def get(self, request, hex_code: str):
        try:
            normalized = normalize_hex(hex_code)
        except ValueError as e:
            return JsonResponse(
                {"error": "invalid_hex", "message": str(e)},
                status=422
            )

        engine = ColorEngine(normalized)
        data = {
            "hex": f"#{normalized}",
            "rgb": engine.to_rgb_dict(),
            "hsl": engine.to_hsl_dict(),
            "cmyk": engine.to_cmyk_dict(),
            "oklch": engine.to_oklch_dict(),
            "luminance": engine.relative_luminance(),
            "is_dark": engine.is_dark(),
            "name": engine.nearest_name(),
            "name_source": engine.nearest_name_source(),
            "name_distance": engine.nearest_name_distance(),
        }
        response = JsonResponse(data)
        response["Cache-Control"] = "public, max-age=86400, stale-while-revalidate=604800"
        return response

Almacena en caché los datos de color de forma agresiva — un código hex dado siempre produce el mismo resultado, por lo que la caché puede tener una vida muy larga.


API de búsqueda y autocompletado de color

GET /api/search

La búsqueda de color con nombre impulsa el autocompletado en selectores de color y campos de búsqueda. El parámetro de consulta es un nombre de color parcial:

GET /api/search?q=coral
GET /api/search?q=teal&limit=5
GET /api/search?q=%230d&source=css   // Buscar por prefijo hex

Respuesta:

{
  "results": [
    {
      "hex": "#FF7F50",
      "name": "Coral",
      "source": "css_named",
      "score": 1.0
    },
    {
      "hex": "#FF6B6B",
      "name": "Light Coral",
      "source": "css_extended",
      "score": 0.85
    },
    {
      "hex": "#E8735A",
      "name": "Terra Cotta",
      "source": "crayola",
      "score": 0.62
    }
  ],
  "total": 3,
  "query": "coral"
}

Consideraciones de implementación de búsqueda

Para una base de datos de colores con nombre pequeña (~2,000 entradas), una búsqueda de prefijos en memoria con coincidencia difusa es suficientemente rápida:

from difflib import SequenceMatcher

def search_colors(query: str, limit: int = 10) -> list[dict]:
    q = query.lower().strip()
    results = []

    for color in NAMED_COLORS:  # Lista precargada
        name = color["name"].lower()
        if q in name:
            # Coincidencia exacta de subcadena: puntaje basado en posición
            pos = name.index(q)
            score = 1.0 - (pos / len(name)) * 0.3
        else:
            # Coincidencia difusa
            score = SequenceMatcher(None, q, name).ratio()
            if score < 0.5:
                continue

        results.append({**color, "score": round(score, 3)})

    results.sort(key=lambda x: x["score"], reverse=True)
    return results[:limit]

Para bases de datos de más de 10,000 entradas, usa la extensión pg_trgm de PostgreSQL con indexación de trigramas para búsqueda difusa de menos de un milisegundo:

-- Habilitar extensión
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- Agregar índice
CREATE INDEX idx_color_name_trgm ON named_colors USING gin(name gin_trgm_ops);

-- Consulta
SELECT hex, name, source, similarity(name, 'coral') AS score
FROM named_colors
WHERE name % 'coral'
ORDER BY score DESC
LIMIT 10;

Optimización del autocompletado

Para un autocompletado de búsqueda en tiempo real mientras se escribe, optimiza para baja latencia:

  1. Debounce en el cliente — mínimo 150ms antes de disparar la solicitud.
  2. Establece una longitud mínima de consulta — rechaza consultas de menos de 2 caracteres (devuelve resultados vacíos inmediatamente).
  3. Almacena en caché los resultados por consulta — la misma consulta de diferentes usuarios devuelve el mismo resultado. Usa Cache-Control: public, max-age=3600.
  4. Responde rápidamente cuando no hay resultados — responde inmediatamente con {"results": [], "total": 0} en lugar de ejecutar una búsqueda difusa completa.

Endpoint de verificación de contraste

GET /api/contrast

La API de contraste es el motor detrás del Verificador de Contraste. Toma dos códigos hex y devuelve la relación de contraste WCAG con el estado de aprobación/fallo para cada nivel:

GET /api/contrast?fg=FF5733&bg=FFFFFF
GET /api/contrast?fg=000000&bg=FFFFFF

Respuesta:

{
  "ratio": 3.02,
  "ratio_formatted": "3.02:1",
  "foreground": {
    "hex": "#FF5733",
    "luminance": 0.215
  },
  "background": {
    "hex": "#FFFFFF",
    "luminance": 1.0
  },
  "wcag": {
    "aa_normal": false,
    "aa_large": true,
    "aaa_normal": false,
    "aaa_large": false
  },
  "level": "AA Large",
  "passes": true
}

El campo level devuelve el nivel WCAG más alto que logra el par ("AAA", "AA", "AA Large" o "Fail"). El campo passes es true si se cumple cualquier nivel WCAG — útil para un indicador verde/rojo simple.

Cálculo de contraste

def relative_luminance(hex_code: str) -> float:
    r, g, b = hex_to_rgb(hex_code)
    def linearize(v: int) -> float:
        s = v / 255
        return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
    return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)

def contrast_ratio(hex1: str, hex2: str) -> float:
    L1 = relative_luminance(hex1)
    L2 = relative_luminance(hex2)
    lighter = max(L1, L2)
    darker = min(L1, L2)
    return round((lighter + 0.05) / (darker + 0.05), 2)

def wcag_result(ratio: float) -> dict:
    return {
        "aa_normal": ratio >= 4.5,
        "aa_large": ratio >= 3.0,
        "aaa_normal": ratio >= 7.0,
        "aaa_large": ratio >= 4.5,
    }

Sugerencia de alternativas accesibles

Una extensión útil: dado un color de primer plano que falla el contraste contra el fondo, sugiere el ajuste mínimo de luminosidad que logra WCAG AA:

def suggest_accessible_foreground(fg_hex: str, bg_hex: str, target_ratio: float = 4.5) -> str:
    """Devuelve una versión con luminosidad ajustada de fg_hex que pasa target_ratio."""
    hsl = rgb_to_hsl(*hex_to_rgb(fg_hex))
    bg_lum = relative_luminance(bg_hex)
    is_dark_bg = bg_lum < 0.179

    # Intenta ajustar la luminosidad en la dirección que aumenta el contraste
    step = -2 if is_dark_bg else 2  # Oscurecer en fondo claro, aclarar en fondo oscuro
    current_l = hsl[2]

    for _ in range(50):
        current_l = max(0, min(100, current_l + step))
        adjusted_hex = hsl_to_hex(hsl[0], hsl[1], current_l)
        ratio = contrast_ratio(adjusted_hex, bg_hex)
        if ratio >= target_ratio:
            return adjusted_hex

    # Devuelve el máximo contraste si el objetivo no es alcanzable
    return '#000000' if not is_dark_bg else '#FFFFFF'

Limitación de velocidad para APIs de color

Por qué las APIs de color necesitan limitación de velocidad

Las APIs de color pueden parecer de bajo riesgo, pero pueden ser objetivo de: - Ataques de enumeración: Iterar a través de los 16.7 millones de códigos hex para extraer la base de datos completa de nombres de color. - Scraping: Usar tu lógica de conversión como un recurso computacional gratuito. - Abuso: Solicitudes de alto volumen desde un único cliente que degradan el servicio para otros.

Estrategia de implementación

Para una API de Django, django-ratelimit proporciona limitación de velocidad basada en decoradores con backend de Redis:

# settings.py
RATELIMIT_USE_CACHE = 'default'  # Usa el backend de caché de Django (Redis recomendado)

# views.py
from django_ratelimit.decorators import ratelimit

@ratelimit(key='ip', rate='100/m', method='GET', block=True)
def color_detail_view(request, hex_code: str):
    ...

Formato de respuesta de límite de velocidad

Cuando se excede un límite de velocidad, devuelve 429 Too Many Requests con un cuerpo de respuesta claro y un encabezado Retry-After:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1708012345
Content-Type: application/json

{
  "error": "rate_limit_exceeded",
  "message": "Has superado el límite de velocidad de 100 solicitudes por minuto.",
  "retry_after": 60
}

Límites de velocidad escalonados

Una estructura escalonada razonable:

Nivel Límite Clave
Anónimo 60 solicitudes / minuto Dirección IP
Clave API (gratis) 300 solicitudes / minuto Clave API
Clave API (pago) 3000 solicitudes / minuto Clave API

Las claves API se pasan como Authorization: Bearer {clave} o X-API-Key: {clave}. Usa la clave API como clave de límite de velocidad para solicitudes autenticadas:

@ratelimit(key='header:x-api-key', rate='300/m', method='GET')
@ratelimit(key='ip', rate='60/m', method='GET')
def color_detail_view(request, hex_code: str):
    # django-ratelimit verifica ambos; el límite más flexible gana para solicitudes autenticadas
    ...

Mejores prácticas para formatos de respuesta

Esquema de error consistente

Cada respuesta de error — fallo de validación, límite de velocidad, no encontrado — debe seguir el mismo esquema:

{
  "error": "invalid_hex",
  "message": "El valor 'gggggg' no es un color hex válido. Se esperaba una cadena hex de 3 o 6 dígitos.",
  "field": "hex_code",
  "value": "gggggg"
}
Campo Propósito
error Código de error legible por máquina (snake_case)
message Explicación legible por humanos
field Qué campo de entrada causó el error (para errores de validación)
value El valor inválido (ayuda en la depuración)

Paginación para endpoints de colección

El endpoint de búsqueda con limit y offset:

{
  "results": [...],
  "pagination": {
    "total": 47,
    "limit": 10,
    "offset": 0,
    "next": "/api/search?q=blue&limit=10&offset=10",
    "prev": null
  }
}

Incluye siempre total para que los clientes puedan renderizar controles de paginación sin conocer el conjunto de resultados completo.

Estrategia de versiones

Versiona tu API en la ruta de URL, no en un encabezado. El versionado de URL es explícito, almacenable en caché por CDN y no requiere que los clientes establezcan encabezados personalizados:

/api/v1/color/FF5733    # Versión 1
/api/v2/color/FF5733    # Versión 2 (cuando se necesitan cambios que rompen la compatibilidad)

Mantén las versiones anteriores durante al menos un año después de un lanzamiento de versión principal. Usa encabezados de deprecación en versiones antiguas:

Deprecation: Sun, 01 Jan 2026 00:00:00 GMT
Sunset: Sun, 01 Jan 2027 00:00:00 GMT
Link: </api/v2/color/FF5733>; rel="successor-version"

Configuración de CORS

Para una API pública consumida por clientes de navegador:

# settings.py (usando django-cors-headers)
CORS_ALLOWED_ORIGINS = [
    "https://yoursite.com",
    "https://app.yoursite.com",
]

# O para una API completamente pública:
CORS_ALLOW_ALL_ORIGINS = True

# Siempre restringe métodos y encabezados
CORS_ALLOW_METHODS = ['GET', 'POST', 'OPTIONS']
CORS_ALLOW_HEADERS = ['Content-Type', 'Authorization', 'X-API-Key']

Arquitectura de caché

Los datos de color son altamente almacenables en caché. El objeto de color para #FF5733 nunca cambiará — las matemáticas de conversión son deterministas. Estructura tu estrategia de caché en consecuencia:

Endpoint Cache-Control TTL de CDN
/api/color/{hex} public, max-age=86400, stale-while-revalidate=604800 1 día
/api/search?q={q} public, max-age=3600 1 hora
/api/contrast?fg=X&bg=Y public, max-age=86400 1 día
/api/palette/{hex} public, max-age=86400 1 día

Agrega un encabezado ETag basado en el código hex para solicitudes condicionales:

response["ETag"] = f'"{hex_code}"'
response["Last-Modified"] = "Thu, 01 Jan 2026 00:00:00 GMT"  # Estático — el contenido nunca cambia

Los clientes que envíen If-None-Match: "FF5733" reciben una respuesta 304 Not Modified sin cuerpo — ahorrando ancho de banda en solicitudes repetidas.


Puntos clave

  • El objeto de color canónico debe incluir todas las representaciones de formato (hex, RGB, HSL, CMYK, OKLCH) en una sola respuesta — nunca hagas que los clientes realicen múltiples solicitudes para obtener diferentes formatos.
  • Normaliza la entrada hex de forma agresiva: elimina #, expande los de 3 dígitos, convierte a mayúsculas. Valida con una expresión regular antes de cualquier procesamiento.
  • El endpoint de contraste es el núcleo de cualquier herramienta de accesibilidad — devuelve relaciones WCAG, aprobación/fallo por nivel y el nivel de aprobación más alto en una sola respuesta.
  • La búsqueda de color con nombre funciona de forma confiable con búsqueda de prefijos en memoria para bases de datos pequeñas; usa PostgreSQL pg_trgm para las grandes.
  • Limita la velocidad por IP para solicitudes no autenticadas (60/min es razonable); usa claves API para niveles más altos.
  • Cada respuesta de error debe seguir el mismo esquema con error (legible por máquina) y message (legible por humanos) como mínimo.
  • Los datos de color son altamente almacenables en caché — la caché de CDN de 24 horas es apropiada para los endpoints de conversión; usa ETag y stale-while-revalidate para una invalidación de caché eficiente.
  • Prueba el Convertidor de Color y el Verificador de Contraste para ver estos endpoints de API en acción.

Colores relacionados

Marcas relacionadas

Herramientas relacionadas