Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions src/Dispatch/ApiProviderInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,4 @@
*
* @deprecated Use ApiProvider instead.
*/
interface ApiProviderInterface extends ApiProvider
{
}
interface ApiProviderInterface extends ApiProvider {}
4 changes: 1 addition & 3 deletions src/Dispatch/MethodInvokerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,4 @@
*
* @deprecated Use MethodInvoker instead.
*/
interface MethodInvokerInterface extends MethodInvoker
{
}
interface MethodInvokerInterface extends MethodInvoker {}
101 changes: 101 additions & 0 deletions src/Soap/Exception/SoapException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*/

namespace Horde\Rpc\Soap\Exception;

use Horde\Exception\HordeRuntimeException;
use SoapFault;
use Throwable;

/**
* SOAP exception with fault-code, fault-actor, and detail accessors.
*
* Used by both the SOAP server and the SOAP client. Transports that do
* not use ext-soap can construct this directly; transports that do can
* convert a native \SoapFault via {@see self::fromSoapFault()}.
*
* The accessor surface is the union of the SOAP 1.1 fault model
* (faultcode, faultactor, detail) and the SOAP 1.2 fault model
* (Code, Role, Detail). Both versions map onto the same three fields.
*/
class SoapException extends HordeRuntimeException implements SoapThrowable
{
/**
* @param string $message Human-readable fault description (SOAP <faultstring> / <Reason>).
* @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,
);
}
}
52 changes: 52 additions & 0 deletions src/Soap/Exception/SoapThrowable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*/

namespace Horde\Rpc\Soap\Exception;

use Horde\Exception\HordeThrowable;

/**
* Marker interface for all SOAP-related exceptions.
*
* Implemented by every exception raised by the SOAP server, client, and
* transport layers, so that callers can catch SOAP errors uniformly
* without depending on the native \SoapFault from ext-soap.
*
* Mirrors the role of {@see \Horde\Rpc\JsonRpc\Exception\JsonRpcThrowable}
* in the JSON-RPC namespace.
*/
interface SoapThrowable extends HordeThrowable
{
/**
* Return the SOAP fault code (e.g. "Client", "Server", "VersionMismatch").
*
* Maps to the SOAP 1.1 <faultcode> element and the SOAP 1.2
* <Code><Value> element.
*/
public function getFaultCode(): string;

/**
* Return the SOAP fault actor / role, if any.
*
* Maps to the SOAP 1.1 <faultactor> element and the SOAP 1.2
* <Role> element. Optional in both versions.
*/
public function getFaultActor(): ?string;

/**
* Return the structured fault detail payload, if any.
*
* Maps to the SOAP 1.1 <detail> element and the SOAP 1.2 <Detail>
* element. The shape is application-defined; ext-soap typically
* exposes it as a stdClass or array.
*/
public function getDetail(): mixed;
}
205 changes: 205 additions & 0 deletions src/Soap/SoapClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*/

namespace Horde\Rpc\Soap;

use Horde\Http\Client\Curl;
use Horde\Http\Client\Options;
use Horde\Http\RequestFactory;
use Horde\Http\ResponseFactory;
use Horde\Http\StreamFactory;
use Horde\Rpc\Soap\Exception\SoapException;
use Horde\Rpc\Soap\Transport\Psr18SoapTransport;
use InvalidArgumentException;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use SoapFault;

/**
* Convenience facade for SOAP client calls.
*
* Supports both WSDL and WSDL-less ("non-WSDL") mode through a single
* constructor; the WSDL argument is optional. All wire I/O is routed
* through a PSR-18 HTTP client, defaulting to {@see Curl} when no
* client is provided so the SOAP transport stays uniform with the
* rest of the modern Horde stack.
*
* Native ext-soap \SoapFault exceptions are caught and re-thrown as
* {@see SoapException} so callers only ever need to handle one type.
*
* Examples (use named arguments throughout — the constructor accepts
* many optional knobs and positional ordering is intentionally not part
* of the public contract beyond `$endpoint`):
*
* // WSDL mode
* $client = new SoapClient(
* endpoint: 'https://api.example.com/soap',
* wsdl: 'https://api.example.com/soap?wsdl',
* );
* $user = $client->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<int|string, mixed> $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;
}
}
Loading
Loading