Skip to content

Commit

Permalink
Inherit XMLNS during encoding like we did in v3
Browse files Browse the repository at this point in the history
  • Loading branch information
veewee committed Jan 26, 2025
1 parent 86a8497 commit 7bb8905
Show file tree
Hide file tree
Showing 13 changed files with 229 additions and 21 deletions.
21 changes: 21 additions & 0 deletions src/Xml/Dom/Builder/default_xmlns_attribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace VeeWee\Xml\Dom\Builder;

use Closure;
use Dom\Element;
use VeeWee\Xml\Xmlns\Xmlns;

/**
* @return Closure(Element): Element
*/
function default_xmlns_attribute(string $namespaceURI): Closure
{
return static function (Element $node) use ($namespaceURI): Element {
$node->setAttributeNS(Xmlns::xmlns()->value(), 'xmlns', $namespaceURI);

return $node;
};
}
7 changes: 3 additions & 4 deletions src/Xml/Dom/Builder/nodes.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@

use Closure;
use Dom\Node;
use Dom\XMLDocument;
use function is_array;
use function Psl\Iter\reduce;
use function VeeWee\Xml\Dom\Locator\Node\detect_document;

/**
* @param list<callable(XMLDocument): (list<Node>|Node)> $builders
* @param list<callable(Node): (list<Node>|Node)> $builders
*
* @return Closure(XMLDocument): list<Node>
* @return Closure(Node): list<Node>
*/
function nodes(callable ... $builders): Closure
{
Expand All @@ -27,7 +26,7 @@ function nodes(callable ... $builders): Closure
$builders,
/**
* @param list<Node> $builds
* @param callable(XMLDocument): (Node|list<Node>) $builder
* @param callable(Node): (Node|list<Node>) $builder
* @return list<Node>
*/
static function (array $builds, callable $builder) use ($node): array {
Expand Down
2 changes: 2 additions & 0 deletions src/Xml/Dom/Document.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ public function manipulate(callable $manipulator): self
}

/**
* @psalm-suppress ArgumentTypeCoercion - nodes() works on node but we provide the parent type XMLDocument.
*
* @param list<callable(XMLDocument): (list<Node>|Node)> $builders
*
* @return list<Node>
Expand Down
10 changes: 10 additions & 0 deletions src/Xml/Dom/Predicate/is_prefixed_node_name.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace VeeWee\Xml\Dom\Predicate;

function is_prefixed_node_name(string $nodeName): bool
{
return (bool)preg_match('/^[^:]+:[^:]+$/', $nodeName);
}
9 changes: 3 additions & 6 deletions src/Xml/Encoding/Internal/Decoder/Builder/namespaces.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,9 @@ function namespaces(Element $element): array
{
return filter([
'@namespaces' => xmlns_attributes_list($element)->reduce(
static fn (array $namespaces, Attr $node)
=> $node->value
? merge($namespaces, [
($node->prefix !== null ? $node->localName : '') => $node->value
])
: $namespaces,
static fn (array $namespaces, Attr $node) => merge($namespaces, [
($node->prefix !== null ? $node->localName : '') => $node->value
]),
[]
),
]);
Expand Down
3 changes: 1 addition & 2 deletions src/Xml/Encoding/Internal/Encoder/Builder/children.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use Dom\Element;
use function Psl\Dict\map;
use function VeeWee\Xml\Dom\Builder\children as buildChildren;
use function VeeWee\Xml\Dom\Builder\element as elementBuilder;
use function VeeWee\Xml\Dom\Builder\value;

/**
Expand All @@ -28,7 +27,7 @@ function children(string $name, array $children): Closure
*/
static fn (array|string $data): Closure => is_array($data)
? element($name, $data)
: elementBuilder($name, value($data))
: xmlns_inheriting_element($name, [value($data)])
)
);
}
7 changes: 1 addition & 6 deletions src/Xml/Encoding/Internal/Encoder/Builder/element.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@
use function VeeWee\Xml\Dom\Builder\attributes;
use function VeeWee\Xml\Dom\Builder\cdata;
use function VeeWee\Xml\Dom\Builder\children as childrenBuilder;
use function VeeWee\Xml\Dom\Builder\element as elementBuilder;
use function VeeWee\Xml\Dom\Builder\escaped_value;
use function VeeWee\Xml\Dom\Builder\namespaced_element as namespacedElementBuilder;
use function VeeWee\Xml\Dom\Builder\xmlns_attributes;

/**
Expand All @@ -46,7 +44,6 @@ function element(string $name, array $data): Closure
static fn (string $key): bool => !in_array($key, ['@attributes', '@namespaces', '@value', '@cdata'], true)
);

$currentNamespace = $namespaces[''] ?? null;
$namedNamespaces = filter_keys($namespaces ?? []);

/** @var list<Closure(Element): Element> $children */
Expand All @@ -66,7 +63,5 @@ function element(string $name, array $data): Closure
)),
]);

