Passed
Push — trunk ( 634730...0037c7 )
by Christian
13:42 queued 12s
created

CacheResponseSubscriber   A

Complexity

Total Complexity 41

Size/Duplication

Total Lines 282
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 120
c 0
b 0
f 0
dl 0
loc 282
rs 9.1199
wmc 41

12 Methods

Rating   Name   Duplication   Size   Complexity  
A updateCacheControlForBrowser() 0 18 3
A setCurrencyCookie() 0 12 2
A getSystemStates() 0 13 2
A buildCacheHash() 0 8 2
A updateSystemState() 0 23 4
A switchState() 0 11 2
A hasInvalidationState() 0 9 3
C setResponseCache() 0 77 16
A addHttpCacheToCoreRoutes() 0 7 2
A __construct() 0 9 1
A setResponseCacheHeader() 0 17 3
A getSubscribedEvents() 0 9 1

How to fix   Complexity   

Complex Class

Complex classes like CacheResponseSubscriber often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use CacheResponseSubscriber, and based on these observations, apply Extract Interface, too.

1
<?php declare(strict_types=1);
2
3
namespace Shopware\Storefront\Framework\Cache;
4
5
use Shopware\Core\Checkout\Cart\Cart;
6
use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
7
use Shopware\Core\Framework\Adapter\Cache\CacheStateSubscriber;
8
use Shopware\Core\Framework\Event\BeforeSendResponseEvent;
9
use Shopware\Core\Framework\Log\Package;
10
use Shopware\Core\PlatformRequest;
11
use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService;
12
use Shopware\Core\System\SalesChannel\SalesChannelContext;
13
use Shopware\Storefront\Framework\Routing\MaintenanceModeResolver;
14
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15
use Symfony\Component\HttpFoundation\Cookie;
16
use Symfony\Component\HttpFoundation\Request;
17
use Symfony\Component\HttpFoundation\Response;
18
use Symfony\Component\HttpKernel\Event\RequestEvent;
19
use Symfony\Component\HttpKernel\Event\ResponseEvent;
20
use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
21
use Symfony\Component\HttpKernel\KernelEvents;
22
23
/**
24
 * @internal
25
 */
