Merge remote-tracking branch 'origin/feature_usetreno_api'

This commit is contained in:
Ondrej Vlach 2024-01-18 21:09:36 +01:00
commit 7db8200b8d
Signed by: ovlach
GPG Key ID: 4FF1A23B4914DE70
53 changed files with 2654 additions and 37 deletions

4
.env
View File

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

View File

@ -1,3 +1,6 @@
TODO:
-----
- [ ] 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ý
- [ ] Nastavení cache ideálně v memcached/redis etc.
- [ ] Vylepsit OTEL logs

View File

@ -10,22 +10,27 @@
"ext-iconv": "*",
"grpc/grpc": "^1.57",
"guzzlehttp/promises": "*",
"nubium/this-should-never-happen-exception": "^1.0",
"nyholm/psr7": "*",
"open-telemetry/exporter-otlp": "^1.0",
"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-traceresponse": "^0.0.2",
"open-telemetry/sdk": "^1.0",
"open-telemetry/transport-grpc": "^1.0",
"php-http/httplug": "*",
"sentry/sentry-symfony": "^4.13",
"symfony/asset": "7.0.*",
"symfony/asset-mapper": "7.0.*",
"symfony/cache": "7.0.*",
"symfony/console": "7.0.*",
"symfony/dotenv": "7.0.*",
"symfony/flex": "^2",
"symfony/form": "7.0.*",
"symfony/framework-bundle": "7.0.*",
"symfony/http-client": "*",
"symfony/monolog-bundle": "^3.10",
"symfony/options-resolver": "7.0.*",
"symfony/runtime": "7.0.*",
"symfony/translation": "7.0.*",

1493
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,7 +1,7 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
prefix_seed: ovlach/symfony_example_app
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.

View File

@ -0,0 +1,68 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev:
monolog:
handlers:
otel: # Tohle bych v realny aplikaci nepouzil na devu
type: service
id: App\Bridge\Monolog\Handler\SymfonyOtelHandler
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
# type: firephp
# level: info
#chromephp:
# type: chromephp
# level: info
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
otel:
type: service
id: App\Bridge\Monolog\Handler\SymfonyOtelHandler
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: stream
path: php://stderr
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr

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

@ -5,7 +5,11 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
env(SYMFONY_EXAMPLE_APP_CURRENCIES): '["CZK", "EUR", "USD", "GBP"]'
env(SYMFONY_EXAMPLE_APP_USETRENO_USERNAME): 'developer'
env(SYMFONY_EXAMPLE_APP_USETRENO_PASSWORD): 'developer123'
app.currencies: '%env(json:SYMFONY_EXAMPLE_APP_CURRENCIES)%'
app.usetreno.username: '%env(string:SYMFONY_EXAMPLE_APP_USETRENO_USERNAME)%'
app.usetreno.password: '%env(string:SYMFONY_EXAMPLE_APP_USETRENO_PASSWORD)%'
services:
# default configuration for services in *this* file
@ -31,9 +35,27 @@ services:
arguments:
$imagePath: '%kernel.project_dir%/assets/images/wip.png'
App\Service\Remote\UsetrenoHttpClient:
arguments:
$username: '%app.usetreno.username%'
$password: '%app.usetreno.password%'
$retryCount: 2
$retryWaitSeconds: 0.5
App\Service\Remote\UsetrenoQRCodeProvider:
arguments:
$retryCount: 2
$retryWaitSeconds: 0.5
App\Service\CachedQRCodeGenerator:
arguments:
$innerGenerator: '@App\Service\Remote\UsetrenoQRCodeProvider'
$cacheDuration: 'PT60S'
App\Service\CurrencyListerInterface: '@App\Service\StaticCurrencyLister'
App\Service\QRCodeQROptionsProviderInterface: '@App\Service\QRCodeQROptionsDefaultProvider'
App\Service\QRCodeGeneratorInterface: '@App\Service\StubQRCodeGenerator'
App\Service\QRCodeGeneratorInterface: '@App\Service\CachedQRCodeGenerator'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

View File

