23

Automatizar APIs REST con Bash

Vistas: 2
Tutorial: Scripts en Bash
Automatizar APIs REST con Bash

Llevaba tiempo dándole vueltas a cómo monitorizar mis servicios sin tener que abrir veinte pestañas del navegador. Cada vez que quería saber el estado de un despliegue, consultar un issue o lanzar una notificación, terminaba con diez terminales abiertas y el caos asegurado. Y al final la solución era más simple de lo que pensaba: Bash, curl y jq. Nada de Python, nada de Node. Lo que ya tenía en el servidor desde el primer día. Como te comenté en el capítulo anterior, cuando dominas funciones, arrays y señales, el siguiente paso natural es salir al mundo exterior. Tus scripts ya saben organizarse internamente, pero llega el momento de que hablen con otros servicios.

¿Y si pudieras consultar el estado de tus servicios desde un script de monitorización? ¿Notificar incidentes a Slack o Telegram? ¿Crear issues en GitHub automáticamente? Todo eso es posible con Bash, curl y jq.

Sobre API REST

El 90% de los servicios modernos exponen una API REST. Tu dashboard de infraestructura, el CI/CD, el sistema de monitorización, el chat del equipo, el proveedor de cloud… todos hablan HTTP. Saber consumir APIs desde Bash es la habilidad que convierte a un administrador de sistemas en un automatizador.

Y lo mejor es que no necesitas Python, Node ni Go. Bash + curl + jq es un stack ubicuo: está en cualquier servidor Linux, en cualquier contenedor mínimo y no requiere instalar runtime alguno. Si puedes hacer SSH a una máquina, puedes consumir APIs desde ella.

Una API REST no es más que un intercambio de documentos HTTP. Bash habla HTTP con curl, parsea la respuesta con jq, y orquesta la lógica con sus capacidades nativas. No hay magia, solo HTTP y JSON.

curl, el cuchillo suizo HTTP

curl es la herramienta por excelencia para transferir datos con URLs. Soporta HTTP, HTTPS, FTP, SFTP, SCP, LDAP y más de 20 protocolos. Para APIs REST usarás casi exclusivamente HTTP y HTTPS.

Opciones esenciales

Aquí tienes las opciones que usarás en el 99% de las llamadas:

  • -s (–silent): suprime la barra de progreso y mensajes de error
  • -S (–show-error): muestra errores aunque estés en modo silencioso
  • -o (–output): guarda la respuesta en un archivo
  • -w (–write-out): muestra información adicional tras la respuesta
  • -X (–request): método HTTP: GET, POST, PUT, DELETE, PATCH
  • -H (–header): añade una cabecera HTTP
  • -d (–data): envía datos en el cuerpo de la petición
  • -k (–insecure): omite verificación SSL, úsalo con cuidado
  • -L (–location): sigue redirecciones
  • --connect-timeout: tiempo máximo para establecer conexión
  • --max-time: tiempo máximo total para la operación
  • -f (–fail): falla silenciosamente en errores HTTP
  • -i (–include): incluye las cabeceras HTTP en la salida
  • -v (–verbose): modo verboso para depuración
# Todas las protecciones activadas
curl -sSfL --connect-timeout 10 --max-time 30 \
  -H "Authorization: Bearer ***" \
  "https://api.github.com/repos/user/repo"

GET con parámetros URL

# Parámetros directamente en la URL
curl -s "https://api.github.com/search/repositories?q=bash&sort=stars&per_page=5"

# Con variables
QUERY="bash"
SORT="stars"
PER_PAGE=5
curl -s "https://api.github.com/search/repositories?q=${QUERY}&sort=${SORT}&per_page=${PER_PAGE}"

# Usando -G y -d para construir la URL limpiamente
curl -s -G "https://api.github.com/search/repositories" \
  -d "q=bash" \
  -d "sort=stars" \
  -d "per_page=5"

La forma con -G y -d es más legible y evita problemas con caracteres especiales en la URL. Te recomiendo que la uses siempre que tengas varios parámetros.

POST con cuerpo JSON

# Enviar JSON como string
curl -s -X POST "https://api.github.com/repos/user/repo/issues" \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -d '{"title":"Bug en producción","body":"Descripción del bug","labels":["bug"]}'

