Site du proximo, utilisé pour gérer le stock.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

Api.php 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. <?php /** @noinspection ALL */
  2. namespace OpenFoodFacts;
  3. use GuzzleHttp\Client;
  4. use GuzzleHttp\ClientInterface;
  5. use GuzzleHttp\Exception\GuzzleException;
  6. use GuzzleHttp\TransferStats;
  7. use OpenFoodFacts\Exception\BadRequestException;
  8. use OpenFoodFacts\Exception\ProductNotFoundException;
  9. use Psr\Log\LoggerInterface;
  10. use Psr\Log\NullLogger;
  11. use Psr\SimpleCache\CacheInterface;
  12. use Psr\SimpleCache\InvalidArgumentException;
  13. /**
  14. * this class provide [...]
  15. *
  16. * It a fork of the python OpenFoodFact rewrite on PHP 7.2
  17. */
  18. class Api
  19. {
  20. /**
  21. * the httpClient for all http request
  22. * @var ClientInterface
  23. */
  24. private $httpClient;
  25. /**
  26. * this property store the current base of the url
  27. * @var string
  28. */
  29. private $geoUrl = 'https://%s.openfoodfacts.org';
  30. /**
  31. * this property store the current API (it could be : food/beauty/pet )
  32. * @var string
  33. */
  34. private $currentAPI = '';
  35. /**
  36. * This property store the current location for http call
  37. *
  38. * This property could be world for all product or you can specify le country code (cc) and
  39. * language of the interface (lc). If you want filter on french product you can set fr as country code.
  40. * We strongly recommend to use english as language of the interface
  41. *
  42. * @example fr-en
  43. * @link https://en.wiki.openfoodfacts.org/API/Read#Country_code_.28cc.29_and_Language_of_the_interface_.28lc.29
  44. * @var string
  45. */
  46. private $geography = 'world';
  47. /**
  48. * this property store the auth parameter (username and password)
  49. * @var array
  50. */
  51. private $auth = null;
  52. /**
  53. * this property help you to log information
  54. * @var LoggerInterface
  55. */
  56. private $logger = null;
  57. /**
  58. * this constant defines the environments usable by the API
  59. * @var array
  60. */
  61. private const LIST_API = [
  62. 'food' => 'https://%s.openfoodfacts.org',
  63. 'beauty' => 'https://%s.openbeautyfacts.org',
  64. 'pet' => 'https://%s.openpetfoodfacts.org',
  65. 'product' => 'https://%s.openproductsfacts.org',
  66. ];
  67. /**
  68. * This constant defines the facets usable by the API
  69. *
  70. * This variable is used to create the magic functions like "getIngredients" or "getBrands"
  71. * @var array
  72. */
  73. private const FACETS = [
  74. 'additives',
  75. 'allergens',
  76. 'brands',
  77. 'categories',
  78. 'countries',
  79. 'contributors',
  80. 'code',
  81. 'entry_dates',
  82. 'ingredients',
  83. 'label',
  84. 'languages',
  85. 'nutrition_grade',
  86. 'packaging',
  87. 'packaging_codes',
  88. 'purchase_places',
  89. 'photographer',
  90. 'informer',
  91. 'states',
  92. 'stores',
  93. 'traces',
  94. ];
  95. /**
  96. * This constant defines the extensions authorized for the downloading of the data
  97. * @var array
  98. */
  99. private const FILE_TYPE_MAP = [
  100. "mongodb" => "openfoodfacts-mongodbdump.tar.gz",
  101. "csv" => "en.openfoodfacts.org.products.csv",
  102. "rdf" => "en.openfoodfacts.org.products.rdf"
  103. ];
  104. /**
  105. * the constructor of the function
  106. *
  107. * @param string $api the environment to search
  108. * @param string $geography this parameter represent the the country code and the interface of the language
  109. * @param LoggerInterface $logger this parameter define an logger
  110. * @param ClientInterface|null $clientInterface
  111. * @param CacheInterface|null $cacheInterface
  112. */
  113. public function __construct(
  114. string $api = 'food',
  115. string $geography = 'world',
  116. LoggerInterface $logger = null,
  117. ClientInterface $clientInterface = null,
  118. CacheInterface $cacheInterface = null
  119. )
  120. {
  121. $this->cache = $cacheInterface;
  122. $this->logger = $logger ?? new NullLogger();
  123. $this->httpClient = $clientInterface ?? new Client();
  124. $this->geoUrl = sprintf(self::LIST_API[$api], $geography);
  125. $this->geography = $geography;
  126. $this->currentAPI = $api;
  127. }
  128. /**
  129. * This function allows you to perform tests
  130. * The domain is correct and for testing purposes only
  131. */
  132. public function activeTestMode() : void
  133. {
  134. $this->geoUrl = 'https://world.openfoodfacts.net';
  135. $this->authentification('off', 'off');
  136. }
  137. /**
  138. * This function store the authentication parameter
  139. * @param string $username
  140. * @param string $password
  141. */
  142. public function authentification(string $username, string $password) :void
  143. {
  144. $this->auth = [
  145. 'user_id' => $username,
  146. 'password' => $password
  147. ];
  148. }
  149. /**
  150. * It's a magic function, it works only for facets
  151. * @param string $name The name of the function
  152. * @param void $arguments not use yet (probably needed for ingredients)
  153. * @return Collection The list of all documents found
  154. * @throws InvalidArgumentException
  155. * @throws BadRequestException
  156. * @example getIngredients()
  157. */
  158. public function __call(string $name, $arguments) : Collection
  159. {
  160. //TODO : test with argument for ingredient
  161. if (strpos($name, 'get') === 0) {
  162. $facet = strtolower(substr($name, 3));
  163. //TODO: what about PSR-12, e.g.: getNutritionGrade() ?
  164. if (!in_array($facet, self::FACETS)) {
  165. throw new BadRequestException('Facet "' . $facet . '" not found');
  166. }
  167. if ($facet === "purchase_places") {
  168. $facet = "purchase-places";
  169. } elseif ($facet === "packaging_codes") {
  170. $facet = "packager-codes";
  171. } elseif ($facet === "entry_dates") {
  172. $facet = "entry-dates";
  173. }
  174. $url = $this->buildUrl(null, $facet, []);
  175. $result = $this->fetch($url);
  176. if ($facet !== 'ingredients') {
  177. $result = [
  178. 'products' => $result['tags'],
  179. 'count' => $result['count'],
  180. 'page' => 1,
  181. 'skip' => 0,
  182. 'page_size' => $result['count'],
  183. ];
  184. }
  185. return new Collection($result, $this->currentAPI);
  186. }
  187. throw new BadRequestException('Call to undefined method '.__CLASS__.'::'.$name.'()');
  188. }
  189. /**
  190. * this function search an Document by barcode
  191. * @param string $barcode the barcode [\d]{13}
  192. * @return Document A Document if found
  193. * @throws InvalidArgumentException
  194. * @throws ProductNotFoundException
  195. * @throws BadRequestException
  196. */
  197. public function getProduct(string $barcode) : Document
  198. {
  199. $url = $this->buildUrl('api', 'product', $barcode);
  200. $rawResult = $this->fetch($url);
  201. if ($rawResult['status'] === 0) {
  202. //TODO: maybe return null here? (just throw an exception if something really went wrong?
  203. throw new ProductNotFoundException("Product not found", 1);
  204. }
  205. return Document::createSpecificDocument($this->currentAPI, $rawResult['product']);
  206. }
  207. /**
  208. * This function return a Collection of Document search by facets
  209. * @param array $query list of facets with value
  210. * @param integer $page Number of the page
  211. * @return Collection The list of all documents found
  212. * @throws InvalidArgumentException
  213. * @throws BadRequestException
  214. */
  215. public function getByFacets(array $query = [], int $page = 1) : Collection
  216. {
  217. if (empty($query)) {
  218. return new Collection();
  219. }
  220. $search = [];
  221. ksort($query);
  222. foreach ($query as $key => $value) {
  223. $search[] = $key;
  224. $search[] = $value;
  225. }
  226. $url = $this->buildUrl(null, $search, $page);
  227. $result = $this->fetch($url);
  228. return new Collection($result, $this->currentAPI);
  229. }
  230. /**
  231. * this function help you to add a new product (or update ??)
  232. * @param array $postData The post data
  233. * @return bool|string bool if the product has been added or the error message
  234. * @throws BadRequestException
  235. * @throws InvalidArgumentException
  236. */
  237. public function addNewProduct(array $postData)
  238. {
  239. if (!isset($postData['code']) || !isset($postData['product_name'])) {
  240. throw new BadRequestException('code or product_name not found!');
  241. }
  242. $url = $this->buildUrl('cgi', 'product_jqm2.pl', []);
  243. $result = $this->fetchPost($url, $postData);
  244. if ($result['status_verbose'] === 'fields saved' && $result['status'] === 1) {
  245. return true;
  246. }
  247. return $result['status_verbose'];
  248. }
  249. /**
  250. * [uploadImage description]
  251. * @param string $code the barcode of the product
  252. * @param string $imageField th name of the image
  253. * @param string $imagePath the path of the image
  254. * @return array the http post response (cast in array)
  255. * @throws BadRequestException
  256. * @throws InvalidArgumentException
  257. */
  258. public function uploadImage(string $code, string $imageField, string $imagePath)
  259. {
  260. //TODO : need test
  261. if ($this->currentAPI !== 'food') {
  262. throw new BadRequestException('not Available yet');
  263. }
  264. if (!in_array($imageField, ["front", "ingredients", "nutrition"])) {
  265. throw new BadRequestException('ImageField not valid!');
  266. }
  267. if (!file_exists($imagePath)) {
  268. throw new BadRequestException('Image not found');
  269. }
  270. $url = $this->buildUrl('cgi', 'product_image_upload.pl', []);
  271. $postData = [
  272. 'code' => $code,
  273. 'imagefield' => $imageField,
  274. 'imgupload_' . $imageField => fopen($imagePath, 'r')
  275. ];
  276. return $this->fetchPost($url, $postData, true);
  277. }
  278. /**
  279. * A search function
  280. * @param string $search a search term (fulltext)
  281. * @param integer $page Number of the page
  282. * @param integer $pageSize The page size
  283. * @param string $sortBy the sort
  284. * @return Collection The list of all documents found
  285. * @throws BadRequestException
  286. * @throws InvalidArgumentException
  287. */
  288. public function search(string $search, int $page = 1, int $pageSize = 20, string $sortBy = 'unique_scans')
  289. {
  290. $parameters = [
  291. 'search_terms' => $search,
  292. 'page' => $page,
  293. 'page_size' => $pageSize,
  294. 'sort_by' => $sortBy,
  295. 'json' => '1',
  296. ];
  297. $url = $this->buildUrl('cgi', 'search.pl', $parameters);
  298. $result = $this->fetch($url, false);
  299. return new Collection($result, $this->currentAPI);
  300. }
  301. /**
  302. * This function download all data from OpenFoodFact
  303. * @param string $filePath the location where you want to put the stream
  304. * @param string $fileType mongodb/csv/rdf
  305. * @return bool return true when download is complete
  306. * @throws BadRequestException
  307. */
  308. public function downloadData(string $filePath, string $fileType = "mongodb")
  309. {
  310. if (!isset(self::FILE_TYPE_MAP[$fileType])) {
  311. $this->logger->warning(
  312. 'OpenFoodFact - fetch - failed - File type not recognized!',
  313. ['fileType' => $fileType, 'availableTypes' => self::FILE_TYPE_MAP]
  314. );
  315. throw new BadRequestException('File type not recognized!');
  316. }
  317. $url = $this->buildUrl('data', self::FILE_TYPE_MAP[$fileType]);
  318. try {
  319. $response = $this->httpClient->request('get', $url, ['sink' => $filePath]);
  320. } catch (GuzzleException $guzzleException) {
  321. $this->logger->warning(sprintf('OpenFoodFact - fetch - failed - GET : %s', $url), ['exception' => $guzzleException]);
  322. $exception = new BadRequestException($guzzleException->getMessage(), $guzzleException->getCode(), $guzzleException);
  323. throw $exception;
  324. }
  325. $this->logger->info('OpenFoodFact - fetch - GET : ' . $url . ' - ' . $response->getStatusCode());
  326. //TODO: validate response here (server may respond with 200 - OK but you might not get valid data as a response)
  327. return $response->getStatusCode() == 200;
  328. }
  329. /**
  330. * This private function do a http request
  331. * @param string $url the url to fetch
  332. * @param boolean $isJsonFile the request must be finish by '.json' ?
  333. * @return array return the result of the request in array format
  334. * @throws InvalidArgumentException
  335. * @throws BadRequestException
  336. */
  337. private function fetch(string $url, bool $isJsonFile = true) : array
  338. {
  339. $url .= ($isJsonFile? '.json' : '');
  340. $realUrl = $url;
  341. $cacheKey = md5($realUrl);
  342. if (!empty($this->cache) && $this->cache->has($cacheKey)) {
  343. $cachedResult = $this->cache->get($cacheKey);
  344. return $cachedResult;
  345. }
  346. $data = [
  347. 'on_stats' => function (TransferStats $stats) use (&$realUrl) {
  348. // this function help to find redirection
  349. // On redirect we lost some parameters like page
  350. $realUrl= (string)$stats->getEffectiveUri();
  351. }
  352. ];
  353. if ($this->auth) {
  354. $data['auth'] = array_values($this->auth);
  355. }
  356. try {
  357. $response = $this->httpClient->request('get', $url, $data);
  358. } catch (GuzzleException $guzzleException) {
  359. $this->logger->warning(sprintf('OpenFoodFact - fetch - failed - GET : %s', $url), ['exception' => $guzzleException]);
  360. //TODO: What to do on a error? - return empty array?
  361. $exception = new BadRequestException($guzzleException->getMessage(), $guzzleException->getCode(), $guzzleException);
  362. throw $exception;
  363. }
  364. if ($realUrl !== $url) {
  365. $this->logger->warning('OpenFoodFact - The url : '. $url . ' has been redirect to ' . $realUrl);
  366. trigger_error('OpenFoodFact - Your request has been redirect');
  367. }
  368. $this->logger->info('OpenFoodFact - fetch - GET : ' . $url . ' - ' . $response->getStatusCode());
  369. $jsonResult = json_decode($response->getBody(), true);
  370. if (!empty($this->cache) && !empty($jsonResult)) {
  371. $this->cache->set($cacheKey, $jsonResult);
  372. }
  373. return $jsonResult;
  374. }
  375. /**
  376. * This function performs the same job of the "fetch" function except the call method and parameters
  377. * @param string $url The url to fetch
  378. * @param array $postData The post data
  379. * @param boolean $isMultipart The data is multipart ?
  380. * @return array return the result of the request in array format
  381. * @throws InvalidArgumentException
  382. * @throws BadRequestException
  383. */
  384. private function fetchPost(string $url, array $postData, bool $isMultipart = false) : array
  385. {
  386. $data = [];
  387. if ($this->auth) {
  388. $data['auth'] = array_values($this->auth);
  389. }
  390. if ($isMultipart) {
  391. foreach ($postData as $key => $value) {
  392. $data['multipart'][] = [
  393. 'name' => $key,
  394. 'contents' => $value
  395. ];
  396. }
  397. } else {
  398. $data['form_params'] = $postData;
  399. }
  400. $cacheKey = md5($url . json_encode($data));
  401. if (!empty($this->cache) && $this->cache->has($cacheKey)) {
  402. return $this->cache->get($cacheKey);
  403. }
  404. try {
  405. $response = $this->httpClient->request('post', $url, $data);
  406. }catch (GuzzleException $guzzleException){
  407. $exception = new BadRequestException($guzzleException->getMessage(), $guzzleException->getCode(), $guzzleException);
  408. throw $exception;
  409. }
  410. $this->logger->info('OpenFoodFact - fetch - GET : ' . $url . ' - ' . $response->getStatusCode());
  411. $jsonResult = json_decode($response->getBody(), true);
  412. if (!empty($this->cache) && !empty($jsonResult)) {
  413. $this->cache->set($cacheKey, $jsonResult);
  414. }
  415. return $jsonResult;
  416. }
  417. /**
  418. * This private function generates an url according to the parameters
  419. * @param string|null $service
  420. * @param string|array|null $resourceType
  421. * @param string|array|null $parameters
  422. * @return string the generated url
  423. */
  424. private function buildUrl(string $service = null, $resourceType = null, $parameters = null) : string
  425. {
  426. $baseUrl = null;
  427. switch ($service) {
  428. case 'api':
  429. $baseUrl = implode('/', [
  430. $this->geoUrl,
  431. $service,
  432. 'v0',
  433. $resourceType,
  434. $parameters
  435. ]);
  436. break;
  437. case 'data':
  438. $baseUrl = implode('/', [
  439. $this->geoUrl,
  440. $service,
  441. $resourceType
  442. ]);
  443. break;
  444. case 'cgi':
  445. $baseUrl = implode('/', [
  446. $this->geoUrl,
  447. $service,
  448. $resourceType
  449. ]);
  450. $baseUrl .= '?' . http_build_query($parameters);
  451. break;
  452. case null:
  453. default:
  454. if (is_array($resourceType)) {
  455. $resourceType = implode('/', $resourceType);
  456. }
  457. if ($resourceType == 'ingredients') {
  458. //need test
  459. $resourceType = implode('/', ["state", "complete", $resourceType]);
  460. $parameters = 1;
  461. }
  462. $baseUrl = implode('/', array_filter([
  463. $this->geoUrl,
  464. $resourceType,
  465. $parameters
  466. ], function ($value) {
  467. return !empty($value);
  468. }));
  469. break;
  470. }
  471. return $baseUrl;
  472. }
  473. }