flowstore / lookup
Agnostic lookup layer for external channels (ecommerce/ERP/marketplace) mapping to domain DTOs.
Requires
- php: ^8.1|^8.2|^8.3|^8.4
- illuminate/console: ^9.0|^10.0|^11.0|^12.0
- illuminate/contracts: ^9.0|^10.0|^11.0|^12.0
- illuminate/http: ^9.0|^10.0|^11.0|^12.0
- illuminate/support: ^9.0|^10.0|^11.0|^12.0
Requires (Dev)
- mockery/mockery: ^1.6
- nunomaduro/collision: ^7.0|^8.0
- orchestra/testbench: ^8.0|^9.0
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^10.2|^11.0
This package is auto-updated.
Last update: 2025-09-26 18:57:14 UTC
README
Qué es: Capa agnóstica para consultar APIs externas por "canal" (ecommerce/ERP/marketplace) y convertir respuestas en DTOs de dominio unificados. Se usa para iniciar integraciones (probar conexión, traer datos base, etc.). No persiste ni configura integraciones.
Instalación
- Requerimientos: PHP ^8.1+, Laravel ^9|^10|^11|^12.
- Instala vía Composer:
composer require flowstore/lookup
- Publica la configuración:
php artisan vendor:publish --provider="Flowstore\\Lookup\\LookupServiceProvider" --tag=config
Configuración
Archivo config/lookup.php
:
- providers: mapa
channel_key => ClaseProvider
. - mappers: mapa
channel_key => [entity => ClaseMapper]
. - conventions: namespaces por defecto para resolución por convención.
- http: timeouts y retries por defecto para el cliente HTTP.
Contratos principales
LookupProviderInterface
:resources()
,testConnection(IntegrationContext)
,lookup(IntegrationContext, entity, params)
.EntityMapperInterface<TDomain>
:entity()
,map(payload, IntegrationContext): TDomain
.IntegrationContext
: DTO conchannelKey
ycredentials
.IntegrationContextResolver
: contrato que la app host implementa para resolver un contexto desde unintegrationId
.
Resolución y orquestación
LookupProviderResolver
: resuelve Provider porchannel_key
usandoconfig('lookup.providers')
o la convenciónApp\\Lookup\\Providers\\{Canal}LookupProvider
.EntityMapperResolver
: resuelve Mapper porchannel_key + entity
usandoconfig('lookup.mappers')
o la convenciónApp\\Lookup\\Mappers\\{Canal}\\{Entidad}Mapper
.PerformLookupAction
: invocaprovider->lookup(...)
y mapea conmapper->map(...)
.LookupService
: resuelve contexto (si se usaIntegrationContextResolver
) y devuelve el DTO de dominio.
Resolución de contexto (configurable)
El paquete es agnóstico de tu modelo. Define cómo construir IntegrationContext
desde el request:
// config/lookup.php 'context' => [ // Nombre del parámetro de entrada con el id 'id_param' => 'integration_id', // (opcional) Clase que implementa IntegrationContextResolver // 'resolver' => App\Resolvers\MyContextResolver::class, 'resolver' => null, // (opcional) Resolver genérico basado en Eloquent 'eloquent' => [ // 'model' => App\\Models\\IntegrationTenant::class, // 'channel_column' => 'channel_key', // 'credentials_column' => 'credentials', ], ],
Si usas el resolver genérico Eloquent con IntegrationTenant
:
// config/lookup.php 'context' => [ 'id_param' => 'integration_id', 'resolver' => null, 'eloquent' => [ 'model' => App\Models\IntegrationTenant::class, 'channel_column' => 'channel_key', 'credentials_column' => 'credentials', ], ],
Modelo sugerido:
// app/Models/IntegrationTenant.php namespace App\Models; use Illuminate\Database\Eloquent\Model; class IntegrationTenant extends Model { protected $casts = [ 'credentials' => 'array', ]; }
El controlador del paquete leerá el id desde config('lookup.context.id_param')
(por defecto integration_id
) y resolverá el contexto usando el resolver
configurado o el resolver Eloquent si se define context.eloquent.model
.
HTTP (LookupController)
- La ruta se habilita por defecto al instalar el paquete.
- Endpoint por defecto:
POST /tenant-integrations/lookup
(middlewareapi
). - Puedes deshabilitar u overrides en
config/lookup.php
:
'routes' => [ 'enabled' => true, // pon false para deshabilitar 'path' => '/tenant-integrations/lookup', 'prefix' => null, 'middleware' => ['api'], ],
Ejemplo de request (POST JSON):
{ "integration_id": 123, "entity": "seller", "params": { "limit": 1 } }
Ejemplo de respuesta:
{ "data": { /* DTO de dominio mapeado */ } }
Nota: Debes implementar y bindear IntegrationContextResolver
para traducir integration_id
a IntegrationContext
.
Test de conexión (TestConnectionController)
- Endpoint por defecto:
POST /tenant-integrations/test-connection
- Body (JSON):
{ "channel_key": "shopify", "integration_id": 123 }
- Respuesta:
{ "success": true }
- Requiere un
IntegrationContextResolver
configurado (propio concontext.resolver
o genérico concontext.eloquent.model
). - Ajustes en
config/lookup.php
:
'routes' => [ 'enabled' => true, 'path' => '/tenant-integrations/lookup', 'test_path' => '/tenant-integrations/test-connection', 'prefix' => null, // p.ej. 'api' 'middleware' => ['api'], ], 'context' => [ 'id_param' => 'integration_id', // p.ej. 'tenant_id' ],
Puntos de extensión
- Agregar un canal: crear
App\\Lookup\\Providers\\{Canal}LookupProvider
y registrarlo enconfig/lookup.php
(o seguir la convención). - Agregar una entidad: crear
App\\Lookup\\Mappers\\{Canal}\\{Entidad}Mapper
y registrarlo (o usar la convención). testConnection(...)
permite "ping" de credenciales antes de usarlookup
.
Comando de scaffolding
php artisan make:lookup {channel} {entity} {--provider}
Contribuir
- Issues y PRs en
https://github.com/flowstore/lookup
. - Ejecuta tests y static analysis antes de commitear:
composer test
composer stan
Crea stubs para Provider y/o Mapper en tu app (app/Lookup/...
).
Buenas prácticas
- Evita dependencia dura a modelos: usa
IntegrationContext
o implementaIntegrationContextResolver
en tu app. - Usa el Http Client de Laravel con timeouts/retries; el paquete provee un
AbstractLookupProvider
de ayuda. - Mantén el retorno como DTO de dominio consistente y documentado por entidad.
Uso en la app host
$context = new IntegrationContext(channelKey: 'shopify', credentials: ['token' => '...']); $dto = app(\Flowstore\Lookup\Services\LookupService::class) ->lookup($context, 'seller', ['limit' => 1]);
O vía HTTP con el LookupController
opcional.
IntegrationContext (qué es y cómo personalizar)
IntegrationContext
es un DTO inmutable que describe el contexto de una integración:
channelKey
(string): identifica el canal (p.ej.shopify
,mercadoLibre
).credentials
(array<string, mixed>): credenciales y datos necesarios para llamar al canal (token, apiKey, sellerId, etc.).
Se utiliza en LookupProviderInterface::testConnection(...)
y lookup(...)
, y también en EntityMapperInterface::map(...)
para proveer contexto al mapeo.
Personalización:
- Cambiar el parámetro de ID de entrada (nombre del campo en el request):
// config/lookup.php 'context' => [ 'id_param' => 'tenant_id', // en vez de integration_id 'resolver' => null, 'eloquent' => [ /* ... opcional ... */ ], ],
En este caso, el controlador leerá tenant_id
en el body.
- Proveer tu propio resolver (sin Eloquent genérico):
// app/Resolvers/MyContextResolver.php namespace App\Resolvers; use App\Models\IntegrationTenant; use Flowstore\Lookup\Contracts\IntegrationContextResolver; use Flowstore\Lookup\DTO\IntegrationContext; final class MyContextResolver implements IntegrationContextResolver { public function resolve($integrationId): IntegrationContext { $tenant = IntegrationTenant::findOrFail($integrationId); return new IntegrationContext( channelKey: (string) $tenant->channel_key, credentials: (array) $tenant->credentials, ); } }
Regístralo por configuración (no hace falta bind manual):
// config/lookup.php 'context' => [ 'id_param' => 'integration_id', 'resolver' => App\Resolvers\MyContextResolver::class, ],
- Resolver genérico con Eloquent (sin escribir una clase):
// config/lookup.php 'context' => [ 'id_param' => 'integration_id', 'resolver' => null, 'eloquent' => [ 'model' => App\Models\IntegrationTenant::class, 'channel_column' => 'channel_key', 'credentials_column' => 'credentials', ],**** ],
Sugerencia de modelo:
class IntegrationTenant extends \Illuminate\Database\Eloquent\Model { protected $casts = [ 'credentials' => 'array', ]; }
Ejemplo de request con tenant_id
:
POST /tenant-integrations/lookup Content-Type: application/json { "tenant_id": 42, "entity": "seller", "params": { "limit": 1 } }
Persistencia desde providers (helpers)
Para facilitar inserciones/actualizaciones en tus modelos Eloquent desde un provider custom, AbstractLookupProvider
expone métodos protegidos que usan ModelWriter
internamente:
persistCreate(string $modelClass, array $attributes): Model
persistUpdateOrCreate(string $modelClass, array $where, array $attributes): Model
persistUpsert(string $modelClass, array $rows, array $uniqueBy, array $update): int
Ejemplos:
use Flowstore\Lookup\DTO\IntegrationContext; use Flowstore\Lookup\Support\AbstractLookupProvider; final class ShopifyLookupProvider extends AbstractLookupProvider { public function resources(): array { return ['product']; } public function testConnection(IntegrationContext $context): void {} public function lookup(IntegrationContext $context, string $entity, array $params = []) { // ... obtén $payload remoto y mapea los campos de tu modelo // Crear o actualizar un registro único por external_id $product = $this->persistUpdateOrCreate( \App\Models\Product::class, ['external_id' => $payload['id']], [ 'name' => $payload['title'], 'price' => $payload['price'], ] ); // Upsert masivo $this->persistUpsert( \App\Models\Product::class, $rows /* [[ 'external_id' => '...', 'name' => '...' ], ...] */, ['external_id'], ['name','price'] ); return $product; // o devuelve el payload para que lo mapee el mapper } }
Notas:
modelClass
es el FQCN del modelo (App\Models\...
).- Asegúrate de que tu modelo tenga fillable/casts adecuados para los
attributes
. persistUpsert
sigue la firma deEloquent\Builder::upsert($rows, $uniqueBy, $update)
.