# Con datos en archivo
cat > /tmp/issue.json <<EOF
{
  "title": "Bug en producción",
  "body": "Descripción detallada",
  "labels": ["bug", "urgente"]
}
EOF

curl -s -X POST "https://api.github.com/repos/user/repo/issues" \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -d @/tmp/issue.json

Usar @archivo es preferible cuando el JSON es largo o se genera dinámicamente. Esto te puede ahorrar más de un disgusto con caracteres escapados incorrectamente.

Autenticación: Bearer y Basic

# Bearer Token (el más común en APIs modernas)
curl -s -H "Authorization: Bearer ***" "https://api.ejemplo.com/recurso"

# Basic Auth
curl -s -u "usuario:contraseña" "https://api.ejemplo.com/recurso"

# Basic Auth con token codificado manualmente
TOKEN_B64=$(echo -n "usuario:contraseña" | base64)
curl -s -H "Authorization: Basic $TOKEN_B64" "https://api.ejemplo.com/recurso"

# API Key en cabecera
curl -s -H "X-API-Key: $API_KEY" "https://api.ejemplo.com/recurso"

Manejo de códigos HTTP con -w

-w permite extraer metadatos de la respuesta. La opción más útil es extraer el código HTTP:

# Extraer solo el código HTTP
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://api.github.com/users/nousresearch")

echo "Código HTTP: $HTTP_CODE"

# Patrón completo para validar respuesta
RESPONSE=$(mktemp)
HTTP_CODE=$(curl -s -o "$RESPONSE" -w "%{http_code}" \
  -H "Authorization: Bearer ***" \
  "https://api.github.com/repos/user/repo")

if [[ "$HTTP_CODE" != "200" ]]; then
  echo "Error $HTTP_CODE: $(cat "$RESPONSE")" >&2
  rm -f "$RESPONSE"
  exit 1
fi

cat "$RESPONSE"
rm -f "$RESPONSE"

Otras variables útiles de -w:

curl -s -o /dev/null -w "
  http_code:    %{http_code}
  content_type: %{content_type}
  time_total:   %{time_total}s
  time_connect: %{time_connect}s
  size_download: %{size_download} bytes
  url_effective: %{url_effective}
" "https://api.github.com"

jq, tu navaja para JSON

jq es un procesador JSON de línea de comandos. Es a JSON lo que sed es a texto, pero con conciencia de la estructura del documento. Sin jq, parsear JSON en Bash es una pesadilla de grep y sed frágil. Con jq, es trivial.

Instalación

# Debian/Ubuntu
apt install jq

# RHEL/CentOS/Fedora
dnf install jq

# macOS
brew install jq

# Desde el código fuente
curl -sL "https://github.com/jqlang/jq/releases/latest/download/jq-linux-amd64" -o /usr/local/bin/jq
chmod +x /usr/local/bin/jq

Conceptos básicos

# Supongamos esta respuesta JSON
JSON='{
  "name": "scripts-en-bash",
  "stars": 42,
  "owner": {"login": "atareao", "id": 12345},
  "tags": ["bash", "tutorial", "linux"],
  "contributors": [
    {"name": "Lorenzo", "contributions": 30},
    {"name": "Ana", "contributions": 15}
  ]
}'

# .       — el documento completo
echo "$JSON" | jq '.'

# .campo  — acceder a un campo
echo "$JSON" | jq '.name'        # "scripts-en-bash"
echo "$JSON" | jq '.stars'       # 42

# .campo.anidado
echo "$JSON" | jq '.owner.login' # "atareao"
echo "$JSON" | jq '.owner.id'    # 12345

# .[]?    — iterar sobre arrays (con ? para null safety)
echo "$JSON" | jq '.tags[]'      # "bash", "tutorial", "linux"
echo "$JSON" | jq '.contributors[].name'  # "Lorenzo", "Ana"

select(), filtrar elementos

# Seleccionar contributors con más de 20 contribuciones
echo "$JSON" | jq '.contributors[] | select(.contributions > 20)'
# {"name": "Lorenzo", "contributions": 30}

# Combinar condiciones
echo "$JSON" | jq '.contributors[] | select(.contributions > 10 and .name != "Lorenzo")'

