feat(service): add api service

This commit is contained in:
Ondrej Vlach 2024-07-29 18:50:58 +02:00
parent e929853bb2
commit e1375f1bf5
No known key found for this signature in database
GPG Key ID: 7F141CDACEDEE2DE
18 changed files with 639 additions and 5 deletions

View File

@ -4,7 +4,7 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.3",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"doctrine/dbal": "^3", "doctrine/dbal": "^3",

10
config/services_test.yaml Normal file
View File

@ -0,0 +1,10 @@
services:
App\Service\Remote\BriloApiUsers:
public: true
autowire: true
App\Service\Remote\BriloApiComments:
public: true
autowire: true
App\Service\Remote\BriloApiPosts:
public: true
autowire: true

View File

@ -18,8 +18,11 @@
</php> </php>
<testsuites> <testsuites>
<testsuite name="Project Test Suite"> <testsuite name="unit">
<directory>tests</directory> <directory>tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory>tests/Integration</directory>
</testsuite> </testsuite>
</testsuites> </testsuites>

View File

@ -7,8 +7,8 @@ namespace App\Entity\Remote\Brilo\Users;
readonly class AddressGeo readonly class AddressGeo
{ {
public function __construct( public function __construct(
public float $lat, public string $lat,
public float $lng, public string $lng,
) { ) {
} }
} }

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Service\Remote;
use App\Entity\Remote\Brilo\Comments\Comment;
use App\Entity\Remote\Brilo\Posts\Post;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class BriloApiComments
{
use BriloApiFetchTrait;
public function __construct(
protected readonly HttpClientInterface $httpClient,
protected readonly LoggerInterface $logger,
protected readonly SerializerInterface $serializer,
protected readonly int $retryCount = 3,
protected readonly int $sleepTimeS = 1,
) {
}
/**
* Fetch all comments from the Brilo API and return them as an array of Comment entities.
* @return Comment[]
* @throws \Exception
*/
public function getComments(): array
{
return $this->fetchApiResponse('https://jsonplaceholder.typicode.com/comments', Comment::class);
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Service\Remote;
use App\Entity\Remote\Brilo\Users\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
trait BriloApiFetchTrait
{
use RetryingClientTrait;
protected readonly int $retryCount;
protected readonly int $sleepTimeS;
protected readonly LoggerInterface $logger;
protected readonly HttpClientInterface $httpClient;
protected readonly SerializerInterface $serializer;
/**
* Implementation of fetch logic. Handles retrying and logging of failed requests.
* Returns deserialized data.
* @template T
*
* @param string $uri Uri of the API endpoint.
* @param class-string<T> $class Class name of the entity to deserialize data into.
*
* @return T[]
* @throws \Exception
*/
protected function fetchApiResponse(string $uri, string $class): array
{
return $this->retryingFailRequest($this->retryCount, $this->sleepTimeS, [TransportException::class, BriloRemoteApiException::class], $this->logger, function () use ($uri, $class) {
$this->logger->debug('Trying to download brilo users');
$response = $this->httpClient->request('GET', $uri);
if ($response->getStatusCode() != 200) {
$this->logger->error("Can't download brilo users. HTTP status code: " . $response->getStatusCode());
throw new BriloRemoteApiException("Can't download brilo users. HTTP status code: " . $response->getStatusCode());
}
$this->logger->debug('Brilo users downloaded');
$this->logger->debug('Deserialize brilo users');
// extra attributes muzeme safe ignorovat
return $this->serializer->deserialize($response->getContent(), $class . '[]', 'json');
});
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Service\Remote;
class BriloApiPosts
{
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Service\Remote;
use App\Entity\Remote\Brilo\Users\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class BriloApiUsers
{
use BriloApiFetchTrait;
public function __construct(
protected readonly HttpClientInterface $httpClient,
protected readonly LoggerInterface $logger,
protected readonly SerializerInterface $serializer,
protected readonly int $retryCount = 3,
protected readonly int $sleepTimeS = 1,
) {
}
/**
* @return User[]
* @throws \Exception
*/
public function getUsers(): array
{
return $this->fetchApiResponse('https://jsonplaceholder.typicode.com/users', User::class);
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Service\Remote;
/**
* Remote API exception (in case return code from API endpoint is not 200 - OK)
*/
class BriloRemoteApiException extends \RuntimeException
{
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Service\Remote;
use Exception;
use Psr\Log\LoggerInterface;
trait RetryingClientTrait
{
/**
* Better to retry failed requests (in case of non-destructive operations) than throwing immediately.
*
* @param int $count
* @param float $sleep
* @param array<string> $catchableExceptions
* @param LoggerInterface $logger
* @param callable $callback
* @return mixed
* @throws Exception
*/
protected function retryingFailRequest(
int $count,
float $sleep,
array $catchableExceptions,
LoggerInterface $logger,
callable $callback
): mixed {
for ($i = 0;; $i++) {
try {
return $callback();
} catch (Exception $e) {
foreach ($catchableExceptions as $exceptionClass) {
if ($e instanceof $exceptionClass) {
$logger->error("transport: fail request retrying... got catchable exception", [
'exception' => $e,
'try' => $i
]);
usleep((int) ($sleep * 1_000_000));
if ($i == $count) {
throw $e;
}
continue 2;
}
}
throw $e;
}
}
// phpstan fail
return null;
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Tests\Common;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
trait LoggerTrait
{
/**
* @return Logger
*/
protected function getLogger(): Logger
{
$logger = new Logger('test');
$logger->pushHandler(new TestHandler());
return $logger;
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Service\Remote;
use App\Service\Remote\BriloApiUsers;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class BlogApiUsersTest extends KernelTestCase
{
public function setUp(): void
{
parent::bootKernel();
}
public function testBlogApiUsersAreRetrieved(): void
{
/**
* @var BriloApiUsers $apiUsers
*/
$apiUsers = static::getContainer()->get(BriloApiUsers::class);
$users = $apiUsers->getUsers();
$this->assertIsArray($users);
$this->assertGreaterThan(0, count($users));
// Tady bych dal jeste check na zaznam ktery vim ze vzdy existuje
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Service\Remote;
use App\Service\Remote\BriloApiComments;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class BriloApiCommentsTest extends KernelTestCase
{
public function setUp(): void
{
parent::bootKernel();
}
public function testBlogApiUsersAreRetrieved(): void
{
/**
* @var BriloApiComments $api
*/
$api = static::getContainer()->get(BriloApiComments::class);
$users = $api->getComments();
$this->assertIsArray($users);
$this->assertGreaterThan(0, count($users));
// Tady bych dal jeste check na zaznam ktery vim ze vzdy existuje
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Service\Remote;
use App\Service\Remote\BriloApiPosts;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class BriloApiPostsTest extends KernelTestCase
{
public function setUp(): void
{
parent::bootKernel();
}
public function testBlogApiUsersAreRetrieved(): void
{
/**
* @var BriloApiPosts $api
*/
$api = static::getContainer()->get(BriloApiPosts::class);
$users = $api->getPosts();
$this->assertIsArray($users);
$this->assertGreaterThan(0, count($users));
// Tady bych dal jeste check na zaznam ktery vim ze vzdy existuje
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service\Remote;
class BlogApiPostsTest
{
}

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service\Remote;
use App\Entity\Remote\Brilo\Comments\Comment;
use App\Entity\Remote\Brilo\Posts\Post;
use App\Service\Remote\BriloApiComments;
use App\Service\Remote\BriloApiPosts;
use App\Service\Remote\BriloRemoteApiException;
use App\Tests\Common\LoggerTrait;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
use Symfony\Component\Serializer\SerializerInterface;
class BriloApiCommentsTest extends TestCase
{
use LoggerTrait;
private const array API_COMMENTS_LIST = [
[
"postId" => 1,
"id" => 1,
"name" => "id labore ex et quam laborum",
"email" => "Eliseo@gardner.biz",
"body" => "laudantium enim quasi est quidem magnam voluptate ipsam eos
tempora quo necessitatibus
dolor quam autem quasi
reiciendis et nam sapiente accusantium"
],
[
"postId" => 1,
"id" => 2,
"name" => "quo vero reiciendis velit similique earum",
"email" => "Jayne_Kuhic@sydney.com",
"body" => "est natus enim nihil est dolore omnis voluptatem numquam
et omnis occaecati quod ullam at
voluptatem error expedita pariatur
nihil sint nostrum voluptatem reiciendis et"
]
];
public function testCommentsListSuccess(): void
{
$testCommentsList = $this->buildTestCommentsList();
$serializer = $this->createMock(SerializerInterface::class);
$serializer
->expects($this->once())
->method('deserialize')
->with(
json_encode(self::API_COMMENTS_LIST),
Comment::class . '[]',
'json'
)->willReturn($testCommentsList);
$response = new JsonMockResponse(self::API_COMMENTS_LIST);
$mockedClient = new MockHttpClient($response);
$briloApiPosts = new BriloApiComments($mockedClient, $this->getLogger(), $serializer);
$this->assertEquals($testCommentsList, $briloApiPosts->getComments());
}
public function testInvalidHttpCodeReturned(): void
{
$serializer = $this->createMock(SerializerInterface::class);
$serializer
->expects($this->never())
->method('deserialize');
$response = new JsonMockResponse(self::API_COMMENTS_LIST, [
'http_code' => 401,
]);
$mockedClient = new MockHttpClient(function () use ($response) {
return $response;
});
$this->expectException(BriloRemoteApiException::class);
$briloApiPosts = new BriloApiComments($mockedClient, $this->getLogger(), $serializer);
$briloApiPosts->getComments();
}
/**
* @return Comment[]
*/
public function buildTestCommentsList(): array
{
// Convert self::API_POSTS_LIST to Post[] and return it
return array_map(function ($postData) {
return new Comment(
$postData['id'],
$postData['postId'],
$postData['name'],
$postData['email'],
$postData['body']
);
}, self::API_COMMENTS_LIST);
}
}

View File

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service\Remote;
use App\Entity\Remote\Brilo\Users\Address;
use App\Entity\Remote\Brilo\Users\AddressGeo;
use App\Entity\Remote\Brilo\Users\Company;
use App\Entity\Remote\Brilo\Users\User;
use App\Service\Remote\BriloApiUsers;
use App\Service\Remote\BriloRemoteApiException;
use App\Tests\Common\LoggerTrait;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
use Symfony\Component\Serializer\SerializerInterface;
class BriloApiUsersTest extends TestCase
{
use LoggerTrait;
private const array API_USER_LIST = [
[
"id" => 0,
"name" => "Leanne Graham",
"username" => "Bret",
"email" => "Sincere@april.biz",
"address" => [
"street" => "Kulas Light",
"suite" => "Apt. 555",
"city" => "Gwenborough",
"zipcode" => "92997-3874",
"geo" => [
"lat" => "-38.3159",
"lng" => "80.1496"
]
],
"phone" => "0-770-736-8031 x56442",
"website" => "hildegard.org",
"company" => [
"name" => "Romaguera-Crona",
"catchPhrase" => "Multi-layered client-server neural-net",
"bs" => "harness real-time e-markets"
],
],
[
"id" => 1,
"name" => "Ervin Howell",
"username" => "Antonette",
"email" => "Shanna@melissa.tv",
"address" => [
"street" => "Victor Plains",
"suite" => "Suite 878",
"city" => "Wisokyburgh",
"zipcode" => "90565-7771",
"geo" => [
"lat" => "-44.9509",
"lng" => "-35.4618"
]
],
"phone" => "009-692-6593 x09125",
"website" => "anastasia.net",
"company" => [
"name" => "Deckow-Crist",
"catchPhrase" => "Proactive didactic contingency",
"bs" => "synergize scalable supply-chains"
],
"nonExistentKey" => "value"
]
];
public function testUserListSuccess(): void
{
$testUserList = $this->buildTestUserList();
$serializer = $this->createMock(SerializerInterface::class);
$serializer
->expects($this->once())
->method('deserialize')
->with(
json_encode(self::API_USER_LIST),
User::class . '[]',
'json'
)->willReturn($testUserList);
$response = new JsonMockResponse(self::API_USER_LIST);
$mockedClient = new MockHttpClient($response);
$briloApiUsers = new BriloApiUsers($mockedClient, $this->getLogger(), $serializer);
$this->assertEquals($testUserList, $briloApiUsers->getUsers());
}
public function testInvalidHttpCodeReturned(): void
{
$serializer = $this->createMock(SerializerInterface::class);
$serializer
->expects($this->never())
->method('deserialize');
$response = new JsonMockResponse(self::API_USER_LIST, [
'http_code' => 401,
]);
$mockedClient = new MockHttpClient(function () use ($response) {
return $response;
});
$this->expectException(BriloRemoteApiException::class);
$briloApiUsers = new BriloApiUsers($mockedClient, $this->getLogger(), $serializer);
$briloApiUsers->getUsers();
}
/**
* @return User[]
*/
private function buildTestUserList(): array
{
// convert self::API_USER_LIST to User[]
return array_map(function ($userData) {
return new User(
$userData['id'],
$userData['name'],
$userData['username'],
$userData['email'],
$userData['phone'],
$userData['website'],
new Address(
$userData['address']['street'],
$userData['address']['suite'],
$userData['address']['city'],
$userData['address']['zipcode'],
new AddressGeo(
$userData['address']['geo']['lat'],
$userData['address']['geo']['lng']
)
),
new Company(
$userData['company']['name'],
$userData['company']['catchPhrase'],
$userData['company']['bs']
)
);
}, self::API_USER_LIST);
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service\Remote;
use App\Service\Remote\RetryingClientTrait;
use App\Service\Remote\RetryingFailClientTrait;
use App\Tests\Common\LoggerTrait;
use PHPUnit\Framework\TestCase;
class RetryingClientTraitTest extends TestCase
{
use LoggerTrait;
private int $callCount = 0;
protected function setUp(): void
{
parent::setUp(); // TODO: Change the autogenerated stub
$this->callCount = 0;
}
public function testSuccess(): void
{
$trait = new class {
use RetryingClientTrait {
retryingFailRequest as public; // make the method public
}
};
$result = $trait->retryingFailRequest(2, 0, [\RuntimeException::class], $this->getLogger(), function () {
$this->callCount = $this->callCount + 1;
return 'foo';
});
$this->assertEquals(1, $this->callCount);
$this->assertEquals('foo', $result);
}
public function testRetyingFail(): void
{
$trait = new class {
use RetryingClientTrait {
retryingFailRequest as public; // make the method public
}
};
try {
$trait->retryingFailRequest(2, 0, [\RuntimeException::class], $this->getLogger(), function () {
$this->callCount = $this->callCount + 1;
throw new \RuntimeException("test");
});
} catch (\RuntimeException) {
// do nothing
}
$this->assertEquals(3, $this->callCount);
}
// extra attributes muzeme safe ignorovat
}