Skip to content

Commit

Permalink
Merge pull request #23 from veewee/resolve-conflicting-xmlns
Browse files Browse the repository at this point in the history
Resolve conflicting XMLNS imports
  • Loading branch information
veewee authored Sep 27, 2024
2 parents dff99df + c0fc5a5 commit 8975e4f
Show file tree
Hide file tree
Showing 17 changed files with 439 additions and 26 deletions.
3 changes: 3 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,7 @@
<plugins>
<pluginClass class="Psalm\SymfonyPsalmPlugin\Plugin"/>
</plugins>
<stubs>
<file name="stubs/dom.phpstub" />
</stubs>
</psalm>
2 changes: 2 additions & 0 deletions src/Xml/Configurator/FlattenWsdlImports.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use VeeWee\Xml\Exception\RuntimeException;
use function VeeWee\Xml\Dom\Locator\document_element;
use function VeeWee\Xml\Dom\Locator\Node\children;
use function VeeWee\Xml\Dom\Manipulator\Element\copy_named_xmlns_attributes;
use function VeeWee\Xml\Dom\Manipulator\Node\append_external_node;
use function VeeWee\Xml\Dom\Manipulator\Node\remove;
use function VeeWee\Xml\Dom\Manipulator\Node\replace_by_external_nodes;
Expand Down Expand Up @@ -82,6 +83,7 @@ private function importWsdlImportElement(DOMElement $import): void
private function importWsdlPart(DOMElement $importElement, Document $importedDocument): void
{
$definitions = $importedDocument->map(document_element());
copy_named_xmlns_attributes($importElement->ownerDocument->documentElement, $definitions);

replace_by_external_nodes(
$importElement,
Expand Down
31 changes: 5 additions & 26 deletions src/Xml/Configurator/FlattenXsdImports.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use Soap\Wsdl\Loader\Context\FlatteningContext;
use Soap\Wsdl\Uri\IncludePathBuilder;
use Soap\Wsdl\Xml\Exception\FlattenException;
use Soap\Wsdl\Xml\Xmlns\FixRemovedDefaultXmlnsDeclarationsDuringImport;
use Soap\Wsdl\Xml\Xmlns\RegisterNonConflictingXmlnsNamespaces;
use Soap\Xml\Xpath\WsdlPreset;
use VeeWee\Xml\Dom\Configurator\Configurator;
use VeeWee\Xml\Dom\Document;
Expand All @@ -21,7 +23,6 @@
use function Psl\Vec\reverse;
use function VeeWee\Xml\Dom\Assert\assert_element;
use function VeeWee\Xml\Dom\Locator\Node\children;
use function VeeWee\Xml\Dom\Manipulator\Element\copy_named_xmlns_attributes;
use function VeeWee\Xml\Dom\Manipulator\Node\append_external_node;
use function VeeWee\Xml\Dom\Manipulator\Node\remove;

Expand Down Expand Up @@ -170,6 +171,7 @@ private function loadSchema(string $location): ?DOMElement
* This function registers the newly provided schema in the WSDL types section.
* It groups all imports by targetNamespace.
*
* @throws \RuntimeException
* @throws RuntimeException
* @throws AssertException
*/
Expand All @@ -187,42 +189,19 @@ private function registerSchemaInTypes(DOMElement $schema): void
// If no schema exists yet: Add the newly loaded schema as a completely new schema in the WSDL types.
if (!$existingSchema) {
$imported = assert_element(append_external_node($types, $schema));
$this->fixRemovedDefaultXmlnsDeclarationsDuringImport($imported, $schema);
(new FixRemovedDefaultXmlnsDeclarationsDuringImport())($imported, $schema);
return;
}

// When an existing schema exists, all xmlns attributes need to be copied.
// This is to make sure that possible QNames (strings) get resolved in XSD.
// Finally - all children of the newly loaded schema can be appended to the existing schema.
copy_named_xmlns_attributes($existingSchema, $schema);
$this->fixRemovedDefaultXmlnsDeclarationsDuringImport($existingSchema, $schema);
(new RegisterNonConflictingXmlnsNamespaces())($existingSchema, $schema);
children($schema)->forEach(
static fn (DOMNode $node) => append_external_node($existingSchema, $node)
);
}

/**
* @see https://gist.github.com/veewee/32c3aa94adcf878700a9d5baa4b2a2de
*
* PHP does an optimization of namespaces during `importNode()`.
* In some cases, this causes the root xmlns to be removed from the imported node which could lead to xsd qname errors.
*
* This function tries to re-add the root xmlns if it's available on the source but not on the target.
*
* It will most likely be solved in PHP 8.4's new spec compliant DOM\XMLDocument implementation.
* @see https://github.com/php/php-src/pull/13031
*
* For now, this will do the trick.
*/
private function fixRemovedDefaultXmlnsDeclarationsDuringImport(DOMElement $target, DOMElement $source): void
{
if (!$source->getAttribute('xmlns') || $target->hasAttribute('xmlns')) {
return;
}

$target->setAttribute('xmlns', $source->getAttribute('xmlns'));
}

/**
* Makes sure to rearrange the import statements on top of the flattened XSD schema.
* This makes the flattened XSD spec compliant:
Expand Down
40 changes: 40 additions & 0 deletions src/Xml/Visitor/ReprefixTypeQname.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php declare(strict_types=1);

namespace Soap\Wsdl\Xml\Visitor;

use DOMNode;
use VeeWee\Xml\Dom\Traverser\Action;
use VeeWee\Xml\Dom\Traverser\Visitor;
use function VeeWee\Xml\Dom\Predicate\is_attribute;

final class ReprefixTypeQname extends Visitor\AbstractVisitor
{
/**
* @param array<string, string> $prefixMap - "From" key - "To" value prefix map
*/
public function __construct(
private readonly array $prefixMap
) {
}

public function onNodeEnter(DOMNode $node): Action
{
if (!is_attribute($node) || $node->localName !== 'type') {
return new Action\Noop();
}

$parts = explode(':', $node->nodeValue ?? '', 2);
if (count($parts) !== 2) {
return new Action\Noop();
}

[$currentPrefix, $currentTypeName] = $parts;
if (!array_key_exists($currentPrefix, $this->prefixMap)) {
return new Action\Noop();
}

$node->nodeValue = $this->prefixMap[$currentPrefix].':'.$currentTypeName;

return new Action\Noop();
}
}
30 changes: 30 additions & 0 deletions src/Xml/Xmlns/FixRemovedDefaultXmlnsDeclarationsDuringImport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php declare(strict_types=1);

