From b688e20bc310e81ec25c9ceb9b68998abd8c9a12 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Fri, 19 Jun 2026 21:47:40 +0100 Subject: [PATCH 1/2] feat(soap): add SOAP client with PSR-18 transport and SoapException --- src/Soap/Exception/SoapException.php | 101 +++++++ src/Soap/Exception/SoapThrowable.php | 52 ++++ src/Soap/SoapClient.php | 205 +++++++++++++ src/Soap/SoapVersion.php | 40 +++ src/Soap/Transport/Psr18SoapTransport.php | 109 +++++++ .../Unit/Soap/Exception/SoapExceptionTest.php | 75 +++++ test/Unit/Soap/SoapClientTest.php | 275 ++++++++++++++++++ 7 files changed, 857 insertions(+) create mode 100644 src/Soap/Exception/SoapException.php create mode 100644 src/Soap/Exception/SoapThrowable.php create mode 100644 src/Soap/SoapClient.php create mode 100644 src/Soap/SoapVersion.php create mode 100644 src/Soap/Transport/Psr18SoapTransport.php create mode 100644 test/Unit/Soap/Exception/SoapExceptionTest.php create mode 100644 test/Unit/Soap/SoapClientTest.php diff --git a/src/Soap/Exception/SoapException.php b/src/Soap/Exception/SoapException.php new file mode 100644 index 0000000..fee2feb --- /dev/null +++ b/src/Soap/Exception/SoapException.php @@ -0,0 +1,101 @@ + / ). + * @param string $faultCode Fault code, defaults to "Server" for caller-side faults of unknown origin. + * @param string|null $faultActor Fault actor / role, if any. + * @param mixed $detail Application-defined fault detail payload, if any. + * @param Throwable|null $previous Original exception (commonly the wrapped \SoapFault). + */ + public function __construct( + string $message, + private readonly string $faultCode = 'Server', + private readonly ?string $faultActor = null, + private readonly mixed $detail = null, + ?Throwable $previous = null, + ) { + parent::__construct($message, 0, $previous); + } + + public function getFaultCode(): string + { + return $this->faultCode; + } + + public function getFaultActor(): ?string + { + return $this->faultActor; + } + + public function getDetail(): mixed + { + return $this->detail; + } + + /** + * Build a SoapException from a native ext-soap \SoapFault. + * + * Preserves the original fault as the previous exception so callers + * who need the raw faultcode_ns, headerfault, etc. can still reach + * them via {@see Throwable::getPrevious()}. + * + * The native \SoapFault exposes properties (not getters): + * - faultcode : fault code string (SOAP 1.1) or local part (1.2) + * - faultstring : human-readable message + * - faultactor : actor URI, optional + * - detail : application-specific detail, optional + */ + public static function fromSoapFault(SoapFault $fault): self + { + $faultCode = isset($fault->faultcode) && is_string($fault->faultcode) && $fault->faultcode !== '' + ? $fault->faultcode + : 'Server'; + + $faultActor = isset($fault->faultactor) && is_string($fault->faultactor) && $fault->faultactor !== '' + ? $fault->faultactor + : null; + + $detail = $fault->detail ?? null; + + $message = $fault->getMessage(); + if ($message === '' && isset($fault->faultstring) && is_string($fault->faultstring)) { + $message = $fault->faultstring; + } + + return new self( + message: $message, + faultCode: $faultCode, + faultActor: $faultActor, + detail: $detail, + previous: $fault, + ); + } +} diff --git a/src/Soap/Exception/SoapThrowable.php b/src/Soap/Exception/SoapThrowable.php new file mode 100644 index 0000000..878fa60 --- /dev/null +++ b/src/Soap/Exception/SoapThrowable.php @@ -0,0 +1,52 @@ + element and the SOAP 1.2 + * element. + */ + public function getFaultCode(): string; + + /** + * Return the SOAP fault actor / role, if any. + * + * Maps to the SOAP 1.1 element and the SOAP 1.2 + * element. Optional in both versions. + */ + public function getFaultActor(): ?string; + + /** + * Return the structured fault detail payload, if any. + * + * Maps to the SOAP 1.1 element and the SOAP 1.2 + * element. The shape is application-defined; ext-soap typically + * exposes it as a stdClass or array. + */ + public function getDetail(): mixed; +} diff --git a/src/Soap/SoapClient.php b/src/Soap/SoapClient.php new file mode 100644 index 0000000..5f19aa9 --- /dev/null +++ b/src/Soap/SoapClient.php @@ -0,0 +1,205 @@ +call('getUser', [42]); + * + * // WSDL-less mode + * $client = new SoapClient( + * endpoint: 'https://api.example.com/soap', + * uri: 'urn:example-service', + * ); + * $user = $client->call('getUser', [42]); + * + * // SOAP 1.2 with basic auth and a custom PSR-18 client + * $client = new SoapClient( + * endpoint: 'https://api.example.com/soap', + * wsdl: 'https://api.example.com/soap?wsdl', + * version: SoapVersion::V1_2, + * login: 'alice', + * password: 'secret', + * httpClient: $guzzleClient, + * ); + */ +final class SoapClient +{ + private readonly Psr18SoapTransport $transport; + + /** + * @param string $endpoint The service endpoint URL. Used as the SOAP `location` + * and as the target for every HTTP request, overriding any address + * embedded in the WSDL. + * @param string|null $wsdl WSDL URI, or null for WSDL-less mode. When null, + * `$uri` must be supplied. + * @param string|null $uri SOAP namespace / service URI. Required in WSDL-less + * mode, ignored when a WSDL is supplied. + * @param SoapVersion $version SOAP protocol version. + * @param string|null $userAgent Override the User-Agent header on outbound + * HTTP requests. + * @param int|null $connectionTimeout Connection timeout in seconds (passed + * to ext-soap as `connection_timeout`). Applies to WSDL fetching; + * per-request HTTP timeouts come from the PSR-18 client. + * @param string|null $login HTTP basic-auth username. + * @param string|null $password HTTP basic-auth password. + * @param ClientInterface|null $httpClient PSR-18 client, defaults to + * {@see Curl}. + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory, + * defaults to {@see RequestFactory}. + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory, + * defaults to {@see StreamFactory}. + * + * @throws InvalidArgumentException When `$wsdl` is null and `$uri` is also null. + */ + public function __construct( + string $endpoint, + ?string $wsdl = null, + ?string $uri = null, + SoapVersion $version = SoapVersion::V1_1, + ?string $userAgent = null, + ?int $connectionTimeout = null, + ?string $login = null, + ?string $password = null, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + if ($wsdl === null && ($uri === null || $uri === '')) { + throw new InvalidArgumentException( + 'SoapClient requires either a WSDL URI or, in WSDL-less mode, a service URI.', + ); + } + + $streamFactory ??= new StreamFactory(); + $requestFactory ??= new RequestFactory(); + + if ($httpClient === null) { + $responseFactory = new ResponseFactory(); + $httpClient = new Curl($responseFactory, $streamFactory, new Options()); + } + + $options = [ + 'location' => $endpoint, + 'soap_version' => $version->toExtSoapConstant(), + 'exceptions' => true, + // trace lets callers inspect the last request/response via + // ext-soap's __getLast*() helpers when debugging. + 'trace' => true, + ]; + + if ($wsdl === null) { + $options['uri'] = $uri; + } + + if ($connectionTimeout !== null) { + $options['connection_timeout'] = $connectionTimeout; + } + + if ($login !== null) { + $options['login'] = $login; + } + + if ($password !== null) { + $options['password'] = $password; + } + + $this->transport = new Psr18SoapTransport( + wsdl: $wsdl, + options: $options, + httpClient: $httpClient, + requestFactory: $requestFactory, + streamFactory: $streamFactory, + userAgent: $userAgent ?? 'Horde SOAP Client', + ); + } + + /** + * Invoke a SOAP method on the remote service. + * + * Parameters are positional and passed straight through to ext-soap, + * which marshals them according to the WSDL (WSDL mode) or wraps them + * as a generic request (WSDL-less mode). For named-parameter SOAP + * methods, pass a single associative array. + * + * @param string $method The SOAP operation name. + * @param array $params Arguments for the operation. + * + * @return mixed The unmarshaled return value. + * + * @throws SoapException On any SOAP fault, including HTTP transport faults + * re-raised by {@see Psr18SoapTransport}. + */ + public function call(string $method, array $params = []): mixed + { + try { + return $this->transport->__soapCall($method, $params); + } catch (SoapFault $e) { + throw SoapException::fromSoapFault($e); + } + } + + /** + * Return the raw XML of the last request sent, if any. + * + * Useful for debugging. Requires that the underlying \SoapClient was + * built with the `trace` option (which this facade always sets). + */ + public function getLastRequest(): ?string + { + $xml = $this->transport->__getLastRequest(); + + return $xml !== null && $xml !== '' ? $xml : null; + } + + /** + * Return the raw XML of the last response received, if any. + */ + public function getLastResponse(): ?string + { + $xml = $this->transport->__getLastResponse(); + + return $xml !== null && $xml !== '' ? $xml : null; + } +} diff --git a/src/Soap/SoapVersion.php b/src/Soap/SoapVersion.php new file mode 100644 index 0000000..ce90cce --- /dev/null +++ b/src/Soap/SoapVersion.php @@ -0,0 +1,40 @@ + SOAP_1_1, + self::V1_2 => SOAP_1_2, + }; + } +} diff --git a/src/Soap/Transport/Psr18SoapTransport.php b/src/Soap/Transport/Psr18SoapTransport.php new file mode 100644 index 0000000..32acf45 --- /dev/null +++ b/src/Soap/Transport/Psr18SoapTransport.php @@ -0,0 +1,109 @@ + $options ext-soap options; must contain at least + * 'location' (and 'uri' in non-WSDL mode). Built by {@see \Horde\Rpc\Soap\SoapClient}. + */ + public function __construct( + ?string $wsdl, + array $options, + private readonly ClientInterface $httpClient, + private readonly RequestFactoryInterface $requestFactory, + private readonly StreamFactoryInterface $streamFactory, + private readonly string $userAgent = 'Horde SOAP Client', + ) { + parent::__construct($wsdl, $options); + } + + /** + * ext-soap entry point for outbound HTTP. Returns the raw response body. + * + * @param string $request Serialized SOAP envelope. + * @param string $location Target URL (from the WSDL, the 'location' option, or per-call override). + * @param string $action SOAPAction header value. + * @param int $version SOAP_1_1 or SOAP_1_2. + * @param int $oneWay Non-zero when ext-soap considers the call one-way (no response expected). + * + * @return string The raw response body, or empty string for one-way calls. + * + * @throws SoapFault On any HTTP-level failure. Wrapping the PSR-18 exception + * in a \SoapFault keeps ext-soap's own error handling + * uniform; the caller-facing facade re-wraps as SoapException. + */ + public function __doRequest( + string $request, + string $location, + string $action, + int $version, + $oneWay = 0, + ): string { + $contentType = $version === SOAP_1_2 + ? 'application/soap+xml; charset=utf-8' + : 'text/xml; charset=utf-8'; + + $httpRequest = $this->requestFactory->createRequest('POST', $location) + ->withHeader('Content-Type', $contentType) + ->withHeader('User-Agent', $this->userAgent) + ->withBody($this->streamFactory->createStream($request)); + + // SOAP 1.1 puts the action in a separate header; SOAP 1.2 carries it + // as a Content-Type parameter, but ext-soap still passes the action + // string here, so we set it as a header for 1.1 only. + if ($version === SOAP_1_1 && $action !== '') { + $httpRequest = $httpRequest->withHeader('SOAPAction', $action); + } + + try { + $httpResponse = $this->httpClient->sendRequest($httpRequest); + } catch (ClientExceptionInterface $e) { + throw new SoapFault('HTTP', 'SOAP HTTP transport failed: ' . $e->getMessage()); + } + + if ($oneWay) { + return ''; + } + + return (string) $httpResponse->getBody(); + } +} diff --git a/test/Unit/Soap/Exception/SoapExceptionTest.php b/test/Unit/Soap/Exception/SoapExceptionTest.php new file mode 100644 index 0000000..84f6a6c --- /dev/null +++ b/test/Unit/Soap/Exception/SoapExceptionTest.php @@ -0,0 +1,75 @@ +assertSame('Server', $e->getFaultCode()); + $this->assertNull($e->getFaultActor()); + $this->assertNull($e->getDetail()); + $this->assertSame('boom', $e->getMessage()); + } + + public function testCustomFieldsArePreserved(): void + { + $detail = ['errno' => 17, 'where' => 'database']; + + $e = new SoapException( + message: 'database is down', + faultCode: 'Client', + faultActor: 'http://example.com/db-service', + detail: $detail, + ); + + $this->assertSame('Client', $e->getFaultCode()); + $this->assertSame('http://example.com/db-service', $e->getFaultActor()); + $this->assertSame($detail, $e->getDetail()); + } + + public function testImplementsSoapThrowableAndHordeThrowable(): void + { + $e = new SoapException('boom'); + + $this->assertInstanceOf(SoapThrowable::class, $e); + $this->assertInstanceOf(HordeThrowable::class, $e); + } + + public function testFromSoapFaultPreservesSoap11Fields(): void + { + $detail = (object) ['code' => 'E_AUTH']; + + $fault = new SoapFault('Client', 'authentication failed', 'http://example.com/auth', $detail); + + $e = SoapException::fromSoapFault($fault); + + $this->assertSame('Client', $e->getFaultCode()); + $this->assertSame('http://example.com/auth', $e->getFaultActor()); + $this->assertSame($detail, $e->getDetail()); + $this->assertSame('authentication failed', $e->getMessage()); + $this->assertSame($fault, $e->getPrevious()); + } + + public function testFromSoapFaultPreservesEmptyActorAsNull(): void + { + $fault = new SoapFault('Server', 'oops'); + + $e = SoapException::fromSoapFault($fault); + + $this->assertNull($e->getFaultActor()); + $this->assertNull($e->getDetail()); + } +} diff --git a/test/Unit/Soap/SoapClientTest.php b/test/Unit/Soap/SoapClientTest.php new file mode 100644 index 0000000..f18a385 --- /dev/null +++ b/test/Unit/Soap/SoapClientTest.php @@ -0,0 +1,275 @@ +streamFactory = new StreamFactory(); + } + + public function testConstructorRejectsBothWsdlAndUriMissing(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('SoapClient requires either a WSDL URI'); + + new SoapClient( + endpoint: 'http://example.com/soap', + httpClient: $this->makeRecordingClient(''), + ); + } + + public function testConstructorRejectsEmptyUri(): void + { + $this->expectException(InvalidArgumentException::class); + + new SoapClient( + endpoint: 'http://example.com/soap', + uri: '', + httpClient: $this->makeRecordingClient(''), + ); + } + + #[RequiresPhpExtension('soap')] + public function testWsdlLessCallSendsSoapRequestAndUnmarshalsResponse(): void + { + $http = $this->makeRecordingClient($this->buildSoapResponse( + method: 'getUser', + namespace: 'urn:horde-test', + innerXml: 'alice', + )); + + $client = new SoapClient( + endpoint: 'http://example.com/soap', + uri: 'urn:horde-test', + httpClient: $http, + ); + + $result = $client->call('getUser', [42]); + + $this->assertSame('alice', $result); + + $sent = $http->lastRequest; + $this->assertNotNull($sent); + $this->assertSame('POST', $sent->getMethod()); + $this->assertSame('http://example.com/soap', (string) $sent->getUri()); + $this->assertStringContainsString('text/xml', $sent->getHeaderLine('Content-Type')); + $this->assertStringContainsString('Horde SOAP Client', $sent->getHeaderLine('User-Agent')); + // SOAP 1.1 sets a SOAPAction header + $this->assertNotSame('', $sent->getHeaderLine('SOAPAction')); + // The body should be a SOAP envelope mentioning the method + $this->assertStringContainsString('getUser', (string) $sent->getBody()); + } + + #[RequiresPhpExtension('soap')] + public function testSoap12UsesApplicationSoapXmlContentType(): void + { + $http = $this->makeRecordingClient($this->buildSoapResponse( + method: 'getUser', + namespace: 'urn:horde-test', + innerXml: 'bob', + soap12: true, + )); + + $client = new SoapClient( + endpoint: 'http://example.com/soap', + uri: 'urn:horde-test', + version: SoapVersion::V1_2, + httpClient: $http, + ); + + $client->call('getUser', [1]); + + $sent = $http->lastRequest; + $this->assertNotNull($sent); + $this->assertStringContainsString('application/soap+xml', $sent->getHeaderLine('Content-Type')); + // SOAP 1.2 does not use the SOAPAction header. + $this->assertSame('', $sent->getHeaderLine('SOAPAction')); + } + + #[RequiresPhpExtension('soap')] + public function testCustomUserAgentIsForwarded(): void + { + $http = $this->makeRecordingClient($this->buildSoapResponse( + method: 'ping', + namespace: 'urn:horde-test', + innerXml: 'pong', + )); + + $client = new SoapClient( + endpoint: 'http://example.com/soap', + uri: 'urn:horde-test', + userAgent: 'AcmeCorp/2.0', + httpClient: $http, + ); + + $client->call('ping'); + + $sent = $http->lastRequest; + $this->assertNotNull($sent); + $this->assertSame('AcmeCorp/2.0', $sent->getHeaderLine('User-Agent')); + } + + #[RequiresPhpExtension('soap')] + public function testSoapFaultIsConvertedToSoapException(): void + { + $http = $this->makeRecordingClient($this->buildSoapFaultResponse( + faultCode: 'Client', + faultString: 'bad input', + )); + + $client = new SoapClient( + endpoint: 'http://example.com/soap', + uri: 'urn:horde-test', + httpClient: $http, + ); + + try { + $client->call('explode'); + $this->fail('Expected SoapException'); + } catch (SoapException $e) { + $this->assertStringContainsString('bad input', $e->getMessage()); + $this->assertNotSame('', $e->getFaultCode()); + } + } + + #[RequiresPhpExtension('soap')] + public function testHttpTransportFailureSurfacesAsSoapException(): void + { + $http = new class implements ClientInterface { + public function sendRequest(RequestInterface $request): ResponseInterface + { + throw new class ('network down') extends RuntimeException implements ClientExceptionInterface {}; + } + }; + + $client = new SoapClient( + endpoint: 'http://example.com/soap', + uri: 'urn:horde-test', + httpClient: $http, + ); + + $this->expectException(SoapException::class); + $this->expectExceptionMessage('SOAP HTTP transport failed'); + + $client->call('ping'); + } + + #[RequiresPhpExtension('soap')] + public function testGetLastRequestAndResponseAreExposed(): void + { + $responseBody = $this->buildSoapResponse( + method: 'ping', + namespace: 'urn:horde-test', + innerXml: 'pong', + ); + $http = $this->makeRecordingClient($responseBody); + + $client = new SoapClient( + endpoint: 'http://example.com/soap', + uri: 'urn:horde-test', + httpClient: $http, + ); + + $client->call('ping'); + + $this->assertNotNull($client->getLastRequest()); + $this->assertStringContainsString('ping', $client->getLastRequest() ?? ''); + $this->assertSame($responseBody, $client->getLastResponse()); + } + + /** + * Build a recording PSR-18 client that returns a fixed response body. + */ + private function makeRecordingClient(string $responseBody, int $statusCode = 200): object + { + $streamFactory = $this->streamFactory; + + return new class ($responseBody, $statusCode, $streamFactory) implements ClientInterface { + public ?RequestInterface $lastRequest = null; + + public function __construct( + private readonly string $responseBody, + private readonly int $statusCode, + private readonly StreamFactory $sf, + ) {} + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->lastRequest = $request; + $body = $this->sf->createStream($this->responseBody); + + return new Response($this->statusCode, body: $body); + } + }; + } + + private function buildSoapResponse( + string $method, + string $namespace, + string $innerXml, + bool $soap12 = false, + ): string { + $envelopeNs = $soap12 + ? 'http://www.w3.org/2003/05/soap-envelope' + : 'http://schemas.xmlsoap.org/soap/envelope/'; + + return sprintf( + '' + . '' + . '' + . '%s' + . '' + . '', + $envelopeNs, + $namespace, + $method, + $innerXml, + $method, + ); + } + + private function buildSoapFaultResponse(string $faultCode, string $faultString): string + { + return sprintf( + '' + . '' + . '' + . '' + . 'SOAP-ENV:%s' + . '%s' + . '' + . '' + . '', + htmlspecialchars($faultCode, ENT_XML1, 'UTF-8'), + htmlspecialchars($faultString, ENT_XML1, 'UTF-8'), + ); + } +} From 8145a73f1626873b591915f59afc8a73e5251492 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 20 Jun 2026 08:10:46 +0100 Subject: [PATCH 2/2] style: ws --- src/Dispatch/ApiProviderInterface.php | 4 +--- src/Dispatch/MethodInvokerInterface.php | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Dispatch/ApiProviderInterface.php b/src/Dispatch/ApiProviderInterface.php index 3b13eff..b6f044e 100644 --- a/src/Dispatch/ApiProviderInterface.php +++ b/src/Dispatch/ApiProviderInterface.php @@ -20,6 +20,4 @@ * * @deprecated Use ApiProvider instead. */ -interface ApiProviderInterface extends ApiProvider -{ -} +interface ApiProviderInterface extends ApiProvider {} diff --git a/src/Dispatch/MethodInvokerInterface.php b/src/Dispatch/MethodInvokerInterface.php index e6cdac5..2b7a4ef 100644 --- a/src/Dispatch/MethodInvokerInterface.php +++ b/src/Dispatch/MethodInvokerInterface.php @@ -19,6 +19,4 @@ * * @deprecated Use MethodInvoker instead. */ -interface MethodInvokerInterface extends MethodInvoker -{ -} +interface MethodInvokerInterface extends MethodInvoker {}