diff --git a/src/ChunkedDecoder.php b/src/ChunkedDecoder.php new file mode 100644 index 00000000..1d3bc16f --- /dev/null +++ b/src/ChunkedDecoder.php @@ -0,0 +1,158 @@ +input = $input; + + $this->input->on('data', array($this, 'handleData')); + $this->input->on('end', array($this, 'handleEnd')); + $this->input->on('error', array($this, 'handleError')); + $this->input->on('close', array($this, 'close')); + } + + public function isReadable() + { + return !$this->closed && $this->input->isReadable(); + } + + public function pause() + { + $this->input->pause(); + } + + public function resume() + { + $this->input->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->buffer = ''; + + $this->closed = true; + + $this->input->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + + /** @internal */ + public function handleEnd() + { + if (!$this->closed) { + $this->handleError(new \Exception('Unexpected end event')); + } + } + + /** @internal */ + public function handleError(\Exception $e) + { + $this->emit('error', array($e)); + $this->close(); + } + + /** @internal */ + public function handleData($data) + { + $this->buffer .= $data; + + while ($this->buffer !== '') { + if (!$this->headerCompleted) { + $positionCrlf = strpos($this->buffer, static::CRLF); + + if ($positionCrlf === false) { + // Header shouldn't be bigger than 1024 bytes + if (isset($this->buffer[static::MAX_CHUNK_HEADER_SIZE])) { + $this->handleError(new \Exception('Chunk header size inclusive extension bigger than' . static::MAX_CHUNK_HEADER_SIZE. ' bytes')); + } + return; + } + + $header = strtolower((string)substr($this->buffer, 0, $positionCrlf)); + $hexValue = $header; + + if (strpos($header, ';') !== false) { + $array = explode(';', $header); + $hexValue = $array[0]; + } + + $this->chunkSize = hexdec($hexValue); + if (dechex($this->chunkSize) !== $hexValue) { + $this->handleError(new \Exception($hexValue . ' is not a valid hexadecimal number')); + return; + } + + $this->buffer = (string)substr($this->buffer, $positionCrlf + 2); + $this->headerCompleted = true; + if ($this->buffer === '') { + return; + } + } + + $chunk = (string)substr($this->buffer, 0, $this->chunkSize - $this->transferredSize); + + if ($chunk !== '') { + $this->transferredSize += strlen($chunk); + $this->emit('data', array($chunk)); + $this->buffer = (string)substr($this->buffer, strlen($chunk)); + } + + $positionCrlf = strpos($this->buffer, static::CRLF); + + if ($positionCrlf === 0) { + if ($this->chunkSize === 0) { + $this->emit('end'); + $this->close(); + return; + } + $this->chunkSize = 0; + $this->headerCompleted = false; + $this->transferredSize = 0; + $this->buffer = (string)substr($this->buffer, 2); + } + + if ($positionCrlf !== 0 && $this->chunkSize === $this->transferredSize && strlen($this->buffer) > 2) { + // the first 2 characters are not CLRF, send error event + $this->handleError(new \Exception('Chunk does not end with a CLRF')); + return; + } + + if ($positionCrlf !== 0 && strlen($this->buffer) < 2) { + // No CLRF found, wait for additional data which could be a CLRF + return; + } + } + } +} diff --git a/src/Server.php b/src/Server.php index 1a33bcbd..a9c2d623 100644 --- a/src/Server.php +++ b/src/Server.php @@ -88,7 +88,7 @@ public function handleConnection(ConnectionInterface $conn) $that->handleRequest($conn, $request); if ($bodyBuffer !== '') { - $request->emit('data', array($bodyBuffer)); + $conn->emit('data', array($bodyBuffer)); } }); @@ -122,6 +122,15 @@ public function handleRequest(ConnectionInterface $conn, Request $request) '[]' ); + $stream = $conn; + if ($request->hasHeader('Transfer-Encoding')) { + $transferEncodingHeader = $request->getHeader('Transfer-Encoding'); + // 'chunked' must always be the final value of 'Transfer-Encoding' according to: https://tools.ietf.org/html/rfc7230#section-3.3.1 + if (strtolower(end($transferEncodingHeader)) === 'chunked') { + $stream = new ChunkedDecoder($conn); + } + } + // forward pause/resume calls to underlying connection $request->on('pause', array($conn, 'pause')); $request->on('resume', array($conn, 'resume')); @@ -133,10 +142,11 @@ public function handleRequest(ConnectionInterface $conn, Request $request) }); // forward connection events to request - $conn->on('end', function () use ($request) { + $stream->on('end', function () use ($request) { $request->emit('end'); }); - $conn->on('data', function ($data) use ($request) { + + $stream->on('data', function ($data) use ($request) { $request->emit('data', array($data)); }); diff --git a/tests/ChunkedDecoderTest.php b/tests/ChunkedDecoderTest.php new file mode 100644 index 00000000..6f0c3048 --- /dev/null +++ b/tests/ChunkedDecoderTest.php @@ -0,0 +1,399 @@ +input = new ReadableStream(); + $this->parser = new ChunkedDecoder($this->input); + } + + public function testSimpleChunk() + { + $this->parser->on('data', $this->expectCallableOnceWith('hello')); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableNever()); + + $this->input->emit('data', array("5\r\nhello\r\n")); + } + + public function testTwoChunks() + { + $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'bla'))); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableNever()); + + $this->input->emit('data', array("5\r\nhello\r\n3\r\nbla\r\n")); + } + + public function testEnd() + { + $this->parser->on('end', $this->expectCallableOnce()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("0\r\n\r\n")); + } + + public function testParameterWithEnd() + { + $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'bla'))); + $this->parser->on('end', $this->expectCallableOnce()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("5\r\nhello\r\n3\r\nbla\r\n0\r\n\r\n")); + } + + public function testInvalidChunk() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("bla\r\n")); + } + + public function testNeverEnd() + { + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("0\r\n")); + } + + public function testWrongChunkHex() + { + $this->parser->on('error', $this->expectCallableOnce()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + + $this->input->emit('data', array("2\r\na\r\n5\r\nhello\r\n")); + } + + public function testSplittedChunk() + { + $this->parser->on('data', $this->expectCallableOnceWith('welt')); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("4\r\n")); + $this->input->emit('data', array("welt\r\n")); + } + + public function testSplittedHeader() + { + $this->parser->on('data', $this->expectCallableOnceWith('welt')); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever());# + $this->parser->on('error', $this->expectCallableNever()); + + + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\nwelt\r\n")); + } + + public function testSplittedBoth() + { + $this->parser->on('data', $this->expectCallableOnceWith('welt')); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("welt\r\n")); + } + + public function testCompletlySplitted() + { + $this->parser->on('data', $this->expectCallableConsecutive(2, array('we', 'lt'))); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("we")); + $this->input->emit('data', array("lt\r\n")); + } + + public function testMixed() + { + $this->parser->on('data', $this->expectCallableConsecutive(3, array('we', 'lt', 'hello'))); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("we")); + $this->input->emit('data', array("lt\r\n")); + $this->input->emit('data', array("5\r\nhello\r\n")); + } + + public function testBigger() + { + $this->parser->on('data', $this->expectCallableConsecutive(2, array('abcdeabcdeabcdea', 'hello'))); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("1")); + $this->input->emit('data', array("0")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("abcdeabcdeabcdea\r\n")); + $this->input->emit('data', array("5\r\nhello\r\n")); + } + + public function testOneUnfinished() + { + $this->parser->on('data', $this->expectCallableConsecutive(2, array('bla', 'hello'))); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("3\r\n")); + $this->input->emit('data', array("bla\r\n")); + $this->input->emit('data', array("5\r\nhello")); + } + + public function testChunkIsBiggerThenExpected() + { + $this->parser->on('data', $this->expectCallableOnceWith('hello')); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("5\r\n")); + $this->input->emit('data', array("hello world\r\n")); + } + + public function testHandleUnexpectedEnd() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('end'); + } + + public function testExtensionWillBeIgnored() + { + $this->parser->on('data', $this->expectCallableOnceWith('bla')); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("3;hello=world;foo=bar\r\nbla")); + } + + public function testChunkHeaderIsTooBig() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $data = ''; + for ($i = 0; $i < 1025; $i++) { + $data .= 'a'; + } + $this->input->emit('data', array($data)); + } + + public function testChunkIsMaximumSize() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $data = ''; + for ($i = 0; $i < 1024; $i++) { + $data .= 'a'; + } + $data .= "\r\n"; + + $this->input->emit('data', array($data)); + } + + public function testLateCrlf() + { + $this->parser->on('data', $this->expectCallableOnceWith('late')); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("4\r\nlate")); + $this->input->emit('data', array("\r")); + $this->input->emit('data', array("\n")); + } + + public function testNoCrlfInChunk() + { + $this->parser->on('data', $this->expectCallableOnceWith('no')); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("2\r\nno crlf")); + } + + public function testNoCrlfInChunkSplitted() + { + $this->parser->on('data', $this->expectCallableOnceWith('no')); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("2\r\n")); + $this->input->emit('data', array("no")); + $this->input->emit('data', array("further")); + $this->input->emit('data', array("clrf")); + } + + public function testEmitEmptyChunkBody() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("2\r\n")); + $this->input->emit('data', array("")); + $this->input->emit('data', array("")); + } + + public function testEmitCrlfAsChunkBody() + { + $this->parser->on('data', $this->expectCallableOnceWith("\r\n")); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("2\r\n")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("\r\n")); + } + + public function testNegativeHeader() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("-2\r\n")); + } + + public function testHexDecimalInBodyIsPotentialThread() + { + $this->parser->on('data', $this->expectCallableOnce('test')); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("4\r\ntest5\r\nworld")); + } + + public function testHexDecimalInBodyIsPotentialThreadSplitted() + { + $this->parser->on('data', $this->expectCallableOnce('test')); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("test")); + $this->input->emit('data', array("5")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("world")); + } + + public function testEmitSingleCharacter() + { + $this->parser->on('data', $this->expectCallableConsecutive(4, array('t', 'e', 's', 't'))); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableOnce()); + $this->parser->on('error', $this->expectCallableNever()); + + $array = str_split("4\r\ntest\r\n0\r\n\r\n"); + + foreach ($array as $character) { + $this->input->emit('data', array($character)); + } + } + + public function testHandleError() + { + $this->parser->on('error', $this->expectCallableOnce()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + + $this->input->emit('error', array(new \RuntimeException())); + + $this->assertFalse($this->parser->isReadable()); + } + + public function testPauseStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $parser = new ChunkedDecoder($input); + $parser->pause(); + } + + public function testResumeStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $parser = new ChunkedDecoder($input); + $parser->pause(); + $parser->resume(); + } + + public function testPipeStream() + { + $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + + $ret = $this->parser->pipe($dest); + + $this->assertSame($dest, $ret); + } + + public function testHandleClose() + { + $this->parser->on('close', $this->expectCallableOnce()); + + $this->input->close(); + $this->input->emit('end', array()); + + $this->assertFalse($this->parser->isReadable()); + } + + public function testOutputStreamCanCloseInputStream() + { + $input = new ReadableStream(); + $input->on('close', $this->expectCallableOnce()); + + $stream = new ChunkedDecoder($input); + $stream->on('close', $this->expectCallableOnce()); + + $stream->close(); + + $this->assertFalse($input->isReadable()); + } +} diff --git a/tests/ServerTest.php b/tests/ServerTest.php index d2ff7aef..84b33ca9 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -265,6 +265,179 @@ function ($data) use (&$buffer) { $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); } + public function testBodyDataWillBeSendViaRequestEvent() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 5\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', array($data)); + } + + public function testChunkedEncodedRequestWillBeParsedForRequestEvent() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: chunked\r\n"; + $data .= "\r\n"; + $data .= "5\r\nhello\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: chunked\r\n"; + $data .= "\r\n"; + $data .= "5\r\nhello\r\n"; + $data .= "0\r\n\r\n"; + $data .= "2\r\nhi\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testEmptyChunkedEncodedRequest() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: chunked\r\n"; + $data .= "\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testChunkedIsUpperCase() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: CHUNKED\r\n"; + $data .= "\r\n"; + $data .= "5\r\nhello\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testChunkedIsMixedUpperAndLowerCase() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: CHunKeD\r\n"; + $data .= "\r\n"; + $data .= "5\r\nhello\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; diff --git a/tests/TestCase.php b/tests/TestCase.php index 73bb401e..74ad0bc7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -45,6 +45,20 @@ protected function expectCallableNever() return $mock; } + protected function expectCallableConsecutive($numberOfCalls, array $with) + { + $mock = $this->createCallableMock(); + + for ($i = 0; $i < $numberOfCalls; $i++) { + $mock + ->expects($this->at($i)) + ->method('__invoke') + ->with($this->equalTo($with[$i])); + } + + return $mock; + } + protected function createCallableMock() { return $this