Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 109
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
PandaDocService
0.00% covered (danger)
0.00%
0 / 109
0.00% covered (danger)
0.00%
0 / 12
930
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDocumentDetails
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 createSigningSession
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 resendDocument
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 createDocumentFromTemplate
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 getDocumentStatus
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 sendDocument
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 inspectTemplate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 listTemplates
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 downloadDocument
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 makeRequest
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
56
 parseStatusCode
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Document\Service;
6
7use App\Domain\Document\Data\PandaDocDocumentData;
8use App\Domain\Document\Data\PandaDocDocumentRefData;
9use App\Domain\Document\Data\PandaDocSigningSessionData;
10use App\Domain\Document\Data\PandaDocTemplateDetailsData;
11use App\Support\Row;
12use RuntimeException;
13
14/**
15 * PandaDoc HTTP client.
16 *
17 * Implements {@see PandaDocClientInterface} so application code depends only
18 * on our DTOs and the interface. All PandaDoc wire-format details (field
19 * names, URL paths, auth headers) are confined to this class.
20 */
21final readonly class PandaDocService implements PandaDocClientInterface
22{
23    public function __construct(
24        private string $apiKey,
25        private string $baseUrl,
26    ) {}
27
28    public function getDocumentDetails(string $documentId): PandaDocDocumentData
29    {
30        $response = $this->makeRequest('GET', '/documents/' . urlencode($documentId) . '/details');
31
32        return PandaDocDocumentData::fromPandaDocResponse($response);
33    }
34
35    public function createSigningSession(string $documentId, string $recipientEmail): PandaDocSigningSessionData
36    {
37        $response = $this->makeRequest('POST', '/documents/' . urlencode($documentId) . '/session', [
38            'recipient' => $recipientEmail,
39            'lifetime' => 900,
40        ]);
41
42        return PandaDocSigningSessionData::fromPandaDocResponse($response);
43    }
44
45    public function resendDocument(string $documentId, ?string $message = null): void
46    {
47        $body = [
48            'message' => $message ?? 'Please review and sign this document.',
49            'silent' => false,
50        ];
51
52        $this->makeRequest('POST', '/documents/' . urlencode($documentId) . '/send', $body);
53    }
54
55    public function createDocumentFromTemplate(
56        string $templateId,
57        string $documentName,
58        string $recipientEmail,
59        string $firstName,
60        string $lastName,
61        array $fields = [],
62        array $tokens = [],
63        string $recipientRole = 'Signer',
64    ): PandaDocDocumentRefData {
65        $body = [
66            'name' => $documentName,
67            'template_uuid' => $templateId,
68            'recipients' => [
69                [
70                    'email' => $recipientEmail,
71                    'first_name' => $firstName,
72                    'last_name' => $lastName,
73                    'role' => $recipientRole,
74                ],
75            ],
76        ];
77
78        if ($fields !== []) {
79            $body['fields'] = $fields;
80        }
81
82        if ($tokens !== []) {
83            $body['tokens'] = array_map(
84                static fn(string $name, string $value) => ['name' => $name, 'value' => $value],
85                array_keys($tokens),
86                array_values($tokens),
87            );
88        }
89
90        $response = $this->makeRequest('POST', '/documents', $body);
91
92        return PandaDocDocumentRefData::fromPandaDocResponse($response);
93    }
94
95    public function getDocumentStatus(string $documentId): string
96    {
97        $response = $this->makeRequest('GET', '/documents/' . urlencode($documentId));
98
99        return Row::nullableString($response, 'status') ?? '';
100    }
101
102    public function sendDocument(string $documentId, string $message = 'Please review and sign this document.'): void
103    {
104        $this->makeRequest('POST', '/documents/' . urlencode($documentId) . '/send', [
105            'message' => $message,
106            'silent' => false,
107        ]);
108    }
109
110    public function inspectTemplate(string $templateId): PandaDocTemplateDetailsData
111    {
112        $response = $this->makeRequest('GET', '/templates/' . urlencode($templateId) . '/details');
113
114        return PandaDocTemplateDetailsData::fromPandaDocResponse($response);
115    }
116
117    public function listTemplates(): array
118    {
119        $response = $this->makeRequest('GET', '/templates?count=100');
120
121        $templates = [];
122        $results = $response['results'] ?? [];
123        if (is_array($results)) {
124            foreach ($results as $template) {
125                if (!is_array($template)) {
126                    continue;
127                }
128                $id = Row::nullableString($template, 'id');
129                $name = Row::nullableString($template, 'name');
130                if ($id !== null && $id !== '') {
131                    $templates[$id] = $name ?? '(unnamed)';
132                }
133            }
134        }
135
136        return $templates;
137    }
138
139    public function downloadDocument(string $documentId): string
140    {
141        $url = $this->baseUrl . '/documents/' . urlencode($documentId) . '/download';
142
143        $context = stream_context_create([
144            'http' => [
145                'method' => 'GET',
146                'header' => implode("\r\n", [
147                    'Authorization: API-Key ' . $this->apiKey,
148                    'Accept: application/pdf',
149                ]),
150                'ignore_errors' => true,
151            ],
152        ]);
153
154        $result = file_get_contents($url, false, $context);
155
156        if ($result === false) {
157            throw new RuntimeException('PandaDoc download failed: unable to connect to ' . $url);
158        }
159
160        /** @var array<int, string> $http_response_header */
161        $statusCode = $this->parseStatusCode($http_response_header);
162
163        if ($statusCode < 200 || $statusCode >= 300) {
164            throw new RuntimeException(
165                sprintf('PandaDoc download failed with status %d', $statusCode),
166            );
167        }
168
169        return $result;
170    }
171
172    /**
173     * Make an HTTP request to the PandaDoc API.
174     *
175     * @param array<string, mixed>|null $body
176     * @param string $method
177     * @param string $path
178     *
179     * @return array<mixed>
180     */
181    private function makeRequest(string $method, string $path, ?array $body = null): array
182    {
183        $url = $this->baseUrl . $path;
184
185        $headers = [
186            'Authorization: API-Key ' . $this->apiKey,
187            'Content-Type: application/json',
188            'Accept: application/json',
189        ];
190
191        $options = [
192            'http' => [
193                'method' => $method,
194                'header' => implode("\r\n", $headers),
195                'ignore_errors' => true,
196            ],
197        ];
198
199        if ($body !== null) {
200            $options['http']['content'] = json_encode($body, JSON_THROW_ON_ERROR);
201        }
202
203        $context = stream_context_create($options);
204        $result = file_get_contents($url, false, $context);
205
206        if ($result === false) {
207            throw new RuntimeException('PandaDoc API request failed: unable to connect to ' . $url);
208        }
209
210        /** @var array<int, string> $http_response_header */
211        $statusCode = $this->parseStatusCode($http_response_header);
212
213        if ($statusCode < 200 || $statusCode >= 300) {
214            throw new RuntimeException(
215                sprintf('PandaDoc API request failed with status %d: %s', $statusCode, $result),
216            );
217        }
218
219        if ($result === '') {
220            return [];
221        }
222
223        $decoded = json_decode($result, true, 512, JSON_THROW_ON_ERROR);
224
225        if (!is_array($decoded)) {
226            throw new RuntimeException('PandaDoc API returned invalid JSON response');
227        }
228
229        return $decoded;
230    }
231
232    /**
233     * Parse HTTP status code from response headers.
234     *
235     * @param array<int, string> $headers
236     */
237    private function parseStatusCode(array $headers): int
238    {
239        foreach ($headers as $header) {
240            if (preg_match('/^HTTP\/[\d.]+ (\d{3})/', $header, $matches)) {
241                return (int)$matches[1];
242            }
243        }
244
245        return 0;
246    }
247}