Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve error reporting and add parsing error message to Exception and ignore JSON_THROW_ON_ERROR option (available as of PHP 7.3) #14

Merged
merged 3 commits into from
Jan 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/Decoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
53 changes: 31 additions & 22 deletions src/Encoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
}
Expand Down
41 changes: 36 additions & 5 deletions tests/DecoderTest.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php

use React\Stream\ReadableResourceStream;
use Clue\React\NDJson\Decoder;
use React\Stream\ThroughStream;

class DecoderTest extends TestCase
{
Expand All @@ -10,10 +10,7 @@ class DecoderTest extends TestCase

public function setUp()
{
$stream = fopen('php://temp', 'r');
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();

$this->input = new ReadableResourceStream($stream, $loop);
$this->input = new ThroughStream();
$this->decoder = new Decoder($this->input);
}

Expand Down Expand Up @@ -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());

Expand Down
68 changes: 68 additions & 0 deletions tests/EncoderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down