Compare commits
13 Commits
cf08d21e50
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
8b4f3ed64a
|
|||
|
ba143ed109
|
|||
|
98362e0379
|
|||
|
64667cc24c
|
|||
|
a31b283847
|
|||
|
0efd5343e3
|
|||
|
476cc641e5
|
|||
|
7db8200b8d
|
|||
|
6e5f929dc3
|
|||
|
7eac908bc4
|
|||
|
d7ff806129
|
|||
|
13bdd096be
|
|||
|
192cfb9b73
|
4
.env
4
.env
@@ -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 ###
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -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 ;-)
|
||||||
|
|||||||
@@ -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
1233
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
26
config/packages/sentry.yaml
Normal file
26
config/packages/sentry.yaml
Normal 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 }
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
33
src/Bridge/Monolog/Handler/SymfonyOtelHandler.php
Normal file
33
src/Bridge/Monolog/Handler/SymfonyOtelHandler.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Controller/ExceptionController.php
Normal file
17
src/Controller/ExceptionController.php
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Entity\Remote\Usetreno;
|
namespace App\Entity\Remote\Usetreno;
|
||||||
|
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Remote\Exception;
|
||||||
|
|
||||||
|
class UsetrenoQRCodeRemoteServerErrorException extends UsetrenoQRCodeException
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
58
src/Service/Remote/RetryingFailClientTrait.php
Normal file
58
src/Service/Remote/RetryingFailClientTrait.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|
||||||
12
symfony.lock
12
symfony.lock
@@ -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": {
|
||||||
|
|||||||
12
templates/bundles/TwigBundle/Exception/error404.html.twig
Normal file
12
templates/bundles/TwigBundle/Exception/error404.html.twig
Normal 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 %}
|
||||||
13
templates/bundles/TwigBundle/Exception/error500.html.twig
Normal file
13
templates/bundles/TwigBundle/Exception/error500.html.twig
Normal 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 %}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
56
tests/Service/Remote/RetryingFailClientTraitTest.php
Normal file
56
tests/Service/Remote/RetryingFailClientTraitTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 :-('
|
||||||
|
|||||||
Reference in New Issue
Block a user