Add Symfony payload mapping, fixtures, and QA tooling

This commit is contained in:
Bernard Ngandu
2025-10-10 08:48:27 +02:00
parent 16a8af3507
commit 49d93ffc63
48 changed files with 7456 additions and 208 deletions
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\SignalPayload;
use App\Entity\Signal;
use App\Repository\SignalRepository;
use App\Service\SignalSnapshotBuilder;
use DateTimeImmutable;
use DateTimeZone;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Annotation\Route;
#[Route(path: '/api/signals')]
class SignalController
{
public function __construct(
private readonly SignalRepository $signals,
private readonly SignalSnapshotBuilder $snapshotBuilder,
) {
}
#[Route(path: '', name: 'api_signals_index', methods: ['GET'])]
public function index(Request $request, #[MapQueryParameter('limit', flags: FILTER_NULL_ON_FAILURE)] ?int $limit = null): JsonResponse
{
$limit = $this->normalizeLimit($limit);
$clientKey = $this->hashIp($this->extractClientIp($request));
$signals = $this->signals->findRecent($limit);
$snapshot = $this->snapshotBuilder->build($signals);
$payload = [
'clientKey' => $clientKey,
'points' => $snapshot['points'],
'density' => $snapshot['density'],
'latestByUser' => $snapshot['latestByUser'],
'totals' => $snapshot['totals'],
'updatedAt' => new DateTimeImmutable('now', new DateTimeZone('UTC'))->format(DATE_ATOM),
];
return new JsonResponse($payload);
}
#[Route(path: '', name: 'api_signals_store', methods: ['POST'])]
public function store(#[MapRequestPayload(validationFailedStatusCode: JsonResponse::HTTP_UNPROCESSABLE_ENTITY)] SignalPayload $payload, Request $request): JsonResponse
{
$lat = $payload->lat;
$lng = $payload->lng;
if (! is_finite($lat) || ! is_finite($lng)) {
return $this->errorResponse('invalid_coordinates', 'Latitude and longitude must be numbers.', JsonResponse::HTTP_UNPROCESSABLE_ENTITY);
}
if ($lat < -90 || $lat > 90 || $lng < -180 || $lng > 180) {
return $this->errorResponse('out_of_bounds', 'Latitude or longitude out of range.', JsonResponse::HTTP_UNPROCESSABLE_ENTITY);
}
$clientIp = $this->extractClientIp($request);
$clientKey = $this->hashIp($clientIp);
$signal = new Signal()
->setUserKey($clientKey)
->setLat($lat)
->setLng($lng)
->setCreatedAt(new DateTimeImmutable('now', new DateTimeZone('UTC')));
$this->signals->save($signal);
return new JsonResponse([
'status' => 'stored',
'clientKey' => $clientKey,
'point' => [
'id' => $signal->getId(),
'lat' => $signal->getLat(),
'lng' => $signal->getLng(),
'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM),
'userKey' => $signal->getUserKey(),
],
], JsonResponse::HTTP_CREATED);
}
private function errorResponse(string $error, string $message, int $statusCode): JsonResponse
{
return new JsonResponse([
'error' => $error,
'message' => $message,
], $statusCode);
}
private function normalizeLimit(?int $limit): int
{
$limit ??= 750;
if ($limit < 1 || $limit > 5000) {
return 750;
}
return $limit;
}
private function extractClientIp(Request $request): string
{
$forwarded = $request->headers->get('X-Forwarded-For');
if ($forwarded !== null && $forwarded !== '') {
$parts = array_filter(array_map('trim', explode(',', $forwarded)));
if ($parts !== []) {
return $parts[0];
}
}
return $request->getClientIp() ?? '0.0.0.0';
}
private function hashIp(string $ip): string
{
return substr(hash('sha256', $ip), 0, 12);
}
}