Skip to content

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)
);