diff --git a/src/Decoder.php b/src/Decoder.php index 5d79551..912a049 100644 --- a/src/Decoder.php +++ b/src/Decoder.php @@ -27,6 +27,9 @@ public function __construct(ReadableStreamInterface $input, $assoc = false, $dep if ($options !== 0 && PHP_VERSION < 5.4) { throw new \BadMethodCallException('Options parameter is only supported on PHP 5.4+'); } + if (defined('JSON_THROW_ON_ERROR')) { + $options = $options & ~JSON_THROW_ON_ERROR; + } // @codeCoverageIgnoreEnd $this->input = $input; @@ -95,17 +98,24 @@ public function handleData($data) $this->buffer = (string)substr($this->buffer, $newline + 1); // decode data with options given in ctor - // @codeCoverageIgnoreStart if ($this->options === 0) { $data = json_decode($data, $this->assoc, $this->depth); } else { $data = json_decode($data, $this->assoc, $this->depth, $this->options); } - // @codeCoverageIgnoreEnd // abort stream if decoding failed if ($data === null && json_last_error() !== JSON_ERROR_NONE) { - return $this->handleError(new \RuntimeException('Unable to decode JSON', json_last_error())); + // @codeCoverageIgnoreStart + if (PHP_VERSION_ID > 50500) { + $errstr = json_last_error_msg(); + } elseif (json_last_error() === JSON_ERROR_SYNTAX) { + $errstr = 'Syntax error'; + } else { + $errstr = 'Unknown error'; + } + // @codeCoverageIgnoreEnd + return $this->handleError(new \RuntimeException('Unable to decode JSON: ' . $errstr, json_last_error())); } $this->emit('data', array($data)); diff --git a/src/Encoder.php b/src/Encoder.php index f7dcad1..37070b8 100644 --- a/src/Encoder.php +++ b/src/Encoder.php @@ -25,6 +25,9 @@ public function __construct(WritableStreamInterface $output, $options = 0, $dept if ($depth !== 512 && PHP_VERSION < 5.5) { throw new \BadMethodCallException('Depth parameter is only supported on PHP 5.5+'); } + if (defined('JSON_THROW_ON_ERROR')) { + $options = $options & ~JSON_THROW_ON_ERROR; + } // @codeCoverageIgnoreEnd $this->output = $output; @@ -47,40 +50,46 @@ public function write($data) return false; } - // we have to handle PHP warning for legacy PHP < 5.5 (see below) + // we have to handle PHP warnings for legacy PHP < 5.5 + // certain values (such as INF etc.) emit a warning, but still encode successfully // @codeCoverageIgnoreStart if (PHP_VERSION_ID < 50500) { - $found = null; - set_error_handler(function ($error) use (&$found) { - $found = $error; + $errstr = null; + set_error_handler(function ($_, $error) use (&$errstr) { + $errstr = $error; }); - } - // encode data with options given in ctor - if ($this->depth === 512) { + // encode data with options given in ctor (depth not supported) $data = json_encode($data, $this->options); - } else { - $data = json_encode($data, $this->options, $this->depth); - } - // legacy error handler for PHP < 5.5 - // certain values (such as INF etc.) emit a warning, but still encode successfully - if (PHP_VERSION_ID < 50500) { + // always check error code and match missing error messages restore_error_handler(); + $errno = json_last_error(); + if (defined('JSON_ERROR_UTF8') && $errno === JSON_ERROR_UTF8) { + // const JSON_ERROR_UTF8 added in PHP 5.3.3, but no error message assigned in legacy PHP < 5.5 + // this overrides PHP 5.3.14 only: https://3v4l.org/IGP8Z#v5314 + $errstr = 'Malformed UTF-8 characters, possibly incorrectly encoded'; + } elseif ($errno !== JSON_ERROR_NONE && $errstr === null) { + // error number present, but no error message applicable + $errstr = 'Unknown error'; + } - // emit an error event if a warning has been raised - if ($found !== null) { - $this->handleError(new \RuntimeException('Unable to encode JSON: ' . $found)); + // abort stream if encoding fails + if ($errno !== JSON_ERROR_NONE || $errstr !== null) { + $this->handleError(new \RuntimeException('Unable to encode JSON: ' . $errstr, $errno)); return false; } - } - // @codeCoverageIgnoreEnd + } else { + // encode data with options given in ctor + $data = json_encode($data, $this->options, $this->depth); - // abort stream if encoding fails - if ($data === false && json_last_error() !== JSON_ERROR_NONE) { - $this->handleError(new \RuntimeException('Unable to encode JSON', json_last_error())); - return false; + // abort stream if encoding fails + if ($data === false && json_last_error() !== JSON_ERROR_NONE) { + $this->handleError(new \RuntimeException('Unable to encode JSON: ' . json_last_error_msg(), json_last_error())); + return false; + } } + // @codeCoverageIgnoreEnd return $this->output->write($data . "\n"); } diff --git a/tests/DecoderTest.php b/tests/DecoderTest.php index 0ce48a6..2d335dc 100644 --- a/tests/DecoderTest.php +++ b/tests/DecoderTest.php @@ -1,7 +1,7 @@ getMockBuilder('React\EventLoop\LoopInterface')->getMock(); - - $this->input = new ReadableResourceStream($stream, $loop); + $this->input = new ThroughStream(); $this->decoder = new Decoder($this->input); } @@ -54,8 +51,42 @@ public function testEmitDataNullInMultipleChunksWillForward() $this->input->emit('data', array("\n")); } + public function testEmitDataBigIntOptionWillForwardAsString() + { + if (!defined('JSON_BIGINT_AS_STRING')) { + $this->markTestSkipped('Const JSON_BIGINT_AS_STRING only available in PHP 5.4+'); + } + $this->decoder = new Decoder($this->input, false, 512, JSON_BIGINT_AS_STRING); + $this->decoder->on('data', $this->expectCallableOnceWith($this->identicalTo('999888777666555444333222111000'))); + + $this->input->emit('data', array("999888777666555444333222111000\n")); + } + public function testEmitDataErrorWillForwardError() { + $this->decoder->on('data', $this->expectCallableNever()); + $error = null; + $this->decoder->on('error', function ($e) use (&$error) { + $error = $e; + }); + $this->decoder->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("invalid\n")); + + $this->assertInstanceOf('RuntimeException', $error); + $this->assertContains('Syntax error', $error->getMessage()); + $this->assertEquals(JSON_ERROR_SYNTAX, $error->getCode()); + } + + public function testEmitDataErrorWillForwardErrorAlsoWhenCreatedWithThrowOnError() + { + if (!defined('JSON_THROW_ON_ERROR')) { + $this->markTestSkipped('Const JSON_THROW_ON_ERROR only available in PHP 7.3+'); + } + + $this->input = new ThroughStream(); + $this->decoder = new Decoder($this->input, false, 512, JSON_THROW_ON_ERROR); + $this->decoder->on('data', $this->expectCallableNever()); $this->decoder->on('error', $this->expectCallableOnce()); diff --git a/tests/EncoderTest.php b/tests/EncoderTest.php index 059b6a7..0f109e2 100644 --- a/tests/EncoderTest.php +++ b/tests/EncoderTest.php @@ -60,6 +60,74 @@ public function testWriteInfiniteWillEmitErrorAndClose() $this->output->expects($this->never())->method('write'); + $error = null; + $this->encoder->on('error', function ($e) use (&$error) { + $error = $e; + }); + $this->encoder->on('error', $this->expectCallableOnce()); + $this->encoder->on('close', $this->expectCallableOnce()); + + $ret = $this->encoder->write(INF); + $this->assertFalse($ret); + + $this->assertFalse($this->encoder->isWritable()); + + $this->assertInstanceOf('RuntimeException', $error); + if (PHP_VERSION_ID >= 50500) { + // PHP 5.5+ reports error with proper code + $this->assertContains('Inf and NaN cannot be JSON encoded', $error->getMessage()); + $this->assertEquals(JSON_ERROR_INF_OR_NAN, $error->getCode()); + } else { + // PHP < 5.5 reports error message without code + $this->assertContains('double INF does not conform to the JSON spec', $error->getMessage()); + $this->assertEquals(0, $error->getCode()); + } + } + + public function testWriteInvalidUtf8WillEmitErrorAndClose() + { + $this->output = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $this->output->expects($this->once())->method('isWritable')->willReturn(true); + $this->encoder = new Encoder($this->output); + + $this->output->expects($this->never())->method('write'); + + $error = null; + $this->encoder->on('error', function ($e) use (&$error) { + $error = $e; + }); + $this->encoder->on('error', $this->expectCallableOnce()); + $this->encoder->on('close', $this->expectCallableOnce()); + + $ret = $this->encoder->write("\xfe"); + $this->assertFalse($ret); + + $this->assertFalse($this->encoder->isWritable()); + + $this->assertInstanceOf('RuntimeException', $error); + if (PHP_VERSION_ID >= 50500) { + // PHP 5.5+ reports error with proper code + $this->assertContains('Malformed UTF-8 characters, possibly incorrectly encoded', $error->getMessage()); + $this->assertEquals(JSON_ERROR_UTF8, $error->getCode()); + } elseif (PHP_VERSION_ID >= 50303) { + // PHP 5.3.3+ reports error with proper code (const JSON_ERROR_UTF8 added in PHP 5.3.3) + $this->assertContains('Malformed UTF-8 characters, possibly incorrectly encoded', $error->getMessage()); + $this->assertEquals(JSON_ERROR_UTF8, $error->getCode()); + } + } + + public function testWriteInfiniteWillEmitErrorAndCloseAlsoWhenCreatedWithThrowOnError() + { + if (!defined('JSON_THROW_ON_ERROR')) { + $this->markTestSkipped('Const JSON_THROW_ON_ERROR only available in PHP 7.3+'); + } + + $this->output = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $this->output->expects($this->once())->method('isWritable')->willReturn(true); + $this->encoder = new Encoder($this->output, JSON_THROW_ON_ERROR); + + $this->output->expects($this->never())->method('write'); + $this->encoder->on('error', $this->expectCallableOnce()); $this->encoder->on('close', $this->expectCallableOnce());