@ -11,22 +11,40 @@ processors:
actions:
- action: insert
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
from_attribute: log.file
key: log.file
from_attribute: context
key: context
- action: insert
from_attribute: log.line
key: log.line
from_attribute: code.namespace
key: code.namespace
- action: insert
from_attribute: log.module_path
key: log.module_path
from_attribute: code.function
key: code.function
- action: insert
from_attribute: http.request_id
key: http.request_id
from_attribute: code.lineno
key: code.lineno
- action: insert
from_attribute: http.uri
key: http.uri
from_attribute: http.request.method
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
key: loki.format
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

@ -2,8 +2,9 @@
namespace App\Controller;
use App\Entity\QRCode\QRCode;
use App\Entity\Input\QRCode\QRCode;
use App\Form\Type\QRCodeType;
use App\Service\DTO\QRCodeEntityConverter;
use App\Service\QRCodeGeneratorInterface;
use App\Service\QRCodeQROptionsProviderInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -12,7 +13,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class IndexController extends AbstractController {
public function __construct(private readonly QRCodeQROptionsProviderInterface $qrCodeQROptionsFactory) {}
public function __construct(private readonly QRCodeQROptionsProviderInterface $qrCodeQROptionsFactory, private readonly QRCodeEntityConverter $codeEntityConverter) {}
#[Route('/', name: 'homepage')]
public function indexAction(
Request $request,
@ -33,7 +34,7 @@ class IndexController extends AbstractController {
do session, nicmene u takovehleho typu aplikace me to prijde naopak kontraproduktivni.
a zadani o tom mlci. Navic v pripade ze aplikace bude provozovana ve vice instancich
by se musela resit memcache, redis... */
$qrCodeImage = $qrCodeGenerator->generateQRCodeFromEntity($qrCode);
$qrCodeImage = $qrCodeGenerator->generateQRCodeFromEntity($this->codeEntityConverter->convert($qrCode));
$form = $this->createForm(QRCodeType::class, $this->createQrCodeEntity());
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Entity\DTO\QRCode;
readonly class QRCode {
public function __construct(
public string $iban,
public \DateTimeImmutable $dueDate,
public string $message,
public QRCodeMoney $money,
public QRCodeQROptions $qrOptions,
public ?QRCodePaymentIdentification $paymentIdentification)
{ }
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Entity\DTO\QRCode;
readonly class QRCodeMoney
{
public function __construct(public string $amount, public string $currency) { }
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Entity\DTO\QRCode;
readonly class QRCodePaymentIdentification
{
public function __construct(public ?string $variableSymbol, public ?string $specificSymbol, public ?string $constantSymbol) { }
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Entity\DTO\QRCode;
readonly class QRCodeQROptions
{
public function __construct(public int $scale, public int $margin)
{
}
}

View File

@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Entity\QRCode;
namespace App\Entity\Input\QRCode;
use Symfony\Component\Validator\Constraints as Assert;
/**

View File

@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Entity\QRCode;
namespace App\Entity\Input\QRCode;
use App\Validator\Currency;
use App\Validator\Money;

View File

@ -1,10 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Entity\QRCode;
namespace App\Entity\Input\QRCode;
use App\Validator\BankPaymentIdentificationNumber;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Payment identification for QR code
@ -13,17 +12,17 @@ class QRCodePaymentIdentification {
#[BankPaymentIdentificationNumber(
message: 'messages.not_variable_symbol',
)]
private ?string $variableSymbol;
private ?string $variableSymbol = null;
#[BankPaymentIdentificationNumber(
message: 'messages.not_specific_symbol',
)]
private ?string $specificSymbol;
private ?string $specificSymbol = null;
#[BankPaymentIdentificationNumber(
message: 'messages.not_constant_symbol',
)] // https://www.hyponamiru.cz/en/glossary/constant-symbol/
private ?string $constantSymbol;
private ?string $constantSymbol = null;
public function getVariableSymbol(): ?string
{

View File

@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Entity\QRCode;
namespace App\Entity\Input\QRCode;
use Symfony\Component\Validator\Constraints as Assert;

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Entity\Remote\Usetreno;
readonly class AuthRequest
{
public function __construct(public string $username, public string $password) {}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Entity\Remote\Usetreno\Edge;
/* Bohuzel jsem se nedozvedel z API co je optional a co je required. Tudiz jsem vychazel z toho ze vsechno je required az na EdgeQRCodeMoney.*
ktery uz z principu veci by mel byt optional (cizi staty nepodporuji variable, constant, specific...)
*/
readonly class EdgeQRCode {
public function __construct(
public string $iban,
public string $dueDate,
public string $message,
public EdgeQRCodeMoney $money,
public EdgeQRCodeQROptions $qrOptions,
public EdgeQRCodePaymentIdentification $paymentIdentification)
{ }
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Entity\Remote\Usetreno\Edge;
readonly class EdgeQRCodeMoney
{
public function __construct(public string $amount, public string $currency) { }
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Entity\Remote\Usetreno\Edge;
readonly class EdgeQRCodePaymentIdentification
{
public function __construct(public string $variableSymbol, public string $specificSymbol, public string $constantSymbol) { }
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Entity\Remote\Usetreno\Edge;
readonly class EdgeQRCodeQROptions
{
public function __construct(public int $scale, public int $margin)
{
}
}

View File

@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App\Form\Type;
use App\Entity\QRCode\QRCodeMoney;
use App\Entity\Input\QRCode\QRCodeMoney;
use App\Service\CurrencyListerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;

View File

@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App\Form\Type;
use App\Entity\QRCode\QRCodePaymentIdentification;
use App\Entity\Input\QRCode\QRCodePaymentIdentification;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Type;
use App\Entity\QRCode\QRCode;
use App\Entity\Input\QRCode\QRCode;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\DTO\QRCode\QRCode;
interface CacheableQRCodeGeneratorInterface extends QRCodeGeneratorInterface
{
// We can't calculate cache key directly from QRCode
public function getCacheKey(QRCode $entity): string;
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\DTO\QRCode\QRCode;
use Exception;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final readonly class CachedQRCodeGenerator implements QRCodeGeneratorInterface
{
private \DateInterval $cacheDuration;
/**
* @throws Exception
*/
public function __construct(private CacheableQRCodeGeneratorInterface $innerGenerator, private CacheInterface $cache, private LoggerInterface $logger, string $cacheDuration)
{
$this->cacheDuration = new \DateInterval($cacheDuration);
}
public function generateQRCodeFromEntity(QRCode $entity): string
{
$key = $this->innerGenerator->getCacheKey($entity);
return $this->cache->get($key, function(ItemInterface $c) use ($entity) {
$this->logger->debug("cache miss for key " . $c->getKey(), [
'entity' => $entity,
]);
$c->expiresAfter($this->cacheDuration);
return $this->innerGenerator->generateQRCodeFromEntity($entity);
});
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Service\DTO;
use App\Entity\DTO\QRCode\QRCode;
use App\Entity\DTO\QRCode\QRCodeMoney;
use App\Entity\DTO\QRCode\QRCodePaymentIdentification;
use App\Entity\DTO\QRCode\QRCodeQROptions;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final readonly class QRCodeEntityConverter
{
public function __construct(private ValidatorInterface $validator) {
}
public function convert(\App\Entity\Input\QRCode\QRCode $code): QRCode {
if (count($this->validator->validate($code)) !== 0) {
throw new \InvalidArgumentException("QRCode entity is not valid");
}
return new QRCode(
$code->getIban() ?? throw new \InvalidArgumentException("iban not set"),
\DateTimeImmutable::createFromMutable($code->getDueDate() ?? throw new \InvalidArgumentException("due date not set")),
$code->getMessage() ?? throw new \InvalidArgumentException("message not set"),
$this->convertMoney($code->getMoney() ?? throw new \InvalidArgumentException("money not set")),
$this->convertCodeOptions($code->getCodeQROptions() ?? throw new \InvalidArgumentException("codeQROptions not set")),
$this->convertIdentification($code->getPaymentIdentification())
);
}
protected function convertMoney(\App\Entity\Input\QRCode\QRCodeMoney $money): QRCodeMoney
{
return new QRCodeMoney(
$money->getAmount() ?? throw new \InvalidArgumentException("amount not set"),
$money->getCurrency() ?? throw new \InvalidArgumentException("currency not set")
);
}
protected function convertCodeOptions(\App\Entity\Input\QRCode\QRCodeQROptions $options): QRCodeQROptions
{
return new QRCodeQROptions(
$options->getScale(),
$options->getMargin()
);
}
protected function convertIdentification(?\App\Entity\Input\QRCode\QRCodePaymentIdentification $ident): ?QRCodePaymentIdentification {
return match ($ident) {
null => null,
default => new QRCodePaymentIdentification(
$ident->getVariableSymbol(),
$ident->getSpecificSymbol(),
$ident->getConstantSymbol()
)
};
}
}

View File

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

View File

@ -3,7 +3,8 @@ declare(strict_types=1);
namespace App\Service;
use App\Entity\QRCode\QRCode;
use App\Entity\DTO\QRCode\QRCode;
use App\Service\Exception\QRCodeGeneratorException;
interface QRCodeGeneratorInterface
{
@ -11,6 +12,7 @@ interface QRCodeGeneratorInterface
* Generates QR code from entity
* @param QRCode $entity
* @return string base64 encoded PNG
* @throws QRCodeGeneratorException
*/
public function generateQRCodeFromEntity(QRCode $entity): string;
}

View File

@ -3,15 +3,18 @@ declare(strict_types=1);
namespace App\Service;
use App\Entity\QRCode\QRCodeQROptions;
use App\Entity\Input\QRCode\QRCodeQROptions;
readonly final class QRCodeQROptionsDefaultProvider implements QRCodeQROptionsProviderInterface {
const DEFAULT_SCALE = 8;
const DEFAULT_MARGIN = 0;
private QRCodeQROptions $qrCodeDefaultOptions;
public function __construct() {
$this->qrCodeDefaultOptions = new QRCodeQROptions(
1,
0
self::DEFAULT_SCALE,
self::DEFAULT_MARGIN
);
}

View File

@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Entity\QRCode\QRCodeQROptions;
use App\Entity\Input\QRCode\QRCodeQROptions;
interface QRCodeQROptionsProviderInterface {
public function getDefault(): QRCodeQROptions;

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Service\Remote\Edge\Exceptions;
class MissingParameterException extends \RuntimeException
{
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Service\Remote\Edge;
use App\Entity\DTO\QRCode\QRCode;
use App\Entity\DTO\QRCode\QRCodeMoney;
use App\Entity\DTO\QRCode\QRCodePaymentIdentification;
use App\Entity\DTO\QRCode\QRCodeQROptions;
use App\Entity\Remote\Usetreno\Edge\EdgeQRCode;
use App\Entity\Remote\Usetreno\Edge\EdgeQRCodeMoney;
use App\Entity\Remote\Usetreno\Edge\EdgeQRCodePaymentIdentification;
use App\Entity\Remote\Usetreno\Edge\EdgeQRCodeQROptions;
use App\Service\Remote\Edge\Exceptions\MissingParameterException;
class QRCodeEntityConverter
{
public function __construct() { }
public function convert(QRCode $code): EdgeQRCode {
return new EdgeQRCode(
$code->iban,
$code->dueDate->format('Y-m-d'),
$code->message,
$this->convertMoney($code->money),
$this->convertCodeOptions($code->qrOptions),
$this->convertIdentification($code->paymentIdentification)
);
}
protected function convertMoney(QRCodeMoney $money): EdgeQRCodeMoney
{
return new EdgeQRCodeMoney(
$money->amount,
$money->currency
);
}
protected function convertCodeOptions(QRCodeQROptions $options): EdgeQRCodeQROptions
{
return new EdgeQRCodeQROptions(
$options->scale,
$options->margin
);
}
protected function convertIdentification(?QRCodePaymentIdentification $ident): EdgeQRCodePaymentIdentification {
// OMG: proc? symboly v ostatnich statech nejsou, prijde me lepsi posilat NULL, nebo empty string ;-)
return match ($ident) {
null => new EdgeQRCodePaymentIdentification(
"0",
"0",
"0"
),
default => new EdgeQRCodePaymentIdentification(
$ident->variableSymbol ?? "0",
$ident->specificSymbol ?? "0",
$ident->constantSymbol ?? "0"
)
};
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Service\Remote\Exception;
class AuthorizeException extends \RuntimeException
{
}

View File

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

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

@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Service\Remote;
use App\Entity\Remote\Usetreno\AuthRequest;
use App\Service\Remote\Exception\AuthorizeException;
use Nubium\Exception\ThisShouldNeverHappenException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\TransportException;
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;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
class UsetrenoHttpClient implements HttpClientInterface
{
use RetryingFailClientTrait;
const AUTHORIZE_API = "https://topapi.top-test.cz/chameleon/api/v1/token";
protected ?string $authorizationToken = null;
public function __construct(protected HttpClientInterface $innerClient, protected readonly LoggerInterface $logger, protected readonly string $username,
protected readonly string $password,
protected readonly float $retryWaitSeconds,
protected readonly int $retryCount) { }
/**
* @param string $method
* @param string $url
* @param array<string, mixed> $options
* @return ResponseInterface
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
public function request(string $method, string $url, array $options = []): ResponseInterface {
if ($this->authorizationToken === null) {
$this->authorize();
}
return $this->innerClient->request($method, $url, $options);
}
/**
* @param ResponseInterface|iterable<array-key, ResponseInterface> $responses One or more responses created by the current HTTP client
* @param float|null $timeout
* @return ResponseStreamInterface
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
public function stream(iterable|ResponseInterface $responses, float $timeout = null): ResponseStreamInterface
{
if ($this->authorizationToken === null) {
$this->authorize();
}
return $this->innerClient->stream($responses, $timeout);
}
/**
* @param array<string, mixed> $options
* @return static(UsetrenoHttpClient)
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
public function withOptions(array $options): static
{
if ($this->authorizationToken === null) {
$this->authorize();
}
$this->innerClient = $this->innerClient->withOptions($options);
return $this;
}
/**
* @throws TransportExceptionInterface
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
*/
protected function authorize(): void {
$this->logger->debug("trying authorize request", [
"AUTHORIZE_API" => static::AUTHORIZE_API
]);
$responseData = $this->retryingFailRequest($this->retryCount, $this->retryWaitSeconds,
[AuthorizeException::class, TransportException::class], $this->logger, function() {
$rq = $this->innerClient->request(
"POST",
static::AUTHORIZE_API,
[
'json' => new AuthRequest($this->username, $this->password),
]
);
$statusCode = $rq->getStatusCode();
$responseData = $rq->getContent(false);
if ($statusCode != 200) {
$this->logger->error("authorization request status code is not ok", [
"AUTHORIZE_API" => static::AUTHORIZE_API,
"statusCode" => $statusCode,
"content" => $responseData,
"username" => $this->username
]);
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->innerClient = $this->innerClient->withOptions([
'headers' => [
'Authorization' => "Bearer " . $this->authorizationToken
]
]);
}
/**
* @param string $responseData
* @return string Bearer token
* @throws AuthorizeException
*/
protected function processAuthorizeResponse(string $responseData): string {
$data = json_decode($responseData);
if (!is_object($data)) {
$this->logger->error("authorize: received null response data", [
"responseData" => $responseData
]);
throw new AuthorizeException("Can't decode json response");
}
if (!isset($data->data->token)) {
$this->logger->error("authorize: empty token in response data", [
"responseData" => $responseData
]);
throw new AuthorizeException("Got invalid json response");
}
return $data->data->token;
}
}

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Service\Remote;
use App\Entity\DTO\QRCode\QRCode;
use App\Service\CacheableQRCodeGeneratorInterface;
use App\Service\Remote\Edge\QRCodeEntityConverter;
use App\Service\Remote\Exception\UsetrenoQRCodeException;
use App\Service\Remote\Exception\UsetrenoQRCodeRemoteServerErrorException;
use Nubium\Exception\ThisShouldNeverHappenException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\TransportException;
class UsetrenoQRCodeProvider implements CacheableQRCodeGeneratorInterface
{
use RetryingFailClientTrait;
const QRCODE_API = 'https://topapi.top-test.cz/chameleon/api/v1/qr-code/create-for-bank-account-payment';
const QRCODE_METHOD = 'POST';
public function __construct(protected readonly LoggerInterface $logger,
protected readonly UsetrenoHttpClient $client,
protected readonly QRCodeEntityConverter $codeEntityConverter,
protected readonly float $retryWaitSeconds,
protected readonly int $retryCount) { }
public function generateQRCodeFromEntity(QRCode $entity): string
{
$edgeEntity = $this->codeEntityConverter->convert($entity);
$this->logger->debug("Sending request for QR code", [
"entity" => $entity,
"edgeEntity" => $edgeEntity
]);
$responseData = $this->retryingFailRequest($this->retryCount, $this->retryWaitSeconds,
[TransportException::class, UsetrenoQRCodeRemoteServerErrorException::class], $this->logger,
function() use ($edgeEntity) {
$response = $this->client->request(static::QRCODE_METHOD, static::QRCODE_API, [
'json' => $edgeEntity,
]);
$statusCode = $response->getStatusCode();
$responseData = $response->getContent(false);
if ($statusCode != 200) {
$this->logger->error("qrcode request status code is not ok", [
"AUTHORIZE_API" => static::QRCODE_API,
"statusCode" => $statusCode,
"content" => $responseData,
]);
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");
}
return $this->parseBase64String($this->processQRCodeResponseEntity($responseData));
}
protected function parseBase64String(string $content): string {
$data = explode(';base64,', $content);
// check if response is image (we support png only now)
if ($data[0] === "image/png") {
throw new UsetrenoQRCodeException("Unsupported image type $data[0]");
}
return $data[1];
}
/**
* @param string $responseData
* @return string Bearer token
* @throws UsetrenoQRCodeException
*/
protected function processQRCodeResponseEntity(string $responseData): string {
$data = json_decode($responseData);
if (!is_object($data)) {
$this->logger->error("qrcode: received null response data", [
"responseData" => $responseData
]);
throw new UsetrenoQRCodeException("Can't decode json response");
}
if (!isset($data->data->base64Data)) {
$this->logger->error("qrcode: empty base64Data in response data", [
"responseData" => $responseData
]);
throw new UsetrenoQRCodeException("Got invalid json response");
}
return $data->data->base64Data;
}
public function getCacheKey(QRCode $entity): string
{
$edgeEntity = $this->codeEntityConverter->convert($entity);
$encodedEntity = json_encode($edgeEntity);
if ($encodedEntity === false) {
throw new \RuntimeException("Can't serialize edge entity");
}
return base64_encode($encodedEntity);
}
}

View File

@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Entity\QRCode\QRCode;
use App\Entity\DTO\QRCode\QRCode;
readonly final class StubQRCodeGenerator implements QRCodeGeneratorInterface
{

View File

@ -24,7 +24,7 @@ class BankPaymentIdentificationNumberValidator extends ConstraintValidator
throw new UnexpectedValueException($value, "string");
}
if (strlen($value) <= 10 && filter_var($value, FILTER_VALIDATE_INT) !== false) {
if (strlen($value) <= 10 && filter_var($value, FILTER_VALIDATE_INT) !== false && (int) $value > 0) {
return ;
}

View File

@ -49,6 +49,18 @@
"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": {
"version": "7.0",
"recipe": {
@ -107,6 +119,18 @@
"src/Kernel.php"
]
},
"symfony/monolog-bundle": {
"version": "3.10",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "213676c4ec929f046dfde5ea8e97625b81bc0578"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/phpunit-bridge": {
"version": "7.0",
"recipe": {

View File

@ -0,0 +1,18 @@
<?php
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,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

@ -0,0 +1,54 @@
<?php
namespace App\Tests\Service\Remote;
use App\Service\Remote\Exception\AuthorizeException;
use App\Service\Remote\UsetrenoHttpClient;
use App\Tests\Common\LoggerTrait;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
class UsetrenoHttpClientTest extends TestCase
{
use LoggerTrait;
public function testRequestWillDoAutomaticAuthorization() {
$authMockResponse = new JsonMockResponse([
"data" => [
"expires_in" => 1000,
"token" => "foobarfoobar"
],
"timestamp" => "2024-01-17T22:47:59+01:00",
"uri" => "/api/v1/token"
]);
$authorizedRequestResponse = clone $authMockResponse;
$mockedClient = new MockHttpClient([$authMockResponse, $authorizedRequestResponse]);
$client = new UsetrenoHttpClient($mockedClient, $this->getLogger(), "foo", "bar", 0, 0);
$client->request("POST", "https://www.root.cz/");
$this->assertEquals("https://topapi.top-test.cz/chameleon/api/v1/token", $authMockResponse->getRequestUrl());
$headers = $authorizedRequestResponse->getRequestOptions()['headers'];
$this->assertEquals("https://www.root.cz/", $authorizedRequestResponse->getRequestUrl());
$this->assertTrue(in_array("Authorization: Bearer foobarfoobar", $headers), "missing bearer authorization header");
}
public function testRequestFailedAuthorization() {
$this->expectException(AuthorizeException::class);
$authMockResponse = new JsonMockResponse([
"errors" => [
[
"message" => "Bad credentials, please verify that your username/password are correctly set.",
"specificType" => "bad_credentials",
"type" => "security_error"
]
],
"timestamp" => "2024-01-17T23:55:25+01:00",
"uri" => "/api/v1/token"
]);
$authorizedRequestResponse = clone $authMockResponse;
$mockedClient = new MockHttpClient([$authMockResponse, $authorizedRequestResponse]);
$client = new UsetrenoHttpClient($mockedClient, $this->getLogger(), "foo", "bar", 0, 0);
$client->request("POST", "https://www.root.cz/");
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace App\Tests\Service\Remote;
use App\Entity\DTO\QRCode\QRCode;
use App\Entity\DTO\QRCode\QRCodeMoney;
use App\Entity\DTO\QRCode\QRCodePaymentIdentification;
use App\Entity\DTO\QRCode\QRCodeQROptions;
use App\Entity\Remote\Usetreno\Edge\EdgeQRCode;
use App\Entity\Remote\Usetreno\Edge\EdgeQRCodeMoney;
use App\Entity\Remote\Usetreno\Edge\EdgeQRCodePaymentIdentification;
use App\Entity\Remote\Usetreno\Edge\EdgeQRCodeQROptions;
use App\Service\Exception\QRCodeGeneratorException;
use App\Service\Remote\Edge\QRCodeEntityConverter;
use App\Service\Remote\UsetrenoHttpClient;
use App\Service\Remote\UsetrenoQRCodeProvider;
use App\Tests\Common\LoggerTrait;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\HttpClient\ResponseInterface;
class UsetrenoQRCodeProviderTest extends TestCase
{
use LoggerTrait;
public function testSuccessRequest() {
$base64Image = "iVBORw0KGgoAAAANSUhEUgAAAEkAAABJCAIAAAD+EZyLAAAACXBIWXMAAA7EAAAOxAGVKw4bAAACzklEQVRoge1a0Y6DMAwLp/v/X+49IFWenXaQZNt1qh8mKCnEJHUC7Git2Zfi59MOvBCb25rY3NbE5rYmNrc1sbmtic1tTWxua2JzWxO/sWnHcehga+04Dvw9B3HWZHx+5oCTQW56ve7xuaEEcGK3OQ3IzD1zAHFueFW96+cIEkaPKWg25pN5nZPiNgdmoIG7Ggc3UfOo15LWGlLq7o42dLwKqbipN5SKeIjCiINqXMIzzs1d4ugraqaO4HSyDIsHO/OKd6/qq8rJGxBcbwegj9ijYJx8UO6fBgSn49yYk3Et0RVClQ2z0RXMvusqZ15jCnSS7i4yIT5UBk9WruuTgnEdcW4kcfjr6iTGAbcpja1OS4LcVN9HnYfGCm2oUzMhdmWVjpBab8gHlcOtbLjrisSkyscQ1GXSd/MkRO1dMu6paCOGODcbrC7KOnWR8pCaySpilqzdqI3KlkAhdQNOZsmngWzc6PLaixA9PQPujixjTgb7SVrupCLUQ9KUUbQpVq4s3UK2druuu8JN67Dnnnae9pgXYXo1z90mmYOud4OntQujnalsJ1LPOJNird0GWqqWkrEbzLvI9sq9I3HDYrJgtOLbsybr3TnZ10Nnhf3xxVqHxK40NHeR0hKM1eje9yXnVmrz+KjqBt1L1n7njJdfrboFuqdA3rHUMw7pJN1gSlE1QFDPbY+5GkP8nQKFoq89HCSnO0ySU3U1H7eynJzHRNvlUbeFsywnJ2XvXjEg+KvNlz3GiooklfuMS8XfcYgDtRdu72JSDKoqQap20xLq47hN3TCVDb0do1MFUPwdh3aJhvb42nPiyZOiUvytQysvHiJflaq2LB/rS1xgNdMbT7EaRaakdhd/x8FDE3rmvY1Ey3mhv4hsX4JJSEddextIP010tee2h1W1+x/im/+Dsbmtic1tTWxua2JzWxOb25rY3NbE5rYmNrc18QcV0HKSoAc9PQAAAABJRU5ErkJggg==";
$successRequest = [
"uri" => "/api/v1/qr-code/create-for-bank-account-payment",
"timestamp" => "2024-01-16T14:05:42+01:00",
"data" => [
"base64Data" => "data:image/png;base64,$base64Image"
]
];
[$edgeEntity, $qrCodeEntity] = $this->createQRCodeEntityPair();
$qrCodeProvider = $this->createQRCodeProvider($successRequest, 200, $edgeEntity, $qrCodeEntity);
$data = $qrCodeProvider->generateQRCodeFromEntity($qrCodeEntity);
$this->assertEquals($base64Image, $data);
}
public function testFailureRequest() {
$this->expectException(QRCodeGeneratorException::class);
$failureRequest = [
"error" => "internal server error",
];
[$edgeEntity, $qrCodeEntity] = $this->createQRCodeEntityPair();
$qrCodeProvider = $this->createQRCodeProvider($failureRequest, 500, $edgeEntity, $qrCodeEntity);
$data = $qrCodeProvider->generateQRCodeFromEntity($qrCodeEntity);
$this->assertEquals($failureRequest["data"]["base64Data"], $data);
}
public function createQRCodeProvider(array $response, int $responseCode, EdgeQRCode $edgeEntity, QRCode $entity): UsetrenoQRCodeProvider
{
$responseMock = $this->createMock(ResponseInterface::class);
$responseMock->expects($this->any())
->method('getContent')
->will($this->returnValue(json_encode($response)));
$responseMock->expects($this->any())
->method('getStatusCode')
->will($this->returnValue($responseCode));
$mock = $this->createMock(UsetrenoHttpClient::class);
$mock->expects($this->once())
->method('request')
->will($this->returnValue($responseMock));
$converterMock = $this->createMock(QRCodeEntityConverter::class);
$converterMock->expects($this->once())
->method('convert')
->will($this->returnValue($edgeEntity));
return new UsetrenoQRCodeProvider($this->getLogger(), $mock, $converterMock, 0, 0);
}
protected function createQRCodeEntityPair() {
$edgeEntity = new EdgeQRCode(
"CZ0000",
(new \DateTime("now"))->format('Y-m-d'),
"foo",
new EdgeQRCodeMoney(
100,
"CZK"
),
new EdgeQRCodeQROptions(
1,
1
),
new EdgeQRCodePaymentIdentification(
"0", "0", "0"
)
);
$qrCodeEntity = new QRCode(
"CZ0000",
new \DateTimeImmutable("now"),
"foo",
new QRCodeMoney(
100,
"CZK"
),
new QRCodeQROptions(
1,
1
),
new QRCodePaymentIdentification(
"0", "0", "0"
)
);
return [$edgeEntity, $qrCodeEntity];
}
}

View File

@ -35,6 +35,6 @@ class BankPaymentIdentificationNumberValidatorTest extends ValidatorTestCase
}
private function failureDp(): array {
return [['122.b'], ['a.a'], ['a'], ['2.040'], ['2,a'], ['122.1'], ['12345678901']];
return [['122.b'], ['a.a'], ['a'], ['2.040'], ['2,a'], ['122.1'], ['12345678901'], ['-323']];
}
}

View File

@ -35,6 +35,6 @@ class MoneyValidatorTest extends ValidatorTestCase
}
private function failureDp(): array {
return [['122.b'], ['a.a'], ['a'], ['2.040'], ['2,a']];
return [['122.b'], ['a.a'], ['a'], ['2.040'], ['2,a'], ['-223']];
}
}