diff --git a/src/Fault/Encoder/Soap11FaultEncoder.php b/src/Fault/Encoder/Soap11FaultEncoder.php index a857155..c7f3689 100644 --- a/src/Fault/Encoder/Soap11FaultEncoder.php +++ b/src/Fault/Encoder/Soap11FaultEncoder.php @@ -5,10 +5,10 @@ use Soap\Encoding\Fault\Soap11Fault; use Soap\Encoding\Restriction\WhitespaceRestriction; +use Soap\Encoding\Xml\Writer\InMemoryXmlWriter; use Soap\Xml\Xmlns; use VeeWee\Reflecta\Iso\Iso; use VeeWee\Xml\Dom\Document; -use VeeWee\Xml\Writer\Writer; use function Psl\invariant; use function VeeWee\Xml\Dom\Xpath\Configurator\namespaces; use function VeeWee\Xml\Writer\Builder\children; @@ -16,7 +16,6 @@ use function VeeWee\Xml\Writer\Builder\namespaced_element; use function VeeWee\Xml\Writer\Builder\raw; use function VeeWee\Xml\Writer\Builder\value; -use function VeeWee\Xml\Writer\Mapper\memory_output; /** * @implements SoapFaultEncoder @@ -41,9 +40,8 @@ private function to(Soap11Fault $fault): string { $envNamespace = Xmlns::soap11Envelope()->value(); - /** @var non-empty-string */ - return Writer::inMemory() - ->write(children([ + return (new InMemoryXmlWriter())( + children([ namespaced_element( $envNamespace, 'env', @@ -70,8 +68,8 @@ private function to(Soap11Fault $fault): string ...($fault->detail !== null ? [raw($fault->detail)] : []), ]) ) - ])) - ->map(memory_output()); + ]) + ); } /** diff --git a/src/Fault/Encoder/Soap12FaultEncoder.php b/src/Fault/Encoder/Soap12FaultEncoder.php index 36b02af..30d5a24 100644 --- a/src/Fault/Encoder/Soap12FaultEncoder.php +++ b/src/Fault/Encoder/Soap12FaultEncoder.php @@ -5,9 +5,9 @@ use Soap\Encoding\Fault\Soap12Fault; use Soap\Encoding\Restriction\WhitespaceRestriction; +use Soap\Encoding\Xml\Writer\InMemoryXmlWriter; use VeeWee\Reflecta\Iso\Iso; use VeeWee\Xml\Dom\Document; -use VeeWee\Xml\Writer\Writer; use function Psl\invariant; use function VeeWee\Xml\Dom\Xpath\Configurator\namespaces; use function VeeWee\Xml\Writer\Builder\children; @@ -15,7 +15,6 @@ use function VeeWee\Xml\Writer\Builder\prefixed_element; use function VeeWee\Xml\Writer\Builder\raw; use function VeeWee\Xml\Writer\Builder\value; -use function VeeWee\Xml\Writer\Mapper\memory_output; /** * @implements SoapFaultEncoder @@ -40,9 +39,8 @@ public function iso(): Iso */ private function to(Soap12Fault $fault): string { - /** @var non-empty-string */ - return Writer::inMemory() - ->write(children([ + return (new InMemoryXmlWriter())( + children([ namespaced_element( self::ENV_NAMESPACE, 'env', @@ -113,8 +111,8 @@ private function to(Soap12Fault $fault): string ...($fault->detail !== null ? [raw($fault->detail)] : []), ]) ) - ])) - ->map(memory_output()); + ]) + ); } /** diff --git a/src/Xml/Writer/InMemoryXmlWriter.php b/src/Xml/Writer/InMemoryXmlWriter.php new file mode 100644 index 0000000..8a4f739 --- /dev/null +++ b/src/Xml/Writer/InMemoryXmlWriter.php @@ -0,0 +1,62 @@ + $writer + * + * @throws RuntimeException + * + * @return non-empty-string + */ + public function __invoke(callable $writer): string + { + $xmlWriter = XMLWriter::toMemory(); + + $previousErrorReporting = libxml_use_internal_errors(true); + libxml_clear_errors(); + + $failure = null; + try { + foreach ($writer($xmlWriter) as $written) { + if ($written === false) { + throw RuntimeException::withMessage('Could not write the provided XML to the stream.'); + } + } + } catch (Throwable $exception) { + $failure = $exception; + } + + $errors = libxml_get_errors(); + libxml_clear_errors(); + libxml_use_internal_errors($previousErrorReporting); + + if ($failure !== null) { + throw RuntimeException::combineExceptionWithIssues($failure, issue_collection_from_xml_errors($errors)); + } + + if ($errors) { + $issues = issue_collection_from_xml_errors($errors); + if (count($issues)) { + throw RuntimeException::fromIssues('XML issues detected: ', $issues); + } + } + + /** @psalm-var non-empty-string */ + return $xmlWriter->outputMemory(); + } +} diff --git a/src/Xml/Writer/SoapEnvelopeWriter.php b/src/Xml/Writer/SoapEnvelopeWriter.php index 1622199..8d6b4e9 100644 --- a/src/Xml/Writer/SoapEnvelopeWriter.php +++ b/src/Xml/Writer/SoapEnvelopeWriter.php @@ -12,14 +12,12 @@ use Soap\WsdlReader\Model\Definitions\SoapVersion; use Soap\Xml\Xmlns; use VeeWee\Xml\Exception\RuntimeException as XmlRuntimeException; -use VeeWee\Xml\Writer\Writer; use XMLWriter; use function Psl\Vec\filter_nulls; use function VeeWee\Xml\Writer\Builder\children; use function VeeWee\Xml\Writer\Builder\namespace_attribute; use function VeeWee\Xml\Writer\Builder\namespaced_attribute; use function VeeWee\Xml\Writer\Builder\namespaced_element; -use function VeeWee\Xml\Writer\Mapper\memory_output; final class SoapEnvelopeWriter { @@ -45,45 +43,43 @@ public function __invoke(): string }; try { - return Writer::inMemory() - ->write( + return (new InMemoryXmlWriter())( + namespaced_element( + $envelopeNamespace, + 'SOAP-ENV', + 'Envelope', namespaced_element( $envelopeNamespace, 'SOAP-ENV', - 'Envelope', - namespaced_element( - $envelopeNamespace, - 'SOAP-ENV', - 'Body', - children( - filter_nulls([ - // In SOAP 1.2 the position of the encoding attributes is limited: - // See: https://www.w3.org/TR/soap12-part1/#soapencattr - // For SOAP 1.1 it can be everywhere: - // See: https://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383495 - $this->encodingStyle - ->filter(fn (): bool => $this->bindingUse === BindingUse::ENCODED) - ->map( - static fn (EncodingStyle $encodingStyle) => children([ - namespace_attribute( - $encodingStyle->value, - 'SOAP-ENC' - ), - namespaced_attribute( - $envelopeNamespace, - 'SOAP-ENV', - 'encodingStyle', - $encodingStyle->value - ) - ]) - )->unwrapOr(null), - $this->children - ]) - ) + 'Body', + children( + filter_nulls([ + // In SOAP 1.2 the position of the encoding attributes is limited: + // See: https://www.w3.org/TR/soap12-part1/#soapencattr + // For SOAP 1.1 it can be everywhere: + // See: https://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383495 + $this->encodingStyle + ->filter(fn (): bool => $this->bindingUse === BindingUse::ENCODED) + ->map( + static fn (EncodingStyle $encodingStyle) => children([ + namespace_attribute( + $encodingStyle->value, + 'SOAP-ENC' + ), + namespaced_attribute( + $envelopeNamespace, + 'SOAP-ENV', + 'encodingStyle', + $encodingStyle->value + ) + ]) + )->unwrapOr(null), + $this->children + ]) ) ) ) - ->map(memory_output()); + ); } catch (XmlRuntimeException $e) { throw ($e->getPrevious() instanceof EncodingExceptionInterface) ? $e->getPrevious() : $e; } diff --git a/src/Xml/Writer/XsdTypeXmlElementWriter.php b/src/Xml/Writer/XsdTypeXmlElementWriter.php index f2558c5..2d23ec6 100644 --- a/src/Xml/Writer/XsdTypeXmlElementWriter.php +++ b/src/Xml/Writer/XsdTypeXmlElementWriter.php @@ -5,22 +5,20 @@ use Generator; use Soap\Encoding\Encoder\Context; -use VeeWee\Xml\Writer\Writer; +use VeeWee\Xml\Exception\RuntimeException; use XMLWriter; -use function VeeWee\Xml\Writer\Mapper\memory_output; final class XsdTypeXmlElementWriter { /** * @param callable(XMLWriter): Generator $children * + * @throws RuntimeException + * * @return non-empty-string */ public function __invoke(Context $context, callable $children): string { - /** @psalm-var non-empty-string */ - return Writer::inMemory() - ->write(new ElementBuilder($context, $children(...))) - ->map(memory_output()); + return (new InMemoryXmlWriter())(new ElementBuilder($context, $children(...))); } } diff --git a/tests/Unit/Fault/Encoder/Soap11FaultEncoderTest.php b/tests/Unit/Fault/Encoder/Soap11FaultEncoderTest.php index 74d5a5a..d817029 100644 --- a/tests/Unit/Fault/Encoder/Soap11FaultEncoderTest.php +++ b/tests/Unit/Fault/Encoder/Soap11FaultEncoderTest.php @@ -13,6 +13,21 @@ #[CoversClass(Soap11Fault::class)] final class Soap11FaultEncoderTest extends AbstractFaultEncoderTests { + public function test_it_writes_the_exact_fault_bytes(): void + { + $actual = (new Soap11FaultEncoder())->iso()->to(new Soap11Fault( + faultCode: 'env:Server', + faultString: 'a < b', + faultActor: 'uri:actor', + detail: 'value', + )); + + static::assertSame( + 'env:Servera < buri:actorvalue', + $actual + ); + } + public static function provideIsomorphicCases(): iterable { $baseConfig = [ diff --git a/tests/Unit/Fault/Encoder/Soap12FaultEncoderTest.php b/tests/Unit/Fault/Encoder/Soap12FaultEncoderTest.php index 5265134..72a4ea4 100644 --- a/tests/Unit/Fault/Encoder/Soap12FaultEncoderTest.php +++ b/tests/Unit/Fault/Encoder/Soap12FaultEncoderTest.php @@ -13,6 +13,23 @@ #[CoversClass(Soap12Fault::class)] final class Soap12FaultEncoderTest extends AbstractFaultEncoderTests { + public function test_it_writes_the_exact_fault_bytes(): void + { + $actual = (new Soap12FaultEncoder())->iso()->to(new Soap12Fault( + code: 'env:Sender', + reason: 'a < b', + subCode: 'm:Invalid', + node: 'uri:node', + role: 'uri:role', + detail: 'value', + )); + + static::assertSame( + 'env:Senderm:Invalida < buri:nodeuri:rolevalue', + $actual + ); + } + public static function provideIsomorphicCases(): iterable { $baseConfig = [ diff --git a/tests/Unit/Xml/Writer/InMemoryXmlWriterTest.php b/tests/Unit/Xml/Writer/InMemoryXmlWriterTest.php new file mode 100644 index 0000000..0410bce --- /dev/null +++ b/tests/Unit/Xml/Writer/InMemoryXmlWriterTest.php @@ -0,0 +1,198 @@ +hello', $actual); + } + + public function test_it_wraps_an_exception_thrown_by_the_writer(): void + { + $original = new LogicException('boom', 5); + + try { + (new InMemoryXmlWriter())( + static function () use ($original) { + throw $original; + yield true; + } + ); + } catch (Throwable $exception) { + static::assertInstanceOf(RuntimeException::class, $exception); + static::assertSame('boom' . PHP_EOL, $exception->getMessage()); + static::assertSame(5, $exception->getCode()); + static::assertSame($original, $exception->getPrevious()); + + return; + } + + static::fail('Expected the write to fail.'); + } + + public function test_it_fails_when_the_writer_returns_false(): void + { + try { + (new InMemoryXmlWriter())( + static function () { + yield false; + } + ); + } catch (Throwable $exception) { + $message = 'Could not write the provided XML to the stream.'; + static::assertInstanceOf(RuntimeException::class, $exception); + static::assertSame($message . PHP_EOL, $exception->getMessage()); + + $previous = $exception->getPrevious(); + static::assertInstanceOf(RuntimeException::class, $previous); + static::assertSame($message, $previous->getMessage()); + static::assertNull($previous->getPrevious()); + + return; + } + + static::fail('Expected the write to fail.'); + } + + public function test_it_wraps_an_invalid_element_name(): void + { + try { + (new InMemoryXmlWriter())(element('1st', value('hello'))); + } catch (Throwable $exception) { + $message = 'XMLWriter::startElement(): Argument #2 must be a valid element name, "1st" given'; + static::assertInstanceOf(RuntimeException::class, $exception); + static::assertSame($message . PHP_EOL, $exception->getMessage()); + + $previous = $exception->getPrevious(); + static::assertInstanceOf(ValueError::class, $previous); + static::assertSame($message, $previous->getMessage()); + + return; + } + + static::fail('Expected the write to fail.'); + } + + public function test_it_fails_on_libxml_issues_raised_while_writing(): void + { + try { + (new InMemoryXmlWriter())( + static function () { + (new DOMDocument())->loadXML('getMessage()); + static::assertNull($exception->getPrevious()); + + return; + } + + static::fail('Expected the write to fail.'); + } + + public function test_it_adds_libxml_issues_to_an_exception_thrown_by_the_writer(): void + { + $original = new LogicException('boom'); + + try { + (new InMemoryXmlWriter())( + static function () use ($original) { + (new DOMDocument())->loadXML('getMessage()); + static::assertSame($original, $exception->getPrevious()); + + return; + } + + static::fail('Expected the write to fail.'); + } + + public function test_it_wraps_once_per_nested_write(): void + { + $original = new LogicException('inner'); + $writer = new InMemoryXmlWriter(); + + try { + $writer( + static function (XMLWriter $xmlWriter) use ($writer, $original) { + yield $xmlWriter->writeRaw($writer( + static function () use ($original) { + throw $original; + yield true; + } + )); + } + ); + } catch (Throwable $exception) { + static::assertInstanceOf(RuntimeException::class, $exception); + static::assertSame('inner' . PHP_EOL . PHP_EOL, $exception->getMessage()); + + $previous = $exception->getPrevious(); + static::assertInstanceOf(RuntimeException::class, $previous); + static::assertSame('inner' . PHP_EOL, $previous->getMessage()); + static::assertSame($original, $previous->getPrevious()); + + return; + } + + static::fail('Expected the write to fail.'); + } + + public function test_it_clears_libxml_errors_and_restores_the_setting(): void + { + $previousSetting = libxml_use_internal_errors(true); + try { + (new DOMDocument())->loadXML('content') + ); + + static::assertSame( + '' + . '' + . 'content' + . '', + $writer() + ); + } + public function test_it_can_fail_writing_with_encoding_exception(): void { $this->expectException(ExceptionInterface::class); @@ -53,6 +83,42 @@ static function () { $writer(); } + public function test_it_surfaces_an_element_write_failure_as_the_encoding_exception(): void + { + $context = new Context( + XsdType::guess('string') + ->withXmlTargetNodeName('1st') + ->withMeta(static fn (TypeMeta $meta) => $meta->withIsSimple(true)->withIsElement(true)), + new InMemoryMetadata(new TypeCollection(), new MethodCollection()), + EncoderRegistry::default(), + new Namespaces([], []) + ); + $parameter = new ParameterBuilder( + (new MethodMeta())->withBindingStyle(BindingStyle::RPC->value), + $context, + 'hello' + ); + $writer = new SoapEnvelopeWriter(SoapVersion::SOAP_11, BindingUse::LITERAL, none(), $parameter(...)); + + try { + $writer(); + } catch (EncodingException $exception) { + $message = 'XMLWriter::startElement(): Argument #2 must be a valid element name, "1st" given'; + + $previous = $exception->getPrevious(); + static::assertInstanceOf(XmlRuntimeException::class, $previous); + static::assertSame($message . PHP_EOL, $previous->getMessage()); + + $previous = $previous->getPrevious(); + static::assertInstanceOf(ValueError::class, $previous); + static::assertSame($message, $previous->getMessage()); + + return; + } + + static::fail('Expected an EncodingException.'); + } + public static function provideEnvelopeCases() { yield 'soap-1.1-literal' => [ diff --git a/tests/Unit/Xml/Writer/XsdTypeXmlElementWriterTest.php b/tests/Unit/Xml/Writer/XsdTypeXmlElementWriterTest.php new file mode 100644 index 0000000..59342f9 --- /dev/null +++ b/tests/Unit/Xml/Writer/XsdTypeXmlElementWriterTest.php @@ -0,0 +1,52 @@ +hello', $actual); + } + + public function test_it_wraps_an_invalid_element_name(): void + { + try { + (new XsdTypeXmlElementWriter())(self::contextFor('1st'), value('hello')); + } catch (Throwable $exception) { + $message = 'XMLWriter::startElement(): Argument #2 must be a valid element name, "1st" given'; + static::assertInstanceOf(RuntimeException::class, $exception); + static::assertSame($message . PHP_EOL, $exception->getMessage()); + + $previous = $exception->getPrevious(); + static::assertInstanceOf(ValueError::class, $previous); + static::assertSame($message, $previous->getMessage()); + + return; + } + + static::fail('Expected the write to fail.'); + } + + private static function contextFor(string $nodeName): Context + { + return self::createContext(XsdType::guess('string')->withXmlTargetNodeName($nodeName)); + } +}