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 $options * @return array */ 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 */ 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]); } }