friendship ended with social-app. php is my new best friend
1<?php
2/**
3 * Class StreamUtil
4 *
5 * @created 21.07.2023
6 * @author smiley <smiley@chillerlan.net>
7 * @copyright 2023 smiley
8 * @license MIT
9 */
10declare(strict_types=1);
11
12namespace chillerlan\HTTP\Utils;
13
14use Psr\Http\Message\StreamInterface;
15use InvalidArgumentException, RuntimeException, Throwable;
16use function fopen, in_array, min, preg_match, restore_error_handler, set_error_handler,
17 sprintf, str_contains, stream_get_contents, strlen, substr;
18
19final class StreamUtil{
20
21 /**
22 * Checks whether the given mode allows reading and writing
23 */
24 public static function modeAllowsReadWrite(string $mode):bool{
25 return str_contains(self::validateMode($mode), '+');
26 }
27
28 /**
29 * Checks whether the given mode allows only reading
30 */
31 public static function modeAllowsReadOnly(string $mode):bool{
32 $mode = self::validateMode($mode);
33
34 return $mode[0] === 'r' && !str_contains($mode, '+');
35 }
36
37 /**
38 * Checks whether the given mode allows only writing
39 */
40 public static function modeAllowsWriteOnly(string $mode):bool{
41 $mode = self::validateMode($mode);
42
43 return in_array($mode[0], ['a', 'c', 'w', 'x'], true) && !str_contains($mode, '+');
44 }
45
46 /**
47 * Checks whether the given mode allows reading
48 */
49 public static function modeAllowsRead(string $mode):bool{
50 $mode = self::validateMode($mode);
51
52 return $mode[0] === 'r' || (in_array($mode[0], ['a', 'c', 'w', 'x'], true) && str_contains($mode, '+'));
53 }
54
55 /**
56 * Checks whether the given mode allows writing
57 */
58 public static function modeAllowsWrite(string $mode):bool{
59 $mode = self::validateMode($mode);
60
61 return in_array($mode[0], ['a', 'c', 'w', 'x'], true) || ($mode[0] === 'r' && str_contains($mode, '+'));
62 }
63
64 /**
65 * Checks if the given mode is valid for fopen().
66 * Returns the first 15 characters, throws if that string doesn't match the pattern.
67 *
68 * Note: we don't care where the modifier flags are in the string, what matters is that the first character
69 * is one of "acrwx" and the rest may contain one of "bet+" from 2nd position onwards, so "aaaaaaaaaaaaaa+b" is valid.
70 *
71 * The documentation of fopen() says that the text-mode translation flag (b/t) should be added as last character,
72 * however, it doesn't matter as PHP internally only reads the mode from the first character and 15 characters total.
73 * and does a strchr() on it for the flags, so technically "rb+" is equivalent to "r+b" and "rrrbbbb++".
74 * Also, some libraries allow a mode "rw" which is wrong and just falls back to "r" - see above. (looking at you, Guzzle)
75 *
76 * gzopen() adds a bunch of other flags that are hardly documented, so we'll ignore these until we get a full list.
77 *
78 * @see https://www.php.net/manual/en/function.fopen
79 * @see https://www.php.net/manual/en/function.gzopen.php
80 * @see https://stackoverflow.com/a/44483367/3185624
81 * @see https://github.com/php/php-src/blob/6602ddead5c81fb67ebf2b21c32b58aa1de67699/main/streams/plain_wrapper.c#L71-L121
82 * @see https://github.com/guzzle/psr7/blob/815698d9f11c908bc59471d11f642264b533346a/src/Stream.php#L19
83 *
84 * @throws \InvalidArgumentException
85 */
86 public static function validateMode(string $mode):string{
87 $mode = substr($mode, 0, 15);
88
89 if(!preg_match('/^[acrwx]+[befht+\d]*$/', $mode)){ // [bet+]*
90 throw new InvalidArgumentException('invalid fopen mode: '.$mode);
91 }
92
93 return $mode;
94 }
95
96 /**
97 * Reads the content from a stream and make sure we rewind
98 *
99 * Returns the stream content as a string, null if an error occurs, e.g. the StreamInterface throws.
100 */
101 public static function getContents(StreamInterface $stream):string|null{
102
103 // rewind before read...
104 if($stream->isSeekable()){
105 $stream->rewind();
106 }
107
108 try{
109 $data = $stream->isReadable()
110 // stream is readable - great!
111 ? $stream->getContents()
112 // try the __toString() method
113 // there's a chance the stream is implemented in such a way (might throw)
114 : $stream->__toString(); // @codeCoverageIgnore
115 }
116 catch(Throwable){
117 return null;
118 }
119
120 // ...and after
121 if($stream->isSeekable()){
122 $stream->rewind();
123 }
124
125 return $data;
126 }
127
128 /**
129 * Copies a stream to another stream, starting from the current position of the source stream,
130 * reading to the end or until the given maxlength is hit.
131 *
132 * Throws if the source is not readable or the destination not writable.
133 *
134 * @see https://github.com/guzzle/psr7/blob/815698d9f11c908bc59471d11f642264b533346a/src/Utils.php#L36-L69
135 *
136 * @throws \RuntimeException
137 */
138 public static function copyToStream(StreamInterface $source, StreamInterface $destination, int|null $maxLength = null):int{
139
140 if(!$source->isReadable() || !$destination->isWritable()){
141 throw new RuntimeException('$source must be readable and $destination must be writable');
142 }
143
144 $remaining = ($maxLength ?? ($source->getSize() - $source->tell()));
145 $bytesRead = 0;
146
147 while($remaining > 0 && !$source->eof()){
148 $chunk = $source->read(min(8192, $remaining));
149 $length = strlen($chunk);
150 $bytesRead += $length;
151
152 if($length === 0){
153 break; // @codeCoverageIgnore
154 }
155
156 $remaining -= $length;
157 $destination->write($chunk);
158 }
159
160 return $bytesRead;
161 }
162
163 /**
164 * Safely open a PHP resource, throws instead of raising warnings and errors
165 *
166 * @see https://github.com/guzzle/psr7/blob/815698d9f11c908bc59471d11f642264b533346a/src/Utils.php#L344-L391
167 *
168 * @param resource|null $context
169 *
170 * @return resource
171 * @throws \RuntimeException
172 */
173 public static function tryFopen(string $filename, string $mode, mixed $context = null){
174 $mode = self::validateMode($mode);
175 $exception = null;
176 $message = 'Unable to open "%s" using mode "%s": %s';
177
178 $errorHandler = function(int $errno, string $errstr) use ($filename, $mode, &$exception, $message):bool{
179 $exception = new RuntimeException(sprintf($message, $filename, $mode, $errstr));
180
181 return true;
182 };
183
184 set_error_handler($errorHandler);
185
186 try{
187 /** @var resource $handle */
188 $handle = fopen(filename: $filename, mode: $mode, context: $context);
189 }
190 catch(Throwable $e){
191 $exception = new RuntimeException(message: sprintf($message, $filename, $mode, $e->getMessage()), previous: $e);
192 }
193
194 restore_error_handler();
195
196 if($exception !== null){
197 throw $exception;
198 }
199
200 return $handle;
201 }
202
203 /**
204 * Safely get the contents of a stream resource, throws instead of raising warnings and errors
205 *
206 * @see https://github.com/guzzle/psr7/blob/815698d9f11c908bc59471d11f642264b533346a/src/Utils.php#L393-L438
207 *
208 * @param resource $stream
209
210 * @throws \RuntimeException
211 */
212 public static function tryGetContents($stream, int|null $length = null, int $offset = -1):string{
213 $exception = null;
214 $message = 'Unable to read stream contents: %s';
215
216 $errorHandler = function(int $errno, string $errstr) use (&$exception, $message):bool{
217 $exception = new RuntimeException(sprintf($message, $errstr));
218
219 return true;
220 };
221
222 set_error_handler($errorHandler);
223
224 try{
225 $contents = stream_get_contents($stream, $length, $offset);
226
227 if($contents === false){
228 $exception = new RuntimeException(sprintf($message, '(returned false)'));
229 }
230
231 }
232 catch(Throwable $e){
233 $exception = new RuntimeException(message: sprintf($message, $e->getMessage()), previous: $e);
234 }
235
236 restore_error_handler();
237
238 if($exception !== null){
239 throw $exception;
240 }
241
242 return $contents;
243 }
244
245}