13 Commits

Author SHA1 Message Date
8b4f3ed64a docs: update README 2024-01-18 23:04:50 +01:00
ba143ed109 docs: update README 2024-01-18 22:43:04 +01:00
98362e0379 fix: NumberClasss invalid_message 2024-01-18 22:23:53 +01:00
64667cc24c fix: replace InvalidArgumentException -> ThisShouldNeverHappenException 2024-01-18 22:19:13 +01:00
a31b283847 fix: use same terminology 2024-01-18 22:08:07 +01:00
0efd5343e3 feat: add error pages 2024-01-18 21:57:13 +01:00
476cc641e5 docs: update README 2024-01-18 21:56:47 +01:00
7db8200b8d Merge remote-tracking branch 'origin/feature_usetreno_api' 2024-01-18 21:09:36 +01:00
6e5f929dc3 feat: retries of api requests in case of failure 2024-01-18 21:08:11 +01:00
7eac908bc4 feat: add retry client trait for api requests
feat: add retry client trait for api requests
2024-01-18 21:08:11 +01:00
d7ff806129 feat: add nubium/this-should-never-happen-exception 2024-01-18 20:50:37 +01:00
13bdd096be feat: add sentry
feat: add sentry
2024-01-18 20:50:37 +01:00
192cfb9b73 feat: add support for OTEL logs
feat: add support for OTEL logs
2024-01-18 18:59:31 +01:00
31 changed files with 1654 additions and 75 deletions

4
.env
View File

@@ -18,3 +18,7 @@
APP_ENV=dev APP_ENV=dev
APP_SECRET=f27f6cb11fb594fcb5ab4591ec0aac77 APP_SECRET=f27f6cb11fb594fcb5ab4591ec0aac77
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> sentry/sentry-symfony ###
SENTRY_DSN=
###< sentry/sentry-symfony ###

View File

@@ -1,5 +1,34 @@
symfony_example_app
===================
Generátor QR kódů pro platby z https://topapi.top-test.cz
Quick setup
-----------
```bash
# git clone https://git.nanobyte.cz/nanobyte-public/symfony_example_app.git
# cd symfony_example_app
# docker compose run --build php-fpm composer install
# docker compose up --build
```
- aplikace je dostupná na http://localhost:8000/
- na adrese http://localhost:3000/explore je běžící grafana (s loki a tempo)
- aplikace je nastavená (i na lokálu, běžně bych to nastavil až na devech/stage/PROD) aby posílala logy do lokiho a tracing do tempa pomocí otel protokolu
TODO: TODO:
----- -----
- [ ] Chybí speciální slovník nebo vypnutí slovníku pro testy - [ ] Chybí speciální slovník nebo vypnutí slovníku pro testy
- [ ] V reálný aplikaci bych použil Mockery, nicméně tady mě to přijde zbytečný - [ ] V reálný aplikaci bych použil Mockery, nicméně tady mě to přijde zbytečný
- [ ] Nastavení cache ideálně v memcached/redis etc. - [ ] Nastavení cache ideálně v memcached/redis etc.
- [ ] Vyhezkat OTEL logs, OTEL tracing
- [ ] CI pipelines
- [ ] k8s deployment
- [ ] prometheus country na počet requestů/api (počet 200OK/500ERR)
- [ ] Je dost na zvážení zda nezobrazit výsledek remote validace (response_create_400.json) a nenechat uživatele špatné hodnoty opravit. Ovšem znamená to že lokální validátory jsou špatně, chybu by bylo vhodné zalogovat do Sentry (do logu etc.) a opravit ji...
- [ ] Na produkci bych statický content rozhodně netlačil přes app container ale přes static nginx container (asset-map:compile -> copy do nginx static containeru)
- [ ] xdebug v dockeru
Poznámky
--------
- Nejsem kodér (a javascript developer), nevypadá to nijak extra ;-)

View File

