Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/HttpClient/Client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace App\HttpClient;

use App\HttpClient\Exception\ConnectionError;

interface Client
{
/**
* @throws ConnectionError When a connection could not be established
*/
public function request(Request $request, ?ClientOptions $options = null): Response;
}
20 changes: 20 additions & 0 deletions src/HttpClient/ClientOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\HttpClient;

class ClientOptions
{
private $timeoutInSeconds = 3;

public function setTimeout(int $timeoutInSeconds): self
{
$this->timeoutInSeconds = $timeoutInSeconds;

return $this;
}

public function getTimeout(): int
{
return $this->timeoutInSeconds;
}
}
7 changes: 7 additions & 0 deletions src/HttpClient/Exception/ConnectionError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace App\HttpClient\Exception;

class ConnectionError extends \Exception
{
}
7 changes: 7 additions & 0 deletions src/HttpClient/Exception/InvalidHeaderKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace App\HttpClient\Exception;

class InvalidHeaderKey extends \Exception
{
}
7 changes: 7 additions & 0 deletions src/HttpClient/Exception/InvalidHeaderValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace App\HttpClient\Exception;

class InvalidHeaderValue extends \Exception
{
}
61 changes: 61 additions & 0 deletions src/HttpClient/Header.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace App\HttpClient;

use App\HttpClient\Exception\InvalidHeaderKey;
use App\HttpClient\Exception\InvalidHeaderValue;

class Header
{
private $key;

private $normalizedKey;

private $value;

/**
* @throws InvalidHeaderKey When a the key contains invalid characters
* @throws InvalidHeaderValue When a the value contains invalid characters
*/
public function __construct(string $key, string $value)
{
if (strpos($key, "\r\n") !== false || strpos($key, ':') !== false) {
throw new InvalidHeaderKey();
}

if (strpos($value, "\r\n") !== false) {
throw new InvalidHeaderValue();
}

$this->key = $key;
$this->normalizedKey = strtolower($key);
$this->value = $value;
}

public static function createFromString(string $header): self
{
$headerParts = explode(': ', $header);

return new self($headerParts[0], $headerParts[1]);
}

public function getKey(): string
{
return $this->key;
}

public function getNormalizedKey(): string
{
return $this->normalizedKey;
}

public function getValue(): string
{
return $this->value;
}

public function toString(): string
{
return sprintf("%s: %s\r\n", $this->key, $this->value);
}
}
38 changes: 38 additions & 0 deletions src/HttpClient/NativeClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace App\HttpClient;

use App\HttpClient\Exception\ConnectionError;

class NativeClient implements Client
{
private $options;

public function __construct(?ClientOptions $options = null)
{
$this->options = $options ?? new ClientOptions();
}

public function request(Request $request, ?ClientOptions $options = null): Response
{
$options = $options ?? $this->options;

$streamContext = stream_context_create([
'http' => [
'method' => $request->getMethod(),
'header' => $request->getHeadersAsString(),
'content' => $request->getBody(),
'ignore_errors' => true,
'timeout' => $options->getTimeout(),
],
]);

$responseBody = @file_get_contents($request->getUri(), false, $streamContext);

if ($responseBody === false) {
throw new ConnectionError(error_get_last()['message']);
}

return new Response($responseBody, ...$http_response_header);
}
}
82 changes: 82 additions & 0 deletions src/HttpClient/Request.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace App\HttpClient;

class Request
{
private const DEFAULT_USER_AGENT = 'PHP.net HTTP client';

private const DEFAULT_POST_CONTENT_TYPE = 'application/x-www-form-urlencoded';

private $uri;

private $method;

private $headers = [];

private $body;

public function __construct(string $uri, string $method = 'GET')
{
$this->uri = $uri;
$this->method = $method;

$this->headers['user-agent'] = new Header('User-Agent', self::DEFAULT_USER_AGENT);

if ($method === 'POST') {
$this->headers['content-type'] = new Header('Content-Type', self::DEFAULT_POST_CONTENT_TYPE);
}
}

public function addHeaders(Header ...$headers): self
{
foreach ($headers as $header) {
$this->headers[$header->getNormalizedKey()] = $header;
}

return $this;
}

public function setBody(string $body): self
{
$this->body = $body;

return $this;
}

public function getUri(): string
{
return $this->uri;
}

public function getMethod(): string
{
return $this->method;
}

/**
* @return Header[]
*/
public function getHeaders(): array
{
return $this->headers;
}

public function getHeadersAsString(): string
{
return array_reduce($this->headers, function (string $headers, Header $header) {
$headers .= sprintf("%s: %s\r\n", $header->getKey(), $header->getValue());

return $headers;
}, '');
}

public function getBody(): string
{
if ($this->body === null) {
return '';
}

return $this->body;
}
}
66 changes: 66 additions & 0 deletions src/HttpClient/Response.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace App\HttpClient;