namespace Soap\Wsdl\Xml\Xmlns;

use DOMElement;

/**
* @see https://gist.github.com/veewee/32c3aa94adcf878700a9d5baa4b2a2de
*
* PHP does an optimization of namespaces during `importNode()`.
* In some cases, this causes the root xmlns to be removed from the imported node which could lead to xsd qname errors.
*
* This function tries to re-add the root xmlns if it's available on the source but not on the target.
*
* It will most likely be solved in PHP 8.4's new spec compliant DOM\XMLDocument implementation.
* @see https://github.com/php/php-src/pull/13031
*
* For now, this will do the trick.
*/
final class FixRemovedDefaultXmlnsDeclarationsDuringImport
{
public function __invoke(DOMElement $target, DOMElement $source): void
{
if (!$source->getAttribute('xmlns') || $target->hasAttribute('xmlns')) {
return;
}

$target->setAttribute('xmlns', $source->getAttribute('xmlns'));
}
}
129 changes: 129 additions & 0 deletions src/Xml/Xmlns/RegisterNonConflictingXmlnsNamespaces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php declare(strict_types=1);

namespace Soap\Wsdl\Xml\Xmlns;

use DOMElement;
use DOMNameSpaceNode;
use Psl\Option\Option;
use RuntimeException;
use Soap\Wsdl\Xml\Visitor\ReprefixTypeQname;
use VeeWee\Xml\Dom\Collection\NodeList;
use VeeWee\Xml\Dom\Document;
use function Psl\Dict\merge;
use function Psl\Option\none;
use function Psl\Option\some;
use function VeeWee\Xml\Dom\Builder\xmlns_attribute;
use function VeeWee\Xml\Dom\Locator\Xmlns\linked_namespaces;

