diff --git a/composer.json b/composer.json index d1744fe..0e77049 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "php-soap/wsdl": "^1.6", "php-soap/xml": "^1.7", "php-soap/wsdl-reader": "0.14.0", - "goetas-webservices/xsd-reader": "^0.4.5" + "goetas-webservices/xsd-reader": "^0.4.6" }, "require-dev": { "vimeo/psalm": "^5.16", diff --git a/src/Exception/SoapFaultException.php b/src/Exception/SoapFaultException.php new file mode 100644 index 0000000..cd870ee --- /dev/null +++ b/src/Exception/SoapFaultException.php @@ -0,0 +1,28 @@ +fault->reason(), + $this->fault->code(), + ), + ); + } + + public function fault(): SoapFault + { + return $this->fault; + } +} diff --git a/src/Fault/Encoder/Soap11FaultEncoder.php b/src/Fault/Encoder/Soap11FaultEncoder.php new file mode 100644 index 0000000..1d9735f --- /dev/null +++ b/src/Fault/Encoder/Soap11FaultEncoder.php @@ -0,0 +1,99 @@ + + */ +final class Soap11FaultEncoder implements SoapFaultEncoder +{ + /** + * @return Iso + */ + public function iso(): Iso + { + return new Iso( + $this->to(...), + $this->from(...) + ); + } + + /** + * @return non-empty-string + */ + private function to(Soap11Fault $fault): string + { + $envNamespace = Xmlns::soap11Envelope()->value(); + + /** @var non-empty-string */ + return Writer::inMemory() + ->write(children([ + namespaced_element( + $envNamespace, + 'env', + 'Fault', + children([ + element( + 'faultcode', + value($fault->faultCode), + ), + element( + 'faultstring', + value($fault->faultString), + ), + ...( + $fault->faultActor !== null + ? [ + element( + 'faultactor', + value($fault->faultActor) + ) + ] + : [] + ), + ...($fault->detail !== null ? [raw($fault->detail)] : []), + ]) + ) + ])) + ->map(memory_output()); + } + + /** + * @param non-empty-string $fault + */ + private function from(string $fault): Soap11Fault + { + $document = Document::fromXmlString($fault); + $documentElement = $document->locateDocumentElement(); + + $envelopeUri = $documentElement->namespaceURI; + invariant($envelopeUri !== null, 'No SoapFault envelope namespace uri was specified.'); + $xpath = $document->xpath(namespaces(['env' => $envelopeUri])); + + $actor = $xpath->query('./faultactor'); + $detail = $xpath->query('./detail'); + + return new Soap11Fault( + faultCode: WhitespaceRestriction::collapse($xpath->querySingle('./faultcode')->textContent), + faultString: WhitespaceRestriction::collapse($xpath->querySingle('./faultstring')->textContent), + faultActor: $actor->count() ? trim($actor->expectFirst()->textContent) : null, + detail: $detail->count() ? Document::fromXmlNode($detail->expectFirst())->stringifyDocumentElement() : null, + ); + } +} diff --git a/src/Fault/Encoder/Soap12FaultEncoder.php b/src/Fault/Encoder/Soap12FaultEncoder.php new file mode 100644 index 0000000..39cdede --- /dev/null +++ b/src/Fault/Encoder/Soap12FaultEncoder.php @@ -0,0 +1,146 @@ + + */ +final class Soap12FaultEncoder implements SoapFaultEncoder +{ + private const ENV_NAMESPACE = 'http://www.w3.org/2003/05/soap-envelope'; + + /** + * @return Iso + */ + public function iso(): Iso + { + return new Iso( + $this->to(...), + $this->from(...) + ); + } + + /** + * @return non-empty-string + */ + private function to(Soap12Fault $fault): string + { + /** @var non-empty-string */ + return Writer::inMemory() + ->write(children([ + namespaced_element( + self::ENV_NAMESPACE, + 'env', + 'Fault', + children([ + prefixed_element( + 'env', + 'Code', + children([ + prefixed_element( + 'env', + 'Value', + value($fault->code) + ), + ...( + $fault->subCode !== null + ? [ + prefixed_element( + 'env', + 'Subcode', + children([ + prefixed_element( + 'env', + 'Value', + value($fault->subCode) + ) + ]) + ) + ] + : [] + ), + + ]) + ), + prefixed_element( + 'env', + 'Reason', + children([ + prefixed_element( + 'env', + 'Text', + value($fault->reason) + ) + ]) + ), + ...( + $fault->node !== null + ? [ + prefixed_element( + 'env', + 'Node', + value($fault->node) + ) + ] + : [] + ), + ...( + $fault->role !== null + ? [ + prefixed_element( + 'env', + 'Role', + value($fault->role) + ) + ] + : [] + ), + ...($fault->detail !== null ? [raw($fault->detail)] : []), + ]) + ) + ])) + ->map(memory_output()); + } + + /** + * @param non-empty-string $fault + */ + private function from(string $fault): Soap12Fault + { + $document = Document::fromXmlString($fault); + $documentElement = $document->locateDocumentElement(); + + $envelopeUri = $documentElement->namespaceURI; + invariant($envelopeUri !== null, 'No SoapFault envelope namespace uri was specified.'); + $xpath = $document->xpath(namespaces(['env' => $envelopeUri])); + + $subCode = $xpath->query('./env:Code/env:Subcode/env:Value'); + $node = $xpath->query('./env:Node'); + $role = $xpath->query('./env:Role'); + $detail = $xpath->query('./env:Detail'); + + return new Soap12Fault( + code: WhitespaceRestriction::collapse($xpath->querySingle('./env:Code/env:Value')->textContent), + reason: WhitespaceRestriction::collapse($xpath->querySingle('./env:Reason/env:Text')->textContent), + subCode: $subCode->count() ? WhitespaceRestriction::collapse($subCode->expectFirst()->textContent) : null, + node: $node->count() ? trim($node->expectFirst()->textContent) : null, + role: $role->count() ? trim($role->expectFirst()->textContent) : null, + detail: $detail->count() ? Document::fromXmlNode($detail->expectFirst())->stringifyDocumentElement() : null, + ); + } +} diff --git a/src/Fault/Encoder/SoapFaultEncoder.php b/src/Fault/Encoder/SoapFaultEncoder.php new file mode 100644 index 0000000..b9aca33 --- /dev/null +++ b/src/Fault/Encoder/SoapFaultEncoder.php @@ -0,0 +1,18 @@ + + */ + public function iso(): Iso; +} diff --git a/src/Fault/Guard/SoapFaultGuard.php b/src/Fault/Guard/SoapFaultGuard.php new file mode 100644 index 0000000..4b12bf8 --- /dev/null +++ b/src/Fault/Guard/SoapFaultGuard.php @@ -0,0 +1,41 @@ +locateDocumentElement()->namespaceURI; + invariant($envelopeUri !== null, 'No SoapFault envelope namespace uri was specified.'); + $xpath = $envelope->xpath(namespaces([ + 'env' => $envelopeUri, + ])); + + $fault = $xpath->query('//env:Fault'); + if (!$fault->count()) { + return; + } + + $faultXml = Document::fromXmlNode($fault->expectFirst())->stringifyDocumentElement(); + + $fault = match($envelopeUri) { + Xmlns::soap11Envelope()->value() => (new Soap11FaultEncoder())->iso()->from($faultXml), + default => (new Soap12FaultEncoder())->iso()->from($faultXml), + }; + + throw new SoapFaultException($fault); + } +} diff --git a/src/Fault/Soap11Fault.php b/src/Fault/Soap11Fault.php new file mode 100644 index 0000000..c56e74b --- /dev/null +++ b/src/Fault/Soap11Fault.php @@ -0,0 +1,38 @@ +faultCode; + } + + public function reason(): string + { + return $this->faultString; + } + + public function detail(): ?string + { + return $this->detail; + } +} diff --git a/src/Fault/Soap12Fault.php b/src/Fault/Soap12Fault.php new file mode 100644 index 0000000..7857c89 --- /dev/null +++ b/src/Fault/Soap12Fault.php @@ -0,0 +1,41 @@ +code; + } + + public function reason(): string + { + return $this->reason; + } + + public function detail(): ?string + { + return $this->detail; + } +} diff --git a/src/Fault/SoapFault.php b/src/Fault/SoapFault.php new file mode 100644 index 0000000..a54586c --- /dev/null +++ b/src/Fault/SoapFault.php @@ -0,0 +1,13 @@ +locate(new SoapBodyLocator())); + $envelope = Document::fromXmlString($xml); + + // Make sure it does not contain a fault response before parsing the body parts. + (new SoapFaultGuard())($envelope); + + // Locate all body parts: + $body = assert_element($envelope->locate(new SoapBodyLocator())); return (new ChildrenReader())(Document::fromXmlNode($body)->toXmlString()); } diff --git a/tests/Unit/Fault/Encoder/AbstractFaultEncoderTests.php b/tests/Unit/Fault/Encoder/AbstractFaultEncoderTests.php new file mode 100644 index 0000000..40b45b5 --- /dev/null +++ b/tests/Unit/Fault/Encoder/AbstractFaultEncoderTests.php @@ -0,0 +1,44 @@ + + */ + abstract public static function provideIsomorphicCases(): iterable; + + /** + * + * @dataProvider provideIsomorphicCases + */ + public function test_it_can_decode_from_xml(SoapFaultEncoder $encoder, ?string $xml, mixed $data): void + { + $iso = $encoder->iso(); + $actual = $iso->from($xml); + + static::assertEquals($data, $actual); + } + + /** + * + * @dataProvider provideIsomorphicCases + */ + public function test_it_can_encode_into_xml(SoapFaultEncoder $encoder, ?string $xml, mixed $data): void + { + $iso = $encoder->iso(); + $actual = $iso->to($data); + + static::assertSame($xml, $actual); + } +} diff --git a/tests/Unit/Fault/Encoder/Soap11FaultEncoderTest.php b/tests/Unit/Fault/Encoder/Soap11FaultEncoderTest.php new file mode 100644 index 0000000..2ec8857 --- /dev/null +++ b/tests/Unit/Fault/Encoder/Soap11FaultEncoderTest.php @@ -0,0 +1,65 @@ + $encoder = new Soap11FaultEncoder(), + ]; + + yield 'required-fields-only' => [ + ...$baseConfig, + 'xml' => Document::configure( + trim_spaces(), + loader(xml_string_loader( + << + a:Microsoft.Dynamics.ServiceBrokerException + Invalid input parameter x + + EOXML + )) + )->stringifyDocumentElement(), + 'data' => new Soap11Fault( + faultCode: 'a:Microsoft.Dynamics.ServiceBrokerException', + faultString: 'Invalid input parameter x', + ), + ]; + yield 'all-fields' => [ + ...$baseConfig, + 'xml' => Document::configure( + trim_spaces(), + loader(xml_string_loader( + << + a:Microsoft.Dynamics.ServiceBrokerException + Invalid input parameter x + uri:actor + value + + EOXML + )) + )->stringifyDocumentElement(), + 'data' => new Soap11Fault( + faultCode: 'a:Microsoft.Dynamics.ServiceBrokerException', + faultString: 'Invalid input parameter x', + faultActor: 'uri:actor', + detail: 'value', + ), + ]; + } +} diff --git a/tests/Unit/Fault/Encoder/Soap12FaultEncoderTest.php b/tests/Unit/Fault/Encoder/Soap12FaultEncoderTest.php new file mode 100644 index 0000000..ed0ea37 --- /dev/null +++ b/tests/Unit/Fault/Encoder/Soap12FaultEncoderTest.php @@ -0,0 +1,111 @@ + $encoder = new Soap12FaultEncoder(), + ]; + + yield 'required-fields-only' => [ + ...$baseConfig, + 'xml' => Document::configure( + trim_spaces(), + loader(xml_string_loader( + << + + env:Sender + + + Sender Timeout + + + EOXML + )) + )->stringifyDocumentElement(), + 'data' => new Soap12Fault( + code: 'env:Sender', + reason: 'Sender Timeout', + ), + ]; + yield 'subcode-and-details-example' => [ + ...$baseConfig, + 'xml' => Document::configure( + trim_spaces(), + loader(xml_string_loader( + << + + env:Sender + + m:MessageTimeout + + + + Sender Timeout + + P5M + + EOXML + )) + )->stringifyDocumentElement(), + 'data' => new Soap12Fault( + code: 'env:Sender', + subCode: 'm:MessageTimeout', + reason: 'Sender Timeout', + detail: trim(<<P5M + EOXML) + ), + ]; + yield 'full-example' => [ + ...$baseConfig, + 'xml' => Document::configure( + trim_spaces(), + loader(xml_string_loader( + << + + env:Sender + + m:MessageTimeout + + + + Sender Timeout + + urn:node + urn:role + P5M + + EOXML + )) + )->stringifyDocumentElement(), + 'data' => new Soap12Fault( + code: 'env:Sender', + subCode: 'm:MessageTimeout', + reason: 'Sender Timeout', + node: 'urn:node', + role: 'urn:role', + detail: trim(<<P5M + EOXML) + ), + ]; + } +} diff --git a/tests/Unit/Xml/Reader/SoapEnvelopeReaderTest.php b/tests/Unit/Xml/Reader/SoapEnvelopeReaderTest.php index 79f1732..3075c3f 100644 --- a/tests/Unit/Xml/Reader/SoapEnvelopeReaderTest.php +++ b/tests/Unit/Xml/Reader/SoapEnvelopeReaderTest.php @@ -5,12 +5,16 @@ namespace Soap\Encoding\Test\Unit\Xml\Writer; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Soap\Encoding\Exception\SoapFaultException; +use Soap\Encoding\Fault\Guard\SoapFaultGuard; use Soap\Encoding\Xml\Reader\SoapEnvelopeReader; use Soap\Encoding\Xml\Writer\SoapEnvelopeWriter; use Soap\WsdlReader\Model\Definitions\SoapVersion; #[CoversClass(SoapEnvelopeWriter::class)] +#[CoversClass(SoapFaultGuard::class)] final class SoapEnvelopeReaderTest extends TestCase { /** @@ -25,6 +29,29 @@ public function test_it_can_read_a_soap_envelope(SoapVersion $version, string $e static::assertXmlStringEqualsXmlString($expected, $actual); } + #[Test] + public function it_fails_reading_on_soap_12_fault(): void + { + $this->expectException(SoapFaultException::class); + + $reader = new SoapEnvelopeReader(); + $reader(<< + + + + soap:Sender + + + Sender Timeout + + + + + EOXML); + + } + public static function provideEnvelopeCases() { yield 'soap-1.1' => [