Multi-Market Implementation: Phase 5 - API Integration¶
Status: Planning¶
Dependencies¶
Requires completed: - Phase 1 (Entities) - Multi-market entities exist - Phase 2 (Migration) - Fields added to existing entities - Phase 3 (Services) - BuyingOptionService, AffiliateUrlService - Phase 4 (Cache/Repository) - Repository methods, cache invalidation
Blocks: - Phase 6 (EasyAdmin) - Admin interface uses same DTOs - Phase 7 (Matrix UI) - Optional bean-centric UI
📋 Todo Checklist¶
- [ ] Refactor EntityToDtoMapper to use BuyingOptionService
- [ ] Remove selectCrawlConfig() from coffee bean mapping
- [ ] Update roaster DTO mapping to use markets
- [ ] Verify all API endpoints work with new logic
- [ ] Test backward compatibility (no breaking changes)
- [ ] Update API documentation
- [ ] Test performance (caching, N+1 prevention)
- [ ] Test with various visitor countries
🔍 Analysis & Investigation¶
Problem Statement¶
The current API uses EntityToDtoMapper::selectCrawlConfig() to pick URLs based on visitor country. This needs to be replaced with the multi-market URL resolution system that includes:
- Manual overrides (BuyingOption)
- Priority-based selection
- Affiliate transformation
- Market-based availability
Current Architecture¶
EntityToDtoMapper (lines 218-267 in current implementation):
- Uses selectCrawlConfig() to pick RoasterCrawlConfig by visitor country
- Finds matching CrawlUrl for that config
- Returns raw URL without affiliate transformation
- Uses global CoffeeBean.available field
API Endpoints:
- GET /api/coffee-beans - List endpoint
- GET /api/coffee-beans/{id} - Detail endpoint
- GET /api/coffee-beans/by-slug/{slug} - Slug lookup
- GET /api/coffee-beans/{id}/similar - Similar beans
All endpoints accept visitorCountryId query parameter.
Target Architecture¶
EntityToDtoMapper:
- Inject BuyingOptionService and CountryRepository
- Delegate URL resolution to BuyingOptionService::getUrlForVisitor()
- Remove selectCrawlConfig() for coffee beans (keep for roasters)
- Calculate availability from CrawlUrl.available based on visitor country
API Endpoints: - No changes to endpoint signatures - Backward compatible behavior - Same DTOs, different internal logic
📝 Implementation Plan¶
Prerequisites¶
- Phase 1-4 completed
- All services and repository methods implemented
- Cache invalidation working
Step-by-Step Implementation¶
Step 1: Update EntityToDtoMapper Constructor¶
File: src/Service/Api/EntityToDtoMapper.php (modify existing)
Add service dependencies:
<?php
namespace App\Service\Api;
use App\Repository\CountryRepository;
use App\Service\Market\BuyingOptionService;
// ... existing imports
final class EntityToDtoMapper
{
public function __construct(
// ... existing dependencies
private readonly BuyingOptionService $buyingOptionService,
private readonly CountryRepository $countryRepository,
) {}
// ... methods
}
Step 2: Refactor mapCoffeeBeanToDto()¶
File: src/Service/Api/EntityToDtoMapper.php
Replace URL resolution logic:
/**
* Map CoffeeBean entity to DTO.
*
* @param CoffeeBean $coffeeBean Entity to map (should have crawlUrls loaded with joins)
* @param string|null $visitorCountryId Visitor's country code (2-letter, e.g., 'DE')
* @return CoffeeBeanDTO Mapped DTO with URL and availability
*/
public function mapCoffeeBeanToDto(
CoffeeBean $coffeeBean,
?string $visitorCountryId = null
): CoffeeBeanDTO {
// ... existing field mapping (name, roaster, origin, variety, etc.) ...
// NEW: URL resolution with affiliate transformation
$url = null;
$available = false;
if ($visitorCountryId !== null) {
// Resolve URL for specific country
$visitorCountry = $this->countryRepository->find($visitorCountryId);
if ($visitorCountry) {
$url = $this->buyingOptionService->getUrlForVisitor(
$coffeeBean,
$visitorCountry
);
$available = ($url !== null); // URL exists = available in this country
}
} else {
// No country filter: check if available in ANY market
$available = $this->buyingOptionService->isAvailableInAnyMarket($coffeeBean);
// url remains null (no country = no specific URL)
}
return new CoffeeBeanDTO(
id: $coffeeBean->getId(),
name: $coffeeBean->getName(),
roaster: $this->mapRoasterToDto($coffeeBean->getRoaster(), $visitorCountryId),
// ... other fields ...
url: $url,
available: $available,
// ... remaining fields ...
);
}
Key changes:
- With visitorCountryId: Returns affiliate-transformed URL from BuyingOptionService
- Without visitorCountryId: Returns null URL, availability based on any market
- Availability is per-country, not global
- URL includes affiliate tracking automatically
Step 3: Remove Old selectCrawlConfig() for Beans¶
File: src/Service/Api/EntityToDtoMapper.php
Remove or comment out the old method:
/**
* DEPRECATED: No longer used for coffee beans (now using BuyingOptionService).
* Kept for roaster DTO mapping only.
*
* @deprecated Use BuyingOptionService::getUrlForVisitor() for coffee beans
*/
private function selectCrawlConfig(
Collection $crawlConfigs,
?string $visitorCountryId = null
): ?RoasterCrawlConfig {
// ... existing implementation (keep for roasters) ...
}
Step 4: Update Roaster DTO Mapping (Keep Simplified Logic)¶
File: src/Service/Api/EntityToDtoMapper.php
Update roaster config selection to use markets:
/**
* Select a RoasterCrawlConfig for a roaster based on visitor country.
*
* Used for roaster DTO mapping (website URLs, not product URLs).
*
* @param Collection<RoasterCrawlConfig> $crawlConfigs Available configs
* @param string|null $visitorCountryId Visitor's country code
* @return RoasterCrawlConfig|null Selected config or null
*/
private function selectCrawlConfig(
Collection $crawlConfigs,
?string $visitorCountryId = null
): ?RoasterCrawlConfig {
$activeConfigs = $crawlConfigs->filter(
fn(RoasterCrawlConfig $config) => $config->isActive()
);
if ($activeConfigs->isEmpty()) {
return null;
}
// With visitor country: find config whose market serves that country
if ($visitorCountryId !== null) {
foreach ($activeConfigs as $config) {
$market = $config->getMarket();
if ($market && $market->isActive()) {
foreach ($market->getCountries() as $country) {
if ($country->getId() === $visitorCountryId) {
return $config;
}
}
}
}
return null; // No config serves this country
}
// Without country: find default config, or fall back to first active
foreach ($activeConfigs as $config) {
if ($config->isDefault()) {
return $config;
}
}
return $activeConfigs->first() ?: null;
}
Notes: - Roasters don't need the full multi-market resolution (no manual overrides, no affiliate transformation) - Simple selection: config whose market serves visitor's country - Falls back to default config if no visitor country provided
Step 5: Verify API Endpoint Controllers¶
Files: src/Controller/Api/* (verify, do not modify)
Verify all endpoints already:
1. Accept visitorCountryId query parameter
2. Pass it to EntityToDtoMapper
3. Return DTOs with url and available fields
Endpoints to verify:
- CoffeeBeanController::list()
- CoffeeBeanController::detail()
- CoffeeBeanController::bySlug()
- CoffeeBeanController::similar()
No changes needed if they already follow this pattern.
Step 6: Update CoffeeBeanDTO Documentation¶
File: src/DTO/CoffeeBeanDTO.php (add docblock)
Document the new behavior:
/**
* Coffee Bean Data Transfer Object
*
* Fields:
* - url: Buying URL for visitor's country (with affiliate tracking), or null if:
* - No visitorCountryId provided
* - Bean not available in visitor's country
* - available: Availability status:
* - With visitorCountryId: true if bean has available CrawlUrl for that country
* - Without visitorCountryId: true if bean has available CrawlUrl in any market
*/
final readonly class CoffeeBeanDTO
{
public function __construct(
public string $id,
public string $name,
public RoasterDTO $roaster,
// ... other fields ...
public ?string $url, // Affiliate-transformed URL or null
public bool $available, // Market-aware availability
// ... remaining fields ...
) {}
}
Step 7: Create API Integration Tests¶
File: tests/Controller/Api/CoffeeBeanControllerTest.php (extend existing)
Add tests for multi-market behavior:
public function testListEndpointWithVisitorCountry(): void
{
// Create test data: bean available in DE market
$bean = $this->createBeanInMarket('DE');
// Request with DE visitor
$response = $this->client->request('GET', '/api/coffee-beans?visitorCountryId=DE');
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertCount(1, $data['items']);
$this->assertTrue($data['items'][0]['available']);
$this->assertNotNull($data['items'][0]['url']);
// Verify URL has affiliate tracking
$this->assertStringContainsString('ref=', $data['items'][0]['url']);
}
public function testListEndpointFiltersUnavailableBeansForCountry(): void
{
// Create bean only available in US market
$bean = $this->createBeanInMarket('US');
// Request with DE visitor
$response = $this->client->request('GET', '/api/coffee-beans?visitorCountryId=DE&available=true');
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertCount(0, $data['items'], 'Bean should not appear for DE visitor');
}
public function testDetailEndpointWithManualOverride(): void
{
// Create bean with manual Amazon override for DE market
$bean = $this->createBeanWithManualOverride('DE', 'https://amazon.de/dp/B08XYZ');
// Request with DE visitor
$response = $this->client->request('GET', '/api/coffee-beans/' . $bean->getId() . '?visitorCountryId=DE');
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertEquals('https://amazon.de/dp/B08XYZ', $data['url']);
$this->assertTrue($data['available']);
}
public function testPriorityBasedSelection(): void
{
// Create bean with two configs for DE:
// - Config A: priority 50
// - Config B: priority 100
$bean = $this->createBeanWithMultipleConfigs([
['country' => 'DE', 'priority' => 50, 'url' => 'https://low-priority.com'],
['country' => 'DE', 'priority' => 100, 'url' => 'https://high-priority.com'],
]);
// Request with DE visitor
$response = $this->client->request('GET', '/api/coffee-beans/' . $bean->getId() . '?visitorCountryId=DE');
$this->assertResponseIsSuccessful();
$data = $response->toArray();
// Should return high-priority URL
$this->assertStringContainsString('high-priority.com', $data['url']);
}
public function testBackwardCompatibilityWithoutCountry(): void
{
// Create bean available in multiple markets
$bean = $this->createBeanInMultipleMarkets(['US', 'DE', 'FR']);
// Request WITHOUT visitorCountryId
$response = $this->client->request('GET', '/api/coffee-beans/' . $bean->getId());
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertNull($data['url'], 'URL should be null without country');
$this->assertTrue($data['available'], 'Should be available (in ANY market)');
}
Step 8: Performance Testing¶
File: tests/Performance/ApiPerformanceTest.php (create new)
Test query performance:
public function testListEndpointQueryCount(): void
{
// Create 100 beans with multiple CrawlUrls, Markets, AffiliatePrograms
$this->createComplexBeanDataset(100);
// Enable query logging
$queryLogger = $this->enableQueryLogging();
// Request list endpoint
$this->client->request('GET', '/api/coffee-beans?visitorCountryId=DE&limit=50');
// Verify acceptable query count (no N+1)
$queries = $queryLogger->getQueries();
$this->assertLessThan(10, count($queries), 'Should not have excessive queries');
}
public function testCachingReducesQueries(): void
{
$bean = $this->createBeanInMarket('DE');
// First request (cache miss)
$queryLogger1 = $this->enableQueryLogging();
$this->client->request('GET', '/api/coffee-beans/' . $bean->getId() . '?visitorCountryId=DE');
$firstRequestQueries = count($queryLogger1->getQueries());
// Second request (cache hit)
$queryLogger2 = $this->enableQueryLogging();
$this->client->request('GET', '/api/coffee-beans/' . $bean->getId() . '?visitorCountryId=DE');
$secondRequestQueries = count($queryLogger2->getQueries());
$this->assertLessThan($firstRequestQueries, $secondRequestQueries, 'Cache should reduce queries');
}
Testing Strategy¶
Unit Tests¶
- EntityToDtoMapper:
- Test
mapCoffeeBeanToDto()with visitor country - Test without visitor country
- Test with manual override
- Test with priority-based selection
-
Test availability calculation
-
Roaster DTO Mapping:
- Test
selectCrawlConfig()with markets - Test fallback to default config
- Test with no matching config
Integration Tests¶
- API Endpoints:
- Test all endpoints with
visitorCountryId - Test filtering by availability
- Test URL includes affiliate tracking
- Test manual overrides
-
Test priority-based selection
-
Backward Compatibility:
- Test endpoints without
visitorCountryId - Verify no breaking changes
-
Test legacy configs (market = NULL)
-
Performance:
- Test query count (no N+1)
- Test caching effectiveness
- Test with large datasets
Manual Testing¶
- Different Countries:
- Test visitor from US, DE, FR, etc.
- Verify correct URLs returned
-
Verify affiliate tracking
-
Edge Cases:
- Bean available in some markets, not others
- Multiple configs with different priorities
- Manual overrides
- Legacy configs (no market)
🎯 Success Criteria¶
- EntityToDtoMapper uses BuyingOptionService for URL resolution
- API endpoints return affiliate-transformed URLs
- Manual overrides (BuyingOption) work correctly
- Priority-based selection works as expected
- Availability is calculated per-country (not global)
- Backward compatibility maintained (no breaking changes)
- All API tests pass
- Performance acceptable (caching, no N+1 queries)
- URLs include correct affiliate tracking parameters
- Legacy mode (market = NULL) still works
Behavioral Changes¶
URL Field¶
Before:
- Raw product URL from selected RoasterCrawlConfig
- No affiliate tracking
- Selected by shipsTo countries only
After: - Affiliate-transformed URL from BuyingOptionService - Includes affiliate tracking parameters - Selected by priority cascade (override → priority → affiliate) - NULL if no visitor country or bean not available
Available Field¶
Before:
- Global CoffeeBean.available field
- Same for all countries
After:
- Per-country availability based on CrawlUrl.available
- With country: true if bean has available CrawlUrl for that market
- Without country: true if bean has available CrawlUrl in any market
No Breaking Changes¶
- API endpoint signatures unchanged
- DTO structure unchanged
- Query parameter names unchanged
- Response format unchanged
- Only internal logic changed
Related Files¶
Files to Modify:¶
src/Service/Api/EntityToDtoMapper.php
Files to Create:¶
tests/Controller/Api/CoffeeBeanControllerTest.php(extend existing)tests/Performance/ApiPerformanceTest.php
Files to Reference (verify, not modify):¶
src/Controller/Api/CoffeeBeanController.phpsrc/DTO/CoffeeBeanDTO.phpsrc/DTO/RoasterDTO.php
Files Used (from previous phases):¶
src/Service/Market/BuyingOptionService.phpsrc/Service/Affiliate/AffiliateUrlService.phpsrc/Repository/CountryRepository.phpsrc/Repository/CoffeeBeanRepository.php
Next Steps¶
After completing this phase: - Proceed to Phase 6 (EasyAdmin) to create admin controllers for managing markets - API ready for production use - Frontend can consume affiliate URLs with tracking - Admins can configure markets and affiliate programs