/**
* Cross-import schemas can contain namespace conflicts.
*
* For example: import1 requires import2:
*
* - Import 1 specifies xmlns:ns1="urn:1"
* - Import 2 specifies xmlns:ns1="urn:2".
*
* This method will detect conflicting namespaces and resolve them.
* Namespaces will be renamed to a unique name and the "type" argument with QName's will be re-prefixed.
*
* @psalm-type RePrefixMap=array<string, string>
*/
final class RegisterNonConflictingXmlnsNamespaces
{
/**
* @throws RuntimeException
*/
public function __invoke(DOMElement $existingSchema, DOMElement $newSchema): void
{
$existingLinkedNamespaces = linked_namespaces($existingSchema);

$rePrefixMap = linked_namespaces($newSchema)->reduce(
/**
* @param RePrefixMap $rePrefixMap
* @return RePrefixMap
*/
function (array $rePrefixMap, DOMNameSpaceNode $xmlns) use ($existingSchema, $existingLinkedNamespaces): array {
// Skip non-named xmlns attributes:
if (!$xmlns->prefix) {
return $rePrefixMap;
}

// Check for duplicates:
if ($existingSchema->hasAttribute($xmlns->nodeName) && $existingSchema->getAttribute($xmlns->nodeName) !== $xmlns->prefix) {
return merge(
$rePrefixMap,
// Can be improved with orElse when we are using PSL V3.
$this->tryUsingExistingPrefix($existingLinkedNamespaces, $xmlns)
->unwrapOrElse(
fn () => $this->tryUsingUniquePrefixHash($existingSchema, $xmlns)
->unwrapOrElse(
static fn () => throw new RuntimeException('Could not resolve conflicting namespace declarations whilst flattening your WSDL file.')
)
)
);
}

xmlns_attribute($xmlns->prefix, $xmlns->namespaceURI)($existingSchema);

return $rePrefixMap;
},
[]
);

if (count($rePrefixMap)) {
Document::fromUnsafeDocument($newSchema->ownerDocument)->traverse(new ReprefixTypeQname($rePrefixMap));
}
(new FixRemovedDefaultXmlnsDeclarationsDuringImport())($existingSchema, $newSchema);
}

/**
* @param NodeList<DOMNameSpaceNode> $existingLinkedNamespaces
*
* @return Option<RePrefixMap>
*/
private function tryUsingExistingPrefix(
NodeList $existingLinkedNamespaces,
DOMNameSpaceNode $xmlns
): Option {
$existingPrefix = $existingLinkedNamespaces->filter(
static fn (DOMNameSpaceNode $node) => $node->namespaceURI === $xmlns->namespaceURI
)->first()?->prefix;

if ($existingPrefix === null) {
/** @var Option<RePrefixMap> */
return none();
}

/** @var Option<RePrefixMap> */
return some([$xmlns->prefix => $existingPrefix]);
}

/**
* @return Option<RePrefixMap>
*
* @throws RuntimeException
*/
private function tryUsingUniquePrefixHash(
DOMElement $existingSchema,
DOMNameSpaceNode $xmlns
): Option {
$uniquePrefix = 'ns' . substr(md5($xmlns->namespaceURI), 0, 8);
if ($existingSchema->hasAttribute('xmlns:'.$uniquePrefix)) {
/** @var Option<RePrefixMap> */
return none();
}

$this->copyXmlnsDeclaration($existingSchema, $xmlns->namespaceURI, $uniquePrefix);

/** @var Option<RePrefixMap> */
return some([$xmlns->prefix => $uniquePrefix]);
}

/**
* @throws RuntimeException
*/
private function copyXmlnsDeclaration(DOMElement $existingSchema, string $namespaceUri, string $prefix): void
{
xmlns_attribute($prefix, $namespaceUri)($existingSchema);
}
}
7 changes: 7 additions & 0 deletions stubs/dom.phpstub
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

