TenantAtlas/apps/platform/app/Services/Pdf/PdfRendererClient.php
Ahmed Darrazi 99c2b5b6e6
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m59s
feat(report): implement management report pdf v1
Added PDF generation service for management reports as per Spec 378, including Gotenberg integration in docker-compose and configuration updates.
2026-06-14 20:29:21 +02:00

202 lines
7.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Pdf;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
final class PdfRendererClient
{
public function healthCheck(?string $correlationId = null): PdfRenderResult
{
try {
$response = Http::timeout($this->timeoutSeconds())
->connectTimeout($this->connectTimeoutSeconds())
->withHeaders($this->correlationHeaders($correlationId))
->get($this->url($this->configString('health_path', '/health')));
} catch (ConnectionException) {
return PdfRenderResult::failure(
PdfRenderResult::RENDERER_UNAVAILABLE,
'PDF renderer is unavailable.',
correlationId: $correlationId,
);
}
if ($response->successful()) {
return PdfRenderResult::success(
pdfBytes: '',
contentType: (string) $response->header('Content-Type', 'text/plain'),
correlationId: $this->responseCorrelationId($response, $correlationId),
statusCode: $response->status(),
);
}
return PdfRenderResult::failure(
PdfRenderResult::RENDERER_UNAVAILABLE,
'PDF renderer health check failed.',
statusCode: $response->status(),
correlationId: $this->responseCorrelationId($response, $correlationId),
);
}
public function render(PdfRenderRequest $request): PdfRenderResult
{
try {
$pending = Http::timeout($this->timeoutSeconds())
->connectTimeout($this->connectTimeoutSeconds())
->accept('application/pdf')
->withHeaders(array_filter(array_merge(
$this->correlationHeaders($request->correlationId),
[
'Gotenberg-Output-Filename' => $request->outputFilename
?? $this->configString('output_filename', 'tenantpilot-management-report'),
],
)));
$pending->attach('files', $request->html, 'index.html', [
'Content-Type' => 'text/html; charset=UTF-8',
]);
foreach ($request->assets as $filename => $contents) {
$pending->attach('files', $contents, $filename);
}
$response = $pending->post(
$this->url($this->configString('html_route', '/forms/chromium/convert/html')),
$this->stringFormOptions($request->options),
);
} catch (ConnectionException) {
return PdfRenderResult::failure(
PdfRenderResult::RENDERER_UNAVAILABLE,
'PDF renderer did not respond before the configured timeout or was unavailable.',
correlationId: $request->correlationId,
);
}
return $this->resultFromResponse($response, $request->correlationId);
}
private function resultFromResponse(Response $response, ?string $fallbackCorrelationId): PdfRenderResult
{
$correlationId = $this->responseCorrelationId($response, $fallbackCorrelationId);
if ($response->successful()) {
$body = $response->body();
$contentType = (string) $response->header('Content-Type', 'application/pdf');
if ($body === '' || (! str_contains(strtolower($contentType), 'pdf') && ! str_starts_with($body, '%PDF'))) {
return PdfRenderResult::failure(
PdfRenderResult::INVALID_RESPONSE,
'PDF renderer returned an invalid PDF response.',
statusCode: $response->status(),
correlationId: $correlationId,
);
}
return PdfRenderResult::success(
pdfBytes: $body,
contentType: $contentType,
outputFilename: $this->filenameFromDisposition((string) $response->header('Content-Disposition', '')),
correlationId: $correlationId,
statusCode: $response->status(),
);
}
if ($response->status() === 400) {
return PdfRenderResult::failure(
PdfRenderResult::INVALID_REQUEST,
'PDF renderer rejected the HTML render request.',
statusCode: $response->status(),
correlationId: $correlationId,
);
}
if ($response->status() === 503) {
return PdfRenderResult::failure(
PdfRenderResult::RENDERER_TIMEOUT,
'PDF renderer did not complete within the configured timeout.',
statusCode: $response->status(),
correlationId: $correlationId,
);
}
return PdfRenderResult::failure(
PdfRenderResult::RENDERER_FAILED,
'PDF renderer failed to create the document.',
statusCode: $response->status(),
correlationId: $correlationId,
);
}
/**
* @param array<string, bool|float|int|string> $options
* @return array<string, string>
*/
private function stringFormOptions(array $options): array
{
$formatted = [];
foreach ($options as $key => $value) {
$formatted[$key] = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value;
}
return $formatted;
}
/**
* @return array<string, string>
*/
private function correlationHeaders(?string $correlationId): array
{
if ($correlationId === null || trim($correlationId) === '') {
return [];
}
return [
$this->configString('correlation_header', 'Gotenberg-Trace') => trim($correlationId),
];
}
private function responseCorrelationId(Response $response, ?string $fallback): ?string
{
$header = $this->configString('correlation_header', 'Gotenberg-Trace');
$value = $response->header($header);
return is_string($value) && trim($value) !== '' ? trim($value) : $fallback;
}
private function url(string $path): string
{
return rtrim($this->configString('base_url', 'http://gotenberg:3000'), '/').'/'.ltrim($path, '/');
}
private function timeoutSeconds(): int
{
return max(1, (int) config('tenantpilot.pdf_renderer.timeout_seconds', 30));
}
private function connectTimeoutSeconds(): int
{
return max(1, (int) config('tenantpilot.pdf_renderer.connect_timeout_seconds', 5));
}
private function configString(string $key, string $default): string
{
$value = config('tenantpilot.pdf_renderer.'.$key, $default);
return is_string($value) && trim($value) !== '' ? trim($value) : $default;
}
private function filenameFromDisposition(string $disposition): ?string
{
if (preg_match('/filename="?([^";]+)"?/i', $disposition, $matches) !== 1) {
return null;
}
return basename($matches[1]);
}
}