diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index 318b20dac8..9fcbdf111a 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -34,6 +34,10 @@ export 'src/model/ordered_nutrients.dart'; export 'src/model/origins_of_ingredients.dart'; export 'src/model/owner_field.dart'; export 'src/model/packaging.dart'; +export 'src/model/flexible/flexible_map.dart'; +export 'src/model/flexible/flexible_product.dart'; +export 'src/model/flexible/flexible_product_result.dart'; +export 'src/model/flexible/flexible_search_result.dart'; export 'src/model/parameter/allergens_parameter.dart'; export 'src/model/parameter/barcode_parameter.dart'; export 'src/model/parameter/ingredients_analysis_parameter.dart'; diff --git a/lib/src/model/flexible/flexible_map.dart b/lib/src/model/flexible/flexible_map.dart new file mode 100644 index 0000000000..ad9080b388 --- /dev/null +++ b/lib/src/model/flexible/flexible_map.dart @@ -0,0 +1,11 @@ +import 'package:meta/meta.dart'; + +@experimental +typedef JsonMap = Map; + +@experimental +abstract class FlexibleMap { + const FlexibleMap(this.json); + + final JsonMap json; +} diff --git a/lib/src/model/flexible/flexible_product.dart b/lib/src/model/flexible/flexible_product.dart new file mode 100644 index 0000000000..865e9e63b5 --- /dev/null +++ b/lib/src/model/flexible/flexible_product.dart @@ -0,0 +1,212 @@ +import 'package:meta/meta.dart'; + +import 'flexible_map.dart'; +import '../additives.dart'; +import '../attribute_group.dart'; +import '../ingredient.dart'; +import '../knowledge_panels.dart'; +import '../nutriments.dart'; +import '../product_image.dart'; +import '../product_packaging.dart'; +import '../product_type.dart'; +import '../../utils/json_helper.dart'; +import '../../utils/language_helper.dart'; +import '../../utils/product_fields.dart'; +import '../../utils/uri_helper.dart'; + +/// Product without predefined structure, relying mainly on the json. +/// +/// The typical use case would be "extending" this class in order to control +/// brand new field values. +/// The current field list matches what is used in Smoothie. +@experimental +class FlexibleProduct extends FlexibleMap { + FlexibleProduct(super.json); + + FlexibleProduct.fromServer( + super.json, { + required final UriProductHelper uriHelper, + }) { + json['image_url_base'] = uriHelper.getImageUrlBase(); + } + + // mandatory for images + String get imageUrlBase => json['image_url_base']; + + String? get barcode => json['code']; + + ProductType? get productType => ProductType.fromOffTag(json['product_type']); + + int? get schemaVersion => json['schema_version']; + + String? get abbreviatedName => json['abbreviated_product_name']; + + Additives? get additives => + Additives.additivesFromJson(json['additives_tags'] as List?); + + Iterable? get attributeGroups => + (json['attribute_groups'] as List?) + ?.map(AttributeGroup.fromJson); + + static const String _brandsSeparator = ', '; + + String? get brandsAsString { + final result = json['brands']; + if (result == null) { + return null; + } + if (result is String) { + return result; + } + return (result as List).join(_brandsSeparator); + } + + List? get brandsAsList { + final result = json['brands']; + if (result == null) { + return null; + } + if (result is List) { + return result; + } + return (result as String).split(_brandsSeparator); + } + + String? get categories => json['categories'] as String?; + + Iterable? get categoriesTags => + (json['categories_tags'] as List?)?.map( + (e) => e as String, + ); + + Map>? get categoriesTagsInLanguages => + LanguageHelper.fromJsonStringsListMapWithPrefix( + json, + inProductField: ProductField.CATEGORIES_TAGS_IN_LANGUAGES, + ); + + String? get comparedToCategory => json['compared_to_category'] as String?; + + String? get countries => json['countries'] as String?; + + Map>? get countriesTagsInLanguages => + LanguageHelper.fromJsonStringsListMapWithPrefix( + json, + inProductField: ProductField.COUNTRIES_TAGS_IN_LANGUAGES, + ); + + String? get embCodes => json['emb_codes'] as String?; + + String? get genericName => json['generic_name'] as String?; + + String? get imageFrontUrl => json['image_front_url'] as String?; + + String? get imageFrontSmallUrl => json['image_front_small_url'] as String?; + + String? get imageIngredientsUrl => json['image_ingredients_url'] as String?; + + String? get imageIngredientsSmallUrl => + json['image_ingredients_small_url'] as String?; + + String? get imageNutritionUrl => json['image_nutrition_url'] as String?; + + String? get imageNutritionSmallUrl => + json['image_nutrition_small_url'] as String?; + + String? get imagePackagingUrl => json['image_packaging_url'] as String?; + + String? get imagePackagingSmallUrl => + json['image_packaging_small_url'] as String?; + + List? get images => + JsonHelper.allImagesFromJson(json['images'] as Map?); + + Iterable? get ingredients => + (json['ingredients'] as List?) + ?.map((e) => Ingredient.fromJson(e as Map)); + + String? get ingredientsText => json['ingredients_text'] as String?; + + Map? get ingredientsTextInLanguages => + LanguageHelper.fromJsonStringMapWithPrefix( + json, + inProductField: ProductField.INGREDIENTS_TEXT_IN_LANGUAGES, + allProductField: ProductField.INGREDIENTS_TEXT_ALL_LANGUAGES, + ); + + KnowledgePanels? get knowledgePanels => + KnowledgePanels.fromJsonHelper(json['knowledge_panels'] as Map?); + + String? get labels => json['labels'] as String?; + + Map>? get labelsTagsInLanguages => + LanguageHelper.fromJsonStringsListMapWithPrefix( + json, + inProductField: ProductField.LABELS_TAGS_IN_LANGUAGES, + ); + + OpenFoodFactsLanguage? get lang => + LanguageHelper.fromJson(json['lang'] as String?); + + DateTime? get lastImage => JsonHelper.timestampToDate(json['last_image_t']); + + Iterable? get lastImageDates => + (json['last_image_dates_tags'] as List?) + ?.map((e) => e as String); + + bool? get noNutritionData => + JsonHelper.checkboxFromJSON(json['no_nutrition_data']); + + String? get nutrimentDataPer => json['nutrition_data_per'] as String?; + + Nutriments? get nutriments => noNutritionData == true + ? null + : json['nutriments'] == null + ? null + : Nutriments.fromJson(json['nutriments'] as Map); + + bool? get obsolete => JsonHelper.checkboxFromJSON(json['obsolete']); + + String? get origins => json['origins'] as String?; + + Map? get ownerFields => + (json['owner_fields'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toInt()), + ); + + Iterable? get packagings => + (json['packagings'] as List?)?.map(ProductPackaging.fromJson); + + bool? get packagingsComplete => + JsonHelper.boolFromJSON(json['packagings_complete']); + + Map? get packagingTextInLanguages => + LanguageHelper.fromJsonStringMapWithPrefix( + json, + inProductField: ProductField.PACKAGING_TEXT_IN_LANGUAGES, + allProductField: ProductField.PACKAGING_TEXT_ALL_LANGUAGES, + ); + + String? get productName => json['product_name'] as String?; + + Map? get productNameInLanguages => + LanguageHelper.fromJsonStringMapWithPrefix( + json, + inProductField: ProductField.NAME_IN_LANGUAGES, + allProductField: ProductField.NAME_ALL_LANGUAGES, + ); + + String? get quantity => json['quantity'] as String?; + + List? get selectedImages => + JsonHelper.selectedImagesFromJson(json['selected_images'] as Map?); + + String? get servingSize => json['serving_size'] as String?; + + Iterable? get statesTags => + (json['states_tags'] as List?)?.map((e) => e as String); + + String? get stores => json['stores'] as String?; + + String? get website => json['link'] as String?; +} diff --git a/lib/src/model/flexible/flexible_product_result.dart b/lib/src/model/flexible/flexible_product_result.dart new file mode 100644 index 0000000000..c18589ddc2 --- /dev/null +++ b/lib/src/model/flexible/flexible_product_result.dart @@ -0,0 +1,40 @@ +import 'package:meta/meta.dart'; + +import 'flexible_map.dart'; +import 'flexible_product.dart'; +import '../localized_tag.dart'; +import '../product_result_field_answer.dart'; +import '../product_result_v3.dart'; +import '../../utils/uri_helper.dart'; + +/// API answer from a call to /api/v???/product/$barcode, in a flexible manner. +@experimental +class FlexibleProductResult extends FlexibleMap { + FlexibleProductResult( + super.json, { + required this.uriHelper, + }); + + final UriProductHelper uriHelper; + + String? get barcode => json['code'] as String?; + + LocalizedTag? get result => json['result'] == null + ? null + : LocalizedTag.fromJson(json['result'] as Map); + + String? get status => json['status'] as String?; + + List? get errors => + ProductResultV3.fromJsonListAnswerForField(json['errors']); + + List? get warnings => + ProductResultV3.fromJsonListAnswerForField(json['warnings']); + + FlexibleProduct? get product => json['product'] == null + ? null + : FlexibleProduct.fromServer( + json['product'] as JsonMap, + uriHelper: uriHelper, + ); +} diff --git a/lib/src/model/flexible/flexible_search_result.dart b/lib/src/model/flexible/flexible_search_result.dart new file mode 100644 index 0000000000..bbe74e6871 --- /dev/null +++ b/lib/src/model/flexible/flexible_search_result.dart @@ -0,0 +1,35 @@ +import 'package:meta/meta.dart'; + +import 'flexible_map.dart'; +import 'flexible_product.dart'; +import '../../interface/json_object.dart'; +import '../../utils/uri_helper.dart'; + +/// API answer from a call product search, in a flexible manner. +@experimental +class FlexibleSearchResult extends FlexibleMap { + FlexibleSearchResult( + super.json, { + required this.uriHelper, + }); + + final UriProductHelper uriHelper; + + int? get page => JsonObject.parseInt(json['page']); + + int? get pageSize => JsonObject.parseInt(json['page_size']); + + int? get count => JsonObject.parseInt(json['count']); + + int? get pageCount => JsonObject.parseInt(json['page_count']); + + int? get skip => JsonObject.parseInt(json['skip']); + + Iterable? get products => + (json['products'] as List?)?.map( + (toElement) => FlexibleProduct.fromServer( + toElement, + uriHelper: uriHelper, + ), + ); +} diff --git a/lib/src/model/product_image.dart b/lib/src/model/product_image.dart index 89dc7ff796..52f2b7db6a 100644 --- a/lib/src/model/product_image.dart +++ b/lib/src/model/product_image.dart @@ -1,3 +1,5 @@ +import 'package:meta/meta.dart'; + import '../utils/language_helper.dart'; import '../utils/open_food_api_configuration.dart'; import '../utils/uri_helper.dart'; @@ -191,6 +193,17 @@ class ProductImage { '/' '${getUrlFilename(imageSize: imageSize)}'; + /// Returns the url to display this image, for [FlexibleProduct]. + @experimental + String getFlexibleUrl( + final String barcode, { + final ImageSize? imageSize, + required final String imageUrlBase, + }) => + '$imageUrlBase' + '/${UriProductHelper.getBarcodeSubPath(barcode)}' + '/${getUrlFilename(imageSize: imageSize)}'; + /// Returns just the filename of the url to display this image. /// /// See also [getUrl]. diff --git a/lib/src/model/product_result_v3.dart b/lib/src/model/product_result_v3.dart index 428ccad385..5fcaa87c40 100644 --- a/lib/src/model/product_result_v3.dart +++ b/lib/src/model/product_result_v3.dart @@ -33,13 +33,13 @@ class ProductResultV3 extends JsonObject { /// Errors. /// /// Typically populated if [status] is [statusFailure]. - @JsonKey(includeIfNull: false, fromJson: _fromJsonListAnswerForField) + @JsonKey(includeIfNull: false, fromJson: fromJsonListAnswerForField) List? errors; /// Warnings. /// /// Typically populated if [status] is [statusWarning]. - @JsonKey(includeIfNull: false, fromJson: _fromJsonListAnswerForField) + @JsonKey(includeIfNull: false, fromJson: fromJsonListAnswerForField) List? warnings; @JsonKey(includeIfNull: false) @@ -70,7 +70,7 @@ class ProductResultV3 extends JsonObject { String toString() => toJson().toString(); /// From a `List` in `dynamic`'s clothing (JsonKey) - static List? _fromJsonListAnswerForField( + static List? fromJsonListAnswerForField( dynamic list) { if (list == null) { return null; diff --git a/lib/src/model/product_result_v3.g.dart b/lib/src/model/product_result_v3.g.dart index 54a74184f5..b504bfc3d9 100644 --- a/lib/src/model/product_result_v3.g.dart +++ b/lib/src/model/product_result_v3.g.dart @@ -13,8 +13,8 @@ ProductResultV3 _$ProductResultV3FromJson(Map json) => ? null : LocalizedTag.fromJson(json['result'] as Map) ..status = json['status'] as String? - ..errors = ProductResultV3._fromJsonListAnswerForField(json['errors']) - ..warnings = ProductResultV3._fromJsonListAnswerForField(json['warnings']) + ..errors = ProductResultV3.fromJsonListAnswerForField(json['errors']) + ..warnings = ProductResultV3.fromJsonListAnswerForField(json['warnings']) ..product = json['product'] == null ? null : Product.fromJson(json['product'] as Map); diff --git a/lib/src/open_food_api_client.dart b/lib/src/open_food_api_client.dart index 01e6d3ab17..ba4cf6fde5 100644 --- a/lib/src/open_food_api_client.dart +++ b/lib/src/open_food_api_client.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart'; +import 'package:meta/meta.dart'; import 'interface/json_object.dart'; import 'model/login_status.dart'; @@ -33,6 +34,8 @@ import 'model/taxonomy_packaging_material.dart'; import 'model/taxonomy_packaging_recycling.dart'; import 'model/taxonomy_packaging_shape.dart'; import 'model/user.dart'; +import 'model/flexible/flexible_product_result.dart'; +import 'model/flexible/flexible_search_result.dart'; import 'utils/abstract_query_configuration.dart'; import 'utils/country_helper.dart'; import 'utils/http_helper.dart'; @@ -280,6 +283,22 @@ class OpenFoodAPIClient { return result; } + @experimental + static Future getFlexibleProductResult( + final ProductQueryConfiguration configuration, { + final User? user, + final UriProductHelper uriHelper = uriHelperFoodProd, + }) async { + final Response response = await configuration.getResponse(user, uriHelper); + TooManyRequestsException.check(response); + final String productString = response.body; + final String jsonStr = _replaceQuotes(productString); + return FlexibleProductResult( + HttpHelper().jsonDecode(jsonStr), + uriHelper: uriHelper, + ); + } + /// Returns the response body of "get product" API for the given barcode. static Future getProductString( final ProductQueryConfiguration configuration, { @@ -460,6 +479,21 @@ class OpenFoodAPIClient { return result; } + @experimental + static Future searchFlexibleProducts( + final User? user, + final AbstractQueryConfiguration configuration, { + final UriProductHelper uriHelper = uriHelperFoodProd, + }) async { + final Response response = await configuration.getResponse(user, uriHelper); + TooManyRequestsException.check(response); + final String jsonStr = _replaceQuotes(response.body); + return FlexibleSearchResult( + HttpHelper().jsonDecode(jsonStr), + uriHelper: uriHelper, + ); + } + /// Returns the [ProductFreshness] for all the [barcodes]. static Future> getProductFreshness({ required final List barcodes, diff --git a/lib/src/utils/abstract_query_configuration.dart b/lib/src/utils/abstract_query_configuration.dart index 9525669092..544f43b9c8 100644 --- a/lib/src/utils/abstract_query_configuration.dart +++ b/lib/src/utils/abstract_query_configuration.dart @@ -64,6 +64,13 @@ abstract class AbstractQueryConfiguration { /// List? fields; + /// Additional fields that aren't [ProductField] yet. + /// + /// To be used in experimental code, with new server fields that haven't made + /// it to off-dart yet. + /// Eventually, those fields should be added to [ProductField]. + List? flexibleFields; + List additionalParameters; AbstractQueryConfiguration({ @@ -71,6 +78,7 @@ abstract class AbstractQueryConfiguration { this.languages, this.country, this.fields, + this.flexibleFields, this.additionalParameters = const [], }) { fields ??= [ProductField.ALL]; @@ -117,6 +125,9 @@ abstract class AbstractQueryConfiguration { ); if (!ignoreFieldsFilter) { final fieldsStrings = convertFieldsToStrings(fields!, queryLanguages); + if (flexibleFields != null) { + fieldsStrings.addAll(flexibleFields!); + } result.putIfAbsent('fields', () => fieldsStrings.join(',')); } } diff --git a/lib/src/utils/language_helper.dart b/lib/src/utils/language_helper.dart index 70ef9560ca..8d98fafd72 100644 --- a/lib/src/utils/language_helper.dart +++ b/lib/src/utils/language_helper.dart @@ -1,4 +1,8 @@ +import 'package:meta/meta.dart'; + +import 'product_fields.dart'; import '../model/off_tagged.dart'; +import '../model/flexible/flexible_map.dart'; /// Available languages enum OpenFoodFactsLanguage implements OffTagged { @@ -714,4 +718,76 @@ class LanguageHelper { } return result; } + + @experimental + static Iterable _getLanguageTags( + final JsonMap json, + final String prefix, + ) { + final result = []; + for (final key in json.keys) { + if (!key.startsWith(prefix)) { + continue; + } + final String languageCode = key.substring(prefix.length); + final OpenFoodFactsLanguage? language = + OpenFoodFactsLanguage.fromOffTag(languageCode); + if (language != null) { + result.add(language); + } + } + return result; + } + + @experimental + static Map? fromJsonStringMapWithPrefix( + final JsonMap json, { + final ProductField? inProductField, + final ProductField? allProductField, + }) { + final result = {}; + if (allProductField != null) { + final dynamic translations = + json[allProductField.offTag] as Map?; + if (translations != null) { + for (final MapEntry entry in translations.entries) { + final OpenFoodFactsLanguage? language = + OpenFoodFactsLanguage.fromOffTag(entry.key); + if (language == null) { + continue; + } + result[language] = entry.value as String; + } + } + } + if (inProductField != null) { + final String prefix = inProductField.offTag; + final Iterable languages = _getLanguageTags( + json, + prefix, + ); + for (final language in languages) { + result[language] = json['$prefix${language.offTag}']! as String; + } + } + return result; + } + + @experimental + static Map>? + fromJsonStringsListMapWithPrefix( + final JsonMap json, { + required final ProductField inProductField, + }) { + final result = >{}; + final String prefix = inProductField.offTag; + final Iterable languages = _getLanguageTags( + json, + prefix, + ); + for (final language in languages) { + result[language] = json['$prefix${language.offTag}']!.cast(); + } + return result; + } } diff --git a/lib/src/utils/product_fields.dart b/lib/src/utils/product_fields.dart index d010d7cbb8..7349ee93b0 100644 --- a/lib/src/utils/product_fields.dart +++ b/lib/src/utils/product_fields.dart @@ -5,6 +5,7 @@ import 'language_helper.dart'; enum ProductField implements OffTagged { BARCODE(offTag: 'code'), PRODUCT_TYPE(offTag: 'product_type'), + SCHEMA_VERSION(offTag: 'schema_version'), NAME( offTag: 'product_name', inLanguagesProductField: ProductField.NAME_IN_LANGUAGES, diff --git a/lib/src/utils/product_query_configurations.dart b/lib/src/utils/product_query_configurations.dart index 71248d4215..5ca85d630d 100644 --- a/lib/src/utils/product_query_configurations.dart +++ b/lib/src/utils/product_query_configurations.dart @@ -8,10 +8,10 @@ import 'uri_helper.dart'; /// Api version for product queries (minimum forced version number: 2). class ProductQueryVersion { - const ProductQueryVersion(final int version) + const ProductQueryVersion(final num version) : version = version < 2 ? 2 : version; - final int version; + final num version; static const ProductQueryVersion v3 = ProductQueryVersion(3); @@ -40,6 +40,7 @@ class ProductQueryConfiguration extends AbstractQueryConfiguration { super.languages, super.country, super.fields, + super.flexibleFields, this.productTypeFilter, }); diff --git a/lib/src/utils/product_search_query_configuration.dart b/lib/src/utils/product_search_query_configuration.dart index e870ce41df..2b4b6bc4ba 100644 --- a/lib/src/utils/product_search_query_configuration.dart +++ b/lib/src/utils/product_search_query_configuration.dart @@ -12,6 +12,7 @@ class ProductSearchQueryConfiguration extends AbstractQueryConfiguration { super.languages, super.country, super.fields, + super.flexibleFields, required List parametersList, required this.version, }) : super( diff --git a/test/api_get_flexible_product.dart b/test/api_get_flexible_product.dart new file mode 100644 index 0000000000..885d7f1152 --- /dev/null +++ b/test/api_get_flexible_product.dart @@ -0,0 +1,275 @@ +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:test/test.dart'; + +import 'test_constants.dart'; + +void main() { + OpenFoodAPIConfiguration.userAgent = TestConstants.TEST_USER_AGENT; + OpenFoodAPIConfiguration.globalUser = TestConstants.PROD_USER; + + const OpenFoodFactsLanguage language = OpenFoodFactsLanguage.FRENCH; + const String barcode = '7300400481588'; + + // cf. https://github.com/openfoodfacts/openfoodfacts-server/issues/11745 + void expectNonNullProduct( + final FlexibleProduct product, { + required final bool directSearch, + }) { + expect(product.imageUrlBase, isNotNull); + expect(product.barcode, isNotNull); + expect(product.productType, isNotNull); + expect(product.schemaVersion, isNotNull); + expect(product.abbreviatedName, isNotNull); + expect(product.additives, isNotNull); + expect(product.attributeGroups, isNotNull); + expect(product.brandsAsString, isNotNull); + expect(product.brandsAsList, isNotNull); + expect(product.categories, isNotNull); + expect(product.categoriesTags, isNotNull); + expect(product.categoriesTagsInLanguages, isNotNull); + expect(product.comparedToCategory, isNotNull); + expect(product.countries, isNotNull); + expect(product.countriesTagsInLanguages, isNotNull); + expect(product.embCodes, isNotNull); + expect(product.genericName, isNotNull); + expect(product.imageFrontUrl, isNotNull); + expect(product.imageFrontSmallUrl, isNotNull); + if (directSearch) { + expect(product.imageIngredientsUrl, isNotNull); + expect(product.imageIngredientsSmallUrl, isNotNull); + expect(product.imageNutritionUrl, isNotNull); + expect(product.imageNutritionSmallUrl, isNotNull); + expect(product.imagePackagingUrl, isNotNull); + expect(product.imagePackagingSmallUrl, isNotNull); + } + expect(product.images, isNotNull); + expect(product.ingredients, isNotNull); + expect(product.ingredientsText, isNotNull); + expect(product.ingredientsTextInLanguages, isNotNull); + expect(product.knowledgePanels, isNotNull); + expect(product.labels, isNotNull); + expect(product.labelsTagsInLanguages, isNotNull); + expect(product.lang, isNotNull); + expect(product.lastImage, isNotNull); + expect(product.lastImageDates, isNotNull); + expect(product.noNutritionData, isNotNull); + expect(product.nutrimentDataPer, isNotNull); + expect(product.nutriments, isNotNull); + expect(product.obsolete, isNotNull); + expect(product.origins, isNotNull); + expect(product.ownerFields, isNotNull); + expect(product.packagings, isNotNull); + expect(product.packagingsComplete, isNotNull); + expect(product.packagingTextInLanguages, isNotNull); + expect(product.productName, isNotNull); + expect(product.productNameInLanguages, isNotNull); + expect(product.quantity, isNotNull); + expect(product.selectedImages, isNotNull); + expect(product.servingSize, isNotNull); + expect(product.statesTags, isNotNull); + expect(product.stores, isNotNull); + expect(product.website, isNotNull); + } + + // Smoothie fields as of 2025-04-04 + const List fields = [ + ProductField.SCHEMA_VERSION, + ProductField.NAME, + ProductField.NAME_ALL_LANGUAGES, + ProductField.BRANDS, + ProductField.BARCODE, + ProductField.PRODUCT_TYPE, + ProductField.NUTRISCORE, + ProductField.FRONT_IMAGE, + ProductField.IMAGE_FRONT_URL, + ProductField.IMAGE_INGREDIENTS_URL, + ProductField.IMAGE_NUTRITION_URL, + ProductField.IMAGE_PACKAGING_URL, + ProductField.IMAGES, + ProductField.SELECTED_IMAGE, + ProductField.QUANTITY, + ProductField.SERVING_SIZE, + ProductField.STORES, + ProductField.PACKAGING_QUANTITY, + ProductField.PACKAGING, + ProductField.PACKAGINGS, + ProductField.PACKAGINGS_COMPLETE, + ProductField.PACKAGING_TAGS, + ProductField.PACKAGING_TEXT_ALL_LANGUAGES, + ProductField.NO_NUTRITION_DATA, + ProductField.NUTRIMENT_DATA_PER, + ProductField.NUTRITION_DATA, + ProductField.NUTRIMENTS, + ProductField.NUTRIENT_LEVELS, + ProductField.NUTRIMENT_ENERGY_UNIT, + ProductField.ADDITIVES, + ProductField.INGREDIENTS_ANALYSIS_TAGS, + ProductField.INGREDIENTS_TEXT, + ProductField.INGREDIENTS_TEXT_ALL_LANGUAGES, + ProductField.LABELS_TAGS, + ProductField.LABELS_TAGS_IN_LANGUAGES, + ProductField.COMPARED_TO_CATEGORY, + ProductField.CATEGORIES_TAGS, + ProductField.CATEGORIES_TAGS_IN_LANGUAGES, + ProductField.LANGUAGE, + ProductField.ATTRIBUTE_GROUPS, + ProductField.STATES_TAGS, + ProductField.ECOSCORE_DATA, + ProductField.ECOSCORE_GRADE, + ProductField.ECOSCORE_SCORE, + ProductField.KNOWLEDGE_PANELS, + ProductField.COUNTRIES, + ProductField.COUNTRIES_TAGS, + ProductField.COUNTRIES_TAGS_IN_LANGUAGES, + ProductField.EMB_CODES, + ProductField.ORIGINS, + ProductField.WEBSITE, + ProductField.OBSOLETE, + ProductField.OWNER_FIELDS, + ProductField.OWNER, + // The following fields are not queried by Smoothie, but needed, curiously. + ProductField.ABBREVIATED_NAME, + ProductField.GENERIC_NAME, + ProductField.IMAGE_FRONT_SMALL_URL, + ProductField.IMAGE_INGREDIENTS_SMALL_URL, + ProductField.IMAGE_NUTRITION_SMALL_URL, + ProductField.IMAGE_PACKAGING_SMALL_URL, + ProductField.INGREDIENTS, + ProductField.LABELS, + ProductField.LAST_IMAGE, + ProductField.LAST_IMAGE_DATES, + ProductField.CATEGORIES, + ]; + + Future getFlexibleProductInProd( + ProductQueryConfiguration configuration, + ) async { + await getProductTooManyRequestsManager.waitIfNeeded(); + return OpenFoodAPIClient.getFlexibleProductResult(configuration); + } + + Future searchFlexibleProductsInProd( + final AbstractQueryConfiguration configuration, + ) async { + await searchProductsTooManyRequestsManager.waitIfNeeded(); + return OpenFoodAPIClient.searchFlexibleProducts( + TestConstants.PROD_USER, + configuration, + ); + } + + final Map schemaVersions = { + 3: 999, + 3.1: 1000, + 3.2: 1001, + }; + + group('$OpenFoodAPIClient get flexible product', () { + test('check schema version numbers', () async { + for (final MapEntry entry in schemaVersions.entries) { + final num apiVersion = entry.key; + final int expectedSchemaVersion = entry.value; + + final ProductQueryConfiguration configurations = + ProductQueryConfiguration( + barcode, + language: language, + fields: [ProductField.SCHEMA_VERSION], + version: ProductQueryVersion(apiVersion), + ); + final FlexibleProductResult result = await getFlexibleProductInProd( + configurations, + ); + expect(result.status, ProductResultV3.statusSuccess); + final FlexibleProduct? product = result.product; + expect(product, isNotNull); + expect(product!.schemaVersion, expectedSchemaVersion); + } + }); + + test('check brands in api 3.2', () async { + for (final MapEntry entry in schemaVersions.entries) { + final num apiVersion = entry.key; + final int expectedSchemaVersion = entry.value; + + final ProductQueryConfiguration configurations = + ProductQueryConfiguration( + barcode, + language: language, + fields: [ProductField.BRANDS, ProductField.SCHEMA_VERSION], + flexibleFields: [ + 'brands_hierarchy', + 'brands_lc', + 'brands_tags' + ], + version: ProductQueryVersion(apiVersion), + ); + final FlexibleProductResult result = await getFlexibleProductInProd( + configurations, + ); + expect(result.status, ProductResultV3.statusSuccess); + final FlexibleProduct? product = result.product; + expect(product, isNotNull); + expect(product!.schemaVersion, expectedSchemaVersion); + expect(product.brandsAsString, 'Wasa'); + expect(product.brandsAsList, ['Wasa']); + if (apiVersion >= 3.2) { + expect(product.json['brands_hierarchy'], ["xx:Wasa"]); + expect(product.json['brands_lc'], 'xx'); + expect(product.json['brands_tags'], ["xx:wasa"]); + } else { + expect(product.json['brands_hierarchy'], isNull); + expect(product.json['brands_lc'], isNull); + expect(product.json['brands_tags'], ['wasa']); + } + } + }); + + test('check all "Smoothie" fields in api 3', () async { + final ProductQueryConfiguration configurations = + ProductQueryConfiguration( + barcode, + language: language, + fields: fields, + version: ProductQueryVersion(3), + ); + final FlexibleProductResult result = await getFlexibleProductInProd( + configurations, + ); + expect(result.status, ProductResultV3.statusSuccess); + final FlexibleProduct? product = result.product; + expect(product, isNotNull); + expectNonNullProduct( + product!, + directSearch: true, + ); + }); + }); + + group('$OpenFoodAPIClient search flexible products', () { + test('check wasa products in api 3', () async { + final ProductSearchQueryConfiguration configurations = + ProductSearchQueryConfiguration( + parametersList: [ + BarcodeParameter(barcode), + ], + fields: fields, + language: language, + version: ProductQueryVersion(3), + ); + await OpenFoodAPIClient.searchFlexibleProducts( + TestConstants.PROD_USER, + configurations, + ); + final FlexibleSearchResult result = await searchFlexibleProductsInProd( + configurations, + ); + expect(result.count, 1); + final Iterable? products = result.products; + expect(products, isNotNull); + for (final FlexibleProduct product in result.products!) { + expectNonNullProduct(product, directSearch: false); + } + }); + }); +}