Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
Psr6OptionsCache
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
7 / 7
17
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 set
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 delete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 handleIstagChange
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 trackKey
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 untrackKey
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * SPDX-License-Identifier: EUPL-1.2
5 *
6 * This file is part of icap-flow.
7 *
8 * Licensed under the EUPL, Version 1.2 only (the "Licence");
9 * you may not use this work except in compliance with the Licence.
10 * You may obtain a copy of the Licence at:
11 *
12 *     https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
13 *
14 * Unless required by applicable law or agreed to in writing, software
15 * distributed under the Licence is distributed on an "AS IS" basis,
16 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 */
18
19declare(strict_types=1);
20
21namespace Ndrstmr\Icap\Cache;
22
23use Ndrstmr\Icap\DTO\IcapResponse;
24use Psr\Cache\CacheItemPoolInterface;
25
26/**
27 * OPTIONS-cache adapter that delegates to any PSR-6
28 * (Cache Item Pool) implementation.
29 *
30 * The adapter stores each {@see IcapResponse} as a serializable array
31 * in the backing pool. ISTag tracking uses a dedicated meta-key so
32 * the flush-on-change behaviour works across processes (unlike
33 * {@see InMemoryOptionsCache}, which is process-local).
34 *
35 * Requires `psr/cache` ^3.0 — listed in composer.json `suggest`.
36 */
37final class Psr6OptionsCache implements OptionsCacheInterface
38{
39    private const string ISTAG_META_KEY = '__icap_istag';
40    private const string KEYS_META_KEY = '__icap_keys';
41
42    public function __construct(
43        private readonly CacheItemPoolInterface $pool,
44        private readonly string $prefix = '',
45    ) {
46    }
47
48    #[\Override]
49    public function get(string $key): ?IcapResponse
50    {
51        $item = $this->pool->getItem($this->prefix . $key);
52        if (!$item->isHit()) {
53            return null;
54        }
55
56        /** @var array{statusCode: int, headers: array<string, list<string>>, body: string} $data */
57        $data = $item->get();
58
59        return new IcapResponse(
60            $data['statusCode'],
61            $data['headers'],
62            $data['body'],
63        );
64    }
65
66    #[\Override]
67    public function set(string $key, IcapResponse $response, int $ttlSeconds, ?string $istag = null): void
68    {
69        if ($ttlSeconds <= 0) {
70            return;
71        }
72
73        if ($istag !== null) {
74            $this->handleIstagChange($istag);
75        }
76
77        $data = [
78            'statusCode' => $response->statusCode,
79            'headers'    => $response->headers,
80            'body'       => $response->body,
81        ];
82
83        $item = $this->pool->getItem($this->prefix . $key);
84        $item->set($data);
85        $item->expiresAfter($ttlSeconds);
86        $this->pool->save($item);
87
88        $this->trackKey($key);
89    }
90
91    #[\Override]
92    public function delete(string $key): void
93    {
94        $this->pool->deleteItem($this->prefix . $key);
95        $this->untrackKey($key);
96    }
97
98    /**
99     * When the ISTag changes, flush all previously tracked entries.
100     */
101    private function handleIstagChange(string $istag): void
102    {
103        $istagItem = $this->pool->getItem($this->prefix . self::ISTAG_META_KEY);
104
105        if ($istagItem->isHit()) {
106            /** @var string $stored */
107            $stored = $istagItem->get();
108            if ($stored !== $istag) {
109                // ISTag changed — flush all tracked entries.
110                $keysItem = $this->pool->getItem($this->prefix . self::KEYS_META_KEY);
111                /** @var list<string> $keys */
112                $keys = $keysItem->isHit() ? $keysItem->get() : [];
113                foreach ($keys as $trackedKey) {
114                    $this->pool->deleteItem($this->prefix . $trackedKey);
115                }
116                $this->pool->deleteItem($this->prefix . self::KEYS_META_KEY);
117            }
118        }
119
120        // Always update to the latest ISTag.
121        $item = $this->pool->getItem($this->prefix . self::ISTAG_META_KEY);
122        $item->set($istag);
123        $this->pool->save($item);
124    }
125
126    private function trackKey(string $key): void
127    {
128        $keysItem = $this->pool->getItem($this->prefix . self::KEYS_META_KEY);
129        /** @var list<string> $keys */
130        $keys = $keysItem->isHit() ? $keysItem->get() : [];
131        if (!in_array($key, $keys, true)) {
132            $keys[] = $key;
133            $newItem = $this->pool->getItem($this->prefix . self::KEYS_META_KEY);
134            $newItem->set($keys);
135            $this->pool->save($newItem);
136        }
137    }
138
139    private function untrackKey(string $key): void
140    {
141        $keysItem = $this->pool->getItem($this->prefix . self::KEYS_META_KEY);
142        /** @var list<string> $keys */
143        $keys = $keysItem->isHit() ? $keysItem->get() : [];
144        $keys = array_values(array_filter($keys, static fn (string $k): bool => $k !== $key));
145        $newItem = $this->pool->getItem($this->prefix . self::KEYS_META_KEY);
146        $newItem->set($keys);
147        $this->pool->save($newItem);
148    }
149}