Importar datos de una API con Laravel 13, Docker y MySQL — Product Hub #1

Quería arrancar Sistemas y Código construyendo algo real, no haciendo otro ejemplo aislado de Laravel que termina cuando conseguimos devolver un JSON desde un controlador.

Así nació Product Hub.

La idea del proyecto es construir, paso a paso, un sistema capaz de recibir productos desde distintas fuentes, transformar esos datos y guardarlos utilizando una estructura común.

En esta primera parte todavía no hay arquitectura multi-fuente, colas, interfaces ni normalizadores. Empezamos con una sola API, DummyJSON, y cerramos primero un flujo completo:

DummyJSON
    ↓
   HTTP
    ↓
 Laravel
    ↓
transformación
    ↓
  MySQL

La condición importante es que podamos ejecutar la importación todas las veces que queramos sin duplicar productos.

Video completo

En el video construyo todo desde cero, incluyendo Docker, Laravel, MySQL, las migraciones, el cliente HTTP, el comando de sincronización y los tests.

Código fuente

El proyecto completo de esta serie está disponible en GitHub:

Ver el código de Product Hub en GitHub

Podés clonar el repositorio y seguir exactamente el mismo entorno que construimos en el video.

Un entorno que pertenece al proyecto

Una de las primeras decisiones fue no depender del PHP, Composer o MySQL instalados en mi máquina.

El entorno queda definido por el propio repositorio:

Docker
├── app
│   ├── PHP 8.4
│   ├── Composer
│   └── Laravel 13
│
└── mysql
    └── MySQL 8.4

En el host solamente necesitamos Git y Docker.

Incluso Laravel se crea inicialmente utilizando la imagen oficial de Composer:

docker run --rm \
  --user "$(id -u):$(id -g)" \
  --volume "$PWD":/app \
  --workdir /app \
  composer:2 \
  create-project laravel/laravel product-hub "^13.0"

La idea es sencilla: si alguien clona el repositorio dentro de seis meses, no debería importar qué versión de PHP tenía instalada yo cuando grabé el video.

La versión correcta ya está declarada en el proyecto.

Laravel y MySQL viven en contenedores diferentes

El docker-compose.yml levanta dos servicios principales:

app
mysql

Y acá aparece una diferencia que suele generar confusión cuando se empieza a trabajar con Docker.

Laravel se conecta a:

mysql:3306

porque ambos contenedores están dentro de la misma red de Docker Compose.

DBeaver, en cambio, está ejecutándose en Windows y entra desde afuera:

localhost:3307

Es la misma base de datos, pero el camino para llegar depende de dónde esté el cliente.

Antes de importar: definir la identidad del producto

DummyJSON puede devolver un producto con:

id = 10

Pero ese 10 pertenece a DummyJSON.

Nuestro producto tendrá su propio identificador:

id          = 57
external_id = 10

Esto empieza a ser importante cuando pensamos en más de una fuente.

Podemos tener perfectamente:

DummyJSON + 10
Otra API  + 10

y estar hablando de dos productos completamente diferentes.

Por eso la identidad externa del producto queda definida por:

source_id + external_id

y esa combinación tiene una restricción única en MySQL.

Es una decisión pequeña, pero es también la que permite que después podamos ejecutar la sincronización varias veces sin terminar acumulando duplicados.

No guardar solamente lo que usamos hoy

La tabla products contiene campos comunes como:

name
description
category
brand
sku
price
currency
stock
image_url

Pero además guardamos:

raw_payload

Ahí queda almacenado el JSON original que recibimos desde la fuente.

Hoy puede parecer innecesario.

Pero cuando una integración externa cambia, devuelve algo inesperado o aparece un error difícil de reproducir, poder ver exactamente qué había enviado el proveedor puede ahorrar muchísimo tiempo.

También hay otro detalle intencional: si la API no nos proporciona un dato, no lo inventamos.

Por ejemplo, si recibimos un precio pero no tenemos información fiable sobre la moneda:

'currency' => null,

