diff --git a/README.md b/README.md index 0bf1d6d..606c425 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ as a second argument: ```php 'POST', @@ -53,8 +53,14 @@ $response = fetch('https://some-domain.example/some-form', [ 'body' => json_encode(['data' => 'value']) ]); -// Emit the response to stdout -while (($chunk = $response->body()->read()) !== null) { +// Emit the response to stdout in chunks +while (true) { + $chunk = ''; + try { + $response->body()->read(4096, $chunk); + } catch (Eof $e) { + break; + } echo $chunk; } ``` @@ -86,7 +92,9 @@ echo $response->status()->reasonPhrase(); // OK echo $response->headers()->has('content-type'); // true echo $response->headers()->contains('content-type', 'html'); // true echo $response->headers()->get('content-type'); // text/html;charset=utf-8 -echo $response->body()->read(); // Outputs some bytes from the response body +$bytes = ''; +echo $response->body()->read(4096, $bytes); // Allocates reader data into $bytes +echo $bytes; // Outputs some bytes from the response body ``` ### Exception Handling @@ -112,23 +120,23 @@ you most likely will be reacting in different ways to each one of them. ### Body Buffering When you call the `MNC\Http\Response::body()` method you get an instance of -`MNC\Http\Io\Reader`, which is a very simple interface inspired in golang's +`Castor\Io\Reader`, which is a very simple interface inspired in golang's `io.Reader`. This interface allows you to read a chunk of bytes until you reach `EOF` in the data source. Often times, you don't want to read byte per byte, but get the whole contents -of the body as a string at once. This library provides the `buffer` function +of the body as a string at once. This library provides the `readAll` function as a convenience for that: ```php body()); // Buffers all the contents in memory and emits them. +echo readAll($response->body()); // Buffers all the contents in memory and emits them. ```` Buffering is a very good convenience, but it needs to be used with care, since it could @@ -148,15 +156,15 @@ in content types like `text/plain`. However, there is big gain in user experienc when we provide helpers like these in our apis. This library provides an approach a bit more safe. If the response headers contain the -`application/json` content type, the `MNC\Http\Io\Reader` object of the body is internally +`application/json` content type, the ``Castor\Io\Reader`` object of the body is internally decorated with a `MNC\Http\Encoding\Json` object. This object implements both the -`Reader` and `JsonDecoder` interfaces. Checking for the former is the safest way of -handling json payloads: +`Reader` interface. Checking for the former is the safest way of handling json +payloads: ```php body(); -if ($body instanceof JsonDecoder) { +if ($body instanceof Json) { var_dump($body->decode()); // Dumps the json as an array } else { // The response body is not json encoded @@ -236,7 +244,7 @@ api, using the token internally. ```php body(); - if ($body instanceof JsonDecoder) { + if ($body instanceof Json) { return $body->decode(); } return null; @@ -285,7 +293,7 @@ api client that I need to use. ```php body(); - if ($body instanceof JsonDecoder) { + if ($body instanceof Json) { return $body->decode(); } return null; diff --git a/composer.json b/composer.json index 64d1a98..3c604c3 100644 --- a/composer.json +++ b/composer.json @@ -24,8 +24,9 @@ } }, "require": { - "php": "^7.4|^8.0", - "ext-json": "*" + "php": ">=7.4", + "ext-json": "*", + "castor/io": "^0.2.1" }, "scripts": { "lint": "php-cs-fixer fix", @@ -42,6 +43,6 @@ "amphp/http-server": "^2.1", "amphp/http-server-static-content": "^1.0.6", "mnavarrocarter/amp-http-router": "^0.1.0", - "friendsofphp/php-cs-fixer": "^2.16" + "friendsofphp/php-cs-fixer": "dev-master" } } diff --git a/examples/arguments.php b/examples/arguments.php index 404c60d..6ac9861 100644 --- a/examples/arguments.php +++ b/examples/arguments.php @@ -1,6 +1,6 @@ json_encode(['data' => 'value']) ]); -echo buffer($response->body()); // Emits the response +echo readAll($response->body()); // Emits the response diff --git a/examples/buffered.php b/examples/buffered.php index 0050e6e..805c2cf 100644 --- a/examples/buffered.php +++ b/examples/buffered.php @@ -2,9 +2,9 @@ require_once __DIR__ . '/../vendor/autoload.php'; -use function MNC\Http\buffer; +use function Castor\Io\readAll; use function MNC\Http\fetch; $response = fetch('https://mnavarro.dev'); -echo buffer($response->body()); +echo readAll($response->body()); diff --git a/examples/class.php b/examples/class.php index fbafd57..dc3c8cc 100644 --- a/examples/class.php +++ b/examples/class.php @@ -1,6 +1,6 @@ body(); - if ($body instanceof JsonDecoder) { + if ($body instanceof Json) { return $body->decode(); } return null; diff --git a/examples/composition.php b/examples/composition.php index a747fa8..ec82395 100644 --- a/examples/composition.php +++ b/examples/composition.php @@ -1,6 +1,6 @@ body(); - if ($body instanceof JsonDecoder) { + if ($body instanceof Json) { return $body->decode(); } return null; diff --git a/examples/info.php b/examples/info.php index 1e36af9..df5a2eb 100644 --- a/examples/info.php +++ b/examples/info.php @@ -12,4 +12,6 @@ echo $response->headers()->has('content-type'); // true echo $response->headers()->contains('content-type', 'html'); // true echo $response->headers()->get('content-type'); // text/html;charset=utf-8 -echo $response->body()->read(); // Outputs some bytes from the response body +$bytes = ''; +$response->body()->read(4096, $bytes); // Reads data into $bytes +echo $bytes; // Outputs some bytes from the response body diff --git a/examples/json.php b/examples/json.php index a801218..3264a80 100644 --- a/examples/json.php +++ b/examples/json.php @@ -2,7 +2,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; -use MNC\Http\Encoding\JsonDecoder; +use MNC\Http\Encoding\Json; use function MNC\Http\fetch; $response = fetch('https://api.github.com/users/mnavarrocarter', [ @@ -13,6 +13,6 @@ $body = $response->body(); -if ($body instanceof JsonDecoder) { +if ($body instanceof Json) { var_dump($body->decode()); // Dumps the json as an array } diff --git a/examples/simple.php b/examples/simple.php index c70d95c..6ea564f 100644 --- a/examples/simple.php +++ b/examples/simple.php @@ -1,11 +1,18 @@ body()->read()) !== null) { +while (true) { + $chunk = ''; + try { + $response->body()->read(4096, $chunk); + } catch (Eof $e) { + break; + } echo $chunk; } diff --git a/src/Encoding/Json.php b/src/Encoding/Json.php index cc80e54..b1c6df6 100644 --- a/src/Encoding/Json.php +++ b/src/Encoding/Json.php @@ -9,15 +9,15 @@ namespace MNC\Http\Encoding; +use Castor\Io\Error; +use function Castor\Io\readAll; +use Castor\Io\Reader; use JsonException; -use function MNC\Http\buffer; -use MNC\Http\Io\Reader; -use MNC\Http\Io\ReaderError; /** * Class Json. */ -final class Json implements Reader, JsonDecoder +final class Json implements Reader { private Reader $reader; @@ -33,15 +33,18 @@ public function __construct(Reader $reader) * @return array * * @throws JsonException - * @throws ReaderError + * @throws Error */ public function decode(): array { - return json_decode(buffer($this->reader), true, 512, JSON_THROW_ON_ERROR); + return json_decode(readAll($this->reader), true, 512, JSON_THROW_ON_ERROR); } - public function read(int $bytes = self::DEFAULT_CHUNK_SIZE): ?string + /** + * {@inheritDoc} + */ + public function read(int $length, string &$bytes): int { - return $this->reader->read($bytes); + return $this->reader->read($length, $bytes); } } diff --git a/src/Encoding/JsonDecoder.php b/src/Encoding/JsonDecoder.php deleted file mode 100644 index 136198e..0000000 --- a/src/Encoding/JsonDecoder.php +++ /dev/null @@ -1,27 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace MNC\Http\Encoding; - -use JsonException; - -/** - * A JsonDecoder decodes json data. - */ -interface JsonDecoder -{ - /** - * Decodes a json string into an associative array. - * - * @return array - * - * @throws JsonException when parsing fails - */ - public function decode(): array; -} diff --git a/src/Headers.php b/src/Headers.php index 3c3b13a..23b91b6 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -103,7 +103,7 @@ public function filter(callable $callable): array { return array_filter( $this->headers, - fn (string $value, string $name) => $callable($value, $name), + static fn (string $value, string $name) => $callable($value, $name), ARRAY_FILTER_USE_BOTH ); } diff --git a/src/HttpResponse.php b/src/HttpResponse.php index 3e8e569..d80ee59 100644 --- a/src/HttpResponse.php +++ b/src/HttpResponse.php @@ -9,8 +9,8 @@ namespace MNC\Http; +use Castor\Io\Reader; use MNC\Http\Encoding\Json; -use MNC\Http\Io\Reader; /** * Class Response. diff --git a/src/Io/Reader.php b/src/Io/Reader.php deleted file mode 100644 index 13a3a90..0000000 --- a/src/Io/Reader.php +++ /dev/null @@ -1,27 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace MNC\Http\Io; - -/** - * Interface Reader. - */ -interface Reader -{ - public const DEFAULT_CHUNK_SIZE = 4096; - - /** - * Reads raw bytes from the source. - * - * Returns null on EOF - * - * @throws ReaderError when contents cannot be read - */ - public function read(int $bytes = self::DEFAULT_CHUNK_SIZE): ?string; -} diff --git a/src/Io/ReaderError.php b/src/Io/ReaderError.php deleted file mode 100644 index 593b171..0000000 --- a/src/Io/ReaderError.php +++ /dev/null @@ -1,19 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace MNC\Http\Io; - -use Exception; - -/** - * Class ReadingError. - */ -class ReaderError extends Exception -{ -} diff --git a/src/Io/ResourceReader.php b/src/Io/ResourceReader.php deleted file mode 100644 index 90f7ee7..0000000 --- a/src/Io/ResourceReader.php +++ /dev/null @@ -1,52 +0,0 @@ - - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace MNC\Http\Io; - -/** - * Class ResourceReader. - */ -final class ResourceReader implements Reader -{ - /** - * @var resource - */ - private $resource; - - /** - * ResourceReader constructor. - * - * @param resource $resource - */ - public function __construct($resource) - { - if (!is_resource($resource)) { - throw new \InvalidArgumentException(sprintf('Argument 1 passed to %s() must be a resource, %s given', __METHOD__, gettype($resource))); - } - $this->resource = $resource; - } - - /** - * @throws ReaderError - */ - public function read(int $bytes = self::DEFAULT_CHUNK_SIZE): ?string - { - if (feof($this->resource)) { - return null; - } - $result = @fread($this->resource, $bytes); - if ($result === false) { - throw new ReaderError(error_get_last()['message'] ?? 'Unknown error'); - } - - return $result; - } -} diff --git a/src/RedirectedHttpResponse.php b/src/RedirectedHttpResponse.php index 9e53bd7..ca14c2f 100644 --- a/src/RedirectedHttpResponse.php +++ b/src/RedirectedHttpResponse.php @@ -9,7 +9,7 @@ namespace MNC\Http; -use MNC\Http\Io\Reader; +use Castor\Io\Reader; /** * The RedirectedHttpResponse composes a Response and Redirected types. diff --git a/src/Response.php b/src/Response.php index 1eab1ac..7a9cd78 100644 --- a/src/Response.php +++ b/src/Response.php @@ -9,7 +9,7 @@ namespace MNC\Http; -use MNC\Http\Io\Reader; +use Castor\Io\Reader; /** * A Response represents a full HTTP protocol response. diff --git a/src/ResponseBody.php b/src/ResponseBody.php new file mode 100644 index 0000000..dba7b50 --- /dev/null +++ b/src/ResponseBody.php @@ -0,0 +1,46 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace MNC\Http; + +use Castor\Io\Reader; +use Castor\Io\ResourceHelper; +use InvalidArgumentException; + +/** + * Class ResponseBody. + */ +final class ResponseBody implements Reader +{ + use ResourceHelper; + + /** + * ResponseBody constructor. + * + * @param resource $resource + */ + public function __construct($resource) + { + if (!is_resource($resource)) { + throw new InvalidArgumentException(sprintf('Argument 1 passed to %s must be a resource, %s given', __METHOD__, gettype($resource))); + } + $this->resource = $resource; + } + + /** + * {@inheritDoc} + * + * @psalm-suppress MoreSpecificReturnType + * @psalm-suppress LessSpecificReturnStatement + */ + public function read(int $length, string &$bytes): int + { + return $this->innerRead($length, $bytes); + } +} diff --git a/src/functions.php b/src/functions.php index 67412aa..f9829c1 100644 --- a/src/functions.php +++ b/src/functions.php @@ -11,9 +11,6 @@ namespace MNC\Http; -use MNC\Http\Io\Reader; -use MNC\Http\Io\ResourceReader; - /** * Fetches a url. * @@ -47,9 +44,8 @@ function fetch(string $url, array $options = []): Response // We create objects out of that data. $partials = HttpPartialResponse::parseLines($meta['wrapper_data']); - /** @var HttpPartialResponse $mainPartial */ $mainPartial = array_pop($partials); - $response = new HttpResponse($mainPartial, new ResourceReader($resource)); + $response = new HttpResponse($mainPartial, new ResponseBody($resource)); // If there are still partials, we are dealing with a redirect here. // We decorate the response on previous request. @@ -57,25 +53,10 @@ function fetch(string $url, array $options = []): Response $response = new RedirectedHttpResponse($response, ...$partials); } - // If the request is an error according to the spec, we throw an exception. + // If the request is an error according to the http spec, we throw an exception. if ($response->status()->isClientError() || $response->status()->isServerError()) { throw new ProtocolError($response); } return $response; } - -/** - * @return string The buffered string - * - * @throws Io\ReaderError - */ -function buffer(Reader $reader) -{ - $buffer = ''; - while (($chunk = $reader->read()) !== null) { - $buffer .= $chunk; - } - - return $buffer; -} diff --git a/tests/Encoding/JsonTest.php b/tests/Encoding/JsonTest.php index 6c1fae6..a455aae 100644 --- a/tests/Encoding/JsonTest.php +++ b/tests/Encoding/JsonTest.php @@ -2,36 +2,19 @@ namespace MNC\Http\Encoding; +use Castor\Io\TestReader; use JsonException; -use MNC\Http\Io\Reader; use PHPUnit\Framework\TestCase; +/** + * Class JsonTest + * @package MNC\Http\Encoding + */ class JsonTest extends TestCase { - public function testItReadsFromReader(): void - { - $jsonString = '{"id":"123456"}'; - $reader = $this->createMock(Reader::class); - - $reader->expects(self::exactly(2)) - ->method('read') - ->with(Reader::DEFAULT_CHUNK_SIZE) - ->willReturnOnConsecutiveCalls($jsonString, null); - - $json = new Json($reader); - self::assertSame($jsonString, $json->read()); - self::assertNull($json->read()); - } - public function testItDecodesJson(): void { - $jsonString = '{"id":"123456"}'; - $reader = $this->createMock(Reader::class); - - $reader->expects(self::exactly(2)) - ->method('read') - ->with(Reader::DEFAULT_CHUNK_SIZE) - ->willReturnOnConsecutiveCalls($jsonString, null); + $reader = TestReader::fromString('{"id":"123456"}'); $json = new Json($reader); self::assertSame(['id' => '123456'], $json->decode()); @@ -39,13 +22,7 @@ public function testItDecodesJson(): void public function testItThrowsExceptionOnInvalidJson(): void { - $jsonString = '{"id":"123456", this is invalid json}'; - $reader = $this->createMock(Reader::class); - - $reader->expects(self::exactly(2)) - ->method('read') - ->with(Reader::DEFAULT_CHUNK_SIZE) - ->willReturnOnConsecutiveCalls($jsonString, null); + $reader = TestReader::fromString('{"id":"123456", this is invalid json}'); $json = new Json($reader); $this->expectException(JsonException::class); diff --git a/tests/FetchFunctionalTest.php b/tests/FetchFunctionalTest.php index 81117a9..9da5273 100644 --- a/tests/FetchFunctionalTest.php +++ b/tests/FetchFunctionalTest.php @@ -3,8 +3,9 @@ namespace MNC\Http; -use MNC\Http\Encoding\JsonDecoder; +use MNC\Http\Encoding\Json; use PHPUnit\Framework\TestCase; +use function Castor\Io\readAll; /** * Class FetchFunctionalTest @@ -23,7 +24,7 @@ public function testItFetchesRootHtml(): void self::assertTrue($response->headers()->has('etag')); self::assertSame('355', $response->headers()->get('Content-Length')); $html = file_get_contents(__DIR__ . '/static/index.html'); - self::assertSame($html, buffer($response->body())); + self::assertSame($html, readAll($response->body())); } public function testItThrowsSocketErrorOnRefusedConnection(): void @@ -75,7 +76,7 @@ public function testItDecodesJson(): void $response = fetch('http://127.0.0.1:5488/user.json'); $body = $response->body(); self::assertTrue($response->headers()->contains('Content-Type', 'application/json')); - self::assertInstanceOf(JsonDecoder::class, $body); + self::assertInstanceOf(Json::class, $body); self::assertSame([ 'id' => 'd1129f05-45e3-47ba-be0e-cffba7fdf9f6', 'name' => 'John Doe',