From 4a3e5719d1c2cc61215cac3cdeba1b02843b5ddb Mon Sep 17 00:00:00 2001 From: Belisoful Date: Thu, 17 Sep 2026 01:27:19 +0000 Subject: [PATCH] Fixes #4. Write a namespaced class name with dots, and refuse a document the parser objects to The provider's class name reached the document as it stood: in the target namespace, urn:App\Soap\QuoteProviderwsdl, and in the definitions, portType, binding, service and port names and the QNames referring to them. A backslash is valid in neither a URI nor an NCName. libxml loads such a document and warns about the namespace, PRADO's error handler turns the warning into an exception, and TSoapServer::getWsdl() threw for every namespaced provider. The unreleased loadOrFail() hid the warning behind @ and handed the document back, which is worse: it parses, and no client accepts it. A namespaced type had a related gap. The @param, @return, @var and @soaptype grammars accepted only \w, so App\Soap\Quote was cut at the first separator and reflected on as a class named App. Separate the class name, which reflection needs, from the name the document carries. Wsdl::documentName() replaces each separator with a dot and drops a leading one, so App\Soap\QuoteProvider is written as App.Soap.QuoteProvider, valid as both a URN and an NCName. The full name is kept rather than the short one, so two classes sharing a short name do not collide. Wsdl maps the service name once and writes the mapped name everywhere, and convertType() maps a type name the same way while declaring it and referring to it, so every tns: reference resolves to the complexType it names, the Array form included. The four tags take a fully qualified name, with or without the leading separator; a short name resolves in the global namespace as it always did. A class name holds nothing else the mapping touches, so a global class produces the document it always did, byte for byte. Two documents generated before this change are committed under tests/unit/Fixtures and compared whole, in both styles. loadOrFail() no longer hides anything. It keeps libxml's errors internal, so no warning reaches an error handler, and refuses a document the parser said anything about, naming what it said. The first parse now catches an invalid namespace, so the re-parse of the serialized document is gone. A service name that is not a class name and yields a URN the parser rejects, such as one holding a quote or a space, is refused where it loaded with a hidden warning before. The SOAP client and server of PHP accept the document of a namespaced provider in both styles: a client reads the signatures and the dotted struct names, and a client calling a server in the same process round-trips a call, through an adapter in the document style, where PHP hands a method the request wrapper as one object and encodes what it returns as the response wrapper. --- CHANGELOG.md | 22 +++- README.md | 35 ++++-- phpstan.neon.dist | 1 + src/Wsdl.php | 91 ++++++++++---- src/WsdlGenerator.php | 39 ++++-- tests/bootstrap.php | 8 +- .../WsdlTestTypeTagProvider.document.wsdl | 2 + .../Fixtures/WsdlTestTypeTagProvider.rpc.wsdl | 2 + tests/unit/Fixtures/namespaced.php | 102 +++++++++++++++ tests/unit/InProcessSoapClient.php | 53 ++++++++ tests/unit/WsdlGeneratorTest.php | 82 ++++++++++++ tests/unit/WsdlSoapClientTest.php | 118 +++++++++++++++++- tests/unit/WsdlTest.php | 114 ++++++++++++----- tests/unit/WsdlTestCase.php | 28 ++++- 14 files changed, 615 insertions(+), 82 deletions(-) create mode 100644 tests/unit/Fixtures/WsdlTestTypeTagProvider.document.wsdl create mode 100644 tests/unit/Fixtures/WsdlTestTypeTagProvider.rpc.wsdl create mode 100644 tests/unit/Fixtures/namespaced.php create mode 100644 tests/unit/InProcessSoapClient.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 042d2fc..440241e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,27 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `Wsdl::STYLE_RPC` remains the default and is unchanged. - A unit test suite, static analysis at level 6, and continuous integration on PHP 8.1, 8.2 and 8.3. +- `@param`, `@return`, `@var` and `@soaptype` take a fully qualified class name, + with or without the leading separator, so a namespaced class is a type. A + short name resolves in the global namespace, as it always did. +- `Wsdl::documentName()` maps a class name to the name the document carries. ### Fixed +- A namespaced provider produced a target namespace holding a backslash, such + as `urn:App\Soap\QuoteProviderwsdl`, which is not a URI. libxml warned while + loading it, PRADO's error handler turned the warning into an exception, and + `TSoapServer::getWsdl()` threw for every namespaced provider. The separator + is now written as a dot wherever the name appears: the target namespace, the + service, portType, binding and port names, and the complexType names, so + `App\Soap\QuoteProvider` becomes `App.Soap.QuoteProvider`, valid as both a + URN and an NCName. The full name is kept, so two classes sharing a short name + do not collide. A global class produces the document it always did, byte for + byte, and the SOAP server and client of PHP accept the document of a + namespaced provider in both binding styles. +- A document whose namespace the parser objected to was handed back regardless, + with the warning hidden. It is refused now, as a document that does not parse + is, naming what the parser said, and no warning reaches the error handler. - An array of a primitive declared its element as `tns:`, a type no document declares. A schema validator rejects a reference that does not resolve. The element now takes its XSD type, and the aliases `str`, `integer`, @@ -88,7 +106,9 @@ prose export a method. The generated document changes where the fixes above apply. A service returning an array of a primitive, a method returning void, and a second service generated in one process each produce a document that differs from 1.1. In each -case the 1.2 document is the correct one. +case the 1.2 document is the correct one. A namespaced provider produces a +document whose names are dotted, where 1.1 produced one no client loaded; a +global provider produces the document 1.1 did. Nothing was removed from the public or protected API, and no input that this package accepted in 1.1 is refused. Several that were fatal before now either diff --git a/README.md b/README.md index cd30471..fc8f358 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ echo WsdlGenerator::generate(MyProvider::class, 'https://example.com/soap', 'UTF `generate()` reflects on the provider, reads the tags below, and returns the document. It throws an `InvalidArgumentException` if the encoding is not an XML -encoding name, and a `RuntimeException` if the service name cannot be written -into a document. +encoding name, and a `RuntimeException` if the document does not parse or the +parser objects to it, which a service name that is not a class name can cause. ## Doc tags @@ -53,8 +53,10 @@ class MyProvider } ``` -A `[]` suffix declares the array form, which also declares the element type. Type names carry no -namespace, matching the names `@param` and `@return` use. +A `[]` suffix declares the array form, which also declares the element type. A type is named as +`@param` and `@return` name it: a global class by its name, and a namespaced class by its fully +qualified name, with or without the leading backslash. A short name resolves in the global +namespace, not in the namespace of the provider. `@soapproperty` supports `nillable`, `minOccurs` and `maxOccurs`, written in braces after the variable name: @@ -93,6 +95,24 @@ line or directly after the opening of the comment: */ ``` +## Namespaced classes + +A namespace separator is valid in neither a URI nor an NCName, and the class +name is written into both. The generator reflects on the class name as given and +writes it with each separator replaced by a dot, keeping the full name so two +classes sharing a short name do not collide. For `App\Soap\QuoteProvider`: + +| | Written as | +|---|---| +| `targetNamespace` | `urn:App.Soap.QuoteProviderwsdl` | +| `wsdl:service` | `App.Soap.QuoteProviderService`, and the portType, binding and port likewise | +| `@return App\Soap\Quote` | `tns:App.Soap.Quote`, declared as the complexType `App.Soap.Quote` | +| `@return App\Soap\Quote[]` | `tns:App.Soap.QuoteArray`, an unbounded sequence of `App.Soap.Quote` | + +`Wsdl::documentName()` is the mapping. A global class name holds nothing the +mapping touches, so the document of a global provider is what every earlier +release produced. + ## Binding style The generator emits remote procedure calls with SOAP encoding, as WSDL 1.1 and @@ -120,11 +140,8 @@ major release may change that. ## Limitations -- A type name carries no namespace. The generator reflects on the unqualified - name written in the doc comment, and writes it into the document as it stands. - A namespaced provider produces a `targetNamespace` that libxml reports is not - a valid URI, while still parsing the document. -- The binding style is always `rpc`, and the body is always `encoded`. +- A short type name resolves in the global namespace, not in the namespace of + the provider. A namespaced type is named by its fully qualified name. ## Development diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 7563c4c..cf7e6b7 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -12,3 +12,4 @@ parameters: # their declarations and not analysed. - tests/unit/Fixtures/types.php - tests/unit/Fixtures/providers.php + - tests/unit/Fixtures/namespaced.php diff --git a/src/Wsdl.php b/src/Wsdl.php index efdcea5..6d7d5f2 100644 --- a/src/Wsdl.php +++ b/src/Wsdl.php @@ -27,11 +27,20 @@ class Wsdl { /** - * The name of the service, usually the class name. + * The name of the service, usually the class name. It names the service when + * the document is refused; {@see $name} is what the document carries. * @var string */ private string $serviceName; + /** + * The name the document carries for the service, as {@see documentName()} + * maps it. It is written where an NCName or a URI is expected, which a + * namespaced class name is not. + * @var string + */ + private string $name; + /** * The URI the service is found at. An empty URI falls back to the current * request, without its query string. @@ -131,7 +140,8 @@ class Wsdl public const STYLE_DOCUMENT = 'document'; /** - * Creates a new wsdl document. + * Creates a new wsdl document. The service name is usually the class name of + * the provider, and reaches the document as {@see documentName()} maps it. * @param mixed $name The name of the service, a string, or a value coerced to * one as interpolation coerced it before the properties carried types * @param string $serviceUri The URI of the service that handles this WSDL @@ -148,13 +158,33 @@ public function __construct($name, $serviceUri = '', $encoding = '', $style = se // passed something other than a string still gets what it always got. $this->_encoding = (string) $encoding; $this->serviceName = (string) $name; + $this->name = self::documentName($this->serviceName); $protocol = (isset($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] !== 'off')) ? 'https://' : 'http://'; if ($serviceUri === '') { $serviceUri = $protocol . ($_SERVER['HTTP_HOST'] ?? '') . ($_SERVER['PHP_SELF'] ?? ''); } $this->serviceUri = str_replace('&', '&', $serviceUri); $this->types = new \ArrayObject(); - $this->targetNamespace = 'urn:' . $this->serviceName . 'wsdl'; + $this->targetNamespace = 'urn:' . $this->name . 'wsdl'; + } + + /** + * Maps a class name to the name the document carries for it. The service + * name and each complexType name are written as NCNames and into the target + * namespace URN, and a namespace separator is valid in neither. Each + * separator becomes a dot, which both allow, and a leading one is dropped: + * App\Soap\Quote is written as App.Soap.Quote. The full name is kept, so two + * classes sharing a short name do not collide. A class name holds nothing + * else a URI or an NCName refuses, and a global class name is returned as it + * stands, so the document of a global class is unchanged. The generator maps + * a type name the same way, so every tns: reference resolves. + * @param string $className The class name, with or without a leading separator + * @return string The name as the document carries it + * @since 1.2 + */ + public static function documentName($className) + { + return str_replace('\\', '.', ltrim((string) $className, '\\')); } /** @@ -206,7 +236,8 @@ protected function isDocumentStyle() /** * Generates the WSDL file into the $this->wsdl variable * @throws \InvalidArgumentException if the encoding is not an XML encoding name - * @throws \RuntimeException if the generated document does not parse + * @throws \RuntimeException if the generated document does not parse, or the + * parser objects to the target namespace * @return void */ protected function buildWsdl() @@ -219,7 +250,7 @@ protected function buildWsdl() $encoding = 'encoding="' . $this->_encoding . '"'; } - $name = self::escapeAttribute($this->serviceName); + $name = self::escapeAttribute($this->name); $targetNamespace = self::escapeAttribute($this->targetNamespace); $xml = ' @@ -243,26 +274,41 @@ protected function buildWsdl() $this->addService($dom); $this->wsdl = $dom->saveXML(); - - // A namespace URI is serialized as it was given, so a service name - // carrying a character a URI cannot hold reaches the caller as a document - // that no longer parses. Escaping does not reach a namespace declaration. - $this->loadOrFail(new \DOMDocument(), $this->wsdl); } /** - * Parses a document, and reports the service rather than the parser when it - * does not parse. + * Parses a document, and reports the service rather than the parser when the + * parser objects to it. A namespace URI that is not a URI loads with a + * warning, which is as fatal as a failure: an error handler such as PRADO's + * throws on it, and hiding it hands the caller a document no client accepts. + * The parser keeps its errors here rather than raising them, so nothing + * reaches the error handler, and a document it said anything about is + * refused, naming what it said. Escaping does not reach a namespace + * declaration, so this is the check that keeps such a document from the + * caller. * @param \DOMDocument $dom The document to parse into * @param string $xml The document to parse - * @throws \RuntimeException if the document does not parse + * @throws \RuntimeException if the document does not parse, or the parser + * objects to it * @return void * @since 1.2 */ private function loadOrFail(\DOMDocument $dom, $xml) { - if (!@$dom->loadXml($xml)) { - throw new \RuntimeException('The wsdl of the "' . $this->serviceName . '" service does not parse. Its name is not usable in a document.'); + $previous = libxml_use_internal_errors(true); + libxml_clear_errors(); + + try { + $loaded = $dom->loadXML($xml); + $errors = libxml_get_errors(); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + + if (!$loaded || $errors !== []) { + $reason = $errors === [] ? '.' : ': ' . trim($errors[0]->message); + throw new \RuntimeException('The wsdl of the "' . $this->serviceName . '" service does not parse' . $reason); } } @@ -465,7 +511,7 @@ protected function addMessages(\DOMDocument $dom) protected function addPortTypes(\DOMDocument $dom) { $portType = $dom->createElementNS('http://schemas.xmlsoap.org/wsdl/', 'wsdl:portType'); - $portType->setAttribute('name', $this->serviceName . 'PortType'); + $portType->setAttribute('name', $this->name . 'PortType'); $this->definitions->appendChild($portType); foreach ($this->operations as $operation) { @@ -482,8 +528,8 @@ protected function addPortTypes(\DOMDocument $dom) protected function addBindings(\DOMDocument $dom) { $binding = $dom->createElementNS('http://schemas.xmlsoap.org/wsdl/', 'wsdl:binding'); - $binding->setAttribute('name', $this->serviceName . 'Binding'); - $binding->setAttribute('type', 'tns:' . $this->serviceName . 'PortType'); + $binding->setAttribute('name', $this->name . 'Binding'); + $binding->setAttribute('type', 'tns:' . $this->name . 'PortType'); $soapBinding = $dom->createElementNS('http://schemas.xmlsoap.org/wsdl/soap/', 'soap:binding'); $soapBinding->setAttribute('style', $this->bindingStyle); @@ -506,11 +552,11 @@ protected function addBindings(\DOMDocument $dom) protected function addService(\DOMDocument $dom) { $service = $dom->createElementNS('http://schemas.xmlsoap.org/wsdl/', 'wsdl:service'); - $service->setAttribute('name', $this->serviceName . 'Service'); + $service->setAttribute('name', $this->name . 'Service'); $port = $dom->createElementNS('http://schemas.xmlsoap.org/wsdl/', 'wsdl:port'); - $port->setAttribute('name', $this->serviceName . 'Port'); - $port->setAttribute('binding', 'tns:' . $this->serviceName . 'Binding'); + $port->setAttribute('name', $this->name . 'Port'); + $port->setAttribute('binding', 'tns:' . $this->name . 'Binding'); $soapAddress = $dom->createElementNS('http://schemas.xmlsoap.org/wsdl/soap/', 'soap:address'); $soapAddress->setAttribute('location', $this->serviceUri); @@ -533,7 +579,8 @@ public function addOperation(WsdlOperation $operation) /** * Adds complexTypes to the wsdl - * @param string $type Name of the type + * @param string $type Name of the type, as the document carries it, which for + * a namespaced class is what {@see documentName()} returns * @param array>|string $elements Elements of the type, each an * associative array of name and type, or an empty string for an array type * @return void diff --git a/src/WsdlGenerator.php b/src/WsdlGenerator.php index 7fd36aa..f5d01bb 100644 --- a/src/WsdlGenerator.php +++ b/src/WsdlGenerator.php @@ -46,8 +46,10 @@ class WsdlGenerator private static ?WsdlGenerator $instance = null; /** - * The complex types to use in the wsdl, indexed by type name. An array type - * holds an empty string, because its element is derived from its name. + * The complex types to use in the wsdl, indexed by the name the document + * carries, which {@see Wsdl::documentName()} derives from the class name. An + * array type holds an empty string, because its element is derived from its + * name. * @var array>|string> */ private array $types = []; @@ -176,8 +178,9 @@ public static function generate($className, $serviceUri = '', $encoding = '', $s * \@soaptype MyRecord * \@soaptype MyRecord[] * - * The class name follows the same grammar as \@param and \@return, so it carries - * no namespace. + * The class name follows the same grammar as \@param and \@return: a global + * class by its name, a namespaced class by its fully qualified name, with or + * without the leading separator. * @param \ReflectionClass $classReflect The class to read the tags from * @return void * @since 1.2 @@ -189,7 +192,7 @@ protected function processTypeTags(\ReflectionClass $classReflect) return; } - if (preg_match_all('/' . self::TAG_START . 'soaptype\s+(\w+(\[\s*\])?)/mi', $comment, $matches)) { + if (preg_match_all('/' . self::TAG_START . 'soaptype\s+([\w\\\\]+(\[\s*\])?)/mi', $comment, $matches)) { foreach ($matches[1] as $type) { $this->convertType(preg_replace('/\s+/', '', $type)); } @@ -220,7 +223,9 @@ protected static function hasTag($comment, $tag) } /** - * Process a method found in the passed in class. + * Process a method found in the passed in class. A \@param or \@return names + * a global class by its name, or a namespaced class by its fully qualified + * name, with or without the leading separator. * @param \ReflectionMethod $method The method to process * @return void */ @@ -252,13 +257,13 @@ protected function processMethod(\ReflectionMethod $method) } if ($line[0] == '@') { $gotDesc = true; - if (preg_match('/^@param\s+([\w\[\]()]+)\s+\$([\w()]+)\s*(.*)/i', $line, $match)) { + if (preg_match('/^@param\s+([\w\\\\\[\]()]+)\s+\$([\w()]+)\s*(.*)/i', $line, $match)) { $param = []; $param['type'] = $this->convertType($match[1]); $param['name'] = $match[2]; $param['desc'] = $match[3]; $params[] = $param; - } elseif (preg_match('/^@return\s+([\w\[\]()]+)\s*(.*)/i', $line, $match)) { + } elseif (preg_match('/^@return\s+([\w\\\\\[\]()]+)\s*(.*)/i', $line, $match)) { $gotParams = true; $return['type'] = $this->convertType($match[1]); $return['desc'] = $match[2]; @@ -294,6 +299,11 @@ protected function processMethod(\ReflectionMethod $method) * Converts from a PHP type into a WSDL type. This is borrowed from * Cerebral Cortex (let me know and I'll remove asap). * + * A class is reflected on by the name written, and declared and referred to + * by the name {@see Wsdl::documentName()} derives from it, so a namespaced + * class produces a QName the document can hold. Both come from one name, so + * the reference and the declaration agree. + * * TODO: date and dateTime * @param string $type The php type to convert * @return string The XSD type. @@ -331,12 +341,14 @@ private function convertType($type): string default: if (strpos($type, '[]')) { // if it is an array $className = substr($type, 0, strlen($type) - 2); - $type = $className . 'Array'; + $type = Wsdl::documentName($className) . 'Array'; $this->types[$type] = ''; $this->convertType($className); } else { + $className = $type; + $type = Wsdl::documentName($className); if (!isset($this->types[$type])) { - $this->extractClassProperties($type); + $this->extractClassProperties($className); } } return 'tns:' . $type; @@ -348,6 +360,8 @@ private function convertType($type): string * This method extract properties from PHPDoc formatted comments for variables. Unfortunately the reflectionproperty * class doesn't have a getDocComment method to extract comments about it, so we have to extract the information * about the variables manually. Thanks heaps to Cristian Losada for implementing this. + * The type is stored under the name the document carries, and a \@var names + * its class as \@param does. * @param string $className The name of the class */ private function extractClassProperties($className): void @@ -358,11 +372,12 @@ private function extractClassProperties($className): void * DocComment is available since PHP 5.1 */ $reflection = new \ReflectionClass($className); + $type = Wsdl::documentName($className); $properties = $reflection->getProperties(); foreach ($properties as $property) { $comment = $property->getDocComment(); if (self::hasTag($comment, 'soapproperty')) { - if (preg_match('/@var\s+([\w\.]+(\[\s*\])?)\s*?\$(.*)$/mi', $comment, $matches)) { + if (preg_match('/@var\s+([\w\.\\\\]+(\[\s*\])?)\s*?\$(.*)$/mi', $comment, $matches)) { // support nillable, minOccurs, maxOccurs attributes $nillable = $minOccurs = $maxOccurs = false; if (preg_match('/{(.+)}/', $matches[3], $attr)) { @@ -386,7 +401,7 @@ private function extractClassProperties($className): void $param['nil'] = $nillable; $param['minOc'] = $minOccurs; $param['maxOc'] = $maxOccurs; - $this->types[$className][] = $param; + $this->types[$type][] = $param; } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index dcb309c..5b40a9b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -4,11 +4,13 @@ * Bootstrap for the unit tests. * * The generator resolves a complex type by reflecting on the class name written - * in a doc comment, and those names carry no namespace. The types under test - * therefore live in the global namespace, where psr-4 cannot reach them, and - * are loaded here instead. + * in a doc comment. A name without a namespace resolves in the global one, so + * the types and providers under test live there, where psr-4 cannot reach them, + * and are loaded here instead. The namespaced fixtures are loaded the same way, + * because a file holding several classes is not one psr-4 can find. */ require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/unit/Fixtures/types.php'; require __DIR__ . '/unit/Fixtures/providers.php'; +require __DIR__ . '/unit/Fixtures/namespaced.php'; diff --git a/tests/unit/Fixtures/WsdlTestTypeTagProvider.document.wsdl b/tests/unit/Fixtures/WsdlTestTypeTagProvider.document.wsdl new file mode 100644 index 0000000..f4c1929 --- /dev/null +++ b/tests/unit/Fixtures/WsdlTestTypeTagProvider.document.wsdl @@ -0,0 +1,2 @@ + +Looks a record up. diff --git a/tests/unit/Fixtures/WsdlTestTypeTagProvider.rpc.wsdl b/tests/unit/Fixtures/WsdlTestTypeTagProvider.rpc.wsdl new file mode 100644 index 0000000..ff87238 --- /dev/null +++ b/tests/unit/Fixtures/WsdlTestTypeTagProvider.rpc.wsdl @@ -0,0 +1,2 @@ + +Looks a record up. diff --git a/tests/unit/Fixtures/namespaced.php b/tests/unit/Fixtures/namespaced.php new file mode 100644 index 0000000..11270d7 --- /dev/null +++ b/tests/unit/Fixtures/namespaced.php @@ -0,0 +1,102 @@ +symbol = $symbol; + $quote->price = new Money(); + $quote->price->amount = 1.5; + $quote->price->currency = $limit->currency ?? 'USD'; + return $quote; + } + + /** + * Lists the quotes of the day. + * @return Prado\Wsdl\Test\Unit\Fixtures\Quote[] the quotes + * @soapmethod + */ + public function history() + { + return [$this->quote('ABC'), $this->quote('XYZ')]; + } +} + +class Quote +{ + /** + * @soapproperty + * @var string $symbol + */ + public $symbol; + + /** + * @soapproperty + * @var Prado\Wsdl\Test\Unit\Fixtures\Money $price + */ + public $price; +} + +class Money +{ + /** + * @soapproperty + * @var float $amount + */ + public $amount; + + /** + * @soapproperty + * @var string $currency + */ + public $currency; +} + +/** + * Serves QuoteProvider in the document and literal style. The SOAP server of + * PHP hands a method the request wrapper as one object and encodes what the + * method returns as the response wrapper, so each method unwraps and wraps. + */ +class WrappedQuoteProvider +{ + /** + * @param object $request the quote wrapper, holding symbol and limit + * @return array the quoteResponse wrapper + */ + public function quote($request) + { + return ['return' => (new QuoteProvider())->quote($request->symbol, $request->limit ?? null)]; + } + + /** + * @return array the historyResponse wrapper + */ + public function history() + { + return ['return' => (new QuoteProvider())->history()]; + } +} diff --git a/tests/unit/InProcessSoapClient.php b/tests/unit/InProcessSoapClient.php new file mode 100644 index 0000000..c3c4728 --- /dev/null +++ b/tests/unit/InProcessSoapClient.php @@ -0,0 +1,53 @@ + $options The client options + * @param SoapServer $server The server that answers, reading the same document + */ + public function __construct($wsdl, array $options, SoapServer $server) + { + parent::__construct($wsdl, $options); + $this->server = $server; + } + + /** + * Hands the request to the server and returns what it wrote. + * @param string $request The SOAP request + * @param string $location The service URI, unused + * @param string $action The SOAP action, unused + * @param int $version The SOAP version, unused + * @param bool $oneWay Whether no response is expected + * @return ?string The SOAP response + */ + public function __doRequest($request, $location, $action, $version, $oneWay = false): ?string + { + ob_start(); + try { + $this->server->handle($request); + } finally { + $response = ob_get_clean(); + } + return $oneWay ? null : (string) $response; + } +} diff --git a/tests/unit/WsdlGeneratorTest.php b/tests/unit/WsdlGeneratorTest.php index a87833c..3ff0193 100644 --- a/tests/unit/WsdlGeneratorTest.php +++ b/tests/unit/WsdlGeneratorTest.php @@ -2,6 +2,7 @@ namespace Prado\Wsdl\Test\Unit; +use Prado\Wsdl\Test\Unit\Fixtures\QuoteProvider; use Prado\Wsdl\Wsdl; use Prado\Wsdl\WsdlGenerator; use ReflectionClass; @@ -366,9 +367,90 @@ public static function providerNameProvider(): array ['WsdlTestTypeTagProvider'], ['WsdlTestNestedProvider'], ['WsdlTestDocCommentProvider'], + [QuoteProvider::class], ]; } + /** + * A namespaced provider used to produce a target namespace holding a + * backslash, which is not a URI. The parser warned, and PRADO's error + * handler turned the warning into an exception, so every namespaced provider + * failed to serve its document. The separator is written as a dot. + */ + public function testANamespacedProviderProducesADocumentTheParserAccepts(): void + { + $dom = $this->generate(QuoteProvider::class); + + $this->assertSame('urn:Prado.Wsdl.Test.Unit.Fixtures.QuoteProviderwsdl', $dom->documentElement->getAttribute('targetNamespace')); + $this->assertSame('Prado.Wsdl.Test.Unit.Fixtures.QuoteProvider', $dom->documentElement->getAttribute('name')); + $this->assertSame(['Prado.Wsdl.Test.Unit.Fixtures.QuoteProviderService'], $this->attributes($dom, '//wsdl:service', 'name')); + $this->assertSame(['tns:Prado.Wsdl.Test.Unit.Fixtures.QuoteProviderBinding'], $this->attributes($dom, '//wsdl:port', 'binding')); + $this->assertSame(['quote', 'history'], $this->attributes($dom, '//wsdl:portType/wsdl:operation', 'name')); + } + + /** + * A type named by its fully qualified name is reflected on as such, and + * declared under the dotted form of that name, which is what each reference + * to it carries. + */ + public function testANamespacedTypeIsDeclaredUnderItsDottedName(): void + { + $dom = $this->generate(QuoteProvider::class); + $quote = 'Prado.Wsdl.Test.Unit.Fixtures.Quote'; + $money = 'Prado.Wsdl.Test.Unit.Fixtures.Money'; + + $this->assertEqualsCanonicalizing([$money, $quote . 'Array', $quote], $this->complexTypes($dom)); + $this->assertSame(['symbol' => 'xsd:string', 'limit' => 'tns:' . $money], $this->parts($dom, 'quoteRequest')); + $this->assertSame(['return' => 'tns:' . $quote], $this->parts($dom, 'quoteResponse')); + $this->assertSame(['return' => 'tns:' . $quote . 'Array'], $this->parts($dom, 'historyResponse')); + + $price = $this->element($dom, "//xsd:complexType[@name='" . $quote . "']/*/xsd:element[@name='price']"); + $this->assertSame('tns:' . $money, $price->getAttribute('type')); + + $element = $this->element($dom, "//xsd:complexType[@name='" . $quote . "Array']/xsd:sequence/xsd:element"); + $this->assertSame($quote, $element->getAttribute('name')); + $this->assertSame('tns:' . $quote, $element->getAttribute('type')); + } + + /** + * A leading separator names the same class, so the type it names is declared + * once, however the tags spell it. + */ + public function testALeadingSeparatorNamesTheSameType(): void + { + $types = $this->complexTypes($this->generate(QuoteProvider::class)); + $this->assertCount(1, array_keys($types, 'Prado.Wsdl.Test.Unit.Fixtures.Money')); + } + + public function testNoNameInANamespacedDocumentHoldsASeparator(): void + { + $dom = $this->generate(QuoteProvider::class, 'http://example.com/soap', Wsdl::STYLE_DOCUMENT); + $this->assertStringNotContainsString('\\', $dom->saveXML()); + } + + /** + * A client written against a global provider, or a cached copy of its + * document, reads the document 1.1 produced. The mapping touches nothing + * but a separator, so the document of a global class is unchanged to the + * byte. + * + * @dataProvider styleProvider + * @param string $style The binding style to generate + */ + public function testTheDocumentOfAGlobalProviderIsUnchanged($style): void + { + $expected = file_get_contents(__DIR__ . '/Fixtures/WsdlTestTypeTagProvider.' . $style . '.wsdl'); + $this->assertSame($expected, WsdlGenerator::generate('WsdlTestTypeTagProvider', 'http://example.com/soap', 'UTF-8', $style)); + } + + /** + * @return array> The binding style + */ + public static function styleProvider(): array + { + return ['rpc' => [Wsdl::STYLE_RPC], 'document' => [Wsdl::STYLE_DOCUMENT]]; + } + /** * A marker ends its line, so a comment that goes on to say something about * the tag is discussing it rather than carrying it. diff --git a/tests/unit/WsdlSoapClientTest.php b/tests/unit/WsdlSoapClientTest.php index 58e2826..b54ec3c 100644 --- a/tests/unit/WsdlSoapClientTest.php +++ b/tests/unit/WsdlSoapClientTest.php @@ -2,8 +2,12 @@ namespace Prado\Wsdl\Test\Unit; +use Prado\Wsdl\Test\Unit\Fixtures\QuoteProvider; +use Prado\Wsdl\Test\Unit\Fixtures\WrappedQuoteProvider; +use Prado\Wsdl\Wsdl; use Prado\Wsdl\WsdlGenerator; use SoapClient; +use SoapServer; /** * Reads generated documents back with the SOAP client of PHP itself, which is @@ -32,20 +36,51 @@ protected function tearDown(): void } /** - * Generates a provider's document and reads it back with a SOAP client. + * Generates a provider's document and writes it where a client can read it. * @param string $className The provider to generate for - * @return SoapClient The client reading the document + * @param string $style The binding style to generate + * @return string The URI of the written document */ - protected function client($className) + protected function wsdlFile($className, $style = Wsdl::STYLE_RPC) { $generator = new WsdlGenerator(); + $generator->setStyle($style); $generator->generateWsdl($className, 'http://example.com/soap', 'UTF-8'); $file = tempnam(sys_get_temp_dir(), 'wsdl') . '.wsdl'; $this->files[] = $file; file_put_contents($file, $generator->getWsdl()); - return new SoapClient('file://' . $file, ['exceptions' => true, 'cache_wsdl' => WSDL_CACHE_NONE]); + return 'file://' . $file; + } + + /** + * Generates a provider's document and reads it back with a SOAP client. + * @param string $className The provider to generate for + * @param string $style The binding style to generate + * @return SoapClient The client reading the document + */ + protected function client($className, $style = Wsdl::STYLE_RPC) + { + return new SoapClient($this->wsdlFile($className, $style), ['exceptions' => true, 'cache_wsdl' => WSDL_CACHE_NONE]); + } + + /** + * Generates a provider's document, serves a class from it, and returns a + * client calling that server in process. + * @param string $className The provider to generate for + * @param string $style The binding style to generate + * @param string $serverClass The class the server hands each request to + * @return InProcessSoapClient The client calling the server + */ + protected function roundTrip($className, $style, $serverClass) + { + $wsdl = $this->wsdlFile($className, $style); + + $server = new SoapServer($wsdl, ['cache_wsdl' => WSDL_CACHE_NONE]); + $server->setClass($serverClass); + + return new InProcessSoapClient($wsdl, ['exceptions' => true, 'cache_wsdl' => WSDL_CACHE_NONE], $server); } public function testTheClientReadsAnOperationSignature(): void @@ -99,4 +134,79 @@ public function testTheClientReadsThePropertiesOfADeclaredType(): void $this->assertStringContainsString('string name;', $person); $this->assertStringContainsString('WsdlTestAddress address;', $person); } + + /** + * A namespaced provider used to produce a document the client refused, its + * target namespace not being a URI. The client reads the dotted names, and + * in the document style reads each operation as taking its request wrapper, + * which is how the client of PHP presents a wrapped document. + */ + public function testTheClientReadsANamespacedProvider(): void + { + $client = $this->client(QuoteProvider::class); + $this->assertSame([ + 'Prado.Wsdl.Test.Unit.Fixtures.Quote quote(string $symbol, Prado.Wsdl.Test.Unit.Fixtures.Money $limit)', + 'Prado.Wsdl.Test.Unit.Fixtures.QuoteArray history()', + ], $client->__getFunctions()); + $this->assertNamespacedTypesAreRead($client); + + $client = $this->client(QuoteProvider::class, Wsdl::STYLE_DOCUMENT); + $this->assertSame([ + 'quoteResponse quote(quote $parameters)', + 'historyResponse history(history $parameters)', + ], $client->__getFunctions()); + $this->assertNamespacedTypesAreRead($client); + } + + /** + * Asserts that a client reads the namespaced types under their dotted names. + * @param SoapClient $client The client reading the document + * @return void + */ + protected function assertNamespacedTypesAreRead(SoapClient $client) + { + $types = implode("\n", $client->__getTypes()); + $this->assertStringContainsString('struct Prado.Wsdl.Test.Unit.Fixtures.Quote {', $types); + $this->assertStringContainsString('Prado.Wsdl.Test.Unit.Fixtures.Money price;', $types); + $this->assertStringContainsString('struct Prado.Wsdl.Test.Unit.Fixtures.Money {', $types); + $this->assertStringContainsString('struct Prado.Wsdl.Test.Unit.Fixtures.QuoteArray {', $types); + } + + /** + * The server of PHP reads the same document to decode a request and encode + * its answer, so a call through both ends is the check that each accepts + * the document. The client hands an array type back as an object holding + * the elements under the element name. + */ + public function testAServerAndClientRoundTripANamespacedProviderInRpcStyle(): void + { + $client = $this->roundTrip(QuoteProvider::class, Wsdl::STYLE_RPC, QuoteProvider::class); + + $quote = $client->quote('ABC', ['amount' => 2.5, 'currency' => 'EUR']); + $this->assertSame('ABC', $quote->symbol); + $this->assertSame(1.5, $quote->price->amount); + $this->assertSame('EUR', $quote->price->currency); + + $quotes = $client->history()->{'Prado.Wsdl.Test.Unit.Fixtures.Quote'}; + $this->assertSame(['ABC', 'XYZ'], array_map(fn ($quote) => $quote->symbol, $quotes)); + } + + /** + * In the document style the server of PHP hands a method the request + * wrapper as one object and encodes what it returns as the response + * wrapper, and the client calls and reads the same way, so the provider is + * served through an adapter following that convention. + */ + public function testAServerAndClientRoundTripANamespacedProviderInDocumentStyle(): void + { + $client = $this->roundTrip(QuoteProvider::class, Wsdl::STYLE_DOCUMENT, WrappedQuoteProvider::class); + + $quote = $client->quote(['symbol' => 'ABC', 'limit' => ['amount' => 2.5, 'currency' => 'EUR']])->return; + $this->assertSame('ABC', $quote->symbol); + $this->assertSame(1.5, $quote->price->amount); + $this->assertSame('EUR', $quote->price->currency); + + $quotes = $client->history()->return->{'Prado.Wsdl.Test.Unit.Fixtures.Quote'}; + $this->assertSame(['ABC', 'XYZ'], array_map(fn ($quote) => $quote->symbol, $quotes)); + } } diff --git a/tests/unit/WsdlTest.php b/tests/unit/WsdlTest.php index cac7e29..a87cf8f 100644 --- a/tests/unit/WsdlTest.php +++ b/tests/unit/WsdlTest.php @@ -32,9 +32,7 @@ protected function newWsdl($name = 'Service', $encoding = 'UTF-8') */ protected function parse(Wsdl $wsdl) { - $dom = new DOMDocument(); - $this->assertTrue($dom->loadXML($wsdl->getWsdl())); - return $dom; + return $this->parseStrictly($wsdl->getWsdl()); } public function testTheStyleDefaultsToRpcAndIsSettable(): void @@ -167,45 +165,103 @@ public function testAnAmpersandInTheServiceNameSurvivesTheDocument(): void } /** - * Parses a document whose namespace URI the parser objects to, and returns - * both the document and what it said. - * @param Wsdl $wsdl The document to read - * @return array{0: DOMDocument, 1: array} The parsed document and the distinct parser messages + * A provider class is namespaced in any PSR-4 project, and a namespace + * separator is valid in neither a URI nor an NCName. The document used to + * carry it as it stood, so the parser warned about the target namespace, + * and PRADO's error handler turned the warning into an exception for every + * namespaced provider. Each separator is written as a dot instead. */ - protected function parseWithComplaints(Wsdl $wsdl) + public function testANamespacedServiceNameIsWrittenWithDots(): void { - $previous = libxml_use_internal_errors(true); - libxml_clear_errors(); + $dom = $this->parse($this->newWsdl('Prado\\Wsdl\\Payments')); - try { - $dom = new DOMDocument(); - $this->assertTrue($dom->loadXML($wsdl->getWsdl()), 'the document still parses'); - $messages = array_values(array_unique(array_map(fn ($error) => trim($error->message), libxml_get_errors()))); - } finally { - libxml_clear_errors(); - libxml_use_internal_errors($previous); - } + $this->assertSame('Prado.Wsdl.Payments', $dom->documentElement->getAttribute('name')); + $this->assertSame('urn:Prado.Wsdl.Paymentswsdl', $dom->documentElement->getAttribute('targetNamespace')); + $this->assertSame('urn:Prado.Wsdl.Paymentswsdl', $dom->documentElement->lookupNamespaceURI('tns')); + $this->assertStringNotContainsString('\\', $dom->saveXML()); + } - return [$dom, $messages]; + /** + * Every name derived from the service name is an NCName, and every QName + * referring to one names the element it stands for. + */ + public function testEveryNameDerivedFromANamespacedServiceNameResolves(): void + { + $dom = $this->parse($this->newWsdl('Prado\\Wsdl\\Payments')); + + $this->assertSame(['Prado.Wsdl.PaymentsPortType'], $this->attributes($dom, '//wsdl:portType', 'name')); + $this->assertSame(['Prado.Wsdl.PaymentsBinding'], $this->attributes($dom, '//wsdl:binding', 'name')); + $this->assertSame(['tns:Prado.Wsdl.PaymentsPortType'], $this->attributes($dom, '//wsdl:binding', 'type')); + $this->assertSame(['Prado.Wsdl.PaymentsService'], $this->attributes($dom, '//wsdl:service', 'name')); + $this->assertSame(['Prado.Wsdl.PaymentsPort'], $this->attributes($dom, '//wsdl:port', 'name')); + $this->assertSame(['tns:Prado.Wsdl.PaymentsBinding'], $this->attributes($dom, '//wsdl:port', 'binding')); + $this->assertSame(['urn:Prado.Wsdl.Paymentswsdl#op'], $this->attributes($dom, '//soap:operation', 'soapAction')); + $this->assertSame(['urn:Prado.Wsdl.Paymentswsdl', 'urn:Prado.Wsdl.Paymentswsdl'], $this->attributes($dom, '//soap:body', 'namespace')); } - public function testAQuoteInTheServiceNameSurvivesTheDocument(): void + /** + * @dataProvider documentNameProvider + * @param string $className The class name to map + * @param string $expected The name the document carries + */ + public function testDocumentNameReplacesEachSeparatorWithADot($className, $expected): void { - [$dom] = $this->parseWithComplaints($this->newWsdl('Pay"Go')); - $this->assertSame('Pay"Go', $dom->documentElement->getAttribute('name')); + $this->assertSame($expected, Wsdl::documentName($className)); } /** - * A name holding a namespace separator reaches the document, and the urn - * built from it is not a URI the parser accepts. It has always been so, and - * every namespaced provider carries one. + * @return array> The class name and the name the document carries */ - public function testANamespacedServiceNameSurvivesTheDocument(): void + public static function documentNameProvider(): array { - [$dom, $messages] = $this->parseWithComplaints($this->newWsdl('Prado\\Wsdl\\Payments')); + return [ + 'global' => ['Payments', 'Payments'], + 'namespaced' => ['App\\Soap\\QuoteProvider', 'App.Soap.QuoteProvider'], + 'leading separator' => ['\\App\\Soap\\QuoteProvider', 'App.Soap.QuoteProvider'], + 'empty' => ['', ''], + 'not a class name' => ['Pay&Go', 'Pay&Go'], + ]; + } - $this->assertSame('Prado\\Wsdl\\Payments', $dom->documentElement->getAttribute('name')); - $this->assertSame(['xmlns:tns: \'urn:Prado\\Wsdl\\Paymentswsdl\' is not a valid URI'], $messages); + /** + * The parser accepts a namespace URI it finds invalid, warning about it, + * and the warning used to be hidden. The document is refused instead, naming + * what the parser said, and no warning reaches the error handler. + * + * @dataProvider unusableNameProvider + * @param string $name The service name + */ + public function testANameTheParserObjectsToIsRefusedWithoutAWarning($name): void + { + $raised = []; + set_error_handler(function ($severity, $message) use (&$raised) { + $raised[] = $message; + return true; + }); + + try { + $this->newWsdl($name)->getWsdl(); + $this->fail('the document was not refused'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('"' . $name . '" service does not parse', $e->getMessage()); + $this->assertStringContainsString('is not a valid URI', $e->getMessage()); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $raised); + } + + /** + * @return array> The service name + */ + public static function unusableNameProvider(): array + { + return [ + 'quote' => ['Pay"Go'], + 'less than' => ['Pay ['Pay Go'], + ]; } /** diff --git a/tests/unit/WsdlTestCase.php b/tests/unit/WsdlTestCase.php index 5ba24e0..805b702 100644 --- a/tests/unit/WsdlTestCase.php +++ b/tests/unit/WsdlTestCase.php @@ -33,8 +33,32 @@ protected function generate($className, $serviceUri = 'http://example.com/soap', $generator->setStyle($style); $generator->generateWsdl($className, $serviceUri, 'UTF-8'); - $dom = new DOMDocument(); - $this->assertTrue($dom->loadXML($generator->getWsdl()), 'the generated wsdl parses'); + return $this->parseStrictly($generator->getWsdl()); + } + + /** + * Parses a document, failing on anything the parser objects to. The parser + * accepts a namespace URI it finds invalid and warns instead, and PRADO's + * error handler turns that warning into an exception, so a warning is as + * fatal as a document that does not parse. + * @param string $xml The document to parse + * @return DOMDocument The parsed document + */ + protected function parseStrictly($xml) + { + $previous = libxml_use_internal_errors(false); + set_error_handler(function ($severity, $message, $file, $line) { + throw new \ErrorException($message, 0, $severity, $file, $line); + }); + + try { + $dom = new DOMDocument(); + $this->assertTrue($dom->loadXML($xml), 'the document parses'); + } finally { + restore_error_handler(); + libxml_use_internal_errors($previous); + } + return $dom; }