1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\Routing\Loader;
13
14use Symfony\Component\Config\Loader\FileLoader;
15use Symfony\Component\Config\Resource\FileResource;
16use Symfony\Component\Config\Util\XmlUtils;
17use Symfony\Component\Routing\Route;
18use Symfony\Component\Routing\RouteCollection;
19
20/**
21 * XmlFileLoader loads XML routing files.
22 *
23 * @author Fabien Potencier <fabien@symfony.com>
24 * @author Tobias Schultze <http://tobion.de>
25 */
26class XmlFileLoader extends FileLoader
27{
28    const NAMESPACE_URI = 'http://symfony.com/schema/routing';
29    const SCHEME_PATH = '/schema/routing/routing-1.0.xsd';
30
31    /**
32     * Loads an XML file.
33     *
34     * @param string      $file An XML file path
35     * @param string|null $type The resource type
36     *
37     * @return RouteCollection A RouteCollection instance
38     *
39     * @throws \InvalidArgumentException when the file cannot be loaded or when the XML cannot be
40     *                                   parsed because it does not validate against the scheme
41     */
42    public function load($file, $type = null)
43    {
44        $path = $this->locator->locate($file);
45
46        $xml = $this->loadFile($path);
47
48        $collection = new RouteCollection();
49        $collection->addResource(new FileResource($path));
50
51        // process routes and imports
52        foreach ($xml->documentElement->childNodes as $node) {
53            if (!$node instanceof \DOMElement) {
54                continue;
55            }
56
57            $this->parseNode($collection, $node, $path, $file);
58        }
59
60        return $collection;
61    }
62
63    /**
64     * Parses a node from a loaded XML file.
65     *
66     * @param RouteCollection $collection Collection to associate with the node
67     * @param \DOMElement     $node       Element to parse
68     * @param string          $path       Full path of the XML file being processed
69     * @param string          $file       Loaded file name
70     *
71     * @throws \InvalidArgumentException When the XML is invalid
72     */
73    protected function parseNode(RouteCollection $collection, \DOMElement $node, $path, $file)
74    {
75        if (self::NAMESPACE_URI !== $node->namespaceURI) {
76            return;
77        }
78
79        switch ($node->localName) {
80            case 'route':
81                $this->parseRoute($collection, $node, $path);
82                break;
83            case 'import':
84                $this->parseImport($collection, $node, $path, $file);
85                break;
86            default:
87                throw new \InvalidArgumentException(sprintf('Unknown tag "%s" used in file "%s". Expected "route" or "import".', $node->localName, $path));
88        }
89    }
90
91    /**
92     * {@inheritdoc}
93     */
94    public function supports($resource, $type = null)
95    {
96        return \is_string($resource) && 'xml' === pathinfo($resource, \PATHINFO_EXTENSION) && (!$type || 'xml' === $type);
97    }
98
99    /**
100     * Parses a route and adds it to the RouteCollection.
101     *
102     * @param RouteCollection $collection RouteCollection instance
103     * @param \DOMElement     $node       Element to parse that represents a Route
104     * @param string          $path       Full path of the XML file being processed
105     *
106     * @throws \InvalidArgumentException When the XML is invalid
107     */
108    protected function parseRoute(RouteCollection $collection, \DOMElement $node, $path)
109    {
110        if ('' === ($id = $node->getAttribute('id')) || !$node->hasAttribute('path')) {
111            throw new \InvalidArgumentException(sprintf('The <route> element in file "%s" must have an "id" and a "path" attribute.', $path));
112        }
113
114        $schemes = preg_split('/[\s,\|]++/', $node->getAttribute('schemes'), -1, \PREG_SPLIT_NO_EMPTY);
115        $methods = preg_split('/[\s,\|]++/', $node->getAttribute('methods'), -1, \PREG_SPLIT_NO_EMPTY);
116
117        list($defaults, $requirements, $options, $condition) = $this->parseConfigs($node, $path);
118
119        $route = new Route($node->getAttribute('path'), $defaults, $requirements, $options, $node->getAttribute('host'), $schemes, $methods, $condition);
120        $collection->add($id, $route);
121    }
122
123    /**
124     * Parses an import and adds the routes in the resource to the RouteCollection.
125     *
126     * @param RouteCollection $collection RouteCollection instance
127     * @param \DOMElement     $node       Element to parse that represents a Route
128     * @param string          $path       Full path of the XML file being processed
129     * @param string          $file       Loaded file name
130     *
131     * @throws \InvalidArgumentException When the XML is invalid
132     */
133    protected function parseImport(RouteCollection $collection, \DOMElement $node, $path, $file)
134    {
135        if ('' === $resource = $node->getAttribute('resource')) {
136            throw new \InvalidArgumentException(sprintf('The <import> element in file "%s" must have a "resource" attribute.', $path));
137        }
138
139        $type = $node->getAttribute('type');
140        $prefix = $node->getAttribute('prefix');
141        $host = $node->hasAttribute('host') ? $node->getAttribute('host') : null;
142        $schemes = $node->hasAttribute('schemes') ? preg_split('/[\s,\|]++/', $node->getAttribute('schemes'), -1, \PREG_SPLIT_NO_EMPTY) : null;
143        $methods = $node->hasAttribute('methods') ? preg_split('/[\s,\|]++/', $node->getAttribute('methods'), -1, \PREG_SPLIT_NO_EMPTY) : null;
144
145        list($defaults, $requirements, $options, $condition) = $this->parseConfigs($node, $path);
146
147        $this->setCurrentDir(\dirname($path));
148
149        /** @var RouteCollection[] $imported */
150        $imported = $this->import($resource, ('' !== $type ? $type : null), false, $file) ?: [];
151
152        if (!\is_array($imported)) {
153            $imported = [$imported];
154        }
155
156        foreach ($imported as $subCollection) {
157            /* @var $subCollection RouteCollection */
158            $subCollection->addPrefix($prefix);
159            if (null !== $host) {
160                $subCollection->setHost($host);
161            }
162            if (null !== $condition) {
163                $subCollection->setCondition($condition);
164            }
165            if (null !== $schemes) {
166                $subCollection->setSchemes($schemes);
167            }
168            if (null !== $methods) {
169                $subCollection->setMethods($methods);
170            }
171            $subCollection->addDefaults($defaults);
172            $subCollection->addRequirements($requirements);
173            $subCollection->addOptions($options);
174
175            $collection->addCollection($subCollection);
176        }
177    }
178
179    /**
180     * Loads an XML file.
181     *
182     * @param string $file An XML file path
183     *
184     * @return \DOMDocument
185     *
186     * @throws \InvalidArgumentException When loading of XML file fails because of syntax errors
187     *                                   or when the XML structure is not as expected by the scheme -
188     *                                   see validate()
189     */
190    protected function loadFile($file)
191    {
192        return XmlUtils::loadFile($file, __DIR__.static::SCHEME_PATH);
193    }
194
195    /**
196     * Parses the config elements (default, requirement, option).
197     *
198     * @param \DOMElement $node Element to parse that contains the configs
199     * @param string      $path Full path of the XML file being processed
200     *
201     * @return array An array with the defaults as first item, requirements as second and options as third
202     *
203     * @throws \InvalidArgumentException When the XML is invalid
204     */
205    private function parseConfigs(\DOMElement $node, $path)
206    {
207        $defaults = [];
208        $requirements = [];
209        $options = [];
210        $condition = null;
211
212        /** @var \DOMElement $n */
213        foreach ($node->getElementsByTagNameNS(self::NAMESPACE_URI, '*') as $n) {
214            if ($node !== $n->parentNode) {
215                continue;
216            }
217
218            switch ($n->localName) {
219                case 'default':
220                    if ($this->isElementValueNull($n)) {
221                        $defaults[$n->getAttribute('key')] = null;
222                    } else {
223                        $defaults[$n->getAttribute('key')] = $this->parseDefaultsConfig($n, $path);
224                    }
225
226                    break;
227                case 'requirement':
228                    $requirements[$n->getAttribute('key')] = trim($n->textContent);
229                    break;
230                case 'option':
231                    $options[$n->getAttribute('key')] = XmlUtils::phpize(trim($n->textContent));
232                    break;
233                case 'condition':
234                    $condition = trim($n->textContent);
235                    break;
236                default:
237                    throw new \InvalidArgumentException(sprintf('Unknown tag "%s" used in file "%s". Expected "default", "requirement", "option" or "condition".', $n->localName, $path));
238            }
239        }
240
241        if ($controller = $node->getAttribute('controller')) {
242            if (isset($defaults['_controller'])) {
243                $name = $node->hasAttribute('id') ? sprintf('"%s".', $node->getAttribute('id')) : sprintf('the "%s" tag.', $node->tagName);
244
245                throw new \InvalidArgumentException(sprintf('The routing file "%s" must not specify both the "controller" attribute and the defaults key "_controller" for ', $path).$name);
246            }
247
248            $defaults['_controller'] = $controller;
249        }
250
251        return [$defaults, $requirements, $options, $condition];
252    }
253
254    /**
255     * Parses the "default" elements.
256     *
257     * @param \DOMElement $element The "default" element to parse
258     * @param string      $path    Full path of the XML file being processed
259     *
260     * @return array|bool|float|int|string|null The parsed value of the "default" element
261     */
262    private function parseDefaultsConfig(\DOMElement $element, $path)
263    {
264        if ($this->isElementValueNull($element)) {
265            return null;
266        }
267
268        // Check for existing element nodes in the default element. There can
269        // only be a single element inside a default element. So this element
270        // (if one was found) can safely be returned.
271        foreach ($element->childNodes as $child) {
272            if (!$child instanceof \DOMElement) {
273                continue;
274            }
275
276            if (self::NAMESPACE_URI !== $child->namespaceURI) {
277                continue;
278            }
279
280            return $this->parseDefaultNode($child, $path);
281        }
282
283        // If the default element doesn't contain a nested "bool", "int", "float",
284        // "string", "list", or "map" element, the element contents will be treated
285        // as the string value of the associated default option.
286        return trim($element->textContent);
287    }
288
289    /**
290     * Recursively parses the value of a "default" element.
291     *
292     * @param \DOMElement $node The node value
293     * @param string      $path Full path of the XML file being processed
294     *
295     * @return array|bool|float|int|string The parsed value
296     *
297     * @throws \InvalidArgumentException when the XML is invalid
298     */
299    private function parseDefaultNode(\DOMElement $node, $path)
300    {
301        if ($this->isElementValueNull($node)) {
302            return null;
303        }
304
305        switch ($node->localName) {
306            case 'bool':
307                return 'true' === trim($node->nodeValue) || '1' === trim($node->nodeValue);
308            case 'int':
309                return (int) trim($node->nodeValue);
310            case 'float':
311                return (float) trim($node->nodeValue);
312            case 'string':
313                return trim($node->nodeValue);
314            case 'list':
315                $list = [];
316
317                foreach ($node->childNodes as $element) {
318                    if (!$element instanceof \DOMElement) {
319                        continue;
320                    }
321
322                    if (self::NAMESPACE_URI !== $element->namespaceURI) {
323                        continue;
324                    }
325
326                    $list[] = $this->parseDefaultNode($element, $path);
327                }
328
329                return $list;
330            case 'map':
331                $map = [];
332
333                foreach ($node->childNodes as $element) {
334                    if (!$element instanceof \DOMElement) {
335                        continue;
336                    }
337
338                    if (self::NAMESPACE_URI !== $element->namespaceURI) {
339                        continue;
340                    }
341
342                    $map[$element->getAttribute('key')] = $this->parseDefaultNode($element, $path);
343                }
344
345                return $map;
346            default:
347                throw new \InvalidArgumentException(sprintf('Unknown tag "%s" used in file "%s". Expected "bool", "int", "float", "string", "list", or "map".', $node->localName, $path));
348        }
349    }
350
351    private function isElementValueNull(\DOMElement $element)
352    {
353        $namespaceUri = 'http://www.w3.org/2001/XMLSchema-instance';
354
355        if (!$element->hasAttributeNS($namespaceUri, 'nil')) {
356            return false;
357        }
358
359        return 'true' === $element->getAttributeNS($namespaceUri, 'nil') || '1' === $element->getAttributeNS($namespaceUri, 'nil');
360    }
361}
362