@@ -10,14 +10,17 @@
"ext-iconv": "*", "ext-iconv": "*",
"grpc/grpc": "^1.57", "grpc/grpc": "^1.57",
"guzzlehttp/promises": "*", "guzzlehttp/promises": "*",
"nubium/this-should-never-happen-exception": "^1.0",
"nyholm/psr7": "*", "nyholm/psr7": "*",
"open-telemetry/exporter-otlp": "^1.0", "open-telemetry/exporter-otlp": "^1.0",
"open-telemetry/opentelemetry-auto-symfony": "^1.0@beta", "open-telemetry/opentelemetry-auto-symfony": "^1.0@beta",
"open-telemetry/opentelemetry-logger-monolog": "^1.0",
"open-telemetry/opentelemetry-propagation-server-timing": "^0.0.1", "open-telemetry/opentelemetry-propagation-server-timing": "^0.0.1",
"open-telemetry/opentelemetry-propagation-traceresponse": "^0.0.2", "open-telemetry/opentelemetry-propagation-traceresponse": "^0.0.2",
"open-telemetry/sdk": "^1.0", "open-telemetry/sdk": "^1.0",
"open-telemetry/transport-grpc": "^1.0", "open-telemetry/transport-grpc": "^1.0",
"php-http/httplug": "*", "php-http/httplug": "*",
"sentry/sentry-symfony": "^4.13",
"symfony/asset": "7.0.*", "symfony/asset": "7.0.*",
"symfony/asset-mapper": "7.0.*", "symfony/asset-mapper": "7.0.*",
"symfony/cache": "7.0.*", "symfony/cache": "7.0.*",

1233
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,4 +6,5 @@ return [
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Sentry\SentryBundle\SentryBundle::class => ['prod' => true],
]; ];

View File

@@ -5,6 +5,10 @@ monolog:
when@dev: when@dev:
monolog: monolog:
handlers: handlers:
otel: # Tohle bych v realny aplikaci nepouzil na devu
type: service
id: App\Bridge\Monolog\Handler\SymfonyOtelHandler
main: main:
type: stream type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log" path: "%kernel.logs_dir%/%kernel.environment%.log"
@@ -40,6 +44,9 @@ when@test:
when@prod: when@prod:
monolog: monolog:
handlers: handlers:
otel:
type: service
id: App\Bridge\Monolog\Handler\SymfonyOtelHandler
main: main:
type: fingers_crossed type: fingers_crossed
action_level: error action_level: error

View File

@@ -0,0 +1,26 @@
when@prod:
sentry:
dsn: '%env(SENTRY_DSN)%'
# this hooks into critical paths of the framework (and vendors) to perform
# automatic instrumentation (there might be some performance penalty)
# https://docs.sentry.io/platforms/php/guides/symfony/performance/instrumentation/automatic-instrumentation/
tracing:
enabled: false
# If you are using Monolog, you also need this additional configuration to log the errors correctly:
# https://docs.sentry.io/platforms/php/guides/symfony/#monolog-integration
# register_error_listener: false
# register_error_handler: false
# monolog:
# handlers:
# sentry:
# type: sentry
# level: !php/const Monolog\Logger::ERROR
# hub_id: Sentry\State\HubInterface
# Uncomment these lines to register a log message processor that resolves PSR-3 placeholders
# https://docs.sentry.io/platforms/php/guides/symfony/#monolog-integration
# services:
# Monolog\Processor\PsrLogMessageProcessor:
# tags: { name: monolog.processor, handler: sentry }

View File

