Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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 ###
5 changes: 5 additions & 0 deletions .env.local.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,24 @@ 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

_instanceof:
App\Validator\Format\DeckFormatValidatorInterface:
tags: ['app.deck_format_validator']
App\Client\CardDataProviderInterface:
tags: ['app.card_data_provider']

when@prod:
services:
Expand Down
13 changes: 13 additions & 0 deletions config/services_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)%'
7 changes: 6 additions & 1 deletion src/Client/AlteredCoreClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,6 +15,11 @@ public function __construct(
) {
}

public function getName(): string
{
return 'altered_core';
}

public function getBaseUrl(): string
{
return $this->alteredCoreUrl;
Expand Down
30 changes: 30 additions & 0 deletions src/Client/CardDataProviderFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Client;

class CardDataProviderFactory
{
/** @var array<string, CardDataProviderInterface> */
private array $providers = [];

/**
* @param iterable<CardDataProviderInterface> $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];
}
}
27 changes: 27 additions & 0 deletions src/Client/CardDataProviderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\Client;

interface CardDataProviderInterface
{
/**
* The provider key used to select this implementation via CARD_DATA_PROVIDER (e.g. "altered_core").
*/
public function getName(): string;

/**
* Fetch card data for a list of references.
*
* @param string[] $references
*
* @return array<string, array> reference => card data
*/
public function getCardsByReferences(array $references, string $locale = 'fr'): array;

/**
* Fetch card data for a single reference.
*
* @return array<string, mixed>
*/
public function getCardByReferences(string $reference, string $locale = 'en'): array;
}
116 changes: 116 additions & 0 deletions src/Client/RustCardsApiProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

namespace App\Client;

use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* Card data provider backed by taum/rust-cards-api (https://github.com/taum/rust-cards-api).
*
* Scaffolding only: this API's `GET /api/v2/card/{reference}` response only overlaps
* partially with altered-core's shape (see AlteredCoreClient). Fields consumed elsewhere
* in the app but not present here — imagePath, isBanned, isSuspended, cardType, rarity,
* effect1/effect2/effect3, echoEffect1, artists[] — are left as TODO below rather than
* guessed at, since getting them wrong silently breaks format validation and hero
* detection. There is also no batch-by-references endpoint, so getCardsByReferences()
* issues one request per reference.
*/
class RustCardsApiProvider implements CardDataProviderInterface
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly CacheInterface $cache,
private readonly string $rustCardsApiUrl,
) {
}

public function getName(): string
{
return 'rust_cards_api';
}

/**
* @param string[] $references
*
* @return array<string, array>
*/
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<string, mixed> $raw
*
* @return array<string, mixed>
*/
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,
];
}
}
6 changes: 3 additions & 3 deletions src/Command/CheckDeckSetLegalityCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
) {
Expand Down Expand Up @@ -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(),
Expand Down
6 changes: 3 additions & 3 deletions src/Controller/BgaDeckController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
}

Expand Down Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions src/Serializer/DeckNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
}
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 4 additions & 4 deletions src/State/DeckStateProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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,
]);
Expand Down
Loading
Loading