Tutorial: Descargar precios de blockchain desde Laravel

Pequeño tutorial de descarga de precios de diferentes blockchains para ver en vivo Laravel Actions, llamadas HTTP, Carbon, Colecciones y los Upsert.

Tutorial: Descargar precios de blockchain desde Laravel
Photo by Pierre Borthiry - Peiobty / Unsplash

Hoy quiero hacer un pequeño tutorial sobre cómo guardar precios de diferentes blockchains en Laravel, con los que pretendo ver en acción los siguientes elementos:

Setup del proyecto

En primer lugar, voy a crear un nuevo proyecto Laravel usando Composer:

composer create-project laravel/laravel Laravel-CoinGecko

Además, vamos a instalar Laravel Actions con el siguiente comando:

composer require lorisleiva/laravel-actions

Conociendo la API de CoinGecko

Para nuestro ejemplo, vamos a descargarnos los precios de Ethereum, Bitcoin, Litecoin y Bitcoin Cash a través de la API de CoinGecko. Los identificadores de estas chains en CoinGecko son los siguientes:

  • ethereum
  • bitcoin
  • litecoin
  • bitcoin-cash

La URL de la API es la siguiente:

https://api.coingecko.com/api/v3/coins/bitcoin-cash/market_chart?vs_currency=USD&days=10&interval=daily

Y devuelve una respuesta más o menos así:

{
	"prices": [
		[1684454400000, 115.36267196694956],
		[1684531299000, 115.30340299861236]
	],
	"market_caps": [
		[1684454400000, 2242285841.689056],
		[1684531299000, 2236489117.0076494]
	],
	"total_volumes": [
		[1684454400000, 65871504.85742252],
		[1684531299000, 49276496.37595198]
	]
}

Nos interesa coger el índice prices, en el que cada elemento es un array donde el primer item es el timestamp en milisegundos, y el segundo elemento es el precio en dólares en ese instante de tiempo.

Configurando la aplicación para usar SQLite

Para trabajar en local suelo ir bastante rápido y suelo utilizar SQLite por la flexibilidad de creación. Nos vamos al fichero .env y borramos las variables que empiezan por DB_, y creamos la siguiente:

DB_CONNECTION=sqlite

Creando el modelo y la migración

Sabiendo esto, podemos crear nuestro modelo y nuestra migración de la siguiente manera:

php artisan make:model BlockchainPrice -m

Al añadir la opción -m, nos creará automáticamente la migración correspondiente. Ahora, nos vamos al fichero de la migración y lo configuramos de la siguiente manera:

public function up(): void
    {
        Schema::create('blockchain_prices', function (Blueprint $table) {
            $table->id();
            $table->string('chain', 32);
            $table->date('date');
            $table->decimal('price');
            $table->timestamps();
            
            $table->unique(['chain', 'date']);
        });
    }

Tras hacer esto, podemos ejecutar las migraciones:

php artisan migrate

A continuación, vamos a configurar Laravel Actions.

Configurando Laravel Actions para ejecutar acciones

Vamos a crear dos acciones:

  • DownloadBlockchainPrices, que usaremos principalmente como comando
  • DownloadBlockchainPrice, que usaremos como Job para poder descargar varias blockchains a la vez

Descargando los datos de una blockchain

Vamos a crear una acción con la que podemos descargar e importar los datos de una blockchain determinada. En primer lugar, creamos una acción:

php artisan make:action DownloadBlockchain

Una vez hecho esto, nos vamos al fichero app/Actions/DownloadBlockchain.php. Si no estás familiarizado con las acciones, puedes leer este post en el que hablo sobre ellas.

<?php

namespace App\Actions;

use App\Models\BlockchainPrice;
use Illuminate\Support\Facades\Http;
use Lorisleiva\Actions\Concerns\AsAction;

class DownloadBlockchain
{
    use AsAction;