@@ -31,7 +31,7 @@ services:
arguments: arguments:
$available_currencies: '%app.currencies%' $available_currencies: '%app.currencies%'
App\Service\StubQRCodeGenerator: App\Service\StubQRCodeProvider:
arguments: arguments:
$imagePath: '%kernel.project_dir%/assets/images/wip.png' $imagePath: '%kernel.project_dir%/assets/images/wip.png'
@@ -39,15 +39,23 @@ services:
arguments: arguments:
$username: '%app.usetreno.username%' $username: '%app.usetreno.username%'
$password: '%app.usetreno.password%' $password: '%app.usetreno.password%'
$retryCount: 2
$retryWaitSeconds: 0.5
App\Service\CachedQRCodeGenerator: App\Service\Remote\UsetrenoQRCodeProvider:
arguments:
$retryCount: 2
$retryWaitSeconds: 0.5
App\Service\CachedQRCodeProvider:
arguments: arguments:
$innerGenerator: '@App\Service\Remote\UsetrenoQRCodeProvider' $innerGenerator: '@App\Service\Remote\UsetrenoQRCodeProvider'
$cacheDuration: 'PT60S' $cacheDuration: 'PT60S'
App\Service\CurrencyListerInterface: '@App\Service\StaticCurrencyLister' App\Service\CurrencyListerInterface: '@App\Service\StaticCurrencyLister'
App\Service\QRCodeQROptionsProviderInterface: '@App\Service\QRCodeQROptionsDefaultProvider' App\Service\QRCodeQROptionsProviderInterface: '@App\Service\QRCodeQROptionsDefaultProvider'
App\Service\QRCodeGeneratorInterface: '@App\Service\CachedQRCodeGenerator' App\Service\QRCodeProviderInterface: '@App\Service\CachedQRCodeProvider'
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones

View File

