Vehicle Data - Data Sources¶
Overview¶
Vehicle data is aggregated from multiple external sources through a unified adapter pattern. Each adapter normalizes data into a common format.
Adapter Interface¶
interface VehicleDataAdapterInterface
{
/**
* Get all makes from this source
*/
public function getMakes(): array;
/**
* Get models for a make
*/
public function getModels(string $makeExternalId): array;
/**
* Get generations/years for a model
*/
public function getGenerations(string $modelExternalId): array;
/**
* Get variants for a generation
*/
public function getVariants(string $generationExternalId): array;
/**
* Get fitment data for a variant
*/
public function getFitments(string $variantExternalId): VehicleFitment;
/**
* Lookup vehicle by license plate
*/
public function lookupByPlate(string $plate, string $country): ?VehicleIdentification;
/**
* Get source identifier
*/
public function getSourceId(): string;
}
DriveRight Adapter¶
Purpose¶
Dutch vehicle data provider with license plate lookup capability.
Configuration¶
#[ORM\Entity]
class DriveRightConfig
{
#[ORM\Column]
private string $apiKey;
#[ORM\Column]
private string $apiUrl = 'https://api.driveright.nl/v2';
#[ORM\Column]
private int $requestTimeout = 30;
#[ORM\Column]
private bool $cacheEnabled = true;
#[ORM\Column]
private int $cacheTtl = 86400; // 24 hours
}
Implementation¶
class DriveRightAdapter implements VehicleDataAdapterInterface
{
public function __construct(
private DriveRightClient $client,
private CacheInterface $cache,
private LoggerInterface $logger
) {}
public function lookupByPlate(string $plate, string $country): ?VehicleIdentification
{
if ($country !== 'NL') {
return null; // DriveRight only supports NL plates
}
$cacheKey = "driveright_plate_{$plate}";
return $this->cache->get($cacheKey, function() use ($plate) {
try {
$response = $this->client->lookupPlate($plate);
return new VehicleIdentification(
make: $response['merk'],
model: $response['model'],
year: (int) $response['bouwjaar'],
engineCode: $response['motorcode'] ?? null,
fuelType: $this->mapFuelType($response['brandstof']),
power: $response['vermogen_kw'] ?? null,
externalId: $response['voertuig_id']
);
} catch (ApiException $e) {
$this->logger->warning('DriveRight lookup failed', [
'plate' => $plate,
'error' => $e->getMessage()
]);
return null;
}
});
}
public function getFitments(string $variantExternalId): VehicleFitment
{
$data = $this->client->getVehicleFitments($variantExternalId);
return new VehicleFitment(
tyreSizes: $this->mapTyreSizes($data['banden']),
wheelSpecs: $this->mapWheelSpecs($data['velgen']),
originalEquipment: $data['oe_maten'] ?? []
);
}
public function getSourceId(): string
{
return 'driveright';
}
private function mapFuelType(string $dutchFuel): string
{
return match ($dutchFuel) {
'Benzine' => 'petrol',
'Diesel' => 'diesel',
'Elektrisch' => 'electric',
'Hybride' => 'hybrid',
default => 'other',
};
}
}
WheelSize Adapter¶
Purpose¶
Global vehicle database with comprehensive wheel/tyre fitment data.
Configuration¶
#[ORM\Entity]
class WheelSizeConfig
{
#[ORM\Column]
private string $apiKey;
#[ORM\Column]
private string $apiUrl = 'https://api.wheel-size.com/v2';
#[ORM\Column]
private int $requestTimeout = 30;
#[ORM\Column]
private array $enabledRegions = ['EU', 'US']; // Filter by market
}
Implementation¶
class WheelSizeAdapter implements VehicleDataAdapterInterface
{
public function __construct(
private WheelSizeClient $client,
private CacheInterface $cache
) {}
public function getMakes(): array
{
return $this->cache->get('wheelsize_makes', function() {
$response = $this->client->getMakes();
return array_map(
fn($make) => new VehicleMake(
externalId: $make['slug'],
name: $make['name'],
source: 'wheelsize'
),
$response['data']
);
});
}
public function getModels(string $makeExternalId): array
{
$cacheKey = "wheelsize_models_{$makeExternalId}";
return $this->cache->get($cacheKey, function() use ($makeExternalId) {
$response = $this->client->getModels($makeExternalId);
return array_map(
fn($model) => new VehicleModel(
externalId: $model['slug'],
name: $model['name'],
makeExternalId: $makeExternalId,
source: 'wheelsize'
),
$response['data']
);
});
}
public function getFitments(string $variantExternalId): VehicleFitment
{
$data = $this->client->getVehicle($variantExternalId);
$tyreSizes = [];
$wheelSpecs = [];
foreach ($data['wheels'] as $wheel) {
// Front and rear may differ (staggered fitment)
$tyreSizes[] = new TyreSize(
width: $wheel['front']['tire_width'],
aspectRatio: $wheel['front']['tire_aspect_ratio'],
diameter: $wheel['front']['rim_diameter'],
position: 'front'
);
if ($wheel['rear'] && $wheel['rear'] !== $wheel['front']) {
$tyreSizes[] = new TyreSize(
width: $wheel['rear']['tire_width'],
aspectRatio: $wheel['rear']['tire_aspect_ratio'],
diameter: $wheel['rear']['rim_diameter'],
position: 'rear'
);
}
$wheelSpecs[] = new WheelSpec(
diameter: $wheel['front']['rim_diameter'],
width: $wheel['front']['rim_width'],
pcd: $wheel['bolt_pattern'],
centerBore: $wheel['center_bore'],
offsetMin: $wheel['front']['rim_offset'] - 5, // Tolerance
offsetMax: $wheel['front']['rim_offset'] + 10
);
}
return new VehicleFitment(
tyreSizes: $tyreSizes,
wheelSpecs: $wheelSpecs,
originalEquipment: $data['technical']['tire_oe'] ?? []
);
}
public function lookupByPlate(string $plate, string $country): ?VehicleIdentification
{
// WheelSize does not support plate lookup
return null;
}
public function getSourceId(): string
{
return 'wheelsize';
}
}
Data Transfer Objects¶
VehicleIdentification¶
readonly class VehicleIdentification
{
public function __construct(
public string $make,
public string $model,
public int $year,
public ?string $engineCode,
public ?string $fuelType,
public ?int $power,
public string $externalId
) {}
}
VehicleFitment¶
readonly class VehicleFitment
{
public function __construct(
/** @var TyreSize[] */
public array $tyreSizes,
/** @var WheelSpec[] */
public array $wheelSpecs,
public array $originalEquipment
) {}
}
TyreSize¶
readonly class TyreSize
{
public function __construct(
public int $width, // 225
public int $aspectRatio, // 45
public float $diameter, // 17
public string $position = 'both' // 'front', 'rear', 'both'
) {}
public function getCode(): string
{
return "{$this->width}/{$this->aspectRatio}R{$this->diameter}";
}
}
WheelSpec¶
readonly class WheelSpec
{
public function __construct(
public float $diameter, // 17
public float $width, // 7.5
public string $pcd, // "5x112"
public float $centerBore, // 57.1
public int $offsetMin, // ET35
public int $offsetMax // ET50
) {}
}
Aggregation Service¶
class VehicleDataAggregator
{
/** @var VehicleDataAdapterInterface[] */
private array $adapters;
public function __construct(
private DriveRightAdapter $driveRight,
private WheelSizeAdapter $wheelSize,
private ConflictResolver $conflictResolver
) {
$this->adapters = [
$this->driveRight,
$this->wheelSize,
];
}
public function lookupByPlate(string $plate, string $country): ?VehicleIdentification
{
foreach ($this->adapters as $adapter) {
$result = $adapter->lookupByPlate($plate, $country);
if ($result !== null) {
return $result;
}
}
return null;
}
public function getFitments(Vehicle $vehicle): VehicleFitment
{
$fitments = [];
foreach ($this->adapters as $adapter) {
$externalId = $vehicle->getExternalId($adapter->getSourceId());
if ($externalId) {
$fitments[$adapter->getSourceId()] = $adapter->getFitments($externalId);
}
}
// Merge and resolve conflicts
return $this->conflictResolver->mergeFitments($fitments);
}
}
Conflict Resolution¶
class ConflictResolver
{
/**
* Priority order for data sources
*/
private array $priority = ['driveright', 'wheelsize', 'manual'];
public function mergeFitments(array $fitmentsBySource): VehicleFitment
{
$allTyreSizes = [];
$allWheelSpecs = [];
$oeData = [];
// Collect all data, higher priority sources override
foreach ($this->priority as $source) {
if (!isset($fitmentsBySource[$source])) {
continue;
}
$fitment = $fitmentsBySource[$source];
foreach ($fitment->tyreSizes as $size) {
$key = $size->getCode() . '_' . $size->position;
$allTyreSizes[$key] = $size;
}
foreach ($fitment->wheelSpecs as $spec) {
$key = "{$spec->diameter}_{$spec->pcd}";
// Merge offset ranges
if (isset($allWheelSpecs[$key])) {
$allWheelSpecs[$key] = $this->mergeWheelSpec(
$allWheelSpecs[$key],
$spec
);
} else {
$allWheelSpecs[$key] = $spec;
}
}
// OE data from first source that has it
if (empty($oeData) && !empty($fitment->originalEquipment)) {
$oeData = $fitment->originalEquipment;
}
}
return new VehicleFitment(
tyreSizes: array_values($allTyreSizes),
wheelSpecs: array_values($allWheelSpecs),
originalEquipment: $oeData
);
}
private function mergeWheelSpec(WheelSpec $a, WheelSpec $b): WheelSpec
{
return new WheelSpec(
diameter: $a->diameter,
width: $a->width,
pcd: $a->pcd,
centerBore: min($a->centerBore, $b->centerBore), // Smaller is more restrictive
offsetMin: min($a->offsetMin, $b->offsetMin),
offsetMax: max($a->offsetMax, $b->offsetMax)
);
}
}
Gherkin Scenarios¶
Feature: Vehicle Data Sources
As the vehicle data system
I want to aggregate data from multiple sources
So that I have comprehensive vehicle information
Scenario: License plate lookup (NL)
Given a Dutch license plate "AB-123-CD"
When I lookup the vehicle
Then DriveRight adapter should be queried
And vehicle identification should be returned
Scenario: License plate lookup falls through adapters
Given a Belgian license plate "1-ABC-234"
When I lookup the vehicle
Then DriveRight returns null (NL only)
And no vehicle is found (no BE adapter)
Scenario: Fitment data from multiple sources
Given vehicle exists in both DriveRight and WheelSize
When I request fitments
Then data from both sources is merged
And DriveRight data takes priority on conflicts
Scenario: Wheel offset ranges are expanded
Given DriveRight says ET35-ET45
And WheelSize says ET40-ET50
When fitments are merged
Then offset range should be ET35-ET50
Scenario: Cache prevents repeated API calls
Given vehicle was looked up 5 minutes ago
When I lookup the same vehicle
Then cached data should be returned
And no API call should be made
Scenario: API failure gracefully handled
Given DriveRight API is unavailable
When I lookup a license plate
Then null is returned
And warning is logged
And no exception is thrown
Database Schema¶
-- External source configurations
CREATE TABLE vehicle_data_sources (
id INT PRIMARY KEY AUTO_INCREMENT,
source_id VARCHAR(50) NOT NULL UNIQUE,
api_key VARCHAR(255) NOT NULL,
api_url VARCHAR(255) NOT NULL,
config JSON,
is_active BOOLEAN DEFAULT TRUE,
priority INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
-- External ID mappings
CREATE TABLE vehicle_external_ids (
id INT PRIMARY KEY AUTO_INCREMENT,
vehicle_id INT NOT NULL,
source_id VARCHAR(50) NOT NULL,
external_id VARCHAR(100) NOT NULL,
last_synced_at DATETIME,
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id),
UNIQUE KEY (source_id, external_id),
INDEX idx_vehicle (vehicle_id)
);