diff --git a/.env b/.env index c76aae0..87e0b41 100644 --- a/.env +++ b/.env @@ -48,3 +48,10 @@ DEV_AUTH_ENABLED=false ###> altered-core ### ALTERED_CORE_URL=http://localhost:41309 ###< altered-core ### + +###> card-data-provider ### +# Selects the active App\Client\CardDataProviderInterface implementation (see CardDataProviderFactory). +# Available: altered_core, rust_cards_api +CARD_DATA_PROVIDER=altered_core +RUST_CARDS_API_URL=https://taum.github.io/rust-cards-api +###< card-data-provider ### diff --git a/.env.local.dist b/.env.local.dist index 5a64970..e76c2d5 100644 --- a/.env.local.dist +++ b/.env.local.dist @@ -5,6 +5,11 @@ # Altered Core API — shared dev instance ALTERED_CORE_URL=https://cards.alteredcore.org +# Card data provider — which App\Client\CardDataProviderInterface implementation is active. +# Available: altered_core, rust_cards_api (scaffolding only, see RustCardsApiProvider). +CARD_DATA_PROVIDER=altered_core +RUST_CARDS_API_URL=https://taum.github.io/rust-cards-api + # Dev auth bypass — keeps Keycloak out of local dev entirely. # WARNING: MUST be false in staging and production. # Set to true only for local development without Keycloak. diff --git a/.env.test b/.env.test index af69e87..c24008a 100644 --- a/.env.test +++ b/.env.test @@ -3,6 +3,8 @@ KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st_extended_for_hs256_tests' DEV_AUTH_ENABLED=true ALTERED_CORE_URL=http://altered-core.mock +CARD_DATA_PROVIDER=altered_core +RUST_CARDS_API_URL=http://rust-cards-api.mock KEYCLOAK_BASE_URL=http://keycloak.mock KEYCLOAK_REALM=test KEYCLOAK_CLIENT_ID=test diff --git a/config/services.yaml b/config/services.yaml index 48b9f2c..ac989a4 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -49,6 +49,15 @@ services: arguments: $alteredCoreUrl: '%env(ALTERED_CORE_URL)%' + App\Client\RustCardsApiProvider: + arguments: + $rustCardsApiUrl: '%env(RUST_CARDS_API_URL)%' + + App\Client\CardDataProviderFactory: + arguments: + $providers: !tagged_iterator app.card_data_provider + $activeProvider: '%env(CARD_DATA_PROVIDER)%' + App\Validator\Format\DeckFormatValidatorFactory: arguments: - !tagged_iterator app.deck_format_validator @@ -56,6 +65,8 @@ services: _instanceof: App\Validator\Format\DeckFormatValidatorInterface: tags: ['app.deck_format_validator'] + App\Client\CardDataProviderInterface: + tags: ['app.card_data_provider'] when@prod: services: diff --git a/config/services_test.yaml b/config/services_test.yaml index 8973d35..2db746c 100644 --- a/config/services_test.yaml +++ b/config/services_test.yaml @@ -11,3 +11,16 @@ services: arguments: $httpClient: '@altered_core.mock_http_client' $alteredCoreUrl: '%env(ALTERED_CORE_URL)%' + tags: ['app.card_data_provider'] + + App\Client\RustCardsApiProvider: + autowire: true + arguments: + $httpClient: '@altered_core.mock_http_client' + $rustCardsApiUrl: '%env(RUST_CARDS_API_URL)%' + tags: ['app.card_data_provider'] + + App\Client\CardDataProviderFactory: + arguments: + $providers: !tagged_iterator app.card_data_provider + $activeProvider: '%env(CARD_DATA_PROVIDER)%' diff --git a/src/Client/AlteredCoreClient.php b/src/Client/AlteredCoreClient.php index 86a176f..b9f0ecf 100644 --- a/src/Client/AlteredCoreClient.php +++ b/src/Client/AlteredCoreClient.php @@ -6,7 +6,7 @@ use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; -class AlteredCoreClient +class AlteredCoreClient implements CardDataProviderInterface { public function __construct( private readonly HttpClientInterface $httpClient, @@ -15,6 +15,11 @@ public function __construct( ) { } + public function getName(): string + { + return 'altered_core'; + } + public function getBaseUrl(): string { return $this->alteredCoreUrl; diff --git a/src/Client/CardDataProviderFactory.php b/src/Client/CardDataProviderFactory.php new file mode 100644 index 0000000..11b8b2e --- /dev/null +++ b/src/Client/CardDataProviderFactory.php @@ -0,0 +1,30 @@ + */ + private array $providers = []; + + /** + * @param iterable $providers + */ + public function __construct( + iterable $providers, + private readonly string $activeProvider, + ) { + foreach ($providers as $provider) { + $this->providers[$provider->getName()] = $provider; + } + } + + public function getProvider(): CardDataProviderInterface + { + if (!isset($this->providers[$this->activeProvider])) { + throw new \InvalidArgumentException(sprintf('No card data provider found for "%s". Available: %s', $this->activeProvider, implode(', ', array_keys($this->providers)))); + } + + return $this->providers[$this->activeProvider]; + } +} diff --git a/src/Client/CardDataProviderInterface.php b/src/Client/CardDataProviderInterface.php new file mode 100644 index 0000000..6137fc6 --- /dev/null +++ b/src/Client/CardDataProviderInterface.php @@ -0,0 +1,27 @@ + reference => card data + */ + public function getCardsByReferences(array $references, string $locale = 'fr'): array; + + /** + * Fetch card data for a single reference. + * + * @return array + */ + public function getCardByReferences(string $reference, string $locale = 'en'): array; +} diff --git a/src/Client/RustCardsApiProvider.php b/src/Client/RustCardsApiProvider.php new file mode 100644 index 0000000..f73e78b --- /dev/null +++ b/src/Client/RustCardsApiProvider.php @@ -0,0 +1,116 @@ + + */ + public function getCardsByReferences(array $references, string $locale = 'fr'): array + { + $result = []; + + foreach ($references as $reference) { + $card = $this->getCardByReferences($reference, $locale); + if (!empty($card)) { + $result[$reference] = $card; + } + } + + return $result; + } + + public function getCardByReferences(string $reference, string $locale = 'en'): array + { + $cacheKey = 'rust_card_'.md5($reference.'_'.$locale); + $cached = $this->cache->get($cacheKey, function (ItemInterface $item) { + $item->expiresAfter(3600); + + return null; // sentinel: missing from cache, will be fetched + }); + + if (null !== $cached) { // @phpstan-ignore notIdentical.alwaysFalse + return $cached; + } + + $response = $this->httpClient->request('GET', $this->rustCardsApiUrl.'/api/v2/card/'.$reference); + $card = $this->normalize($response->toArray()); + + $this->cache->delete($cacheKey); + $this->cache->get($cacheKey, function (ItemInterface $item) use ($card) { + $item->expiresAfter(3600); + + return $card; + }); + + return $card; + } + + /** + * Maps the raw rust-cards-api response onto the field names consumers already + * expect from AlteredCoreClient. Only fields directly present in the source + * response are mapped — nothing here is inferred or guessed. + * + * @param array $raw + * + * @return array + */ + private function normalize(array $raw): array + { + return [ + 'reference' => $raw['reference'] ?? null, + 'name' => $raw['name'] ?? null, + 'faction' => $raw['faction'] ?? null, + 'mainCost' => $raw['mainCost'] ?? null, + 'recallCost' => $raw['recallCost'] ?? null, + 'forestPower' => $raw['forestPower'] ?? null, + 'mountainPower' => $raw['mountainPower'] ?? null, + 'oceanPower' => $raw['oceanPower'] ?? null, + 'cardSubTypes' => $raw['cardSubTypes'] ?? null, + 'set' => $raw['set'] ?? null, + + // TODO: not provided by rust-cards-api — needs a decision before this + // provider can be used for format validation, hero detection or display. + 'imagePath' => null, + 'isBanned' => null, + 'isSuspended' => null, + 'cardType' => null, + 'rarity' => null, + 'artists' => null, + 'effect1' => null, + 'effect2' => null, + 'effect3' => null, + 'echoEffect1' => null, + ]; + } +} diff --git a/src/Command/CheckDeckSetLegalityCommand.php b/src/Command/CheckDeckSetLegalityCommand.php index e9f2252..a2b7b8f 100644 --- a/src/Command/CheckDeckSetLegalityCommand.php +++ b/src/Command/CheckDeckSetLegalityCommand.php @@ -2,7 +2,7 @@ namespace App\Command; -use App\Client\AlteredCoreClient; +use App\Client\CardDataProviderFactory; use App\Entity\Deck; use App\Entity\DeckCard; use App\Repository\DeckRepository; @@ -30,7 +30,7 @@ final class CheckDeckSetLegalityCommand extends Command public function __construct( private readonly DeckRepository $deckRepository, private readonly DeckFormatValidatorFactory $validatorFactory, - private readonly AlteredCoreClient $alteredCoreClient, + private readonly CardDataProviderFactory $cardDataProviderFactory, private readonly EntityManagerInterface $em, private readonly LoggerInterface $logger, ) { @@ -198,7 +198,7 @@ private function fetchCardsData(Deck $deck): array } try { - return $this->alteredCoreClient->getCardsByReferences($references); + return $this->cardDataProviderFactory->getProvider()->getCardsByReferences($references); } catch (\Throwable $e) { $this->logger->warning('CheckDeckSetLegality: could not fetch cards for deck {id}', [ 'id' => $deck->getId(), diff --git a/src/Controller/BgaDeckController.php b/src/Controller/BgaDeckController.php index 6c42c31..45dc434 100644 --- a/src/Controller/BgaDeckController.php +++ b/src/Controller/BgaDeckController.php @@ -2,7 +2,7 @@ namespace App\Controller; -use App\Client\AlteredCoreClient; +use App\Client\CardDataProviderFactory; use App\Entity\Deck; use App\Entity\User; use App\Repository\DeckRepository; @@ -22,7 +22,7 @@ public function __construct( private readonly DeckRepository $deckRepository, private readonly Security $security, private readonly BgaDeckSerializer $bgaDeckSerializer, - private readonly AlteredCoreClient $alteredCoreClient, + private readonly CardDataProviderFactory $cardDataProviderFactory, ) { } @@ -122,7 +122,7 @@ public function item(string $id): JsonResponse )] public function card(string $reference): JsonResponse { - $card = $this->alteredCoreClient->getCardByReferences($reference); + $card = $this->cardDataProviderFactory->getProvider()->getCardByReferences($reference); if (empty($card)) { throw new NotFoundHttpException(); diff --git a/src/Serializer/DeckNormalizer.php b/src/Serializer/DeckNormalizer.php index c840e54..c293625 100644 --- a/src/Serializer/DeckNormalizer.php +++ b/src/Serializer/DeckNormalizer.php @@ -2,7 +2,7 @@ namespace App\Serializer; -use App\Client\AlteredCoreClient; +use App\Client\CardDataProviderFactory; use App\Entity\Deck; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; @@ -16,7 +16,7 @@ class DeckNormalizer implements NormalizerInterface, NormalizerAwareInterface private const ALREADY_CALLED = 'DECK_NORMALIZER_ALREADY_CALLED'; public function __construct( - private readonly AlteredCoreClient $alteredCoreClient, + private readonly CardDataProviderFactory $cardDataProviderFactory, private readonly RequestStack $requestStack, ) { } @@ -59,7 +59,7 @@ public function normalize(mixed $object, ?string $format = null, array $context } $references = array_column($data['deckCards'], 'cardReference'); - $cardsData = $this->alteredCoreClient->getCardsByReferences($references, $locale); + $cardsData = $this->cardDataProviderFactory->getProvider()->getCardsByReferences($references, $locale); if ('bga' === $view) { return $this->normalizeBga($data, $cardsData, $locale); diff --git a/src/State/DeckStateProcessor.php b/src/State/DeckStateProcessor.php index bc65ac3..d61e205 100644 --- a/src/State/DeckStateProcessor.php +++ b/src/State/DeckStateProcessor.php @@ -4,7 +4,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; -use App\Client\AlteredCoreClient; +use App\Client\CardDataProviderFactory; use App\Entity\Deck; use App\Entity\DeckCard; use App\Entity\User; @@ -20,7 +20,7 @@ class DeckStateProcessor implements ProcessorInterface public function __construct( private readonly EntityManagerInterface $em, private readonly Security $security, - private readonly AlteredCoreClient $alteredCoreClient, + private readonly CardDataProviderFactory $cardDataProviderFactory, private readonly DeckFormatValidatorFactory $validatorFactory, private readonly RequestStack $requestStack, private readonly LoggerInterface $logger, @@ -105,9 +105,9 @@ private function fetchCardsData(Deck $deck): ?array $locale = $this->requestStack->getCurrentRequest()?->query->get('locale', 'fr') ?? 'fr'; try { - return $this->alteredCoreClient->getCardsByReferences($references, $locale); + return $this->cardDataProviderFactory->getProvider()->getCardsByReferences($references, $locale); } catch (\Throwable $e) { - $this->logger->error('AlteredCoreClient::getCardsByReferences failed', [ + $this->logger->error('CardDataProvider::getCardsByReferences failed', [ 'error' => $e->getMessage(), 'references' => $references, ]); diff --git a/tests/Serializer/DeckNormalizerBgaTest.php b/tests/Serializer/DeckNormalizerBgaTest.php index fa21db8..f920df1 100644 --- a/tests/Serializer/DeckNormalizerBgaTest.php +++ b/tests/Serializer/DeckNormalizerBgaTest.php @@ -2,7 +2,8 @@ namespace App\Tests\Serializer; -use App\Client\AlteredCoreClient; +use App\Client\CardDataProviderFactory; +use App\Client\CardDataProviderInterface; use App\Entity\Deck; use App\Serializer\DeckNormalizer; use PHPUnit\Framework\TestCase; @@ -18,17 +19,20 @@ class DeckNormalizerBgaTest extends TestCase private DeckNormalizer $normalizer; private NormalizerInterface $inner; - private AlteredCoreClient $coreClient; + private CardDataProviderInterface $coreClient; protected function setUp(): void { $this->inner = $this->createStub(NormalizerInterface::class); - $this->coreClient = $this->createStub(AlteredCoreClient::class); + $this->coreClient = $this->createStub(CardDataProviderInterface::class); + $this->coreClient->method('getName')->willReturn('stub'); $requestStack = $this->createStub(RequestStack::class); $requestStack->method('getCurrentRequest')->willReturn(Request::create('/')); - $this->normalizer = new DeckNormalizer($this->coreClient, $requestStack); + $cardDataProviderFactory = new CardDataProviderFactory([$this->coreClient], 'stub'); + + $this->normalizer = new DeckNormalizer($cardDataProviderFactory, $requestStack); $this->normalizer->setNormalizer($this->inner); }