From 6f8d10d774b59319a3052bc7aa9f58d589cc4788 Mon Sep 17 00:00:00 2001 From: gustavofreze Date: Tue, 13 Jun 2023 15:32:52 -0300 Subject: [PATCH] feat: Adds a wrapper that implements PSR-7 for HTTP responses. --- README.md | 15 ++ composer.json | 17 +- infection.json.dist | 10 +- src/HttpCode.php | 4 + src/HttpResponse.php | 34 ++++ src/Internal/Exceptions/BadMethodCall.php | 14 ++ .../Exceptions/MissingResourceStream.php | 13 ++ src/Internal/Exceptions/NonReadableStream.php | 13 ++ src/Internal/Exceptions/NonSeekableStream.php | 13 ++ src/Internal/Exceptions/NonWritableStream.php | 13 ++ src/Internal/Response.php | 97 ++++++++++ src/Internal/Stream/Stream.php | 170 ++++++++++++++++++ src/Internal/Stream/StreamFactory.php | 40 +++++ src/Internal/Stream/StreamMetaData.php | 44 +++++ tests/HttpResponseTest.php | 95 ++++++++++ tests/Internal/ResponseTest.php | 128 +++++++++++++ tests/Internal/Stream/StreamTest.php | 168 +++++++++++++++++ tests/Mock/Xpto.php | 15 ++ tests/Mock/Xyz.php | 10 ++ 19 files changed, 906 insertions(+), 7 deletions(-) create mode 100644 src/HttpResponse.php create mode 100644 src/Internal/Exceptions/BadMethodCall.php create mode 100644 src/Internal/Exceptions/MissingResourceStream.php create mode 100644 src/Internal/Exceptions/NonReadableStream.php create mode 100644 src/Internal/Exceptions/NonSeekableStream.php create mode 100644 src/Internal/Exceptions/NonWritableStream.php create mode 100644 src/Internal/Response.php create mode 100644 src/Internal/Stream/Stream.php create mode 100644 src/Internal/Stream/StreamFactory.php create mode 100644 src/Internal/Stream/StreamMetaData.php create mode 100644 tests/HttpResponseTest.php create mode 100644 tests/Internal/ResponseTest.php create mode 100644 tests/Internal/Stream/StreamTest.php create mode 100644 tests/Mock/Xpto.php create mode 100644 tests/Mock/Xyz.php diff --git a/README.md b/README.md index 48b27bc..f8cecd3 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,21 @@ echo $method->name; # GET echo $method->value; # GET ``` +### Using the HttpResponse + +The library exposes a concrete implementation for HTTP responses via the `HttpResponse` class. Responses are of the +[ResponseInterface](https://github.com/php-fig/http-message/blob/master/src/ResponseInterface.php) type, according to +the specifications defined in [PSR-7](https://www.php-fig.org/psr/psr-7). + +```php +$data = new Xyz(value: 10); +$response = HttpResponse::ok(data: $data); + +$response->getStatusCode(); # 200 +$response->getReasonPhrase(); # 200 Ok +$response->getBody()->getContents(); # {"value":10} +``` + ## License Math is licensed under [MIT](/LICENSE). diff --git a/composer.json b/composer.json index 0e09683..b63f5d7 100644 --- a/composer.json +++ b/composer.json @@ -9,11 +9,14 @@ "keywords": [ "psr", "psr-4", + "psr-7", "psr-12", "http", "http-code", + "tiny-blocks", "http-status", - "tiny-blocks" + "http-methods", + "http-response" ], "authors": [ { @@ -39,20 +42,22 @@ }, "require": { "php": "^8.1||^8.2", + "tiny-blocks/serializer": "1.*", + "psr/http-message": "2.*", "ext-mbstring": "*" }, "require-dev": { - "infection/infection": "^0.26", - "phpmd/phpmd": "^2.12", - "phpunit/phpunit": "^9.5", - "squizlabs/php_codesniffer": "^3.7" + "infection/infection": "0.*", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "9.*", + "squizlabs/php_codesniffer": "3.*" }, "suggest": { "ext-mbstring": "Provides multibyte-specific string functions that help us deal with multibyte encodings in PHP." }, "scripts": { "phpcs": "phpcs --standard=PSR12 --extensions=php ./src", - "phpmd": "phpmd ./src text phpmd.xml --suffixes php --exclude /src/HttpCode.php --ignore-violations-on-exit", + "phpmd": "phpmd ./src text phpmd.xml --suffixes php --exclude /src/HttpCode.php --exclude /src/Internal/Response --ignore-violations-on-exit", "test": "phpunit --log-junit=report/coverage/junit.xml --coverage-xml=report/coverage/coverage-xml --coverage-html=report/coverage/coverage-html tests", "test-mutation": "infection --only-covered --logger-html=report/coverage/mutation-report.html --coverage=report/coverage --min-msi=100 --min-covered-msi=100 --threads=4", "test-no-coverage": "phpunit --no-coverage", diff --git a/infection.json.dist b/infection.json.dist index 05da0e1..605d106 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -12,7 +12,15 @@ "summary": "report/logs/infection-summary.log" }, "mutators": { - "@default": true + "@default": true, + "ArrayItem": false, + "LogicalOr": false, + "IfNegation": false, + "InstanceOf_": false, + "ArrayItemRemoval": false, + "MethodCallRemoval": false, + "LogicalOrAllSubExprNegation": false, + "LogicalOrSingleSubExprNegation": false }, "phpUnit": { "configDir": "", diff --git a/src/HttpCode.php b/src/HttpCode.php index 039c8c2..055fd22 100644 --- a/src/HttpCode.php +++ b/src/HttpCode.php @@ -9,9 +9,13 @@ * Responses are grouped in five classes: * * Informational (100 – 199) + * * Successful (200 – 299) + * * Redirection (300 – 399) + * * Client error (400 – 499) + * * Server error (500 – 599) * * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#information_responses diff --git a/src/HttpResponse.php b/src/HttpResponse.php new file mode 100644 index 0000000..c8c79e0 --- /dev/null +++ b/src/HttpResponse.php @@ -0,0 +1,34 @@ + cannot be used.'; + parent::__construct(message: sprintf($template, $this->method)); + } +} diff --git a/src/Internal/Exceptions/MissingResourceStream.php b/src/Internal/Exceptions/MissingResourceStream.php new file mode 100644 index 0000000..05a35e8 --- /dev/null +++ b/src/Internal/Exceptions/MissingResourceStream.php @@ -0,0 +1,13 @@ + 'application/json'] : $headers; + + return new Response(code: $code, body: StreamFactory::from(data: $data), headers: $headers); + } + + public function withBody(StreamInterface $body): MessageInterface + { + throw new BadMethodCall(method: __METHOD__); + } + + public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface + { + throw new BadMethodCall(method: __METHOD__); + } + + public function withHeader(string $name, mixed $value): MessageInterface + { + throw new BadMethodCall(method: __METHOD__); + } + + public function withoutHeader(string $name): MessageInterface + { + throw new BadMethodCall(method: __METHOD__); + } + + public function withAddedHeader(string $name, mixed $value): MessageInterface + { + throw new BadMethodCall(method: __METHOD__); + } + + public function withProtocolVersion(string $version): MessageInterface + { + throw new BadMethodCall(method: __METHOD__); + } + + public function getProtocolVersion(): string + { + return '1.1'; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function hasHeader(string $name): bool + { + return isset($this->headers[$name]); + } + + public function getHeader(string $name): array + { + return $this->headers[$name] ?? []; + } + + public function getHeaderLine(string $name): string + { + return implode(', ', $this->getHeader(name: $name)); + } + + public function getBody(): StreamInterface + { + return $this->body; + } + + public function getStatusCode(): int + { + return $this->code->value; + } + + public function getReasonPhrase(): string + { + return $this->code->message(); + } +} diff --git a/src/Internal/Stream/Stream.php b/src/Internal/Stream/Stream.php new file mode 100644 index 0000000..3a806d2 --- /dev/null +++ b/src/Internal/Stream/Stream.php @@ -0,0 +1,170 @@ +noResource()) { + return; + } + + $resource = $this->detach(); + + fclose($resource); + } + + public function detach(): mixed + { + $resource = $this->resource; + $this->resource = null; + + return $resource; + } + + public function getSize(): ?int + { + if ($this->noResource()) { + return null; + } + + return intval(fstat($this->resource)['size']); + } + + public function tell(): int + { + if ($this->noResource()) { + throw new MissingResourceStream(); + } + + return ftell($this->resource); + } + + public function eof(): bool + { + return $this->resource && feof($this->resource); + } + + public function seek(int $offset, int $whence = SEEK_SET): void + { + if (!$this->isSeekable()) { + throw new NonSeekableStream(); + } + + fseek($this->resource, $offset, $whence); + } + + public function rewind(): void + { + $this->seek(offset: 0); + } + + public function read(int $length): string + { + if (!$this->isReadable()) { + throw new NonReadableStream(); + } + + return fread($this->resource, $length); + } + + public function write(string $string): int + { + if (!$this->isWritable()) { + throw new NonWritableStream(); + } + + return fwrite($this->resource, $string); + } + + public function isReadable(): bool + { + if ($this->noResource()) { + return false; + } + + $mode = $this->metaData->getMode(); + + return $mode === 'r' || strstr($mode, '+'); + } + + public function isWritable(): bool + { + if ($this->noResource()) { + return false; + } + + $mode = $this->metaData->getMode(); + + return strstr($mode, 'x') + || strstr($mode, 'w') + || strstr($mode, 'c') + || strstr($mode, 'a') + || strstr($mode, '+'); + } + + public function isSeekable(): bool + { + return !$this->noResource() && $this->metaData->isSeekable(); + } + + public function getContents(): string + { + if (!$this->isReadable()) { + throw new NonReadableStream(); + } + + if (!$this->contentFetched) { + $this->content = stream_get_contents($this->resource); + $this->contentFetched = true; + } + + return $this->content; + } + + public function getMetadata(?string $key = null): mixed + { + $metaData = $this->metaData->toArray(); + + if (is_null($key)) { + return $metaData; + } + + return $metaData[$key] ?? null; + } + + public function __toString(): string + { + if ($this->isSeekable()) { + $this->rewind(); + } + + return $this->getContents(); + } + + private function noResource(): bool + { + return empty($this->resource); + } +} diff --git a/src/Internal/Stream/StreamFactory.php b/src/Internal/Stream/StreamFactory.php new file mode 100644 index 0000000..e9cfde9 --- /dev/null +++ b/src/Internal/Stream/StreamFactory.php @@ -0,0 +1,40 @@ +write(string: json_encode($data->toArray())); + $stream->rewind(); + + return $stream; + } + + if (is_object($data)) { + $stream->write(string: json_encode(get_object_vars($data))); + $stream->rewind(); + + return $stream; + } + + if (is_scalar($data) || is_array($data)) { + $stream->write(string: json_encode($data)); + $stream->rewind(); + + return $stream; + } + + $stream->write(string: ''); + $stream->rewind(); + + return $stream; + } +} diff --git a/src/Internal/Stream/StreamMetaData.php b/src/Internal/Stream/StreamMetaData.php new file mode 100644 index 0000000..fe4131a --- /dev/null +++ b/src/Internal/Stream/StreamMetaData.php @@ -0,0 +1,44 @@ +mode; + } + + public function isSeekable(): bool + { + return $this->seekable; + } + + public function toArray(): array + { + return [ + 'uri' => $this->uri, + 'mode' => $this->getMode(), + 'seekable' => $this->isSeekable(), + 'streamType' => $this->streamType + ]; + } +} diff --git a/tests/HttpResponseTest.php b/tests/HttpResponseTest.php new file mode 100644 index 0000000..5395a3c --- /dev/null +++ b/tests/HttpResponseTest.php @@ -0,0 +1,95 @@ + 'application/json']; + + /** + * @dataProvider providerData + */ + public function testResponseOk(mixed $data, mixed $expected): void + { + $response = HttpResponse::ok(data: $data); + + self::assertEquals($expected, $response->getBody()->__toString()); + self::assertEquals($expected, $response->getBody()->getContents()); + self::assertEquals(HttpCode::OK->value, $response->getStatusCode()); + self::assertEquals(HttpCode::OK->message(), $response->getReasonPhrase()); + self::assertEquals($this->defaultHeader, $response->getHeaders()); + } + + /** + * @dataProvider providerData + */ + public function testResponseCreated(mixed $data, mixed $expected): void + { + $response = HttpResponse::created(data: $data); + + self::assertEquals($expected, $response->getBody()->__toString()); + self::assertEquals($expected, $response->getBody()->getContents()); + self::assertEquals(HttpCode::CREATED->value, $response->getStatusCode()); + self::assertEquals(HttpCode::CREATED->message(), $response->getReasonPhrase()); + self::assertEquals($this->defaultHeader, $response->getHeaders()); + } + + /** + * @dataProvider providerData + */ + public function testResponseAccepted(mixed $data, mixed $expected): void + { + $response = HttpResponse::accepted(data: $data); + + self::assertEquals($expected, $response->getBody()->__toString()); + self::assertEquals($expected, $response->getBody()->getContents()); + self::assertEquals(HttpCode::ACCEPTED->value, $response->getStatusCode()); + self::assertEquals(HttpCode::ACCEPTED->message(), $response->getReasonPhrase()); + self::assertEquals($this->defaultHeader, $response->getHeaders()); + } + + public function testResponseNoContent(): void + { + $response = HttpResponse::noContent(); + + self::assertEquals('', $response->getBody()->__toString()); + self::assertEquals('', $response->getBody()->getContents()); + self::assertEquals(HttpCode::NO_CONTENT->value, $response->getStatusCode()); + self::assertEquals(HttpCode::NO_CONTENT->message(), $response->getReasonPhrase()); + self::assertEquals($this->defaultHeader, $response->getHeaders()); + } + + public function providerData(): array + { + return [ + [ + 'data' => new Xyz(value: 10), + 'expected' => '{"value":10}' + ], + [ + 'data' => new Xpto(value: 9.99), + 'expected' => (new Xpto(value: 9.99))->toJson() + ], + [ + 'data' => null, + 'expected' => null + ], + [ + 'data' => '', + 'expected' => '""' + ], + [ + 'data' => true, + 'expected' => 'true' + ], + [ + 'data' => 10000000000, + 'expected' => '10000000000' + ] + ]; + } +} diff --git a/tests/Internal/ResponseTest.php b/tests/Internal/ResponseTest.php new file mode 100644 index 0000000..3f4d094 --- /dev/null +++ b/tests/Internal/ResponseTest.php @@ -0,0 +1,128 @@ +response = Response::from(code: HttpCode::OK, data: [], headers: []); + } + + public function testGetProtocolVersion(): void + { + self::assertEquals('1.1', $this->response->getProtocolVersion()); + } + + public function testGetHeaders(): void + { + $headers = [ + 'Content-Type' => 'application/json', + 'X-Auth-Token' => 'abc123' + ]; + + $response = Response::from(code: HttpCode::OK, data: [], headers: $headers); + + self::assertEquals($headers, $response->getHeaders()); + } + + public function testHasHeader(): void + { + $headers = [ + 'Content-Type' => 'application/json', + 'X-Auth-Token' => 'abc123' + ]; + + $response = Response::from(code: HttpCode::OK, data: [], headers: $headers); + + self::assertTrue($response->hasHeader(name: 'Content-Type')); + self::assertFalse($response->hasHeader(name: 'Authorization')); + } + + public function testGetHeader(): void + { + $headers = [ + 'Content-Type' => ['application/json'], + 'X-Auth-Token' => ['abc123'], + 'X-Custom-Header' => ['value1', 'value2'] + ]; + + $response = Response::from(code: HttpCode::OK, data: [], headers: $headers); + + self::assertEquals(['application/json'], $response->getHeader(name: 'Content-Type')); + self::assertEquals(['abc123'], $response->getHeader(name: 'X-Auth-Token')); + self::assertEquals(['value1', 'value2'], $response->getHeader(name: 'X-Custom-Header')); + self::assertEquals([], $response->getHeader(name: 'Authorization')); + } + + public function testGetHeaderLine(): void + { + $headers = [ + 'Content-Type' => ['application/json'], + 'X-Auth-Token' => ['abc123'], + 'X-Custom-Header' => ['value1', 'value2'] + ]; + + $response = Response::from(code: HttpCode::OK, data: [], headers: $headers); + + self::assertEquals('application/json', $response->getHeaderLine(name: 'Content-Type')); + self::assertEquals('abc123', $response->getHeaderLine(name: 'X-Auth-Token')); + self::assertEquals('value1, value2', $response->getHeaderLine(name: 'X-Custom-Header')); + self::assertEquals('', $response->getHeaderLine(name: 'Authorization')); + } + + public function testExceptionWhenBadMethodCallOnWithBody(): void + { + self::expectException(BadMethodCall::class); + self::expectExceptionMessage('Method cannot be used.'); + + $this->response->withBody(body: StreamFactory::from(data: [])); + } + + public function testExceptionWhenBadMethodCallOnWithStatus(): void + { + self::expectException(BadMethodCall::class); + self::expectExceptionMessage('Method cannot be used.'); + + $this->response->withStatus(code: HttpCode::OK->value); + } + + public function testExceptionWhenBadMethodCallOnWithHeader(): void + { + self::expectException(BadMethodCall::class); + self::expectExceptionMessage('Method cannot be used.'); + + $this->response->withHeader(name: '', value: ''); + } + + public function testExceptionWhenBadMethodCallOnWithoutHeader(): void + { + self::expectException(BadMethodCall::class); + self::expectExceptionMessage('Method cannot be used.'); + + $this->response->withoutHeader(name: ''); + } + + public function testExceptionWhenBadMethodCallOnWithAddedHeader(): void + { + self::expectException(BadMethodCall::class); + self::expectExceptionMessage('Method cannot be used.'); + + $this->response->withAddedHeader(name: '', value: ''); + } + + public function testExceptionWhenBadMethodCallOnWithProtocolVersion(): void + { + self::expectException(BadMethodCall::class); + self::expectExceptionMessage('Method cannot be used.'); + + $this->response->withProtocolVersion(version: ''); + } +} diff --git a/tests/Internal/Stream/StreamTest.php b/tests/Internal/Stream/StreamTest.php new file mode 100644 index 0000000..f6e6174 --- /dev/null +++ b/tests/Internal/Stream/StreamTest.php @@ -0,0 +1,168 @@ +temporary = tempnam(sys_get_temp_dir(), 'test'); + $this->resource = fopen($this->temporary, 'wb+'); + } + + protected function tearDown(): void + { + if (!empty($this->temporary) && file_exists($this->temporary)) { + unlink($this->temporary); + } + } + + public function testCloseDetachesResource(): void + { + $stream = Stream::from(resource: $this->resource); + $stream->close(); + + self::assertFalse($stream->isReadable()); + self::assertFalse($stream->isWritable()); + self::assertFalse($stream->isSeekable()); + self::assertFalse(is_resource($this->resource)); + } + + public function testCloseWithoutResource(): void + { + $stream = Stream::from(resource: $this->resource); + $stream->close(); + $stream->close(); + + self::assertFalse($stream->isReadable()); + self::assertFalse($stream->isWritable()); + self::assertFalse($stream->isSeekable()); + self::assertFalse(is_resource($this->resource)); + } + + public function testEofReturnsTrueAtEndOfStream(): void + { + $stream = Stream::from(resource: $this->resource); + $stream->write(string: 'Hello'); + $eofBeforeRead = $stream->eof(); + $stream->read(length: 5); + + self::assertTrue($stream->eof()); + self::assertTrue($stream->isReadable()); + self::assertFalse($eofBeforeRead); + } + + public function testGetMetadata(): void + { + $stream = Stream::from(resource: $this->resource); + $actual = $stream->getMetadata(); + $expected = StreamMetaData::from(data: stream_get_meta_data($this->resource))->toArray(); + + self::assertNull($stream->getMetadata(key: '')); + self::assertEquals($expected, $actual); + self::assertEquals($expected['mode'], $stream->getMetadata(key: 'mode')); + } + + public function testSeekMovesCursorPosition(): void + { + $stream = Stream::from(resource: $this->resource); + $stream->write(string: 'Hello, world!'); + $stream->seek(offset: 7); + $tellAfterFirstSeek = $stream->tell(); + $stream->seek(offset: 0, whence: SEEK_END); + + self::assertTrue($stream->isWritable()); + self::assertTrue($stream->isSeekable()); + self::assertEquals(7, $tellAfterFirstSeek); + self::assertEquals(13, $stream->tell()); + } + + public function testRewindResetsCursorPosition(): void + { + $stream = Stream::from(resource: $this->resource); + $stream->write(string: 'Hello, world!'); + $stream->seek(offset: 7); + $stream->rewind(); + + self::assertEquals(0, $stream->tell()); + } + + public function testGetSizeReturnsCorrectSize(): void + { + $stream = Stream::from(resource: $this->resource); + $sizeBeforeWrite = $stream->getSize(); + $stream->write(string: 'Hello, world!'); + + self::assertEquals(0, $sizeBeforeWrite); + self::assertEquals(13, $stream->getSize()); + } + + public function testGetSizeReturnsNullWhenWithoutResource(): void + { + $stream = Stream::from(resource: $this->resource); + $stream->close(); + + self::assertNull($stream->getSize()); + } + + public function testExceptionWhenMissingResourceStreamOnTell(): void + { + $stream = Stream::from(resource: $this->resource); + + self::expectException(MissingResourceStream::class); + self::expectExceptionMessage('No resource available.'); + + $stream->close(); + $stream->tell(); + } + + public function testExceptionWhenNonSeekableStream(): void + { + $stream = Stream::from(resource: $this->resource); + + self::expectException(NonSeekableStream::class); + self::expectExceptionMessage('Stream is not seekable.'); + + $stream->close(); + $stream->seek(offset: 1); + } + + public function testExceptionWhenNonWritableStream(): void + { + $stream = Stream::from(resource: fopen($this->temporary, 'r')); + + self::expectException(NonWritableStream::class); + self::expectExceptionMessage('Stream is not writable.'); + + $stream->write(string: 'Hello, world!'); + } + + public function testExceptionWhenNonReadableStreamOnRead(): void + { + $stream = Stream::from(resource: fopen($this->temporary, 'w')); + + self::expectException(NonReadableStream::class); + self::expectExceptionMessage('Stream is not readable.'); + + $stream->read(length: 13); + } + + public function testExceptionWhenNonReadableStreamOnGetContents(): void + { + $stream = Stream::from(resource: fopen($this->temporary, 'w')); + + self::expectException(NonReadableStream::class); + self::expectExceptionMessage('Stream is not readable.'); + + $stream->getContents(); + } +} diff --git a/tests/Mock/Xpto.php b/tests/Mock/Xpto.php new file mode 100644 index 0000000..5c4564f --- /dev/null +++ b/tests/Mock/Xpto.php @@ -0,0 +1,15 @@ +