map(), transformar arrays

# Extraer solo los nombres
echo "$JSON" | jq '[.contributors[] | .name]'
# ["Lorenzo", "Ana"]

# Mapear a un nuevo formato
echo "$JSON" | jq '[.contributors[] | {nombre: .name, aportes: .contributions}]'

length, contar elementos

echo "$JSON" | jq '.tags | length'                     # 3
echo "$JSON" | jq '.contributors | length'               # 2
echo "$JSON" | jq '.name | length'                       # 14 (caracteres)

-r, raw output sin comillas

La opción -r (–raw-output) elimina las comillas alrededor de strings. Es esencial cuando asignas valores a variables de Bash:

# Sin -r: el valor incluye comillas
NOMBRE=$(echo "$JSON" | jq '.name')
echo "$NOMBRE"  # "scripts-en-bash" (con comillas incluidas)

# Con -r: valor limpio
NOMBRE=$(echo "$JSON" | jq -r '.name')
echo "$NOMBRE"  # scripts-en-bash

# Ejemplo práctico con API real
REPO_NAME=$(curl -s "https://api.github.com/repos/atareao/scripts-en-bash" | jq -r '.name')
STARS=$(curl -s "https://api.github.com/repos/atareao/scripts-en-bash" | jq -r '.stargazers_count')
echo "Repositorio: $REPO_NAME — Estrellas: $STARS"

Arrays y objetos anidados, casos reales

# API de GitHub: lista de repositorios
RESP=$(curl -s "https://api.github.com/users/atareao/repos?per_page=5")

# Extraer nombres y estrellas
echo "$RESP" | jq -r '.[] | "\(.name) — \(.stargazers_count) estrellas"'

# Filtrar repos con más de 10 estrellas
echo "$RESP" | jq -r '.[] | select(.stargazers_count > 10) | .name'

# Tabla formateada
echo "$RESP" | jq -r '.[] | [.name, .language // "N/A", .stargazers_count | tostring] | join(" | ")'

El operador // es el operador alternativo de jq: devuelve el valor de la izquierda si no es null, o el de la derecha si lo es.

Patrones profesionales

Reintentos con exponential backoff

Cuando una API falla por rate limit, timeout o error temporal, reintentar inmediatamente suele empeorar las cosas. El patrón profesional es exponential backoff: esperar 1s, luego 2s, luego 4s, luego 8s, hasta un máximo.

# Función de exponential backoff genérica
api_call_with_backoff() {
  local url=$1
  local max_retries=${2:-5}
  local max_wait=${3:-60}
  local wait_time=1
  local attempt=1

  while (( attempt <= max_retries )); do
    echo "  Intento $attempt/$max_retries..." >&2

    RESPONSE=$(mktemp)
    HTTP_CODE=$(curl -s -o "$RESPONSE" -w "%{http_code}" "$url")

    if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "201" ]]; then
      cat "$RESPONSE"
      rm -f "$RESPONSE"
      return 0
    fi

    # Si es 4xx (error de cliente), no tiene sentido reintentar
    if [[ "$HTTP_CODE" =~ ^4[0-9][0-9]$ ]] && [[ "$HTTP_CODE" != "429" ]]; then
      echo "Error $HTTP_CODE (cliente) — no se reintenta" >&2
      cat "$RESPONSE" >&2
      rm -f "$RESPONSE"
      return 1
    fi

    rm -f "$RESPONSE"

    # Si es 429 (rate limit), usar cabecera Retry-After si existe
    if [[ "$HTTP_CODE" == "429" ]]; then
      local retry_after
      retry_after=$(curl -sI "$url" | grep -i "retry-after" | awk '{print $2}' | tr -d '\r')
      if [[ -n "$retry_after" && "$retry_after" -gt "$wait_time" ]]; then
        wait_time=$retry_after
      fi
    fi

    echo "  Error $HTTP_CODE — esperando ${wait_time}s..." >&2
    sleep "$wait_time"

    # Exponential backoff: doblar tiempo, con tope
    wait_time=$(( wait_time * 2 ))
    if (( wait_time > max_wait )); then
      wait_time=$max_wait
    fi

    ((attempt++))
  done

  echo "Error: agotados $max_retries reintentos" >&2
  return 1
}