No completamos USD simplemente porque parece probable.

Un cliente HTTP con una sola responsabilidad

La comunicación con DummyJSON queda aislada en DummyJsonClient.

Su trabajo es solamente saber hablar con esa API:

return Http::baseUrl(
    config('services.dummyjson.base_url')
)
    ->acceptJson()
    ->connectTimeout(3)
    ->timeout(10)
    ->get('/products', [
        'limit' => $limit,
        'skip' => $skip,
    ])
    ->throw()
    ->json();

No sabe cómo guardamos productos.

No conoce Eloquent.

No sabe que existe una tabla products.

Eso queda para otra parte del sistema.

La primera normalización ya aparece

Dentro del comando de sincronización encontramos algo interesante:

'name' => $item['title'],
'image_url' => $item['thumbnail'],

DummyJSON utiliza title.

Nuestro sistema utiliza name.

DummyJSON utiliza thumbnail.

Nuestro sistema utiliza image_url.

Ya estamos transformando datos de una representación externa a nuestra representación interna.

Por ahora esa transformación puede vivir perfectamente en el comando porque tenemos una sola fuente.

Podríamos crear inmediatamente interfaces, DTOs, adapters y normalizadores.

Pero no lo hacemos.

Primero quiero que aparezca un problema que realmente justifique esa arquitectura.

Ejecutar la sincronización

El comando principal es:

docker compose exec app php artisan products:sync-dummyjson

En la primera ejecución obtenemos algo parecido a:

Recibidos:     30
Creados:       30
Actualizados:   0
Sin cambios:    0

Los productos terminan almacenados en MySQL y podemos inspeccionarlos directamente desde DBeaver.

Pero la prueba interesante viene después.

Ejecutamos exactamente el mismo comando otra vez:

docker compose exec app php artisan products:sync-dummyjson

Y ahora:

Recibidos:     30
Creados:        0
Actualizados:   0
Sin cambios:   30

Seguimos teniendo treinta productos.

No sesenta.

Ese comportamiento va a ser especialmente importante más adelante cuando aparezcan ejecuciones programadas, jobs o reintentos.

Tests sin depender de la API real

La integración también queda cubierta por tests.

Pero los tests no llaman realmente a DummyJSON.

Utilizamos:

Http::fake()

para controlar nosotros mismos la respuesta que recibe la aplicación.

De esta manera la suite no depende de:

  • nuestra conexión a Internet;
  • que DummyJSON esté funcionando;
  • que sus productos actuales no hayan cambiado.

Probamos dos comportamientos fundamentales:

1. Una respuesta de DummyJSON termina correctamente guardada en MySQL.

2. Ejecutar la misma importación dos veces no duplica productos.

Y los tests utilizan además una base separada:

product_hub_testing

para no tocar nunca los datos del entorno de desarrollo.

Comandos principales

Con el proyecto levantado, estos son los comandos que más vamos a utilizar:

# Migraciones y datos iniciales
docker compose exec app php artisan migrate --seed

# Importar productos
docker compose exec app php artisan products:sync-dummyjson

# Ejecutar tests
docker compose exec app php artisan test

# Entrar al contenedor
docker compose exec app bash

Qué dejamos pendiente

Esta primera versión cierra el recorrido completo, pero deliberadamente deja varias cosas afuera.

Entre ellas:

Paginación completa
Segunda fuente
Normalizadores
Adapters
DTOs
Queues
Retries
API REST
Frontend
Autenticación

No porque no sepamos que pueden llegar a hacer falta, sino porque todavía no necesitamos resolver esos problemas.

Y hay uno especialmente interesante.

Ahora mismo nuestro código conoce esto:

DummyJSON

title
category
thumbnail

¿Qué ocurre cuando agregamos otra fuente que utiliza algo como esto?

Otra API

name
category.name
images[0]

Ahí empieza a quedar claro que no queremos llenar el importador de condicionales específicos para cada proveedor.

Ese es el problema que vamos a atacar en la siguiente parte de Product Hub.

Deja un comentario