26
#[Package('storefront')]
27
class CacheResponseSubscriber implements EventSubscriberInterface
28
{
29
    final public const STATE_LOGGED_IN = CacheStateSubscriber::STATE_LOGGED_IN;
30
    final public const STATE_CART_FILLED = CacheStateSubscriber::STATE_CART_FILLED;
31
32
    final public const CURRENCY_COOKIE = 'sw-currency';
33
    final public const CONTEXT_CACHE_COOKIE = 'sw-cache-hash';
34
    final public const SYSTEM_STATE_COOKIE = 'sw-states';
35
    final public const INVALIDATION_STATES_HEADER = 'sw-invalidation-states';
36
37
    private const CORE_HTTP_CACHED_ROUTES = [
38
        'api.acl.privileges.get',
39
    ];
40
41
    /**
42
     * @internal
43
     */
44
    public function __construct(
45
        private readonly CartService $cartService,
46
        private readonly int $defaultTtl,
47
        private readonly bool $httpCacheEnabled,
48
        private readonly MaintenanceModeResolver $maintenanceResolver,
49
        private readonly bool $reverseProxyEnabled,
50
        private readonly ?string $staleWhileRevalidate,
51
        private readonly ?string $staleIfError
52
    ) {
53
    }
54
55
    /**
56
     * @return array<string, string|array{0: string, 1: int}|list<array{0: string, 1?: int}>>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string, string|arr...y{0: string, 1?: int}>> at position 17 could not be parsed: Expected '>' at position 17, but found 'list'.
Loading history...
57
     */
58
    public static function getSubscribedEvents(): array
59
    {
60
        return [
61
            KernelEvents::REQUEST => 'addHttpCacheToCoreRoutes',
62
            KernelEvents::RESPONSE => [
63
                ['setResponseCache', -1500],
64
                ['setResponseCacheHeader', 1500],
65
            ],
66
            BeforeSendResponseEvent::class => 'updateCacheControlForBrowser',
67
        ];
68
    }
69
70
    public function addHttpCacheToCoreRoutes(RequestEvent $event): void
71
    {
72
        $request = $event->getRequest();
73
        $route = $request->attributes->get('_route');
74
75
        if (\in_array($route, self::CORE_HTTP_CACHED_ROUTES, true)) {
76
            $request->attributes->set(PlatformRequest::ATTRIBUTE_HTTP_CACHE, true);
77
        }
78
    }
79
80
    public function setResponseCache(ResponseEvent $event): void
81
    {
82
        if (!$this->httpCacheEnabled) {
83
            return;
84
        }
85
86
        $response = $event->getResponse();
87
88
        $request = $event->getRequest();
89
90
        $context = $request->attributes->get(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT);
91
92
        if (!$context instanceof SalesChannelContext) {
93
            return;
94
        }
95
96
        if (!$this->maintenanceResolver->shouldBeCached($request)) {
97
            return;
98
        }
99
100
        $route = $request->attributes->get('_route');
101
        if ($route === 'frontend.checkout.configure') {
102
            $this->setCurrencyCookie($request, $response);
103
        }
104
105
        $cart = $this->cartService->getCart($context->getToken(), $context);
106
107
        $states = $this->updateSystemState($cart, $context, $request, $response);
108
109
        // We need to allow it on login, otherwise the state is wrong
110
        if (!($route === 'frontend.account.login' || $request->getMethod() === Request::METHOD_GET)) {
111
            return;
112
        }
113
114
        if ($context->getCustomer() || $cart->getLineItems()->count() > 0) {
115
            $newValue = $this->buildCacheHash($context);
116
117
            if ($request->cookies->get(self::CONTEXT_CACHE_COOKIE, '') !== $newValue) {
118
                $cookie = Cookie::create(self::CONTEXT_CACHE_COOKIE, $newValue);
119
                $cookie->setSecureDefault($request->isSecure());
120
121
                $response->headers->setCookie($cookie);
122
            }
123
        } elseif ($request->cookies->has(self::CONTEXT_CACHE_COOKIE)) {
124
            $response->headers->removeCookie(self::CONTEXT_CACHE_COOKIE);
125
            $response->headers->clearCookie(self::CONTEXT_CACHE_COOKIE);
126
        }
127
128
        /** @var bool|array{maxAge?: int, states?: list<string>}|null $cache */
129
        $cache = $request->attributes->get(PlatformRequest::ATTRIBUTE_HTTP_CACHE);
130
        if (!$cache) {
131
            return;
132
        }
133
134
        if ($cache === true) {
135
            $cache = [];
136
        }
137
138
        if ($this->hasInvalidationState($cache['states'] ?? [], $states)) {
139
            return;
140
        }
141
142
        $maxAge = $cache['maxAge'] ?? $this->defaultTtl;
143
144
        $response->setSharedMaxAge($maxAge);
145
        $response->headers->addCacheControlDirective('must-revalidate');
146
        $response->headers->set(
147
            self::INVALIDATION_STATES_HEADER,
148
            implode(',', $cache['states'] ?? [])
149
        );
150
151
        if ($this->staleIfError !== null) {
152
            $response->headers->addCacheControlDirective('stale-if-error', $this->staleIfError);
153
        }
154
155
        if ($this->staleWhileRevalidate !== null) {
156
            $response->headers->addCacheControlDirective('stale-while-revalidate', $this->staleWhileRevalidate);
157
        }
158
    }
159
160
    public function setResponseCacheHeader(ResponseEvent $event): void
161
    {
162
        if (!$this->httpCacheEnabled) {
163
            return;
164
        }
165
166
        $response = $event->getResponse();
167
168
        $request = $event->getRequest();
169
170
        /** @var bool|array{maxAge?: int, states?: list<string>}|null $cache */
171
        $cache = $request->attributes->get(PlatformRequest::ATTRIBUTE_HTTP_CACHE);
172
        if (!$cache) {
173
            return;
174
        }
175
176
        $response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, '1');
177
    }
178
179
    /**
180
     * In the default HttpCache implementation the reverse proxy cache is implemented too in PHP and triggered before the response is send to the client. We don't need to send the "real" cache-control headers to the end client (browser/cloudflare).
181
     * If a external reverse proxy cache is used we still need to provide the actual cache-control, so the external system can cache the system correctly and set the cache-control again to
182
     */
183
    public function updateCacheControlForBrowser(BeforeSendResponseEvent $event): void