Rate limiting con sleep

Muchas APIs imponen un límite de peticiones por minuto u hora. GitHub permite 60 peticiones por hora sin autenticar y 5000 autenticado. El patrón consiste en leer las cabeceras de rate limit y esperar si es necesario.

# Función que respeta rate limits
rate_limited_call() {
  local url=$1
  local response
  local http_code

  response=$(mktemp)
  http_code=$(curl -s -o "$response" -w "%{http_code}" \
    -H "Authorization: Bearer ***" \
    "$url")

  # Leer cabeceras de rate limit (una sola llamada HEAD)
  local headers remaining limit reset
  headers=$(curl -sI -H "Authorization: Bearer ***" "$url")
  remaining=$(echo "$headers" | grep -i "x-ratelimit-remaining" | awk '{print $2}' | tr -d '\r')
  limit=$(echo "$headers" | grep -i "x-ratelimit-limit" | awk '{print $2}' | tr -d '\r')
  reset=$(echo "$headers" | grep -i "x-ratelimit-reset" | awk '{print $2}' | tr -d '\r')

  if (( remaining < 5 )); then
    local wait_time=$(( reset - $(date +%s) + 1 ))
    echo "Rate limit próximo ($remaining/$limit). Esperando ${wait_time}s..." >&2
    sleep "$wait_time"
  fi

  cat "$response"
  rm -f "$response"
  return 0
}

Paginación con bucles

Muchas APIs devuelven resultados paginados. GitHub usa Link headers; otras APIs usan page y per_page en la URL.

# Paginación estilo GitHub (Link header + page param)
fetch_all_pages_github() {
  local base_url=$1
  local page=1
  local per_page=100
  local all_data="[]"

  while true; do
    echo "Obteniendo página $page..." >&2

    response=$(mktemp)
    http_code=$(curl -s -o "$response" -w "%{http_code}" \
      -H "Authorization: Bearer ***" \
      "${base_url}?per_page=${per_page}&page=${page}")

    if [[ "$http_code" != "200" ]]; then
      echo "Error $http_code en página $page" >&2
      rm -f "$response"
      break
    fi

    local data
    data=$(cat "$response")
    rm -f "$response"

    local count
    count=$(echo "$data" | jq 'length')
    if (( count == 0 )); then
      break
    fi

    all_data=$(echo "$all_data" "$data" | jq -s 'add')

    local link_header
    link_header=$(curl -sI -H "Authorization: Bearer ***" \
      "${base_url}?per_page=${per_page}&page=${page}" | \
      grep -i "^link:" || true)

    if ! echo "$link_header" | grep -q 'rel="next"'; then
      break
    fi

    ((page++))
  done

  echo "$all_data"
}

Subida de archivos con -F

Para APIs que aceptan multipart/form-data:

# Subir un archivo
curl -s -X POST "https://api.ejemplo.com/upload" \
  -H "Authorization: Bearer ***" \
  -F "archivo=@/ruta/al/archivo.pdf"

# Con campo adicional
curl -s -X POST "https://api.ejemplo.com/upload" \
  -H "Authorization: Bearer ***" \
  -F "archivo=@/ruta/al/archivo.pdf" \
  -F "descripcion=Informe mensual"

# Cambiar el nombre del archivo en el multipart
curl -s -F "archivo=@/ruta/local.pdf;filename=informe_final.pdf" \
  "https://api.ejemplo.com/upload"

Llamadas paralelas con xargs

Cuando necesitas consultar múltiples endpoints, las llamadas secuenciales son lentas. xargs -P lanza procesos en paralelo:

# Consultar el estado de varios servicios en paralelo
SERVICIOS=(
  "https://api.github.com"
  "https://api.github.com/users/atareao"
  "https://api.github.com/repos/atareao/scripts-en-bash"
)

# xargs -P N: lanza hasta N procesos simultáneos
printf "%s\n" "${SERVICIOS[@]}" | \
  xargs -I {} -P 5 bash -c '
    HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "{}")
    NOMBRE=$(echo "{}" | sed "s|https://api.github.com||")
    echo "$NOMBRE → $HTTP_CODE"
  '

