Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php

494 lines
18 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Http\SessionStoreInterface;
/**
* Gateway for Business Central SOAP codeunit calls used by helpdesk communication.
*/
class BcSoapGateway
{
private const CONNECT_TIMEOUT = 10;
private const REQUEST_TIMEOUT = 30;
private const CODEUNIT_NAME = 'ICISupportWebserviceAPI';
private const SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT = 'urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI:GetTicketCommunicationText';
private const SOAP_ACTION_GET_TICKET_FILE = 'urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI:GetTicketFile';
public function __construct(
private readonly HelpdeskSettingsGateway $settingsGateway,
private readonly HelpdeskOAuthTokenService $oauthTokenService,
private readonly SessionStoreInterface $sessionStore
) {
}
/**
* @return array{
* ok: bool,
* soap_used: bool,
* return_value: int,
* communication_text: string,
* message_entries: string,
* error?: string
* }
*/
public function getTicketCommunicationText(string $ticketNo, string $messageEntries = ''): array
{
$ticketNo = trim($ticketNo);
if ($ticketNo === '') {
return [
'ok' => false,
'soap_used' => false,
'return_value' => 0,
'communication_text' => '',
'message_entries' => '',
'error' => 'Missing ticket number',
];
}
if (!$this->settingsGateway->isConfigured()) {
return [
'ok' => false,
'soap_used' => false,
'return_value' => 0,
'communication_text' => '',
'message_entries' => '',
'error' => 'BC connection not configured',
];
}
$endpoint = $this->buildSoapEndpoint();
if ($endpoint === null) {
return [
'ok' => false,
'soap_used' => false,
'return_value' => 0,
'communication_text' => '',
'message_entries' => '',
'error' => 'Could not derive SOAP endpoint from BC URL',
];
}
$requestBody = $this->buildGetTicketCommunicationTextEnvelope($ticketNo, $messageEntries);
$request = $this->buildRequestContext($endpoint, $requestBody);
if (isset($request['error'])) {
return [
'ok' => false,
'soap_used' => false,
'return_value' => 0,
'communication_text' => '',
'message_entries' => '',
'error' => (string) $request['error'],
];
}
$response = $this->sendSoapRequest(
$endpoint,
self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT,
$requestBody,
(array) ($request['headers'] ?? []),
(string) ($request['basic_user'] ?? ''),
(string) ($request['basic_password'] ?? '')
);
$httpCode = (int) ($response['http_code'] ?? 0);
$rawBody = (string) ($response['body'] ?? '');
if ($httpCode < 200 || $httpCode >= 300) {
$errorSnippet = trim((string) ($response['error'] ?? ''));
if ($errorSnippet === '' && $rawBody !== '') {
$errorSnippet = mb_substr($rawBody, 0, 250);
}
return [
'ok' => false,
'soap_used' => false,
'return_value' => 0,
'communication_text' => '',
'message_entries' => '',
'error' => 'BC SOAP request failed: HTTP ' . $httpCode . ($errorSnippet !== '' ? ' - ' . $errorSnippet : ''),
];
}
$parsed = $this->parseGetTicketCommunicationTextResponse($rawBody);
if (!$parsed['ok']) {
return [
'ok' => false,
'soap_used' => false,
'return_value' => 0,
'communication_text' => '',
'message_entries' => '',
'error' => isset($parsed['error']) ? (string) $parsed['error'] : 'Could not parse SOAP response',
];
}
$returnValue = (int) ($parsed['return_value'] ?? 0);
if ($returnValue >= 400) {
return [
'ok' => false,
'soap_used' => false,
'return_value' => $returnValue,
'communication_text' => (string) ($parsed['communication_text'] ?? ''),
'message_entries' => (string) ($parsed['message_entries'] ?? ''),
'error' => 'BC SOAP returned error code ' . $returnValue,
];
}
return [
'ok' => true,
'soap_used' => true,
'return_value' => $returnValue,
'communication_text' => (string) ($parsed['communication_text'] ?? ''),
'message_entries' => (string) ($parsed['message_entries'] ?? ''),
];
}
private function buildGetTicketCommunicationTextEnvelope(string $ticketNo, string $messageEntries): string
{
$escapedTicketNo = htmlspecialchars($ticketNo, ENT_XML1 | ENT_QUOTES, 'UTF-8');
$normalizedMessageEntries = trim($messageEntries);
if ($normalizedMessageEntries === '') {
$normalizedMessageEntries = '0';
}
$escapedMessageEntries = htmlspecialchars($normalizedMessageEntries, ENT_XML1 | ENT_QUOTES, 'UTF-8');
return <<<XML
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI">
<soap:Body>
<urn:GetTicketCommunicationText>
<urn:ticketNo>{$escapedTicketNo}</urn:ticketNo>
<urn:communicationText></urn:communicationText>
<urn:messageEntries>{$escapedMessageEntries}</urn:messageEntries>
</urn:GetTicketCommunicationText>
</soap:Body>
</soap:Envelope>
XML;
}
/**
* @return array{headers?: array<int, string>, basic_user?: string, basic_password?: string, error?: string}
*/
private function buildRequestContext(string $endpoint, string $requestBody, string $soapAction = ''): array
{
if ($soapAction === '') {
$soapAction = self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT;
}
$headers = [
'Content-Type: text/xml; charset=utf-8',
'Accept: text/xml,application/xml',
'SOAPAction: "' . $soapAction . '"',
'Content-Length: ' . strlen($requestBody),
];
$authMode = $this->settingsGateway->getAuthMode();
if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) {
return [
'headers' => $headers,
'basic_user' => $this->settingsGateway->getBasicUser() ?? '',
'basic_password' => $this->settingsGateway->getBasicPassword() ?? '',
];
}
if ($authMode !== HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) {
return ['headers' => $headers];
}
$tenantId = $this->resolveCurrentTenantId();
if ($tenantId <= 0) {
return ['error' => 'OAuth2 mode requires active tenant context'];
}
$audience = $this->buildOAuthAudienceFromEndpoint($endpoint);
if ($audience === null) {
return ['error' => 'Could not build OAuth audience for SOAP endpoint'];
}
$token = $this->oauthTokenService->getAccessToken($tenantId, $audience);
if ($token === null || trim($token) === '') {
return ['error' => 'Could not obtain OAuth2 access token for SOAP request'];
}
$headers[] = 'Authorization: Bearer ' . $token;
return ['headers' => $headers];
}
/**
* @return array{ok: bool, return_value?: int, communication_text?: string, message_entries?: string, error?: string}
*/
private function parseGetTicketCommunicationTextResponse(string $body): array
{
$body = trim($body);
if ($body === '') {
return ['ok' => false, 'error' => 'Empty SOAP response'];
}
$dom = new \DOMDocument();
$previousLibxmlState = libxml_use_internal_errors(true);
$loaded = $dom->loadXML($body);
libxml_clear_errors();
libxml_use_internal_errors($previousLibxmlState);
if (!$loaded) {
return ['ok' => false, 'error' => 'Invalid SOAP XML response'];
}
$xpath = new \DOMXPath($dom);
$faultNode = $xpath->query("//*[local-name()='Fault']/*[local-name()='faultstring']")->item(0);
if ($faultNode instanceof \DOMNode) {
return ['ok' => false, 'error' => trim((string) $faultNode->textContent) ?: 'SOAP fault'];
}
$returnNode = $xpath->query("//*[local-name()='return_value']")->item(0);
$communicationNode = $xpath->query("//*[local-name()='communicationText']")->item(0);
$messageEntriesNode = $xpath->query("//*[local-name()='messageEntries']")->item(0);
return [
'ok' => true,
'return_value' => $returnNode instanceof \DOMNode && is_numeric(trim((string) $returnNode->textContent))
? (int) trim((string) $returnNode->textContent)
: 0,
'communication_text' => $communicationNode instanceof \DOMNode ? (string) $communicationNode->textContent : '',
'message_entries' => $messageEntriesNode instanceof \DOMNode ? (string) $messageEntriesNode->textContent : '',
];
}
protected function sendSoapRequest(
string $endpoint,
string $soapAction,
string $requestBody,
array $headers,
string $basicUser,
string $basicPassword
): array {
$ch = curl_init();
if ($ch === false) {
return ['http_code' => 0, 'body' => '', 'error' => 'curl_init failed'];
}
curl_setopt_array($ch, [
CURLOPT_URL => $endpoint,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $requestBody,
CURLOPT_HTTPHEADER => $headers,
]);
if ($basicUser !== '' || $basicPassword !== '') {
curl_setopt($ch, CURLOPT_USERPWD, $basicUser . ':' . $basicPassword);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
}
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
unset($ch);
return [
'http_code' => $httpCode,
'body' => is_string($responseBody) ? $responseBody : '',
'error' => $error,
];
}
/**
* Download a ticket file attachment via SOAP GetTicketFile.
*
* Returns the decoded binary data or an error. Does not write to disk.
*
* @return array{ok: bool, data?: string, filename?: string, error?: string}
*/
public function getTicketFile(int $entryNo, string $contactNo = ''): array
{
if ($entryNo <= 0) {
return ['ok' => false, 'error' => 'Invalid entry number'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
$endpoint = $this->buildSoapEndpoint();
if ($endpoint === null) {
return ['ok' => false, 'error' => 'Could not derive SOAP endpoint'];
}
$requestBody = $this->buildGetTicketFileEnvelope($entryNo, $contactNo);
$request = $this->buildRequestContext($endpoint, $requestBody, self::SOAP_ACTION_GET_TICKET_FILE);
if (isset($request['error'])) {
return ['ok' => false, 'error' => (string) $request['error']];
}
$response = $this->sendSoapRequest(
$endpoint,
self::SOAP_ACTION_GET_TICKET_FILE,
$requestBody,
(array) ($request['headers'] ?? []),
(string) ($request['basic_user'] ?? ''),
(string) ($request['basic_password'] ?? '')
);
$httpCode = (int) ($response['http_code'] ?? 0);
$rawBody = (string) ($response['body'] ?? '');
if ($httpCode < 200 || $httpCode >= 300) {
return ['ok' => false, 'error' => 'SOAP request failed: HTTP ' . $httpCode];
}
return $this->parseGetTicketFileResponse($rawBody);
}
private function buildGetTicketFileEnvelope(int $entryNo, string $contactNo): string
{
$escapedContactNo = htmlspecialchars($contactNo, ENT_XML1 | ENT_QUOTES, 'UTF-8');
// BC signature: GetTicketFile(TicketNo, EntryNo, ContactNo, ICISupportFilePort)
// The old monolith omitted ticketNo — BC resolves it from EntryNo.
return <<<XML
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI">
<soap:Body>
<urn:GetTicketFile>
<urn:ticketNo></urn:ticketNo>
<urn:entryNo>{$entryNo}</urn:entryNo>
<urn:contactNo>{$escapedContactNo}</urn:contactNo>
<urn:iCISupportFilePort></urn:iCISupportFilePort>
</urn:GetTicketFile>
</soap:Body>
</soap:Envelope>
XML;
}
/**
* @return array{ok: bool, data?: string, filename?: string, error?: string}
*/
private function parseGetTicketFileResponse(string $body): array
{
$body = trim($body);
if ($body === '') {
return ['ok' => false, 'error' => 'Empty SOAP response'];
}
$dom = new \DOMDocument();
$previousLibxmlState = libxml_use_internal_errors(true);
$loaded = $dom->loadXML($body);
libxml_clear_errors();
libxml_use_internal_errors($previousLibxmlState);
if (!$loaded) {
return ['ok' => false, 'error' => 'Invalid SOAP XML response'];
}
$xpath = new \DOMXPath($dom);
$faultNode = $xpath->query("//*[local-name()='Fault']/*[local-name()='faultstring']")->item(0);
if ($faultNode instanceof \DOMNode) {
return ['ok' => false, 'error' => 'SOAP fault: ' . trim((string) $faultNode->textContent)];
}
$returnNode = $xpath->query("//*[local-name()='return_value']")->item(0);
$returnValue = ($returnNode instanceof \DOMNode && is_numeric(trim((string) $returnNode->textContent)))
? (int) trim((string) $returnNode->textContent)
: 0;
if ($returnValue !== 100) {
return ['ok' => false, 'error' => 'BC returned error code ' . $returnValue];
}
// Collect Base64 parts
$partNodes = $xpath->query("//*[local-name()='PartData']");
$base64 = '';
if ($partNodes !== false) {
foreach ($partNodes as $node) {
$base64 .= trim((string) $node->textContent);
}
}
if ($base64 === '') {
return ['ok' => false, 'error' => 'No file data in response'];
}
// Handle data URI prefix (data:application/octet-stream;base64,...)
if (str_contains($base64, ',')) {
$base64 = substr($base64, strpos($base64, ',') + 1);
}
$decoded = base64_decode($base64, true);
if ($decoded === false) {
return ['ok' => false, 'error' => 'Base64 decode failed'];
}
return ['ok' => true, 'data' => $decoded];
}
private function buildSoapEndpoint(): ?string
{
$baseUrl = trim($this->settingsGateway->getODataBaseUrl());
if ($baseUrl === '') {
return null;
}
$parsed = parse_url($baseUrl);
if (!is_array($parsed)) {
return null;
}
$scheme = strtolower((string) ($parsed['scheme'] ?? 'https'));
$host = trim((string) ($parsed['host'] ?? ''));
if ($host === '') {
return null;
}
$odataPath = '/' . ltrim((string) ($parsed['path'] ?? ''), '/');
$instancePath = (string) preg_replace('#/ODataV4(?:/.*)?$#i', '', $odataPath);
$instancePath = rtrim($instancePath !== '' ? $instancePath : '/BusinessCentral', '/');
if ($instancePath === '') {
$instancePath = '/BusinessCentral';
}
$odataPort = (int) ($parsed['port'] ?? 0);
$soapPort = $odataPort === 7048 ? 7047 : $odataPort;
$authority = $host;
if ($soapPort > 0 && !(($scheme === 'https' && $soapPort === 443) || ($scheme === 'http' && $soapPort === 80))) {
$authority .= ':' . $soapPort;
}
$company = rawurlencode($this->settingsGateway->getCompanyName());
return $scheme . '://' . $authority . $instancePath . '/WS/' . $company . '/Codeunit/' . self::CODEUNIT_NAME;
}
private function buildOAuthAudienceFromEndpoint(string $endpoint): ?string
{
$parsed = parse_url($endpoint);
if (!is_array($parsed)) {
return null;
}
$scheme = strtolower((string) ($parsed['scheme'] ?? 'https'));
$host = trim((string) ($parsed['host'] ?? ''));
if ($host === '') {
return null;
}
$port = (int) ($parsed['port'] ?? 0);
$authority = $host;
if ($port > 0 && !(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) {
$authority .= ':' . $port;
}
return $scheme . '://' . $authority;
}
private function resolveCurrentTenantId(): int
{
$session = $this->sessionStore->all();
$tenantId = (int) (($session['current_tenant']['id'] ?? 0));
return $tenantId > 0 ? $tenantId : 0;
}
}