From 9a128654ef5cbc4eafbe9c81a08b8f665037d2f8 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Thu, 5 Dec 2024 08:40:48 +0100 Subject: [PATCH] feat: 1011 - new "getPriceProducts" method New files: * `get_price_products_order.dart`: Field for the "order by" clause of "get price products". * `get_price_products_parameters.dart`: Parameters for the "get price products" API query. * `get_price_products_result.dart`: List of price product objects returned by the "get price products" method. * `get_price_products_result.g.dart`: generated Impacted files: * `api_prices_test.dart`: new tests around new `getPriceProducts` method; minor refactoring * `get_parameters_helper.dart`: minor refactoring * `open_prices_api_client.dart`: new `getPriceProducts` method * `openfoodfacts.dart`: exported the new 3 files --- lib/openfoodfacts.dart | 3 + lib/src/open_prices_api_client.dart | 30 ++++ lib/src/prices/get_parameters_helper.dart | 8 +- lib/src/prices/get_price_products_order.dart | 13 ++ .../prices/get_price_products_parameters.dart | 37 +++++ lib/src/prices/get_price_products_result.dart | 33 +++++ .../prices/get_price_products_result.g.dart | 28 ++++ test/api_prices_test.dart | 140 ++++++++++++++++-- 8 files changed, 278 insertions(+), 14 deletions(-) create mode 100644 lib/src/prices/get_price_products_order.dart create mode 100644 lib/src/prices/get_price_products_parameters.dart create mode 100644 lib/src/prices/get_price_products_result.dart create mode 100644 lib/src/prices/get_price_products_result.g.dart diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index c8a7e7355a..55bb64512e 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -100,6 +100,9 @@ export 'src/prices/get_locations_result.dart'; // export 'src/prices/get_parameters_helper.dart'; // uncomment if really needed export 'src/prices/get_prices_order.dart'; export 'src/prices/get_prices_parameters.dart'; +export 'src/prices/get_price_products_order.dart'; +export 'src/prices/get_price_products_parameters.dart'; +export 'src/prices/get_price_products_result.dart'; export 'src/prices/get_prices_result.dart'; export 'src/prices/get_price_count_parameters_helper.dart'; export 'src/prices/get_proofs_order.dart'; diff --git a/lib/src/open_prices_api_client.dart b/lib/src/open_prices_api_client.dart index 12fa2e6570..81a1ee910a 100644 --- a/lib/src/open_prices_api_client.dart +++ b/lib/src/open_prices_api_client.dart @@ -12,6 +12,8 @@ import 'prices/proof.dart'; import 'prices/get_locations_parameters.dart'; import 'prices/get_locations_result.dart'; import 'prices/get_parameters_helper.dart'; +import 'prices/get_price_products_parameters.dart'; +import 'prices/get_price_products_result.dart'; import 'prices/get_prices_parameters.dart'; import 'prices/get_prices_result.dart'; import 'prices/get_proofs_parameters.dart'; @@ -163,6 +165,34 @@ class OpenPricesAPIClient { return MaybeError.responseError(response); } + static Future> getPriceProducts( + final GetPriceProductsParameters parameters, { + final UriProductHelper uriHelper = uriHelperFoodProd, + final String? bearerToken, + }) async { + final Uri uri = getUri( + path: '/api/v1/products', + queryParameters: parameters.getQueryParameters(), + uriHelper: uriHelper, + ); + final Response response = await HttpHelper().doGetRequest( + uri, + uriHelper: uriHelper, + bearerToken: bearerToken, + ); + if (response.statusCode == 200) { + try { + final dynamic decodedResponse = HttpHelper().jsonDecodeUtf8(response); + return MaybeError.value( + GetPriceProductsResult.fromJson(decodedResponse), + ); + } catch (e) { + // + } + } + return MaybeError.responseError(response); + } + static Future> getPriceProductById( final int productId, { final UriProductHelper uriHelper = uriHelperFoodProd, diff --git a/lib/src/prices/get_parameters_helper.dart b/lib/src/prices/get_parameters_helper.dart index a996f94245..847185c295 100644 --- a/lib/src/prices/get_parameters_helper.dart +++ b/lib/src/prices/get_parameters_helper.dart @@ -14,7 +14,7 @@ abstract class GetParametersHelper { /// Returns the parameters as a query parameter map. Map getQueryParameters() { - _checkIntValue('page_number', pageSize, min: 1); + _checkIntValue('page_number', pageNumber, min: 1); _checkIntValue('page_size', pageSize, min: 1, max: 100); _result.clear(); addNonNullInt(pageNumber, 'page'); @@ -32,7 +32,7 @@ abstract class GetParametersHelper { } void _checkIntValue( - final String field, + final String fieldDescription, final int? value, { final int? min, final int? max, @@ -43,14 +43,14 @@ abstract class GetParametersHelper { if (min != null) { if (value < min) { throw Exception( - '$field minimum value is $min (actual value is $value)', + '$fieldDescription minimum value is $min (actual value is $value)', ); } } if (max != null) { if (value > max) { throw Exception( - '$field maximum value is $max (actual value is $value)', + '$fieldDescription maximum value is $max (actual value is $value)', ); } } diff --git a/lib/src/prices/get_price_products_order.dart b/lib/src/prices/get_price_products_order.dart new file mode 100644 index 0000000000..0093fd74c2 --- /dev/null +++ b/lib/src/prices/get_price_products_order.dart @@ -0,0 +1,13 @@ +import 'order_by.dart'; + +/// Field for the "order by" clause of "get price products". +enum GetPriceProductsOrderField implements OrderByField { + priceCount(offTag: 'price_count'), + created(offTag: 'created'), + updated(offTag: 'updated'); + + const GetPriceProductsOrderField({required this.offTag}); + + @override + final String offTag; +} diff --git a/lib/src/prices/get_price_products_parameters.dart b/lib/src/prices/get_price_products_parameters.dart new file mode 100644 index 0000000000..12ac3961a6 --- /dev/null +++ b/lib/src/prices/get_price_products_parameters.dart @@ -0,0 +1,37 @@ +import 'package:openfoodfacts/src/prices/flavor.dart'; + +import 'get_price_count_parameters_helper.dart'; +import 'get_price_products_order.dart'; + +/// Parameters for the "get price products" API query. +class GetPriceProductsParameters + extends GetPriceCountParametersHelper { + String? brandsLike; + String? brandsTagsContains; + String? categoriesTagsContains; + String? code; + String? ecoscoreGrade; + String? labelsTagsContains; + String? novaGroup; + String? nutriscoreGrade; + String? productNameLike; + int? uniqueScansNGte; + Flavor? source; + + @override + Map getQueryParameters() { + super.getQueryParameters(); + addNonNullString(brandsLike, 'brands__like'); + addNonNullString(brandsTagsContains, 'brands_tags__contains'); + addNonNullString(categoriesTagsContains, 'categories_tags__contains'); + addNonNullString(code, 'code'); + addNonNullString(ecoscoreGrade, 'ecoscore_grade'); + addNonNullString(labelsTagsContains, 'labels_tags__contains'); + addNonNullString(novaGroup, 'nova_group'); + addNonNullString(nutriscoreGrade, 'nutriscore_grade'); + addNonNullString(productNameLike, 'product_name__like'); + addNonNullInt(uniqueScansNGte, 'unique_scans_n__gte'); + addNonNullString(source?.offTag, 'source'); + return result; + } +} diff --git a/lib/src/prices/get_price_products_result.dart b/lib/src/prices/get_price_products_result.dart new file mode 100644 index 0000000000..5d22fc511b --- /dev/null +++ b/lib/src/prices/get_price_products_result.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; + +import '../interface/json_object.dart'; + +part 'get_price_products_result.g.dart'; + +/// List of price product objects returned by the "get price products" method. +@JsonSerializable() +class GetPriceProductsResult extends JsonObject { + @JsonKey() + List? items; + + @JsonKey() + int? total; + + @JsonKey(name: 'page') + int? pageNumber; + + @JsonKey(name: 'size') + int? pageSize; + + @JsonKey(name: 'pages') + int? numberOfPages; + + GetPriceProductsResult(); + + factory GetPriceProductsResult.fromJson(Map json) => + _$GetPriceProductsResultFromJson(json); + + @override + Map toJson() => _$GetPriceProductsResultToJson(this); +} diff --git a/lib/src/prices/get_price_products_result.g.dart b/lib/src/prices/get_price_products_result.g.dart new file mode 100644 index 0000000000..8413c2487b --- /dev/null +++ b/lib/src/prices/get_price_products_result.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'get_price_products_result.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GetPriceProductsResult _$GetPriceProductsResultFromJson( + Map json) => + GetPriceProductsResult() + ..items = (json['items'] as List?) + ?.map((e) => PriceProduct.fromJson(e as Map)) + .toList() + ..total = (json['total'] as num?)?.toInt() + ..pageNumber = (json['page'] as num?)?.toInt() + ..pageSize = (json['size'] as num?)?.toInt() + ..numberOfPages = (json['pages'] as num?)?.toInt(); + +Map _$GetPriceProductsResultToJson( + GetPriceProductsResult instance) => + { + 'items': instance.items, + 'total': instance.total, + 'page': instance.pageNumber, + 'size': instance.pageSize, + 'pages': instance.numberOfPages, + }; diff --git a/test/api_prices_test.dart b/test/api_prices_test.dart index 4ed9086be4..ee9ce4edd5 100644 --- a/test/api_prices_test.dart +++ b/test/api_prices_test.dart @@ -7,8 +7,9 @@ import 'test_constants.dart'; void main() { OpenFoodAPIConfiguration.userAgent = TestConstants.TEST_USER_AGENT; - const String invalidBearerToken = 'invalid bearer token'; const int HTTP_OK = 200; + const int pageNumber = 1; + const int pageSize = 20; group('$OpenPricesAPIClient default', () { const UriProductHelper uriHelper = uriHelperFoodProd; @@ -173,6 +174,7 @@ void main() { ..priceWithoutDiscount = 13 ..priceIsDiscounted = true; + const String invalidBearerToken = 'invalid bearer token'; String bearerToken = invalidBearerToken; // failing price creation with invalid token @@ -372,8 +374,6 @@ void main() { test('get prices', () async { const UriProductHelper uriHelper = uriHelperFoodProd; - const int pageNumber = 1; - const int pageSize = 20; late GetPricesResult result; @@ -556,9 +556,6 @@ void main() { }); test('get locations', () async { - const int pageNumber = 1; - const int pageSize = 20; - late GetLocationsResult result; // oldest first @@ -707,6 +704,133 @@ void main() { 'No Product matches the given query.', ); }); + + test('get products', () async { + late GetPriceProductsResult result; + + // oldest first + GetPriceProductsParameters parameters = GetPriceProductsParameters() + ..orderBy = >[ + OrderBy(field: GetPriceProductsOrderField.created, ascending: true), + ] + ..pageSize = pageSize + ..pageNumber = pageNumber; + MaybeError maybeResults; + try { + maybeResults = await OpenPricesAPIClient.getPriceProducts( + parameters, + uriHelper: uriHelper, + ); + } catch (e) { + if (e.toString().contains(TestConstants.badGatewayError)) { + return; + } + rethrow; + } + expect(maybeResults.isError, isFalse); + result = maybeResults.value; + expect(result.pageSize, pageSize); + expect(result.pageNumber, pageNumber); + expect(result.total, isNotNull); + expect(result.numberOfPages, (result.total! / result.pageSize!).ceil()); + expect(result.items, isNotNull); + expect(result.items, hasLength(pageSize)); + final DateTime oldestDate = result.items!.first.created; + + // newest first + parameters = GetPriceProductsParameters() + ..orderBy = >[ + OrderBy(field: GetPriceProductsOrderField.created, ascending: false), + ] + ..pageSize = pageSize + ..pageNumber = pageNumber; + try { + maybeResults = await OpenPricesAPIClient.getPriceProducts( + parameters, + uriHelper: uriHelper, + ); + } catch (e) { + if (e.toString().contains(TestConstants.badGatewayError)) { + return; + } + rethrow; + } + expect(maybeResults.isError, isFalse); + result = maybeResults.value; + expect(result.pageSize, pageSize); + expect(result.pageNumber, pageNumber); + expect(result.total, isNotNull); + expect(result.numberOfPages, (result.total! / result.pageSize!).ceil()); + expect(result.items, isNotNull); + expect(result.items, hasLength(pageSize)); + final DateTime newestDate = result.items!.first.created; + + expect( + newestDate.millisecondsSinceEpoch, + greaterThan(oldestDate.millisecondsSinceEpoch), + ); + + // most prices first + parameters = GetPriceProductsParameters() + ..orderBy = >[ + OrderBy( + field: GetPriceProductsOrderField.priceCount, + ascending: false, + ), + ] + ..pageSize = pageSize + ..pageNumber = pageNumber; + try { + maybeResults = await OpenPricesAPIClient.getPriceProducts( + parameters, + uriHelper: uriHelper, + ); + } catch (e) { + if (e.toString().contains(TestConstants.badGatewayError)) { + return; + } + rethrow; + } + expect(maybeResults.isError, isFalse); + result = maybeResults.value; + expect(result.pageSize, pageSize); + expect(result.pageNumber, pageNumber); + expect(result.total, isNotNull); + expect(result.numberOfPages, (result.total! / result.pageSize!).ceil()); + expect(result.items, isNotNull); + expect(result.items, hasLength(pageSize)); + // value as of 2024-12-05 + expect(result.items!.first.priceCount, greaterThanOrEqualTo(107)); + + parameters = GetPriceProductsParameters()..brandsLike = 'ferrero'; + maybeResults = await OpenPricesAPIClient.getPriceProducts( + parameters, + uriHelper: uriHelper, + ); + expect(maybeResults.isError, isFalse); + result = maybeResults.value; + // value as of 2024-12-05 + expect(result.total, greaterThanOrEqualTo(2040)); + + // values as of 2024-12-05 + const Map expectedMinCounts = { + Flavor.openFoodFacts: 3625952, + Flavor.openBeautyFacts: 31463, + Flavor.openPetFoodFacts: 9955, + Flavor.openProductFacts: 15741, + null: 3688608, + }; + for (final Flavor? flavor in expectedMinCounts.keys) { + parameters = GetPriceProductsParameters()..source = flavor; + maybeResults = await OpenPricesAPIClient.getPriceProducts( + parameters, + uriHelper: uriHelper, + ); + expect(maybeResults.isError, isFalse); + result = maybeResults.value; + expect(result.total, greaterThanOrEqualTo(expectedMinCounts[flavor]!)); + } + }); }); group('$OpenPricesAPIClient Proofs', () { @@ -729,8 +853,6 @@ void main() { }); test('get proofs', () async { - const int pageNumber = 1; - const int pageSize = 20; const GetProofsOrderField orderField = GetProofsOrderField.created; const ProofType proofType = ProofType.receipt; @@ -857,8 +979,6 @@ void main() { const UriProductHelper uriHelper = uriHelperFoodProd; test('get users', () async { - const int pageNumber = 1; - const int pageSize = 20; const GetUsersOrderField orderField = GetUsersOrderField.priceCount; late GetUsersResult result;