Issues (5)

src/JsonStream.php (5 issues)

1
<?php
2
3
namespace Violet\StreamingJsonEncoder;
4
5
use Psr\Http\Message\StreamInterface;
6
7
/**
8
 * Provides a http stream interface for encoding JSON.
9
 *
10
 * @author Riikka Kalliomäki <[email protected]>
11
 * @copyright Copyright (c) 2016-2020 Riikka Kalliomäki
12
 * @license http://opensource.org/licenses/mit-license.php MIT License
13
 */
14
class JsonStream implements StreamInterface
15
{
16
    /** @var BufferJsonEncoder|null The encoder used to produce the JSON stream or null once closed */
17
    private $encoder;
18
19
    /** @var int The current position of the cursor in the JSON stream */
20
    private $cursor;
21
22
    /** @var string|null Buffered output from encoding the value as JSON or null when at EOF */
23
    private $buffer;
24
25
    /**
26
     * JsonStream constructor.
27
     * @param BufferJsonEncoder|mixed $value A JSON encoder to use or a value to encode
28
     */
29
    public function __construct($value)
30
    {
31
        if (!$value instanceof BufferJsonEncoder) {
32
            $value = (new BufferJsonEncoder($value))
33
                ->setOptions(JSON_PARTIAL_OUTPUT_ON_ERROR)
34
                ->setIndent(0);
35
        }
36
37
        $this->encoder = $value;
38
        $this->rewind();
39
    }
40
41
    /**
42
     * Returns the JSON encoder used for the JSON stream.
43
     * @return BufferJsonEncoder The currently used JSON encoder
44
     * @throws \RuntimeException If the stream has been closed
45
     */
46
    private function getEncoder()
47
    {
48
        if (!$this->encoder instanceof BufferJsonEncoder) {
49
            throw new \RuntimeException('Cannot operate on a closed JSON stream');
50
        }
51
52
        return $this->encoder;
53
    }
54
55
    /**
56
     * Returns the entire JSON stream as a string.
57
     *
58
     * Note that this operation performs rewind operation on the JSON encoder. Whether
59
     * this works or not is dependant on the underlying value being encoded. An empty
60
     * string is returned if the value cannot be encoded.
61
     *
62
     * @return string The entire JSON stream as a string
63
     */
64
    public function __toString()
65
    {
66
        try {
67
            $this->rewind();
68
            return $this->getContents();
69
        } catch (\Exception $exception) {
70
            return '';
71
        }
72
    }
73
74
    /**
75
     * Frees the JSON encoder from memory and prevents further reading from the JSON stream.
76
     */
77
    public function close()
78
    {
79
        $this->encoder = null;
80
    }
81
82
    /**
83
     * Detaches the underlying PHP resource from the stream and returns it.
84
     * @return null Always returns null as no underlying PHP resource exists
85
     */
86
    public function detach()
87
    {
88
        return null;
89
    }
90
91
    /**
92
     * Returns the total size of the JSON stream.
93
     * @return null Always returns null as the total size cannot be determined
94
     */
95
    public function getSize()
96
    {
97
        return null;
98
    }
99
100
    /**
101
     * Returns the current position of the cursor in the JSON stream.
102
     * @return int Current position of the cursor
103
     */
104
    public function tell()
105
    {
106
        $this->getEncoder();
107
        return $this->cursor;
108
    }
109
110
    /**
111
     * Tells if there are no more bytes to read from the JSON stream.
112
     * @return bool True if there are no more bytes to read, false if there are
113
     */
114
    public function eof()
115
    {
116
        return $this->buffer === null;
117
    }
118
119
    /**
120
     * Tells if the JSON stream is seekable or not.
121
     * @return bool Always returns true as JSON streams as always seekable
122
     */
123
    public function isSeekable()
124
    {
125
        return true;
126
    }
127
128
    /**
129
     * Seeks the given cursor position in the JSON stream.
130
     *
131
     * If the provided seek position is less than the current cursor position, a rewind
132
     * operation is performed on the underlying JSON encoder. Whether this works or not
133
     * depends on whether the encoded value supports rewinding.
134
     *
135
     * Note that since it's not possible to determine the end of the JSON stream without
136
     * encoding the entire value, it's not possible to set the cursor using SEEK_END
137
     * constant and doing so will result in an exception.
138
     *
139
     * @param int $offset The offset for the cursor
140
     * @param int $whence Either SEEK_CUR or SEEK_SET to determine new cursor position
141
     * @throws \RuntimeException If SEEK_END is used to determine the cursor position
142
     */
143
    public function seek($offset, $whence = SEEK_SET)
144
    {
145
        $position = $this->calculatePosition($offset, $whence);
146
147
        if (!isset($this->cursor) || $position < $this->cursor) {
148
            $this->getEncoder()->rewind();
149
            $this->buffer = '';
150
            $this->cursor = 0;
151
        }
152
153
        $this->forward($position);
154
    }
155
156
    /**
157
     * Calculates new position for the cursor based on offset and whence.
158
     * @param int $offset The cursor offset
159
     * @param int $whence One of the SEEK_* constants
160
     * @return int The new cursor position
161
     * @throws \RuntimeException If SEEK_END is used to determine the cursor position
162
     */
163
    private function calculatePosition($offset, $whence)
164
    {
165
        if ($whence === SEEK_CUR) {
166
            return max(0, $this->cursor + (int) $offset);
167
        } elseif ($whence === SEEK_SET) {
168
            return max(0, (int) $offset);
169
        } elseif ($whence === SEEK_END) {
170
            throw new \RuntimeException('Cannot set cursor position from the end of a JSON stream');
171
        }
172
173
        throw new \InvalidArgumentException("Invalid cursor relative position '$whence'");
174
    }
175
176
    /**
177
     * Forwards the JSON stream reading cursor to the given position or to the end of stream.
178
     * @param int $position The new position of the cursor
179
     */
180
    private function forward($position)
181
    {
182
        $encoder = $this->getEncoder();
183
184
        while ($this->cursor < $position) {
185
            $length = strlen($this->buffer);
0 ignored issues
show
It seems like $this->buffer can also be of type null; however, parameter $string of strlen() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

185
            $length = strlen(/** @scrutinizer ignore-type */ $this->buffer);
Loading history...
186
187
            if ($this->cursor + $length > $position) {
188
                $this->buffer = substr($this->buffer, $position - $this->cursor);
0 ignored issues
show
It seems like $this->buffer can also be of type null; however, parameter $string of substr() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

188
                $this->buffer = substr(/** @scrutinizer ignore-type */ $this->buffer, $position - $this->cursor);
Loading history...
189
                $this->cursor = $position;
190
                break;
191
            }
192
193
            $this->cursor += $length;
194
            $this->buffer = '';
195
196
            if (!$encoder->valid()) {
197
                $this->buffer = null;
198
                break;
199
            }
200
201
            $this->buffer = $encoder->current();
202
            $encoder->next();
203
        }
204
    }
205
206
    /**
207
     * Seeks the beginning of the JSON stream.
208
     *
209
     * If the encoding has already been started, rewinding the encoder may not work,
210
     * if the underlying value being encoded does not support rewinding.
211
     */
212
    public function rewind()
213
    {
214
        $this->seek(0);
215
    }
216
217
    /**
218
     * Tells if the JSON stream is writable or not.
219
     * @return bool Always returns false as JSON streams are never writable
220
     */
221
    public function isWritable()
222
    {
223
        return false;
224
    }
225
226
    /**
227
     * Writes the given bytes to the JSON stream.
228
     *
229
     * As the JSON stream does not represent a writable stream, this method will
230
     * always throw a runtime exception.
231
     *
232
     * @param string $string The bytes to write
233
     * @return int The number of bytes written
234
     * @throws \RuntimeException Always throws a runtime exception
235
     */
236
    public function write($string)
237
    {
238
        throw new \RuntimeException('Cannot write to a JSON stream');
239
    }
240
241
    /**
242
     * Tells if the JSON stream is readable or not.
243
     * @return bool Always returns true as JSON streams are always readable
244
     */
245
    public function isReadable()
246
    {
247
        return true;
248
    }
249
250
    /**
251
     * Returns the given number of bytes from the JSON stream.
252
     *
253
     * The underlying value is encoded into JSON until enough bytes have been
254
     * generated to fulfill the requested number of bytes. The extraneous bytes are
255
     * then buffered for the next read from the JSON stream. The stream may return
256
     * fewer number of bytes if the entire value has been encoded and there are no
257
     * more bytes to return.
258
     *
259
     * @param int $length The number of bytes to return
260
     * @return string The bytes read from the JSON stream
261
     */
262
    public function read($length)
263
    {
264
        if ($this->eof()) {
265
            return '';
266
        }
267
268
        $length = max(0, (int) $length);
269
        $encoder = $this->getEncoder();
270
271
        while (strlen($this->buffer) < $length && $encoder->valid()) {
272
            $this->buffer .= $encoder->current();
273
            $encoder->next();
274
        }
275
276
        if (strlen($this->buffer) > $length || $encoder->valid()) {
277
            $output = substr($this->buffer, 0, $length);
278
            $this->buffer = substr($this->buffer, $length);
279
        } else {
280
            $output = (string) $this->buffer;
281
            $this->buffer = null;
282
        }
283
284
        $this->cursor += strlen($output);
285
286
        return $output;
287
    }
288
289
    /**
290
     * Returns the remaining bytes from the JSON stream.
291
     * @return string The remaining bytes from JSON stream
292
     */
293
    public function getContents()
294
    {
295
        if ($this->eof()) {
296
            return '';
297
        }
298
299
        $encoder = $this->getEncoder();
300
        $output = $this->buffer;
301
302
        while ($encoder->valid()) {
303
            $output .= $encoder->current();
304
            $encoder->next();
305
        }
306
307
        $this->cursor += strlen($output);
308
        $this->buffer = null;
309
310
        return $output;
311
    }
312
313
    /**
314
     * Returns the metadata related to the JSON stream.
315
     *
316
     * No underlying PHP resource exists for the stream, but this method will
317
     * still return relevant information regarding the stream that is similar
318
     * to PHP stream meta data.
319
     *
320
     * @param string|null $key The key of the value to return
321
     * @return array|mixed|null The meta data array, a specific value or null if not defined
322
     */
323
    public function getMetadata($key = null)
324
    {
325
        $meta = [
326
            'timed_out' => false,
327
            'blocked' => true,
328
            'eof' => $this->eof(),
329
            'unread_bytes' => strlen($this->buffer),
0 ignored issues
show
It seems like $this->buffer can also be of type null; however, parameter $string of strlen() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

329
            'unread_bytes' => strlen(/** @scrutinizer ignore-type */ $this->buffer),
Loading history...
330
            'stream_type' => get_class($this->encoder),
0 ignored issues
show
It seems like $this->encoder can also be of type null; however, parameter $object of get_class() does only seem to accept object, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

330
            'stream_type' => get_class(/** @scrutinizer ignore-type */ $this->encoder),
Loading history...
331
            'wrapper_type' => 'OBJECT',
332
            'wrapper_data' => [
333
                'step' => $this->encoder->key(),
0 ignored issues
show
The method key() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

333
                'step' => $this->encoder->/** @scrutinizer ignore-call */ key(),

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
334
                'errors' => $this->encoder->getErrors(),
335
            ],
336
            'mode' => 'r',
337
            'seekable' => true,
338
            'uri' => '',
339
        ];
340
341
        return $key === null ? $meta : (isset($meta[$key]) ? $meta[$key] : null);
342
    }
343
}
344