Initial commit

This commit is contained in:
2025-10-05 13:55:28 +02:00
commit 68d521677a
767 changed files with 46947 additions and 0 deletions
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Application\Asset;
/**
* Enum AssetType.
*
* @author bernard-ng <bernard@devscast.tech>
*/
enum AssetType
{
case SOURCE_PROFILE_IMAGE;
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Application\Asset;
/**
* Class AssetUrlProvider.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface AssetUrlProvider
{
public function getUrl(string $id, AssetType $type): ?string;
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Application\Mailing;
use App\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Interface EmailDefinition.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface EmailDefinition
{
public function recipient(): EmailAddress;
public function subject(): string;
public function subjectVariables(): array;
public function template(): string;
public function templateVariables(): array;
public function locale(): string;
public function getDomain(): string;
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Application\Mailing;
/**
* Interface Mailer.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface Mailer
{
public function send(EmailDefinition $email): void;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Application\Messaging;
/**
* Interface AsyncMessage.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface AsyncMessage
{
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Application\Messaging;
/**
* Interface CommandBus.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface CommandBus
{
public function handle(object $message): mixed;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Application\Messaging;
/**
* Interface CommandHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface CommandHandler
{
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Application\Messaging;
/**
* Interface MessageBus.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface MessageBus
{
public function dispatch(AsyncMessage $message): void;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Application\Messaging;
/**
* Interface MessageHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface MessageHandler
{
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Application\Messaging;
/**
* Interface QueryBus.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface QueryBus
{
public function handle(object $message): mixed;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Application\Messaging;
/**
* Interface QueryHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface QueryHandler
{
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain;
/**
* Class Application.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Application
{
public string $name = 'DRC News Corpus';
public string $website = 'https://research.devscast.org/drc-news-corpus';
public string $emailAddress = 'contact@devscast.tech';
public string $infoAddress = 'contact@devscast.tech';
public string $emailName = 'DRC News Corpus';
public string $legalName = 'Devscast Software SàSu';
public string $legalRegistrationCode = '';
public string $legalVatCode = '';
public string $legalAddress = '10465, Avenue Lac kipopo, Lubumbashi, Haut-Katanga, RDC';
public string $legalPhone = '+243892530482';
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain;
use App\SharedKernel\Domain\Exception\InvalidArgument;
/**
* Class Assert.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Assert extends \Webmozart\Assert\Assert
{
#[\Override]
protected static function reportInvalidArgument($message): void
{
throw new InvalidArgument($message);
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\DataTransfert;
/**
* Interface DataExporter.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface DataExporter
{
public function export(iterable $data, TransfertSetting $setting = new TransfertSetting()): \SplFileObject;
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\DataTransfert;
/**
* Class DataImporter.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface DataImporter
{
public function import(\SplFileObject $file, TransfertSetting $setting = new TransfertSetting()): iterable;
}
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\DataTransfert;
use App\SharedKernel\Domain\Assert;
use BackedEnum as T;
use Symfony\Component\Uid\UuidV7;
/**
* Class DataMapping.
*
* @author bernard-ng <bernard@devscast.tech>
*/
abstract class DataMapping
{
/**
* @param array<string, mixed> $data
* @param non-empty-string $key
*/
public static function uuid(array $data, string $key): UuidV7
{
Assert::keyExists($data, $key);
return UuidV7::fromString($data[$key]);
}
/**
* @template T of \BackedEnum
* @param array<string, mixed> $data
* @param non-empty-string $key
* @param class-string<T> $class
* @phpstan-return T
*/
public static function enum(array $data, string $key, string $class): \BackedEnum
{
Assert::keyExists($data, $key);
return $class::from($data[$key]);
}
/**
* @template T of \BackedEnum
* @param array<string, mixed> $data
* @param non-empty-string $key
* @param class-string<T> $class
* @phpstan-return T
*/
public static function nullableEnum(array $data, string $key, string $class): ?\BackedEnum
{
Assert::keyExists($data, $key);
return $class::tryFrom($data[$key]);
}
/**
* @param array<string, mixed> $data
* @param non-empty-string $key
*/
public static function string(array $data, string $key): string
{
if (! isset($data[$key]) || $data[$key] === '') {
return '';
}
return strval($data[$key]);
}
/**
* @param array<string, mixed> $data
* @param non-empty-string $key
*/
public static function nullableString(array $data, string $key): ?string
{
if (! isset($data[$key]) || $data[$key] === '') {
return null;
}
return strval($data[$key]);
}
/**
* @param array<string, mixed> $data
* @param non-empty-string $key
*/
public static function boolean(array $data, string $key): bool
{
return isset($data[$key]) && (bool) $data[$key];
}
/**
* @param array<string, mixed> $data
* @param non-empty-string $key
*/
public static function nullableBoolean(array $data, string $key): ?bool
{
if (! isset($data[$key])) {
return null;
}
return (bool) $data[$key];
}
/**
* @param array<string, mixed> $data
* @param non-empty-string $key
*/
public static function integer(array $data, string $key): int
{
return isset($data[$key]) ? (int) $data[$key] : 0;
}
/**
* @param array<string, mixed> $data
* @param non-empty-string $key
*/
public static function nullableInteger(array $data, string $key): ?int
{
if (! isset($data[$key])) {
return null;
}
return (int) $data[$key];
}
/**
* @param array<string, mixed> $data
* @param non-empty-string $key
*/
public static function float(array $data, string $key): float
{
return isset($data[$key]) ? (float) $data[$key] : 0.0;
}
/**
* @param array<string, mixed> $data
* @param non-empty-string $key
*/
public static function nullableFloat(array $data, string $key): ?float
{
if (! isset($data[$key])) {
return null;
}
return (float) $data[$key];
}
/**
* @param array<string, mixed> $data
* @param non-empty-string $key
*/
public static function datetime(array $data, string $key, string $format = 'Y-m-d H:i:s'): \DateTimeImmutable
{
Assert::keyExists($data, $key);
$datetime = \DateTimeImmutable::createFromFormat($format, $data[$key]);
if ($datetime === false) {
throw new \InvalidArgumentException('Invalid datetime format');
}
return $datetime;
}
/**
* @param array<string, mixed> $data
* @param non-empty-string $key
*/
public static function nullableDatetime(array $data, string $key, string $format = 'Y-m-d H:i:s'): ?\DateTimeImmutable
{
if (! isset($data[$key])) {
return null;
}
$datetime = \DateTimeImmutable::createFromFormat($format, $data[$key]);
if ($datetime === false) {
throw new \InvalidArgumentException('Invalid datetime format');
}
return $datetime;
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\DataTransfert;
/**
* Class TransfertSetting.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class TransfertSetting
{
public function __construct(
public ?string $filename = null,
public ?string $type = null,
public string $format = 'csv'
) {
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\EventDispatcher;
/**
* Interface EventDispatcher.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface EventDispatcher
{
/**
* @param array<int, object> $events
*/
public function dispatch(array $events): void;
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\EventDispatcher;
/**
* Trait EventEmitterTrait.
*
* @author bernard-ng <bernard@devscast.tech>
*/
trait EventEmitterTrait
{
/**
* @var array<int, object>
*/
private array $emittedEvents = [];
public function emitEvent(object $event): void
{
$this->emittedEvents[] = $event;
}
/**
* @return array<int, object>
*/
public function releaseEvents(): array
{
return $this->emittedEvents;
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\EventListener;
/**
* Interface EventListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface EventListener
{
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Exception;
/**
* Class InvalidArgument.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class InvalidArgument extends \RuntimeException implements UserFacingError
{
#[\Override]
public function translationId(): string
{
return 'shared_kernel.exceptions.invalid_argument';
}
#[\Override]
public function translationParameters(): array
{
return [];
}
#[\Override]
public function translationDomain(): string
{
return 'messages';
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Exception;
/**
* Class InvalidEmailAddress.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class InvalidEmailAddress extends \InvalidArgumentException implements UserFacingError
{
public function __construct(
private readonly string $email
) {
parent::__construct(sprintf('%s : Invalid email address provided', $this->email));
}
public static function withValue(string $value): self
{
return new self($value);
}
#[\Override]
public function translationId(): string
{
return 'shared_kernel.exceptions.invalid_email_address';
}
#[\Override]
public function translationParameters(): array
{
return [
'%email%' => $this->email,
];
}
#[\Override]
public function translationDomain(): string
{
return 'messages';
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Exception;
/**
* Interface UserFacingError.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface UserFacingError extends \Throwable
{
public function translationId(): string;
public function translationParameters(): array;
public function translationDomain(): string;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Model\Collection;
use Doctrine\Common\Collections\Collection as DoctrineCollection;
/**
* Interface Collection.
*
* @phpstan-template TKey of array-key
* @phpstan-template T
* @phpstan-extends DoctrineCollection<TKey, T>
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface Collection extends DoctrineCollection
{
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Model\Collection;
use Doctrine\Common\Collections\ArrayCollection as DoctrineArrayCollection;
/**
* Class DataCollection.
*
* @phpstan-template TKey of array-key
* @phpstan-template T
* @phpstan-extends DoctrineArrayCollection<int, T>
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class DataCollection extends DoctrineArrayCollection
{
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Model\Pagination;
use App\SharedKernel\Domain\Assert;
/**
* Class Page.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class Page
{
public const int DEFAULT_PAGE = 1;
public const int DEFAULT_LIMIT = 5;
public const int MAX_LIMIT = 100;
public function __construct(
public int $page = self::DEFAULT_PAGE,
public int $limit = self::DEFAULT_LIMIT,
public ?string $cursor = null,
public int $offset = 0,
) {
Assert::greaterThanEq($this->page, self::DEFAULT_PAGE);
Assert::greaterThanEq($this->limit, self::DEFAULT_LIMIT);
Assert::lessThanEq($this->limit, self::MAX_LIMIT);
$this->offset = intval(($this->page - 1) * $this->limit);
}
public function next(): self
{
return new self($this->page + 1, $this->limit);
}
public function previous(): self
{
if ($this->page === self::DEFAULT_PAGE) {
return $this;
}
return new self($this->page - 1, $this->limit);
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Model\Pagination;
use App\SharedKernel\Domain\DataTransfert\DataMapping;
use Symfony\Component\Uid\UuidV7;
/**
* Class PaginationCursor.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PaginationCursor
{
public function __construct(
public UuidV7 $id,
public \DateTimeImmutable $date,
) {
}
/**
* Creates a new PaginationCursor from a DateTimeImmutable and a UuidV7.
* @throws \JsonException When JSON encoding fails
*/
public static function encode(array $item, PaginatorKeyset $keyset): string
{
$id = DataMapping::uuid($item, $keyset->id)->toString();
if ($keyset->date !== null) {
$date = DataMapping::dateTime($item, $keyset->date)->format('Y-m-d H:i:s');
return base64_encode(
json_encode([
'date' => $date,
'id' => $id,
], JSON_THROW_ON_ERROR)
);
}
return base64_encode(
json_encode([
'id' => $id,
], JSON_THROW_ON_ERROR)
);
}
/**
* Decodes a cursor string into a PaginationCursor object.
* Returns null if the cursor is invalid or cannot be decoded.
*/
public static function decode(?string $cursor): ?self
{
if ($cursor === null) {
return null;
}
try {
$data = json_decode(base64_decode($cursor), true, 512, JSON_THROW_ON_ERROR);
if (! is_array($data) || ! isset($data['date'], $data['id'])) {
throw new \InvalidArgumentException('Invalid cursor format');
}
return new self(
id: UuidV7::fromString($data['id']),
date: new \DateTimeImmutable($data['date'])
);
} catch (\Throwable) {
return null;
}
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Model\Pagination;
/**
* Class PaginationInfo.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class PaginationInfo
{
public function __construct(
public readonly int $current,
public readonly int $limit,
public ?string $cursor = null,
public bool $hasNext = false,
) {
}
public static function from(Page $page): self
{
return new self($page->page, $page->limit);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Model\Pagination;
/**
* Class PaginatorKeyset.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class PaginatorKeyset
{
/**
* @param non-empty-string $id
* @param non-empty-string|null $date
*/
public function __construct(
public string $id,
public ?string $date = null,
) {
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Model\ValueObject;
use App\SharedKernel\Domain\Assert;
use DateTime;
/**
* Class DateRange.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class DateRange implements \Stringable
{
private function __construct(
public int $start,
public int $end
) {
Assert::notEq($this->start, 0);
Assert::notEq($this->end, 0);
Assert::greaterThanEq($end, $start);
}
#[\Override]
public function __toString(): string
{
return sprintf('%d:%d', $this->start, $this->end);
}
public static function from(string $interval, string $format = 'Y-m-d', string $separator = ':'): self
{
if ($separator === '') {
throw new \InvalidArgumentException('Separator cannot be empty');
}
[$startDate, $endDate] = explode($separator, $interval);
/** @var DateTime $start */
$start = DateTime::createFromFormat($format, $startDate);
/** @var DateTime $end */
$end = DateTime::createFromFormat($format, $endDate);
return new self((int) $start->format('U'), (int) $end->format('U'));
}
public static function backward(\DateTimeImmutable $date = new \DateTimeImmutable(), ?int $days = null): self
{
$days ??= 30;
$start = $date->modify(sprintf('-%d days', $days));
$end = $date->modify('+1 days');
return new self((int) $start->format('U'), (int) $end->format('U'));
}
public static function forward(\DateTimeImmutable $date): self
{
$start = $date;
$end = new \DateTimeImmutable('now')->modify('+1 days');
return new self((int) $start->format('U'), (int) $end->format('U'));
}
public function format(string $format = 'Y-m-d'): string
{
return sprintf('%s:%s', date($format, $this->start), date($format, $this->end));
}
public function inRange(int $timestamp): bool
{
return $timestamp >= $this->start && $timestamp <= $this->end;
}
public function outRange(int $timestamp): bool
{
return $timestamp < $this->start || $timestamp > $this->end;
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Model\ValueObject;
use App\SharedKernel\Domain\Assert;
use App\SharedKernel\Domain\Exception\InvalidEmailAddress;
/**
* Class EmailAddress.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class EmailAddress implements \Stringable, \JsonSerializable
{
public string $value;
public function __construct(string $value)
{
try {
Assert::notEmpty($value);
Assert::email($value);
} catch (\Throwable) {
throw InvalidEmailAddress::withValue($value);
}
$this->value = $value;
}
#[\Override]
public function __toString(): string
{
return $this->value;
}
/**
* @throws InvalidEmailAddress
*/
public static function from(string $value): self
{
return new self($value);
}
public function provider(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function jsonSerialize(): string
{
return $this->value;
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Model\ValueObject;
/**
* Enum SortDirection.
*
* @author bernard-ng <bernard@devscast.tech>
*/
enum SortDirection: string
{
case ASC = 'asc';
case DESC = 'desc';
public function opposite(): self
{
return match ($this) {
self::ASC => self::DESC,
self::DESC => self::ASC,
};
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Model\ValueObject\Tracking;
/**
* Class ClientProfile.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ClientProfile
{
public function __construct(
#[\SensitiveParameter] public ?string $userIp = null,
public ?string $userAgent = null,
public array $hints = []
) {
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Model\ValueObject\Tracking;
/**
* Class Device.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class Device
{
public function __construct(
public ?string $operatingSystem = null,
public ?string $client = null,
public ?string $device = null,
public bool $isBot = false,
) {
}
public static function empty(): self
{
return new self();
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Model\ValueObject\Tracking;
use App\SharedKernel\Domain\Assert;
/**
* Class GeoLocation.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GeoLocation
{
public function __construct(
public ?string $country = null,
public ?string $city = null,
public ?string $timeZone = null,
public ?float $longitude = null,
public ?float $latitude = null,
public ?int $accuracyRadius = null,
) {
}
public static function from(array $data): self
{
Assert::keyExists($data, 'country');
Assert::keyExists($data, 'city');
Assert::keyExists($data, 'time_zone');
Assert::keyExists($data, 'longitude');
Assert::keyExists($data, 'latitude');
Assert::keyExists($data, 'accuracy_radius');
return new self(
country: $data['country'] ?? null,
city: $data['city'] ?? null,
timeZone: $data['time_zone'] ?? null,
longitude: $data['longitude'] ?? null,
latitude: $data['latitude'] ?? null,
accuracyRadius: $data['accuracy_radius'] ?? null,
);
}
public static function empty(): self
{
return new self();
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Domain\Tracking;
use App\SharedKernel\Domain\Model\ValueObject\Tracking\ClientProfile;
use App\SharedKernel\Domain\Model\ValueObject\Tracking\Device;
use App\SharedKernel\Domain\Model\ValueObject\Tracking\GeoLocation;
/**
* Class ClientProfiler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface ClientProfiler
{
public function detect(ClientProfile $profile): Device;
public function locate(ClientProfile $profile): GeoLocation;
public function locateCountry(ClientProfile $profile): ?string;
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Framework\Symfony\EventDispatcher;
use App\SharedKernel\Domain\EventDispatcher\EventDispatcher;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Class SymfonyEventDispatcher.
*
* @see https://symfony.com/doc/current/components/event_dispatcher.html
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class SymfonyEventDispatcher implements EventDispatcher
{
public function __construct(
private EventDispatcherInterface $eventDispatcher
) {
}
/**
* @param array<int, object> $events
*/
#[\Override]
public function dispatch(array $events): void
{
foreach ($events as $event) {
$this->eventDispatcher->dispatch($event, $event::class);
}
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Framework\Symfony;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
/**
* Class Kernel.
*
* @author bernard-ng <bernard@devscast.tech>
*/
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Framework\Symfony\Logging;
use Monolog\Formatter\FormatterInterface;
use Monolog\Formatter\NormalizerFormatter as MonologNormalizerFormatter;
use Monolog\Utils;
/**
* Class NormalizerFormatter.
*
* @author bernard-ng <bernard@devscast.tech>
*/
class NormalizerFormatter extends MonologNormalizerFormatter implements FormatterInterface
{
public const string SIMPLE_DATE = 'Y-m-d\TH:i:sP';
protected string $dateFormat;
protected int $maxNormalizeDepth = 9;
protected int $maxNormalizeItemCount = 1000;
protected string $basePath = '';
private int $jsonEncodeOptions = JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_PRETTY_PRINT;
/**
* Setting a base path will hide the base path from exception and stack trace file names to shorten them
*
* @return $this
*/
#[\Override]
public function setBasePath(string $path = ''): self
{
if ($path !== '') {
$path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
}
$this->basePath = $path;
return $this;
}
/**
* Return the JSON representation of a value
*
* @param mixed $data
* @return string if encoding fails and ignoreErrors is true 'null' is returned
* @throws \RuntimeException if encoding fails and errors are not ignored
*/
#[\Override]
protected function toJson($data, bool $ignoreErrors = false): string
{
$json = Utils::jsonEncode($data, $this->jsonEncodeOptions, $ignoreErrors);
return <<< JSON
```json
{$json}
```
JSON;
}
}
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Framework\Symfony\Logging;
use Monolog\LogRecord;
/**
* Class TelegramFormatter.
*
* @author bernard-ng <bernard@devscast.tech>
*/
class TelegramFormatter extends NormalizerFormatter
{
public const int BATCH_MODE_JSON = 1;
public const int BATCH_MODE_NEWLINES = 2;
/**
* @param self::BATCH_MODE_* $batchMode
*
* @throws \RuntimeException If the function json_encode does not exist
*/
public function __construct(
protected int $batchMode = self::BATCH_MODE_JSON,
protected bool $appendNewline = true,
protected bool $ignoreEmptyContextAndExtra = false,
protected bool $includeStacktraces = false,
string $basePath = ''
) {
$this->basePath = $basePath;
parent::__construct();
}
/**
* The batch mode option configures the formatting style for
* multiple records. By default, multiple records will be
* formatted as a JSON-encoded array. However, for
* compatibility with some API endpoints, alternative styles
* are available.
*/
public function getBatchMode(): int
{
return $this->batchMode;
}
/**
* True if newlines are appended to every formatted record
*/
public function isAppendingNewlines(): bool
{
return $this->appendNewline;
}
#[\Override]
public function format(LogRecord $record): string
{
/** @var array<string, mixed> $normalized */
$normalized = parent::format($record);
if (isset($normalized['context']) && $normalized['context'] === []) {
unset($normalized['context']);
}
if (isset($normalized['extra']) && $normalized['extra'] === []) {
unset($normalized['extra']);
}
return $this->toJson($normalized, true) . "\n\n";
}
#[\Override]
public function formatBatch(array $records): string
{
return match ($this->batchMode) {
static::BATCH_MODE_NEWLINES => $this->formatBatchNewlines($records),
default => $this->formatBatchJson($records),
};
}
/**
* @return $this
*/
public function includeStacktraces(bool $include = true): self
{
$this->includeStacktraces = $include;
return $this;
}
/**
* Return a JSON-encoded array of records.
*
* @phpstan-param LogRecord[] $records
*/
protected function formatBatchJson(array $records): string
{
return $this->toJson($this->normalize($records), true);
}
/**
* Use new lines to separate records instead of a
* JSON-encoded array.
*
* @phpstan-param LogRecord[] $records
*/
protected function formatBatchNewlines(array $records): string
{
$oldNewline = $this->appendNewline;
$this->appendNewline = false;
$formatted = array_map(fn (LogRecord $record): string => $this->format($record), $records);
$this->appendNewline = $oldNewline;
return implode("\n\n", $formatted);
}
/**
* Normalizes given $data.
*
* @return array<array|bool|float|int|object|string|null>|bool|float|int|object|string|null
*/
#[\Override]
protected function normalize(mixed $data, int $depth = 0): array|bool|float|int|object|string|null
{
if ($depth > $this->maxNormalizeDepth) {
return 'Over ' . $this->maxNormalizeDepth . ' levels deep, aborting normalization';
}
if (\is_array($data)) {
$normalized = [];
$count = 1;
foreach ($data as $key => $value) {
if ($count++ > $this->maxNormalizeItemCount) {
$normalized['...'] = 'Over ' . $this->maxNormalizeItemCount . ' items (' . \count($data) . ' total), aborting normalization';
break;
}
$normalized[$key] = $this->normalize($value, $depth + 1);
}
return $normalized;
}
if (\is_object($data)) {
if ($data instanceof \DateTimeInterface) {
return $this->formatDate($data);
}
if ($data instanceof \Throwable) {
/** @var array|float|object|bool|int|string|null $throwable */
$throwable = $this->normalizeException($data, $depth);
return $throwable;
}
// if the object has specific json serializability we want to make sure we skip the __toString treatment below
if ($data instanceof \JsonSerializable) {
return $data;
}
if ($data instanceof \Stringable) {
return $data->__toString();
}
if ($data::class === '__PHP_Incomplete_Class') {
return new \ArrayObject($data);
}
return $data;
}
if (\is_resource($data)) {
return parent::normalize($data);
}
/** @var array|float|object|bool|int|string|null $data */
return $data;
}
/**
* Normalizes given exception with or without its own stack trace based on
* `includeStacktraces` property.
*
* @inheritDoc
*/
#[\Override]
protected function normalizeException(\Throwable $e, int $depth = 0): array|float|object|bool|int|string|null
{
$data = parent::normalizeException($e, $depth);
if (! $this->includeStacktraces) {
unset($data['trace']);
}
return $data;
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Framework\Symfony\Mailing;
use App\SharedKernel\Application\Mailing\EmailDefinition;
use App\SharedKernel\Application\Mailing\Mailer;
use App\SharedKernel\Domain\Application;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Class SymfonyMailer.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class SymfonyMailer implements Mailer
{
public function __construct(
private MailerInterface $mailer,
private TranslatorInterface $translator,
private Application $application = new Application()
) {
}
/**
* @throws TransportExceptionInterface
*/
#[\Override]
public function send(EmailDefinition $email): void
{
$sender = new Address(
$this->application->emailAddress,
$this->application->emailName
);
$htmlTemplate = sprintf('emails/%s.html.twig', $email->template());
$txtTemplate = sprintf('emails/%s.txt.twig', $email->template());
$message = new TemplatedEmail()
->from($sender)
->to($email->recipient()->value)
->subject(
$this->translator->trans(
$email->subject(),
$email->subjectVariables(),
$email->getDomain(),
$email->locale()
)
)
->htmlTemplate($htmlTemplate)
->textTemplate($txtTemplate)
->context(array_merge(
$email->templateVariables(),
[
'application' => new Application(),
'locale' => $email->locale(),
'domain' => $email->getDomain(),
]
))
;
$this->mailer->send($message);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Framework\Symfony\Messaging;
use App\SharedKernel\Application\Messaging\CommandBus;
use Symfony\Component\Messenger\Exception\HandlerFailedException;
use Symfony\Component\Messenger\HandleTrait;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Class MessengerCommandBus.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class MessengerCommandBus implements CommandBus
{
use HandleTrait {
HandleTrait::handle as messengerHandle;
}
public function __construct(MessageBusInterface $commandBus)
{
$this->messageBus = $commandBus;
}
/**
* @throws \Throwable
*/
#[\Override]
public function handle(object $command): mixed
{
try {
return $this->messengerHandle($command);
} catch (HandlerFailedException $e) {
while ($e instanceof HandlerFailedException) {
/** @var \Throwable $e */
$e = $e->getPrevious();
}
throw $e;
}
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Framework\Symfony\Messaging;
use App\SharedKernel\Application\Messaging\AsyncMessage;
use App\SharedKernel\Application\Messaging\MessageBus;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Class MessengerMessageBus.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class MessengerMessageBus implements MessageBus
{
public function __construct(
private MessageBusInterface $messageBus
) {
}
/**
* @throws ExceptionInterface
*/
#[\Override]
public function dispatch(AsyncMessage $message): void
{
$this->messageBus->dispatch($message);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Framework\Symfony\Messaging;
use App\SharedKernel\Application\Messaging\QueryBus;
use Symfony\Component\Messenger\Exception\HandlerFailedException;
use Symfony\Component\Messenger\HandleTrait;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Class MessengerQueryBus.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class MessengerQueryBus implements QueryBus
{
use HandleTrait {
HandleTrait::handle as messengerHandle;
}
public function __construct(MessageBusInterface $queryBus)
{
$this->messageBus = $queryBus;
}
/**
* @throws \Throwable
*/
#[\Override]
public function handle(object $message): mixed
{
try {
return $this->messengerHandle($message);
} catch (HandlerFailedException $e) {
while ($e instanceof HandlerFailedException) {
/** @var \Throwable $e */
$e = $e->getPrevious();
}
throw $e;
}
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\Features;
use App\SharedKernel\Domain\Model\Pagination\Page;
use App\SharedKernel\Domain\Model\Pagination\PaginationCursor;
use App\SharedKernel\Domain\Model\Pagination\PaginationInfo;
use App\SharedKernel\Domain\Model\Pagination\PaginatorKeyset;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Query\QueryBuilder;
/**
* Provides methods for generating and applying pagination to datasets and query builders.
*
* @author bernard-ng <bernard@devscast.tech>
*/
trait PaginationQuery
{
public function createPaginationInfo(array $data, Page $page, PaginatorKeyset $keyset): PaginationInfo
{
$paginationInfo = PaginationInfo::from($page);
if ($data === []) {
return $paginationInfo;
}
$paginationInfo->cursor = PaginationCursor::encode(array_pop($data), $keyset);
$paginationInfo->hasNext = count($data) > $page->limit;
return $paginationInfo;
}
public function applyCursorPagination(QueryBuilder $qb, Page $page, PaginatorKeyset $keyset): QueryBuilder
{
$cursor = PaginationCursor::decode($page->cursor);
if (! $cursor instanceof PaginationCursor) {
return $this->applyOffsetPagination($qb, $page);
}
if ($keyset->date === null) {
$qb
->andWhere(sprintf('%s <= :cursorLastId', $keyset->id))
->setParameter('cursorLastId', $cursor->id->toString(), ParameterType::BINARY);
} else {
$qb
->andWhere(sprintf('(%s, %s) <= (:cursorLastDate, :cursorLastId)', $keyset->date, $keyset->id))
->setParameter('cursorLastDate', $cursor->id->toBinary(), ParameterType::BINARY)
->setParameter('cursorLastId', $cursor->date->format('Y-m-d H:i:s'));
}
return $qb->setMaxResults($page->limit + 1);
}
public function applyOffsetPagination(QueryBuilder $qb, Page $page): QueryBuilder
{
return $qb
->setFirstResult($page->offset)
->setMaxResults($page->limit)
;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL;
/**
* Class NoResult.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class NoResult extends \RuntimeException
{
public static function forQuery(string $query, array $parameters, ?\Throwable $previous = null): self
{
return new self(
sprintf('%s - Query "%s" (parameters: %s) produced no results', $previous?->getMessage(), $query, json_encode($parameters)),
previous: $previous
);
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\Types;
use App\SharedKernel\Domain\Model\ValueObject\EmailAddress;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;
/**
* Class EmailType.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class EmailType extends Type
{
public const string NAME = 'email';
#[\Override]
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return $platform->getStringTypeDeclarationSQL([
'length' => 255,
]);
}
#[\Override]
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?EmailAddress
{
if ($value === null) {
return null;
}
if (! \is_string($value)) {
throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', EmailAddress::class]);
}
try {
return EmailAddress::from($value);
} catch (\Throwable $e) {
throw ConversionException::conversionFailed($value, $this->getName(), $e);
}
}
#[\Override]
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if ($value instanceof EmailAddress) {
return (string) $value;
}
if ($value === null || $value === '') {
return null;
}
if (! \is_string($value)) {
throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', EmailAddress::class]);
}
try {
return (string) EmailAddress::from($value);
} catch (\Throwable $e) {
throw ConversionException::conversionFailed($value, $this->getName(), $e);
}
}
#[\Override]
public function getName(): string
{
return self::NAME;
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Persistence\Filesystem\Asset;
use App\SharedKernel\Application\Asset\AssetType;
use App\SharedKernel\Application\Asset\AssetUrlProvider as AssetUrlProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Class AssetUrlProvider.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class AssetUrlProvider implements AssetUrlProviderInterface
{
public function __construct(
#[Autowire(env: 'SERVER_ADDR')] public ?string $serverAddress = null,
#[Autowire(env: 'SERVER_PORT')] public ?string $serverPort = null,
#[Autowire(env: 'APP_ENV')] public string $env = 'dev'
) {
$this->serverAddress ??= $_SERVER['SERVER_ADDR'] ?? null;
$this->serverPort ??= $_SERVER['SERVER_PORT'] ?? null;
}
public function getUrl(string $id, AssetType $type): ?string
{
if ($this->serverAddress === null) {
return null;
}
$path = match ($type) {
AssetType::SOURCE_PROFILE_IMAGE => sprintf('/images/sources/%s.png', $id),
};
$scheme = $this->env === 'prod' ? 'https' : 'http';
return sprintf('%s://%s:%s/%s', $scheme, $this->serverAddress, $this->serverPort, $path);
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Persistence\Filesystem;
use App\SharedKernel\Domain\Assert;
use App\SharedKernel\Domain\DataTransfert\DataExporter;
use App\SharedKernel\Domain\DataTransfert\DataImporter;
use App\SharedKernel\Domain\DataTransfert\TransfertSetting;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Class DataTransfert.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class DataTransfert implements DataImporter, DataExporter
{
private const array SUPPORTED_FORMATS = ['csv'];
public function __construct(
private SerializerInterface $serializer,
private Filesystem $filesystem,
) {
}
#[\Override]
public function export(iterable $data, TransfertSetting $setting = new TransfertSetting()): \SplFileObject
{
Assert::oneOf($setting->format, self::SUPPORTED_FORMATS);
$data = $this->serializer->serialize($data, $setting->format);
$filename = $setting->filename ?? $this->filesystem->tempnam(sys_get_temp_dir(), 'export');
$this->filesystem->dumpFile($filename, $data);
return new \SplFileObject($filename);
}
#[\Override]
public function import(\SplFileObject $file, TransfertSetting $setting = new TransfertSetting()): iterable
{
Assert::notNull($setting->type);
Assert::oneOf($setting->format, self::SUPPORTED_FORMATS);
$data = $this->filesystem->readFile($file->getPathname());
return $this->serializer->deserialize($data, $setting->type, $setting->format);
}
}
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Tracking;
use App\SharedKernel\Domain\Assert;
use App\SharedKernel\Domain\Model\ValueObject\Tracking\ClientProfile;
use App\SharedKernel\Domain\Model\ValueObject\Tracking\Device;
use App\SharedKernel\Domain\Model\ValueObject\Tracking\GeoLocation;
use App\SharedKernel\Domain\Tracking\ClientProfiler as ClientProfilerInterface;
use DeviceDetector\ClientHints;
use DeviceDetector\DeviceDetector;
use DeviceDetector\Parser\Client\Browser;
use DeviceDetector\Parser\OperatingSystem;
use GeoIp2\Database\Reader;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* Class ClientProfiler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ClientProfiler implements ClientProfilerInterface
{
private const string GEOIP_CITY_DATABASE = 'geoip_city.mmdb';
private const string GEOIP_COUNTRY_DATABASE = 'geoip_country.mmdb';
public function __construct(
private string $projectDir,
private LoggerInterface $logger
) {
}
#[\Override]
public function locate(ClientProfile $profile): GeoLocation
{
if ($this->shouldSkipIpLocalization($profile)) {
return GeoLocation::empty();
}
try {
$database = sprintf('%s/%s', $this->projectDir, self::GEOIP_CITY_DATABASE);
Assert::notNull($profile->userIp);
$data = new Reader($database)->city($profile->userIp);
return GeoLocation::from([
'country' => $data->country->isoCode,
'city' => $data->city->name,
'time_zone' => $data->location->timeZone,
'longitude' => $data->location->longitude,
'latitude' => $data->location->latitude,
'accuracy_radius' => $data->location->accuracyRadius,
]);
} catch (\Throwable $e) {
$this->logger->error('Unable to fetch location from IP address', [
'ip' => $profile->userIp,
'exception' => $e,
]);
return GeoLocation::empty();
}
}
#[\Override]
public function locateCountry(ClientProfile $profile): ?string
{
if ($this->shouldSkipIpLocalization($profile)) {
return null;
}
try {
$database = sprintf('%s/%s', $this->projectDir, self::GEOIP_COUNTRY_DATABASE);
Assert::notNull($profile->userIp);
$data = new Reader($database)->country($profile->userIp);
/** @var string|null $country */
$country = $data->country->isoCode;
return $country;
} catch (\Throwable $e) {
$this->logger->error('Unable to fetch country from IP address', [
'ip' => $profile->userIp,
'exception' => $e,
]);
return null;
}
}
#[\Override]
public function detect(ClientProfile $profile): Device
{
if ($profile->userAgent === null || $profile->hints === []) {
return Device::empty();
}
try {
$detector = new DeviceDetector($profile->userAgent, ClientHints::factory($profile->hints));
$detector->parse();
$osLabel = is_string($detector->getOs('name')) ? $detector->getOs('name') : '';
$clientLabel = is_string($detector->getClient('name')) ? $detector->getClient('name') : '';
return new Device(
operatingSystem: OperatingSystem::getOsFamily($osLabel),
client: match (true) {
$detector->isBrowser() => Browser::getBrowserFamily($clientLabel),
default => $clientLabel
},
device: $detector->getDeviceName(),
isBot: $detector->isBot(),
);
} catch (\Throwable $e) {
$this->logger->error('Unable to detect device', [
'user_agent' => $profile->userAgent,
'exception' => $e,
]);
return Device::empty();
}
}
private function shouldSkipIpLocalization(ClientProfile $profile): bool
{
return \filter_var($profile->userIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)
|| $profile->userIp === null
|| IpUtils::isPrivateIp($profile->userIp);
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Presentation\Console;
use Symfony\Component\Console\Input\InputInterface;
/**
* Trait AskArgumentFeature.
*
* @author bernard-ng <bernard@devscast.tech>
*/
trait AskArgumentFeature
{
private function askArgument(InputInterface $input, string $name, bool $hidden = false): void
{
$value = \strval($input->getArgument($name));
if ($value !== '') {
$this->io->text(\sprintf(' > <info>%s</info>: %s', $name, $value));
} else {
$value = match ($hidden) {
false => $this->io->ask(\strtoupper($name)),
default => $this->io->askHidden(\strtoupper($name))
};
$input->setArgument($name, $value);
}
}
private function askOption(InputInterface $input, string $name): void
{
$value = \strval($input->getOption($name));
if ($value !== '') {
$this->io->text(\sprintf(' > <info>%s</info>: %s', $name, $value));
} else {
$value = $this->io->ask(\strtoupper($name));
$input->setOption($name, $value);
}
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Presentation\Web\Controller;
use App\IdentityAndAccess\Infrastructure\Framework\Symfony\Security\SecurityUser;
use App\SharedKernel\Application\Messaging\CommandBus;
use App\SharedKernel\Application\Messaging\QueryBus;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as SymfonyController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Class AbstractController.
*
* @author bernard-ng <bernard@devscast.tech>
*/
abstract class AbstractController extends SymfonyController
{
protected ?Response $response = null;
#[\Override]
public static function getSubscribedServices(): array
{
$subscribedServices = parent::getSubscribedServices();
$subscribedServices[] = CommandBus::class;
$subscribedServices[] = QueryBus::class;
$subscribedServices[] = TranslatorInterface::class;
$subscribedServices[] = LoggerInterface::class;
$subscribedServices[] = SerializerInterface::class;
return $subscribedServices;
}
public function getSecurityUser(): SecurityUser
{
/** @var SecurityUser|null $user */
$user = $this->getUser();
if ($user === null) {
throw $this->createAccessDeniedException(
'You must be authenticated to access this resource.'
);
}
return $user;
}
public function serialize(mixed $data, string $format = 'json', array $context = []): string
{
/** @var SerializerInterface $serializer */
$serializer = $this->container->get(SerializerInterface::class);
return $serializer->serialize($data, $format, $context);
}
#[\Override]
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
return parent::render($view, $parameters, $response ?? $this->response);
}
protected function handleCommand(object $command): mixed
{
/** @var CommandBus $commandBus */
$commandBus = $this->container->get(CommandBus::class);
return $commandBus->handle($command);
}
protected function handleQuery(object $query): mixed
{
/** @var QueryBus $queryBus */
$queryBus = $this->container->get(QueryBus::class);
return $queryBus->handle($query);
}
protected function trans(string $key, array $params = [], string $domain = 'messages'): string
{
/** @var TranslatorInterface $trans */
$trans = $this->container->get(TranslatorInterface::class);
return $trans->trans($key, $params, $domain);
}
protected function setStatus(int $status): void
{
$this->response = new Response(status: $status);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Presentation\Web\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* Class DefaultController.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class DefaultController extends AbstractController
{
#[Route(
path: '',
name: 'default',
methods: ['GET']
)]
public function __invoke(): JsonResponse
{
return $this->json([
'repository' => 'https://github.com/bernard-ng/drc-news-corpus',
'title' => 'DRC News Corpus : Towards a scalable and intelligent system for Congolese News curation',
'description' => 'The DRC News Corpus is a structured and scalable dataset of news articles sourced from major media outlets covering diverse aspects of the Democratic Republic of Congo (DRC). Designed for efficiency, this system enables the automated collection, processing, and organization of news stories spanning politics, economy, society, culture, environment, and international affairs.',
'status' => 200,
]);
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Presentation\Web\EventListener;
use App\Aggregator\Domain\Exception\ArticleNotFound;
use App\Aggregator\Domain\Exception\SourceNotFound;
use App\FeedManagement\Domain\Exception\BookmarkedArticleNotFound;
use App\FeedManagement\Domain\Exception\BookmarkNotFound;
use App\FeedManagement\Domain\Exception\CommentNotFound;
use App\FeedManagement\Domain\Exception\FollowedSourceNotFound;
use App\IdentityAndAccess\Domain\Exception\PermissionNotGranted;
use App\IdentityAndAccess\Domain\Exception\UserNotFound;
use App\SharedKernel\Domain\Exception\InvalidArgument;
use App\SharedKernel\Domain\Exception\UserFacingError;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Class UserFacingErrorListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
#[AsEventListener(KernelEvents::EXCEPTION, priority: -1)]
final readonly class UserFacingErrorListener
{
private const array NOT_FOUND_EXCEPTIONS = [
ArticleNotFound::class,
SourceNotFound::class,
UserNotFound::class,
CommentNotFound::class,
BookmarkNotFound::class,
FollowedSourceNotFound::class,
BookmarkedArticleNotFound::class,
];
private const array BAD_REQUEST_EXCEPTIONS = [
InvalidArgument::class,
];
private const array FORBIDDEN_EXCEPTIONS = [
PermissionNotGranted::class,
];
public function __construct(
private TranslatorInterface $translator
) {
}
public function __invoke(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if ($exception instanceof UserFacingError) {
$message = $this->translator->trans(
$exception->translationId(),
$exception->translationParameters(),
$exception->translationDomain(),
);
$status = $this->getResponseStatus($exception);
$response = new JsonResponse([
'type' => 'https://symfony.com/errors/domain',
'title' => $exception->translationId(),
'detail' => $message,
'status' => $status,
], $status);
$event->setResponse($response);
}
}
public function getResponseStatus(UserFacingError $exception): int
{
return match (true) {
in_array($exception::class, self::NOT_FOUND_EXCEPTIONS) => Response::HTTP_NOT_FOUND,
in_array($exception::class, self::BAD_REQUEST_EXCEPTIONS) => Response::HTTP_BAD_REQUEST,
in_array($exception::class, self::FORBIDDEN_EXCEPTIONS) => Response::HTTP_FORBIDDEN,
default => Response::HTTP_UNPROCESSABLE_ENTITY
};
}
}