Para un control más fino con timeout por llamada y manejo de errores individual:

check_endpoint() {
  local url=$1
  local timeout=${2:-10}
  local start end elapsed

  start=$(date +%s%N)
  HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
    --max-time "$timeout" "$url" 2>/dev/null || echo "000")
  end=$(date +%s%N)
  elapsed=$(( (end - start) / 1000000 ))

  echo "$url|$HTTP_CODE|${elapsed}ms"
}
export -f check_endpoint

# Lanzar 10 checks en paralelo
printf "%s\n" "${ENDPOINTS[@]}" | xargs -I {} -P 10 bash -c 'check_endpoint "{}"'

/dev/tcp, curl sin curl

Bash tiene una característica poco conocida: puede abrir conexiones TCP directamente con /dev/tcp. No necesitas curl para peticiones HTTP simples, aunque tiene sus limitaciones:

# GET request sin curl (HTTP/1.0 plano, sin SSL)
exec 3<>/dev/tcp/example.com/80
echo -e "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n" >&3
cat <&3
exec 3>&-

Para conexiones HTTPS necesitas openssl, pero entonces ya no es tan «sin curl»:

# GET vía HTTPS con openssl
echo -e "GET /users/atareao HTTP/1.1\r\nHost: api.github.com\r\nConnection: close\r\n\r\n" | \
  openssl s_client -quiet -connect api.github.com:443 2>/dev/null | head -20

Cuidado con esta técnica: no maneja HTTPS directamente, el parseo de respuesta es manual y es poco portable. Usa curl siempre que esté disponible. /dev/tcp es un buen recurso en contenedores ultra-mínimos donde no cabe ni curl.

APIs reales desde Bash

GitHub API, listar repositorios y crear issues

#!/bin/bash
# github_api.sh

GITHUB_TOKEN="${GITHUB_TOKEN:-}"
[[ -z "$GITHUB_TOKEN" ]] && { echo "Define GITHUB_TOKEN" >&2; exit 1; }

GITHUB_API="https://api.github.com"
USER="atareao"

# Listar repositorios del usuario
echo "=== Repositorios de $USER ==="
curl -s -H "Authorization: Bearer ***" \
  "${GITHUB_API}/users/${USER}/repos?per_page=5&sort=updated" | \
  jq -r '.[] | "\(.name) — \(.stargazers_count) estrellas — \(.forks_count) forks — \(.language // "N/A")"'

# Crear un issue
crear_issue() {
  local repo=$1
  local title=$2
  local body=$3
  local labels=${4:-"[]"}

  echo "Creando issue en $repo: $title"

  RESPONSE=$(mktemp)
  HTTP_CODE=$(curl -s -o "$RESPONSE" -w "%{http_code}" \
    -X POST "${GITHUB_API}/repos/${USER}/${repo}/issues" \
    -H "Authorization: Bearer ***" \
    -H "Content-Type: application/json" \
    -d "$(jq -n --arg t "$title" --arg b "$body" --argjson l "$labels" \
      '{title: $t, body: $b, labels: $l}')")

  if [[ "$HTTP_CODE" == "201" ]]; then
    local issue_url
    issue_url=$(jq -r '.html_url' "$RESPONSE")
    echo "Issue creado: $issue_url"
  else
    echo "Error $HTTP_CODE: $(cat "$RESPONSE")" >&2
    return 1
  fi

  rm -f "$RESPONSE"
}

Weather API, clima desde terminal

No necesitas API key para consultar el clima. wttr.in es un servicio que devuelve el tiempo en varios formatos:

# Clima actual en JSON
curl -s "https://wttr.in/Madrid?format=j1" | \
  jq -r '.current_condition[0] | 
    "\(.temp_C)°C — \(.weatherDesc[0].value) — Humedad \(.humidity)% — Viento \(.windspeedKmph) km/h"'

# Pronóstico resumido
curl -s "wttr.in/Buenos+Aires?format=%l:+%c+%t+%w+%h" 

# Pronóstico para los próximos 3 días
curl -s "wttr.in/London?format=j1" | \
  jq -r '.weather[] | 
    "\(.date): Max \(.maxtempC)°C / Min \(.mintempC)°C — \(.hourly[0].weatherDesc[0].value)"'

