Added PDF generation service for management reports as per Spec 378, including Gotenberg integration in docker-compose and configuration updates. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #449
202 lines
7.0 KiB
PHP
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]);
|
|
}
|
|
}
|