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
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<type>`, 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`,
Expand Down Expand Up @@ -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
Expand Down
35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
91 changes: 69 additions & 22 deletions src/Wsdl.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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('&amp;', '&', $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, '\\'));
}

/**
Expand Down Expand Up @@ -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()
Expand All @@ -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 = '<?xml version="1.0" ' . $encoding . '?>
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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<int, array<string, mixed>>|string $elements Elements of the type, each an
* associative array of name and type, or an empty string for an array type
* @return void
Expand Down
Loading
Loading