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