client = $client; } public static function fromCredentials(string $url, string $apiKeyOrUsername, ?string $password = null): self { return new self(new NativeCurlClient($url, $apiKeyOrUsername, $password)); } /** * List Redmine issues. * * @param array $params Standard Redmine issue list filters. * * @return array */ public function issues(array $params = []): array { return $this->listIssues($params); } /** * List Redmine issues. * * @param array $params Standard Redmine issue list filters. * * @return array */ public function listIssues(array $params = []): array { $response = $this->client->getApi('issue')->list($params); if (!is_array($response)) { throw new RuntimeException('Could not list issues: ' . $this->stringifyError($response)); } return $response; } /** * Alias for listIssues() that makes Redmine's built-in issue filters * explicit at call sites. * * Useful filters include project_id, tracker_id, status_id, * assigned_to_id, author_id, category_id, fixed_version_id, query_id, * created_on, updated_on, sort, offset, and limit. * * @param array $filters Standard Redmine issue list filters. * * @return array */ public function filterIssues(array $filters = []): array { return $this->listIssues($filters); } /** * Search Redmine using the built-in /search.json endpoint. * * Typical params include project_id, all_words, titles_only, scope, * open_issues, issues, projects, news, documents, changesets, wiki_pages, * messages, offset, and limit. * * @param array $params Standard Redmine search params. * * @return array */ public function search(string $query, array $params = []): array { $query = trim($query); if ($query === '') { throw new RuntimeException('Searching Redmine requires a non-empty query.'); } return $this->getJson('/search', ['q' => $query] + $params) ?? []; } /** * Search only issues using Redmine's built-in /search.json endpoint. * * @param array $params Additional Redmine search params. * * @return array */ public function searchIssues(string $query, array $params = []): array { return $this->search($query, ['issues' => '1'] + $params); } /** * List Redmine projects. * * @param array $params Standard Redmine project list params. * * @return array */ public function projects(array $params = []): array { return $this->listProjects($params); } /** * List Redmine projects. * * @param array $params Standard Redmine project list params. * * @return array */ public function listProjects(array $params = []): array { return $this->getJson('/projects', $params) ?? []; } /** * Fetch a Redmine project by numeric id or identifier. * * @param array $params Standard Redmine project show params. * * @return array */ public function project(int|string $projectId, array $params = []): array { $projectId = trim((string) $projectId); if ($projectId === '') { throw new RuntimeException('Fetching a project requires a project id or identifier.'); } $response = $this->getJson('/projects/' . rawurlencode($projectId), $params); if (!is_array($response)) { throw new RuntimeException('Could not fetch project ' . $projectId . '.'); } return $response['project'] ?? $response; } /** * List Redmine users. * * @param array $params Standard Redmine user list params. * * @return array */ public function users(array $params = []): array { return $this->listUsers($params); } /** * List Redmine users. * * @param array $params Standard Redmine user list params. * * @return array */ public function listUsers(array $params = []): array { return $this->getJson('/users', $params) ?? []; } /** * Fetch one Redmine user. * * @param array $params Standard Redmine user show params. * * @return array */ public function user(int $userId, array $params = []): array { if ($userId <= 0) { throw new RuntimeException('Fetching a user requires a positive user id.'); } $response = $this->getJson('/users/' . rawurlencode((string) $userId), $params); if (!is_array($response)) { throw new RuntimeException('Could not fetch user #' . $userId . '.'); } return $response['user'] ?? $response; } /** * List memberships for a Redmine project. * * @param array $params Standard Redmine membership list params. * * @return array */ public function projectMemberships(int|string $projectId, array $params = []): array { $projectId = trim((string) $projectId); if ($projectId === '') { throw new RuntimeException('Fetching project memberships requires a project id or identifier.'); } return $this->getJson('/projects/' . rawurlencode($projectId) . '/memberships', $params) ?? []; } /** * Fetch a normal Redmine issue. * * @param array $issueIncludes Standard Redmine issue includes: * journals, attachments, children, * relations, changesets. * * @return array */ public function issue(int $issueId, array $issueIncludes = ['journals', 'attachments']): array { $issueResponse = $this->client->getApi('issue')->show($issueId, ['include' => $issueIncludes]); if (!is_array($issueResponse)) { throw new RuntimeException('Could not fetch issue #' . $issueId . ': ' . $this->stringifyError($issueResponse)); } return $issueResponse['issue'] ?? $issueResponse; } /** * Create a Redmine issue. * * Typical fields include project_id, subject, description, tracker_id, * status_id, priority_id, assigned_to_id, category_id, parent_issue_id, * parent_id, uploads, due_date, and start_date. * * @param array $fields * * @return array */ public function createIssue(array $fields): array { if (!isset($fields['project_id']) || !isset($fields['subject'])) { throw new RuntimeException('Creating an issue requires at least project_id and subject.'); } $response = $this->postJson('/issues', ['issue' => $fields]); return is_array($response['issue'] ?? null) ? $response['issue'] : $response; } /** * Update a Redmine issue. * * By default this uses the normal Redmine issue REST API and does not send * a Helpdesk email. To send a customer-visible Helpdesk response, pass * ['send_helpdesk_email' => true] with a notes field, or call * sendHelpdeskIssueResponse() directly. * * Typical fields include notes, subject, status_id, priority_id, * assigned_to_id, private_notes, parent_issue_id, parent_id, category_id, * uploads, due_date, and tracker_id. * * @param array $fields */ public function updateIssue(int $issueId, array $fields, array $options = []): bool { if ($fields === []) { throw new RuntimeException('Updating an issue requires at least one field.'); } if (!empty($options['send_helpdesk_email'])) { if (!isset($fields['notes']) || trim((string) $fields['notes']) === '') { throw new RuntimeException('Sending a Helpdesk email requires a non-empty notes field.'); } if (!empty($fields['private_notes'])) { throw new RuntimeException('A private note cannot be sent as a Helpdesk email.'); } $issueFields = $fields; unset($issueFields['notes'], $issueFields['private_notes']); if ($issueFields !== []) { $this->updateIssue($issueId, $issueFields); } $this->sendHelpdeskIssueResponse($issueId, (string) $fields['notes'], $options); return true; } $this->putJson('/issues/' . rawurlencode((string) $issueId), ['issue' => $fields]); return true; } /** * @return array */ public function issueWithStructure(int $issueId): array { return $this->issue($issueId, ['journals', 'attachments', 'children', 'relations']); } /** * @return array */ public function listIssueChildren(int $issueId, array $params = []): array { return $this->listIssues(['parent_id' => $issueId] + $params); } public function setIssueParent(int $issueId, int $parentIssueId): bool { if ($parentIssueId <= 0) { throw new RuntimeException('Setting an issue parent requires a positive parent_issue_id.'); } return $this->updateIssue($issueId, ['parent_issue_id' => $parentIssueId]); } public function clearIssueParent(int $issueId): bool { return $this->updateIssue($issueId, ['parent_issue_id' => null]); } /** * @return array */ public function listIssueRelations(int $issueId): array { return $this->getJson('/issues/' . rawurlencode((string) $issueId) . '/relations') ?? []; } /** * @return array */ public function issueRelation(int $relationId): array { if ($relationId <= 0) { throw new RuntimeException('Fetching an issue relation requires a positive relation id.'); } $response = $this->getJson('/relations/' . rawurlencode((string) $relationId)); if (!is_array($response)) { throw new RuntimeException('Could not fetch issue relation #' . $relationId . '.'); } return $response['relation'] ?? $response; } /** * @param array $fields * * @return array */ public function createIssueRelation(int $issueId, array $fields): array { if (!isset($fields['issue_to_id']) || (int) $fields['issue_to_id'] <= 0) { throw new RuntimeException('Creating an issue relation requires a positive issue_to_id.'); } $fields += ['relation_type' => 'relates']; $response = $this->postJson('/issues/' . rawurlencode((string) $issueId) . '/relations', ['relation' => $fields]); return is_array($response['relation'] ?? null) ? $response['relation'] : $response; } public function removeIssueRelation(int $relationId): bool { if ($relationId <= 0) { throw new RuntimeException('Removing an issue relation requires a positive relation id.'); } $this->deleteJson('/relations/' . rawurlencode((string) $relationId)); return true; } /** * @return array */ public function listProjectIssueCategories(int|string $projectId): array { $projectId = trim((string) $projectId); if ($projectId === '') { throw new RuntimeException('Listing issue categories requires a project id or identifier.'); } return $this->getJson('/projects/' . rawurlencode($projectId) . '/issue_categories') ?? []; } /** * @return array */ public function issueCategory(int $categoryId): array { if ($categoryId <= 0) { throw new RuntimeException('Fetching an issue category requires a positive category id.'); } $response = $this->getJson('/issue_categories/' . rawurlencode((string) $categoryId)); if (!is_array($response)) { throw new RuntimeException('Could not fetch issue category #' . $categoryId . '.'); } return $response['issue_category'] ?? $response; } /** * @param array $fields * * @return array */ public function createIssueCategory(int|string $projectId, array $fields): array { $projectId = trim((string) $projectId); if ($projectId === '') { throw new RuntimeException('Creating an issue category requires a project id or identifier.'); } if (!isset($fields['name']) || trim((string) $fields['name']) === '') { throw new RuntimeException('Creating an issue category requires a non-empty name.'); } $response = $this->postJson('/projects/' . rawurlencode($projectId) . '/issue_categories', ['issue_category' => $fields]); return is_array($response['issue_category'] ?? null) ? $response['issue_category'] : $response; } /** * @param array $fields * * @return array */ public function updateIssueCategory(int $categoryId, array $fields): array { if ($fields === []) { throw new RuntimeException('Updating an issue category requires at least one field.'); } $response = $this->putJson('/issue_categories/' . rawurlencode((string) $categoryId), ['issue_category' => $fields]); return is_array($response['issue_category'] ?? null) ? $response['issue_category'] : $response; } /** * @return array */ public function attachment(int $attachmentId): array { if ($attachmentId <= 0) { throw new RuntimeException('Fetching an attachment requires a positive attachment id.'); } $response = $this->getJson('/attachments/' . rawurlencode((string) $attachmentId)); if (!is_array($response)) { throw new RuntimeException('Could not fetch attachment #' . $attachmentId . '.'); } return $response['attachment'] ?? $response; } /** * @param array $source * * @return array */ public function uploadAttachment(array $source): array { $source = $this->normalizeAttachmentUploadSource($source); $filename = isset($source['filename']) ? trim((string) $source['filename']) : ''; $contentType = trim((string) ($source['content_type'] ?? 'application/octet-stream')); if ($contentType === '') { $contentType = 'application/octet-stream'; } if (isset($source['path'])) { $path = (string) $source['path']; if (!is_file($path) || !is_readable($path)) { throw new RuntimeException('Uploading an attachment from path requires a readable local file.'); } $bytes = file_get_contents($path); if ($bytes === false) { throw new RuntimeException('Could not read attachment file: ' . $path); } if ($filename === '') { $filename = basename($path); } } elseif (isset($source['base64_content'])) { if ($filename === '') { throw new RuntimeException('Uploading base64 attachment content requires filename.'); } $decoded = base64_decode((string) $source['base64_content'], true); if ($decoded === false) { throw new RuntimeException('Attachment base64_content is not valid base64.'); } $bytes = $decoded; } else { throw new RuntimeException('Uploading an attachment requires either path or base64_content.'); } if ($filename === '') { throw new RuntimeException('Uploading an attachment requires filename.'); } $response = $this->rawRequest( 'POST', PathSerializer::create('/uploads.json', ['filename' => $filename])->getPath(), 'application/octet-stream', $bytes ); $status = $response->getStatusCode(); if ($status >= 400) { throw new RuntimeException('Redmine upload failed with HTTP ' . $status . ': ' . $response->getContent()); } $decoded = $this->decodeJsonResponse($response, 'Redmine upload'); $upload = is_array($decoded['upload'] ?? null) ? $decoded['upload'] : $decoded; if (isset($source['description'])) { $upload['description'] = (string) $source['description']; } $upload += [ 'filename' => $filename, 'content_type' => $contentType, ]; return $upload; } /** * @param array $source * * @return array */ private function normalizeAttachmentUploadSource(array $source): array { if (isset($source['file']) && is_array($source['file'])) { $file = $source['file']; unset($source['file']); foreach ([ 'path' => 'path', 'filename' => 'filename', 'name' => 'filename', 'content_type' => 'content_type', 'mime_type' => 'content_type', 'mimeType' => 'content_type', 'media_type' => 'content_type', 'description' => 'description', 'base64_content' => 'base64_content', 'base64' => 'base64_content', 'data' => 'base64_content', 'blob' => 'base64_content', 'data_url' => 'data_url', 'url' => 'data_url', ] as $from => $to) { if (!isset($source[$to]) && isset($file[$from])) { $source[$to] = $file[$from]; } } } foreach ([ 'name' => 'filename', 'mime_type' => 'content_type', 'mimeType' => 'content_type', 'media_type' => 'content_type', 'base64' => 'base64_content', 'data' => 'base64_content', 'blob' => 'base64_content', ] as $from => $to) { if (!isset($source[$to]) && isset($source[$from])) { $source[$to] = $source[$from]; } } if (isset($source['data_url']) || (isset($source['base64_content']) && str_starts_with((string) $source['base64_content'], 'data:'))) { $dataUrl = (string) ($source['data_url'] ?? $source['base64_content']); $parsed = $this->parseAttachmentDataUrl($dataUrl); $source['base64_content'] = $parsed['base64_content']; if (!isset($source['content_type'])) { $source['content_type'] = $parsed['content_type']; } if (!isset($source['filename']) || trim((string) $source['filename']) === '') { $source['filename'] = $this->defaultAttachmentFilename((string) $source['content_type']); } } return $source; } /** * @return array{content_type:string,base64_content:string} */ private function parseAttachmentDataUrl(string $dataUrl): array { if (!preg_match('/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s', $dataUrl, $matches)) { throw new RuntimeException('Attachment data_url must be a base64 data URL.'); } $contentType = trim($matches[1] !== '' ? $matches[1] : 'application/octet-stream'); return [ 'content_type' => $contentType !== '' ? $contentType : 'application/octet-stream', 'base64_content' => $matches[2], ]; } private function defaultAttachmentFilename(string $contentType): string { return match (strtolower($contentType)) { 'application/pdf' => 'attachment.pdf', 'text/plain' => 'attachment.txt', 'text/csv' => 'attachment.csv', 'application/json' => 'attachment.json', 'image/jpeg' => 'attachment.jpg', 'image/png' => 'attachment.png', 'image/gif' => 'attachment.gif', default => 'attachment.bin', }; } /** * @return array */ public function updateAttachment(int $attachmentId, array $fields): array { if ($fields === []) { throw new RuntimeException('Updating an attachment requires at least one field.'); } $response = $this->putJson('/attachments/' . rawurlencode((string) $attachmentId), ['attachment' => $fields]); return is_array($response['attachment'] ?? null) ? $response['attachment'] : $response; } /** * @return array */ public function downloadAttachment(int $attachmentId, string $destinationPath, bool $includeBase64 = false, int $maxBase64Bytes = 262144): array { $destinationPath = $this->safeDownloadPath($destinationPath); $response = $this->rawRequest('GET', '/attachments/download/' . rawurlencode((string) $attachmentId)); $status = $response->getStatusCode(); if ($status >= 400) { throw new RuntimeException('Redmine attachment download failed with HTTP ' . $status . ': ' . $response->getContent()); } $bytes = $response->getContent(); if (file_put_contents($destinationPath, $bytes) === false) { throw new RuntimeException('Could not write attachment download to ' . $destinationPath . '.'); } $result = [ 'attachment_id' => $attachmentId, 'path' => $destinationPath, 'bytes' => strlen($bytes), 'content_type' => $response->getContentType(), ]; if ($includeBase64 && strlen($bytes) <= $maxBase64Bytes) { $result['base64_content'] = base64_encode($bytes); } elseif ($includeBase64) { $result['base64_omitted'] = true; $result['base64_limit_bytes'] = $maxBase64Bytes; } return $result; } /** * Send a Helpdesk email response for an existing Helpdesk-backed issue. * * This uses the RedmineUP Helpdesk API endpoint that corresponds to sending * a note with the Helpdesk UI's "Send note" checkbox enabled. * * @param array $options Optional to_address, cc_address, * bcc_address, and status_id fields. * * @return array */ public function sendHelpdeskIssueResponse(int $issueId, string $content, array $options = []): array { if (trim($content) === '') { throw new RuntimeException('Sending a Helpdesk response requires non-empty content.'); } $message = [ 'issue_id' => $issueId, 'content' => $content, ]; if (isset($options['status_id'])) { $message['status_id'] = $options['status_id']; } $payload = ['message' => $message]; foreach (['to_address', 'cc_address', 'bcc_address'] as $key) { if (isset($options[$key])) { $payload[$key] = $options[$key]; } } $response = $this->postJson('/helpdesk/email_note', $payload); if (!isset($response['message']) || !is_array($response['message'])) { throw new RuntimeException('Helpdesk email response returned unexpected JSON.'); } return $response['message']; } /** * Delete a Redmine issue. */ public function deleteIssue(int $issueId): bool { $issueApi = $this->client->getApi('issue'); $issueApi->remove($issueId); $this->assertLastApiResponseSucceeded($issueApi, 'delete issue #' . $issueId); return true; } /** * Alias for deleteIssue(). */ public function removeIssue(int $issueId): bool { return $this->deleteIssue($issueId); } /** * @param array $issueIncludes Standard Redmine issue includes. * * @return array */ public function issueWithHelpdesk(int $issueId, int $messageLimit = 100, array $issueIncludes = ['journals', 'attachments']): array { return $this->getIssueWithHelpdeskContext($issueId, $messageLimit, $issueIncludes); } /** * @param array $issueIncludes Standard Redmine issue includes. * * @return array */ public function getIssueWithHelpdeskContext( int $issueId, int $messageLimit = 100, array $issueIncludes = ['journals', 'attachments'] ): array { $issue = $this->issue($issueId, $issueIncludes); $ticket = $this->helpdeskTicketByIssue($issueId); $messages = $this->helpdeskMessagesByIssue($issueId, $messageLimit); return [ 'issue' => $issue, 'helpdesk' => [ 'available' => $ticket !== null || $messages !== [], 'ticket' => $ticket, 'journal_messages' => $messages, ], 'meta' => [ 'issue_id' => $issueId, 'issue_includes' => array_values($issueIncludes), 'helpdesk_sources' => [ 'ticket' => '/helpdesk_search/issues/' . $issueId . '/ticket', 'messages' => '/helpdesk_search/issues/' . $issueId . '/messages', ], ], ]; } /** * @return array|null */ public function helpdeskTicketByIssue(int $issueId): ?array { return $this->getHelpdeskTicketByIssue($issueId); } /** * @return array|null */ public function getHelpdeskTicketByIssue(int $issueId): ?array { $response = $this->getJson('/helpdesk_search/issues/' . rawurlencode((string) $issueId) . '/ticket', [], [403, 404]); if (!is_array($response) || !isset($response['helpdesk_ticket']) || !is_array($response['helpdesk_ticket'])) { return null; } return $response['helpdesk_ticket']; } /** * @return array */ public function helpdeskMessagesByIssue(int $issueId, int $limit = 100): array { return $this->getHelpdeskMessagesByIssue($issueId, $limit); } /** * @return array */ public function getHelpdeskMessagesByIssue(int $issueId, int $limit = 100): array { $response = $this->getJson( '/helpdesk_search/issues/' . rawurlencode((string) $issueId) . '/messages', ['limit' => max(1, min($limit, 200))], [403, 404] ); if (!is_array($response) || !isset($response['journal_messages']) || !is_array($response['journal_messages'])) { return []; } return $response['journal_messages']; } /** * @param array $params * * @return array|null */ private function getJson(string $path, array $params = [], array $nullStatuses = []): ?array { if (!$this->client instanceof HttpClient) { throw new RuntimeException('Configured Redmine client cannot issue raw HTTP requests.'); } $requestPath = $this->buildPath($path, $params); $response = $this->client->request(HttpFactory::makeJsonRequest('GET', $requestPath)); $status = $response->getStatusCode(); if (in_array($status, $nullStatuses, true)) { return null; } if ($status >= 400) { throw new RuntimeException('Redmine GET ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent()); } $body = $response->getContent(); if ($body === '') { return []; } try { $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); } catch (Throwable $exception) { throw new RuntimeException('Redmine GET ' . $requestPath . ' returned invalid JSON.', 0, $exception); } if (!is_array($decoded)) { throw new RuntimeException('Redmine GET ' . $requestPath . ' returned invalid JSON.'); } return $decoded; } /** * @param array $payload * * @return array */ private function postJson(string $path, array $payload): array { $requestPath = $this->buildPath($path, []); $encoded = json_encode($payload); if ($encoded === false) { throw new RuntimeException('Could not encode Redmine POST payload.'); } $response = $this->rawRequest('POST', $requestPath, 'application/json', $encoded); $status = $response->getStatusCode(); if ($status >= 400) { throw new RuntimeException('Redmine POST ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent()); } return $this->decodeJsonResponse($response, 'Redmine POST ' . $requestPath); } /** * @param array $payload * * @return array */ private function putJson(string $path, array $payload): array { $requestPath = $this->buildPath($path, []); $encoded = json_encode($payload); if ($encoded === false) { throw new RuntimeException('Could not encode Redmine PUT payload.'); } $response = $this->rawRequest('PUT', $requestPath, 'application/json', $encoded); $status = $response->getStatusCode(); if ($status >= 400) { throw new RuntimeException('Redmine PUT ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent()); } return $this->decodeJsonResponse($response, 'Redmine PUT ' . $requestPath); } private function deleteJson(string $path): void { $requestPath = $this->buildPath($path, []); $response = $this->rawRequest('DELETE', $requestPath); $status = $response->getStatusCode(); if ($status >= 400) { throw new RuntimeException('Redmine DELETE ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent()); } } private function rawRequest(string $method, string $path, string $contentType = '', string $content = ''): \Redmine\Http\Response { if (!$this->client instanceof HttpClient) { throw new RuntimeException('Configured Redmine client cannot issue raw HTTP requests.'); } return $this->client->request(HttpFactory::makeRequest($method, $path, $contentType, $content)); } /** * @return array */ private function decodeJsonResponse(\Redmine\Http\Response $response, string $action): array { $body = $response->getContent(); if ($body === '') { return []; } try { $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); } catch (Throwable $exception) { throw new RuntimeException($action . ' returned invalid JSON.', 0, $exception); } if (!is_array($decoded)) { throw new RuntimeException($action . ' returned invalid JSON.'); } return $decoded; } /** * @param array $params */ private function buildPath(string $path, array $params): string { return PathSerializer::create($path . '.json', $params)->getPath(); } private function safeDownloadPath(string $path): string { $path = trim($path); if ($path === '' || str_contains($path, "\0")) { throw new RuntimeException('Attachment download requires a safe local destination path.'); } $directory = dirname($path); $realDirectory = realpath($directory); if ($realDirectory === false || !is_dir($realDirectory)) { throw new RuntimeException('Attachment download destination directory does not exist: ' . $directory); } $resolved = $realDirectory . DIRECTORY_SEPARATOR . basename($path); $repoRoot = realpath(dirname(__DIR__, 2)); $tmpRoot = realpath(sys_get_temp_dir()); foreach (array_filter([$repoRoot, $tmpRoot]) as $allowedRoot) { if ($resolved === $allowedRoot || str_starts_with($resolved, $allowedRoot . DIRECTORY_SEPARATOR)) { return $resolved; } } throw new RuntimeException('Attachment downloads must write under /tmp or the repository tree.'); } /** * @param mixed $api */ private function assertLastApiResponseSucceeded($api, string $action): void { if (!method_exists($api, 'getLastResponse')) { return; } $response = $api->getLastResponse(); $status = $response->getStatusCode(); if ($status >= 400) { throw new RuntimeException('Could not ' . $action . ': HTTP ' . $status . ': ' . $response->getContent()); } } /** * @param mixed $response * * @return array */ private function xmlResponseToArray($response): array { if ($response instanceof SimpleXMLElement) { $encoded = json_encode($response); if ($encoded === false) { throw new RuntimeException('Could not encode Redmine XML response.'); } $decoded = json_decode($encoded, true); if (is_array($decoded)) { return $decoded; } } if ($response === '') { return []; } throw new RuntimeException('Unexpected Redmine response: ' . $this->stringifyError($response)); } /** * @param mixed $value */ private function stringifyError($value): string { if ($value === false) { return 'empty response'; } if (is_scalar($value)) { return (string) $value; } return 'unexpected response'; } }