Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolve conflicting XMLNS imports #23

Merged
merged 1 commit into from
Sep 27, 2024
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
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