Telegram Bot, enviar mensajes desde el servidor

#!/bin/bash
# telegram.sh

TELEGRAM_TOKEN="${TELEGRAM_TOKEN:-}"
CHAT_ID="${CHAT_ID:-}"

[[ -z "$TELEGRAM_TOKEN" || -z "$CHAT_ID" ]] && {
  echo "Define TELEGRAM_TOKEN y CHAT_ID" >&2
  exit 1
}

enviar_telegram() {
  local mensaje=$1
  local parse_mode=${2:-"Markdown"}

  RESPONSE=$(mktemp)
  HTTP_CODE=$(curl -s -o "$RESPONSE" -w "%{http_code}" \
    -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
    -H "Content-Type: application/json" \
    -d "$(jq -n --arg chat "$CHAT_ID" --arg text "$mensaje" --arg mode "$parse_mode" \
      '{chat_id: $chat, text: $text, parse_mode: $mode}')")

  if [[ "$HTTP_CODE" != "200" ]]; then
    echo "Error al enviar mensaje: $(cat "$RESPONSE")" >&2
    rm -f "$RESPONSE"
    return 1
  fi

  rm -f "$RESPONSE"
}

# Enviar notificación
enviar_telegram "Alerta en servidor $(hostname)
- Disco: 85% usado
- Memoria: 3.2 GB libres
- CPU: carga media 2.5"

# Enviar archivo
enviar_documento() {
  local archivo=$1
  local caption=${2:-""}

  curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendDocument" \
    -F "chat_id=$CHAT_ID" \
    -F "document=@$archivo" \
    -F "caption=$caption"
}

Webhooks, disparar eventos

Los webhooks son callbacks HTTP: cuando algo ocurre, haces un POST a una URL.

# Disparar webhook genérico
disparar_webhook() {
  local url=$1
  local event_type=$2
  local payload=${3:-"{}"}

  curl -s -X POST "$url" \
    -H "Content-Type: application/json" \
    -H "X-Event-Type: $event_type" \
    -H "X-Source: $(hostname)" \
    -d "$payload"
}

# Notificar a Slack via webhook
SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T00/B00/XXXXX"

notificar_slack() {
  local mensaje=$1
  local nivel=${2:-"info"}

  local color
  case "$nivel" in
    error)   color="#FF0000" ;;
    warning) color="#FFA500" ;;
    success) color="#00FF00" ;;
    *)       color="#808080" ;;
  esac

  curl -s -X POST "$SLACK_WEBHOOK_URL" \
    -H "Content-Type: application/json" \
    -d "$(jq -n --arg msg "$mensaje" --arg col "$color" \
      '{
        attachments: [{
          color: $col,
          text: $msg,
          footer: "Servidor: \(env.HOSTNAME // "desconocido")",
          ts: (now | floor)
        }]
      }')"
}

Funciones profesionales

Construyamos una librería REST completa para Bash. Estas funciones te servirán en cualquier script que interactúe con APIs:

#!/bin/bash
# lib-rest.sh

# Configuración global
REST_TIMEOUT=30
REST_CONNECT_TIMEOUT=10
REST_RETRIES=3
REST_BASE_URL=""
REST_TOKEN=""
REST_VERBOSE=false

# Función base
_rest_request() {
  local method=$1
  local endpoint=$2
  local data=${3:-}
  local content_type=${4:-"application/json"}

  local url="${REST_BASE_URL}${endpoint}"
  local response
  local http_code

  response=$(mktemp)

  local curl_args=(
    -s -o "$response"
    -w "%{http_code}"
    -X "$method"
    --connect-timeout "$REST_CONNECT_TIMEOUT"
    --max-time "$REST_TIMEOUT"
  )

  [[ -n "$REST_TOKEN" ]] && curl_args+=(-H "Authorization: Bearer $REST_TOKEN")
  curl_args+=(-H "Content-Type: $content_type")
  [[ -n "$data" ]] && curl_args+=(-d "$data")
  curl_args+=(-H "User-Agent: bash-rest-client/1.0")
  $REST_VERBOSE && curl_args+=(-v)

  http_code=$(curl "${curl_args[@]}" "$url" 2>/dev/null)

  echo "${http_code}|$(cat "$response" | base64 -w0)"
  rm -f "$response"
}

