[backend] stabilise codebase
This commit is contained in:
@@ -40,19 +40,14 @@ class SignalController
|
||||
$clientKey = $this->clientKeyProvider->fromRequest($request);
|
||||
$signal = $this->submissionService->submit($clientKey, $payload);
|
||||
|
||||
$signalLocation = $signal->getSignalLocation();
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => 'stored',
|
||||
'clientKey' => $clientKey,
|
||||
'point' => [
|
||||
'id' => $signal->getId(),
|
||||
'signalLocation' => [
|
||||
'lat' => $signalLocation->getLat(),
|
||||
'lng' => $signalLocation->getLng(),
|
||||
],
|
||||
'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM),
|
||||
'userKey' => $signal->getUserKey(),
|
||||
'id' => $signal->id->toString(),
|
||||
'signalLocation' => $signal->signalLocation,
|
||||
'createdAt' => $signal->createdAt->format(DATE_ATOM),
|
||||
'userKey' => $signal->userKey,
|
||||
],
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
class AppFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// $product = new Product();
|
||||
// $manager->persist($product);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,6 @@ namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\Signal;
|
||||
use App\ValueObject\Point;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
@@ -16,7 +13,6 @@ class SignalFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$baseTime = new DateTimeImmutable('2024-08-01 12:00:00', new DateTimeZone('UTC'));
|
||||
$coordinates = [
|
||||
[
|
||||
'user' => 'user-alpha',
|
||||
@@ -41,11 +37,11 @@ class SignalFixtures extends Fixture
|
||||
foreach ($coordinates as $config) {
|
||||
$signalLocation = Point::fromLatLng($config['lat'], $config['lng']);
|
||||
|
||||
$signal = new Signal()
|
||||
->setUserKey($config['user'])
|
||||
->setUserLocation(Point::fromLatLng($config['lat'], $config['lng']))
|
||||
->setSignalLocation($signalLocation)
|
||||
->setCreatedAt($baseTime->add(new DateInterval(sprintf('PT%dM', $config['offset']))));
|
||||
$signal = Signal::create(
|
||||
$config['user'],
|
||||
Point::fromLatLng($config['lat'], $config['lng']),
|
||||
$signalLocation,
|
||||
);
|
||||
|
||||
$manager->persist($signal);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Identifier;
|
||||
|
||||
use Symfony\Component\Uid\UuidV7;
|
||||
|
||||
final class SignalId extends UuidV7
|
||||
{
|
||||
}
|
||||
@@ -4,85 +4,33 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Identifier\SignalId;
|
||||
use App\Repository\SignalRepository;
|
||||
use App\ValueObject\Point;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SignalRepository::class)]
|
||||
#[ORM\Table(name: 'signals')]
|
||||
#[ORM\Index(columns: ['created_at'], name: 'idx_signals_created_at')]
|
||||
#[ORM\Index(columns: ['user_key'], name: 'idx_signals_user_key')]
|
||||
class Signal
|
||||
#[ORM\Index(name: 'idx_signals_created_at', columns: ['created_at'])]
|
||||
#[ORM\Index(name: 'idx_signals_user_key', columns: ['user_key'])]
|
||||
readonly class Signal
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
private ?int $id = null; // @phpstan-ignore property.unusedType
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 64)]
|
||||
private string $userKey;
|
||||
|
||||
#[ORM\Embedded(class: Point::class, columnPrefix: 'user_')]
|
||||
private Point $userLocation;
|
||||
|
||||
#[ORM\Embedded(class: Point::class, columnPrefix: 'signal_')]
|
||||
private Point $signalLocation;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'created_at')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
private function __construct(
|
||||
#[ORM\Column(type: Types::STRING, length: 64)] public string $userKey,
|
||||
#[ORM\Embedded(class: Point::class, columnPrefix: 'user_')] public Point $userLocation,
|
||||
#[ORM\Embedded(class: Point::class, columnPrefix: 'signal_')] public Point $signalLocation,
|
||||
#[ORM\Id] #[ORM\GeneratedValue(strategy: 'NONE')] #[ORM\Column(type: 'uuid')] public SignalId $id = new SignalId(),
|
||||
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)] public \DateTimeImmutable $createdAt = new \DateTimeImmutable()
|
||||
) {
|
||||
}
|
||||
|
||||
public function getUserKey(): string
|
||||
public static function create(string $userKey, Point $userLocation, Point $signalLocation): self
|
||||
{
|
||||
return $this->userKey;
|
||||
}
|
||||
|
||||
public function setUserKey(string $userKey): self
|
||||
{
|
||||
$this->userKey = $userKey;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserLocation(): Point
|
||||
{
|
||||
return $this->userLocation;
|
||||
}
|
||||
|
||||
public function setUserLocation(Point $userLocation): self
|
||||
{
|
||||
$this->userLocation = $userLocation;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSignalLocation(): Point
|
||||
{
|
||||
return $this->signalLocation;
|
||||
}
|
||||
|
||||
public function setSignalLocation(Point $signalLocation): self
|
||||
{
|
||||
$this->signalLocation = $signalLocation;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(DateTimeImmutable $createdAt): self
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
return new self(
|
||||
userKey: $userKey,
|
||||
userLocation: $userLocation,
|
||||
signalLocation: $signalLocation
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,8 @@ use function sprintf;
|
||||
#[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY, headers: [
|
||||
'x-error-code' => 'invalid_coordinates',
|
||||
])]
|
||||
final class InvalidPointException extends \RuntimeException
|
||||
final class InvalidPointException extends \DomainException
|
||||
{
|
||||
private function __construct(string $message)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
public static function invalidNumber(): self
|
||||
{
|
||||
return new self('Latitude and longitude must be finite numbers.');
|
||||
|
||||
@@ -10,7 +10,7 @@ use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
|
||||
#[WithHttpStatus(Response::HTTP_BAD_REQUEST, headers: [
|
||||
'x-error-code' => 'missing_client_key',
|
||||
])]
|
||||
final class MissingClientKeyException extends \RuntimeException
|
||||
final class MissingClientKeyException extends \DomainException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ use function sprintf;
|
||||
#[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY, headers: [
|
||||
'x-error-code' => 'point_too_far',
|
||||
])]
|
||||
final class PointTooFarException extends \RuntimeException
|
||||
final class PointTooFarException extends \DomainException
|
||||
{
|
||||
public function __construct(float $maximumDistanceKm)
|
||||
{
|
||||
|
||||
@@ -4,21 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
|
||||
use function sprintf;
|
||||
|
||||
#[WithHttpStatus(Response::HTTP_TOO_MANY_REQUESTS, headers: [
|
||||
'x-error-code' => 'submission_rate_limited',
|
||||
])]
|
||||
final class SubmissionRateLimitedException extends \RuntimeException
|
||||
final class SubmissionRateLimitedException extends \DomainException
|
||||
{
|
||||
public function __construct(?DateTimeInterface $retryAfter)
|
||||
public function __construct(?\DateTimeInterface $retryAfter)
|
||||
{
|
||||
$message = 'Too many submissions from your network. Please wait before trying again.';
|
||||
if ($retryAfter !== null) {
|
||||
$message .= sprintf(' You can retry after %s.', $retryAfter->format(DATE_ATOM));
|
||||
if ($retryAfter instanceof \DateTimeInterface) {
|
||||
$message .= \sprintf(' You can retry after %s.', $retryAfter->format(DATE_ATOM));
|
||||
}
|
||||
|
||||
parent::__construct($message);
|
||||
|
||||
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
final class SignalCreatedMessage
|
||||
use App\Entity\Identifier\SignalId;
|
||||
|
||||
final readonly class SignalCreatedMessage
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $signalId
|
||||
public SignalId $signalId
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,30 +7,32 @@ namespace App\MessageHandler;
|
||||
use App\Message\SignalCreatedMessage;
|
||||
use App\Repository\SignalRepository;
|
||||
use App\Service\SignalSnapshotBuilder;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Serializer\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Throwable;
|
||||
use function json_encode;
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
#[AsMessageHandler]
|
||||
final class SignalCreatedMessageHandler
|
||||
final readonly class SignalCreatedMessageHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SignalRepository $signals,
|
||||
private readonly SignalSnapshotBuilder $snapshotBuilder,
|
||||
private readonly HubInterface $hub,
|
||||
#[Autowire('%app.signal_stream_topic%')] private readonly string $topic,
|
||||
#[Autowire('%app.signal_snapshot_limit%')] private readonly int $snapshotLimit,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
private SignalRepository $signals,
|
||||
private SignalSnapshotBuilder $snapshotBuilder,
|
||||
private HubInterface $hub,
|
||||
private SerializerInterface $serializer,
|
||||
private LoggerInterface $logger,
|
||||
#[Autowire('%app.signal_stream_topic%')] private string $topic,
|
||||
#[Autowire('%app.signal_snapshot_limit%')] private int $snapshotLimit,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExceptionInterface
|
||||
*/
|
||||
public function __invoke(SignalCreatedMessage $message): void
|
||||
{
|
||||
$recentSignals = $this->signals->findRecent($this->snapshotLimit);
|
||||
@@ -43,21 +45,17 @@ final class SignalCreatedMessageHandler
|
||||
'density' => $snapshot['density'],
|
||||
'latestByUser' => $snapshot['latestByUser'],
|
||||
'totals' => $snapshot['totals'],
|
||||
'updatedAt' => (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_ATOM),
|
||||
'updatedAt' => new \DateTimeImmutable('now', new \DateTimeZone('UTC'))->format(DATE_ATOM),
|
||||
],
|
||||
];
|
||||
|
||||
$data = json_encode($payload, JSON_THROW_ON_ERROR);
|
||||
|
||||
$update = new Update(
|
||||
topics: $this->topic,
|
||||
data: $data,
|
||||
);
|
||||
$data = $this->serializer->serialize($payload, 'json');
|
||||
$update = new Update(topics: $this->topic, data: $data);
|
||||
|
||||
try {
|
||||
$this->hub->publish($update);
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger?->error('Failed to publish signal update to Mercure.', [
|
||||
$this->logger->error('Failed to publish signal update to Mercure.', [
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ use App\Exception\MissingClientKeyException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
final class ClientKeyProvider
|
||||
final readonly class ClientKeyProvider
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -10,17 +10,17 @@ use App\ValueObject\Point;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final class PointProximityValidator
|
||||
final readonly class PointProximityValidator
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire('%app.max_signal_distance_km%')] private readonly float $maximumDistanceKm,
|
||||
#[Autowire(service: 'monolog.logger.signals')] private readonly LoggerInterface $logger,
|
||||
#[Autowire('%app.max_signal_distance_km%')] private float $maximumDistanceKm,
|
||||
#[Autowire(service: 'monolog.logger.signals')] private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function assertWithinRange(Point $userLocation, Point $signalLocation): void
|
||||
{
|
||||
$distance = Distance::betweenPoints($userLocation, $signalLocation)->inKilometers();
|
||||
$distance = Distance::between($userLocation, $signalLocation)->inKilometers();
|
||||
|
||||
$this->logger->debug('Calculated proximity between user and signal.', [
|
||||
'distance_km' => $distance,
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Signal;
|
||||
use App\ValueObject\Point;
|
||||
|
||||
class SignalSnapshotBuilder
|
||||
{
|
||||
@@ -13,15 +14,15 @@ class SignalSnapshotBuilder
|
||||
*
|
||||
* @return array{
|
||||
* points: list<array{
|
||||
* id: int,
|
||||
* signalLocation: array{lat: float, lng: float},
|
||||
* id: string,
|
||||
* signalLocation: Point,
|
||||
* createdAt: string,
|
||||
* userKey: string,
|
||||
* }>,
|
||||
* density: list<array{lat: float, lng: float, intensity: int}>,
|
||||
* latestByUser: list<array{
|
||||
* id: int,
|
||||
* signalLocation: array{lat: float, lng: float},
|
||||
* id: string,
|
||||
* signalLocation: Point,
|
||||
* createdAt: string,
|
||||
* userKey: string,
|
||||
* }>,
|
||||
@@ -35,22 +36,17 @@ class SignalSnapshotBuilder
|
||||
$latestByUser = [];
|
||||
|
||||
foreach ($signals as $signal) {
|
||||
$signalLocation = $signal->getSignalLocation();
|
||||
|
||||
$point = [
|
||||
'id' => (int) $signal->getId(),
|
||||
'signalLocation' => [
|
||||
'lat' => $signalLocation->getLat(),
|
||||
'lng' => $signalLocation->getLng(),
|
||||
],
|
||||
'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM),
|
||||
'userKey' => $signal->getUserKey(),
|
||||
'id' => $signal->id->toString(),
|
||||
'signalLocation' => $signal->signalLocation,
|
||||
'createdAt' => $signal->createdAt->format(DATE_ATOM),
|
||||
'userKey' => $signal->userKey,
|
||||
];
|
||||
|
||||
$points[] = $point;
|
||||
|
||||
$bucketLat = round($signalLocation->getLat(), 3);
|
||||
$bucketLng = round($signalLocation->getLng(), 3);
|
||||
$bucketLat = round($signal->signalLocation->getLat(), 3);
|
||||
$bucketLng = round($signal->signalLocation->getLng(), 3);
|
||||
$bucketKey = $bucketLat . ':' . $bucketLng;
|
||||
|
||||
if (! isset($densityBuckets[$bucketKey])) {
|
||||
|
||||
@@ -10,13 +10,13 @@ use DateTimeZone;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final class SignalSnapshotService
|
||||
final readonly class SignalSnapshotService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SignalRepository $signals,
|
||||
private readonly SignalSnapshotBuilder $snapshotBuilder,
|
||||
#[Autowire('%app.signal_snapshot_limit%')] private readonly int $snapshotLimit,
|
||||
#[Autowire(service: 'monolog.logger.signals')] private readonly LoggerInterface $logger,
|
||||
private SignalRepository $signals,
|
||||
private SignalSnapshotBuilder $snapshotBuilder,
|
||||
#[Autowire('%app.signal_snapshot_limit%')] private int $snapshotLimit,
|
||||
#[Autowire(service: 'monolog.logger.signals')] private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -9,21 +9,19 @@ use App\Exception\SubmissionRateLimitedException;
|
||||
use App\Message\SignalCreatedMessage;
|
||||
use App\Payload\SignalPayload;
|
||||
use App\Repository\SignalRepository;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
|
||||
final class SignalSubmissionService
|
||||
final readonly class SignalSubmissionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SignalRepository $signals,
|
||||
#[Autowire(service: 'limiter.signal_submission')] private readonly RateLimiterFactory $submissionLimiter,
|
||||
private readonly PointProximityValidator $proximityValidator,
|
||||
private readonly MessageBusInterface $bus,
|
||||
#[Autowire(service: 'monolog.logger.signals')] private readonly LoggerInterface $logger,
|
||||
private SignalRepository $signals,
|
||||
private MessageBusInterface $messageBus,
|
||||
#[Autowire(service: 'limiter.signal_submission')] private RateLimiterFactory $submissionLimiter,
|
||||
private PointProximityValidator $proximityValidator,
|
||||
#[Autowire(service: 'monolog.logger.signals')] private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -47,25 +45,17 @@ final class SignalSubmissionService
|
||||
],
|
||||
]);
|
||||
|
||||
$signal = (new Signal())
|
||||
->setUserKey($clientKey)
|
||||
->setUserLocation($userLocation)
|
||||
->setSignalLocation($signalLocation)
|
||||
->setCreatedAt(new DateTimeImmutable('now', new DateTimeZone('UTC')));
|
||||
|
||||
$signal = Signal::create($clientKey, $userLocation, $signalLocation);
|
||||
$this->signals->save($signal);
|
||||
|
||||
$signalId = $signal->getId();
|
||||
if ($signalId !== null) {
|
||||
$this->logger->debug('Dispatching signal created message.', [
|
||||
'signal_id' => $signalId,
|
||||
]);
|
||||
$this->bus->dispatch(new SignalCreatedMessage($signalId));
|
||||
$this->logger->info('Signal stored successfully.', [
|
||||
'signal_id' => $signalId,
|
||||
'client_key' => $clientKey,
|
||||
]);
|
||||
}
|
||||
$this->logger->debug('Dispatching signal created message.', [
|
||||
'signal_id' => $signal->id->toString(),
|
||||
]);
|
||||
$this->messageBus->dispatch(new SignalCreatedMessage($signal->id));
|
||||
$this->logger->info('Signal stored successfully.', [
|
||||
'signal_id' => $signal->id->toString(),
|
||||
'client_key' => $clientKey,
|
||||
]);
|
||||
|
||||
return $signal;
|
||||
}
|
||||
|
||||
@@ -4,16 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\ValueObject;
|
||||
|
||||
final class Distance
|
||||
final readonly class Distance
|
||||
{
|
||||
private const EARTH_RADIUS_KM = 6371;
|
||||
private const int EARTH_RADIUS_KM = 6371;
|
||||
|
||||
private function __construct(
|
||||
private readonly float $kilometers,
|
||||
private float $kilometers,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function betweenPoints(Point $from, Point $to): self
|
||||
public static function between(Point $from, Point $to): self
|
||||
{
|
||||
$lat1 = deg2rad($from->getLat());
|
||||
$lat2 = deg2rad($to->getLat());
|
||||
|
||||
Reference in New Issue
Block a user