Skip to content

Commit ac53294

Browse files
Merge pull request #213 from cleverage/202
#202 - Fix XmlReaderTask
2 parents 7426dca + d593791 commit ac53294

4 files changed

Lines changed: 153 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Latest
1212
* [#198](https://github.com/cleverage/process-bundle/issues/198) Fix DateParserTransformer: a `\DateTimeImmutable` input is converted to a `\DateTime` instead of throwing a `TypeError`. Update documentation, add tests.
1313
* [#199](https://github.com/cleverage/process-bundle/issues/199) Fix PregFilterTransformer: an array `replacement` is no longer cast to the string `"Array"`, and requires an array `pattern`. Update documentation, add tests.
1414
* [#200](https://github.com/cleverage/process-bundle/issues/200) Fix GenericTransformer: a contextual option declared with `required: false` and no default can now be used; its placeholders are replaced by `null` when omitted. Update documentation, add tests.
15+
* [#202](https://github.com/cleverage/process-bundle/issues/202) Fix XmlReaderTask: throw an explicit `\UnexpectedValueException` on an empty file or invalid XML (with the libxml error messages) instead of a `ValueError` or a silent empty `\DOMDocument`. Update documentation, add tests.
1516

1617
## Deprecated
1718
* [#189](https://github.com/cleverage/process-bundle/issues/189) EventDispatcherTask: when `event_name` is set, listening to `CleverAge\ProcessBundle\Event\EventDispatcherTaskEvent` is deprecated (the event is still dispatched under its class name, with an `E_USER_DEPRECATED` error, if it has listeners). Listen to the configured `event_name` instead: the BC layer will be removed in v6.0.

‎docs/reference/tasks/xml_reader_task.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ read_xml:
4343
Notes
4444
-----
4545
46-
* The result of `loadXML()` is not checked: an invalid XML content raises libxml warnings (which may be converted to
47-
exceptions by the Symfony error handler) and produces an empty or partial document.
48-
* An empty file raises a `\ValueError` (the file content is read with `fread()` using the file size as length).
46+
* An empty file throws an `\UnexpectedValueException`.
47+
* An invalid XML content (not well-formed, undefined namespace prefix...) throws an `\UnexpectedValueException` whose
48+
message contains the libxml error(s) with their line and column. libxml warnings are tolerated. The libxml internal
49+
errors setting is restored after loading.

‎src/Filesystem/XmlFile.php‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,39 @@ public function __construct(string $path, string $mode = 'rb')
2727

2828
public function read(): \DOMDocument
2929
{
30-
$dom = new \DOMDocument();
3130
$this->file->rewind();
32-
$fileSize = $this->file->getSize();
31+
// fstat() on the open handle, as getSize() may return a stale size from the stat cache
32+
$fileSize = $this->file->fstat()['size'];
33+
if (0 === $fileSize) {
34+
throw new \UnexpectedValueException(\sprintf('XML file "%s" is empty', $this->file->getPathname()));
35+
}
36+
3337
$fileContent = $this->file->fread($fileSize);
38+
if (false === $fileContent) {
39+
throw new \RuntimeException(\sprintf('Could not read content from XML file "%s"', $this->file->getPathname()));
40+
}
3441

35-
$dom->loadXML($fileContent);
42+
$dom = new \DOMDocument();
43+
$previousUseErrors = libxml_use_internal_errors(true);
44+
libxml_clear_errors();
45+
try {
46+
$loaded = $dom->loadXML($fileContent);
47+
$errors = libxml_get_errors();
48+
} finally {
49+
libxml_clear_errors();
50+
libxml_use_internal_errors($previousUseErrors);
51+
}
52+
53+
// Warnings are tolerated, errors (e.g. undefined namespace prefix) and fatal errors are not
54+
$errors = array_filter($errors, static fn (\LibXMLError $error): bool => \LIBXML_ERR_WARNING !== $error->level);
55+
if (!$loaded || [] !== $errors) {
56+
$messages = array_map(
57+
static fn (\LibXMLError $error): string => \sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column),
58+
$errors,
59+
);
60+
61+
throw new \UnexpectedValueException(\sprintf('Invalid XML in file "%s": %s', $this->file->getPathname(), [] !== $messages ? implode('; ', $messages) : 'unknown error'));
62+
}
3663

3764
return $dom;
3865
}

‎tests/Filesystem/XmlFileTest.php‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the CleverAge/ProcessBundle package.
7+
*
8+
* Copyright (c) Clever-Age
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace CleverAge\ProcessBundle\Tests\Filesystem;
15+
16+
use CleverAge\ProcessBundle\Filesystem\XmlFile;
17+
use PHPUnit\Framework\TestCase;
18+
19+
#[\PHPUnit\Framework\Attributes\CoversClass(XmlFile::class)]
20+
class XmlFileTest extends TestCase
21+
{
22+
private string $path;
23+
24+
protected function setUp(): void
25+
{
26+
$path = tempnam(sys_get_temp_dir(), 'xml_file_test_');
27+
self::assertIsString($path);
28+
$this->path = $path;
29+
}
30+
31+
protected function tearDown(): void
32+
{
33+
if (is_file($this->path)) {
34+
unlink($this->path);
35+
}
36+
}
37+
38+
public function testReadValidXml(): void
39+
{
40+
file_put_contents($this->path, '<root><a>1</a></root>');
41+
42+
$dom = (new XmlFile($this->path))->read();
43+
44+
self::assertNotNull($dom->documentElement);
45+
self::assertSame('root', $dom->documentElement->nodeName);
46+
self::assertSame('1', $dom->documentElement->textContent);
47+
}
48+
49+
public function testReadEmptyFileThrows(): void
50+
{
51+
file_put_contents($this->path, '');
52+
53+
$this->expectException(\UnexpectedValueException::class);
54+
$this->expectExceptionMessage(\sprintf('XML file "%s" is empty', $this->path));
55+
56+
(new XmlFile($this->path))->read();
57+
}
58+
59+
public function testReadMalformedXmlThrowsWithLibxmlMessage(): void
60+
{
61+
file_put_contents($this->path, '<root><a>1</a>');
62+
63+
try {
64+
(new XmlFile($this->path))->read();
65+
self::fail('An exception should have been thrown');
66+
} catch (\UnexpectedValueException $e) {
67+
self::assertStringContainsString(\sprintf('Invalid XML in file "%s"', $this->path), $e->getMessage());
68+
self::assertStringContainsString('Premature end of data in tag root', $e->getMessage());
69+
}
70+
}
71+
72+
public function testReadNotXmlThrows(): void
73+
{
74+
file_put_contents($this->path, 'not xml at all');
75+
76+
$this->expectException(\UnexpectedValueException::class);
77+
$this->expectExceptionMessage("Start tag expected, '<' not found");
78+
79+
(new XmlFile($this->path))->read();
80+
}
81+
82+
public function testReadUndefinedNamespacePrefixThrows(): void
83+
{
84+
file_put_contents($this->path, '<root><x:a/></root>');
85+
86+
$this->expectException(\UnexpectedValueException::class);
87+
$this->expectExceptionMessage('Namespace prefix x on a is not defined');
88+
89+
(new XmlFile($this->path))->read();
90+
}
91+
92+
public function testReadRestoresLibxmlErrorHandling(): void
93+
{
94+
file_put_contents($this->path, '<root>');
95+
$previous = libxml_use_internal_errors(false);
96+
97+
try {
98+
(new XmlFile($this->path))->read();
99+
self::fail('An exception should have been thrown');
100+
} catch (\UnexpectedValueException) {
101+
self::assertFalse(libxml_use_internal_errors());
102+
self::assertSame([], libxml_get_errors());
103+
} finally {
104+
libxml_use_internal_errors($previous);
105+
}
106+
}
107+
108+
public function testWriteThenRead(): void
109+
{
110+
$dom = new \DOMDocument();
111+
$dom->loadXML('<root><a>ok</a></root>');
112+
113+
(new XmlFile($this->path, 'wb'))->write($dom);
114+
$read = (new XmlFile($this->path))->read();
115+
116+
self::assertSame('ok', $read->documentElement?->textContent);
117+
}
118+
}

0 commit comments

Comments
 (0)