    public function handle(string $chain, int $days = 365)
    {
        $data = Http::retry(3, 60000)
            ->get('https://api.coingecko.com/api/v3/coins/' . $chain . '/market_chart?vs_currency=USD&days=' . $days . '&interval=daily')
            ->throw()
            ->collect('prices')
            ->map(fn ($item) => [
                'chain' => $chain,
                'date' => now()->parse($item[0] / 1000)->toDateString(),
                'price' => $item[1],
            ])
            ->keyBy('date')
            ->values();

        BlockchainPrice::upsert($data->toArray(), ['chain', 'date'], ['price']);
    }
}

Desgranemos un poquito lo que hacemos en el código:

  • Http::retry(3, 60000) : Inicializamos una llamada HTTP y la configuramos para que, en caso de fallar, la reintente hasta 3 veces esperando 1 minuto entre cada petición. La razón de esto es el límite de llamadas por minuto de CoinGecko, así que me aseguro de no sobrepasarlo de esta manera.
  • → get($url): Especificamos la URL de la petición, no tiene mucho misterio. También podríamos pasar los query params en un array en el segundo parámetro de la función.
  • →throw(): En caso de no devolver una respuesta con código 200, quiero que lance una excepción.
  • →collect('prices'): convierte la respuesta a JSON, busca el índice prices y me lo devuelve en una colección
  • →map(...): cojo cada elemento del array de precios y lo convierto a un array con las columnas que necesitamos en la tabla.
  • now()→parse($item[0] / 1000)→toDateString(): parseamos el timestamp pasándolo a segundos y lo convertimos a una string de fecha
  • → keyBy('date'): nos quedamos únicamente con un elemento por cada fecha. Aunque la API ya nos devuelve 1 único elemento por día, en el caso del día de hoy devuelve 2 elementos, así que con el keyBy nos quedamos con el último.
  • →values(): lo reconvertimos a una colección secuencial, sin que la clave sea el date
  • Por último, ejecutamos un upsert para actualizar o insertar los datos en la tabla

Ahora, creamos otra acción en la que lanzaremos diferentes jobs para cada blockchain:

php artisan make:action DownloadBlockchains

Y la configuramos de la siguiente forma:

<?php

namespace App\Actions;

use Lorisleiva\Actions\Concerns\AsAction;

class DownloadBlockchains
{
    use AsAction;
    
    public string $commandSignature = 'download:blockchains';

    public function handle()
    {
        $chains = [
            'bitcoin',
            'ethereum',
            'litecoin',
            'bitcoin-cash',
        ];

        foreach ($chains as $chain) {
            DownloadBlockchain::dispatch($chain);
        }
    }
}

Al añadir el $commandSignature, estamos configurando nuestra action para que funcione también como un comando. Por otro lado, llamar a DownloadBlockchain::dispatch($chain) lanzará un job por cada chain, permitiendo su procesamiento de forma paralela y en background.

Si ejecutamos ahora nuestro download:blockchains:

php artisan download:blockchains

Veremos un grandioso error :-)

Error "There are no commands defined in the 'download' namespace."

Esto es porque las acciones no se autoregistran de forma automática en Laravel. Tenemos que configurarlas nosotros.

Para poder usar automáticamente las acciones como comando nos vamos a nuestro AppServiceProvider y añadimos el siguiente código:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Lorisleiva\Actions\Facades\Actions;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Actions::registerCommands();
    }
}

Al añadir el Actions::registerCommands(), las acciones quedarán automáticamente registradas en Laravel y podremos acceder a ellas.

Ahora, si ejecutamos nuestro php artisan download:blockchains deberíamos de poder ver en nuestra base de datos que efectivamente se han descargado todos los precios:

Base de datos mostrando precios descargados de Bitcoin

Por último, podemos configurar nuestra acción para correr automáticamente cada día y disponer de los precios actualizados. Para ello, nos vamos a nuestro app/Console/Kernel.php y agregamos la siguiente línea dentro del método schedule:

$schedule->command('download:blockchains')->dailyAt('01:00');

Con esto, configuramos nuestra aplicación para ejecutar la acción que acabamos de crear cada madrugada.

Espero que os haya sido útil para conocer cómo realizamos ciertas acciones y cómo organizamos nuestro código en algunos de nuestros proyectos :-)