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

361 lines
13 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';
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'] ?? false)) {
return [
'ok' => false,
'soap_used' => false,
'return_value' => 0,
'communication_text' => '',
'message_entries' => '',
'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): array
{
$headers = [
'Content-Type: text/xml; charset=utf-8',
'Accept: text/xml,application/xml',
'SOAPAction: "' . self::SOAP_ACTION_GET_TICKET_COMMUNICATION_TEXT . '"',
'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,
];
}
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;
}
}