commit 7dcdb01136e36c15cb23cd2ae7cdf4d91ad3d33d Author: Danny Date: Mon May 5 13:48:29 2025 +0100 Initial commit - clone from lumax/http-component diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f0ce890 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +/tests/ export-ignore +phpunit.xml export-ignore +.gitignore export-ignore +.gitattributes export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fc675d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.idea +/coverage +/vendor +/node_modules +/composer.lock +/.phpunit.result.cache \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0e0bc83 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,104 @@ +# Loom | HTTP Component Changelog + +## [2.3.0] - 2024-07-18 +### Added +- Add `getQueryParam(string $name, ?string $default = null)` method to `Request` + +### Changed +- N/A + +### Deprecated +- N/A + +### Removed +- N/A + +### Fixed +- N/A + +### Security +- N/A + +--- + +## [2.2.1] - 2024-04-30 +### Added +- N/A + +### Changed +- N/A + +### Deprecated +- N/A + +### Removed +- N/A + +### Fixed +- Fix for subsequent calls to `Request::get()` returning null - rewind stream after reading. + +### Security +- N/A + +--- + +## [2.2.0] - 2024-04-29 +### Added +- Added `get` method to `Request` + +### Changed +- N/A + +### Deprecated +- N/A + +### Removed +- N/A + +### Fixed +- N/A + +### Security +- N/A + +--- + +## [2.1.0] - 2024-04-17 +### Added +- Added `getData` method to `Request` + +### Changed +- N/A + +### Deprecated +- N/A + +### Removed +- N/A + +### Fixed +- N/A + +### Security +- N/A + +--- + +## [2.0.6] - 2024-03-17 +### Added +- Added `CHANGELOG.md` + +### Changed +- N/A + +### Deprecated +- N/A + +### Removed +- N/A + +### Fixed +- N/A + +### Security +- N/A \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..046df3e --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# Loom | HTTP Component + +
+ +Version 1.0.0 + +PHP Coverage 79.23% + +License GPL--3.0--or--later +
+ +The HTTP Component is a PHP library designed to simplify the process of making HTTP requests and handling HTTP responses +in your applications. It follows the [PSR-7 HTTP Message Interface](https://www.php-fig.org/psr/psr-7/) standards for +HTTP messages, making it compatible with other libraries and frameworks that also adhere to these standards. + +## Installation + +You can install this library using Composer: + +```bash +composer require loomlabs/http-component +``` + +## Features + +This HTTP Component package provides the following key features: + +### Request and Response Handling + +- `Request` and `Response` classes that implement the `Psr\Http\Message\RequestInterface` and `Psr\Http\Message\ResponseInterface`, respectively. +- Easily create and manipulate HTTP requests and responses. +- Handle headers, request methods, status codes, and more. + +### Stream Handling + +- A `Stream` class that implements the `Psr\Http\Message\StreamInterface` for working with stream data. +- Read and write data to streams, check for stream availability, and more. + +### HTTP Client + +- A `HttpClient` class that implements the `Psr\Http\Client\ClientInterface`. +- Simplifies sending HTTP requests using cURL and processing HTTP responses. +- Supports common HTTP methods like GET, POST, PUT, PATCH and DELETE. +- Automatically parses response headers and handles redirects. + +### URI Handling + +- A `Uri` class that implements the `Psr\Http\Message\UriInterface` for working with URIs. +- Easily construct and manipulate URIs, including handling scheme, host, port, path, query, and fragment. + +## Usage + +### Creating an HTTP Request + +```php +use Loom\HttpComponent\HttpClient; +use Loom\HttpComponent\Request; +use Loom\HttpComponent\StreamBuilder; +use Loom\HttpComponent\Uri; + +// Create a URI +$uri = new Uri('https', 'example.com', '/'); + +// Create an HTTP GET request +$body = 'Some text!'; +$request = new Request( + 'GET', + $uri, + ['Content-Type' => 'application/json'], + StreamBuilder::build($body) +); + +// Customise the request headers +$request = $request->withHeader('Authorization', 'Bearer AccessToken'); + +// Send the request using the built-in HTTP Client +$response = (new HttpClient())->sendRequest($request); + +// Get the response status code +$status = $response->getStatusCode(); + +// Get the response body +$body = $response->getBody()->getContents(); +``` + +### Creating an HTTP Client + +```php +use Loom\HttpComponent\HttpClient; + +// Create an HTTP client +$client = new HttpClient(); + +// Send GET request +$response = $client->get('https://example.com/api/resource'); + +// Send POST request to endpoint with headers and body +$response = $client->post( + 'https://example.com/api/resource', + ['Content-Type' => 'application/json', 'Authorization' => 'Bearer AccessToken'], + json_encode(['data' => 'value']) +); +``` + +### Working with Streams + +```php +use Loom\HttpComponent\StreamBuilder; + +// Create a stream from a string +$stream = StreamBuilder::build('Hello, World!'); + +// Read from the stream +$data = $stream->read(1024); + +// Write to the stream +$stream->write('New data to append'); + +// Rewind the streams internal pointer +$stream->rewind(); + +// Get the stream contents +$contents = $stream->getContents(); +``` + +### URI Handling + +```php +use Loom\HttpComponent\Uri; +use Loom\HttpComponent\Web\WebServerUri; + +// Create a URI +$uri = new Uri('https', 'example.com', '/api/resource'); + +// Modify the URI +$uri = $uri->withScheme('http'); +$uri = $uri->withPort(8888); +$uri = $uri->withQuery('new_param=new_value'); + +// Get the URI as a string +$uriString = $uri->__toString(); + +// Build a URI based on the current request to your web server +$uri = WebServerUri::generate(); +``` + +### License +This package is open-source software licensed under the +[GNU General Public License, version 3.0 (GPL-3.0)](https://opensource.org/licenses/GPL-3.0). \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f6099b0 --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name": "loomlabs/http-component", + "description": "A component for handling HTTP Requests/Responses", + "scripts": { + "test": "php -d xdebug.mode=coverage ./vendor/bin/phpunit --testdox --colors=always --coverage-html coverage --coverage-clover coverage/coverage.xml --testdox-html coverage/testdox.html && npx badger --phpunit ./coverage/coverage.xml && npx badger --version ./composer.json && npx badger --license ./composer.json" + }, + "license": "GPL-3.0-or-later", + "require": { + "psr/http-message": "^2.0", + "psr/http-client": "^1.0", + "ext-curl": "*" + }, + "autoload": { + "psr-4": { + "Loom\\HttpComponent\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Loom\\HttpComponentTests\\": "tests/" + } + }, + "version": "1.0.0", + "require-dev": { + "phpunit/phpunit": "^12.1" + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e8e671f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "html", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@dannyxcii/badger": "^0.4.1" + } + }, + "node_modules/@dannyxcii/badger": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@dannyxcii/badger/-/badger-0.4.1.tgz", + "integrity": "sha512-fK39595AbeKiajI6cxI+Odov+/r8aWXLItNELoxFhf59uzTEI6lL7JUm2t7GyBi0OZieO+7me1zWdRkIONdFOQ==", + "dev": true, + "bin": { + "badger": "bin/badger" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fa8ddbe --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "repository": { + "type": "git", + "url": "git+https://github.com/DanielWinning/http-component.git" + }, + "bugs": { + "url": "https://github.com/DanielWinning/http-component/issues" + }, + "homepage": "https://github.com/DanielWinning/http-component#readme", + "devDependencies": { + "@dannyxcii/badger": "^0.4.1" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..9a66d44 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests + + + + + + src + + + \ No newline at end of file diff --git a/spinner.yaml b/spinner.yaml new file mode 100644 index 0000000..7f8b594 --- /dev/null +++ b/spinner.yaml @@ -0,0 +1,4 @@ +options: + environment: + database: + enabled: false \ No newline at end of file diff --git a/src/HttpClient.php b/src/HttpClient.php new file mode 100644 index 0000000..0c5e895 --- /dev/null +++ b/src/HttpClient.php @@ -0,0 +1,197 @@ +setCurlOptions($curl, $request); + + $responseText = $this->getRawResponse($curl); + + $response = (new Response())->withStatus(curl_getinfo($curl, CURLINFO_HTTP_CODE)); + $headerSize = curl_getinfo($curl, CURLINFO_HEADER_SIZE); + $response = $this->parseHeaders($response, $responseText, $headerSize); + + return $response->withBody(StreamBuilder::build(substr($responseText, $headerSize))); + } + + /** + * @param string $endpoint + * @param array $headers + * @param string $body + * + * @return ResponseInterface + */ + public function get(string $endpoint, array $headers = [], string $body = ''): ResponseInterface + { + return $this->buildAndSend('GET', $endpoint, $headers, $body); + } + + /** + * @param string $endpoint + * @param array $headers + * @param string $body + * + * @return ResponseInterface + */ + public function post(string $endpoint, array $headers = [], string $body = ''): ResponseInterface + { + return $this->buildAndSend('POST', $endpoint, $headers, $body); + } + + /** + * @param string $endpoint + * @param array $headers + * @param string $body + * + * @return ResponseInterface + */ + public function put(string $endpoint, array $headers = [], string $body = ''): ResponseInterface + { + return $this->buildAndSend('PUT', $endpoint, $headers, $body); + } + + /** + * @param string $endpoint + * @param array $headers + * @param string $body + * + * @return ResponseInterface + */ + public function patch(string $endpoint, array $headers = [], string $body = ''): ResponseInterface + { + return $this->buildAndSend('PATCH', $endpoint, $headers, $body); + } + + /** + * @param string $endpoint + * @param array $headers + * @param string $body + * + * @return ResponseInterface + */ + public function delete(string $endpoint, array $headers = [], string $body = ''): ResponseInterface + { + return $this->buildAndSend('DELETE', $endpoint, $headers, $body); + } + + /** + * @param string $method + * @param string $endpoint + * @param array $headers + * @param string $body + * + * @return ResponseInterface + */ + private function buildAndSend(string $method, string $endpoint, array $headers, string $body): ResponseInterface + { + $request = new Request($method, $this->buildUriFromEndpoint($endpoint), $headers, StreamBuilder::build($body)); + + return $this->sendRequest($request); + } + + /** + * @param string $endpoint + * + * @return UriInterface + */ + private function buildUriFromEndpoint(string $endpoint): UriInterface + { + if (!parse_url($endpoint, PHP_URL_SCHEME)) { + $endpoint = 'https://' . $endpoint; + } + + $parts = parse_url($endpoint); + + return new Uri( + $parts['scheme'], + $parts['host'] ?? '', + $parts['path'] ?? '/', + $parts['query'] ?? '', + $parts['port'] ?? '' + ); + } + + /** + * @param \CurlHandle $curl + * @param RequestInterface $request + * + * @return void + */ + private function setCurlOptions(\CurlHandle &$curl, RequestInterface $request): void + { + curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $request->getMethod()); + curl_setopt($curl, CURLOPT_URL, $request->getUri()->__toString()); + curl_setopt($curl, CURLOPT_HEADER, 1); + curl_setopt($curl, CURLOPT_HTTPHEADER, $request->getFlatHeaders()); + curl_setopt($curl, CURLOPT_POSTFIELDS, $request->getBody()->getContents()); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_FOLLOWLOCATION, 1); + curl_setopt($curl, CURLINFO_HEADER_OUT, true); + } + + /** + * @param \CurlHandle $curl + * + * @return bool|string + * + * @throws \RuntimeException + */ + private function getRawResponse(\CurlHandle &$curl): bool|string + { + $responseText = curl_exec($curl); + + if (curl_errno($curl)) { + throw new \RuntimeException(sprintf('cURL error: %s', curl_error($curl))); + } + + curl_close($curl); + + return $responseText; + } + + /** + * @param ResponseInterface $response + * @param string $responseText + * @param int $headerSize + * + * @return ResponseInterface + */ + private function parseHeaders(ResponseInterface &$response, string $responseText, int $headerSize): ResponseInterface + { + $responseHeaders = substr($responseText, 0, $headerSize); + $headerLines = explode("\r\n", trim($responseHeaders)); + + foreach ($headerLines as $headerLine) { + $splitLine = explode(':', $headerLine, 2); + [$key, $value] = count($splitLine) === 2 ? $splitLine : [$splitLine[0], null]; + + $key = trim($key); + $value = $value ? trim($value) : null; + + if ($key && $value) { + if (array_key_exists($key, $response->getHeaders()) && in_array($value, $response->getHeader($key))) { + continue; + } + + $response = $response->withAddedHeader($key, $value); + } + } + + return $response; + } +} \ No newline at end of file diff --git a/src/Request.php b/src/Request.php new file mode 100644 index 0000000..1834fc2 --- /dev/null +++ b/src/Request.php @@ -0,0 +1,298 @@ +method = $method; + $this->uri = $uri; + $this->headers = $this->setHeaders($headers); + $this->body = $body ?? new Stream(fopen('php://temp', 'r+')); + $this->protocolVersion = $protocolVersion; + } + + /** + * @return string + */ + public function getProtocolVersion(): string + { + return $this->protocolVersion; + } + + /** + * @param string $version + * + * @return MessageInterface + */ + public function withProtocolVersion(string $version): MessageInterface + { + $request = clone $this; + $request->protocolVersion = $version; + + return $request; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * @param string $name + * + * @return bool + */ + public function hasHeader(string $name): bool + { + return isset($this->headers[strtolower($name)]); + } + + /** + * @param string $name + * + * @return array|string[] + */ + public function getHeader(string $name): array + { + return $this->headers[strtolower($name)] ?? []; + } + + /** + * @param string $name + * + * @return string + */ + public function getHeaderLine(string $name): string + { + return implode(', ', $this->getHeader($name)); + } + + /** + * @param string $name + * @param $value + * + * @return MessageInterface + */ + public function withHeader(string $name, $value): MessageInterface + { + $request = clone $this; + $request->headers[strtolower($name)] = is_array($value) ? $value : [$value]; + + return $request; + } + + /** + * @param string $name + * @param $value + * + * @return MessageInterface + */ + public function withAddedHeader(string $name, $value): MessageInterface + { + $name = strtolower($name); + $request = clone $this; + $request->headers[$name] = array_merge($this->headers[$name] ?? [], is_array($value) ? $value : [$value]); + + return $request; + } + + /** + * @param string $name + * + * @return MessageInterface + */ + public function withoutHeader(string $name): MessageInterface + { + $request = clone $this; + unset($request->headers[strtolower($name)]); + + return $request; + } + + /** + * @return StreamInterface + */ + public function getBody(): StreamInterface + { + return $this->body; + } + + /** + * @param StreamInterface $body + * + * @return MessageInterface + */ + public function withBody(StreamInterface $body): MessageInterface + { + $request = clone $this; + $request->body = $body; + + return $request; + } + + /** + * @return string + */ + public function getRequestTarget(): string + { + $path = $this->uri->getPath(); + $query = $this->uri->getQuery(); + + return $path + ? ($query ? sprintf('%s?%s', $path, $query) : $path) + : ($query ? sprintf('/?%s', $query) : '/'); + } + + /** + * @param string $requestTarget + * + * @return RequestInterface + */ + public function withRequestTarget(string $requestTarget): RequestInterface + { + $request = clone $this; + $request->uri = $request->uri->withPath(strtok($requestTarget, '?')); + $request->uri = $request->uri->withQuery(substr(strstr($requestTarget, '?'), 1)); + + return $request; + } + + /** + * @return string + */ + public function getMethod(): string + { + return $this->method; + } + + /** + * @param string $method + * + * @return RequestInterface + */ + public function withMethod(string $method): RequestInterface + { + $request = clone $this; + $request->method = $method; + + return $request; + } + + /** + * @return UriInterface + */ + public function getUri(): UriInterface + { + return $this->uri; + } + + /** + * @param UriInterface $uri + * @param bool $preserveHost + * + * @return RequestInterface + */ + public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface + { + $request = clone $this; + $request->uri = $uri; + + if (!$preserveHost) { + $request->uri = $request->uri->withHost($request->getHeaderLine('host')); + } + + return $request; + } + + /** + * @return array + */ + public function getFlatHeaders(): array + { + $headerStrings = []; + + foreach ($this->headers as $header => $values) { + foreach ($values as $value) { + $headerStrings[] = "$header: $value"; + } + } + + return $headerStrings; + } + + /** + * @return array + */ + public function getData(): array + { + $contentType = $this->getHeaderLine('Content-Type'); + $bodyContents = $this->getBody()->getContents(); + + if ($contentType === 'application/json') { + $data = json_decode($bodyContents, true); + } else if ($contentType === 'application/x-www-form-urlencoded') { + parse_str($bodyContents, $data); + } else { + $data = []; + } + + return $data; + } + + /** + * @param string $key + * + * @return mixed + */ + public function get(string $key): mixed + { + return $this->getData()[$key] ?? null; + } + + /** + * @param string $name + * @param string|null $default + * + * @return string|null + */ + public function getQueryParam(string $name, ?string $default = null): ?string + { + return $this->getQueryParams()[$name] ?? $default; + } + + /** + * @return array + */ + protected function getQueryParams(): array + { + $queryParams = []; + + parse_str($this->uri->getQuery(), $queryParams); + + return $queryParams; + } +} \ No newline at end of file diff --git a/src/Response.php b/src/Response.php new file mode 100644 index 0000000..b593b38 --- /dev/null +++ b/src/Response.php @@ -0,0 +1,187 @@ +statusCode = $statusCode; + $this->reasonPhrase = $reasonPhrase; + $this->headers = $this->setHeaders($headers); + $this->body = $body ?? new Stream(fopen('php://temp', 'r+')); + $this->protocolVersion = $protocolVersion; + } + + /** + * @return string + */ + public function getProtocolVersion(): string + { + return $this->protocolVersion; + } + + /** + * @param string $version + * + * @return ResponseInterface + */ + public function withProtocolVersion(string $version): ResponseInterface + { + $response = clone $this; + $response->protocolVersion = $version; + + return $response; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * @param string $name + * + * @return bool + */ + public function hasHeader(string $name): bool + { + return isset($this->headers[strtolower($name)]); + } + + /** + * @param string $name + * + * @return array|string[] + */ + public function getHeader(string $name): array + { + return $this->headers[strtolower($name)] ?? []; + } + + /** + * @param string $name + * + * @return string + */ + public function getHeaderLine(string $name): string + { + return implode(', ', $this->getHeader($name)); + } + + /** + * @param string $name + * @param $value + * + * @return ResponseInterface + */ + public function withHeader(string $name, $value): ResponseInterface + { + $response = clone $this; + $name = strtolower($name); + $response->headers[$name] = is_array($value) ? $value : [$value]; + + return $response; + } + + /** + * @param string $name + * @param $value + * + * @return ResponseInterface + */ + public function withAddedHeader(string $name, $value): ResponseInterface + { + $response = clone $this; + $name = strtolower($name); + $response->headers[$name] = array_merge($this->headers[$name] ?? [], is_array($value) ? $value : [$value]); + + return $response; + } + + /** + * @param string $name + * + * @return ResponseInterface + */ + public function withoutHeader(string $name): ResponseInterface + { + $response = clone $this; + $name = strtolower($name); + unset($response->headers[$name]); + + return $response; + } + + /** + * @return StreamInterface + */ + public function getBody(): StreamInterface + { + return $this->body; + } + + /** + * @param StreamInterface $body + * + * @return ResponseInterface + */ + public function withBody(StreamInterface $body): ResponseInterface + { + $response = clone $this; + $response->body = $body; + + return $response; + } + + /** + * @return int + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * @param $code + * @param $reasonPhrase + * + * @return ResponseInterface + */ + public function withStatus($code, $reasonPhrase = ''): ResponseInterface + { + $response = clone $this; + $response->statusCode = $code; + $response->reasonPhrase = $reasonPhrase; + + return $response; + } + + /** + * @return string + */ + public function getReasonPhrase(): string + { + return $this->reasonPhrase; + } +} \ No newline at end of file diff --git a/src/Stream.php b/src/Stream.php new file mode 100644 index 0000000..86e92b9 --- /dev/null +++ b/src/Stream.php @@ -0,0 +1,240 @@ +resource = $resource; + $this->rewind(); + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->getContents(); + } + + /** + * @return void + */ + public function close(): void + { + if (is_resource($this->resource)) { + fclose($this->resource); + } + } + + /** + * @return resource + */ + public function detach(): mixed + { + $resource = $this->resource; + $this->resource = null; + + return $resource; + } + + /** + * @return int|null + */ + public function getSize(): ?int + { + if (!$this->resource) { + return null; + } + + $stats = fstat($this->resource); + + return $stats['size'] ?? null; + } + + /** + * @return int + */ + public function tell(): int + { + if (!$this->resource) { + throw new \RuntimeException('Stream is detached'); + } + + $position = ftell($this->resource); + + if ($position === false) { + throw new \RuntimeException('Unable to get the stream position'); + } + + return $position; + } + + /** + * @return bool + */ + public function eof(): bool + { + if (!$this->resource) { + throw new \RuntimeException('Stream is detached'); + } + + return feof($this->resource); + } + + /** + * @return bool + */ + public function isSeekable(): bool + { + if (!$this->resource) { + return false; + } + + $meta = stream_get_meta_data($this->resource); + + return isset($meta['seekable']) && $meta['seekable']; + } + + /** + * @param int $offset + * @param int $whence + * + * @return void + */ + public function seek(int $offset, int $whence = SEEK_SET): void + { + if (!$this->isSeekable()) { + throw new \RuntimeException('Stream is not seekable'); + } + + if (fseek($this->resource, $offset, $whence) === -1) { + throw new \RuntimeException('Unable to seek to stream position ' . $offset); + } + } + + /** + * @return void + */ + public function rewind(): void + { + $this->seek(0); + } + + /** + * @return bool + */ + public function isWritable(): bool + { + if (!$this->resource) { + return false; + } + + $meta = stream_get_meta_data($this->resource); + + return isset($meta['mode']) && (str_contains($meta['mode'], 'w') || str_contains($meta['mode'], 'a')); + } + + /** + * @param string $string + * + * @return int + */ + public function write(string $string): int + { + if (!$this->isWritable()) { + throw new \RuntimeException('Stream is not writable'); + } + + $result = fwrite($this->resource, $string); + + if ($result === false) { + throw new \RuntimeException('Error writing to stream'); + } + + return $result; + } + + /** + * @return bool + */ + public function isReadable(): bool + { + if (!$this->resource) { + return false; + } + + $meta = stream_get_meta_data($this->resource); + + return isset($meta['mode']) && (str_contains($meta['mode'], 'r') || str_contains($meta['mode'], 'a') || str_contains($meta['mode'], '+')); + } + + /** + * @param int $length + * + * @return string + */ + public function read(int $length): string + { + if (!$this->isReadable()) { + throw new \RuntimeException('Stream is not readable'); + } + + $data = fread($this->resource, $length); + + if ($data === false) { + throw new \RuntimeException('Error reading from stream'); + } + + return $data; + } + + /** + * @return string + */ + public function getContents(): string + { + if (!$this->isReadable()) { + throw new \RuntimeException('Stream is not readable'); + } + + $contents = stream_get_contents($this->resource); + + if ($contents === false) { + throw new \RuntimeException('Error getting stream contents'); + } + + $this->rewind(); + + return $contents; + } + + /** + * @param mixed $key + * + * @return mixed + */ + public function getMetadata(mixed $key = null): mixed + { + if (!$this->resource) { + return $key ? null : []; + } + + $meta = stream_get_meta_data($this->resource); + + if ($key === null) { + return $meta; + } + + return $meta[$key] ?? null; + } +} \ No newline at end of file diff --git a/src/StreamBuilder.php b/src/StreamBuilder.php new file mode 100644 index 0000000..33c648e --- /dev/null +++ b/src/StreamBuilder.php @@ -0,0 +1,22 @@ +write($body); + $stream->rewind(); + + return $stream; + } +} \ No newline at end of file diff --git a/src/Traits/ResolveHeadersTrait.php b/src/Traits/ResolveHeadersTrait.php new file mode 100644 index 0000000..3004873 --- /dev/null +++ b/src/Traits/ResolveHeadersTrait.php @@ -0,0 +1,22 @@ + $header) { + $sortedHeaders[strtolower($key)] = is_array($header) ? $header : [$header]; + } + + return $sortedHeaders; + } +} \ No newline at end of file diff --git a/src/Uri.php b/src/Uri.php new file mode 100644 index 0000000..cc21f14 --- /dev/null +++ b/src/Uri.php @@ -0,0 +1,217 @@ +scheme = $scheme; + $this->host = $host; + $this->port = (string) $port; + $this->path = $path; + $this->query = $query; + $this->fragment = ''; + $this->userInfo = ''; + } + + /** + * @return string + */ + public function getScheme(): string + { + return $this->scheme; + } + + /** + * @return string + */ + public function getAuthority(): string + { + $authority = $this->host; + + if (!empty($this->userInfo)) { + $authority = sprintf('%s@%s', $this->userInfo, $authority); + } + + if (!empty($this->port)) { + $authority = sprintf('%s:%s', $authority, $this->port); + } + + return $authority; + } + + /** + * @return string + */ + public function getUserInfo(): string + { + return $this->userInfo; + } + + /** + * @return string + */ + public function getHost(): string + { + return $this->host; + } + + /** + * @return int|null + */ + public function getPort(): ?int + { + return (int) $this->port; + } + + /** + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + /** + * @return string + */ + public function getQuery(): string + { + return $this->query; + } + + /** + * @return string + */ + public function getFragment(): string + { + return $this->fragment; + } + + /** + * @param string $scheme + * + * @return UriInterface + */ + public function withScheme(string $scheme): UriInterface + { + $uri = clone $this; + $uri->scheme = $scheme; + + return $uri; + } + + /** + * @param string $user + * @param string|null $password + * + * @return UriInterface + */ + public function withUserInfo(string $user, ?string $password = null): UriInterface + { + $uri = clone $this; + $uri->userInfo = $password ? sprintf('%s:%s', $user, $password) : $user; + + return $uri; + } + + /** + * @param string $host + * + * @return UriInterface + */ + public function withHost(string $host): UriInterface + { + $uri = clone $this; + $uri->host = $host; + + return $uri; + } + + /** + * @param int|null $port + * + * @return UriInterface + */ + public function withPort(?int $port): UriInterface + { + $uri = clone $this; + $uri->port = $port; + + return $uri; + } + + /** + * @param string $path + * + * @return UriInterface + */ + public function withPath(string $path): UriInterface + { + $uri = clone $this; + $uri->path = $path; + + return $uri; + } + + /** + * @param string $query + * + * @return UriInterface + */ + public function withQuery(string $query): UriInterface + { + $uri = clone $this; + $uri->query = $query; + + return $uri; + } + + /** + * @param string $fragment + * + * @return UriInterface + */ + public function withFragment(string $fragment): UriInterface + { + $uri = clone $this; + $uri->fragment = $fragment; + + return $uri; + } + + /** + * @return string + */ + public function __toString(): string + { + $uri = sprintf('%s://%s', $this->scheme, $this->host); + + if (!empty($this->port)) { + $uri .= sprintf(':%d', $this->port); + } + + $uri .= $this->path; + + if (!empty($this->query)) { + $uri .= sprintf('?%s', $this->query); + } + + if (!empty($this->fragment)) { + $uri .= sprintf('#%s', $this->fragment); + } + + return $uri; + } +} \ No newline at end of file diff --git a/src/Web/WebServerUri.php b/src/Web/WebServerUri.php new file mode 100644 index 0000000..34f6914 --- /dev/null +++ b/src/Web/WebServerUri.php @@ -0,0 +1,26 @@ +sendRequest($request); + + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * @param string $method + * + * @dataProvider methodProvider + * + * @return void + */ + public function testItSendsRequest(string $method): void + { + $uri = new Uri('http', 'localhost', '/tests/api.php', '', 8000); + $request = new Request($method, $uri, []); + + $response = (new HttpClient())->sendRequest($request); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @return void + */ + public function testGet(): void + { + $this->assertEquals(200, (new HttpClient())->get(...$this->getDummyRequestData())->getStatusCode()); + } + + /** + * @return void + */ + public function testPost(): void + { + $this->assertEquals(200, (new HttpClient())->post(...$this->getDummyRequestData())->getStatusCode()); + } + + /** + * @return void + */ + public function testPut(): void + { + $this->assertEquals(200, (new HttpClient())->put(...$this->getDummyRequestData())->getStatusCode()); + } + + /** + * @return void + */ + public function testPatch(): void + { + $this->assertEquals(200, (new HttpClient())->patch(...$this->getDummyRequestData())->getStatusCode()); + } + + /** + * @return void + */ + public function testDelete(): void + { + $this->assertEquals(200, (new HttpClient())->delete(...$this->getDummyRequestData())->getStatusCode()); + } + + private function getDummyRequestData(): array + { + return [ + 'http://localhost:8000/tests/api.php', + [ + 'Content-Type' => 'application/json', + ], + '' + ]; + } +} \ No newline at end of file diff --git a/tests/RequestTest.php b/tests/RequestTest.php new file mode 100644 index 0000000..36bd04c --- /dev/null +++ b/tests/RequestTest.php @@ -0,0 +1,220 @@ +simpleGetRequest(); + + $this->assertEquals(['content-type' => ['application/json']], $request->getHeaders()); + } + + /** + * @return void + * + * @throws MockObjectException + */ + public function testMultipleHeaders(): void + { + [$uri, $body] = $this->getMockObjects(); + + $request = new Request('GET', $uri, ['Content-Type' => ['application/json', 'text/plain']], $body); + + $this->assertEquals(['content-type' => ['application/json', 'text/plain']], $request->getHeaders()); + } + + /** + * @return void + * + * @throws MockObjectException + */ + public function testWithHeader(): void + { + $request = $this->simpleGetRequest(); + + $request = $request->withHeader('Content-Type', 'text/html'); + + $this->assertEquals(['content-type' => ['text/html']], $request->getHeaders()); + } + + /** + * @return void + * + * @throws MockObjectException + */ + public function testWithAddedHeader(): void + { + $request = $this->simpleGetRequest(); + + $request = $request->withAddedHeader('Content-Type', 'text/html'); + + $this->assertEquals(['content-type' => ['application/json', 'text/html']], $request->getHeaders()); + } + + /** + * @param string $key + * + * @dataProvider headerKeyProvider + * + * @return void + * + * @throws MockObjectException + */ + public function testHasHeader(string $key): void + { + $request = $this->simpleGetRequest(); + + $this->assertTrue($request->hasHeader($key)); + } + + /** + * @return void + * + * @throws MockObjectException + */ + public function testWithoutHeader(): void + { + $request = $this->simpleGetRequest(); + + $request = $request->withoutHeader('CONTENT-TYPE'); + + $this->assertEquals([], $request->getHeaders()); + } + + /** + * @return void + * + * @throws MockObjectException + */ + public function testGetHeaderLine(): void + { + $request = $this->simpleGetRequest(); + + $this->assertEquals('application/json', $request->getHeaderLine('content-type')); + } + + /** + * @param string $method + * + * @dataProvider methodProvider + * + * @return void + * + * @throws MockObjectException + */ + public function testWithMethod(string $method): void + { + $request = $this->simpleGetRequest(); + + $request = $request->withMethod($method); + + $this->assertEquals($method, $request->getMethod()); + } + + /** + * @return void + */ + public function testGetRequestTarget(): void + { + $uri = new Uri('https', 'localhost', '/test', 'hello=world'); + $body = StreamBuilder::build('test'); + $request = new Request('GET', $uri, ['Content-Type' => 'application/json'], $body); + + $this->assertEquals('/test?hello=world', $request->getRequestTarget()); + } + + /** + * @return void + */ + public function testWithRequestTarget(): void + { + $uri = new Uri('https', 'localhost', '/test', 'hello=world'); + $body = StreamBuilder::build('test'); + $request = new Request('GET', $uri, ['Content-Type' => 'application/json'], $body); + + $request = $request->withRequestTarget('/default?var=1'); + + $this->assertEquals('/default?var=1', $request->getRequestTarget()); + } + + /** + * @return void + * + * @throws MockObjectException + */ + public function testWithUri(): void + { + $request = $this->simpleGetRequest(); + $uri = new Uri('https', 'localhost', '/test', 'hello=world'); + + $request = $request->withUri($uri, true); + + $this->assertSame($uri, $request->getUri()); + } + + /** + * @return void + * + * @throws MockObjectException + */ + public function testGetData(): void + { + // application/json + $uri = $this->createMock(Uri::class); + $body = StreamBuilder::build('{"key":"value"}'); + $request = new Request('GET', $uri, ['Content-Type' => 'application/json'], $body); + $this->assertEquals(['key' => 'value'], $request->getData()); + + // application/x-www-form-urlencoded + $body = StreamBuilder::build('key=value'); + $request = new Request('GET', $uri, ['Content-Type' => 'application/x-www-form-urlencoded'], $body); + $this->assertEquals(['key' => 'value'], $request->getData()); + + // other + $body = StreamBuilder::build('key=value'); + $request = new Request('GET', $uri, ['Content-Type' => 'text/plain'], $body); + $this->assertEquals([], $request->getData()); + } + + /** + * @return array + * + * @throws MockObjectException + */ + private function getMockObjects(): array + { + return [ + $this->createMock(Uri::class), + $this->createMock(Stream::class), + ]; + } + + /** + * @return Request + * + * @throws MockObjectException + */ + private function simpleGetRequest(): Request + { + [$uri, $body] = $this->getMockObjects(); + + return new Request('GET', $uri, ['Content-Type' => 'application/json'], $body); + } +} \ No newline at end of file diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php new file mode 100644 index 0000000..8a82f4b --- /dev/null +++ b/tests/ResponseTest.php @@ -0,0 +1,119 @@ +assertEquals('1.1', (new Response())->getProtocolVersion()); + } + + /** + * @return void + */ + public function testWithProtocolVersion(): void + { + $response = new Response(); + $response = $response->withProtocolVersion('2.0'); + + $this->assertEquals('2.0', $response->getProtocolVersion()); + } + + /** + * @return void + */ + public function testGetHeaders(): void + { + $this->assertEquals(['content-type' => ['application/json']], ($this->getSimpleResponse())->getHeaders()); + } + + /** + * @param string $key + * + * @dataProvider headerKeyProvider + * + * @return void + */ + public function testHasHeader(string $key): void + { + $this->assertTrue(($this->getSimpleResponse())->hasHeader($key)); + } + + /** + * @return void + */ + public function testGetHeader(): void + { + $this->assertEquals(['application/json'], ($this->getSimpleResponse())->getHeader('CONTENT-TYPE')); + } + + /** + * @return void + */ + public function testWithHeader(): void + { + $response = $this->getSimpleResponse(); + $response = $response->withHeader('content-type', 'text/html'); + + $this->assertEquals(['text/html'], $response->getHeader('content-type')); + } + + /** + * @return void + */ + public function testWithAddedHeader(): void + { + $response = $this->getSimpleResponse(); + $response = $response->withAddedHeader('content-type', 'text/html'); + + $this->assertEquals(['application/json', 'text/html'], $response->getHeader('content-type')); + } + + /** + * @return void + */ + public function testGetHeaderLine(): void + { + $response = $this->getSimpleResponse(); + $response = $response->withAddedHeader('content-type', 'text/html'); + + $this->assertEquals('application/json, text/html', $response->getHeaderLine('content-type')); + } + + /** + * @return void + */ + public function testWithoutHeader(): void + { + $response = $this->getSimpleResponse(); + $response = $response->withoutHeader('CONTENT-TYPE'); + + $this->assertEquals([], $response->getHeaders()); + } + + /** + * @return void + */ + public function testWithStatus(): void + { + $response = $this->getSimpleResponse(); + $response = $response->withStatus(404); + + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * @return Response + */ + private function getSimpleResponse(): Response + { + return new Response(200, 'OK', ['Content-Type' => 'application/json']); + } +} \ No newline at end of file diff --git a/tests/StreamTest.php b/tests/StreamTest.php new file mode 100644 index 0000000..64d07e8 --- /dev/null +++ b/tests/StreamTest.php @@ -0,0 +1,182 @@ +getWritableStreamWithData(); + + $this->assertEquals($data, $stream->__toString()); + + $stream->close(); + } + + /** + * @return void + */ + public function testGetSize(): void + { + [$stream, $data] = $this->getWritableStreamWithData(); + + $this->assertEquals(strlen($data), $stream->getSize()); + + $stream->close(); + } + + /** + * @return void + */ + public function testTell(): void + { + [$stream] = $this->getWritableStreamWithData(); + + $this->assertEquals(0, $stream->tell()); + $stream->seek(5); + $this->assertEquals(5, $stream->tell()); + + $stream->close(); + } + + /** + * @return void + */ + public function testClose(): void + { + $resource = fopen('php://temp', 'r+'); + $stream = new Stream($resource); + + $this->assertTrue(is_resource($resource)); + + $stream->close(); + + $this->assertFalse(is_resource($resource)); + } + + /** + * @return void + */ + public function testDetach(): void + { + $resource = fopen('php://temp', 'r+'); + $stream = new Stream($resource); + + $detachedResource = $stream->detach(); + + $this->assertSame($resource, $detachedResource); + $this->assertNull($stream->detach()); + + $stream->close(); + } + + /** + * @return void + */ + public function testEof(): void + { + [$stream, $data] = $this->getWritableStreamWithData(); + + $this->assertFalse($stream->eof()); + + $stream->read(strlen($data) + 1); + + $this->assertTrue($stream->eof()); + + $stream->close(); + } + + /** + * @return void + */ + public function testIsSeekableWithSeekableResource(): void + { + [$stream] = $this->getWritableStreamWithData(); + + $this->assertTrue($stream->isSeekable()); + + $stream->close(); + } + + /** + * @return void + */ + public function testSeek(): void + { + [$stream] = $this->getWritableStreamWithData(); + + $stream->seek(5); + + $this->assertEquals('string', $stream->getContents()); + + $stream->close(); + } + + /** + * @return void + */ + public function testRead(): void + { + [$stream] = $this->getWritableStreamWithData(); + + $read = []; + + $read[] = $stream->read(4); + $read[] = $stream->read(7); + $read[] = $stream->read(12); + + $this->assertSame('Test', $read[0]); + $this->assertSame(' string', $read[1]); + $this->assertSame('', $read[2]); + } + + /** + * @return void + */ + public function testWrite(): void + { + $resource = fopen('php://temp', 'r+'); + $stream = new Stream($resource); + + $writeData = 'Some text'; + $stream->write($writeData); + + $stream->rewind(); + + $this->assertSame(strlen($writeData), $stream->getSize()); + $this->assertSame($writeData, $stream->getContents()); + + $stream->close(); + } + + /** + * @return void + */ + public function testGetMetadata(): void + { + [$stream] = $this->getWritableStreamWithData(); + + $this->assertNotNull($stream->getMetadata('wrapper_type')); + + $stream->close(); + } + + /** + * @return array + */ + private function getWritableStreamWithData(): array + { + $data = 'Test string'; + $resource = fopen('php://temp', 'r+'); + fwrite($resource, 'Test string'); + $stream = new Stream($resource); + + return [$stream, $data]; + } +} \ No newline at end of file diff --git a/tests/Traits/ProvidesHeaderDataTrait.php b/tests/Traits/ProvidesHeaderDataTrait.php new file mode 100644 index 0000000..e72c1f5 --- /dev/null +++ b/tests/Traits/ProvidesHeaderDataTrait.php @@ -0,0 +1,45 @@ + [ + 'POST', + ], + 'PUT' => [ + 'PUT', + ], + 'DELETE' => [ + 'DELETE', + ], + 'GET' => [ + 'GET', + ], + 'PATCH' => [ + 'PATCH', + ] + ]; + } + + public static function headerKeyProvider(): array + { + return [ + 'Content-Type' => [ + 'key' => 'Content-Type', + ], + 'content-type' => [ + 'key' => 'content-type', + ], + 'CONTENT-TYPE' => [ + 'key' => 'CONTENT-TYPE', + ], + ]; + } +} \ No newline at end of file diff --git a/tests/UriTest.php b/tests/UriTest.php new file mode 100644 index 0000000..4bfdb7a --- /dev/null +++ b/tests/UriTest.php @@ -0,0 +1,109 @@ +assertEquals('https', ($this->getDefaultUri())->getScheme()); + } + + /** + * @return void + */ + public function testGetAuthority(): void + { + $uri = new Uri('https', 'test.com', '/path', 'var=1', 443); + $this->assertEquals('test.com:443', $uri->getAuthority()); + + $uri = new Uri('ftp', 'user:password@test.com', '/path', 'var=1'); + $this->assertEquals('user:password@test.com', $uri->getAuthority()); + } + + /** + * @return void + */ + public function testWithPath(): void + { + $this->assertEquals('/', ($this->getDefaultUri())->getPath()); + $this->assertEquals('/new', ($this->getDefaultUri())->withPath('/new')->getPath()); + } + + /** + * @return void + */ + public function testToString(): void + { + $this->assertEquals('https://test.com/', ($this->getDefaultUri())->__toString()); + } + + /** + * @return void + */ + public function testWithUserInfo(): void + { + $this->assertEquals( + 'username:password', + ($this->getDefaultUri())->withUserInfo('username', 'password')->getUserInfo() + ); + } + + /** + * @return void + */ + public function testWithScheme(): void + { + $this->assertEquals('ftp', ($this->getDefaultUri())->withScheme('ftp')->getScheme()); + } + + /** + * @return void + */ + public function testWithHost(): void + { + $this->assertEquals('localhost', ($this->getDefaultUri())->withHost('localhost')->getHost()); + } + + /** + * @return void + */ + public function testWithPort(): void + { + $this->assertEquals(8080, ($this->getDefaultUri())->withPort(8080)->getPort()); + } + + /** + * @return void + */ + public function testWithQuery(): void + { + $this->assertEquals( + 'name=test&hello=world', + ($this->getDefaultUri())->withQuery('name=test&hello=world')->getQuery() + ); + } + + /** + * @return void + */ + public function testWithFragment(): void + { + $this->assertEquals('news', ($this->getDefaultUri())->withFragment('news')->getFragment()); + } + + /** + * @return UriInterface + */ + private function getDefaultUri(): UriInterface + { + return new Uri('https', 'test.com', '/', ''); + } +} \ No newline at end of file diff --git a/tests/api.php b/tests/api.php new file mode 100644 index 0000000..96fc9d0 --- /dev/null +++ b/tests/api.php @@ -0,0 +1,3 @@ +