@@ -11,22 +11,40 @@ processors:
actions: actions:
- action: insert - action: insert
key: loki.attribute.labels key: loki.attribute.labels
value: log.file, log.line, log.module_path, http.request_id, http.uri value: context, code.filepath, code.namespace, code.function, code.lineno, http.request.method, http.request.body.size, url.full, url.scheme, url.path, http.route, http.response.status_code
- action: insert - action: insert
from_attribute: log.file from_attribute: context
key: log.file key: context
- action: insert - action: insert
from_attribute: log.line from_attribute: code.namespace
key: log.line key: code.namespace
- action: insert - action: insert
from_attribute: log.module_path from_attribute: code.function
key: log.module_path key: code.function
- action: insert - action: insert
from_attribute: http.request_id from_attribute: code.lineno
key: http.request_id key: code.lineno
- action: insert - action: insert
from_attribute: http.uri from_attribute: http.request.method
key: http.uri key: http.request.method
- action: insert
from_attribute: http.request.body.size
key: http.request.body.size
- action: insert
from_attribute: http.response.status_code
key: http.response.status_code
- action: insert
from_attribute: url.full
key: url.full
- action: insert
from_attribute: url.scheme
key: url.scheme
- action: insert
from_attribute: url.path
key: url.path
- action: insert
from_attribute: http.route
key: http.route
- action: insert - action: insert
key: loki.format key: loki.format
value: raw value: raw

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Bridge\Monolog\Handler;
use Monolog\Formatter\FormatterInterface;
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\AbstractHandler;
use Monolog\Handler\FormattableHandlerTrait;
use Monolog\LogRecord;
use OpenTelemetry\API\Globals;
use OpenTelemetry\Contrib\Logs\Monolog\Handler;
use Psr\Log\LogLevel;
use Symfony\Bridge\Monolog\Formatter\VarDumperFormatter;
final class SymfonyOtelHandler extends AbstractHandler
{
protected readonly Handler $innerHandler;
public function __construct() {
parent::__construct();
$this->innerHandler = new Handler(
Globals::loggerProvider(),
LogLevel::INFO, //or `Logger::INFO`, or `Level::Info` depending on monolog version
true,
);
}
public function handle(LogRecord $record): bool
{
return $this->innerHandler->handle($record);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route;
/**
* Used for testing error handling (sentry...)
*/
class ExceptionController extends AbstractController
{
#[Route("give-me-error-please/exception")]
public function makeException(): void {
throw new \InvalidArgumentException("There is exception");
}
}

View File

@@ -5,7 +5,7 @@ namespace App\Controller;
use App\Entity\Input\QRCode\QRCode; use App\Entity\Input\QRCode\QRCode;
use App\Form\Type\QRCodeType; use App\Form\Type\QRCodeType;
use App\Service\DTO\QRCodeEntityConverter; use App\Service\DTO\QRCodeEntityConverter;
use App\Service\QRCodeGeneratorInterface; use App\Service\QRCodeProviderInterface;
use App\Service\QRCodeQROptionsProviderInterface; use App\Service\QRCodeQROptionsProviderInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -16,8 +16,8 @@ class IndexController extends AbstractController {
public function __construct(private readonly QRCodeQROptionsProviderInterface $qrCodeQROptionsFactory, private readonly QRCodeEntityConverter $codeEntityConverter) {} public function __construct(private readonly QRCodeQROptionsProviderInterface $qrCodeQROptionsFactory, private readonly QRCodeEntityConverter $codeEntityConverter) {}
#[Route('/', name: 'homepage')] #[Route('/', name: 'homepage')]
public function indexAction( public function indexAction(
Request $request, Request $request,
QRCodeGeneratorInterface $qrCodeGenerator, QRCodeProviderInterface $qrCodeGenerator,
): Response ): Response
{ {
$qrCodeImage = null; $qrCodeImage = null;

View File

@@ -1,4 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Entity\Remote\Usetreno; namespace App\Entity\Remote\Usetreno;

View File

@@ -23,7 +23,7 @@ class QRCodeMoneyType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('amount', NumberType::class) ->add('amount', NumberType::class, ['invalid_message' => 'messages.not_valid_amount'])
->add('currency', ChoiceType::class, [ ->add('currency', ChoiceType::class, [
'choices' => array_combine( 'choices' => array_combine(
iterator_to_array($this->currencyLister->getCurrencies()), iterator_to_array($this->currencyLister->getCurrencies()),

View File

@@ -5,7 +5,7 @@ namespace App\Service;
use App\Entity\DTO\QRCode\QRCode; use App\Entity\DTO\QRCode\QRCode;
interface CacheableQRCodeGeneratorInterface extends QRCodeGeneratorInterface interface CacheableQRCodeProviderInterface extends QRCodeProviderInterface
{ {
// We can't calculate cache key directly from QRCode // We can't calculate cache key directly from QRCode
public function getCacheKey(QRCode $entity): string; public function getCacheKey(QRCode $entity): string;

View File

@@ -10,14 +10,14 @@ use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
final readonly class CachedQRCodeGenerator implements QRCodeGeneratorInterface final readonly class CachedQRCodeProvider implements QRCodeProviderInterface
{ {
private \DateInterval $cacheDuration; private \DateInterval $cacheDuration;
/** /**
* @throws Exception * @throws Exception
*/ */
public function __construct(private CacheableQRCodeGeneratorInterface $innerGenerator, private CacheInterface $cache, private LoggerInterface $logger, string $cacheDuration) public function __construct(private CacheableQRCodeProviderInterface $innerGenerator, private CacheInterface $cache, private LoggerInterface $logger, string $cacheDuration)
{ {
$this->cacheDuration = new \DateInterval($cacheDuration); $this->cacheDuration = new \DateInterval($cacheDuration);
} }

View File

@@ -6,6 +6,7 @@ use App\Entity\DTO\QRCode\QRCode;
use App\Entity\DTO\QRCode\QRCodeMoney; use App\Entity\DTO\QRCode\QRCodeMoney;
use App\Entity\DTO\QRCode\QRCodePaymentIdentification; use App\Entity\DTO\QRCode\QRCodePaymentIdentification;
use App\Entity\DTO\QRCode\QRCodeQROptions; use App\Entity\DTO\QRCode\QRCodeQROptions;
use Nubium\Exception\ThisShouldNeverHappenException;
use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Validator\Validator\ValidatorInterface;
final readonly class QRCodeEntityConverter final readonly class QRCodeEntityConverter
@@ -20,11 +21,11 @@ final readonly class QRCodeEntityConverter
} }
return new QRCode( return new QRCode(
$code->getIban() ?? throw new \InvalidArgumentException("iban not set"), $code->getIban() ?? throw new ThisShouldNeverHappenException("iban not set"),
\DateTimeImmutable::createFromMutable($code->getDueDate() ?? throw new \InvalidArgumentException("due date not set")), \DateTimeImmutable::createFromMutable($code->getDueDate() ?? throw new ThisShouldNeverHappenException("due date not set")),
$code->getMessage() ?? throw new \InvalidArgumentException("message not set"), $code->getMessage() ?? throw new ThisShouldNeverHappenException("message not set"),
$this->convertMoney($code->getMoney() ?? throw new \InvalidArgumentException("money not set")), $this->convertMoney($code->getMoney() ?? throw new ThisShouldNeverHappenException("money not set")),
$this->convertCodeOptions($code->getCodeQROptions() ?? throw new \InvalidArgumentException("codeQROptions not set")), $this->convertCodeOptions($code->getCodeQROptions() ?? throw new ThisShouldNeverHappenException("codeQROptions not set")),
$this->convertIdentification($code->getPaymentIdentification()) $this->convertIdentification($code->getPaymentIdentification())
); );
} }
@@ -32,8 +33,8 @@ final readonly class QRCodeEntityConverter
protected function convertMoney(\App\Entity\Input\QRCode\QRCodeMoney $money): QRCodeMoney protected function convertMoney(\App\Entity\Input\QRCode\QRCodeMoney $money): QRCodeMoney
{ {
return new QRCodeMoney( return new QRCodeMoney(
$money->getAmount() ?? throw new \InvalidArgumentException("amount not set"), $money->getAmount() ?? throw new ThisShouldNeverHappenException("amount not set"),
$money->getCurrency() ?? throw new \InvalidArgumentException("currency not set") $money->getCurrency() ?? throw new ThisShouldNeverHappenException("currency not set")
); );
} }

View File

@@ -6,7 +6,7 @@ namespace App\Service;
use App\Entity\DTO\QRCode\QRCode; use App\Entity\DTO\QRCode\QRCode;
use App\Service\Exception\QRCodeGeneratorException; use App\Service\Exception\QRCodeGeneratorException;
interface QRCodeGeneratorInterface interface QRCodeProviderInterface
{ {
/** /**
* Generates QR code from entity * Generates QR code from entity

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Service\Remote\Exception;
class UsetrenoQRCodeRemoteServerErrorException extends UsetrenoQRCodeException
{
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Service\Remote;
use Exception;
use Psr\Log\LoggerInterface;
trait RetryingFailClientTrait
{
/**
* Lepsi zopakovat request kvuli drobnemu vypadku site nebo sluzby (u nedestruktivni operace)
* nez hodit klientovi rovnou 500
*
* @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

@@ -5,7 +5,9 @@ namespace App\Service\Remote;
use App\Entity\Remote\Usetreno\AuthRequest; use App\Entity\Remote\Usetreno\AuthRequest;
use App\Service\Remote\Exception\AuthorizeException; use App\Service\Remote\Exception\AuthorizeException;
use Nubium\Exception\ThisShouldNeverHappenException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
@@ -16,12 +18,16 @@ use Symfony\Contracts\HttpClient\ResponseStreamInterface;
class UsetrenoHttpClient implements HttpClientInterface class UsetrenoHttpClient implements HttpClientInterface
{ {
use RetryingFailClientTrait;
const AUTHORIZE_API = "https://topapi.top-test.cz/chameleon/api/v1/token"; const AUTHORIZE_API = "https://topapi.top-test.cz/chameleon/api/v1/token";
protected ?string $authorizationToken = null; protected ?string $authorizationToken = null;
public function __construct(protected HttpClientInterface $innerClient, protected readonly LoggerInterface $logger, protected readonly string $username, public function __construct(protected HttpClientInterface $innerClient, protected readonly LoggerInterface $logger, protected readonly string $username,
protected readonly string $password) { } protected readonly string $password,
protected readonly float $retryWaitSeconds,
protected readonly int $retryCount) { }
/** /**
* @param string $method * @param string $method
@@ -88,26 +94,35 @@ class UsetrenoHttpClient implements HttpClientInterface
"AUTHORIZE_API" => static::AUTHORIZE_API "AUTHORIZE_API" => static::AUTHORIZE_API
]); ]);
$rq = $this->innerClient->request( $responseData = $this->retryingFailRequest($this->retryCount, $this->retryWaitSeconds,
"POST", [AuthorizeException::class, TransportException::class], $this->logger, function() {
static::AUTHORIZE_API, $rq = $this->innerClient->request(
[ "POST",
'json' => new AuthRequest($this->username, $this->password), static::AUTHORIZE_API,
] [
); 'json' => new AuthRequest($this->username, $this->password),
]
);
$statusCode = $rq->getStatusCode(); $statusCode = $rq->getStatusCode();
$responseData = $rq->getContent(false); $responseData = $rq->getContent(false);
if ($statusCode != 200) { if ($statusCode != 200) {
$this->logger->error("authorization request status code is not ok", [ $this->logger->error("authorization request status code is not ok", [
"AUTHORIZE_API" => static::AUTHORIZE_API, "AUTHORIZE_API" => static::AUTHORIZE_API,
"statusCode" => $statusCode, "statusCode" => $statusCode,
"content" => $responseData, "content" => $responseData,
"username" => $this->username "username" => $this->username
]); ]);
throw new AuthorizeException("Return code is not 200 OK (got: code: $statusCode)"); throw new AuthorizeException("Return code is not 200 OK (got: code: $statusCode)");
}
return $responseData;
});
if (!is_string($responseData)) {
throw new ThisShouldNeverHappenException("responseData is not a string");
} }
$this->authorizationToken = $this->processAuthorizeResponse($responseData); $this->authorizationToken = $this->processAuthorizeResponse($responseData);

View File

@@ -4,19 +4,26 @@ declare(strict_types=1);
namespace App\Service\Remote; namespace App\Service\Remote;
use App\Entity\DTO\QRCode\QRCode; use App\Entity\DTO\QRCode\QRCode;
use App\Service\CacheableQRCodeGeneratorInterface; use App\Service\CacheableQRCodeProviderInterface;
use App\Service\Remote\Edge\QRCodeEntityConverter; use App\Service\Remote\Edge\QRCodeEntityConverter;
use App\Service\Remote\Exception\UsetrenoQRCodeException; use App\Service\Remote\Exception\UsetrenoQRCodeException;
use App\Service\Remote\Exception\UsetrenoQRCodeRemoteServerErrorException;
use Nubium\Exception\ThisShouldNeverHappenException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\TransportException;
class UsetrenoQRCodeProvider implements CacheableQRCodeGeneratorInterface class UsetrenoQRCodeProvider implements CacheableQRCodeProviderInterface
{ {
use RetryingFailClientTrait;
const QRCODE_API = 'https://topapi.top-test.cz/chameleon/api/v1/qr-code/create-for-bank-account-payment'; const QRCODE_API = 'https://topapi.top-test.cz/chameleon/api/v1/qr-code/create-for-bank-account-payment';
const QRCODE_METHOD = 'POST'; const QRCODE_METHOD = 'POST';
public function __construct(protected readonly LoggerInterface $logger, public function __construct(protected readonly LoggerInterface $logger,
protected readonly UsetrenoHttpClient $client, protected readonly UsetrenoHttpClient $client,
protected readonly QRCodeEntityConverter $codeEntityConverter) { } protected readonly QRCodeEntityConverter $codeEntityConverter,
protected readonly float $retryWaitSeconds,
protected readonly int $retryCount) { }
public function generateQRCodeFromEntity(QRCode $entity): string public function generateQRCodeFromEntity(QRCode $entity): string
{ {
@@ -27,27 +34,41 @@ class UsetrenoQRCodeProvider implements CacheableQRCodeGeneratorInterface
"edgeEntity" => $edgeEntity "edgeEntity" => $edgeEntity
]); ]);
$response = $this->client->request(static::QRCODE_METHOD, static::QRCODE_API, [ $responseData = $this->retryingFailRequest($this->retryCount, $this->retryWaitSeconds,
'json' => $edgeEntity, [TransportException::class, UsetrenoQRCodeRemoteServerErrorException::class], $this->logger,
]); function() use ($edgeEntity) {
$response = $this->client->request(static::QRCODE_METHOD, static::QRCODE_API, [
'json' => $edgeEntity,
]);
$statusCode = $response->getStatusCode(); $statusCode = $response->getStatusCode();
$responseData = $response->getContent(false); $responseData = $response->getContent(false);
if ($statusCode != 200) { if ($statusCode != 200) {
$this->logger->error("qrcode request status code is not ok", [ $this->logger->error("qrcode request status code is not ok", [
"AUTHORIZE_API" => static::QRCODE_API, "AUTHORIZE_API" => static::QRCODE_API,
"statusCode" => $statusCode, "statusCode" => $statusCode,
"content" => $responseData, "content" => $responseData,
]); ]);
throw new UsetrenoQRCodeException("Return code is not 200 OK (got: code: $statusCode)"); if ($statusCode > 500) {
throw new UsetrenoQRCodeRemoteServerErrorException("Return code is not 200 OK (got: code: $statusCode)");
}
throw new UsetrenoQRCodeException("Return code is not 200 OK (got: code: $statusCode)");
}
$this->logger->debug("QRCode generation success", [
"responseContent" => $responseData,
]);
return $responseData;
});
if (!is_string($responseData)) {
throw new ThisShouldNeverHappenException("responseData is not a string");
} }
$this->logger->debug("QRCode generation success", [
"responseContent" => $responseData,
]);
return $this->parseBase64String($this->processQRCodeResponseEntity($responseData)); return $this->parseBase64String($this->processQRCodeResponseEntity($responseData));
} }

View File

@@ -5,7 +5,7 @@ namespace App\Service;
use App\Entity\DTO\QRCode\QRCode; use App\Entity\DTO\QRCode\QRCode;
readonly final class StubQRCodeGenerator implements QRCodeGeneratorInterface readonly final class StubQRCodeProvider implements QRCodeProviderInterface
{ {
public function __construct(private string $imagePath) {} public function __construct(private string $imagePath) {}

View File

@@ -49,6 +49,18 @@
"tests/bootstrap.php" "tests/bootstrap.php"
] ]
}, },
"sentry/sentry-symfony": {
"version": "4.13",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "4.6",
"ref": "153de5f041f7e8a9c19f3674b800b76be0e6fd90"
},
"files": [
"config/packages/sentry.yaml"
]
},
"symfony/asset-mapper": { "symfony/asset-mapper": {
"version": "7.0", "version": "7.0",
"recipe": { "recipe": {

View File

@@ -0,0 +1,12 @@
{% extends 'base.html.twig' %}
{% block body %}
<p>
<div class="container">
<div style="text-align: center;">
<h1>{{ "page not found" | trans }}</h1>
<a href="{{ path('homepage') }}">{{ "return to the homepage" | trans }}</a>
</div>
</div>
</p>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'base.html.twig' %}
{% block body %}
<p>
<div class="container">
<div style="text-align: center;">
<h1>{{ "internal server error" | trans }}</h1>
{{ "our developers are doing their best to fix it. please try again later." | trans }}
<a href="{{ path('homepage') }}">{{ "return to the homepage" | trans }}</a>
</div>
</div>
</p>
{% endblock %}

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\Service\QRCodeGeneratorInterface; use App\Service\QRCodeProviderInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -76,7 +76,7 @@ class IndexControllerTest extends WebTestCase
private function generateClientForSubmitTest(): KernelBrowser private function generateClientForSubmitTest(): KernelBrowser
{ {
$mock = $this->createMock(QRCodeGeneratorInterface::class); $mock = $this->createMock(QRCodeProviderInterface::class);
$mock->expects($this->any()) $mock->expects($this->any())
->method('generateQRCodeFromEntity') ->method('generateQRCodeFromEntity')
->will($this->returnValue('foo')); ->will($this->returnValue('foo'));
@@ -85,7 +85,7 @@ class IndexControllerTest extends WebTestCase
$client = static::createClient(); $client = static::createClient();
$client->disableReboot(); $client->disableReboot();
self::getContainer()->set('App\Service\QRCodeGeneratorInterface', $mock); self::getContainer()->set('App\Service\QRCodeProviderInterface', $mock);
return $client; return $client;
} }

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Tests\Service\Remote;
use App\Service\Remote\RetryingFailClientTrait;
use App\Tests\Common\LoggerTrait;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class RetryingFailClientTraitTest 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() {
$trait = new class {
use RetryingFailClientTrait {
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() {
$trait = new class {
use RetryingFailClientTrait {
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);
}
}

View File

@@ -24,7 +24,7 @@ class UsetrenoHttpClientTest extends TestCase
]); ]);
$authorizedRequestResponse = clone $authMockResponse; $authorizedRequestResponse = clone $authMockResponse;
$mockedClient = new MockHttpClient([$authMockResponse, $authorizedRequestResponse]); $mockedClient = new MockHttpClient([$authMockResponse, $authorizedRequestResponse]);
$client = new UsetrenoHttpClient($mockedClient, $this->getLogger(), "foo", "bar"); $client = new UsetrenoHttpClient($mockedClient, $this->getLogger(), "foo", "bar", 0, 0);
$client->request("POST", "https://www.root.cz/"); $client->request("POST", "https://www.root.cz/");
$this->assertEquals("https://topapi.top-test.cz/chameleon/api/v1/token", $authMockResponse->getRequestUrl()); $this->assertEquals("https://topapi.top-test.cz/chameleon/api/v1/token", $authMockResponse->getRequestUrl());
$headers = $authorizedRequestResponse->getRequestOptions()['headers']; $headers = $authorizedRequestResponse->getRequestOptions()['headers'];
@@ -48,7 +48,7 @@ class UsetrenoHttpClientTest extends TestCase
$authorizedRequestResponse = clone $authMockResponse; $authorizedRequestResponse = clone $authMockResponse;
$mockedClient = new MockHttpClient([$authMockResponse, $authorizedRequestResponse]); $mockedClient = new MockHttpClient([$authMockResponse, $authorizedRequestResponse]);
$client = new UsetrenoHttpClient($mockedClient, $this->getLogger(), "foo", "bar"); $client = new UsetrenoHttpClient($mockedClient, $this->getLogger(), "foo", "bar", 0, 0);
$client->request("POST", "https://www.root.cz/"); $client->request("POST", "https://www.root.cz/");
} }
} }

View File

@@ -75,7 +75,7 @@ class UsetrenoQRCodeProviderTest extends TestCase
->method('convert') ->method('convert')
->will($this->returnValue($edgeEntity)); ->will($this->returnValue($edgeEntity));
return new UsetrenoQRCodeProvider($this->getLogger(), $mock, $converterMock); return new UsetrenoQRCodeProvider($this->getLogger(), $mock, $converterMock, 0, 0);
} }
protected function createQRCodeEntityPair() { protected function createQRCodeEntityPair() {

View File

@@ -9,3 +9,7 @@ Generate qr code: "Vygenerovat QR kód"
Iban: "IBAN" Iban: "IBAN"
QR code generator: "Generátor bankovních QR kódů" QR code generator: "Generátor bankovních QR kódů"
You can generate another QR code here: "Další QR kód si můžete vygenerovat zde" You can generate another QR code here: "Další QR kód si můžete vygenerovat zde"
page not found: 'Stránka nenalezena'
return to the homepage: 'Zpět na homepage'
"our developers are doing their best to fix it. please try again later.": "Naši programátoři dělají maximum aby to opravili. Zkuste to prosím později."
internal server error: 'Něco se pokazilo :-('