Lines 97.10% 67 / 69
Methods 83.33% 10 / 12
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 put 100.00% 7 / 7 100.00% 1 / 1 1
 putStream 100.00% 9 / 9 100.00% 1 / 1 2
 get 100.00% 8 / 8 100.00% 1 / 1 2
 delete 100.00% 6 / 6 100.00% 1 / 1 1
 exists 100.00% 2 / 2 100.00% 1 / 1 1
 url 100.00% 5 / 5 100.00% 1 / 1 3
 size 100.00% 8 / 8 100.00% 1 / 1 2
 run 100.00% 3 / 3 100.00% 1 / 1 2
 buildKey 100.00% 10 / 10 100.00% 1 / 1 6
 hasResultField 80.00% 4 / 5 0.00% 0 / 1 3.07
 getResultField 80.00% 4 / 5 0.00% 0 / 1 3.07
25final readonly class S3Storage implements StorageInterface
26{
27    public function __construct(
28        private object $client,
29        private string $bucket,
30        private string $prefix = '',
31        private ?string $publicBaseUrl = null,
32    ) {
33    }
34
35    public function put(string $path, string $contents): bool
36    {
37        $key = $this->buildKey($path);
38        $this->run(fn(): mixed => $this->client->putObject([
39            'Bucket' => $this->bucket,
40            'Key' => $key,
41            'Body' => $contents,
42        ]));
43
44        return true;
45    }
46
47    public function putStream(string $path, mixed $stream): bool
48    {
49        if (!is_resource($stream)) {
50            throw new StorageException('Stream must be a valid resource.');
51        }
52
53        $key = $this->buildKey($path);
54        $this->run(fn(): mixed => $this->client->putObject([
55            'Bucket' => $this->bucket,
56            'Key' => $key,
57            'Body' => $stream,
58        ]));
59
60        return true;
61    }
62
63    public function get(string $path): string
64    {
65        $key = $this->buildKey($path);
66        $result = $this->run(fn(): mixed => $this->client->getObject([
67            'Bucket' => $this->bucket,
68            'Key' => $key,
69        ]));
70
71        if (!$this->hasResultField($result, 'Body')) {
72            throw new StorageException('Invalid S3 response while reading object: ' . $key);
73        }
74
75        return (string) $this->getResultField($result, 'Body');
76    }
77
78    public function delete(string $path): bool
79    {
80        $key = $this->buildKey($path);
81        $this->run(fn(): mixed => $this->client->deleteObject([
82            'Bucket' => $this->bucket,
83            'Key' => $key,
84        ]));
85
86        return true;
87    }
88
89    public function exists(string $path): bool
90    {
91        $key = $this->buildKey($path);
92
93        return (bool) $this->run(fn(): mixed => $this->client->doesObjectExistV2($this->bucket, $key));
94    }
95
96    public function url(string $path): string
97    {
98        $key = $this->buildKey($path);
99        if ($this->publicBaseUrl !== null && $this->publicBaseUrl !== '') {
100            return rtrim(string: $this->publicBaseUrl, characters: '/') . '/' . $key;
101        }
102
103        $result = $this->run(fn(): mixed => $this->client->getObjectUrl($this->bucket, $key));
104
105        return (string) $result;
106    }
107
108    public function size(string $path): int
109    {
110        $key = $this->buildKey($path);
111        $result = $this->run(fn(): mixed => $this->client->headObject([
112            'Bucket' => $this->bucket,
113            'Key' => $key,
114        ]));
115
116        if (!$this->hasResultField($result, 'ContentLength')) {
117            throw new StorageException('Invalid S3 response while reading object size: ' . $key);
118        }
119
120        return (int) $this->getResultField($result, 'ContentLength');
121    }
122
123    /**
124     * @param callable(): mixed $callback
125     */
126    private function run(callable $callback): mixed
127    {
128        try {
129            return $callback();
130        } catch (Throwable $throwable) {
131            throw new StorageException(message: $throwable->getMessage(), previous: $throwable);
132        }
133    }
134
135    private function buildKey(string $path): string
136    {
137        $normalizedPath = ltrim(string: str_replace(search: '\\', replace: '/', subject: trim($path)), characters: '/');
138        if ($normalizedPath === '') {
139            throw new StorageException('Invalid storage path.');
140        }
141
142        $segments = explode('/', $normalizedPath);
143        foreach ($segments as $segment) {
144            if ($segment === '..' || $segment === '') {
145                throw new StorageException('Invalid storage path.');
146            }
147        }
148
149        if ($this->prefix === '') {
150            return $normalizedPath;
151        }
152
153        return trim(string: $this->prefix, characters: '/') . '/' . $normalizedPath;
154    }
155
156    private function hasResultField(mixed $result, string $field): bool
157    {
158        if (is_array($result)) {
159            return array_key_exists($field, $result);
160        }
161
162        if ($result instanceof ArrayAccess) {
163            return $result->offsetExists($field);
164        }
165
166        return false;
167    }
168
169    private function getResultField(mixed $result, string $field): mixed
170    {
171        if (is_array($result)) {
172            return $result[$field] ?? null;
173        }
174
175        if ($result instanceof ArrayAccess) {
176            return $result->offsetGet($field);
177        }
178
179        return null;
180    }
181}