API Log Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add input/output visibility by logging all API traffic and displaying it in the admin UI with per-entity timelines.
Architecture: A kernel event subscriber captures all /api/v1/* request/response pairs and persists them to an ApiLog entity. The admin UI provides both a dedicated log browser and per-entity timeline tabs on product/partner/sale-order detail pages. A console command handles 30-day retention.
Tech Stack: Symfony 8.0, PHP 8.4, Doctrine ORM, Twig templates, PHPUnit
Task 1: ApiLogDirection Enum
Files:
- Create: src/Enum/ApiLogDirection.php
- Test: tests/Unit/Enum/ApiLogDirectionTest.php
Step 1: Write the failing test
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Enum;
use App\Enum\ApiLogDirection;
use PHPUnit\Framework\TestCase;
class ApiLogDirectionTest extends TestCase
{
public function testEnumValues(): void
{
self::assertSame('inbound', ApiLogDirection::INBOUND->value);
self::assertSame('outbound', ApiLogDirection::OUTBOUND->value);
}
public function testFromString(): void
{
self::assertSame(ApiLogDirection::INBOUND, ApiLogDirection::from('inbound'));
self::assertSame(ApiLogDirection::OUTBOUND, ApiLogDirection::from('outbound'));
}
}
Step 2: Run test to verify it fails
Run: bin/phpunit tests/Unit/Enum/ApiLogDirectionTest.php
Expected: FAIL - class not found
Step 3: Write the enum
<?php
declare(strict_types=1);
namespace App\Enum;
enum ApiLogDirection: string
{
case INBOUND = 'inbound';
case OUTBOUND = 'outbound';
}
Step 4: Run test to verify it passes
Run: bin/phpunit tests/Unit/Enum/ApiLogDirectionTest.php
Expected: PASS
Step 5: Commit
git add src/Enum/ApiLogDirection.php tests/Unit/Enum/ApiLogDirectionTest.php
git commit -m "Add ApiLogDirection enum"
Task 2: ApiLog Entity
Files:
- Create: src/Entity/ApiLog.php
- Test: tests/Unit/Entity/ApiLogTest.php
Step 1: Write the failing test
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Entity;
use App\Entity\ApiLog;
use App\Enum\ApiLogDirection;
use PHPUnit\Framework\TestCase;
class ApiLogTest extends TestCase
{
public function testNewApiLogHasCorrectDefaults(): void
{
$log = new ApiLog();
self::assertNull($log->getId());
self::assertNull($log->getMethod());
self::assertNull($log->getPath());
self::assertNull($log->getEntityType());
self::assertNull($log->getExternalId());
self::assertNull($log->getRequestBody());
self::assertNull($log->getResponseBody());
self::assertNull($log->getDuration());
self::assertNull($log->getCreatedAt());
}
public function testSettersReturnStaticForFluency(): void
{
$log = new ApiLog();
$result = $log->setMethod('POST');
self::assertSame($log, $result);
$result = $log->setPath('/api/v1/products');
self::assertSame($log, $result);
}
public function testDirectionGetterReturnsEnum(): void
{
$log = new ApiLog();
$log->setDirection(ApiLogDirection::INBOUND);
self::assertSame(ApiLogDirection::INBOUND, $log->getDirection());
}
public function testSourceGetterSetter(): void
{
$log = new ApiLog();
$log->setSource('odoo');
self::assertSame('odoo', $log->getSource());
}
public function testAllFieldsRoundTrip(): void
{
$log = new ApiLog();
$log->setMethod('POST')
->setPath('/api/v1/products')
->setDirection(ApiLogDirection::INBOUND)
->setSource('odoo')
->setEntityType('product')
->setExternalId('PROD-001')
->setRequestBody('{"external_id":"PROD-001"}')
->setResponseBody('{"status":"ok"}')
->setStatusCode(200)
->setIpAddress('192.168.1.1')
->setDuration(42);
self::assertSame('POST', $log->getMethod());
self::assertSame('/api/v1/products', $log->getPath());
self::assertSame(ApiLogDirection::INBOUND, $log->getDirection());
self::assertSame('odoo', $log->getSource());
self::assertSame('product', $log->getEntityType());
self::assertSame('PROD-001', $log->getExternalId());
self::assertSame('{"external_id":"PROD-001"}', $log->getRequestBody());
self::assertSame('{"status":"ok"}', $log->getResponseBody());
self::assertSame(200, $log->getStatusCode());
self::assertSame('192.168.1.1', $log->getIpAddress());
self::assertSame(42, $log->getDuration());
}
public function testPrePersistSetsCreatedAt(): void
{
$log = new ApiLog();
self::assertNull($log->getCreatedAt());
$log->setCreatedAtValue();
self::assertInstanceOf(\DateTimeImmutable::class, $log->getCreatedAt());
}
}
Step 2: Run test to verify it fails
Run: bin/phpunit tests/Unit/Entity/ApiLogTest.php
Expected: FAIL - class not found
Step 3: Write the entity
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\ApiLogDirection;
use App\Repository\ApiLogRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ApiLogRepository::class)]
#[ORM\Table(name: 'api_log')]
#[ORM\Index(columns: ['entity_type', 'external_id'], name: 'idx_api_log_entity')]
#[ORM\Index(columns: ['created_at'], name: 'idx_api_log_created_at')]
#[ORM\Index(columns: ['direction'], name: 'idx_api_log_direction')]
#[ORM\HasLifecycleCallbacks]
class ApiLog
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 10)]
private ?string $method = null;
#[ORM\Column(length: 500)]
private ?string $path = null;
#[ORM\Column(length: 20)]
private string $direction = ApiLogDirection::INBOUND->value;
#[ORM\Column(length: 20)]
private ?string $source = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $entityType = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $externalId = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $requestBody = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $responseBody = null;
#[ORM\Column(type: Types::SMALLINT)]
private int $statusCode = 0;
#[ORM\Column(length: 45)]
private ?string $ipAddress = null;
#[ORM\Column(nullable: true)]
private ?int $duration = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getMethod(): ?string
{
return $this->method;
}
public function setMethod(string $method): static
{
$this->method = $method;
return $this;
}
public function getPath(): ?string
{
return $this->path;
}
public function setPath(string $path): static
{
$this->path = $path;
return $this;
}
public function getDirection(): ApiLogDirection
{
return ApiLogDirection::from($this->direction);
}
public function setDirection(ApiLogDirection $direction): static
{
$this->direction = $direction->value;
return $this;
}
public function getSource(): ?string
{
return $this->source;
}
public function setSource(string $source): static
{
$this->source = $source;
return $this;
}
public function getEntityType(): ?string
{
return $this->entityType;
}
public function setEntityType(?string $entityType): static
{
$this->entityType = $entityType;
return $this;
}
public function getExternalId(): ?string
{
return $this->externalId;
}
public function setExternalId(?string $externalId): static
{
$this->externalId = $externalId;
return $this;
}
public function getRequestBody(): ?string
{
return $this->requestBody;
}
public function setRequestBody(?string $requestBody): static
{
$this->requestBody = $requestBody;
return $this;
}
public function getResponseBody(): ?string
{
return $this->responseBody;
}
public function setResponseBody(?string $responseBody): static
{
$this->responseBody = $responseBody;
return $this;
}
public function getStatusCode(): int
{
return $this->statusCode;
}
public function setStatusCode(int $statusCode): static
{
$this->statusCode = $statusCode;
return $this;
}
public function getIpAddress(): ?string
{
return $this->ipAddress;
}
public function setIpAddress(string $ipAddress): static
{
$this->ipAddress = $ipAddress;
return $this;
}
public function getDuration(): ?int
{
return $this->duration;
}
public function setDuration(?int $duration): static
{
$this->duration = $duration;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$this->createdAt = new \DateTimeImmutable();
}
}
Step 4: Run test to verify it passes
Run: bin/phpunit tests/Unit/Entity/ApiLogTest.php
Expected: PASS
Step 5: Commit
Task 3: ApiLogRepository
Files:
- Create: src/Repository/ApiLogRepository.php
- Test: tests/Unit/Repository/ApiLogRepositoryTest.php
Step 1: Write the failing test
Note: Unit tests for repositories are limited since most methods need a real database. We test the class structure and any pure methods. Integration tests for query methods will be covered by the controller tests in Task 7.
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Repository;
use App\Entity\ApiLog;
use App\Repository\ApiLogRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\Persistence\ManagerRegistry;
use PHPUnit\Framework\TestCase;
class ApiLogRepositoryTest extends TestCase
{
public function testRepositoryCanBeInstantiated(): void
{
$registry = $this->createMock(ManagerRegistry::class);
$em = $this->createMock(EntityManagerInterface::class);
$metadata = new ClassMetadata(ApiLog::class);
$registry->method('getManagerForClass')->willReturn($em);
$em->method('getClassMetadata')->willReturn($metadata);
$repository = new ApiLogRepository($registry);
self::assertInstanceOf(ApiLogRepository::class, $repository);
}
}
Step 2: Run test to verify it fails
Run: bin/phpunit tests/Unit/Repository/ApiLogRepositoryTest.php
Expected: FAIL - class not found
Step 3: Write the repository
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ApiLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ApiLog>
*/
class ApiLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ApiLog::class);
}
public function save(ApiLog $apiLog, bool $flush = true): void
{
$this->getEntityManager()->persist($apiLog);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* @return ApiLog[]
*/
public function findByEntity(string $entityType, string $externalId, int $limit = 50): array
{
return $this->createQueryBuilder('a')
->where('a.entityType = :entityType')
->andWhere('a.externalId = :externalId')
->setParameter('entityType', $entityType)
->setParameter('externalId', $externalId)
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function deleteOlderThan(\DateTimeImmutable $cutoff): int
{
return $this->createQueryBuilder('a')
->delete()
->where('a.createdAt < :cutoff')
->setParameter('cutoff', $cutoff)
->getQuery()
->execute();
}
}
Step 4: Run test to verify it passes
Run: bin/phpunit tests/Unit/Repository/ApiLogRepositoryTest.php
Expected: PASS
Step 5: Commit
git add src/Repository/ApiLogRepository.php tests/Unit/Repository/ApiLogRepositoryTest.php
git commit -m "Add ApiLogRepository with entity query and purge methods"
Task 4: Database Migration
Files:
- Create: migrations/Version<timestamp>.php (generated by Doctrine)
Step 1: Generate the migration
Run: php -d variables_order=EGPCS bin/console doctrine:migrations:diff
This will generate a migration file creating the api_log table.
Step 2: Review the generated migration
Verify it creates the api_log table with all columns and indexes. The down() method should drop the table.
Step 3: Run the migration
Run: php -d variables_order=EGPCS bin/console doctrine:migrations:migrate
Expected: Migration applied successfully
Step 4: Commit
Task 5: ApiLogSubscriber (Capture Mechanism)
Files:
- Create: src/EventSubscriber/ApiLogSubscriber.php
- Test: tests/Unit/EventSubscriber/ApiLogSubscriberTest.php
Step 1: Write the failing test
<?php
declare(strict_types=1);
namespace App\Tests\Unit\EventSubscriber;
use App\Entity\ApiLog;
use App\Enum\ApiLogDirection;
use App\EventSubscriber\ApiLogSubscriber;
use App\Repository\ApiLogRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
class ApiLogSubscriberTest extends TestCase
{
private ApiLogRepository&MockObject $apiLogRepository;
private ApiLogSubscriber $subscriber;
private HttpKernelInterface&MockObject $kernel;
protected function setUp(): void
{
$this->apiLogRepository = $this->createMock(ApiLogRepository::class);
$this->subscriber = new ApiLogSubscriber($this->apiLogRepository);
$this->kernel = $this->createMock(HttpKernelInterface::class);
}
public function testGetSubscribedEvents(): void
{
$events = ApiLogSubscriber::getSubscribedEvents();
self::assertArrayHasKey(KernelEvents::REQUEST, $events);
self::assertArrayHasKey(KernelEvents::RESPONSE, $events);
}
public function testIgnoresNonApiRoutes(): void
{
$request = Request::create('/admin/products', 'GET');
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->subscriber->onKernelRequest($event);
// No attribute set means it won't be logged
self::assertFalse($request->attributes->has('_api_log_start'));
}
public function testCapturesApiV1Routes(): void
{
$request = Request::create('/api/v1/products', 'POST', [], [], [], [], '{"external_id":"PROD-001"}');
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->subscriber->onKernelRequest($event);
self::assertTrue($request->attributes->has('_api_log_start'));
}
public function testInboundPostProductDetectsDirectionAndEntityType(): void
{
$request = Request::create('/api/v1/products', 'POST', [], [], [], [], '{"external_id":"PROD-001"}');
$requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->subscriber->onKernelRequest($requestEvent);
$response = new Response('{"status":"ok"}', 200);
$responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);
$savedLog = null;
$this->apiLogRepository->expects(self::once())
->method('save')
->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
$savedLog = $log;
return true;
}));
$this->subscriber->onKernelResponse($responseEvent);
self::assertSame('POST', $savedLog->getMethod());
self::assertSame('/api/v1/products', $savedLog->getPath());
self::assertSame(ApiLogDirection::INBOUND, $savedLog->getDirection());
self::assertSame('odoo', $savedLog->getSource());
self::assertSame('product', $savedLog->getEntityType());
self::assertSame('PROD-001', $savedLog->getExternalId());
self::assertSame('{"external_id":"PROD-001"}', $savedLog->getRequestBody());
self::assertSame('{"status":"ok"}', $savedLog->getResponseBody());
self::assertSame(200, $savedLog->getStatusCode());
}
public function testOutboundGetUpdatedProductsDetectsDirection(): void
{
$request = Request::create('/api/v1/updated-products', 'GET');
$requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->subscriber->onKernelRequest($requestEvent);
$response = new Response('[]', 200);
$responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);
$savedLog = null;
$this->apiLogRepository->expects(self::once())
->method('save')
->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
$savedLog = $log;
return true;
}));
$this->subscriber->onKernelResponse($responseEvent);
self::assertSame(ApiLogDirection::OUTBOUND, $savedLog->getDirection());
self::assertSame('webshop', $savedLog->getSource());
self::assertSame('product', $savedLog->getEntityType());
}
public function testOutboundGetSingleUpdatedProductExtractsExternalId(): void
{
$request = Request::create('/api/v1/updated-products/PROD-001', 'GET');
$requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->subscriber->onKernelRequest($requestEvent);
$response = new Response('{}', 200);
$responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);
$savedLog = null;
$this->apiLogRepository->expects(self::once())
->method('save')
->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
$savedLog = $log;
return true;
}));
$this->subscriber->onKernelResponse($responseEvent);
self::assertSame('PROD-001', $savedLog->getExternalId());
}
public function testInboundFailureFromWebshop(): void
{
$request = Request::create('/api/v1/failures', 'POST', [], [], [], [], '{"entity_type":"product","external_id":"PROD-001"}');
$requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->subscriber->onKernelRequest($requestEvent);
$response = new Response('{}', 201);
$responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);
$savedLog = null;
$this->apiLogRepository->expects(self::once())
->method('save')
->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
$savedLog = $log;
return true;
}));
$this->subscriber->onKernelResponse($responseEvent);
self::assertSame(ApiLogDirection::INBOUND, $savedLog->getDirection());
self::assertSame('webshop', $savedLog->getSource());
}
public function testAcknowledgementFromWebshop(): void
{
$request = Request::create('/api/v1/updates/product/PROD-001/ack', 'POST');
$requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->subscriber->onKernelRequest($requestEvent);
$response = new Response('{}', 200);
$responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);
$savedLog = null;
$this->apiLogRepository->expects(self::once())
->method('save')
->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
$savedLog = $log;
return true;
}));
$this->subscriber->onKernelResponse($responseEvent);
self::assertSame(ApiLogDirection::INBOUND, $savedLog->getDirection());
self::assertSame('webshop', $savedLog->getSource());
self::assertSame('product', $savedLog->getEntityType());
self::assertSame('PROD-001', $savedLog->getExternalId());
}
public function testInboundStocksDetectsCorrectly(): void
{
$request = Request::create('/api/v1/stocks', 'POST', [], [], [], [], '{"items":[]}');
$requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->subscriber->onKernelRequest($requestEvent);
$response = new Response('{}', 200);
$responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);
$savedLog = null;
$this->apiLogRepository->expects(self::once())
->method('save')
->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
$savedLog = $log;
return true;
}));
$this->subscriber->onKernelResponse($responseEvent);
self::assertSame(ApiLogDirection::INBOUND, $savedLog->getDirection());
self::assertSame('odoo', $savedLog->getSource());
self::assertSame('stock', $savedLog->getEntityType());
}
public function testOutboundStocksGetDetectsCorrectly(): void
{
$request = Request::create('/api/v1/stocks', 'GET');
$requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->subscriber->onKernelRequest($requestEvent);
$response = new Response('[]', 200);
$responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);
$savedLog = null;
$this->apiLogRepository->expects(self::once())
->method('save')
->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
$savedLog = $log;
return true;
}));
$this->subscriber->onKernelResponse($responseEvent);
self::assertSame(ApiLogDirection::OUTBOUND, $savedLog->getDirection());
self::assertSame('webshop', $savedLog->getSource());
}
public function testIgnoresSubRequests(): void
{
$request = Request::create('/api/v1/products', 'POST');
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::SUB_REQUEST);
$this->subscriber->onKernelRequest($event);
self::assertFalse($request->attributes->has('_api_log_start'));
}
public function testResponseWithoutRequestMarkIsIgnored(): void
{
$request = Request::create('/admin/products', 'GET');
$response = new Response('', 200);
$event = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);
$this->apiLogRepository->expects(self::never())->method('save');
$this->subscriber->onKernelResponse($event);
}
public function testBatchPostHasNullExternalId(): void
{
$body = json_encode([
['external_id' => 'PROD-001', 'name' => 'A'],
['external_id' => 'PROD-002', 'name' => 'B'],
]);
$request = Request::create('/api/v1/products', 'POST', [], [], [], [], $body);
$requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->subscriber->onKernelRequest($requestEvent);
$response = new Response('{}', 200);
$responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);
$savedLog = null;
$this->apiLogRepository->expects(self::once())
->method('save')
->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
$savedLog = $log;
return true;
}));
$this->subscriber->onKernelResponse($responseEvent);
self::assertNull($savedLog->getExternalId());
}
public function testDurationIsCalculated(): void
{
$request = Request::create('/api/v1/products', 'POST', [], [], [], [], '{}');
$requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->subscriber->onKernelRequest($requestEvent);
$response = new Response('{}', 200);
$responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);
$savedLog = null;
$this->apiLogRepository->expects(self::once())
->method('save')
->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
$savedLog = $log;
return true;
}));
$this->subscriber->onKernelResponse($responseEvent);
self::assertNotNull($savedLog->getDuration());
self::assertGreaterThanOrEqual(0, $savedLog->getDuration());
}
}
Step 2: Run test to verify it fails
Run: bin/phpunit tests/Unit/EventSubscriber/ApiLogSubscriberTest.php
Expected: FAIL - class not found
Step 3: Write the subscriber
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\ApiLog;
use App\Enum\ApiLogDirection;
use App\Repository\ApiLogRepository;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class ApiLogSubscriber implements EventSubscriberInterface
{
private const API_PREFIX = '/api/v1/';
/**
* Path patterns that indicate inbound traffic from the webshop (not Odoo).
*/
private const WEBSHOP_INBOUND_PATTERNS = [
'/api/v1/failures',
'/api/v1/updates/',
];
/**
* Maps path segments to entity types.
*/
private const PATH_TO_ENTITY_TYPE = [
'products' => 'product',
'updated-products' => 'product',
'partners' => 'partner',
'updated-partners' => 'partner',
'sale-orders' => 'sale_order',
'updated-sale-orders' => 'sale_order',
'stocks' => 'stock',
'pricelists' => 'pricelist',
'purchase-orders' => 'purchase_order',
'outbound-queue' => 'outbound_queue',
'failures' => 'failure',
];
public function __construct(
private readonly ApiLogRepository $apiLogRepository,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 0],
KernelEvents::RESPONSE => ['onKernelResponse', 0],
];
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$path = $request->getPathInfo();
if (!str_starts_with($path, self::API_PREFIX)) {
return;
}
$request->attributes->set('_api_log_start', microtime(true));
}
public function onKernelResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
if (!$request->attributes->has('_api_log_start')) {
return;
}
$startTime = $request->attributes->get('_api_log_start');
$response = $event->getResponse();
$path = $request->getPathInfo();
$method = $request->getMethod();
$direction = $this->determineDirection($method, $path);
$source = $this->determineSource($direction, $path);
$entityType = $this->extractEntityType($path);
$externalId = $this->extractExternalId($method, $path, $request->getContent());
$duration = (int) round((microtime(true) - $startTime) * 1000);
$log = new ApiLog();
$log->setMethod($method)
->setPath($path)
->setDirection($direction)
->setSource($source)
->setEntityType($entityType)
->setExternalId($externalId)
->setRequestBody($request->getContent() ?: null)
->setResponseBody($response->getContent() ?: null)
->setStatusCode($response->getStatusCode())
->setIpAddress($request->getClientIp() ?? '0.0.0.0')
->setDuration($duration);
$this->apiLogRepository->save($log);
}
private function determineDirection(string $method, string $path): ApiLogDirection
{
// GET requests to API are always outbound (webshop fetching data)
if ('GET' === $method || 'DELETE' === $method) {
return ApiLogDirection::OUTBOUND;
}
// POST to outbound-queue is outbound
if (str_contains($path, '/outbound-queue')) {
return ApiLogDirection::OUTBOUND;
}
// All other POST requests are inbound
return ApiLogDirection::INBOUND;
}
private function determineSource(ApiLogDirection $direction, string $path): string
{
if (ApiLogDirection::OUTBOUND === $direction) {
// Outbound queue operations are toward Odoo
if (str_contains($path, '/outbound-queue')) {
return 'odoo';
}
return 'webshop';
}
// Inbound: failures and acks come from webshop
foreach (self::WEBSHOP_INBOUND_PATTERNS as $pattern) {
if (str_starts_with($path, $pattern)) {
return 'webshop';
}
}
return 'odoo';
}
private function extractEntityType(string $path): ?string
{
// Remove /api/v1/ prefix
$relativePath = substr($path, \strlen(self::API_PREFIX));
// Get the first path segment
$segment = explode('/', $relativePath)[0];
// Handle /updates/{entityType}/{id}/ack
if ('updates' === $segment) {
$parts = explode('/', $relativePath);
return $parts[1] ?? null;
}
return self::PATH_TO_ENTITY_TYPE[$segment] ?? null;
}
private function extractExternalId(string $method, string $path, string $body): ?string
{
// Try to extract from /updates/{entityType}/{externalId}/ack
if (preg_match('#/api/v1/updates/[^/]+/([^/]+)/ack#', $path, $matches)) {
return $matches[1];
}
// Try to extract from /updated-{entity}/{externalId}
if (preg_match('#/api/v1/updated-[^/]+/([^/?]+)#', $path, $matches)) {
return $matches[1];
}
// For POST requests, try to extract from body
if ('POST' === $method && '' !== $body) {
$data = json_decode($body, true);
if (\is_array($data) && isset($data['external_id']) && \is_string($data['external_id'])) {
return $data['external_id'];
}
// If the body is a JSON array (batch), don't extract a single external_id
}
return null;
}
}
Step 4: Run test to verify it passes
Run: bin/phpunit tests/Unit/EventSubscriber/ApiLogSubscriberTest.php
Expected: PASS
Step 5: Run full test suite
Run: bin/phpunit
Expected: All tests pass (subscriber doesn't break existing tests)
Step 6: Commit
git add src/EventSubscriber/ApiLogSubscriber.php tests/Unit/EventSubscriber/ApiLogSubscriberTest.php
git commit -m "Add ApiLogSubscriber to capture API traffic"
Task 6: Admin ApiLogController
Files:
- Create: src/Controller/Admin/ApiLogController.php
- Test: tests/Controller/Admin/ApiLogControllerTest.php
Step 1: Write the failing test
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin;
use App\Entity\ApiLog;
use App\Entity\User;
use App\Enum\ApiLogDirection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ApiLogControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
protected function setUp(): void
{
$this->client = static::createClient();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
$this->loginAsAdmin();
}
public function testIndexPageIsAccessible(): void
{
$this->createApiLog('/api/v1/products', 'POST', ApiLogDirection::INBOUND, 'odoo', 200);
$this->client->request('GET', '/admin/api-logs');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('body', '/api/v1/products');
}
public function testIndexFiltersByDirection(): void
{
$this->createApiLog('/api/v1/products', 'POST', ApiLogDirection::INBOUND, 'odoo', 200);
$this->createApiLog('/api/v1/updated-products', 'GET', ApiLogDirection::OUTBOUND, 'webshop', 200);
$this->client->request('GET', '/admin/api-logs?direction=inbound');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('body', 'POST');
}
public function testShowPageDisplaysLogDetails(): void
{
$log = $this->createApiLog('/api/v1/products', 'POST', ApiLogDirection::INBOUND, 'odoo', 200);
$this->client->request('GET', '/admin/api-logs/'.$log->getId());
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('body', '/api/v1/products');
$this->assertSelectorTextContains('body', 'POST');
}
public function testShowPageDisplaysJsonBodies(): void
{
$log = $this->createApiLog(
'/api/v1/products',
'POST',
ApiLogDirection::INBOUND,
'odoo',
200,
'{"external_id":"PROD-001"}',
'{"status":"ok"}'
);
$this->client->request('GET', '/admin/api-logs/'.$log->getId());
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('body', 'PROD-001');
}
public function testIndexRequiresAuthentication(): void
{
$client = static::createClient();
$client->request('GET', '/admin/api-logs');
$this->assertResponseRedirects();
}
private function loginAsAdmin(): void
{
$userRepository = $this->em->getRepository(User::class);
$admin = $userRepository->findOneBy([]);
if (!$admin) {
$admin = new User();
$admin->setEmail('admin@test.com');
$admin->setPassword('$2y$13$dummy');
$admin->setRoles(['ROLE_ADMIN']);
$this->em->persist($admin);
$this->em->flush();
}
$this->client->loginUser($admin);
}
private function createApiLog(
string $path,
string $method,
ApiLogDirection $direction,
string $source,
int $statusCode,
?string $requestBody = null,
?string $responseBody = null,
): ApiLog {
$log = new ApiLog();
$log->setMethod($method)
->setPath($path)
->setDirection($direction)
->setSource($source)
->setStatusCode($statusCode)
->setIpAddress('127.0.0.1')
->setRequestBody($requestBody)
->setResponseBody($responseBody);
$this->em->persist($log);
$this->em->flush();
return $log;
}
}
Step 2: Run test to verify it fails
Run: bin/phpunit tests/Controller/Admin/ApiLogControllerTest.php
Expected: FAIL - 404 (route not found)
Step 3: Write the controller
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\ApiLog;
use App\Enum\ApiLogDirection;
use App\Repository\ApiLogRepository;
use App\Service\Admin\AdminPaginationService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* @extends AbstractAdminController<ApiLog>
*/
#[IsGranted('ROLE_ADMIN')]
#[Route('/admin/api-logs', name: 'admin_api_logs')]
class ApiLogController extends AbstractAdminController
{
public function __construct(
EntityManagerInterface $entityManager,
AdminPaginationService $paginationService,
private readonly ApiLogRepository $apiLogRepository,
) {
parent::__construct($entityManager, $paginationService);
}
protected function getEntityClass(): string
{
return ApiLog::class;
}
protected function getFormType(): ?string
{
return null;
}
protected function getEntityLabel(): string
{
return 'API Log';
}
protected function getRoutePrefix(): string
{
return 'admin_api_logs';
}
protected function getTemplateDirectory(): string
{
return 'admin/api_logs';
}
#[Route('', name: '', methods: ['GET'])]
#[Route('', name: '_index', methods: ['GET'])]
public function index(Request $request): Response
{
$direction = $request->query->get('direction');
$source = $request->query->get('source');
$entityType = $request->query->get('entity_type');
$qb = $this->createEntityQueryBuilder('e');
if ($direction && ApiLogDirection::tryFrom($direction)) {
$qb->andWhere('e.direction = :direction')
->setParameter('direction', $direction);
}
if ($source) {
$qb->andWhere('e.source = :source')
->setParameter('source', $source);
}
if ($entityType) {
$qb->andWhere('e.entityType = :entityType')
->setParameter('entityType', $entityType);
}
$qb->orderBy('e.id', 'DESC');
$result = $this->paginationService->paginate($qb, $request, 50);
return $this->render($this->getTemplateDirectory().'/index.html.twig', [
'result' => $result,
'current_direction' => $direction,
'current_source' => $source,
'current_entity_type' => $entityType,
'entity_label' => $this->getEntityLabel(),
'route_prefix' => $this->getRoutePrefix(),
]);
}
#[Route('/{id}', name: '_show', requirements: ['id' => '\d+'], methods: ['GET'])]
public function show(int $id): Response
{
$entity = $this->findEntityOr404($id);
return $this->renderShow($entity);
}
}
Step 4: Create the templates (see Task 7)
Defer template creation to next task. For now, create minimal templates so tests pass.
Step 5: Run test to verify it passes
Run: bin/phpunit tests/Controller/Admin/ApiLogControllerTest.php
Expected: PASS
Step 6: Commit
git add src/Controller/Admin/ApiLogController.php tests/Controller/Admin/ApiLogControllerTest.php
git commit -m "Add admin ApiLogController with index and show"
Task 7: Admin Templates (Index + Show)
Files:
- Create: templates/admin/api_logs/index.html.twig
- Create: templates/admin/api_logs/show.html.twig
- Modify: templates/admin/base.html.twig (add sidebar link)
Step 1: Create the index template
{% extends 'admin/base.html.twig' %}
{% import 'admin/components/pagination.html.twig' as pagination %}
{% import 'admin/components/flash.html.twig' as flash %}
{% block admin_title %}API Logs{% endblock %}
{% block admin_breadcrumb %}
<span class="admin-breadcrumb-separator">/</span>
<span>API Logs</span>
{% endblock %}
{% block admin_content %}
{{ flash.render_flash_messages() }}
<div class="admin-page-header">
<h1 class="admin-page-title">{{ entity_label }}</h1>
</div>
{# Filters #}
<div class="admin-card" style="margin-bottom: 20px;">
<div class="admin-card-body" style="padding: 15px;">
<form method="get" action="{{ path(route_prefix) }}" style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
<select name="direction" style="padding: 6px 10px; border: 1px solid var(--admin-border-color); border-radius: 4px;">
<option value="">All Directions</option>
<option value="inbound" {{ current_direction == 'inbound' ? 'selected' : '' }}>Inbound</option>
<option value="outbound" {{ current_direction == 'outbound' ? 'selected' : '' }}>Outbound</option>
</select>
<select name="source" style="padding: 6px 10px; border: 1px solid var(--admin-border-color); border-radius: 4px;">
<option value="">All Sources</option>
<option value="odoo" {{ current_source == 'odoo' ? 'selected' : '' }}>Odoo</option>
<option value="webshop" {{ current_source == 'webshop' ? 'selected' : '' }}>Webshop</option>
</select>
<select name="entity_type" style="padding: 6px 10px; border: 1px solid var(--admin-border-color); border-radius: 4px;">
<option value="">All Entity Types</option>
<option value="product" {{ current_entity_type == 'product' ? 'selected' : '' }}>Product</option>
<option value="partner" {{ current_entity_type == 'partner' ? 'selected' : '' }}>Partner</option>
<option value="sale_order" {{ current_entity_type == 'sale_order' ? 'selected' : '' }}>Sale Order</option>
<option value="stock" {{ current_entity_type == 'stock' ? 'selected' : '' }}>Stock</option>
<option value="pricelist" {{ current_entity_type == 'pricelist' ? 'selected' : '' }}>Pricelist</option>
<option value="purchase_order" {{ current_entity_type == 'purchase_order' ? 'selected' : '' }}>Purchase Order</option>
</select>
<button type="submit" class="admin-btn admin-btn-primary admin-btn-sm">Filter</button>
<a href="{{ path(route_prefix) }}" class="admin-btn admin-btn-secondary admin-btn-sm">Clear</a>
</form>
</div>
</div>
<div class="admin-card">
<div class="admin-card-body" style="padding: 0; overflow-x: auto;">
{% if result.items|length == 0 %}
<div class="admin-empty-state" style="padding: 40px;">
<div class="admin-empty-state-icon">📜</div>
<div class="admin-empty-state-title">No API logs found</div>
<p>No API log entries match your current filters.</p>
</div>
{% else %}
<table class="admin-table">
<thead>
<tr>
<th>Time</th>
<th>Method</th>
<th>Path</th>
<th>Direction</th>
<th>Source</th>
<th>Entity Type</th>
<th>Status</th>
<th>Duration</th>
<th></th>
</tr>
</thead>
<tbody>
{% for log in result.items %}
<tr>
<td style="white-space: nowrap;">{{ log.createdAt ? log.createdAt|date('Y-m-d H:i:s') : '-' }}</td>
<td>
<span class="admin-badge admin-badge-{{ log.method == 'GET' ? 'success' : (log.method == 'POST' ? 'info' : 'warning') }}">
{{ log.method }}
</span>
</td>
<td style="font-family: monospace; font-size: 13px;">{{ log.path }}</td>
<td>
<span class="admin-badge admin-badge-{{ log.direction.value == 'inbound' ? 'info' : 'warning' }}">
{{ log.direction.value == 'inbound' ? '← IN' : '→ OUT' }}
</span>
</td>
<td>{{ log.source }}</td>
<td>{{ log.entityType ?? '-' }}</td>
<td>
<span class="admin-badge admin-badge-{{ log.statusCode < 300 ? 'success' : (log.statusCode < 500 ? 'warning' : 'danger') }}">
{{ log.statusCode }}
</span>
</td>
<td>{{ log.duration is not null ? log.duration ~ 'ms' : '-' }}</td>
<td>
<a href="{{ path(route_prefix ~ '_show', {id: log.id}) }}" class="admin-btn admin-btn-sm admin-btn-secondary">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{{ pagination.render_pagination(result, route_prefix ~ '_index', {direction: current_direction, source: current_source, entity_type: current_entity_type}) }}
{% endblock %}
Step 2: Create the show template
{% extends 'admin/base.html.twig' %}
{% import 'admin/components/flash.html.twig' as flash %}
{% block admin_title %}API Log #{{ entity.id }}{% endblock %}
{% block admin_breadcrumb %}
<span class="admin-breadcrumb-separator">/</span>
<a href="{{ path(route_prefix ~ '_index') }}">API Logs</a>
<span class="admin-breadcrumb-separator">/</span>
<span>#{{ entity.id }}</span>
{% endblock %}
{% block admin_actions %}
<a href="{{ path(route_prefix ~ '_index') }}" class="admin-btn admin-btn-secondary">
Back to List
</a>
{% endblock %}
{% block admin_content %}
{{ flash.render_flash_messages() }}
<div class="admin-page-header">
<h1 class="admin-page-title">API Log #{{ entity.id }}</h1>
</div>
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Request Details</h3>
</div>
<div class="admin-card-body">
<table class="admin-table" style="max-width: 800px;">
<tbody>
<tr>
<th style="width: 200px;">ID</th>
<td>{{ entity.id }}</td>
</tr>
<tr>
<th>Time</th>
<td>{{ entity.createdAt ? entity.createdAt|date('Y-m-d H:i:s') : '-' }}</td>
</tr>
<tr>
<th>Method</th>
<td>
<span class="admin-badge admin-badge-{{ entity.method == 'GET' ? 'success' : (entity.method == 'POST' ? 'info' : 'warning') }}">
{{ entity.method }}
</span>
</td>
</tr>
<tr>
<th>Path</th>
<td style="font-family: monospace;">{{ entity.path }}</td>
</tr>
<tr>
<th>Direction</th>
<td>
<span class="admin-badge admin-badge-{{ entity.direction.value == 'inbound' ? 'info' : 'warning' }}">
{{ entity.direction.value == 'inbound' ? '← Inbound' : '→ Outbound' }}
</span>
</td>
</tr>
<tr>
<th>Source</th>
<td>{{ entity.source }}</td>
</tr>
<tr>
<th>Entity Type</th>
<td>{{ entity.entityType ?? '-' }}</td>
</tr>
<tr>
<th>External ID</th>
<td>{{ entity.externalId ?? '-' }}</td>
</tr>
<tr>
<th>Status Code</th>
<td>
<span class="admin-badge admin-badge-{{ entity.statusCode < 300 ? 'success' : (entity.statusCode < 500 ? 'warning' : 'danger') }}">
{{ entity.statusCode }}
</span>
</td>
</tr>
<tr>
<th>Duration</th>
<td>{{ entity.duration is not null ? entity.duration ~ ' ms' : '-' }}</td>
</tr>
<tr>
<th>IP Address</th>
<td>{{ entity.ipAddress }}</td>
</tr>
</tbody>
</table>
</div>
</div>
{% if entity.requestBody %}
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Request Body</h3>
</div>
<div class="admin-card-body">
<pre style="background: #f8f9fa; padding: 15px; border-radius: 4px; overflow-x: auto; font-family: monospace; font-size: 13px; margin: 0;">{{ entity.requestBody|json_encode is not same as(false) ? (entity.requestBody|raw|json_encode(constant('JSON_PRETTY_PRINT'))|default(entity.requestBody)) : entity.requestBody }}</pre>
</div>
</div>
{% endif %}
{% if entity.responseBody %}
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">Response Body</h3>
</div>
<div class="admin-card-body">
<pre style="background: #f8f9fa; padding: 15px; border-radius: 4px; overflow-x: auto; font-family: monospace; font-size: 13px; margin: 0;">{{ entity.responseBody|json_encode is not same as(false) ? (entity.responseBody|raw|json_encode(constant('JSON_PRETTY_PRINT'))|default(entity.responseBody)) : entity.responseBody }}</pre>
</div>
</div>
{% endif %}
{% endblock %}
Note on JSON pretty-printing: The request/response bodies are stored as raw strings. To pretty-print them in Twig, we need a small trick. The simplest approach is to decode+encode in the template. A cleaner approach is a Twig extension, but for now we can use json_decode in a Twig extension or simply output the raw string (it's already JSON). The implementer should test whether entity.requestBody can be pretty-printed with a simple Twig filter or if a custom json_pretty Twig filter is needed. Simplest path: just output the raw string in the <pre> tag - it's readable as-is since it's JSON.
Simplified approach for the <pre> tags:
<pre style="background: #f8f9fa; padding: 15px; border-radius: 4px; overflow-x: auto; font-family: monospace; font-size: 13px; margin: 0;">{{ entity.requestBody }}</pre>
Step 3: Add sidebar link to base template
In templates/admin/base.html.twig, add the API Logs link in the "Sync & Webhooks" nav section (after Webhook Deliveries):
<a href="{{ path('admin_api_logs') }}" class="admin-nav-link {{ app.request.attributes.get('_route') starts with 'admin_api_logs' ? 'active' : '' }}">
<span class="admin-nav-icon">📜</span>
API Logs
</a>
Add this line after the Webhook Deliveries link (around line 778 in templates/admin/base.html.twig).
Step 4: Run tests
Run: bin/phpunit tests/Controller/Admin/ApiLogControllerTest.php
Expected: PASS
Step 5: Commit
git add templates/admin/api_logs/ templates/admin/base.html.twig
git commit -m "Add admin API log templates and sidebar link"
Task 8: Product API Log Tab
Files:
- Modify: src/Controller/Admin/ProductController.php (add apiLogs action)
- Modify: templates/admin/products/_tabs.html.twig (add API Log tab)
- Create: templates/admin/products/api-logs.html.twig
- Modify: tests/Controller/Admin/ProductDetailTabsTest.php (add test)
Step 1: Write the failing test
Add to tests/Controller/Admin/ProductDetailTabsTest.php:
public function testApiLogsTabDisplaysLogEntries(): void
{
$product = $this->createProduct('PROD-LOG-001');
$this->createApiLogForProduct('PROD-LOG-001');
$this->client->request('GET', '/admin/products/'.$product->getId().'/api-logs');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('body', '/api/v1/products');
}
public function testApiLogsTabShowsEmptyStateWhenNoLogs(): void
{
$product = $this->createProduct('PROD-LOG-002');
$this->client->request('GET', '/admin/products/'.$product->getId().'/api-logs');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('body', 'No API log entries');
}
private function createApiLogForProduct(string $externalId): ApiLog
{
$log = new ApiLog();
$log->setMethod('POST')
->setPath('/api/v1/products')
->setDirection(ApiLogDirection::INBOUND)
->setSource('odoo')
->setEntityType('product')
->setExternalId($externalId)
->setStatusCode(200)
->setIpAddress('127.0.0.1');
$this->em->persist($log);
$this->em->flush();
return $log;
}
Add required imports at top of test file:
Step 2: Run test to verify it fails
Run: bin/phpunit tests/Controller/Admin/ProductDetailTabsTest.php --filter testApiLogsTab
Expected: FAIL - 404 (route not found)
Step 3: Add route to ProductController
Add to src/Controller/Admin/ProductController.php:
use App\Repository\ApiLogRepository;
// Add this method:
#[Route('/{id}/api-logs', name: 'admin_products_api_logs', requirements: ['id' => '\d+'], methods: ['GET'])]
public function apiLogs(int $id, ApiLogRepository $apiLogRepository): Response
{
$entity = $this->findEntityOr404($id);
$logs = $apiLogRepository->findByEntity('product', $entity->getExternalId());
return $this->render('admin/products/api-logs.html.twig', [
'entity' => $entity,
'entity_label' => $this->getEntityLabel(),
'route_prefix' => $this->getRoutePrefix(),
'logs' => $logs,
'active_tab' => 'api_logs',
]);
}
Step 4: Add tab to _tabs.html.twig
Add an "API Log" tab to templates/admin/products/_tabs.html.twig:
<a href="{{ path('admin_products_api_logs', {id: entity.id}) }}"
style="padding: 10px 20px; text-decoration: none; font-weight: 500; border-bottom: 2px solid {{ active_tab == 'api_logs' ? 'var(--admin-primary)' : 'transparent' }}; margin-bottom: -2px; color: {{ active_tab == 'api_logs' ? 'var(--admin-primary)' : 'var(--admin-secondary)' }};">
API Log
</a>
Step 5: Create the template
Create templates/admin/products/api-logs.html.twig:
{% extends 'admin/base.html.twig' %}
{% block admin_title %}Product #{{ entity.id }} - API Log{% endblock %}
{% block admin_breadcrumb %}
<span class="admin-breadcrumb-separator">/</span>
<a href="{{ path('admin_products_index') }}">Products</a>
<span class="admin-breadcrumb-separator">/</span>
<a href="{{ path('admin_products_show', {id: entity.id}) }}">#{{ entity.id }}</a>
<span class="admin-breadcrumb-separator">/</span>
<span>API Log</span>
{% endblock %}
{% block admin_actions %}
<a href="{{ path('admin_products_edit', {id: entity.id}) }}" class="admin-btn admin-btn-primary">
Edit Product
</a>
<a href="{{ path('admin_products_index') }}" class="admin-btn admin-btn-secondary">
Back to List
</a>
{% endblock %}
{% block admin_content %}
<div class="admin-page-header">
<h1 class="admin-page-title">Product #{{ entity.id }}</h1>
</div>
{% include 'admin/products/_tabs.html.twig' with {entity: entity, active_tab: active_tab} %}
<div class="admin-card">
<div class="admin-card-header">
<h3 class="admin-card-title">API Log ({{ logs|length }})</h3>
</div>
<div class="admin-card-body" style="padding: 0; overflow-x: auto;">
{% if logs|length == 0 %}
<div class="admin-empty-state" style="padding: 40px;">
<div class="admin-empty-state-icon">📜</div>
<div class="admin-empty-state-title">No API log entries</div>
<p>No API traffic has been recorded for this product yet.</p>
</div>
{% else %}
<table class="admin-table">
<thead>
<tr>
<th>Time</th>
<th>Direction</th>
<th>Method</th>
<th>Path</th>
<th>Source</th>
<th>Status</th>
<th>Duration</th>
<th></th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td style="white-space: nowrap;">{{ log.createdAt ? log.createdAt|date('Y-m-d H:i:s') : '-' }}</td>
<td>
<span class="admin-badge admin-badge-{{ log.direction.value == 'inbound' ? 'info' : 'warning' }}">
{{ log.direction.value == 'inbound' ? '← IN' : '→ OUT' }}
</span>
</td>
<td>
<span class="admin-badge admin-badge-{{ log.method == 'GET' ? 'success' : (log.method == 'POST' ? 'info' : 'warning') }}">
{{ log.method }}
</span>
</td>
<td style="font-family: monospace; font-size: 13px;">{{ log.path }}</td>
<td>{{ log.source }}</td>
<td>
<span class="admin-badge admin-badge-{{ log.statusCode < 300 ? 'success' : (log.statusCode < 500 ? 'warning' : 'danger') }}">
{{ log.statusCode }}
</span>
</td>
<td>{{ log.duration is not null ? log.duration ~ 'ms' : '-' }}</td>
<td>
<a href="{{ path('admin_api_logs_show', {id: log.id}) }}" class="admin-btn admin-btn-sm admin-btn-secondary">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% endblock %}
Step 6: Run test to verify it passes
Run: bin/phpunit tests/Controller/Admin/ProductDetailTabsTest.php
Expected: All tests PASS
Step 7: Commit
git add src/Controller/Admin/ProductController.php templates/admin/products/_tabs.html.twig templates/admin/products/api-logs.html.twig tests/Controller/Admin/ProductDetailTabsTest.php
git commit -m "Add API Log tab to product detail page"
Task 9: Partner API Log Tab
Files:
- Modify: src/Controller/Admin/PartnerController.php
- Create: templates/admin/partners/_tabs.html.twig
- Create: templates/admin/partners/api-logs.html.twig
- Modify: templates/admin/partners/show.html.twig (add tabs include)
Step 1: Write the failing test
Create tests/Controller/Admin/PartnerApiLogTabTest.php:
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin;
use App\Entity\ApiLog;
use App\Entity\Partner;
use App\Entity\User;
use App\Enum\ApiLogDirection;
use App\Enum\ProcessingState;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class PartnerApiLogTabTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
protected function setUp(): void
{
$this->client = static::createClient();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
$this->loginAsAdmin();
}
public function testShowPageDisplaysTabBar(): void
{
$partner = $this->createPartner('PARTNER-001');
$this->client->request('GET', '/admin/partners/'.$partner->getId());
$this->assertResponseIsSuccessful();
$this->assertSelectorExists('a[href*="/admin/partners/'.$partner->getId().'/api-logs"]');
}
public function testApiLogsTabDisplaysLogEntries(): void
{
$partner = $this->createPartner('PARTNER-LOG-001');
$this->createApiLogForPartner('PARTNER-LOG-001');
$this->client->request('GET', '/admin/partners/'.$partner->getId().'/api-logs');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('body', '/api/v1/partners');
}
private function loginAsAdmin(): void
{
$userRepository = $this->em->getRepository(User::class);
$admin = $userRepository->findOneBy([]);
if (!$admin) {
$admin = new User();
$admin->setEmail('admin@test.com');
$admin->setPassword('$2y$13$dummy');
$admin->setRoles(['ROLE_ADMIN']);
$this->em->persist($admin);
$this->em->flush();
}
$this->client->loginUser($admin);
}
private function createPartner(string $externalId): Partner
{
$partner = new Partner();
$partner->setExternalId($externalId);
$partner->setType('B');
$partner->setProcessingState(ProcessingState::PENDING);
$partner->setCurrentData(['external_id' => $externalId]);
$partner->setChangedFields([]);
$this->em->persist($partner);
$this->em->flush();
return $partner;
}
private function createApiLogForPartner(string $externalId): ApiLog
{
$log = new ApiLog();
$log->setMethod('POST')
->setPath('/api/v1/partners')
->setDirection(ApiLogDirection::INBOUND)
->setSource('odoo')
->setEntityType('partner')
->setExternalId($externalId)
->setStatusCode(200)
->setIpAddress('127.0.0.1');
$this->em->persist($log);
$this->em->flush();
return $log;
}
}
Step 2: Run test to verify it fails
Run: bin/phpunit tests/Controller/Admin/PartnerApiLogTabTest.php
Expected: FAIL
Step 3: Add route to PartnerController
Add to src/Controller/Admin/PartnerController.php:
use App\Repository\ApiLogRepository;
#[Route('/{id}/api-logs', name: 'admin_partners_api_logs', requirements: ['id' => '\d+'], methods: ['GET'])]
public function apiLogs(int $id, ApiLogRepository $apiLogRepository): Response
{
$entity = $this->findEntityOr404($id);
$logs = $apiLogRepository->findByEntity('partner', $entity->getExternalId());
return $this->render('admin/partners/api-logs.html.twig', [
'entity' => $entity,
'entity_label' => $this->getEntityLabel(),
'route_prefix' => $this->getRoutePrefix(),
'logs' => $logs,
'active_tab' => 'api_logs',
]);
}
Also update the show method to pass active_tab:
Step 4: Create partner tabs partial and API logs template
Create templates/admin/partners/_tabs.html.twig:
{# Expects: entity (Partner), active_tab (string: 'details'|'api_logs') #}
<div style="display: flex; gap: 0; margin-bottom: 20px; border-bottom: 2px solid var(--admin-border-color);">
<a href="{{ path('admin_partners_show', {id: entity.id}) }}"
style="padding: 10px 20px; text-decoration: none; font-weight: 500; border-bottom: 2px solid {{ active_tab == 'details' ? 'var(--admin-primary)' : 'transparent' }}; margin-bottom: -2px; color: {{ active_tab == 'details' ? 'var(--admin-primary)' : 'var(--admin-secondary)' }};">
Details
</a>
<a href="{{ path('admin_partners_api_logs', {id: entity.id}) }}"
style="padding: 10px 20px; text-decoration: none; font-weight: 500; border-bottom: 2px solid {{ active_tab == 'api_logs' ? 'var(--admin-primary)' : 'transparent' }}; margin-bottom: -2px; color: {{ active_tab == 'api_logs' ? 'var(--admin-primary)' : 'var(--admin-secondary)' }};">
API Log
</a>
</div>
Create templates/admin/partners/api-logs.html.twig following the same pattern as the product API logs template, but with partner-specific breadcrumbs and routes.
Update templates/admin/partners/show.html.twig to include the tabs partial:
{% include 'admin/partners/_tabs.html.twig' with {entity: entity, active_tab: active_tab|default('details')} %}
Step 5: Run test to verify it passes
Run: bin/phpunit tests/Controller/Admin/PartnerApiLogTabTest.php
Expected: PASS
Step 6: Commit
git add src/Controller/Admin/PartnerController.php templates/admin/partners/ tests/Controller/Admin/PartnerApiLogTabTest.php
git commit -m "Add API Log tab to partner detail page"
Task 10: Sale Order API Log Tab
Files:
- Modify: src/Controller/Admin/SaleOrderController.php
- Create: templates/admin/sale_orders/_tabs.html.twig
- Create: templates/admin/sale_orders/api-logs.html.twig
- Modify: templates/admin/sale_orders/show.html.twig (add tabs include)
Follow the exact same pattern as Task 9 but for sale orders:
Step 1: Write the failing test
Create tests/Controller/Admin/SaleOrderApiLogTabTest.php following the same pattern as PartnerApiLogTabTest.php but using SaleOrder entity, route /admin/sale-orders/{id}/api-logs, entity type sale_order.
Step 2: Run test to verify it fails
Run: bin/phpunit tests/Controller/Admin/SaleOrderApiLogTabTest.php
Expected: FAIL
Step 3: Add route to SaleOrderController
use App\Repository\ApiLogRepository;
#[Route('/{id}/api-logs', name: 'admin_sale_orders_api_logs', requirements: ['id' => '\d+'], methods: ['GET'])]
public function apiLogs(int $id, ApiLogRepository $apiLogRepository): Response
{
$entity = $this->findEntityOr404($id);
$logs = $apiLogRepository->findByEntity('sale_order', $entity->getExternalId());
return $this->render('admin/sale_orders/api-logs.html.twig', [
'entity' => $entity,
'entity_label' => $this->getEntityLabel(),
'route_prefix' => $this->getRoutePrefix(),
'logs' => $logs,
'active_tab' => 'api_logs',
]);
}
Also update the show method to pass active_tab:
Step 4: Create templates
Same pattern as Task 9: _tabs.html.twig + api-logs.html.twig + modify show.html.twig.
Step 5: Run test to verify it passes
Run: bin/phpunit tests/Controller/Admin/SaleOrderApiLogTabTest.php
Expected: PASS
Step 6: Commit
git add src/Controller/Admin/SaleOrderController.php templates/admin/sale_orders/ tests/Controller/Admin/SaleOrderApiLogTabTest.php
git commit -m "Add API Log tab to sale order detail page"
Task 11: Purge Command
Files:
- Create: src/Command/ApiLogPurgeCommand.php
- Test: tests/Command/ApiLogPurgeCommandTest.php
Step 1: Write the failing test
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Command\ApiLogPurgeCommand;
use App\Repository\ApiLogRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class ApiLogPurgeCommandTest extends TestCase
{
public function testPurgesOldEntries(): void
{
$repository = $this->createMock(ApiLogRepository::class);
$repository->expects(self::once())
->method('deleteOlderThan')
->with(self::callback(function (\DateTimeImmutable $cutoff): bool {
$expected = new \DateTimeImmutable('-30 days');
$diff = abs($cutoff->getTimestamp() - $expected->getTimestamp());
return $diff < 5; // within 5 seconds
}))
->willReturn(42);
$command = new ApiLogPurgeCommand($repository);
$tester = new CommandTester($command);
$tester->execute([]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('42', $tester->getDisplay());
}
public function testReportsZeroWhenNothingToPurge(): void
{
$repository = $this->createMock(ApiLogRepository::class);
$repository->method('deleteOlderThan')->willReturn(0);
$command = new ApiLogPurgeCommand($repository);
$tester = new CommandTester($command);
$tester->execute([]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('0', $tester->getDisplay());
}
}
Step 2: Run test to verify it fails
Run: bin/phpunit tests/Command/ApiLogPurgeCommandTest.php
Expected: FAIL - class not found
Step 3: Write the command
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\ApiLogRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:api-log:purge',
description: 'Purge API log entries older than 30 days',
)]
class ApiLogPurgeCommand extends Command
{
public function __construct(
private readonly ApiLogRepository $apiLogRepository,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$cutoff = new \DateTimeImmutable('-30 days');
$deleted = $this->apiLogRepository->deleteOlderThan($cutoff);
$io->success(\sprintf('Purged %d API log entries older than 30 days.', $deleted));
return Command::SUCCESS;
}
}
Step 4: Run test to verify it passes
Run: bin/phpunit tests/Command/ApiLogPurgeCommandTest.php
Expected: PASS
Step 5: Commit
git add src/Command/ApiLogPurgeCommand.php tests/Command/ApiLogPurgeCommandTest.php
git commit -m "Add app:api-log:purge command for 30-day retention"
Task 12: Final Verification
Step 1: Run full test suite
Run: bin/phpunit
Expected: All tests pass
Step 2: Run static analysis
Run: vendor/bin/phpstan analyse
Expected: No errors
Step 3: Run code style fixer
Run: vendor/bin/php-cs-fixer fix --dry-run --diff
Expected: No style violations (or fix them)
Step 4: Commit any fixes