risetechapps / fusion-report-client
Laravel client for the Fusion Report Server — fluent, typed API for report generation.
Package info
github.com/risetechapps/fusion-report-client
pkg:composer/risetechapps/fusion-report-client
Requires
- php: ^8.4
- illuminate/bus: ^12
- illuminate/contracts: ^12
- illuminate/http: ^12
- illuminate/queue: ^12
- illuminate/support: ^12
Requires (Dev)
- fakerphp/faker: ^1.23
- laravel/pint: ^1.24
- mockery/mockery: ^1.6
- nunomaduro/collision: ^8.6
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.1
- pestphp/pest-plugin-laravel: ^4.0
README
Laravel client package para comunicação com o fusion-report-server — API fluente e tipada para geração de relatórios JasperSoft.
Requisitos
- PHP ^8.4
- Laravel 12
Instalação
composer require risetechapps/fusion-report-client
Publique o arquivo de configuração:
php artisan vendor:publish --tag=fusion-report
Configuração
// config/fusion-report.php return [ 'api_key' => env('FUSION_REPORT_API_KEY'), // Persiste cada geração na tabela fusion_report_generations 'log_generations' => env('FUSION_REPORT_LOG_GENERATIONS', true), 'webhook' => [ 'url' => env('FUSION_REPORT_WEBHOOK_URL'), 'secret' => env('FUSION_REPORT_WEBHOOK_SECRET'), // Exige assinatura HMAC valida. Com true (padrao) e 'secret' vazio, // a rota recusa a requisicao em vez de aceitar payload nao assinado. // So desligue em desenvolvimento local. 'require_signature' => env('FUSION_REPORT_WEBHOOK_REQUIRE_SIGNATURE', true), // Processa o callback numa fila em vez de dentro da requisicao. // false = inline; true = fila padrao; string = fila nomeada. // Exige worker rodando. Ver "Processamento em fila". 'queue' => env('FUSION_REPORT_WEBHOOK_QUEUE', false), // Path e middleware da rota que recebe o callback do servidor. // O package é agnóstico de contexto: a app hospedeira injeta aqui // o middleware que estabelece o contexto necessário (tenancy, auth, // etc.) ANTES do controller rodar. Ver a seção "Webhook". 'path' => env('FUSION_REPORT_WEBHOOK_PATH', '/fusion/webhook'), 'middleware' => ['api'], ], 'defaults' => [ // Tema padrão aplicado quando a geração não define um via ->theme(). // Ignorado nas gerações por ID de template. 'theme' => env('FUSION_REPORT_THEME', 'default'), // Locale padrão aplicado quando a geração não define um via ->locale(). 'locale' => env('FUSION_REPORT_LOCALE', 'pt_BR'), // Formato(s) usados quando a geracao nao define nenhum via ->format(). // O servidor exige 'format': sem este default, geracao sem ->format() // volta 422. Aceita 'pdf', 'pdf,xlsx' ou ['pdf', 'xlsx']. 'format' => env('FUSION_REPORT_FORMAT', 'pdf'), 'params' => [ // Parâmetros enviados em toda geração automaticamente // 'TENANT_ID' => '123', ], ], 'reports' => [ // Definições de relatórios pré-registradas // 'client_all' => \App\Reports\ClientesAllReport::class, ], ];
.env:
FUSION_REPORT_API_KEY=sua_api_key FUSION_REPORT_THEME=default FUSION_REPORT_LOCALE=pt_BR FUSION_REPORT_FORMAT=pdf FUSION_REPORT_WEBHOOK_URL=https://seu-projeto.com/fusion/webhook FUSION_REPORT_WEBHOOK_SECRET=seu_secret # obrigatorio: sem ele o webhook responde 503 FUSION_REPORT_WEBHOOK_QUEUE=false
api_keyé obrigatória. É ela que vai no headerX-API-KEY, a única credencial que o client usa. Sem ela, qualquer chamada — inclusivefile($id)->download()— lançaAuthenticationExceptionantes de a requisição sair, em vez de partir anônima e voltar como 401 genérico.A chave é lida do config a cada requisição, não uma vez na inicialização. Aplicações que a definem em runtime (bootstrapper de tenant, worker de fila que troca de contexto) são atendidas com o valor do contexto corrente.
Templates JRXML
Listar
Retorna todos os templates registrados. O endpoint do servidor é paginado
(per_page padrão 50, máximo 100), mas list() percorre as páginas
internamente — não é preciso paginar na aplicação.
$templates = FusionReport::templates()->list(); foreach ($templates as $template) { $template->id(); $template->name(); $template->theme(); $template->description(); $template->hasResources(); $template->originalFilename(); $template->createdAt(); $template->updatedAt(); } return response()->json($templates);
Buscar por ID
$template = FusionReport::templates()->find('uuid-do-template');
Cadastrar
$template = FusionReport::templates()->upload( filePath: resource_path('reports/client_all.jrxml'), name: 'client_all', theme: 'default', description: 'Relatório de clientes', resourcesZipPath: resource_path('reports/resources.zip'), // opcional ); $template->id(); $template->hasResources();
Atualizar por ID
$template = FusionReport::templates()->update( id: 'uuid-do-template', filePath: resource_path('reports/client_all_v2.jrxml'), name: 'client_all', // opcional theme: 'blue', // opcional description: 'Nova descrição', // opcional resourcesZipPath: resource_path('reports/resources.zip'), // opcional );
Atualizar por nome
$template = FusionReport::templates()->updateByName( name: 'client_all', filePath: resource_path('reports/client_all_v2.jrxml'), theme: 'blue', // opcional description: 'Nova descrição', // opcional resourcesZipPath: resource_path('reports/resources.zip'), // opcional );
Baixar o .jrxml registrado
Devolve o XML cru (application/xml), não o envelope JSON do resto da API.
Útil para conferir o que está publicado antes de reenviar.
$response = FusionReport::templates()->downloadJrxml('uuid-do-template'); $xml = $response->body(); // Comparar com o arquivo local antes de sincronizar $local = file_get_contents(resource_path('reports/client_all/default.jrxml')); if (trim($xml) !== trim($local)) { $this->info('Template divergente do servidor.'); }
Excluir por ID
FusionReport::templates()->delete('uuid-do-template');
Excluir por nome
FusionReport::templates()->deleteByName('client_all'); FusionReport::templates()->deleteByName('client_all', 'blue'); // tema específico
Histórico de gerações
Indisponível via
X-API-KEY— ambas as rotas são restritas ao dashboard (Sanctum) e retornam 401. Para histórico na integração por API key, use a tabela localfusion_report_generations. Ver Métodos indisponíveis via X-API-KEY.
// Por ID do template $generations = FusionReport::templates()->generations('uuid-do-template'); // Por nome do template $generations = FusionReport::templates()->generationsByName('client_all');
Geração de Relatórios
Report Definitions (recomendado)
Crie uma classe que estende ReportDefinition:
namespace App\Reports; use RiseTechApps\FusionReport\Datasources\Contracts\Datasource; use RiseTechApps\FusionReport\Datasources\InlineJsonDatasource; use RiseTechApps\FusionReport\Definitions\ReportDefinition; use RiseTechApps\FusionReport\Definitions\ReportProtection; use RiseTechApps\FusionReport\Definitions\ThemeFusion; use RiseTechApps\FusionReport\Resources\GenerationResource; use RiseTechApps\FusionReport\Webhook\WebhookPayload; class ClientesAllReport extends ReportDefinition { public function name(): string { return 'client_all'; } // ── Usado pelo comando fusion-report:sync (registro de templates) ── // // No servidor a identidade de um template é o par (name + theme), então // cada ThemeFusion é um template distinto, com seu próprio .jrxml e resources. public function themes(): array { return [ ThemeFusion::make('default') ->from(resource_path('reports/client_all/default.jrxml')) ->withResources(resource_path('reports/client_all/default.zip')) ->describedAs('Relatório consolidado de clientes'), // Cada tema tem seu próprio arquivo; resources/description são opcionais. ThemeFusion::make('blue') ->from(resource_path('reports/client_all/blue.jrxml')), ]; } public function datasource(array $params = []): Datasource { return InlineJsonDatasource::from( Cliente::query() ->when($params['ANO'] ?? null, fn($q, $v) => $q->where('ano', $v)) ->get() ->toArray() ); } public function defaultParams(): array { return ['ANO' => date('Y')]; } public function protection(): ?ReportProtection { return ReportProtection::password('secreta123'); // opcional } public function webhookParams(array $params = []): array { // Parâmetros anexados à URL base do webhook (query string). // Útil para carregar contexto que o callback precisa restabelecer. // Ex.: em apps multi-tenant, identificar o tenant na volta. return ['tenant_id' => tenancy()->getKey()]; } public function onGenerated(GenerationResource $generation, array $context = []): void { // Só no modo SÍNCRONO (generate()). generateAsync() não chama este hook: // ali o servidor apenas enfileirou o job e files() viria vazio. // O equivalente assíncrono é onWebhookReceived(), logo abaixo. if ($context['send_email'] ?? false) { Mail::to($context['email'])->send(new ReportReadyMail($generation)); } dispatch(new ProcessReportJob($generation->id())); } public function onWebhookReceived(WebhookPayload $payload, array $context = []): void { // Executado automaticamente quando o callback da geração assíncrona // chega — desde que o template_name do payload bata com name(). // O $context é o mesmo informado no disparo (->context([...])). if (! $payload->isSuccess()) { return; } if ($context['send_email'] ?? false) { Mail::to($context['email'])->send(new ReportReadyMail($payload)); } dispatch(new ProcessReportJob($payload->uuid())); } }
Context no fluxo assíncrono. Em
onGenerated(síncrono) o$contextchega direto por parâmetro. NoonWebhookReceived(assíncrono) o callback é uma requisição nova, então ocontextinformado no disparo é persistido com a geração e o package o recarrega e injeta automaticamente no segundo parâmetro — você não precisa consultar nada. Requerlog_generationshabilitado (default).O package ainda injeta no context o
localeefetivo da geração (o de->locale()ou, na falta, odefaults.localedo config), disponível como$context['locale']nos dois hooks. Umlocaleque você já tenha colocado no context manualmente é preservado (não é sobrescrito).
webhookParams()— os parâmetros retornados são anexados como query string à URL base (webhook.url) apenas nogenerateAsync(). A URL final fica, por exemplo,https://seu-app.com/fusion/webhook?tenant_id=abc. Recebe os params já mesclados da geração, então você pode derivar valores do próprio relatório. Retorne[](default) para não anexar nada.
Registre no config:
'reports' => [ 'client_all' => \App\Reports\ClientesAllReport::class, ],
Sincronizar templates com o servidor
O comando fusion-report:sync registra no servidor os templates definidos em
fusion-report.reports, expandindo o themes() de cada definition: cada
ThemeFusion vira um template (name + theme) independente. Ele primeiro lista o
que já existe no servidor (templates()->list(), indexado por name + theme) e
decide a ação por tema:
# Cadastra apenas os temas ainda não registrados (ignora os existentes) php artisan fusion-report:sync # Seleção interativa: escolhe quais temas já registrados atualizar php artisan fusion-report:sync --force # Atualiza TODOS os temas já registrados, sem perguntar php artisan fusion-report:sync --all
| Situação | sync |
sync --force |
sync --all |
|---|---|---|---|
Tema novo (name + theme) |
cadastra (upload) |
não toca¹ | cadastra (upload) |
| Tema já registrado | ignora | atualiza só se selecionado | atualiza (updateByName) |
themes() vazio |
falha (pula a definition) | — | falha (pula a definition) |
ThemeFusion sem ->from() / arquivo inexistente |
falha (pula o tema) | falha (pula o tema) | falha (pula o tema) |
¹ --force é cirúrgico: lista só os temas já registrados num seletor
(multiselect), processa apenas os marcados e ignora todo o resto — a barra de
progresso reflete só o trabalho real. Para cadastrar temas novos use o sync
normal ou --all.
-
--forceabre um seletor interativo dos temas já registrados; marque quais reenviar (espaço marca, Enter confirma). Sem terminal interativo (ex.: CI) ele cai para o comportamento de atualizar todos os registrados. -
--allatualiza todos os registrados e cadastra os novos, sem prompt — útil em pipelines / deploy. -
Se nada for selecionado no
--force, o comando informa e sai sem alterações. -
O nome do tema é o que você passa em
ThemeFusion::make('...')— não há mais umdefaults.themeglobal no registro. -
->withResources()é opcional: sem ele, o template é registrado sem recursos. -
Cada tema é processado isoladamente: uma falha não aborta os demais; o comando retorna código de saída diferente de zero se houver qualquer falha.
Geração síncrona
$generation = FusionReport::make('client_all')->format('pdf')->generate(); // Com params e context $generation = FusionReport::make('client_all', ['ANO' => '2025']) ->format(['pdf', 'xlsx']) ->context(['send_email' => true, 'email' => 'user@exemplo.com']) ->generate(); // Acessar arquivos $generation->id(); $generation->mode(); // 'sync' $generation->currentStatus(); // 'completed' $generation->frpId(); // ID do arquivo .frp para re-exportar depois $generation->files(); // Collection<FileResource> $generation->expiresAt(); // URLs dos arquivos $urls = $generation->files()->mapWithKeys( fn($f) => [$f->format() => $f->url()] ); // ['pdf' => 'https://...', 'xlsx' => 'https://...'] // Download direto $response = $generation->download('pdf'); return response($response->body(), 200, [ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'attachment; filename="relatorio.pdf"', ]);
Geração assíncrona
// webhook_url vai automaticamente do config $generation = FusionReport::make('client_all') ->format('pdf') ->generateAsync(); $id = $generation->id(); // salve para consultar depois // Com webhook diferente do padrão $generation = FusionReport::make('client_all') ->format('pdf') ->generateAsync(webhook: 'https://outro-endpoint.com/webhook');
O resultado da geração assíncrona chega pelo webhook, não por polling.
FusionReport::generation($id)->get()bate emGET /api/v1/generations/{id}, que o servidor restringe ao dashboard (Sanctum). Autenticado porX-API-KEY, retorna 401 →FusionReportException. Ver Métodos indisponíveis via X-API-KEY.Configure
webhook.urle implementeonWebhookReceived()na suaReportDefinition— é o caminho suportado para saber que o relatório ficou pronto.
Formatos
O servidor exige pelo menos um formato em toda geração. Quando você não chama
->format(), vale o defaults.format do config (pdf de fábrica):
// Usa defaults.format FusionReport::make('client_all')->generate(); // Sobrescreve o default — não acumula com ele FusionReport::make('client_all')->format('xlsx')->generate(); // Múltiplos formatos, nas três formas aceitas ->format(['pdf', 'xlsx']) ->format('pdf,xlsx') ->format('pdf')->format('xlsx')
Entrada é normalizada: espaços são removidos, maiúsculas viram minúsculas
('PDF' → 'pdf') e duplicatas caem fora.
Válidos: pdf, xlsx, xls, docx, odt, csv, html, frp.
Definir
defaults.formatcomonullou''volta ao comportamento anterior: o payload omiteformate o servidor responde 422 se a geração não informar um.
Parâmetros
O servidor recebe params como lista de strings CHAVE=valor. A conversão é
feita pelo package:
| Tipo PHP | Vira |
|---|---|
string, int, float |
o próprio valor |
bool |
true / false |
null |
string vazia |
array |
JSON (["a","b"], {"x":1}) |
BackedEnum |
o value do case |
DateTimeInterface |
ISO-8601 (DATE_ATOM) |
Stringable |
__toString() |
| qualquer outro | FusionReportException |
->params([ 'ANO' => 2026, 'ATIVO' => true, // ATIVO=true 'PERIODO' => new DateTimeImmutable('2026-01-01'), 'TAGS' => ['a', 'b'], // TAGS=["a","b"] ])
Geração pelo builder (sem registry)
use RiseTechApps\FusionReport\Datasources\InlineJsonDatasource; FusionReport::report('client_all') ->theme('blue') ->format(['pdf', 'xlsx']) ->params(['ANO' => '2026', 'EMPRESA' => 'Acme']) ->datasource(InlineJsonDatasource::from($data)) ->locale('pt_BR') ->protection(ReportProtection::password('senha123')) ->generate();
Geração por ID do template
FusionReport::reportById('uuid-do-template') ->format('pdf') ->generate(); FusionReport::reportById('uuid-do-template') ->format('pdf') ->generateAsync();
Datasources
Nenhum datasource
FusionReport::report('template')->format('pdf')->generate();
JSON inline
use RiseTechApps\FusionReport\Datasources\InlineJsonDatasource; ->datasource(InlineJsonDatasource::from($array)) ->datasource(InlineJsonDatasource::fromJson('{"key":"value"}'))
CSV inline
use RiseTechApps\FusionReport\Datasources\InlineCsvDatasource; ->datasource(InlineCsvDatasource::from($csvString)) ->datasource(InlineCsvDatasource::from($csvString, firstRow: true, delimiter: ';')) ->datasource(InlineCsvDatasource::fromRows($arrayOfRows))
URL JSON externa
use RiseTechApps\FusionReport\Datasources\JsonUrlDatasource; ->datasource(JsonUrlDatasource::get('https://api.exemplo.com/dados')) ->datasource(JsonUrlDatasource::get('https://api.exemplo.com/dados', [ 'Authorization' => 'Bearer token123', ])) ->datasource(JsonUrlDatasource::post('https://api.exemplo.com/dados'))
Perfil pré-configurado no servidor
use RiseTechApps\FusionReport\Datasources\ProfileDatasource; ->datasource(ProfileDatasource::use('nome_do_perfil'))
Proteção de documentos
use RiseTechApps\FusionReport\Definitions\ReportProtection; // Senha de abertura ->protection(ReportProtection::password('senha123')) // Senha de abertura + senha do owner ->protection(ReportProtection::withOwner('senha_user', 'senha_owner'))
Arquivos Gerados
$file = $generation->files()->first(fn($f) => $f->format() === 'pdf'); $file->id(); $file->format(); // 'pdf', 'xlsx', 'frp', etc. $file->filename(); $file->url(); // URL de download (requer autenticação) $file->expiresAt(); // Download do arquivo $response = $file->download(); // Re-exportar .frp para outro formato → retorna nova GenerationResource $novaGeracao = $file->export('xlsx'); $novaGeracao = FusionReport::file($frpId)->export('pdf');
Re-exportar uma geração inteira
Quando você tem o ID da geração e não o do arquivo, o servidor localiza o .frp
sozinho:
$nova = FusionReport::generation($generationId)->export('xlsx'); $nova->id(); // geração nova; a original continua intacta $nova->files();
Formatos aceitos: pdf, xlsx, xls, docx, odt, csv, html. O frp não
entra na lista — é o próprio intermediário.
Lança ReportNotFoundException quando a geração não tem .frp disponível ou ele
já expirou.
Webhook
O package registra automaticamente a rota do callback (default POST /fusion/webhook). Quando a geração assíncrona termina, o servidor faz POST nessa rota e o package:
- Verifica a assinatura HMAC (obrigatória — ver Verificação de assinatura).
- Atualiza o registro em
fusion_report_generations(status + URLs). - Descarta reentregas: se o status gravado já é o do payload, para aqui.
- Dispara
ReportGenerationCompleted/ReportGenerationFailed. - Chama
onWebhookReceived()daReportDefinitioncujoname()bate com otemplate_namedo payload.
Entrega duplicada. O servidor reentrega o callback até 3 vezes (backoff 10s/60s/180s) sempre que não recebe resposta — e o timeout dele é de 15s, contados enquanto o seu handler ainda está rodando. Um
onWebhookReceived()lento aumenta a própria chance de ser reexecutado.O passo 3 protege contra isso comparando o status já gravado com o do payload. Ainda assim, mantenha o hook leve: prefira
dispatch()de um job a fazer o trabalho pesado inline.
Processamento em fila (recomendado)
Por padrão o callback é processado dentro da requisição: o 200 só sai
depois do update, dos eventos e do onWebhookReceived(). Como o servidor
concede 15s e reentrega até 3 vezes, um hook que manda e-mail ou sobe arquivo
passa desse limite — o trabalho é feito, mas o servidor registra
Client webhook gave up after all attempts e reentrega. O log passa a mentir
sobre o que aconteceu.
Ligando a fila, a assinatura é verificada, o payload é enfileirado e o 200 sai
em milissegundos:
FUSION_REPORT_WEBHOOK_QUEUE=true # fila padrão FUSION_REPORT_WEBHOOK_QUEUE=webhooks # fila nomeada
O job é o ProcessFusionReportWebhook (tries = 3, backoff 10s/60s/180s).
Exige worker rodando. Sem
queue:workconsumindo a fila, os callbacks ficam parados e o fluxo assíncrono não completa. Por isso o padrão éfalse.
Contexto (tenancy, auth). O job é despachado de dentro da requisição, onde o
middleware de webhook.middleware já estabeleceu o contexto. Pacotes de tenancy
com suporte a fila propagam esse contexto automaticamente. Se o seu não faz
isso, mantenha queue => false ou trate a propagação no seu próprio
WebhookHandler.
Validação de payload malformado continua acontecendo antes do
enfileiramento, então o 422 segue valendo — depois do 200 não haveria como
avisar o servidor.
Deduplicação
A deduplicação usa dois guards, e basta um acusar entrega anterior:
| Guard | Força | Limite |
|---|---|---|
| Status já gravado na linha | Durável, sobrevive a flush de cache | Só existe com log_generations ligado |
Cache::add() da chave fusion-report:webhook:{uuid}:{status} |
Atômico — fecha a corrida de entregas simultâneas | Perde efeito se o cache for limpo, ou com driver null |
O TTL do cache sai de webhook.deduplication_ttl (minutos, padrão 1440). O
servidor reentrega no máximo por ~4 minutos, então 24h cobre com folga.
Com
log_generations = falsenão existe registro, e com ele some o$context— que chega sempre vazio emonWebhookReceived()e nos eventos — e o$owner, que vemnull. A deduplicação passa a depender só do cache. Os hooks continuam disparando; antes desta versão, o callback virava no-op silencioso.Se você usa
->context([...])ou->for($model), mantenha o log ligado.
Registro ausente com o log ligado é tratado como anomalia, não como
duplicata: o package registra Log::warning e não dispara nada. Em apps
multi-tenant isso normalmente significa que o middleware de webhook.middleware
não inicializou o tenant antes do controller — a consulta caiu no banco errado.
1. Reagindo ao callback — onWebhookReceived() (recomendado)
Basta implementar onWebhookReceived() na sua ReportDefinition (ver exemplo na seção Report Definitions). O package faz o roteamento pelo template_name automaticamente — você não precisa de rota nem handler próprios.
2. Handler customizado (opcional)
Para lógica global (independente de template), rebinde o contrato WebhookHandler num service provider seu:
use RiseTechApps\FusionReport\Contracts\WebhookHandler; $this->app->bind(WebhookHandler::class, \App\Handlers\MeuWebhookHandler::class);
Contexto da requisição (multi-tenant, auth, etc.)
O callback chega do servidor externo sem o contexto da sua aplicação. Como o package é agnóstico de contexto, a rota do webhook usa o middleware definido em webhook.middleware — é aí que a app hospedeira estabelece o que precisar antes do handler rodar.
Exemplo multi-tenant: a definition anexa o tenant_id na URL via webhookParams(), e um middleware seu lê esse valor e inicializa o tenant — assim a query da geração já cai no banco correto:
// app/Http/Middleware/InitializeFusionTenant.php public function handle(Request $request, Closure $next) { if ($tenantId = $request->query('tenant_id')) { tenancy()->initialize($tenantId); } return $next($request); }
// config/fusion-report.php 'webhook' => [ // ... 'middleware' => ['api', \App\Http\Middleware\InitializeFusionTenant::class], ],
API do payload
$payload->uuid(); // ID da geração $payload->status(); // 'completed' | 'failed' | ... $payload->isSuccess(); // status === 'completed' $payload->isFailed(); // status === 'failed' $payload->templateName(); // nome do template $payload->frpId(); // ID do .frp (re-exportar depois) $payload->error(); // mensagem de erro (quando falhou) $payload->downloads(); // Collection<WebhookDownload> foreach ($payload->downloads() as $download) { $download->format(); // 'pdf' $download->filename(); // 'relatorio.pdf' $download->url(); // URL de download (chave download_url) $download->expiresAt(); // expiração da URL (ou null) }
Verificação de assinatura
A verificação HMAC é feita automaticamente pelo package.
webhook.secreté obrigatório. Sem ele a rota responde 503 e não processa nada — a alternativa seria aceitar payload não assinado, permitindo que qualquer POST alterasse status e URLs de download das suas gerações e disparasse os eventos da aplicação.Para desenvolvimento local sem segredo, e só para isso:
FUSION_REPORT_WEBHOOK_REQUIRE_SIGNATURE=falseCom essa flag o package registra um
Log::warninga cada callback aceito sem verificação.
Respostas da rota de webhook:
| Situação | HTTP |
|---|---|
| Processado | 200 |
| Assinatura ausente ou inválida | 401 |
Payload sem id ou status |
422 (o servidor para de retentar) |
webhook.secret não configurado |
503 |
Para uso manual:
use RiseTechApps\FusionReport\Webhook\WebhookPayload; use RiseTechApps\FusionReport\Exceptions\WebhookSignatureException; try { $webhook = WebhookPayload::fromRequest($request, secret: config('fusion-report.webhook.secret')); } catch (WebhookSignatureException $e) { return response()->json(['error' => 'Unauthorized'], 401); }
Monitoramento Jasper
Indisponível via
X-API-KEY. As cinco rotas/api/v1/jasper/*são exclusivas do dashboard (Sanctum) — expõem estado global do servidor Java, que não é escopado por cliente. Chamadas autenticadas por API key retornam 401. Os métodos seguem no package para quem autentica de outra forma.
FusionReport::jasper()->formats(); // Formatos suportados FusionReport::jasper()->status(); // Status geral FusionReport::jasper()->jobs(); // Jobs assíncronos em andamento FusionReport::jasper()->fontsStatus(); // Status das fontes instaladas FusionReport::jasper()->fontsSync(); // Sincronizar fontes no servidor Java
Tratamento de Erros
Todas as exceptions do package herdam de FusionReportException, então um único
catch continua capturando tudo. As específicas existem para quem precisa
distinguir o caso.
use RiseTechApps\FusionReport\Exceptions\AuthenticationException; use RiseTechApps\FusionReport\Exceptions\AuthorizationException; use RiseTechApps\FusionReport\Exceptions\FusionReportException; use RiseTechApps\FusionReport\Exceptions\RateLimitException; use RiseTechApps\FusionReport\Exceptions\ReportNotFoundException; try { $generation = FusionReport::make('client_all')->format('pdf')->generate(); } catch (RateLimitException $e) { // Cota do plano esgotada — reenfileire para depois $wait = $e->retryAfter() ?? 60; // segundos, quando o servidor informa } catch (AuthenticationException $e) { // api_key não configurada (lançada antes da requisição sair), // api_key inválida, ou rota restrita ao dashboard } catch (AuthorizationException $e) { // Recurso de outra conta } catch (ReportNotFoundException $e) { // Template não encontrado (404 ou 410) } catch (FusionReportException $e) { // Validação (422) ou erro interno do servidor }
Falha de conexão fica de fora. Timeout e rede indisponível lançam
Illuminate\Http\Client\ConnectionException, que não herda deFusionReportException— não há resposta HTTP para o package traduzir. Capture separadamente quando isso importar:use Illuminate\Http\Client\ConnectionException; } catch (ConnectionException $e) { // O servidor pode ter concluído a geração mesmo assim — ver // "Limitações conhecidas". }
| HTTP | Situação | Exception |
|---|---|---|
| — | api_key não configurada — nenhuma requisição chega a sair |
AuthenticationException |
| 401 | Credencial ausente/inválida, ou rota só de dashboard | AuthenticationException |
| 403 | Recurso pertence a outra conta | AuthorizationException |
| 422 | Campo obrigatório ausente ou arquivo inválido | FusionReportException |
| 429 | Cota de requisições do plano esgotada | RateLimitException |
| 404 | ID não encontrado (route model binding) | ReportNotFoundException |
| 410 | Nome não encontrado, conflito ou erro interno | ReportNotFoundException / FusionReportException |
Eventos
O package dispara eventos em todo ciclo de geração. Use quando quiser reagir sem
ser dono da ReportDefinition — o hook onGenerated()/onWebhookReceived()
continua funcionando e os dois convivem.
use RiseTechApps\FusionReport\Events\ReportGenerationStarted; use RiseTechApps\FusionReport\Events\ReportGenerationCompleted; use RiseTechApps\FusionReport\Events\ReportGenerationFailed;
| Evento | Quando |
|---|---|
ReportGenerationStarted |
O servidor aceitou a requisição (síncrona e assíncrona) |
ReportGenerationCompleted |
Concluída — na síncrona, pela resposta; na assíncrona, pelo webhook |
ReportGenerationFailed |
Falhou, pelos mesmos dois caminhos |
Todos expõem as mesmas propriedades públicas:
class MyListener { public function handle(ReportGenerationCompleted $event): void { $event->generation; // GenerationResource $event->context; // array passado via ->context() $event->owner; // ?Model — de ->for() / ReportDefinition::owner() foreach ($event->generation->files() as $file) { Storage::put("relatorios/{$file->filename()}", $file->download()->body()); } } }
ReportGenerationFailed tem ainda $event->error.
Na geração síncrona o
Startedé seguido imediatamente porCompleted/Failed, no mesmo ciclo — a resposta já vem finalizada. Na assíncrona só oStartedsai na hora; o desfecho chega pelo webhook.
$event->errorcostuma virnullno caminho assíncrono: o servidor mantémerror_messageem$hidden. Ver Campos sempre vazios.
Métodos indisponíveis via X-API-KEY
O client autentica exclusivamente por X-API-KEY. Parte das rotas do servidor é
restrita ao dashboard e exige Bearer token do Sanctum — chamá-las com API key
retorna 401, que o package converte em FusionReportException.
Os métodos abaixo continuam existindo (podem ser usados por quem autentica de outra forma), mas não funcionam na integração por API key:
| Método | Rota | Alternativa |
|---|---|---|
generation($id)->get() |
GET /api/v1/generations/{id} |
webhook (onWebhookReceived()) |
generation($id)->status() |
GET /api/v1/generations/{id} |
webhook |
generation($id)->cancel() |
DELETE /api/v1/generations/{id} |
— |
templates()->generations($id) |
GET /api/v1/reports/{id}/generations |
tabela fusion_report_generations |
templates()->generationsByName($n) |
GET /api/v1/reports/name/{n}/generations |
tabela fusion_report_generations |
jasper()->formats() |
GET /api/v1/jasper/formats |
— |
jasper()->status() |
GET /api/v1/jasper/status |
— |
jasper()->jobs() |
GET /api/v1/jasper/jobs |
— |
jasper()->fontsStatus() |
GET /api/v1/jasper/fonts/status |
— |
jasper()->fontsSync() |
POST /api/v1/jasper/fonts/sync |
— |
Para histórico de gerações, use o log local do próprio package
(fusion_report_generations), alimentado na geração e atualizado pelo webhook —
ver Report Definitions e a trait
HasFusionReportGenerations.
Campos sempre vazios
O servidor mantém alguns campos em $hidden, então nunca chegam no JSON. Estes
accessors retornam sempre o valor padrão, independentemente da autenticação:
| Accessor | Retorna sempre |
|---|---|
GenerationResource::errorMessage() |
null |
GenerationResource::progressPercent() |
0 |
WebhookPayload::error() |
null |
TemplateResource::filePath() |
'' |
TemplateResource::resourcesPath() |
null |
Na prática: quando uma geração assíncrona falha, o webhook chega com
status: 'failed' e sem motivo. O texto do erro só aparece na geração
síncrona, na mensagem da FusionReportException.
Limitações conhecidas
Comportamentos que valem saber antes de integrar. Nenhum é acidental — todos estão registrados aqui em vez de serem descobertos em produção.
Timeout menor que o do servidor
O client espera 60s por resposta. O servidor concede 90s ao Jasper numa geração síncrona, e ainda gasta tempo depois disso baixando e armazenando os arquivos.
Numa geração síncrona pesada, o client desiste antes de o servidor terminar:
t=60s client estoura → Illuminate\Http\Client\ConnectionException
t=75s Jasper devolve o PDF
t=80s servidor grava os arquivos e debita a cota
→ geração existe e foi cobrada, e o client não tem o ID nem os arquivos
Duas consequências práticas:
- O timeout não é configurável — está fixo em
FusionReportHttp. ConnectionExceptionnão herda deFusionReportException. Quem fazcatch (FusionReportException $e)não pega falha de conexão. CaptureIlluminate\Http\Client\ConnectionExceptionseparadamente.
Para relatórios grandes, prefira generateAsync() com webhook.
Sem retry automático
Um 5xx transitório ou soluço de rede vira exceção direto no chamador. Retry não
foi adicionado de propósito: seria seguro apenas em leituras (get, download)
e perigoso em generate, async, upload e export, que criam estado e
consomem cota — o servidor cobra por requisição recebida, então três tentativas
de uma geração seriam três débitos.
Se precisar, implemente o retry na sua aplicação, envolvendo só as leituras.
URL do servidor não é configurável
FusionReportHttp::BASE_URL é uma constante. Não há config nem variável de
ambiente para apontar o client a outra instância — trocar exige editar o
package.
Replay de webhook
O servidor assina apenas o corpo, sem timestamp, então o par corpo+assinatura é válido indefinidamente. A deduplicação limita muito o estrago: um replay do mesmo status não reexecuta os efeitos. Proteção completa exigiria timestamp assinado, o que é mudança no servidor.
Com log_generations = false a deduplicação depende só do cache — expirado o
TTL, um replay volta a disparar os hooks.
Outros
- A poda de
fusion_report_generationsé fixa em 90 dias (FusionReportGeneration::prunable()), sem config. - Os dois
export()não registram a geração nova emfusion_report_generations, ao contrário degenerate()egenerateAsync(). generationsByName()não aceitatheme. Com o mesmo nome em temas diferentes, não dá para escolher qual histórico buscar.- O roteamento do webhook para a
ReportDefinitioncasa apenas porname(), ignorando o tema.
Referência completa — Endpoints cobertos
Rotas marcadas com ⚠️ exigem Sanctum e não funcionam via X-API-KEY
(ver seção acima).
| Endpoint | Método do package |
|---|---|
GET /api/v1/reports |
templates()->list() |
POST /api/v1/reports |
templates()->upload(...) |
GET /api/v1/reports/{id} |
templates()->find($id) |
POST /api/v1/reports/{id} |
templates()->update($id, ...) |
POST /api/v1/reports/name/{name} |
templates()->updateByName($name, ...) |
GET /api/v1/reports/{id}/jrxml |
templates()->downloadJrxml($id) |
DELETE /api/v1/reports/{id} |
templates()->delete($id) |
DELETE /api/v1/reports/name/{name} |
templates()->deleteByName($name, $theme) |
GET /api/v1/reports/{id}/generations |
templates()->generations($id) ⚠️ |
GET /api/v1/reports/name/{name}/generations |
templates()->generationsByName($name) ⚠️ |
POST /api/v1/generate |
report('name')->generate() |
POST /api/v1/async |
report('name')->generateAsync() |
POST /api/v1/reports/{id}/generate |
reportById($id)->generate() |
POST /api/v1/reports/{id}/async |
reportById($id)->generateAsync() |
GET /api/v1/generations/{id} |
generation($id)->get() ⚠️ |
DELETE /api/v1/generations/{id} |
generation($id)->cancel() ⚠️ |
GET /api/v1/files/{id}/download |
file($id)->download() |
POST /api/v1/files/{id}/export |
file($id)->export($format) |
POST /api/v1/generations/{id}/export |
generation($id)->export($format) |
GET /api/v1/jasper/formats |
jasper()->formats() ⚠️ |
GET /api/v1/jasper/status |
jasper()->status() ⚠️ |
GET /api/v1/jasper/jobs |
jasper()->jobs() ⚠️ |
GET /api/v1/jasper/fonts/status |
jasper()->fontsStatus() ⚠️ |
POST /api/v1/jasper/fonts/sync |
jasper()->fontsSync() ⚠️ |