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;
13
14/**
15 * A Route describes a route and its parameters.
16 *
17 * @author Fabien Potencier <fabien@symfony.com>
18 * @author Tobias Schultze <http://tobion.de>
19 */
20class Route implements \Serializable
21{
22    private $path = '/';
23    private $host = '';
24    private $schemes = [];
25    private $methods = [];
26    private $defaults = [];
27    private $requirements = [];
28    private $options = [];
29    private $condition = '';
30
31    /**
32     * @var CompiledRoute|null
33     */
34    private $compiled;
35
36    /**
37     * Constructor.
38     *
39     * Available options:
40     *
41     *  * compiler_class: A class name able to compile this route instance (RouteCompiler by default)
42     *  * utf8:           Whether UTF-8 matching is enforced ot not
43     *
44     * @param string          $path         The path pattern to match
45     * @param array           $defaults     An array of default parameter values
46     * @param array           $requirements An array of requirements for parameters (regexes)
47     * @param array           $options      An array of options
48     * @param string|null     $host         The host pattern to match
49     * @param string|string[] $schemes      A required URI scheme or an array of restricted schemes
50     * @param string|string[] $methods      A required HTTP method or an array of restricted methods
51     * @param string|null     $condition    A condition that should evaluate to true for the route to match
52     */
53    public function __construct(string $path, array $defaults = [], array $requirements = [], array $options = [], ?string $host = '', $schemes = [], $methods = [], ?string $condition = '')
54    {
55        $this->setPath($path);
56        $this->addDefaults($defaults);
57        $this->addRequirements($requirements);
58        $this->setOptions($options);
59        $this->setHost($host);
60        $this->setSchemes($schemes);
61        $this->setMethods($methods);
62        $this->setCondition($condition);
63    }
64
65    public function __serialize(): array
66    {
67        return [
68            'path' => $this->path,
69            'host' => $this->host,
70            'defaults' => $this->defaults,
71            'requirements' => $this->requirements,
72            'options' => $this->options,
73            'schemes' => $this->schemes,
74            'methods' => $this->methods,
75            'condition' => $this->condition,
76            'compiled' => $this->compiled,
77        ];
78    }
79
80    /**
81     * @internal
82     */
83    final public function serialize(): string
84    {
85        return serialize($this->__serialize());
86    }
87
88    public function __unserialize(array $data): void
89    {
90        $this->path = $data['path'];
91        $this->host = $data['host'];
92        $this->defaults = $data['defaults'];
93        $this->requirements = $data['requirements'];
94        $this->options = $data['options'];
95        $this->schemes = $data['schemes'];
96        $this->methods = $data['methods'];
97
98        if (isset($data['condition'])) {
99            $this->condition = $data['condition'];
100        }
101        if (isset($data['compiled'])) {
102            $this->compiled = $data['compiled'];
103        }
104    }
105
106    /**
107     * @internal
108     */
109    final public function unserialize($serialized)
110    {
111        $this->__unserialize(unserialize($serialized));
112    }
113
114    /**
115     * @return string
116     */
117    public function getPath()
118    {
119        return $this->path;
120    }
121
122    /**
123     * @return $this
124     */
125    public function setPath(string $pattern)
126    {
127        $pattern = $this->extractInlineDefaultsAndRequirements($pattern);
128
129        // A pattern must start with a slash and must not have multiple slashes at the beginning because the
130        // generated path for this route would be confused with a network path, e.g. '//domain.com/path'.
131        $this->path = '/'.ltrim(trim($pattern), '/');
132        $this->compiled = null;
133
134        return $this;
135    }
136
137    /**
138     * @return string
139     */
140    public function getHost()
141    {
142        return $this->host;
143    }
144
145    /**
146     * @return $this
147     */
148    public function setHost(?string $pattern)
149    {
150        $this->host = $this->extractInlineDefaultsAndRequirements((string) $pattern);
151        $this->compiled = null;
152
153        return $this;
154    }
155
156    /**
157     * Returns the lowercased schemes this route is restricted to.
158     * So an empty array means that any scheme is allowed.
159     *
160     * @return string[]
161     */
162    public function getSchemes()
163    {
164        return $this->schemes;
165    }
166
167    /**
168     * Sets the schemes (e.g. 'https') this route is restricted to.
169     * So an empty array means that any scheme is allowed.
170     *
171     * @param string|string[] $schemes The scheme or an array of schemes
172     *
173     * @return $this
174     */
175    public function setSchemes($schemes)
176    {
177        $this->schemes = array_map('strtolower', (array) $schemes);
178        $this->compiled = null;
179
180        return $this;
181    }
182
183    /**
184     * Checks if a scheme requirement has been set.
185     *
186     * @return bool
187     */
188    public function hasScheme(string $scheme)
189    {
190        return \in_array(strtolower($scheme), $this->schemes, true);
191    }
192
193    /**
194     * Returns the uppercased HTTP methods this route is restricted to.
195     * So an empty array means that any method is allowed.
196     *
197     * @return string[]
198     */
199    public function getMethods()
200    {
201        return $this->methods;
202    }
203
204    /**
205     * Sets the HTTP methods (e.g. 'POST') this route is restricted to.
206     * So an empty array means that any method is allowed.
207     *
208     * @param string|string[] $methods The method or an array of methods
209     *
210     * @return $this
211     */
212    public function setMethods($methods)
213    {
214        $this->methods = array_map('strtoupper', (array) $methods);
215        $this->compiled = null;
216
217        return $this;
218    }
219
220    /**
221     * @return array
222     */
223    public function getOptions()
224    {
225        return $this->options;
226    }
227
228    /**
229     * @return $this
230     */
231    public function setOptions(array $options)
232    {
233        $this->options = [
234            'compiler_class' => 'Symfony\\Component\\Routing\\RouteCompiler',
235        ];
236
237        return $this->addOptions($options);
238    }
239
240    /**
241     * @return $this
242     */
243    public function addOptions(array $options)
244    {
245        foreach ($options as $name => $option) {
246            $this->options[$name] = $option;
247        }
248        $this->compiled = null;
249
250        return $this;
251    }
252
253    /**
254     * Sets an option value.
255     *
256     * @param mixed $value The option value
257     *
258     * @return $this
259     */
260    public function setOption(string $name, $value)
261    {
262        $this->options[$name] = $value;
263        $this->compiled = null;
264
265        return $this;
266    }
267
268    /**
269     * Returns the option value or null when not found.
270     *
271     * @return mixed
272     */
273    public function getOption(string $name)
274    {
275        return $this->options[$name] ?? null;
276    }
277
278    /**
279     * @return bool
280     */
281    public function hasOption(string $name)
282    {
283        return \array_key_exists($name, $this->options);
284    }
285
286    /**
287     * @return array
288     */
289    public function getDefaults()
290    {
291        return $this->defaults;
292    }
293
294    /**
295     * @return $this
296     */
297    public function setDefaults(array $defaults)
298    {
299        $this->defaults = [];
300
301        return $this->addDefaults($defaults);
302    }
303
304    /**
305     * @return $this
306     */
307    public function addDefaults(array $defaults)
308    {
309        if (isset($defaults['_locale']) && $this->isLocalized()) {
310            unset($defaults['_locale']);
311        }
312
313        foreach ($defaults as $name => $default) {
314            $this->defaults[$name] = $default;
315        }
316        $this->compiled = null;
317
318        return $this;
319    }
320
321    /**
322     * @return mixed
323     */
324    public function getDefault(string $name)
325    {
326        return $this->defaults[$name] ?? null;
327    }
328
329    /**
330     * @return bool
331     */
332    public function hasDefault(string $name)
333    {
334        return \array_key_exists($name, $this->defaults);
335    }
336
337    /**
338     * Sets a default value.
339     *
340     * @param mixed $default The default value
341     *
342     * @return $this
343     */
344    public function setDefault(string $name, $default)
345    {
346        if ('_locale' === $name && $this->isLocalized()) {
347            return $this;
348        }
349
350        $this->defaults[$name] = $default;
351        $this->compiled = null;
352
353        return $this;
354    }
355
356    /**
357     * @return array
358     */
359    public function getRequirements()
360    {
361        return $this->requirements;
362    }
363
364    /**
365     * @return $this
366     */
367    public function setRequirements(array $requirements)
368    {
369        $this->requirements = [];
370
371        return $this->addRequirements($requirements);
372    }
373
374    /**
375     * @return $this
376     */
377    public function addRequirements(array $requirements)
378    {
379        if (isset($requirements['_locale']) && $this->isLocalized()) {
380            unset($requirements['_locale']);
381        }
382
383        foreach ($requirements as $key => $regex) {
384            $this->requirements[$key] = $this->sanitizeRequirement($key, $regex);
385        }
386        $this->compiled = null;
387
388        return $this;
389    }
390
391    /**
392     * @return string|null
393     */
394    public function getRequirement(string $key)
395    {
396        return $this->requirements[$key] ?? null;
397    }
398
399    /**
400     * @return bool
401     */
402    public function hasRequirement(string $key)
403    {
404        return \array_key_exists($key, $this->requirements);
405    }
406
407    /**
408     * @return $this
409     */
410    public function setRequirement(string $key, string $regex)
411    {
412        if ('_locale' === $key && $this->isLocalized()) {
413            return $this;
414        }
415
416        $this->requirements[$key] = $this->sanitizeRequirement($key, $regex);
417        $this->compiled = null;
418
419        return $this;
420    }
421
422    /**
423     * @return string
424     */
425    public function getCondition()
426    {
427        return $this->condition;
428    }
429
430    /**
431     * @return $this
432     */
433    public function setCondition(?string $condition)
434    {
435        $this->condition = (string) $condition;
436        $this->compiled = null;
437
438        return $this;
439    }
440
441    /**
442     * Compiles the route.
443     *
444     * @return CompiledRoute
445     *
446     * @throws \LogicException If the Route cannot be compiled because the
447     *                         path or host pattern is invalid
448     *
449     * @see RouteCompiler which is responsible for the compilation process
450     */
451    public function compile()
452    {
453        if (null !== $this->compiled) {
454            return $this->compiled;
455        }
456
457        $class = $this->getOption('compiler_class');
458
459        return $this->compiled = $class::compile($this);
460    }
461
462    private function extractInlineDefaultsAndRequirements(string $pattern): string
463    {
464        if (false === strpbrk($pattern, '?<')) {
465            return $pattern;
466        }
467
468        return preg_replace_callback('#\{(!?)(\w++)(<.*?>)?(\?[^\}]*+)?\}#', function ($m) {
469            if (isset($m[4][0])) {
470                $this->setDefault($m[2], '?' !== $m[4] ? substr($m[4], 1) : null);
471            }
472            if (isset($m[3][0])) {
473                $this->setRequirement($m[2], substr($m[3], 1, -1));
474            }
475
476            return '{'.$m[1].$m[2].'}';
477        }, $pattern);
478    }
479
480    private function sanitizeRequirement(string $key, string $regex)
481    {
482        if ('' !== $regex) {
483            if ('^' === $regex[0]) {
484                $regex = substr($regex, 1);
485            } elseif (0 === strpos($regex, '\\A')) {
486                $regex = substr($regex, 2);
487            }
488        }
489
490        if (str_ends_with($regex, '$')) {
491            $regex = substr($regex, 0, -1);
492        } elseif (\strlen($regex) - 2 === strpos($regex, '\\z')) {
493            $regex = substr($regex, 0, -2);
494        }
495
496        if ('' === $regex) {
497            throw new \InvalidArgumentException(sprintf('Routing requirement for "%s" cannot be empty.', $key));
498        }
499
500        return $regex;
501    }
502
503    private function isLocalized(): bool
504    {
505        return isset($this->defaults['_locale']) && isset($this->defaults['_canonical_route']) && ($this->requirements['_locale'] ?? null) === preg_quote($this->defaults['_locale']);
506    }
507}
508