class Response
{
private const STATUS_LINE_PATTERN = '~^HTTP/(?P<protocolVersion>\d+\.\d+) (?P<statusCode>\d{3})(?: (?P<reasonPhrase>.+))?~';

private $protocolVersion;

private $statusCode;

private $reasonPhrase;

private $headers = [];

private $body;

public function __construct(string $body, string ...$headers)
{
$this->body = $body;

$this->parseStatusLine(array_shift($headers));

foreach ($headers as $header) {
$this->headers[] = Header::createFromString($header);
}
}

private function parseStatusLine(string $statusLine):void
{
preg_match(self::STATUS_LINE_PATTERN, $statusLine, $matches);

$this->protocolVersion = $matches['protocolVersion'];
$this->statusCode = $matches['statusCode'];
$this->reasonPhrase = $matches['reasonPhrase'] ?? null;
}

public function getProtocolVersion(): string
{
return $this->protocolVersion;
}

public function getStatusCode(): int
{
return $this->statusCode;
}

public function getReasonPhrase(): ?string
{
return $this->reasonPhrase;
}

/**
* @return Header[]
*/
public function getHeaders(): array
{
return $this->headers;
}

public function getBody(): string
{
return $this->body;
}
}
19 changes: 19 additions & 0 deletions tests/Unit/HttpClient/ClientOptionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php declare(strict_types=1);

namespace App\Tests\Unit\HttpClient;

use App\HttpClient\ClientOptions;
use PHPUnit\Framework\TestCase;

class ClientOptionsTest extends TestCase
{
public function testGetTimeoutUsesDefaultValue(): void
{
$this->assertSame(3, (new ClientOptions())->getTimeout());
}

public function testSetTimeoutSetsValue(): void
{
$this->assertSame(10, (new ClientOptions())->setTimeout(10)->getTimeout());
}
}
53 changes: 53 additions & 0 deletions tests/Unit/HttpClient/HeaderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php declare(strict_types=1);

namespace App\Tests\Unit\HttpClient;

use App\HttpClient\Exception\InvalidHeaderKey;
use App\HttpClient\Exception\InvalidHeaderValue;
use App\HttpClient\Header;
use PHPUnit\Framework\TestCase;

class HeaderTest extends TestCase
{
public function testCreateFromStringCorrectlyParsesAHeaderString(): void
{
$header = Header::createFromString('Foo: Bar');

$this->assertSame('Foo', $header->getKey());
$this->assertSame('Bar', $header->getValue());
}

public function testKeyMaintainsOriginalCasing(): void
{
$this->assertSame('fOo', (new Header('fOo', 'bar'))->getKey());
}

public function testKeyGetsNormalized(): void
{
$this->assertSame('foo-foo', (new Header('fOo-FOO', 'bar'))->getNormalizedKey());
}

public function testGetValue(): void
{
$this->assertSame('bar', (new Header('foo', 'bar'))->getValue());
}

public function testHeaderInjectionIsPreventedOnTheKey(): void
{
$this->expectException(InvalidHeaderKey::class);

new Header("foo\r\nbar", 'bar');
}

public function testHeaderInjectionIsPreventedOnTheValue(): void
{
$this->expectException(InvalidHeaderValue::class);

new Header('Foo', "foo\r\nbar");
}

public function testToString(): void
{
$this->assertSame("Foo: Bar\r\n", (new Header('Foo', 'Bar'))->toString());
}
}
Loading