Completed
Pull Request — master (#97)
by
unknown
02:11
created

RateLimitAnnotationListener::dispatch()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2.032

Importance

Changes 0
Metric Value
dl 0
loc 10
ccs 4
cts 5
cp 0.8
rs 9.9332
c 0
b 0
f 0
cc 2
nc 2
nop 2
crap 2.032
1
<?php
2
3
namespace Noxlogic\RateLimitBundle\EventListener;
4
5
use Noxlogic\RateLimitBundle\Annotation\RateLimit;
6
use Noxlogic\RateLimitBundle\Events\CheckedRateLimitEvent;
7
use Noxlogic\RateLimitBundle\Events\GenerateKeyEvent;
8
use Noxlogic\RateLimitBundle\Events\RateLimitEvents;
9
use Noxlogic\RateLimitBundle\Exception\RateLimitExceptionInterface;
10
use Noxlogic\RateLimitBundle\Service\RateLimitService;
11
use Noxlogic\RateLimitBundle\Util\PathLimitProcessor;
12
use Symfony\Component\EventDispatcher\EventDispatcherInterface as LegacyEventDispatcherInterface;
13
use Symfony\Component\HttpFoundation\Request;
14
use Symfony\Component\HttpFoundation\Response;
15
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
16
use Symfony\Component\HttpKernel\HttpKernelInterface;
17
use Symfony\Component\Routing\Route;
18
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
19
20
class RateLimitAnnotationListener extends BaseListener
21
{
22
23
    /**
24
     * @var EventDispatcherInterface | LegacyEventDispatcherInterface
25
     */
26
    protected $eventDispatcher;
27
28
    /**
29
     * @var \Noxlogic\RateLimitBundle\Service\RateLimitService
30
     */
31
    protected $rateLimitService;
32
33
    /**
34
     * @var \Noxlogic\RateLimitBundle\Util\PathLimitProcessor
35
     */
36
    protected $pathLimitProcessor;
37
38
    /**
39
     * @param RateLimitService                    $rateLimitService
40
     */
41 19
    public function __construct(
42
        $eventDispatcher,
43
        RateLimitService $rateLimitService,
44
        PathLimitProcessor $pathLimitProcessor
45
    ) {
46 19
        $this->eventDispatcher = $eventDispatcher;
47 19
        $this->rateLimitService = $rateLimitService;
48 19
        $this->pathLimitProcessor = $pathLimitProcessor;
49 19
    }
50
51
    /**
52
     * @param FilterControllerEvent $event
53
     */
54 15
    public function onKernelController(FilterControllerEvent $event)
55
    {
56
        // Skip if the bundle isn't enabled (for instance in test environment)
57 15
        if( ! $this->getParameter('enabled', true)) {
58 1
            return;
59
        }
60
61
        // Skip if we aren't the main request
62 14
        if ($event->getRequestType() != HttpKernelInterface::MASTER_REQUEST) {
63 1
            return;
64
        }
65
66
        // Find the best match
67 13
        $annotations = $event->getRequest()->attributes->get('_x-rate-limit', array());
68 13
        $rateLimit = $this->findBestMethodMatch($event->getRequest(), $annotations);
69
70
        // Another treatment before applying RateLimit ?
71 13
        $checkedRateLimitEvent = new CheckedRateLimitEvent($event->getRequest(), $rateLimit);
72 13
        $this->dispatch(RateLimitEvents::CHECKED_RATE_LIMIT, $checkedRateLimitEvent);
73 13
        $rateLimit = $checkedRateLimitEvent->getRateLimit();
74
75
        // No matching annotation found
76 13
        if (! $rateLimit) {
77 3
            return;
78
        }
79
80 10
        $key = $this->getKey($event, $rateLimit, $annotations);
81
82
        // Ratelimit the call
83 10
        $rateLimitInfo = $this->rateLimitService->limitRate($key);
84 10
        if (! $rateLimitInfo) {
85
            // Create new rate limit entry for this call
86 5
            $rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod());
87 5
            if (! $rateLimitInfo) {
88
                // @codeCoverageIgnoreStart
89
                return;
90
                // @codeCoverageIgnoreEnd
91
            }
92
        }
93
94
95
        // Store the current rating info in the request attributes
96 9
        $request = $event->getRequest();
97 9
        $request->attributes->set('rate_limit_info', $rateLimitInfo);
98
99
        // Reset the rate limits
100 9
        if(time() >= $rateLimitInfo->getResetTimestamp()) {
101 1
            $this->rateLimitService->resetRate($key);
102 1
            $rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod());
103 1
            if (! $rateLimitInfo) {
104
                // @codeCoverageIgnoreStart
105
                return;
106
                // @codeCoverageIgnoreEnd
107
            }
108
        }
109
110
        // When we exceeded our limit, return a custom error response
111 9
        if ($rateLimitInfo->getCalls() > $rateLimitInfo->getLimit()) {
112
113
            // Throw an exception if configured.
114 5
            if ($this->getParameter('rate_response_exception')) {
115 2
                $class = $this->getParameter('rate_response_exception');
116
117 2
                $e = new $class($this->getParameter('rate_response_message'), $this->getParameter('rate_response_code'));
118
119 2
                if ($e instanceof RateLimitExceptionInterface) {
120 1
                    $e->setPayload($rateLimit->getPayload());
121
                }
122
123 2
                throw $e;
124
            }
125
126 3
            $message = $this->getParameter('rate_response_message');
127 3
            $code = $this->getParameter('rate_response_code');
128 3
            $event->setController(function () use ($message, $code) {
129
                // @codeCoverageIgnoreStart
130
                return new Response($message, $code);
131
                // @codeCoverageIgnoreEnd
132 3
            });
133 3
            $event->stopPropagation();
134
        }
135
136 7
    }
