1: <?php declare(strict_types=1);
2:
3: namespace Salient\Http;
4:
5: use Psr\Http\Message\StreamInterface;
6: use Salient\Contract\Core\DateFormatterInterface;
7: use Salient\Contract\Http\FormDataFlag;
8: use Salient\Contract\Http\HttpMultipartStreamPartInterface;
9: use Salient\Contract\Http\HttpStreamInterface;
10: use Salient\Http\Exception\StreamDetachedException;
11: use Salient\Http\Exception\StreamEncapsulationException;
12: use Salient\Http\Exception\StreamInvalidRequestException;
13: use Salient\Utility\Exception\InvalidArgumentTypeException;
14: use Salient\Utility\File;
15: use Salient\Utility\Json;
16: use Salient\Utility\Str;
17: use InvalidArgumentException;
18:
19: /**
20: * A PSR-7 stream wrapper
21: *
22: * @api
23: */
24: class HttpStream implements HttpStreamInterface
25: {
26: protected const CHUNK_SIZE = 8192;
27:
28: protected ?string $Uri;
29: protected bool $IsReadable;
30: protected bool $IsWritable;
31: protected bool $IsSeekable;
32: /** @var resource|null */
33: protected $Stream;
34:
35: /**
36: * Creates a new HttpStream object
37: *
38: * @param resource $stream
39: */
40: public function __construct($stream)
41: {
42: if (!File::isStream($stream)) {
43: throw new InvalidArgumentTypeException(1, 'stream', 'resource (stream)', $stream);
44: }
45:
46: $meta = stream_get_meta_data($stream);
47:
48: $this->Uri = $meta['uri'] ?? null;
49: $this->IsReadable = strpbrk($meta['mode'], 'r+') !== false;
50: $this->IsWritable = strpbrk($meta['mode'], 'waxc+') !== false;
51: $this->IsSeekable = $meta['seekable'];
52: $this->Stream = $stream;
53: }
54:
55: /**
56: * @internal
57: */
58: public function __destruct()
59: {
60: $this->close();
61: }
62:
63: /**
64: * Creates a new HttpStream object from a string
65: */
66: public static function fromString(string $content): self
67: {
68: return new self(Str::toStream($content));
69: }
70:
71: /**
72: * Encapsulate arbitrarily nested data in a new HttpStream or
73: * HttpMultipartStream object
74: *
75: * @param mixed[]|object $data
76: * @param int-mask-of<FormDataFlag::*> $flags
77: */
78: public static function fromData(
79: $data,
80: int $flags = FormDataFlag::PRESERVE_NUMERIC_KEYS | FormDataFlag::PRESERVE_STRING_KEYS,
81: ?DateFormatterInterface $dateFormatter = null,
82: bool $asJson = false,
83: ?string $boundary = null
84: ): HttpStreamInterface {
85: $formData = new FormData($data);
86: if ($asJson) {
87: $callback = static function (object $value) {
88: if ($value instanceof HttpMultipartStreamPartInterface) {
89: throw new StreamEncapsulationException('Multipart data streams cannot be JSON-encoded');
90: }
91: return false;
92: };
93: $data = $formData->getData($flags, $dateFormatter, $callback);
94: return self::fromString(Json::stringify($data));
95: }
96:
97: $multipart = false;
98: $callback = static function (object $value) use (&$multipart) {
99: if ($value instanceof HttpMultipartStreamPartInterface) {
100: $multipart = true;
101: return $value;
102: }
103: return false;
104: };
105: $data = $formData->getValues($flags, $dateFormatter, $callback);
106:
107: if (!$multipart) {
108: /** @var string $content */
109: foreach ($data as [$name, $content]) {
110: $query[] = rawurlencode($name) . '=' . rawurlencode($content);
111: }
112: return self::fromString(implode('&', $query ?? []));
113: }
114:
115: /** @var string|HttpMultipartStreamPartInterface $content */
116: foreach ($data as [$name, $content]) {
117: if ($content instanceof HttpMultipartStreamPartInterface) {
118: $parts[] = $content->withName($name);
119: } else {
120: $parts[] = new HttpMultipartStreamPart($content, $name);
121: }
122: }
123: return new HttpMultipartStream($parts ?? [], $boundary);
124: }
125:
126: /**
127: * Copy data from a stream to a string
128: */
129: public static function copyToString(StreamInterface $from): string
130: {
131: $out = '';
132: while (!$from->eof()) {
133: $in = $from->read(static::CHUNK_SIZE);
134: if ($in === '') {
135: // @codeCoverageIgnoreStart
136: break;
137: // @codeCoverageIgnoreEnd
138: }
139: $out .= $in;
140: }
141: return $out;
142: }
143:
144: /**
145: * Copy data from one stream to another
146: */
147: public static function copyToStream(StreamInterface $from, StreamInterface $to): void
148: {
149: $out = '';
150: while (!$from->eof()) {
151: $in = $from->read(static::CHUNK_SIZE);
152: if ($in === '') {
153: break;
154: }
155: $out .= $in;
156: $out = substr($out, $to->write($out));
157: }
158: while ($out !== '') {
159: // @codeCoverageIgnoreStart
160: $out = substr($out, $to->write($out));
161: // @codeCoverageIgnoreEnd
162: }
163: }
164:
165: /**
166: * @inheritDoc
167: */
168: public function isReadable(): bool
169: {
170: return $this->IsReadable;
171: }
172:
173: /**
174: * @inheritDoc
175: */
176: public function isWritable(): bool
177: {
178: return $this->IsWritable;
179: }
180:
181: /**
182: * @inheritDoc
183: */
184: public function isSeekable(): bool
185: {
186: return $this->IsSeekable;
187: }
188:
189: /**
190: * @inheritDoc
191: */
192: public function getSize(): ?int
193: {
194: $this->assertHasStream();
195:
196: clearstatcache();
197:
198: return File::stat($this->Stream, $this->Uri)['size'] ?? null;
199: }
200:
201: /**
202: * @inheritDoc
203: */
204: public function getMetadata(?string $key = null)
205: {
206: $this->assertHasStream();
207:
208: $meta = stream_get_meta_data($this->Stream);
209:
210: return $key === null ? $meta : ($meta[$key] ?? null);
211: }
212:
213: /**
214: * @inheritDoc
215: */
216: public function __toString(): string
217: {
218: if ($this->IsSeekable) {
219: $this->rewind();
220: }
221:
222: return $this->getContents();
223: }
224:
225: /**
226: * @inheritDoc
227: */
228: public function getContents(): string
229: {
230: $this->assertIsReadable();
231:
232: return File::getContents($this->Stream, null, $this->Uri);
233: }
234:
235: /**
236: * @inheritDoc
237: */
238: public function tell(): int
239: {
240: $this->assertHasStream();
241:
242: return File::tell($this->Stream, $this->Uri);
243: }
244:
245: /**
246: * @inheritDoc
247: */
248: public function eof(): bool
249: {
250: $this->assertHasStream();
251:
252: return @feof($this->Stream);
253: }
254:
255: /**
256: * @inheritDoc
257: */
258: public function rewind(): void
259: {
260: $this->assertIsSeekable();
261:
262: File::rewind($this->Stream, $this->Uri);
263: }
264:
265: /**
266: * @inheritDoc
267: */
268: public function read(int $length): string
269: {
270: $this->assertIsReadable();
271:
272: if ($length === 0) {
273: return '';
274: }
275:
276: if ($length < 0) {
277: throw new InvalidArgumentException('Argument #1 ($length) must be greater than or equal to 0');
278: }
279:
280: return File::read($this->Stream, $length, $this->Uri);
281: }
282:
283: /**
284: * @inheritDoc
285: */
286: public function write(string $string): int
287: {
288: $this->assertHasStream();
289:
290: if (!$this->IsWritable) {
291: throw new StreamInvalidRequestException('Stream is not open for writing');
292: }
293:
294: return File::write($this->Stream, $string, null, $this->Uri);
295: }
296:
297: /**
298: * @param \SEEK_SET|\SEEK_CUR|\SEEK_END $whence
299: */
300: public function seek(int $offset, int $whence = \SEEK_SET): void
301: {
302: $this->assertIsSeekable();
303:
304: /** @disregard P1006 */
305: File::seek($this->Stream, $offset, $whence, $this->Uri);
306: }
307:
308: /**
309: * @inheritDoc
310: */
311: public function close(): void
312: {
313: if (!$this->Stream) {
314: return;
315: }
316:
317: File::close($this->Stream, $this->Uri);
318: $this->detach();
319: }
320:
321: /**
322: * @inheritDoc
323: */
324: public function detach()
325: {
326: if (!$this->Stream) {
327: return null;
328: }
329:
330: $result = $this->Stream;
331:
332: $this->Stream = null;
333: $this->Uri = null;
334: $this->IsReadable = false;
335: $this->IsWritable = false;
336: $this->IsSeekable = false;
337:
338: return $result;
339: }
340:
341: /**
342: * @phpstan-assert !null $this->Stream
343: * @phpstan-assert true $this->IsSeekable
344: */
345: protected function assertIsSeekable(): void
346: {
347: $this->assertHasStream();
348:
349: if (!$this->IsSeekable) {
350: throw new StreamInvalidRequestException('Stream is not seekable');
351: }
352: }
353:
354: /**
355: * @phpstan-assert !null $this->Stream
356: * @phpstan-assert true $this->IsReadable
357: */
358: protected function assertIsReadable(): void
359: {
360: $this->assertHasStream();
361:
362: if (!$this->IsReadable) {
363: throw new StreamInvalidRequestException('Stream is not open for reading');
364: }
365: }
366:
367: /**
368: * @phpstan-assert !null $this->Stream
369: */
370: protected function assertHasStream(): void
371: {
372: if (!$this->Stream) {
373: throw new StreamDetachedException('Stream is detached');
374: }
375: }
376: }
377: