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