137
138
139
    /**
140
     * @param RateLimit[] $annotations
141
     */
142 17
    protected function findBestMethodMatch(Request $request, array $annotations)
143
    {
144
        // Empty array, check the path limits
145 17
        if (count($annotations) == 0) {
146 5
            return $this->pathLimitProcessor->getRateLimit($request);
147
        }
148
149 12
        $best_match = null;
150 12
        foreach ($annotations as $annotation) {
151
            // cast methods to array, even method holds a string
152 12
            $methods = is_array($annotation->getMethods()) ? $annotation->getMethods() : array($annotation->getMethods());
153
154 12
            if (in_array($request->getMethod(), $methods)) {
155 3
                $best_match = $annotation;
156
            }
157
158
            // Only match "default" annotation when we don't have a best match
159 12
            if (count($annotation->getMethods()) == 0 && $best_match == null) {
160 12
                $best_match = $annotation;
161
            }
162
        }
163
164 12
        return $best_match;
165
    }
166
167 10
    private function getKey(FilterControllerEvent $event, RateLimit $rateLimit, array $annotations)
168
    {
169
        // Let listeners manipulate the key
170 10
        $keyEvent = new GenerateKeyEvent($event->getRequest(), '', $rateLimit->getPayload());
171
172 10
        $rateLimitMethods = implode('.', $rateLimit->getMethods());
173 10
        $keyEvent->addToKey($rateLimitMethods);
174
175 10
        $rateLimitAlias = count($annotations) === 0
176 1
            ? str_replace('/', '.', $this->pathLimitProcessor->getMatchedPath($event->getRequest()))
177 10
            : $this->getAliasForRequest($event);
178 10
        $keyEvent->addToKey($rateLimitAlias);
179
180 10
        $this->dispatch(RateLimitEvents::GENERATE_KEY, $keyEvent);
181
182 10
        return $keyEvent->getKey();
183
    }
184
185 9
    private function getAliasForRequest(FilterControllerEvent $event)
186
    {
187 9
        if (($route = $event->getRequest()->attributes->get('_route'))) {
188
            return $route;
189
        }
190
191 9
        $controller = $event->getController();
192
193 9
        if (is_string($controller) && false !== strpos($controller, '::')) {
194
            $controller = explode('::', $controller);
195
        }
196
197 9
        if (is_array($controller)) {
198 9
            return str_replace('\\', '.', is_string($controller[0]) ? $controller[0] : get_class($controller[0])) . '.' . $controller[1];
199
        }
200
201 1
        if ($controller instanceof \Closure) {
202 1
            return 'closure';
203
        }
204
205
        if (is_object($controller)) {
206
            return str_replace('\\', '.', get_class($controller[0]));
207
        }
208
209
        return 'other';
210
    }
211
212 13
    private function dispatch($eventName, $event)
213
    {
214 13
        if (get_class($this->eventDispatcher) === 'Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface') {
215
            // Symfony >= 4.3
216
            $this->eventDispatcher->dispatch($event, $eventName);
217
        } else {
218
            // Symfony 3.4
219 13
            $this->eventDispatcher->dispatch($eventName, $event);
220
        }
221 13
    }
222
223
}
224