Skip to content

Commit d55cffc

Browse files
committed
first naive draft for RDC implem
1 parent caa3cc6 commit d55cffc

36 files changed

Lines changed: 897 additions & 0 deletions

.env.dist

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ INSEE_PORTAIL_API_KEY=
112112
STANDTRACK_API_KEY=
113113
PROXIMUS_SYNC_URI=
114114

115+
###> logist-x/rdc ###
116+
KEYCLOAK_BASE_URL=https://keycloak.bav.logist-x.eu
117+
KEYCLOAK_REALM=
118+
KEYCLOAK_CLIENT_ID=
119+
KEYCLOAK_CLIENT_SECRET=
120+
KEYCLOAK_USERNAME=
121+
KEYCLOAK_PASSWORD=
122+
RDC_INSTANCE_BASE_URL=
123+
RDC_INSTANCE_NAME=
124+
RDC_ACL_AUTHORIZATIONS=[{"memberIdentifier":"BOL.MEMBER.APPLICOLIS","authorizedBy":"BOL.MEMBER.COURSIERS_BORDELAIS","mode":"READ"}]
125+
RDC_WEBHOOK_SECRET=
126+
###< logist-x/rdc ###
127+
115128
###> feature flags ###
116129
###< feature flags ###
117130

config/services.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1629,6 +1629,29 @@ services:
16291629
arguments:
16301630
$standtrackApiKey: '%env(STANDTRACK_API_KEY)%'
16311631

1632+
AppBundle\Integration\Rdc\Api\RdcClientInterface:
1633+
class: AppBundle\Integration\Rdc\Api\RdcClient
1634+
arguments:
1635+
$keycloakBaseUrl: '%env(KEYCLOAK_BASE_URL)%'
1636+
$keycloakRealm: '%env(KEYCLOAK_REALM)%'
1637+
$keycloakClientId: '%env(KEYCLOAK_CLIENT_ID)%'
1638+
$keycloakClientSecret: '%env(KEYCLOAK_CLIENT_SECRET)%'
1639+
$keycloakUsername: '%env(KEYCLOAK_USERNAME)%'
1640+
$keycloakPassword: '%env(KEYCLOAK_PASSWORD)%'
1641+
$rdcInstanceBaseUrl: '%env(RDC_INSTANCE_BASE_URL)%'
1642+
$memberProvider: '%env(RDC_MEMBER_PROVIDER)%'
1643+
$aclAuthorizations: '%env(json:RDC_ACL_AUTHORIZATIONS)%'
1644+
1645+
AppBundle\Integration\Rdc\Webhook\WebhookPayloadParser: ~
1646+
1647+
AppBundle\Integration\Rdc\Webhook\RdcIdempotencyChecker: ~
1648+
1649+
AppBundle\Controller\Api\RdcWebhookController:
1650+
arguments:
1651+
$rdcWebhookSecret: '%env(RDC_WEBHOOK_SECRET)%'
1652+
tags:
1653+
- { name: monolog.logger, channel: rdc }
1654+
16321655
AppBundle\Utils\Defaults: ~
16331656

16341657
AppBundle\Api\Filter\AssignedFilter:

