Initial commit - clone from lumax/http-component

This commit is contained in:
Daniel Winning 2025-05-05 13:48:29 +01:00
commit 7dcdb01136
24 changed files with 2351 additions and 0 deletions

4
.gitattributes vendored Normal file
View file

@ -0,0 +1,4 @@
/tests/ export-ignore
phpunit.xml export-ignore
.gitignore export-ignore
.gitattributes export-ignore

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/.idea
/coverage
/vendor
/node_modules
/composer.lock
/.phpunit.result.cache

104
CHANGELOG.md Normal file
View file

@ -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

149
README.md Normal file
View file

@ -0,0 +1,149 @@
# Loom | HTTP Component
<div>
<!-- Version Badge -->
<img src="https://img.shields.io/badge/Version-1.0.0-blue" alt="Version 1.0.0">
<!-- PHP Coverage Badge -->
<img src="https://img.shields.io/badge/PHP Coverage-79.23%25-orange" alt="PHP Coverage 79.23%">
<!-- License Badge -->
<img src="https://img.shields.io/badge/License-GPL--3.0--or--later-34ad9b" alt="License GPL--3.0--or--later">
</div>
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).

27
composer.json Normal file
View file

@ -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"
}
}

21
package-lock.json generated Normal file
View file

@ -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"
}
}
}
}

13
package.json Normal file
View file

@ -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"
}
}

22
phpunit.xml Normal file
View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true"
bootstrap="vendor/autoload.php"
displayDetailsOnIncompleteTests="true"
displayDetailsOnSkippedTests="true"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnTestsThatTriggerNotices="true"
failOnWarning="true"
failOnRisky="true">
<testsuites>
<testsuite name="Unit Tests">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage/>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

4
spinner.yaml Normal file
View file

@ -0,0 +1,4 @@
options:
environment:
database:
enabled: false

197
src/HttpClient.php Normal file
View file

@ -0,0 +1,197 @@
<?php
namespace Loom\HttpComponent;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
class HttpClient implements ClientInterface
{
/**
* @param RequestInterface $request
*
* @return ResponseInterface
*/
public function sendRequest(RequestInterface $request): ResponseInterface
{
$curl = curl_init();
$this->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;
}
}

298
src/Request.php Normal file
View file

@ -0,0 +1,298 @@
<?php
namespace Loom\HttpComponent;
use Loom\HttpComponent\Traits\ResolveHeadersTrait;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
class Request implements RequestInterface
{
use ResolveHeadersTrait;
private UriInterface $uri;
private StreamInterface $body;
private string $method;
private array $headers;
private string $protocolVersion;
public function __construct(
string $method,
UriInterface $uri,
array $headers = [],
StreamInterface $body = null,
string $protocolVersion = '1.1'
) {
$this->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;
}
}

187
src/Response.php Normal file
View file

@ -0,0 +1,187 @@
<?php
namespace Loom\HttpComponent;
use Loom\HttpComponent\Traits\ResolveHeadersTrait;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
class Response implements ResponseInterface
{
use ResolveHeadersTrait;
private int $statusCode;
private string $reasonPhrase;
private array $headers;
private StreamInterface $body;
private string $protocolVersion;
public function __construct(
int $statusCode = 200,
string $reasonPhrase = '',
array $headers = [],
StreamInterface $body = null,
string $protocolVersion = '1.1'
) {
$this->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;
}
}

240
src/Stream.php Normal file
View file

@ -0,0 +1,240 @@
<?php
namespace Loom\HttpComponent;
use Psr\Http\Message\StreamInterface;
class Stream implements StreamInterface
{
private $resource;
public function __construct($resource)
{
if (!is_resource($resource)) {
throw new \InvalidArgumentException('Invalid resource provided for Stream');
}
$this->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;
}
}

22
src/StreamBuilder.php Normal file
View file