return $currentNamespace !== null
? namespacedElementBuilder($currentNamespace, $name, ...$children)
: elementBuilder($name, ...$children);
return xmlns_inheriting_element($name, $children, $namespaces);
}
3 changes: 1 addition & 2 deletions src/Xml/Encoding/Internal/Encoder/Builder/parent_node.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
use Psl\Exception\InvariantViolationException;
use Psl\Type\Exception\AssertException;
use function VeeWee\Xml\Dom\Builder\children as buildChildren;
use function VeeWee\Xml\Dom\Builder\element as elementBuilder;
use function VeeWee\Xml\Dom\Builder\escaped_value;

/**
Expand All @@ -25,7 +24,7 @@
function parent_node(string $name, array|string $data): Closure
{
if (is_string($data)) {
return buildChildren(elementBuilder($name, escaped_value($data)));
return buildChildren(xmlns_inheriting_element($name, [escaped_value($data)]));
}

if (is_node_list($data)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace VeeWee\Xml\Encoding\Internal\Encoder\Builder;

use Closure;
use Dom\Element;
use Dom\XMLDocument;
use Webmozart\Assert\Assert;
use function VeeWee\Xml\Dom\Builder\default_xmlns_attribute;
use function VeeWee\Xml\Dom\Builder\element as elementBuilder;
use function VeeWee\Xml\Dom\Builder\namespaced_element as namespacedElementBuilder;
use function VeeWee\Xml\Dom\Predicate\is_element;
use function VeeWee\Xml\Dom\Predicate\is_prefixed_node_name;

/**
* This function can create element nodes that inherit the local xmlns namespace of their parent if none is configured.
*
* @param list<Closure(Element): Element> $children
* @param array<string, string> $namespaces
*
* @return Closure(Element): Element
*/
function xmlns_inheriting_element(string $name, array $children, ?array $namespaces = []): Closure
{
return static function (XMLDocument|Element $parent) use ($namespaces, $name, $children): Element {

$defaultNamespace = $namespaces[''] ?? null;

// These rules apply for non prefixed elements only:
// If no local namespace has been defined: lookup the default local namespace of the closest parent element.
// Use that specific local namespace to create the element if one could be found.
// Otherwise, just create a non-namespaced element.
if (!is_prefixed_node_name($name)) {
// Try to find the inherited default XMLNS for non prefixed elements without a desired local namespace.
if ($defaultNamespace === null && is_element($parent)) {
$defaultNamespace = $parent->lookupNamespaceURI('');
}

return $defaultNamespace !== null
? namespacedElementBuilder($defaultNamespace, $name, ...$children)($parent)
: elementBuilder($name, ...$children)($parent);
}

// Prefixed elements can be created as regular elements:
// The configured xmlns attributes will be added by the $children.
// If a local namespace is configured, make sure to register it on the node manually.
[$prefix] = explode(':', $name);
$prefixedNamespace = $namespaces[$prefix] ?? (is_element($parent) ? $parent->lookupNamespaceURI($prefix) : null);

Assert::notNull($prefixedNamespace, 'No namespace URI could be found for prefix: '.$prefix);

$defaultXmlns = $defaultNamespace !== null ? [default_xmlns_attribute($defaultNamespace)] : [];
return namespacedElementBuilder(
$prefixedNamespace,
$name,
...$defaultXmlns,
...$children,
)($parent);
};
}
3 changes: 3 additions & 0 deletions src/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
'Xml\Dom\Builder\attributes' => __DIR__.'/Xml/Dom/Builder/attributes.php',
'Xml\Dom\Builder\cdata' => __DIR__.'/Xml/Dom/Builder/cdata.php',
'Xml\Dom\Builder\children' => __DIR__.'/Xml/Dom/Builder/children.php',
'Xml\Dom\Builder\default_xmlns_attribute' => __DIR__.'/Xml/Dom/Builder/default_xmlns_attribute.php',
'Xml\Dom\Builder\element' => __DIR__.'/Xml/Dom/Builder/element.php',
'Xml\Dom\Builder\escaped_value' => __DIR__.'/Xml/Dom/Builder/escaped_value.php',
'Xml\Dom\Builder\namespaced_attribute' => __DIR__.'/Xml/Dom/Builder/namespaced_attribute.php',
Expand Down Expand Up @@ -79,6 +80,7 @@
'Xml\Dom\Predicate\is_document_element' => __DIR__.'/Xml/Dom/Predicate/is_document_element.php',
'Xml\Dom\Predicate\is_element' => __DIR__.'/Xml/Dom/Predicate/is_element.php',
'Xml\Dom\Predicate\is_non_empty_text' => __DIR__.'/Xml/Dom/Predicate/is_non_empty_text.php',
'Xml\Dom\Predicate\is_prefixed_node_name' => __DIR__.'/Xml/Dom/Predicate/is_prefixed_node_name.php',
'Xml\Dom\Predicate\is_text' => __DIR__.'/Xml/Dom/Predicate/is_text.php',
'Xml\Dom\Predicate\is_whitespace' => __DIR__.'/Xml/Dom/Predicate/is_whitespace.php',
'Xml\Dom\Predicate\is_xmlns_attribute' => __DIR__.'/Xml/Dom/Predicate/is_xmlns_attribute.php',
Expand Down Expand Up @@ -107,6 +109,7 @@
'Xml\Encoding\Internal\Encoder\Builder\normalize_data' => __DIR__.'/Xml/Encoding/Internal/Encoder/Builder/normalize_data.php',
'Xml\Encoding\Internal\Encoder\Builder\parent_node' => __DIR__.'/Xml/Encoding/Internal/Encoder/Builder/parent_node.php',
'Xml\Encoding\Internal\Encoder\Builder\root' => __DIR__.'/Xml/Encoding/Internal/Encoder/Builder/root.php',
'Xml\Encoding\Internal\Encoder\Builder\xmlns_inheriting_element' => __DIR__.'/Xml/Encoding/Internal/Encoder/Builder/xmlns_inheriting_element.php',
'Xml\Encoding\Internal\wrap_exception' => __DIR__.'/Xml/Encoding/Internal/wrap_exception.php',
'Xml\Encoding\document_encode' => __DIR__.'/Xml/Encoding/document_encode.php',
'Xml\Encoding\element_decode' => __DIR__.'/Xml/Encoding/element_decode.php',
Expand Down
39 changes: 39 additions & 0 deletions tests/Xml/Dom/Builder/DefaultXmlnsAttributeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace VeeWee\Tests\Xml\Dom\Builder;