# rest_get, rest_post, rest_put, rest_delete
rest_get() {
  local endpoint=$1
  local result
  result=$(_rest_request "GET" "$endpoint")
  local http_code="${result%%|*}"
  local body=$(echo "${result#*|}" | base64 -d 2>/dev/null || echo "")
  if [[ "$http_code" != "200" ]]; then
    echo "Error $http_code en GET $endpoint" >&2
    echo "$body" >&2
    return 1
  fi
  echo "$body"
}

rest_post() {
  local endpoint=$1
  local data=$2
  local result
  result=$(_rest_request "POST" "$endpoint" "$data")
  local http_code="${result%%|*}"
  local body=$(echo "${result#*|}" | base64 -d 2>/dev/null || echo "")
  if [[ "$http_code" != "201" && "$http_code" != "200" ]]; then
    echo "Error $http_code en POST $endpoint" >&2
    echo "$body" >&2
    return 1
  fi
  echo "$body"
}

rest_put() {
  local endpoint=$1
  local data=$2
  local result
  result=$(_rest_request "PUT" "$endpoint" "$data")
  local http_code="${result%%|*}"
  local body=$(echo "${result#*|}" | base64 -d 2>/dev/null || echo "")
  if [[ "$http_code" != "200" && "$http_code" != "204" ]]; then
    echo "Error $http_code en PUT $endpoint" >&2
    echo "$body" >&2
    return 1
  fi
  echo "$body"
}

rest_delete() {
  local endpoint=$1
  local result
  result=$(_rest_request "DELETE" "$endpoint")
  local http_code="${result%%|*}"
  local body=$(echo "${result#*|}" | base64 -d 2>/dev/null || echo "")
  if [[ "$http_code" != "204" && "$http_code" != "200" && "$http_code" != "202" ]]; then
    echo "Error $http_code en DELETE $endpoint" >&2
    echo "$body" >&2
    return 1
  fi
  echo "$body"
}

# fetch_paginated — obtiene todas las páginas
fetch_paginated() {
  local base_endpoint=$1
  local per_page=${2:-100}
  local page=1
  local all_data="[]"

  while true; do
    local endpoint="${base_endpoint}?per_page=${per_page}&page=${page}"
    local result
    echo "Obteniendo página $page..." >&2
    result=$(rest_get "$endpoint") || break
    local count=$(echo "$result" | jq 'length' 2>/dev/null || echo "0")
    (( count == 0 )) && break
    all_data=$(echo "$all_data" "$result" | jq -s 'add')
    (( count < per_page )) && break
    ((page++))
  done

  echo "$all_data"
}

# api_with_retry — ejecuta con reintentos
api_with_retry() {
  local rest_func=$1
  local endpoint=$2
  local data=${3:-}
  local max_retries=${4:-3}
  local base_wait=2
  local attempt=1
  local result

  while (( attempt <= max_retries )); do
    echo "Llamada $attempt/$max_retries a $endpoint..." >&2
    if [[ -n "$data" ]]; then
      result=$($rest_func "$endpoint" "$data") && { echo "$result"; return 0; }
    else
      result=$($rest_func "$endpoint") && { echo "$result"; return 0; }
    fi
    local wait_time=$(( base_wait * (2 ** (attempt - 1)) ))
    echo "  Reintentando en ${wait_time}s..." >&2
    sleep "$wait_time"
    ((attempt++))
  done

  echo "Error: agotados $max_retries reintentos en $endpoint" >&2
  return 1
}

Dale caña a esta librería. Es la base que usarás una y otra vez.

Errores comunes

Error 1: No verificar el código HTTP

# Mal: asumes que la respuesta es siempre 200
RESP=$(curl -s "https://api.github.com/users/noexisto")
echo "$RESP" | jq '.name'  # Muestra: null

# Bien: siempre verifica el código HTTP
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://api.github.com/users/noexisto")
if [[ "$HTTP_CODE" == "200" ]]; then
  echo "$RESP" | jq '.name'
else
  echo "Error $HTTP_CODE"
fi

Error 2: Olvidar -s en scripts automatizados

