1<?php 2 3declare(strict_types=1); 4 5namespace GuzzleHttp\Psr7; 6 7use Psr\Http\Message\StreamInterface; 8 9/** 10 * Reads from multiple streams, one after the other. 11 * 12 * This is a read-only stream decorator. 13 */ 14final class AppendStream implements StreamInterface 15{ 16 /** @var StreamInterface[] Streams being decorated */ 17 private $streams = []; 18 19 /** @var bool */ 20 private $seekable = true; 21 22 /** @var int */ 23 private $current = 0; 24 25 /** @var int */ 26 private $pos = 0; 27 28 /** 29 * @param StreamInterface[] $streams Streams to decorate. Each stream must 30 * be readable. 31 */ 32 public function __construct(array $streams = []) 33 { 34 foreach ($streams as $stream) { 35 $this->addStream($stream); 36 } 37 } 38 39 public function __toString(): string 40 { 41 try { 42 $this->rewind(); 43 44 return $this->getContents(); 45 } catch (\Throwable $e) { 46 if (\PHP_VERSION_ID >= 70400) { 47 throw $e; 48 } 49 trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR); 50 51 return ''; 52 } 53 } 54 55 /** 56 * Add a stream to the AppendStream 57 * 58 * @param StreamInterface $stream Stream to append. Must be readable. 59 * 60 * @throws \InvalidArgumentException if the stream is not readable 61 */ 62 public function addStream(StreamInterface $stream): void 63 { 64 if (!$stream->isReadable()) { 65 throw new \InvalidArgumentException('Each stream must be readable'); 66 } 67 68 // The stream is only seekable if all streams are seekable 69 if (!$stream->isSeekable()) { 70 $this->seekable = false; 71 } 72 73 $this->streams[] = $stream; 74 } 75 76 public function getContents(): string 77 { 78 return Utils::copyToString($this); 79 } 80 81 /** 82 * Closes each attached stream. 83 */ 84 public function close(): void 85 { 86 $this->pos = $this->current = 0; 87 $this->seekable = true; 88 89 foreach ($this->streams as $stream) { 90 $stream->close(); 91 } 92 93 $this->streams = []; 94 } 95 96 /** 97 * Detaches each attached stream. 98 * 99 * Returns null as it's not clear which underlying stream resource to return. 100 */ 101 public function detach() 102 { 103 $this->pos = $this->current = 0; 104 $this->seekable = true; 105 106 foreach ($this->streams as $stream) { 107 $stream->detach(); 108 } 109 110 $this->streams = []; 111 112 return null; 113 } 114 115 public function tell(): int 116 { 117 return $this->pos; 118 } 119 120 /** 121 * Tries to calculate the size by adding the size of each stream. 122 * 123 * If any of the streams do not return a valid number, then the size of the 124 * append stream cannot be determined and null is returned. 125 */ 126 public function getSize(): ?int 127 { 128 $size = 0; 129 130 foreach ($this->streams as $stream) { 131 $s = $stream->getSize(); 132 if ($s === null) { 133 return null; 134 } 135 $size += $s; 136 } 137 138 return $size; 139 } 140 141 public function eof(): bool 142 { 143 return !$this->streams 144 || ($this->current >= count($this->streams) - 1 145 && $this->streams[$this->current]->eof()); 146 } 147 148 public function rewind(): void 149 { 150 $this->seek(0); 151 } 152 153 /** 154 * Attempts to seek to the given position. Only supports SEEK_SET. 155 */ 156 public function seek($offset, $whence = SEEK_SET): void 157 { 158 if (!$this->seekable) { 159 throw new \RuntimeException('This AppendStream is not seekable'); 160 } elseif ($whence !== SEEK_SET) { 161 throw new \RuntimeException('The AppendStream can only seek with SEEK_SET'); 162 } 163 164 $this->pos = $this->current = 0; 165 166 // Rewind each stream 167 foreach ($this->streams as $i => $stream) { 168 try { 169 $stream->rewind(); 170 } catch (\Exception $e) { 171 throw new \RuntimeException('Unable to seek stream ' 172 .$i.' of the AppendStream', 0, $e); 173 } 174 } 175 176 // Seek to the actual position by reading from each stream 177 while ($this->pos < $offset && !$this->eof()) { 178 $result = $this->read(min(8096, $offset - $this->pos)); 179 if ($result === '') { 180 break; 181 } 182 } 183 } 184 185 /** 186 * Reads from all of the appended streams until the length is met or EOF. 187 */ 188 public function read($length): string 189 { 190 $buffer = ''; 191 $total = count($this->streams) - 1; 192 $remaining = $length; 193 $progressToNext = false; 194 195 while ($remaining > 0) { 196 // Progress to the next stream if needed. 197 if ($progressToNext || $this->streams[$this->current]->eof()) { 198 $progressToNext = false; 199 if ($this->current === $total) { 200 break; 201 } 202 ++$this->current; 203 } 204 205 $result = $this->streams[$this->current]->read($remaining); 206 207 if ($result === '') { 208 $progressToNext = true; 209 continue; 210 } 211 212 $buffer .= $result; 213 $remaining = $length - strlen($buffer); 214 } 215 216 $this->pos += strlen($buffer); 217 218 return $buffer; 219 } 220 221 public function isReadable(): bool 222 { 223 return true; 224 } 225 226 public function isWritable(): bool 227 { 228 return false; 229 } 230 231 public function isSeekable(): bool 232 { 233 return $this->seekable; 234 } 235 236 public function write($string): int 237 { 238 throw new \RuntimeException('Cannot write to an AppendStream'); 239 } 240 241 /** 242 * @return mixed 243 */ 244 public function getMetadata($key = null) 245 { 246 return $key ? null : []; 247 } 248} 249