use PHPUnit\Framework\TestCase;
use VeeWee\Xml\Dom\Document;
use function VeeWee\Xml\Dom\Builder\default_xmlns_attribute;
use function VeeWee\Xml\Dom\Builder\element;
use function VeeWee\Xml\Dom\Builder\namespaced_element;

final class DefaultXmlnsAttributeTest extends TestCase
{
public function test_it_can_build_an_element_with_default_xmlns_on_namespaced_element(): void
{
$doc = Document::empty()->toUnsafeDocument();

$node = namespaced_element(
'uri://x',
'x:foo',
default_xmlns_attribute('uri://default')
)($doc);

static::assertSame('<x:foo xmlns:x="uri://x" xmlns="uri://default"/>', $doc->saveXml($node));
}

public function test_it_can_not_build_an_element_with_default_xmlns_on_regular_element(): void
{
$doc = Document::empty()->toUnsafeDocument();

$node = element(
'foo',
default_xmlns_attribute('uri://default')
)($doc);

static::assertSame('<foo/>', $doc->saveXml($node));
}
}
45 changes: 45 additions & 0 deletions tests/Xml/Dom/Predicate/IsPrefixedNodeNameTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace VeeWee\Tests\Xml\Dom\Predicate;

use PHPUnit\Framework\TestCase;
use function VeeWee\Xml\Dom\Predicate\is_prefixed_node_name;

final class IsPrefixedNodeNameTest extends TestCase
{
/**
*
* @dataProvider provideValidQNames
*/
public function test_it_does_nothing_on_valid_qnames(string $input): void
{
static::assertTrue(is_prefixed_node_name($input));
}

/**
*
* @dataProvider provideInvalidQNames
*/
public function test_it_throws_on_invalid_qnames(string $input): void
{
static::assertFalse(is_prefixed_node_name($input));
}

public static function provideValidQNames()
{
yield ['hello:world'];
yield ['a:b'];
yield ['---a----:----b---'];
}

public static function provideInvalidQNames()
{
yield [''];
yield ['aa'];
yield ['aa:'];
yield [':bb'];
yield [':b:c:cd:dz'];
}
}
39 changes: 38 additions & 1 deletion tests/Xml/Encoding/EncodingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -243,14 +243,51 @@ public static function provideBidirectionalCases()
'xml' => '<hello>hello <![CDATA[<html>world</html>]]></hello>',
'data' => ['hello' => 'hello <html>world</html>']
];

yield 'deeply-namespaced-xml' => [
'xml' => <<<EOXML
<foo xmlns="uri://bar">
<x:a xmlns:x="uri://x">
<x:b xmlns="uri://aa">
<bar>bar</bar>
<baz xmlns="">baz</baz>
</x:b>
</x:a>
</foo>
EOXML,
'data' => [
'foo' => [
'@namespaces' => [
'' => 'uri://bar',
],
'x:a' => [
'@namespaces' => [
'x' => 'uri://x',
],
'x:b' => [
'@namespaces' => [
'' => 'uri://aa',
],
'bar' => 'bar',
'baz' => [
'@namespaces' => [
'' => '',
],
'@value' => 'baz',
],
],
],
],
]
];
}

public static function provideRiskyBidirectionalCases()
{
yield 'namespaced' => [
'xml' => <<<EOXML
<root xmlns="http://rooty.root" xmlns:test="http://testy.test">
<test:item xmlns="">
<test:item>
<id:int xmlns:id="http://identity.id">1</id:int>
</test:item>
</root>
Expand Down

0 comments on commit 7bb8905

Please sign in to comment.