From 6c807e2a88b784d5a42ea6b4157e3611d024dfe3 Mon Sep 17 00:00:00 2001 From: Kevin <34477826+kkevindev@users.noreply.github.com> Date: Fri, 30 Jan 2026 20:49:06 +0100 Subject: [PATCH 1/2] Implemented Symfony HTTP Client. --- .php-cs-fixer.dist.php | 19 ++++ composer.json | 11 ++- phpstan.neon | 1 + src/Client.php | 91 +++++++++---------- src/Exceptions/HttpException.php | 2 +- src/Http/Request/Headers.php | 39 -------- src/Http/Response/Headers.php | 21 ----- src/Http/Response/Response.php | 62 ------------- src/Postcode.php | 3 +- tests/AbstractClientTestCase.php | 55 +++++++++++ tests/ClientTest.php | 35 ------- tests/Http/Request/HeadersTest.php | 22 ----- tests/Http/Response/HeadersTest.php | 31 ------- tests/Http/Response/ResponseTest.php | 70 -------------- tests/Integration/ClientTest.php | 22 +++++ tests/{ => Integration}/PostcodeTest.php | 10 +- tests/Mock/MockResponseHttpClient.php | 50 ++++++++++ ...api_v1_postcode_postcode=0000AA&number=401 | 3 + ...1_postcode_postcode=0000AA&number=401.meta | 1 + ...api_v1_postcode_postcode=0000AA&number=404 | 1 + ...1_postcode_postcode=0000AA&number=404.meta | 1 + ..._v1_postcode_postcode=1012%20RJ&number=147 | 1 + .../api_v1_postcode_postcode=X&number=422 | 1 + ...api_v1_postcode_postcode=X&number=422.meta | 1 + tests/Unit/ClientTest.php | 17 ++++ 25 files changed, 238 insertions(+), 332 deletions(-) create mode 100644 .php-cs-fixer.dist.php delete mode 100644 src/Http/Request/Headers.php delete mode 100644 src/Http/Response/Headers.php delete mode 100644 src/Http/Response/Response.php create mode 100644 tests/AbstractClientTestCase.php delete mode 100644 tests/ClientTest.php delete mode 100644 tests/Http/Request/HeadersTest.php delete mode 100644 tests/Http/Response/HeadersTest.php delete mode 100644 tests/Http/Response/ResponseTest.php create mode 100644 tests/Integration/ClientTest.php rename tests/{ => Integration}/PostcodeTest.php (83%) create mode 100644 tests/Mock/MockResponseHttpClient.php create mode 100644 tests/Mock/Responses/api_v1_postcode_postcode=0000AA&number=401 create mode 100644 tests/Mock/Responses/api_v1_postcode_postcode=0000AA&number=401.meta create mode 100644 tests/Mock/Responses/api_v1_postcode_postcode=0000AA&number=404 create mode 100644 tests/Mock/Responses/api_v1_postcode_postcode=0000AA&number=404.meta create mode 100644 tests/Mock/Responses/api_v1_postcode_postcode=1012%20RJ&number=147 create mode 100644 tests/Mock/Responses/api_v1_postcode_postcode=X&number=422 create mode 100644 tests/Mock/Responses/api_v1_postcode_postcode=X&number=422.meta create mode 100644 tests/Unit/ClientTest.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..4373b9d --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,19 @@ +setParallelConfig(ParallelConfigFactory::detect()) + ->setRiskyAllowed(true) + ->setRules([ + '@Symfony' => true + ]) + ->setFinder( + (new Finder()) + ->in(__DIR__) + ) +; diff --git a/composer.json b/composer.json index 7fc441e..e64fc72 100644 --- a/composer.json +++ b/composer.json @@ -4,23 +4,28 @@ "license": "gpl-3.0-or-later", "type": "library", "require": { - "php": "^8.1" + "php": "^8.1", + "kkevindev/assert-return-value":"1.11.0", + "symfony/http-client": "^6.4 || ^7.0 || ^8.0" }, "require-dev": { "phpunit/phpunit": "^9.6", "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^v3.89" }, - "autoload": { "psr-4": { "Kkevindev\\PostcodeTech\\": "src/" } }, - "autoload-dev": { "psr-4": { "Kkevindev\\PostcodeTech\\Tests\\": "tests/" } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "cs-fix": "vendor/bin/php-cs-fixer fix", + "phpstan": "vendor/bin/phpstan analyse" } } diff --git a/phpstan.neon b/phpstan.neon index cfb8442..67fb864 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,3 +3,4 @@ parameters: level: 10 paths: - src + - tests diff --git a/src/Client.php b/src/Client.php index da7fbcb..366a203 100644 --- a/src/Client.php +++ b/src/Client.php @@ -5,75 +5,74 @@ use Kkevindev\PostcodeTech\Exceptions\HttpException; use Kkevindev\PostcodeTech\Exceptions\PostcodeNotFoundException; use Kkevindev\PostcodeTech\Exceptions\ValidationException; -use Kkevindev\PostcodeTech\Http\Request\Headers; -use Kkevindev\PostcodeTech\Http\Response\Response; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @internal + */ final class Client { - /** @var string */ - private const BASE_URI = 'https://postcode.tech'; - - private Headers $headers; - public function __construct( private readonly string $token, + private readonly HttpClientInterface $httpClient, ) { - $this->headers = new Headers([ - 'Authorization' => sprintf('Bearer %s', $this->token), - ]); } /** - * @throws PostcodeNotFoundException - * @throws ValidationException - * @throws HttpException - * * @return array{ * street: string, * city: string, * } + * + * @throws PostcodeNotFoundException + * @throws ValidationException + * @throws HttpException */ public function get(string $postcode, int $number): array { - $queryParameters = [ - 'postcode' => $postcode, - 'number' => $number, - ]; + try { + $response = $this->httpClient->request( + 'GET', + 'https://postcode.tech/api/v1/postcode', + [ + 'auth_bearer' => $this->token, + 'query' => [ + 'postcode' => $postcode, + 'number' => $number, + ], + ], + ); - $uri = sprintf( - '%s/%s?%s', - self::BASE_URI, - 'api/v1/postcode', - http_build_query($queryParameters), - ); + $statusCode = $response->getStatusCode(); - $response = file_get_contents( - $uri, - false, - stream_context_create([ - 'http' => [ - 'method' => 'GET', - 'header' => $this->headers->getHeaders(), - 'ignore_errors' => true, - ], - ]), - ); + $responseBody = $response->getContent(false); + } catch (TransportExceptionInterface|ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface $exception) { + throw new HttpException($exception->getMessage(), previous: $exception); + } - if (!$response) { - throw new HttpException('No response received from the API.'); + if (200 > $statusCode || 300 <= $statusCode) { + throw match ($statusCode) { + 401 => new HttpException('Unauthorized', $responseBody), + 404 => new PostcodeNotFoundException('No results found for the given postcode and number.', $responseBody), + 422 => new ValidationException('The request data was invalid.', $responseBody), + default => new HttpException('An unknown error occurred while fetching the data from the API.', $responseBody), + }; } - // @todo refactor the magic '$http_response_header' to 'http_get_last_response_headers()' when PHP 8.4 is the lowest supported version. - $response = new Response($response, $http_response_header); + try { + $array = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new HttpException('The response body is not a valid JSON array.', $responseBody, $exception); + } - if (!$response->isSuccess()) { - match ($response->getHeaders()->getStatusCode()) { - 404 => throw new PostcodeNotFoundException('No results found for the given postcode and number.', $response->getResponseBody()), - 422 => throw new ValidationException('The request data was invalid.', $response->getResponseBody()), - default => throw new HttpException('An unknown error occurred while fetching the data from the API.', $response->getResponseBody()), - }; + if (!is_array($array) || empty($array['street']) || !is_string($array['street']) || empty($array['city']) || !is_string($array['city'])) { + throw new HttpException('The response body did not contain the expected data.', $responseBody); } - return $response->getResponseBodyAsArray(); + return $array; } } diff --git a/src/Exceptions/HttpException.php b/src/Exceptions/HttpException.php index 8ce4bca..a1e673e 100644 --- a/src/Exceptions/HttpException.php +++ b/src/Exceptions/HttpException.php @@ -4,7 +4,7 @@ class HttpException extends \Exception { - public function __construct(string $message = "", string $responseBody = "", ?\Throwable $previous = null) + public function __construct(string $message = '', string $responseBody = '', ?\Throwable $previous = null) { parent::__construct(sprintf('%s: %s', $message, $responseBody), previous: $previous); } diff --git a/src/Http/Request/Headers.php b/src/Http/Request/Headers.php deleted file mode 100644 index 2949b06..0000000 --- a/src/Http/Request/Headers.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ - private array $headers = []; - - /** - * @param array $headers - */ - public function __construct(array $headers = []) - { - foreach ($headers as $key => $value) { - $this->addHeader($key, $value); - } - } - - public function addHeader(string $key, string $value): void - { - $this->headers[$key] = $value; - } - - public function getHeaders(): string - { - $headers = ''; - - foreach ($this->headers as $key => $value) { - $headers .= "{$key}: {$value}\r\n"; - } - - return $headers; - } -} diff --git a/src/Http/Response/Headers.php b/src/Http/Response/Headers.php deleted file mode 100644 index ecd983f..0000000 --- a/src/Http/Response/Headers.php +++ /dev/null @@ -1,21 +0,0 @@ -headers[0])[1]); - } -} diff --git a/src/Http/Response/Response.php b/src/Http/Response/Response.php deleted file mode 100644 index 3f4a2e7..0000000 --- a/src/Http/Response/Response.php +++ /dev/null @@ -1,62 +0,0 @@ -headers = new Headers($responseHeaders); - } - - public function getResponseBody(): string - { - return $this->responseBody; - } - - /** - * @throws HttpException - * - * @return array{ - * street: string, - * city: string, - * } - */ - public function getResponseBodyAsArray(): array - { - try { - $array = json_decode($this->responseBody, true, flags: JSON_THROW_ON_ERROR); - } catch (\JsonException $exception) { - throw new HttpException('The response body is not a valid JSON array.', $this->responseBody, $exception); - } - - if (!is_array($array) || empty($array['street']) || !is_string($array['street']) || empty($array['city']) || !is_string($array['city'])) { - throw new HttpException('The response body did not contain the expected data.', $this->responseBody); - } - - return $array; - } - - public function getHeaders(): Headers - { - return $this->headers; - } - - public function isSuccess(): bool - { - $statusCode = $this->headers->getStatusCode(); - - return $statusCode >= 200 && $statusCode < 300; - } -} diff --git a/src/Postcode.php b/src/Postcode.php index 5ac3171..b986c62 100644 --- a/src/Postcode.php +++ b/src/Postcode.php @@ -5,6 +5,7 @@ use Kkevindev\PostcodeTech\Exceptions\HttpException; use Kkevindev\PostcodeTech\Exceptions\PostcodeNotFoundException; use Kkevindev\PostcodeTech\Exceptions\ValidationException; +use Symfony\Component\HttpClient\HttpClient; class Postcode implements PostcodeInterface { @@ -23,7 +24,7 @@ protected function __construct( */ public static function search(string $postcode, int $number, string $token): self { - $client = new Client($token); + $client = new Client($token, HttpClient::create()); $response = $client->get($postcode, $number); diff --git a/tests/AbstractClientTestCase.php b/tests/AbstractClientTestCase.php new file mode 100644 index 0000000..9c925c5 --- /dev/null +++ b/tests/AbstractClientTestCase.php @@ -0,0 +1,55 @@ +getClient('demo')->get('1012 RJ', 147); + + self::assertEquals($expectedStreet, $result['street']); + self::assertEquals($expectedCity, $result['city']); + } + + public function test401ResponseThrowsException(): void + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage( + <<getClient('invalid-token')->get('0000AA', 401); + } + + public function test404ResponseThrowsException(): void + { + $this->expectException(PostcodeNotFoundException::class); + $this->expectExceptionMessage('No results found for the given postcode and number.: {"message":"No result for this combination."}'); + + $this->getClient('demo')->get('0000AA', 404); + } + + public function test422ResponseThrowsExceptionWhenMalformedInput(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('The request data was invalid.: {"message":"The given data was invalid.","errors":{"postcode":["Postcode should be formatted `1111AA` or `1111 AA`."]}}'); + + $this->getClient('demo')->get('X', 422); + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php deleted file mode 100644 index 4975bc8..0000000 --- a/tests/ClientTest.php +++ /dev/null @@ -1,35 +0,0 @@ -client = new Client('demo'); - } - - public static function providePostcodeData(): iterable - { - yield ['1012 RJ', 147, 'Nieuwezijds Voorburgwal', 'Amsterdam']; - yield ['2514 GL', 68, 'Noordeinde', "'s-Gravenhage"]; - yield ['2594 BD', 10, "'s-Gravenhaagse Bos", "'s-Gravenhage"]; - } - - /** - * @dataProvider providePostcodeData - * - * This test actually calls the real API, so it can fail beyond our control. - */ - public function testSearch(string $postcode, int $number, string $expectedStreet, string $expectedCity): void - { - $result = $this->client->get($postcode, $number); - - self::assertEquals($expectedStreet, $result['street']); - self::assertEquals($expectedCity, $result['city']); - } -} diff --git a/tests/Http/Request/HeadersTest.php b/tests/Http/Request/HeadersTest.php deleted file mode 100644 index 5fabecd..0000000 --- a/tests/Http/Request/HeadersTest.php +++ /dev/null @@ -1,22 +0,0 @@ - 'Bearer demo']); - - self::assertEquals("Authorization: Bearer demo\r\n", $headers->getHeaders()); - - $headers->addHeader('Content-Type', 'application/json'); - - self::assertEquals("Authorization: Bearer demo\r\nContent-Type: application/json\r\n", $headers->getHeaders()); - } -} diff --git a/tests/Http/Response/HeadersTest.php b/tests/Http/Response/HeadersTest.php deleted file mode 100644 index bb811c1..0000000 --- a/tests/Http/Response/HeadersTest.php +++ /dev/null @@ -1,31 +0,0 @@ - $statusCode, - ]); - - self::assertEquals($expectedStatusCode, $headers->getStatusCode()); - } -} diff --git a/tests/Http/Response/ResponseTest.php b/tests/Http/Response/ResponseTest.php deleted file mode 100644 index 30aaf00..0000000 --- a/tests/Http/Response/ResponseTest.php +++ /dev/null @@ -1,70 +0,0 @@ - sprintf('HTTP/1.1 %s', $statusCode)]); - - self::assertEquals($expectedIsSuccess, $response->isSuccess()); - } - - public function testGetResponseBodyAsArrayThrowsExceptionForInvalidJsonArray(): void - { - self::expectException(HttpException::class); - self::expectExceptionMessage('The response body is not a valid JSON array.'); - - $response = new Response('', []); - $response->getResponseBodyAsArray(); - } - - public static function provideInvalidJsonResponseBodies(): iterable - { - yield ['{}']; - yield ['{"street": "", "city": ""}']; - yield ['{"city": ""}']; - yield ['{"street": "", "city": ""}']; - yield ['{"street": "non-empty", "city": ""}']; - yield ['{"street": "", "city": "non-empty"}']; - } - - /** - * @dataProvider provideInvalidJsonResponseBodies - */ - public function testGetResponseBodyAsArrayThrowsExceptionForInvalidJsonArrayStructure(string $responseBody): void - { - self::expectException(HttpException::class); - self::expectExceptionMessage('The response body did not contain the expected data.'); - - $response = new Response($responseBody, []); - $response->getResponseBodyAsArray(); - } - - public function testGetResponseBodyAsArrayReturnsArray(): void - { - $response = new Response('{"street": "straat", "city": "stad"}', []); - $responseBodyArray = $response->getResponseBodyAsArray(); - - self::assertSame('straat', $responseBodyArray['street']); - self::assertSame('stad', $responseBodyArray['city']); - } -} diff --git a/tests/Integration/ClientTest.php b/tests/Integration/ClientTest.php new file mode 100644 index 0000000..74a732f --- /dev/null +++ b/tests/Integration/ClientTest.php @@ -0,0 +1,22 @@ + + */ public static function providePostcodeData(): iterable { yield ['1012 RJ', 147, 'Nieuwezijds Voorburgwal', 'Amsterdam']; diff --git a/tests/Mock/MockResponseHttpClient.php b/tests/Mock/MockResponseHttpClient.php new file mode 100644 index 0000000..46bcb22 --- /dev/null +++ b/tests/Mock/MockResponseHttpClient.php @@ -0,0 +1,50 @@ + Date: Fri, 30 Jan 2026 20:53:11 +0100 Subject: [PATCH 2/2] Fixed invalid composer.json file. --- composer.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index e64fc72..1fc4227 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "type": "library", "require": { "php": "^8.1", - "kkevindev/assert-return-value":"1.11.0", + "kkevindev/assert-return-value":"^1.11.0", "symfony/http-client": "^6.4 || ^7.0 || ^8.0" }, "require-dev": { @@ -26,6 +26,7 @@ "scripts": { "test": "vendor/bin/phpunit", "cs-fix": "vendor/bin/php-cs-fixer fix", - "phpstan": "vendor/bin/phpstan analyse" + "phpstan": "vendor/bin/phpstan analyse", + "composer-validate": "composer validate --strict" } }