config/services/commands.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,7 @@ services:
216216
AppBundle\Command\Paygreen\CheckStatusCommand:
217217
tags:
218218
- { name: 'console.command', command: 'coopcycle:paygreen:check-status' }
219+
220+
AppBundle\Command\RdcTestConnectionCommand:
221+
tags:
222+
- { name: 'console.command', command: 'rdc:test-connection' }
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace AppBundle\Command;
4+
5+
use AppBundle\Integration\Rdc\Api\RdcClientInterface;
6+
use Symfony\Component\Console\Attribute\AsCommand;
7+
use Symfony\Component\Console\Command\Command;
8+
use Symfony\Component\Console\Input\InputInterface;
9+
use Symfony\Component\Console\Output\OutputInterface;
10+
use Symfony\Component\Console\Style\SymfonyStyle;
11+
12+
#[AsCommand(
13+
name: 'rdc:test-connection',
14+
description: 'Test RDC integration connection (Keycloak token retrieval)',
15+
)]
16+
class RdcTestConnectionCommand extends Command
17+
{
18+
public function __construct(
19+
private readonly RdcClientInterface $rdcClient,
20+
) {
21+
parent::__construct();
22+
}
23+
24+
protected function execute(InputInterface $input, OutputInterface $output): int
25+
{
26+
$io = new SymfonyStyle($input, $output);
27+
28+
$io->title('RDC Connection Test');
29+
30+
$io->section('Testing Keycloak Token Retrieval');
31+
32+
try {
33+
$io->text('Requesting token from Keycloak...');
34+
35+
$token = $this->rdcClient->getToken();
36+
37+
$io->success('Token retrieved successfully!');
38+
$io->text(sprintf('Token (first 50 chars): %s...', substr($token, 0, 50)));
39+
40+
return Command::SUCCESS;
41+
} catch (\Exception $e) {
42+
$io->error('Failed to retrieve token');
43+
$io->text('Error: ' . $e->getMessage());
44+
45+
return Command::FAILURE;
46+
}
47+
}
48+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AppBundle\Controller\Api;
6+
7+
use AppBundle\Integration\Rdc\Webhook\RdcIdempotencyChecker;
8+
use AppBundle\Integration\Rdc\Webhook\WebhookPayloadParser;
9+
use AppBundle\Message\RdcWebhookMessage;
10+
use Psr\Log\LoggerInterface;
11+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
12+
use Symfony\Component\HttpFoundation\JsonResponse;
13+
use Symfony\Component\HttpFoundation\Request;
14+
use Symfony\Component\HttpFoundation\Response;
15+
use Symfony\Component\Messenger\MessageBusInterface;
16+
use Symfony\Component\Routing\Attribute\Route;
17+
18+
class RdcWebhookController extends AbstractController
19+
{
20+
private const WEBHOOK_SOURCE = 'RDC';
21+
22+
public function __construct(
23+
private readonly WebhookPayloadParser $payloadParser,
24+
private readonly RdcIdempotencyChecker $idempotencyChecker,
25+
private readonly MessageBusInterface $messageBus,
26+
private readonly LoggerInterface $logger,
27+
private readonly string $rdcWebhookSecret,
28+
) {
29+
}
30+
31+
#[Route(path: '/api/v1/webhooks/rdc', name: 'api_v1_webhooks_rdc', methods: ['POST'])]
32+
public function handleWebhook(Request $request): JsonResponse
33+
{
34+
// Validate X-webhook-source header
35+
$source = $request->headers->get('X-webhook-source');
36+
if ($source !== self::WEBHOOK_SOURCE) {
37+
$this->logger->warning('RDC webhook received with invalid source', [
38+
'source' => $source,
39+
]);
40+
return new JsonResponse(['error' => 'Invalid webhook source'], Response::HTTP_FORBIDDEN);
41+
}
42+
43+
// Validate X-webhook-secret header with timing-attack safe comparison
44+
$secret = $request->headers->get('X-webhook-Secret');
45+
if (!hash_equals($this->rdcWebhookSecret, $secret)) {
46+
$this->logger->warning('RDC webhook received with invalid secret');
47+
return new JsonResponse(['error' => 'Invalid webhook secret'], Response::HTTP_FORBIDDEN);
48+
}
49+
50+
// Parse JSON payload
51+
$content = $request->getContent();
52+
$payload = json_decode($content, true);
53+
if (json_last_error() !== JSON_ERROR_NONE) {
54+
$this->logger->warning('RDC webhook received with invalid JSON', [
55+
'json_error' => json_last_error_msg(),
56+
]);
57+
return new JsonResponse(['error' => 'Invalid JSON payload'], Response::HTTP_BAD_REQUEST);
58+
}
59+
60+
// Parse webhook payload
61+
$parsed = $this->payloadParser->parse($payload);
62+
if ($parsed === null) {
63+
return new JsonResponse(['error' => 'Invalid payload structure'], Response::HTTP_BAD_REQUEST);
64+
}
65+
66+
$loUri = $parsed['loUri'];
67+
$eventType = $parsed['eventType'];
68+
$lo = $parsed['lo'];
69+
70+
// Check idempotency and resolve effective event type
71+
$effectiveEventType = $this->idempotencyChecker->resolveEventType($loUri, $eventType);
72+
73+
// Mark as processed (will be idempotent due to unique constraint)
74+
if (!$this->idempotencyChecker->isAlreadyProcessed($loUri)) {
75+
$this->idempotencyChecker->markAsProcessed($loUri, $effectiveEventType);
76+
}
77+
78+
// Dispatch message to queue
79+
$message = new RdcWebhookMessage(
80+
$loUri,
81+
$effectiveEventType,
82+
$lo,
83+
new \DateTimeImmutable(),
84+
);
85+
$this->messageBus->dispatch($message);
86+
87+
$this->logger->info('RDC webhook accepted', [
88+
'lo_uri' => $loUri,
89+
'event_type' => $eventType,
90+
'effective_event_type' => $effectiveEventType,
91+
]);
92+
93+
return new JsonResponse([
94+
'status' => 'accepted',
95+
'lo_uri' => $loUri,
96+
'event_type' => $effectiveEventType,
97+
]);
98+
}
99+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AppBundle\Entity\Rdc;
6+
7+
use Doctrine\ORM\Mapping as ORM;
8+
use Gedmo\Timestampable\Traits\Timestampable;
9+
10+
/**
11+
* Tracks processed webhooks for idempotency.
12+
*
13+
* @ORM\Entity
14+
* @ORM\Table(name="rdc_processed_webhooks")
15+
* @ORM\UniqueConstraint(name="uniq_lo_uri", columns={"lo_uri"})
16+
*/
17+
class RdcProcessedWebhook
18+
{
19+
use Timestampable;
20+
21+
/**
22+
* @ORM\Id
23+
* @ORM\GeneratedValue
24+
* @ORM\Column(type="integer")
25+
*/
26+
private ?int $id = null;
27+
28+
/**
29+
* @ORM\Column(type="string", length=255, unique=true)
30+
*/
31+
private string $loUri;
32+
33+
/**
34+
* @ORM\Column(type="string", length=50)
35+
*/
36+
private string $eventType;
37+
38+
public function __construct(string $loUri, string $eventType)
39+
{
40+
$this->loUri = $loUri;
41+
$this->eventType = $eventType;
42+
}
43+
44+
public function getId(): ?int
45+
{
46+
return $this->id;
47+
}
48+
49+
public function getLoUri(): string
50+
{
51+
return $this->loUri;
52+
}
53+
54+
public function getEventType(): string
55+
{
56+
return $this->eventType;
57+
}
58+
}

0 commit comments

Comments
 (0)