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}