From 3537730bfbefc8085132267403cd71cca8e3ea29 Mon Sep 17 00:00:00 2001 From: Pieter Hordijk Date: Wed, 22 May 2019 00:04:26 +0300 Subject: [PATCH] Implemented an HTTP client --- src/HttpClient/Client.php | 13 ++ src/HttpClient/ClientOptions.php | 20 +++ src/HttpClient/Exception/ConnectionError.php | 7 ++ src/HttpClient/Exception/InvalidHeaderKey.php | 7 ++ .../Exception/InvalidHeaderValue.php | 7 ++ src/HttpClient/Header.php | 61 +++++++++ src/HttpClient/NativeClient.php | 38 ++++++ src/HttpClient/Request.php | 82 ++++++++++++ src/HttpClient/Response.php | 66 ++++++++++ tests/Unit/HttpClient/ClientOptionsTest.php | 19 +++ tests/Unit/HttpClient/HeaderTest.php | 53 ++++++++ tests/Unit/HttpClient/NativeClientTest.php | 71 +++++++++++ tests/Unit/HttpClient/RequestTest.php | 117 ++++++++++++++++++ tests/Unit/HttpClient/ResponseTest.php | 48 +++++++ 14 files changed, 609 insertions(+) create mode 100644 src/HttpClient/Client.php create mode 100644 src/HttpClient/ClientOptions.php create mode 100644 src/HttpClient/Exception/ConnectionError.php create mode 100644 src/HttpClient/Exception/InvalidHeaderKey.php create mode 100644 src/HttpClient/Exception/InvalidHeaderValue.php create mode 100644 src/HttpClient/Header.php create mode 100644 src/HttpClient/NativeClient.php create mode 100644 src/HttpClient/Request.php create mode 100644 src/HttpClient/Response.php create mode 100644 tests/Unit/HttpClient/ClientOptionsTest.php create mode 100644 tests/Unit/HttpClient/HeaderTest.php create mode 100644 tests/Unit/HttpClient/NativeClientTest.php create mode 100644 tests/Unit/HttpClient/RequestTest.php create mode 100644 tests/Unit/HttpClient/ResponseTest.php diff --git a/src/HttpClient/Client.php b/src/HttpClient/Client.php new file mode 100644 index 00000000..f88219e1 --- /dev/null +++ b/src/HttpClient/Client.php @@ -0,0 +1,13 @@ +timeoutInSeconds = $timeoutInSeconds; + + return $this; + } + + public function getTimeout(): int + { + return $this->timeoutInSeconds; + } +} diff --git a/src/HttpClient/Exception/ConnectionError.php b/src/HttpClient/Exception/ConnectionError.php new file mode 100644 index 00000000..b88d4683 --- /dev/null +++ b/src/HttpClient/Exception/ConnectionError.php @@ -0,0 +1,7 @@ +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); + } +} diff --git a/src/HttpClient/NativeClient.php b/src/HttpClient/NativeClient.php new file mode 100644 index 00000000..707752b6 --- /dev/null +++ b/src/HttpClient/NativeClient.php @@ -0,0 +1,38 @@ +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); + } +} diff --git a/src/HttpClient/Request.php b/src/HttpClient/Request.php new file mode 100644 index 00000000..b2cc1f38 --- /dev/null +++ b/src/HttpClient/Request.php @@ -0,0 +1,82 @@ +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; + } +} diff --git a/src/HttpClient/Response.php b/src/HttpClient/Response.php new file mode 100644 index 00000000..dab224d3 --- /dev/null +++ b/src/HttpClient/Response.php @@ -0,0 +1,66 @@ +\d+\.\d+) (?P\d{3})(?: (?P.+))?~'; + + 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; + } +} diff --git a/tests/Unit/HttpClient/ClientOptionsTest.php b/tests/Unit/HttpClient/ClientOptionsTest.php new file mode 100644 index 00000000..cef21081 --- /dev/null +++ b/tests/Unit/HttpClient/ClientOptionsTest.php @@ -0,0 +1,19 @@ +assertSame(3, (new ClientOptions())->getTimeout()); + } + + public function testSetTimeoutSetsValue(): void + { + $this->assertSame(10, (new ClientOptions())->setTimeout(10)->getTimeout()); + } +} diff --git a/tests/Unit/HttpClient/HeaderTest.php b/tests/Unit/HttpClient/HeaderTest.php new file mode 100644 index 00000000..8fa03351 --- /dev/null +++ b/tests/Unit/HttpClient/HeaderTest.php @@ -0,0 +1,53 @@ +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()); + } +} diff --git a/tests/Unit/HttpClient/NativeClientTest.php b/tests/Unit/HttpClient/NativeClientTest.php new file mode 100644 index 00000000..b6367182 --- /dev/null +++ b/tests/Unit/HttpClient/NativeClientTest.php @@ -0,0 +1,71 @@ +request(new Request('https://httpbin.org/post', 'POST')); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testRequestSendsHeaders(): void + { + $request = (new Request('https://httpbin.org/headers')) + ->addHeaders(new Header('Foo', 'Bar')) + ; + + $response = (new NativeClient())->request($request); + + $headersSent = json_decode($response->getBody(), true)['headers']; + + $this->assertSame(200, $response->getStatusCode()); + $this->assertArrayHasKey('Foo', $headersSent); + $this->assertSame('Bar', $headersSent['Foo']); + $this->assertArrayHasKey('User-Agent', $headersSent); + $this->assertSame('PHP.net HTTP client', $headersSent['User-Agent']); + } + + public function testRequestThrowsExceptionOnConnectionErrors(): void + { + $this->expectException(ConnectionError::class); + + (new NativeClient())->request(new Request('https://httpbin.org/delay/4')); + } + + public function testRequestUsesDefaultClientOptions(): void + { + (new NativeClient())->request(new Request('https://httpbin.org/delay/2')); + + $this->expectException(ConnectionError::class); + + (new NativeClient())->request(new Request('https://httpbin.org/delay/4')); + } + + public function testRequestUsesClientOptions(): void + { + $this->expectException(ConnectionError::class); + + $clientOptions = (new ClientOptions())->setTimeout(1); + + (new NativeClient($clientOptions))->request(new Request('https://httpbin.org/delay/2')); + } + + public function testRequestUsesClientOptionsFromRequest(): void + { + $this->expectException(ConnectionError::class); + + $clientOptions = (new ClientOptions())->setTimeout(1); + + (new NativeClient())->request(new Request('https://httpbin.org/delay/2'), $clientOptions); + } +} diff --git a/tests/Unit/HttpClient/RequestTest.php b/tests/Unit/HttpClient/RequestTest.php new file mode 100644 index 00000000..3d4df031 --- /dev/null +++ b/tests/Unit/HttpClient/RequestTest.php @@ -0,0 +1,117 @@ +assertSame('https://example.com', (new Request('https://example.com'))->getUri()); + } + + public function testDefaultMethodIsSetWhenNoMethodIsSupplied(): void + { + $this->assertSame('GET', (new Request('https://example.com'))->getMethod()); + } + + public function testMethodIsSet(): void + { + $this->assertSame('POST', (new Request('https://example.com', 'POST'))->getMethod()); + } + + public function testDefaultUserAgentIsSet(): void + { + $header = (new Request('https://example.com'))->getHeaders()['user-agent']; + + $this->assertSame('PHP.net HTTP client', $header->getValue()); + $this->assertSame('User-Agent', $header->getKey()); + } + + public function testDefaultContentTypeIsSetForPostRequests(): void + { + $header = (new Request('https://example.com', 'POST'))->getHeaders()['content-type']; + + $this->assertSame('application/x-www-form-urlencoded', $header->getValue()); + $this->assertSame('Content-Type', $header->getKey()); + } + + public function testAddingSingleHeader(): void + { + $request = (new Request('https://example.com')) + ->addHeaders(new Header('Foo', 'Bar')) + ; + + $this->assertArrayHasKey('foo', $request->getHeaders()); + } + + public function testAddingMultipleHeader(): void + { + $request = (new Request('https://example.com')) + ->addHeaders( + new Header('Foo', 'Bar'), + new Header('Baz', 'Qux') + ) + ; + + $this->assertArrayHasKey('foo', $request->getHeaders()); + $this->assertArrayHasKey('baz', $request->getHeaders()); + } + + public function testAddHeadersOverwritesExistingHeader(): void + { + $request = (new Request('https://example.com')) + ->addHeaders(new Header('User-Agent', 'User agent override')) + ; + + $this->assertSame('User agent override', $request->getHeaders()['user-agent']->getValue()); + } + + public function testGetHeaders(): void + { + $request = (new Request('https://example.com')) + ->addHeaders( + new Header('Foo', 'Bar'), + new Header('Baz', 'Qux') + ) + ; + + $this->assertCount(3, $request->getHeaders()); + $this->assertInstanceOf(Header::class, $request->getHeaders()['user-agent']); + $this->assertInstanceOf(Header::class, $request->getHeaders()['foo']); + } + + public function testGetHeadersAsString(): void + { + $request = (new Request('https://example.com')) + ->addHeaders( + new Header('Foo', 'Bar'), + new Header('Baz', 'Qux') + ) + ; + + $expectedHeadersString = ''; + + $expectedHeadersString .= "User-Agent: PHP.net HTTP client\r\n"; + $expectedHeadersString .= "Foo: Bar\r\n"; + $expectedHeadersString .= "Baz: Qux\r\n"; + + $this->assertSame($expectedHeadersString, $request->getHeadersAsString()); + } + + public function testGetBodyReturnsEmptyStringWhenBodyIsNotSet(): void + { + $this->assertSame('', (new Request('https://example.com'))->getBody()); + } + + public function testGetBodyReturnsTheBodySet(): void + { + $this->assertSame( + 'The request body', + (new Request('https://example.com'))->setBody('The request body')->getBody() + ); + } +} diff --git a/tests/Unit/HttpClient/ResponseTest.php b/tests/Unit/HttpClient/ResponseTest.php new file mode 100644 index 00000000..c8448c65 --- /dev/null +++ b/tests/Unit/HttpClient/ResponseTest.php @@ -0,0 +1,48 @@ +assertSame('The response body', (new Response('The response body', 'HTTP/1.1 200 OK'))->getBody()); + } + + public function testParsingOfProtocolVersion(): void + { + $this->assertSame('1.1', (new Response('', 'HTTP/1.1 200 OK'))->getProtocolVersion()); + } + + public function testParsingOfStatusCode(): void + { + $this->assertSame(200, (new Response('', 'HTTP/1.1 200 OK'))->getStatusCode()); + } + + public function testParsingOfMissingReasonPhrase(): void + { + $this->assertNull((new Response('', 'HTTP/1.1 200'))->getReasonPhrase()); + } + + public function testParsingOfReasonPhrase(): void + { + $this->assertSame('OK', (new Response('', 'HTTP/1.1 200 OK'))->getReasonPhrase()); + } + + public function testParsingOfHeaders(): void + { + $response = new Response('', 'HTTP/1.1 200 OK', 'Header1Key: Header1Value', 'Header2Key: Header2Value'); + + $this->assertCount(2, $response->getHeaders()); + $this->assertInstanceOf(Header::class, $response->getHeaders()[0]); + $this->assertInstanceOf(Header::class, $response->getHeaders()[1]); + $this->assertSame('Header1Key', $response->getHeaders()[0]->getKey()); + $this->assertSame('Header1Value', $response->getHeaders()[0]->getValue()); + $this->assertSame('Header2Key', $response->getHeaders()[1]->getKey()); + $this->assertSame('Header2Value', $response->getHeaders()[1]->getValue()); + } +}