@ -0,0 +1,22 @@
<?php
namespace Loom\HttpComponent;
use Psr\Http\Message\StreamInterface;
class StreamBuilder
{
/**
* @param string $body
*
* @return StreamInterface
*/
public static function build(string $body): StreamInterface
{
$stream = new Stream(fopen('php://temp', 'r+'));
$stream->write($body);
$stream->rewind();
return $stream;
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace Loom\HttpComponent\Traits;
trait ResolveHeadersTrait
{
/**
* @param array $headers
*
* @return array
*/
protected function setHeaders(array $headers): array
{
$sortedHeaders = [];
foreach ($headers as $key => $header) {
$sortedHeaders[strtolower($key)] = is_array($header) ? $header : [$header];
}
return $sortedHeaders;
}
}

217
src/Uri.php Normal file
View file

@ -0,0 +1,217 @@
<?php
namespace Loom\HttpComponent;
use Psr\Http\Message\UriInterface;
class Uri implements UriInterface
{
private string $scheme;
private string $host;
private ?string $port;
private string $path;
private string $query;
private string $fragment;
private string $userInfo;
public function __construct(string $scheme, string $host, string $path, string $query, string|int $port = null)
{
$this->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;
}
}

26
src/Web/WebServerUri.php Normal file
View file

@ -0,0 +1,26 @@
<?php
namespace Loom\HttpComponent\Web;
use Loom\HttpComponent\Uri;
class WebServerUri
{
/**
* @return Uri
*/
public static function generate(): Uri
{
$scheme = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$urlParts = parse_url($scheme . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
$host = $urlParts['host'];
return new Uri(
$scheme,
$host,
$urlParts['path'],
$urlParts['query'] ?? '',
$urlParts['port'] ?? ($_SERVER['SERVER_PORT'] ?? null)
);
}
}

114
tests/HttpClientTest.php Normal file
View file

@ -0,0 +1,114 @@
<?php
namespace Loom\HttpComponentTests;
use Loom\HttpComponent\HttpClient;
use Loom\HttpComponent\Request;
use Loom\HttpComponent\Uri;
use Loom\HttpComponentTests\Traits\ProvidesHeaderDataTrait;
use PHPUnit\Framework\TestCase;
class HttpClientTest extends TestCase
{
use ProvidesHeaderDataTrait;
private static mixed $serverProcess;
/**
* @return void
*/
public static function setUpBeforeClass(): void
{
self::$serverProcess = proc_open('php -S localhost:8000', [], $pipes);
}
/**
* @return void
*/
public static function tearDownAfterClass(): void
{
proc_terminate(self::$serverProcess);
proc_close(self::$serverProcess);
}
/**
* @return void
*/
public function testNotFound(): void
{
$uri = new Uri('http', 'localhost', '/', '', 8000);
$request = new Request('GET', $uri, []);
$response = (new HttpClient())->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',
],
''
];
}
}

220
tests/RequestTest.php Normal file
View file

@ -0,0 +1,220 @@
<?php
namespace Loom\HttpComponentTests;
use Loom\HttpComponent\Request;
use Loom\HttpComponent\Stream;
use Loom\HttpComponent\StreamBuilder;
use Loom\HttpComponent\Uri;
use Loom\HttpComponentTests\Traits\ProvidesHeaderDataTrait;
use PHPUnit\Framework\MockObject\Exception as MockObjectException;
use PHPUnit\Framework\TestCase;
class RequestTest extends TestCase
{
use ProvidesHeaderDataTrait;
/**
* @return void
*
* @throws MockObjectException
*/
public function testGetHeaders(): void
{
$request = $this->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);
}
}

119
tests/ResponseTest.php Normal file
View file

@ -0,0 +1,119 @@
<?php
namespace Loom\HttpComponentTests;
use Loom\HttpComponent\Response;
use Loom\HttpComponentTests\Traits\ProvidesHeaderDataTrait;
use PHPUnit\Framework\TestCase;
class ResponseTest extends TestCase
{
use ProvidesHeaderDataTrait;
public function testGetProtocolVersion(): void
{
$this->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']);
}
}

182
tests/StreamTest.php Normal file
View file

@ -0,0 +1,182 @@
<?php
namespace Loom\HttpComponentTests;
use Loom\HttpComponent\Stream;
use PHPUnit\Framework\TestCase;
class StreamTest extends TestCase
{
/**
* @return void
*/
public function testToString(): void
{
[$stream, $data] = $this->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];
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace Loom\HttpComponentTests\Traits;
trait ProvidesHeaderDataTrait
{
/**
* @return array
*/
public static function methodProvider(): array
{
return [
'POST' => [
'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',
],
];
}
}

109
tests/UriTest.php Normal file
View file

@ -0,0 +1,109 @@
<?php
namespace Loom\HttpComponentTests;
use Loom\HttpComponent\Uri;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\UriInterface;
class UriTest extends TestCase
{
/**
* @return void
*/
public function testGetScheme(): void
{
$this->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', '/', '');
}
}

3
tests/api.php Normal file
View file

@ -0,0 +1,3 @@
<?php
echo 'Test API endpoint';