184
    {
185
        if ($this->reverseProxyEnabled) {
186
            return;
187
        }
188
189
        $response = $event->getResponse();
190
191
        $noStore = $response->headers->getCacheControlDirective('no-store');
192
193
        // We don't want that the client will cache the website, if no reverse proxy is configured
194
        $response->headers->remove('cache-control');
195
        $response->setPrivate();
196
197
        if ($noStore) {
198
            $response->headers->addCacheControlDirective('no-store');
199
        } else {
200
            $response->headers->addCacheControlDirective('no-cache');
201
        }
202
    }
203
204
    /**
205
     * @param list<string> $cacheStates
0 ignored issues
show
Bug introduced by
The type Shopware\Storefront\Framework\Cache\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
206
     * @param list<string> $states
207
     */
208
    private function hasInvalidationState(array $cacheStates, array $states): bool
209
    {
210
        foreach ($states as $state) {
211
            if (\in_array($state, $cacheStates, true)) {
212
                return true;
213
            }
214
        }
215
216
        return false;
217
    }
218
219
    private function buildCacheHash(SalesChannelContext $context): string
220
    {
221
        return md5(json_encode([
222
            $context->getRuleIds(),
223
            $context->getContext()->getVersionId(),
224
            $context->getCurrency()->getId(),
225
            $context->getCustomer() ? 'logged-in' : 'not-logged-in',
226
        ], \JSON_THROW_ON_ERROR));
227
    }
228
229
    /**
230
     * System states can be used to stop caching routes at certain states. For example,
231
     * the checkout routes are no longer cached if the customer has products in the cart or is logged in.
232
     *
233
     * @return list<string>
234
     */
235
    private function updateSystemState(Cart $cart, SalesChannelContext $context, Request $request, Response $response): array
236
    {
237
        $states = $this->getSystemStates($request, $context, $cart);
238
239
        if (empty($states)) {
240
            if ($request->cookies->has(self::SYSTEM_STATE_COOKIE)) {
241
                $response->headers->removeCookie(self::SYSTEM_STATE_COOKIE);
242
                $response->headers->clearCookie(self::SYSTEM_STATE_COOKIE);
243
            }
244
245
            return [];
0 ignored issues
show
Bug Best Practice introduced by
The expression return array() returns the type array which is incompatible with the documented return type Shopware\Storefront\Framework\Cache\list.
Loading history...
246
        }
247
248
        $newStates = implode(',', $states);
249
250
        if ($request->cookies->get(self::SYSTEM_STATE_COOKIE) !== $newStates) {
251
            $cookie = Cookie::create(self::SYSTEM_STATE_COOKIE, $newStates);
252
            $cookie->setSecureDefault($request->isSecure());
253
254
            $response->headers->setCookie($cookie);
255
        }
256
257
        return $states;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $states returns the type array which is incompatible with the documented return type Shopware\Storefront\Framework\Cache\list.
Loading history...
258
    }
259
260
    /**
261
     * @return list<string>
262
     */
263
    private function getSystemStates(Request $request, SalesChannelContext $context, Cart $cart): array
264
    {
265
        $states = [];
266
        $swStates = (string) $request->cookies->get(self::SYSTEM_STATE_COOKIE);
267
        if ($swStates !== '') {
268
            $states = array_flip(explode(',', $swStates));
269
        }
270
271
        $states = $this->switchState($states, self::STATE_LOGGED_IN, $context->getCustomer() !== null);
272
273
        $states = $this->switchState($states, self::STATE_CART_FILLED, $cart->getLineItems()->count() > 0);
274
275
        return array_keys($states);
0 ignored issues
show
Bug Best Practice introduced by
The expression return array_keys($states) returns the type array which is incompatible with the documented return type Shopware\Storefront\Framework\Cache\list.
Loading history...
276
    }
277
278
    /**
279
     * @param array<string, int|bool> $states
280
     *
281
     * @return array<string, int|bool>
282
     */
283
    private function switchState(array $states, string $key, bool $match): array
284
    {
285
        if ($match) {
286
            $states[$key] = true;
287
288
            return $states;
289
        }
290
291
        unset($states[$key]);
292
293
        return $states;
294
    }
295
296
    private function setCurrencyCookie(Request $request, Response $response): void
297
    {
298
        $currencyId = $request->get(SalesChannelContextService::CURRENCY_ID);
299
300
        if (!$currencyId) {
301
            return;
302
        }
303
304
        $cookie = Cookie::create(self::CURRENCY_COOKIE, $currencyId);
305
        $cookie->setSecureDefault($request->isSecure());
306
307
        $response->headers->setCookie($cookie);
308
    }
309
}
310