Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 79
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Gs1Client
0.00% covered (danger)
0.00%
0 / 79
0.00% covered (danger)
0.00%
0 / 6
870
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 verify
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
42
 isValidGtinChecksum
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 fetchRemoteVerification
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
132
 fetchUrl
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 envString
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices\Barcode;
6
7/**
8 * GTIN verification: local checksum plus optional Verified by GS1 HTTP lookup.
9 */
10class Gs1Client
11{
12    private readonly string $apiUrl;
13    private readonly string $apiKey;
14    private readonly int $timeoutSeconds;
15    /** @var callable(string, array<string, mixed>): ?string|null */
16    private $httpFetcher;
17
18    /**
19     * @param callable(string, array<string, mixed>): ?string|null $httpFetcher Optional test double.
20     */
21    public function __construct(?callable $httpFetcher = null)
22    {
23        $this->apiUrl = $this->envString('BUILD_GS1_VERIFY_URL', '');
24        $this->apiKey = $this->envString('BUILD_GS1_API_KEY', '');
25        $this->timeoutSeconds = max(1, (int) $this->envString('BUILD_BARCODE_DISCOVERY_TIMEOUT', '7'));
26        $this->httpFetcher = $httpFetcher ?? [$this, 'fetchUrl'];
27    }
28
29    /**
30     * @return array{gs1Valid: bool|null, gs1Licensee: string|null}
31     */
32    public function verify(string $barcode): array
33    {
34        $checksumValid = $this->isValidGtinChecksum($barcode);
35        if ($checksumValid === null) {
36            return [
37                'gs1Valid' => null,
38                'gs1Licensee' => null,
39            ];
40        }
41
42        if (!$checksumValid) {
43            return [
44                'gs1Valid' => false,
45                'gs1Licensee' => null,
46            ];
47        }
48
49        if ($this->apiUrl === '' || $this->apiKey === '') {
50            return [
51                'gs1Valid' => true,
52                'gs1Licensee' => null,
53            ];
54        }
55
56        $remote = $this->fetchRemoteVerification($barcode);
57        if ($remote === null) {
58            return [
59                'gs1Valid' => true,
60                'gs1Licensee' => null,
61            ];
62        }
63
64        return $remote;
65    }
66
67    /**
68     * @return bool|null True/false for numeric GTIN lengths; null for non-numeric barcodes.
69     */
70    public function isValidGtinChecksum(string $barcode): ?bool
71    {
72        if (!ctype_digit($barcode)) {
73            return null;
74        }
75
76        $length = strlen($barcode);
77        if (!in_array($length, [8, 12, 13, 14], true)) {
78            return null;
79        }
80
81        $digits = str_split($barcode);
82        $checkDigit = (int) array_pop($digits);
83        $sum = 0;
84        $reverse = array_reverse($digits);
85
86        foreach ($reverse as $index => $digit) {
87            $weight = ($index % 2 === 0) ? 3 : 1;
88            $sum += ((int) $digit) * $weight;
89        }
90
91        $computed = (10 - ($sum % 10)) % 10;
92
93        return $computed === $checkDigit;
94    }
95
96    /**
97     * @return array{gs1Valid: bool|null, gs1Licensee: string|null}|null
98     */
99    private function fetchRemoteVerification(string $barcode): ?array
100    {
101        $url = rtrim($this->apiUrl, '/').'/'.rawurlencode($barcode);
102        $context = [
103            'http' => [
104                'method' => 'GET',
105                'header' => "Authorization: Bearer {$this->apiKey}\r\nAccept: application/json\r\n",
106                'timeout' => $this->timeoutSeconds,
107                'ignore_errors' => true,
108            ],
109        ];
110
111        $json = ($this->httpFetcher)($url, $context);
112        if (!is_string($json) || $json === '') {
113            return null;
114        }
115
116        $data = json_decode($json, true);
117        if (!is_array($data)) {
118            return null;
119        }
120
121        $valid = null;
122        if (isset($data['valid'])) {
123            $valid = (bool) $data['valid'];
124        }
125        elseif (isset($data['gs1Valid'])) {
126            $valid = (bool) $data['gs1Valid'];
127        }
128        elseif (isset($data['found'])) {
129            $valid = (bool) $data['found'];
130        }
131
132        $licensee = null;
133        foreach (['licensee', 'gs1Licensee', 'companyName', 'brandOwnerName'] as $field) {
134            if (isset($data[$field]) && is_string($data[$field]) && trim($data[$field]) !== '') {
135                $licensee = trim($data[$field]);
136                break;
137            }
138        }
139
140        return [
141            'gs1Valid' => $valid,
142            'gs1Licensee' => $licensee,
143        ];
144    }
145
146    /**
147     * @param array<string, mixed> $context
148     */
149    public function fetchUrl(string $url, array $context): ?string
150    {
151        $stream = stream_context_create($context);
152        $body = @file_get_contents($url, false, $stream);
153
154        return is_string($body) ? $body : null;
155    }
156
157    private function envString(string $key, string $default): string
158    {
159        $value = $_ENV[$key] ?? getenv($key);
160        if ($value === false || $value === null || $value === '') {
161            return $default;
162        }
163
164        return (string) $value;
165    }
166}