Skip to content

Commit 1450b8e

Browse files
authored
Merge pull request #17 from chadicus/master
Add XmlFilter
2 parents 3216da7 + 5700f62 commit 1450b8e

File tree

6 files changed

+377
-2
lines changed

6 files changed

+377
-2
lines changed

README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,89 @@ $value = '{ "string": "value", "array": [1, 2, 3] }';
152152
assert($value === ['string' => 'value', 'array' => [1, 2, 3]]);
153153
```
154154

155+
#### XmlFilter::filter
156+
157+
This filter ensures the given string is valid XML.
158+
159+
```php
160+
$value = "<root><outer><inner>value</inner></outer></root>";
161+
$filtered = \TraderInteractive\Filter\XmlFilter::filter($value);
162+
assert($value === $filtered);
163+
```
164+
165+
#### XmlFilter::extract
166+
167+
This filter accepts an XML string and an xpath. It will return the single element found at the xpath.
168+
169+
```php
170+
$value = <<<XML
171+
<?xml version="1.0"?>
172+
<books>
173+
<book id="bk101">
174+
<author>Gambardella, Matthew</author>
175+
<title>XML Developers Guide</title>
176+
<genre>Computer</genre>
177+
<price>44.95</price>
178+
<publish_date>2000-10-01</publish_date>
179+
<description>An in-depth look at creating applications with XML.</description>
180+
</book>
181+
<book id="bk102">
182+
<author>Ralls, Kim</author>
183+
<title>Midnight Rain</title>
184+
<genre>Fantasy</genre>
185+
<price>5.95</price>
186+
<publish_date>2000-12-16</publish_date>
187+
<description>A former architect battles corporate zombies</description>
188+
</book>
189+
</books>
190+
XML;
191+
$xpath = '//book[@id="bk102"]';
192+
193+
$filtered = \TraderInteractive\Filter\XmlFilter::extract($value, $xpath);
194+
$expected = <<<XML
195+
<book id="bk102">
196+
<author>Ralls, Kim</author>
197+
<title>Midnight Rain</title>
198+
<genre>Fantasy</genre>
199+
<price>5.95</price>
200+
<publish_date>2000-12-16</publish_date>
201+
<description>A former architect battles corporate zombies</description>
202+
</book>
203+
XML;
204+
assert($filtered === $expected);
205+
```
206+
207+
#### XmlFilter::validate
208+
209+
This filter accepts an XML string and a filepath to an XSD. It ensures the given XML is valid using the given XSD and returns the original XML.
210+
211+
```php
212+
$value = <<<XML
213+
<?xml version="1.0"?>
214+
<books>
215+
<book id="bk101">
216+
<author>Gambardella, Matthew</author>
217+
<title>XML Developers Guide</title>
218+
<genre>Computer</genre>
219+
<price>44.95</price>
220+
<publish_date>2000-10-01</publish_date>
221+
<description>An in-depth look at creating applications with XML.</description>
222+
</book>
223+
<book id="bk102">
224+
<author>Ralls, Kim</author>
225+
<title>Midnight Rain</title>
226+
<genre>Fantasy</genre>
227+
<price>5.95</price>
228+
<publish_date>2000-12-16</publish_date>
229+
<description>A former architect battles corporate zombies</description>
230+
</book>
231+
</books>
232+
XML;
233+
$xsdFilePath = 'books.xsd';
234+
$filtered = \TraderInteractive\Filter\XmlFilter::validate($value, $xsdFilePath);
235+
assert($filtered === $value);
236+
```
237+
155238
## Contact
156239

157240
Developers may be contacted at:

composer.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,19 @@
1717
"traderinteractive/exceptions": "^1.0"
1818
},
1919
"require-dev": {
20+
"ext-SimpleXML": "*",
21+
"ext-dom": "*",
2022
"ext-json": "*",
23+
"ext-libxml": "*",
2124
"php-coveralls/php-coveralls": "^2.0",
2225
"phpunit/phpunit": "^6.0",
2326
"squizlabs/php_codesniffer": "^3.2"
2427
},
2528
"suggest": {
26-
"ext-json": "Required for JSON filters"
29+
"ext-dom": "Required for XML validation filters",
30+
"ext-json": "Required for JSON filters",
31+
"ext-libxml": "Required for XML validation filters",
32+
"ext-SimpleXML": "Required for XML filters"
2733
},
2834
"autoload": {
2935
"psr-4": { "TraderInteractive\\": "src/" }

src/Filter/XmlFilter.php

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
namespace TraderInteractive\Filter;
4+
5+
use LibXMLError;
6+
use SimpleXMLElement;
7+
use Throwable;
8+
use TraderInteractive\Exceptions\FilterException;
9+
10+
final class XmlFilter
11+
{
12+
/**
13+
* @var string
14+
*/
15+
const LIBXML_ERROR_FORMAT = '%s on line %d at column %d';
16+
17+
/**
18+
* @var string
19+
*/
20+
const EXTRACT_NO_ELEMENT_FOUND_ERROR_FORMAT = "No element found at xpath '%s'";
21+
22+
/**
23+
* @var string
24+
*/
25+
const EXTRACT_MULTIPLE_ELEMENTS_FOUND_ERROR_FORMAT = "Multiple elements found at xpath '%s'";
26+
27+
/**
28+
* @param string $xml The value to be filtered.
29+
* @param string $schemaFilePath The full path to the XSD file used for validation.
30+
*
31+
* @return string
32+
*
33+
* @throws FilterException Thrown if the given value cannot be filtered.
34+
*/
35+
public static function validate(string $xml, string $schemaFilePath) : string
36+
{
37+
$previousLibxmlUserInternalErrors = libxml_use_internal_errors(true);
38+
try {
39+
libxml_clear_errors();
40+
41+
$document = dom_import_simplexml(self::toSimpleXmlElement($xml))->ownerDocument;
42+
if ($document->schemaValidate($schemaFilePath)) {
43+
return $xml;
44+
}
45+
46+
$formattedXmlError = self::formatXmlError(libxml_get_last_error());
47+
throw new FilterException($formattedXmlError);
48+
} finally {
49+
libxml_use_internal_errors($previousLibxmlUserInternalErrors);
50+
}
51+
} //@codeCoverageIgnore
52+
53+
/**
54+
* @param string $xml The value to be filtered.
55+
* @param string $xpath The xpath to the element to be extracted.
56+
*
57+
* @return string
58+
*
59+
* @throws FilterException Thrown if the value cannot be filtered.
60+
*/
61+
public static function extract(string $xml, string $xpath) : string
62+
{
63+
$simpleXmlElement = self::toSimpleXmlElement($xml);
64+
$elements = $simpleXmlElement->xpath($xpath);
65+
66+
$elementCount = count($elements);
67+
68+
if ($elementCount === 0) {
69+
throw new FilterException(sprintf(self::EXTRACT_NO_ELEMENT_FOUND_ERROR_FORMAT, $xpath));
70+
}
71+
72+
if ($elementCount > 1) {
73+
throw new FilterException(sprintf(self::EXTRACT_MULTIPLE_ELEMENTS_FOUND_ERROR_FORMAT, $xpath));
74+
}
75+
76+
return $elements[0]->asXML();
77+
}
78+
79+
/**
80+
* @param string $xml The value to be filtered.
81+
*
82+
* @return string
83+
*
84+
* @throws FilterException Thrown if the given string cannot be parsed as xml.
85+
*/
86+
public static function filter(string $xml) : string
87+
{
88+
return self::toSimpleXmlElement($xml)->asXML();
89+
}
90+
91+
private static function toSimpleXmlElement(string $xml) : SimpleXMLElement
92+
{
93+
try {
94+
return new SimpleXMLElement($xml);
95+
} catch (Throwable $throwable) {
96+
throw new FilterException($throwable->getMessage());
97+
}
98+
}
99+
100+
private static function formatXmlError(LibXMLError $error) : string
101+
{
102+
$message = trim($error->message);
103+
return sprintf(self::LIBXML_ERROR_FORMAT, $message, $error->line, $error->column);
104+
}
105+
}

tests/Filter/StringsTest.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,10 @@ public function concatScalarValue()
372372
*/
373373
public function concatObjectValue()
374374
{
375-
$this->assertSame('prefix' . __FILE__ . 'suffix', Strings::concat(new \SplFileInfo(__FILE__), 'prefix', 'suffix'));
375+
$this->assertSame(
376+
'prefix' . __FILE__ . 'suffix',
377+
Strings::concat(new \SplFileInfo(__FILE__), 'prefix', 'suffix')
378+
);
376379
}
377380

378381
/**

tests/Filter/XmlFilterTest.php

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
namespace Filter;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use TraderInteractive\Exceptions\FilterException;
7+
use TraderInteractive\Filter\XmlFilter;
8+
9+
/**
10+
* @coversDefaultClass \TraderInteractive\Filter\XmlFilter
11+
* @covers ::<private>
12+
*/
13+
final class XmlFilterTest extends TestCase
14+
{
15+
/**
16+
* @var string
17+
*/
18+
const FULL_XML = (''
19+
. "<?xml version=\"1.0\"?>\n"
20+
. '<books>'
21+
. '<book id="bk101">'
22+
. '<author>Gambardella, Matthew</author>'
23+
. "<title>XML Developer's Guide</title>"
24+
. '<genre>Computer</genre>'
25+
. '<price>44.95</price>'
26+
. '<publish_date>2000-10-01</publish_date>'
27+
. '<description>An in-depth look at creating applications with XML.</description>'
28+
. '</book>'
29+
. '<book id="bk102">'
30+
. '<author>Ralls, Kim</author>'
31+
. '<title>Midnight Rain</title>'
32+
. '<genre>Fantasy</genre>'
33+
. '<price>5.95</price>'
34+
. '<publish_date>2000-12-16</publish_date>'
35+
. '<description>A former architect battles corporate zombies</description>'
36+
. '</book>'
37+
. "</books>\n"
38+
);
39+
40+
/**
41+
* @test
42+
* @covers ::extract
43+
*/
44+
public function extract()
45+
{
46+
$xpath = "/books/book[@id='bk101']";
47+
$actualXml = XmlFilter::extract(self::FULL_XML, $xpath);
48+
$expectedXml = (''
49+
. '<book id="bk101">'
50+
. '<author>Gambardella, Matthew</author>'
51+
. "<title>XML Developer's Guide</title>"
52+
. '<genre>Computer</genre>'
53+
. '<price>44.95</price>'
54+
. '<publish_date>2000-10-01</publish_date>'
55+
. '<description>An in-depth look at creating applications with XML.</description>'
56+
. '</book>'
57+
);
58+
59+
$this->assertSame($expectedXml, $actualXml);
60+
}
61+
62+
/**
63+
* @test
64+
* @covers ::extract
65+
*/
66+
public function extractNonXmlValue()
67+
{
68+
$this->expectException(FilterException::class);
69+
$this->expectExceptionMessage('String could not be parsed as XML');
70+
$notXml = json_encode(['foo' => 'bar']);
71+
$xpath = '/books/book';
72+
XmlFilter::extract($notXml, $xpath);
73+
}
74+
75+
/**
76+
* @test
77+
* @covers ::extract
78+
*/
79+
public function extractNoElementFound()
80+
{
81+
$xpath = '/catalog/books/book';
82+
$this->expectException(FilterException::class);
83+
$this->expectExceptionMessage(sprintf(XmlFilter::EXTRACT_NO_ELEMENT_FOUND_ERROR_FORMAT, $xpath));
84+
XmlFilter::extract(self::FULL_XML, $xpath);
85+
}
86+
87+
/**
88+
* @test
89+
* @covers ::extract
90+
*/
91+
public function extractMultipleElementFound()
92+
{
93+
$xpath = '/books/book';
94+
$this->expectException(FilterException::class);
95+
$this->expectExceptionMessage(sprintf(XmlFilter::EXTRACT_MULTIPLE_ELEMENTS_FOUND_ERROR_FORMAT, $xpath));
96+
XmlFilter::extract(self::FULL_XML, $xpath);
97+
}
98+
99+
/**
100+
* @test
101+
* @covers ::validate
102+
*/
103+
public function validate()
104+
{
105+
$xml = self::FULL_XML;
106+
$xsdFile = __DIR__ . '/_files/books.xsd';
107+
$validatedXml = XmlFilter::validate($xml, $xsdFile);
108+
$this->assertSame($xml, $validatedXml);
109+
}
110+
111+
/**
112+
* @test
113+
* @covers ::validate
114+
*/
115+
public function validateXmlMissingRequiredAttribute()
116+
{
117+
$xmlMissingId = (''
118+
. '<books>'
119+
. '<book>'
120+
. '<author>Gambardella, Matthew</author>'
121+
. "<title>XML Developer's Guide</title>"
122+
. '<genre>Computer</genre>'
123+
. '<price>44.95</price>'
124+
. '<publish_date>2000-10-01</publish_date>'
125+
. '<description>An in-depth look at creating applications with XML.</description>'
126+
. '</book>'
127+
. '</books>'
128+
);
129+
130+
$this->expectException(FilterException::class);
131+
$this->expectExceptionMessage("Element 'book': The attribute 'id' is required but missing");
132+
$xsdFile = __DIR__ . '/_files/books.xsd';
133+
XmlFilter::validate($xmlMissingId, $xsdFile);
134+
}
135+
136+
/**
137+
* @test
138+
* @covers ::validate
139+
*/
140+
public function validateEmptyXml()
141+
{
142+
$emptyXml = '';
143+
$this->expectException(FilterException::class);
144+
$this->expectExceptionMessage('String could not be parsed as XML');
145+
$xsdFile = __DIR__ . '/_files/books.xsd';
146+
XmlFilter::validate($emptyXml, $xsdFile);
147+
}
148+
149+
/**
150+
* @test
151+
* @covers ::filter
152+
*/
153+
public function filter()
154+
{
155+
$filteredXml = XmlFilter::filter(self::FULL_XML);
156+
$this->assertSame(self::FULL_XML, $filteredXml);
157+
}
158+
}

0 commit comments

Comments
 (0)