class DOMNameSpaceNode extends DOMNode {
public string $namespaceURI;
public string $nodeName;
public string $prefix;
}
4 changes: 4 additions & 0 deletions tests/Unit/Xml/Configurator/FlattenWsdlImportsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,9 @@ public function provideTestCases()
'wsdl' => FIXTURE_DIR.'/flattening/import-multi-xsd.wsdl',
'expected' => Document::fromXmlFile(FIXTURE_DIR.'/flattening/result/import-multi-xsd-result.wsdl', comparable()),
];
yield 'import-namespaces' => [
'wsdl' => FIXTURE_DIR.'/flattening/import-namespaces.wsdl',
'expected' => Document::fromXmlFile(FIXTURE_DIR.'/flattening/result/import-namespaces.wsdl', comparable()),
];
}
}
5 changes: 5 additions & 0 deletions tests/Unit/Xml/Configurator/FlattenXsdImportsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,10 @@ public function provideTestCases()
'expected' => Document::fromXmlFile(FIXTURE_DIR.'/flattening/result/rearranged-imports.wsdl'),
comparable(),
];
yield 'import-xmlns-issue' => [
'wsdl' => FIXTURE_DIR.'/flattening/conflicting-imports.wsdl',
'expected' => Document::fromXmlFile(FIXTURE_DIR.'/flattening/result/conflicting-imports.wsdl'),
canonicalize(),
];
}
}
73 changes: 73 additions & 0 deletions tests/Unit/Xml/Visitor/ReprefixTypeQnameTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php declare(strict_types=1);

namespace SoapTest\Wsdl\Unit\Xml\Visitor;

use PHPUnit\Framework\TestCase;
use Soap\Wsdl\Xml\Visitor\ReprefixTypeQname;
use VeeWee\Xml\Dom\Document;

final class ReprefixTypeQnameTest extends TestCase
{
/**
*
* @dataProvider provideCases
*/
public function test_it_can_reprefix_qname_types(string $input, string $expected): void
{
$doc = Document::fromXmlString($input);
$doc->traverse(new ReprefixTypeQname([
'tns' => 'new',
'new' => 'previous', // To make sure prefix replacements don't get chained
]));

static::assertXmlStringEqualsXmlString($expected, $doc->toXmlString());
}

public static function provideCases(): iterable
{
yield 'no-attr' => [
'<element />',
'<element />',
];
yield 'other-attribute' => [
'<element other="xsd:Type" />',
'<element other="xsd:Type" />',
];
yield 'no-qualified' => [
'<element type="Type" />',
'<element type="Type" />',
];
yield 'simple' => [
'<node type="tns:Type" />',
'<node type="new:Type" />',
];
yield 'element' => [
'<element type="tns:Type" />',
'<element type="new:Type" />',
];
yield 'attribute' => [
'<attribute type="tns:Type" />',
'<attribute type="new:Type" />',
];
yield 'nested-schema' => [
<<<EOXML
<complexType name="Store">
<sequence>
<element minOccurs="1" maxOccurs="1" name="phone" type="tns:string"/>
</sequence>
</complexType>
EOXML,
<<<EOXML
<complexType name="Store">
<sequence>
<element minOccurs="1" maxOccurs="1" name="phone" type="new:string"/>
</sequence>
</complexType>
EOXML,
];
yield 'dont-chain-reprefixes' => [
'<schema><element type="tns:Type" /><element type="new:Type" /></schema>',
'<schema><element type="new:Type" /><element type="previous:Type" /></schema>',
];
}
}
Loading

0 comments on commit 8975e4f

Please sign in to comment.