Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 118
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
OFFClient
0.00% covered (danger)
0.00%
0 / 118
0.00% covered (danger)
0.00%
0 / 10
1560
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 fetchProduct
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 mapToFields
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
12
 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
 firstNonEmptyLocalized
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 normalizeLang
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 firstNonEmptyString
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 parseAllergens
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 mapNutrition
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices\Barcode;
6
7/**
8 * Open Food Facts product lookup by GTIN/EAN.
9 */
10class OFFClient
11{
12    private readonly string $baseUrl;
13    private readonly string $userAgent;
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->baseUrl = $this->envString('BUILD_OPENFOODFACTS_BASE_URL', 'https://world.openfoodfacts.org');
24        $this->userAgent = $this->envString(
25            'BUILD_OPENFOODFACTS_USER_AGENT',
26            'UnpispasPOS/4.0 (barcode-discovery; contact@unpispas.com)'
27        );
28        $this->timeoutSeconds = max(1, (int) $this->envString('BUILD_BARCODE_DISCOVERY_TIMEOUT', '7'));
29        $this->httpFetcher = $httpFetcher ?? [$this, 'fetchUrl'];
30    }
31
32    /**
33     * @return array<string, mixed>|null Parsed OFF product payload or null when not found / error.
34     */
35    public function fetchProduct(string $barcode): ?array
36    {
37        $url = rtrim($this->baseUrl, '/').'/api/v2/product/'.rawurlencode($barcode).'.json';
38        $context = [
39            'http' => [
40                'method' => 'GET',
41                'header' => "User-Agent: {$this->userAgent}\r\nAccept: application/json\r\n",
42                'timeout' => $this->timeoutSeconds,
43                'ignore_errors' => true,
44            ],
45        ];
46
47        $json = ($this->httpFetcher)($url, $context);
48        if (!is_string($json) || $json === '') {
49            return null;
50        }
51
52        $data = json_decode($json, true);
53        if (!is_array($data)) {
54            return null;
55        }
56
57        $status = isset($data['status']) ? (int) $data['status'] : 0;
58        if ($status !== 1) {
59            return null;
60        }
61
62        $product = $data['product'] ?? null;
63        if (!is_array($product)) {
64            return null;
65        }
66
67        return $product;
68    }
69
70    /**
71     * @param array<string, mixed> $product
72     * @return array<string, mixed>
73     */
74    public function mapToFields(array $product): array
75    {
76        $defaultLang = $this->normalizeLang($product['lang'] ?? null);
77
78        [$name, $nameLang] = $this->firstNonEmptyLocalized([
79            [$product['product_name'] ?? null, $defaultLang],
80            [$product['product_name_es'] ?? null, 'ES'],
81            [$product['product_name_en'] ?? null, 'EN'],
82            [$product['generic_name'] ?? null, $defaultLang],
83        ]);
84
85        $brand = $this->firstNonEmptyString([
86            $product['brands'] ?? null,
87        ]);
88
89        [$category] = $this->firstNonEmptyLocalized([
90            [$product['categories'] ?? null, $defaultLang],
91            [$product['categories_tags_en'] ?? null, 'EN'],
92        ]);
93
94        $imageUrl = $this->firstNonEmptyString([
95            $product['image_front_url'] ?? null,
96            $product['image_url'] ?? null,
97        ]);
98
99        [$ingredients, $ingredientsLang] = $this->firstNonEmptyLocalized([
100            [$product['ingredients_text'] ?? null, $defaultLang],
101            [$product['ingredients_text_es'] ?? null, 'ES'],
102            [$product['ingredients_text_en'] ?? null, 'EN'],
103        ]);
104
105        $allergens = $this->parseAllergens($product['allergens_tags'] ?? null);
106
107        $nutrition = null;
108        if (isset($product['nutriments']) && is_array($product['nutriments'])) {
109            $nutrition = $this->mapNutrition($product['nutriments']);
110        }
111
112        return [
113            'name' => $name,
114            'nameLang' => $nameLang,
115            'brand' => $brand,
116            'category' => $category,
117            'imageUrl' => $imageUrl,
118            'ingredients' => $ingredients,
119            'ingredientsLang' => $ingredientsLang,
120            'allergens' => $allergens,
121            'nutrition' => $nutrition,
122        ];
123    }
124
125    /**
126     * @param array<string, mixed> $context
127     */
128    public function fetchUrl(string $url, array $context): ?string
129    {
130        $stream = stream_context_create($context);
131        $body = @file_get_contents($url, false, $stream);
132
133        return is_string($body) ? $body : null;
134    }
135
136    private function envString(string $key, string $default): string
137    {
138        $value = $_ENV[$key] ?? getenv($key);
139        if ($value === false || $value === null || $value === '') {
140            return $default;
141        }
142
143        return (string) $value;
144    }
145
146    /**
147     * @param array<int, array{0: mixed, 1: string|null}> $candidates
148     * @return array{0: string|null, 1: string|null}
149     */
150    private function firstNonEmptyLocalized(array $candidates): array
151    {
152        foreach ($candidates as [$value, $lang]) {
153            if (!is_string($value)) {
154                continue;
155            }
156
157            $trimmed = trim($value);
158            if ($trimmed === '') {
159                continue;
160            }
161
162            return [$trimmed, $this->normalizeLang($lang)];
163        }
164
165        return [null, null];
166    }
167
168    private function normalizeLang(mixed $lang): ?string
169    {
170        if (!is_string($lang)) {
171            return null;
172        }
173
174        $trimmed = trim($lang);
175        if ($trimmed === '') {
176            return null;
177        }
178
179        return strtoupper($trimmed);
180    }
181
182    /**
183     * @param array<int, mixed> $candidates
184     */
185    private function firstNonEmptyString(array $candidates): ?string
186    {
187        foreach ($candidates as $candidate) {
188            if (!is_string($candidate)) {
189                continue;
190            }
191
192            $trimmed = trim($candidate);
193            if ($trimmed !== '') {
194                return $trimmed;
195            }
196        }
197
198        return null;
199    }
200
201    /**
202     * @param mixed $tags
203     * @return array<int, string>|null
204     */
205    private function parseAllergens(mixed $tags): ?array
206    {
207        if (!is_array($tags)) {
208            return null;
209        }
210
211        $out = [];
212        foreach ($tags as $tag) {
213            if (!is_string($tag)) {
214                continue;
215            }
216
217            $label = trim(str_replace('en:', '', $tag));
218            if ($label !== '') {
219                $out[] = $label;
220            }
221        }
222
223        return count($out) > 0 ? $out : null;
224    }
225
226    /**
227     * @param array<string, mixed> $nutriments
228     * @return array<string, mixed>|null
229     */
230    private function mapNutrition(array $nutriments): ?array
231    {
232        $keys = [
233            'energy-kcal_100g',
234            'fat_100g',
235            'carbohydrates_100g',
236            'proteins_100g',
237            'salt_100g',
238            'sugars_100g',
239        ];
240
241        $out = [];
242        foreach ($keys as $key) {
243            if (isset($nutriments[$key]) && is_numeric($nutriments[$key])) {
244                $out[$key] = (float) $nutriments[$key];
245            }
246        }
247
248        return count($out) > 0 ? $out : null;
249    }
250}