# Mal: la barra de progreso aparece en logs
curl "https://api.github.com" > respuesta.json

# Bien: silencio absoluto
curl -s "https://api.github.com" > respuesta.json

Error 3: No escapar variables en el JSON

# Mal: si $TITLE contiene comillas, el JSON se rompe
curl -d "{\"title\":\"$TITLE\"}" "https://api.ejemplo.com"

# Bien: usa jq para construir JSON seguro
DATA=$(jq -n --arg t "$TITLE" '{title: $t}')
curl -d "$DATA" "https://api.ejemplo.com"

Error 4: Olvidar el Content-Type en POST

# Mal: el servidor no interpreta el body como JSON
curl -X POST -d '{"key":"value"}' "https://api.ejemplo.com"

# Bien: Content-Type explícito
curl -X POST -H "Content-Type: application/json" -d '{"key":"value"}' "https://api.ejemplo.com"

Error 5: Ignorar rate limiting

# Mal: 100 peticiones seguidas, te bloquean
for i in {1..100}; do
  curl -s "https://api.github.com/users/user"
done

# Bien: con pausa y control
for i in {1..100}; do
  curl -s "https://api.github.com/users/user"
  sleep 1
done

Error 6: Ignorar timeouts

# Mal: curl espera indefinidamente
curl -s "https://api.lenta.com/recurso"

# Bien: timeout máximo
curl -s --connect-timeout 10 --max-time 30 "https://api.lenta.com/recurso"

Error 7: No usar -L para seguir redirecciones

# Mal: HTTP 301/302 sin seguir, respuesta vacía
curl -s "http://api.github.com" | jq '.'

# Bien: sigue la redirección
curl -sL "http://api.github.com" | jq '.'

Error 8: No validar la respuesta JSON

# Mal: si la API devuelve HTML, jq falla
curl -s "https://api.ejemplo.com/error" | jq '.data'

# Bien: validar antes de procesar
RESP=$(curl -s "https://api.ejemplo.com/error")
if echo "$RESP" | jq -e '.' > /dev/null 2>&1; then
  echo "$RESP" | jq '.data'
else
  echo "Respuesta no JSON: $RESP"
fi

Error 9: Exponer tokens en el historial

# Mal: el token queda en ~/.bash_history
curl -s -H "Authorization: Bearer ***" "https://api.ejemplo.com"

# Bien: usar variable de entorno
curl -s -H "Authorization: Bearer $TOKEN" "https://api.ejemplo.com"

Error 10: No limpiar archivos temporales

# Mal: archivos temporales quedan en /tmp
RESP=$(mktemp)
curl -s -o "$RESP" "https://api.ejemplo.com"
# No borras $RESP

# Bien: limpieza garantizada con trap
RESP=$(mktemp)
trap 'rm -f "$RESP"' EXIT
curl -s -o "$RESP" "https://api.ejemplo.com"

Para terminar

Consumir APIs REST desde Bash no es un parche temporal. Es una habilidad fundamental para cualquier persona que administre sistemas Linux. Con curl y jq tienes un stack completo para interactuar con cualquier servicio HTTP desde la línea de comandos.

Si te llevas algo de este capítulo, que sean estos principios:

  1. Siempre verifica el código HTTP. No asumas que la API responde correctamente.
  2. Usa jq para construir JSON. No concatenes strings. jq –arg es seguro contra inyección.
  3. Respeta los rate limits. Lee las cabeceras de límite, espera cuando sea necesario.
  4. Añade un User-Agent, usa timeouts razonables, reintenta con backoff.
  5. Nunca expongas tokens. Usa variables de entorno o archivos seguros.
  6. Pon las funciones de este capítulo en un archivo lib-rest.sh y fuentealo. Te ahorrará escribir lo mismo una y otra vez.

El stack Bash + curl + jq es ligero, ubicuo y extraordinariamente potente. La mayoría de las tareas que requieren una librería HTTP en Python o Node se resuelven con una línea de curl y un pipe a jq. Domínalo y cualquier API estará a un script de distancia.

En el próximo capítulo veremos cómo trabajar con bases de datos desde Bash. Porque tus datos no siempre van a llegar en JSON, y a veces lo que necesitas es meterlos en una base.

Deja una respuesta