Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
Psr16OptionsCache
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
7 / 7
14
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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 set
100.00% covered (success)
100.00%
11 / 11
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%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 trackKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 untrackKey
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
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\SimpleCache\CacheInterface;
25
26/**
27 * OPTIONS-cache adapter that delegates to any PSR-16 (Simple Cache)
28 * implementation.
29 *
30 * The adapter stores each {@see IcapResponse} as a serializable array
31 * in the backing store. 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/simple-cache` ^3.0 — listed in composer.json `suggest`.
36 */
37final class Psr16OptionsCache 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 CacheInterface $cache,
44        private readonly string $prefix = '',
45    ) {
46    }
47
48    #[\Override]
49    public function get(string $key): ?IcapResponse
50    {
51        /** @var array{statusCode: int, headers: array<string, list<string>>, body: string}|null $data */
52        $data = $this->cache->get($this->prefix . $key);
53        if ($data === null) {
54            return null;
55        }
56
57        return new IcapResponse(
58            $data['statusCode'],
59            $data['headers'],
60            $data['body'],
61        );
62    }
63
64    #[\Override]
65    public function set(string $key, IcapResponse $response, int $ttlSeconds, ?string $istag = null): void
66    {
67        if ($ttlSeconds <= 0) {
68            return;
69        }
70
71        if ($istag !== null) {
72            $this->handleIstagChange($istag);
73        }
74
75        $data = [
76            'statusCode' => $response->statusCode,
77            'headers'    => $response->headers,
78            'body'       => $response->body,
79        ];
80
81        $this->cache->set($this->prefix . $key, $data, $ttlSeconds);
82        $this->trackKey($key);
83    }
84
85    #[\Override]
86    public function delete(string $key): void
87    {
88        $this->cache->delete($this->prefix . $key);
89        $this->untrackKey($key);
90    }
91
92    /**
93     * When the ISTag changes, flush all previously tracked entries.
94     */
95    private function handleIstagChange(string $istag): void
96    {
97        /** @var string|null $stored */
98        $stored = $this->cache->get($this->prefix . self::ISTAG_META_KEY);
99
100        if ($stored !== null && $stored !== $istag) {
101            // ISTag changed — flush all tracked entries.
102            /** @var list<string> $keys */
103            $keys = $this->cache->get($this->prefix . self::KEYS_META_KEY) ?? [];
104            foreach ($keys as $trackedKey) {
105                $this->cache->delete($this->prefix . $trackedKey);
106            }
107            $this->cache->delete($this->prefix . self::KEYS_META_KEY);
108        }
109
110        // Always update to the latest ISTag (no TTL — lives as long as
111        // the cache backend keeps it, which is fine because we only use
112        // it for comparison).
113        $this->cache->set($this->prefix . self::ISTAG_META_KEY, $istag);
114    }
115
116    private function trackKey(string $key): void
117    {
118        /** @var list<string> $keys */
119        $keys = $this->cache->get($this->prefix . self::KEYS_META_KEY) ?? [];
120        if (!in_array($key, $keys, true)) {
121            $keys[] = $key;
122            $this->cache->set($this->prefix . self::KEYS_META_KEY, $keys);
123        }
124    }
125
126    private function untrackKey(string $key): void
127    {
128        /** @var list<string> $keys */
129        $keys = $this->cache->get($this->prefix . self::KEYS_META_KEY) ?? [];
130        $keys = array_values(array_filter($keys, static fn (string $k): bool => $k !== $key));
131        $this->cache->set($this->prefix . self::KEYS_META_KEY, $keys);
132    }
133}