diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index ddbc5404df..2ceb79f946 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -91,8 +91,10 @@ export 'src/personalized_search/product_preferences_selection.dart'; export 'src/prices/currency.dart'; export 'src/prices/get_prices_result.dart'; export 'src/prices/get_prices_results.dart'; +export 'src/prices/location.dart'; export 'src/prices/location_osm_type.dart'; export 'src/prices/price.dart'; +export 'src/prices/price_product.dart'; export 'src/prices/validation_error.dart'; export 'src/prices/validation_errors.dart'; export 'src/search/autocomplete_search_result.dart'; diff --git a/lib/src/open_prices_api_client.dart b/lib/src/open_prices_api_client.dart index 537d8dd9b2..8c92265c08 100644 --- a/lib/src/open_prices_api_client.dart +++ b/lib/src/open_prices_api_client.dart @@ -2,6 +2,8 @@ import 'package:http/http.dart'; import 'prices/get_prices_result.dart'; import 'prices/get_prices_results.dart'; +import 'prices/location.dart'; +import 'prices/price_product.dart'; import 'prices/validation_errors.dart'; import 'utils/http_helper.dart'; import 'utils/open_food_api_configuration.dart'; @@ -18,7 +20,7 @@ class OpenPricesAPIClient { static String _getHost(final UriProductHelper uriHelper) => uriHelper.getHost(_subdomain); - /// cf. https://prices.openfoodfacts.net/docs#/default/get_price_api_v1_prices_get + /// cf. https://prices.openfoodfacts.org/docs#/default/get_price_api_v1_prices_get static Future getPrices({ // TODO(monsieurtanuki): add all parameters final int? pageSize, @@ -43,4 +45,63 @@ class OpenPricesAPIClient { } return GetPricesResults.error(ValidationErrors.fromJson(decodedResponse)); } + + /// cf. https://prices.openfoodfacts.org/docs#/default/get_location_api_v1_locations__location_id__get + static Future getLocation( + final int locationId, { + final UriProductHelper uriHelper = uriHelperFoodProd, + }) async { + final Uri uri = uriHelper.getUri( + path: '/api/v1/locations/$locationId', + forcedHost: _getHost(uriHelper), + ); + final Response response = await HttpHelper().doGetRequest( + uri, + uriHelper: uriHelper, + ); + dynamic decodedResponse = HttpHelper().jsonDecodeUtf8(response); + if (response.statusCode == 200) { + return Location.fromJson(decodedResponse); + } + return null; + } + + /// cf. https://prices.openfoodfacts.org/docs#/default/get_product_api_v1_products__product_id__get + static Future getPriceProduct( + final int productId, { + final UriProductHelper uriHelper = uriHelperFoodProd, + }) async { + final Uri uri = uriHelper.getUri( + path: '/api/v1/products/$productId', + forcedHost: _getHost(uriHelper), + ); + final Response response = await HttpHelper().doGetRequest( + uri, + uriHelper: uriHelper, + ); + dynamic decodedResponse = HttpHelper().jsonDecodeUtf8(response); + if (response.statusCode == 200) { + return PriceProduct.fromJson(decodedResponse); + } + return null; + } + + /// cf. https://prices.openfoodfacts.org/docs#/default/status_endpoint_api_v1_status_get + static Future getStatus({ + final UriProductHelper uriHelper = uriHelperFoodProd, + }) async { + final Uri uri = uriHelper.getUri( + path: '/api/v1/status', + forcedHost: _getHost(uriHelper), + ); + final Response response = await HttpHelper().doGetRequest( + uri, + uriHelper: uriHelper, + ); + dynamic decodedResponse = HttpHelper().jsonDecodeUtf8(response); + if (response.statusCode == 200) { + return decodedResponse['status']; + } + return null; + } } diff --git a/lib/src/prices/location.dart b/lib/src/prices/location.dart new file mode 100644 index 0000000000..2ebd24cc39 --- /dev/null +++ b/lib/src/prices/location.dart @@ -0,0 +1,66 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'location_osm_type.dart'; +import '../interface/json_object.dart'; +import '../utils/json_helper.dart'; + +part 'location.g.dart'; + +/// Location object in the Prices API. +/// +/// cf. `LocationBase` in https://prices.openfoodfacts.net/docs +@JsonSerializable() +class Location extends JsonObject { + /// ID of the location in OpenStreetMap: the store where the product was bought. + @JsonKey(name: 'osm_id') + late int osmId; + + /// Type of the OpenStreetMap location object. + /// + /// Stores can be represented as nodes, ways or relations in OpenStreetMap. + /// It is necessary to be able to fetch the correct information about the + /// store using the ID. + @JsonKey(name: 'osm_type') + late LocationOSMType type; + + /// ID in the Prices API. + @JsonKey(name: 'id') + late int locationId; + + @JsonKey(name: 'osm_name') + String? name; + + @JsonKey(name: 'osm_display_name') + String? displayName; + + @JsonKey(name: 'osm_address_postcode') + String? postcode; + + @JsonKey(name: 'osm_address_city') + String? city; + + @JsonKey(name: 'osm_address_country') + String? country; + + @JsonKey(name: 'osm_lat') + double? latitude; + + @JsonKey(name: 'osm_lon') + double? longitude; + + /// Date when the product was bought. + @JsonKey(fromJson: JsonHelper.stringTimestampToDate) + late DateTime created; + + /// Date when the product was bought. + @JsonKey(fromJson: JsonHelper.nullableStringTimestampToDate) + DateTime? updated; + + Location(); + + factory Location.fromJson(Map json) => + _$LocationFromJson(json); + + @override + Map toJson() => _$LocationToJson(this); +} diff --git a/lib/src/prices/location.g.dart b/lib/src/prices/location.g.dart new file mode 100644 index 0000000000..59efae4304 --- /dev/null +++ b/lib/src/prices/location.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'location.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Location _$LocationFromJson(Map json) => Location() + ..osmId = json['osm_id'] as int + ..type = $enumDecode(_$LocationOSMTypeEnumMap, json['osm_type']) + ..locationId = json['id'] as int + ..name = json['osm_name'] as String? + ..displayName = json['osm_display_name'] as String? + ..postcode = json['osm_address_postcode'] as String? + ..city = json['osm_address_city'] as String? + ..country = json['osm_address_country'] as String? + ..latitude = (json['osm_lat'] as num?)?.toDouble() + ..longitude = (json['osm_lon'] as num?)?.toDouble() + ..created = JsonHelper.stringTimestampToDate(json['created']) + ..updated = JsonHelper.nullableStringTimestampToDate(json['updated']); + +Map _$LocationToJson(Location instance) => { + 'osm_id': instance.osmId, + 'osm_type': _$LocationOSMTypeEnumMap[instance.type]!, + 'id': instance.locationId, + 'osm_name': instance.name, + 'osm_display_name': instance.displayName, + 'osm_address_postcode': instance.postcode, + 'osm_address_city': instance.city, + 'osm_address_country': instance.country, + 'osm_lat': instance.latitude, + 'osm_lon': instance.longitude, + 'created': instance.created.toIso8601String(), + 'updated': instance.updated?.toIso8601String(), + }; + +const _$LocationOSMTypeEnumMap = { + LocationOSMType.node: 'NODE', + LocationOSMType.way: 'WAY', + LocationOSMType.relation: 'RELATION', +}; diff --git a/lib/src/prices/price.dart b/lib/src/prices/price.dart index 368e0b20bb..827136c454 100644 --- a/lib/src/prices/price.dart +++ b/lib/src/prices/price.dart @@ -40,15 +40,15 @@ class Price extends JsonObject { /// If the price is about a barcode-less product, it must be the price per /// kilogram or per liter. @JsonKey() - num? price; + late num price; /// Currency of the price. @JsonKey() - Currency? currency; + late Currency currency; /// ID of the location in OpenStreetMap: the store where the product was bought. @JsonKey(name: 'location_osm_id') - int? locationOSMId; + late int locationOSMId; /// Type of the OpenStreetMap location object. /// @@ -56,11 +56,11 @@ class Price extends JsonObject { /// It is necessary to be able to fetch the correct information about the /// store using the ID. @JsonKey(name: 'location_osm_type') - LocationOSMType? locationOSMType; + late LocationOSMType locationOSMType; /// Date when the product was bought. @JsonKey(fromJson: JsonHelper.stringTimestampToDate) - DateTime? date; + late DateTime date; /// ID of the proof, if any. /// @@ -77,7 +77,7 @@ class Price extends JsonObject { int? locationId; @JsonKey(fromJson: JsonHelper.stringTimestampToDate) - DateTime? created; + late DateTime created; Price(); diff --git a/lib/src/prices/price.g.dart b/lib/src/prices/price.g.dart index 9a9f720c6e..dea014b896 100644 --- a/lib/src/prices/price.g.dart +++ b/lib/src/prices/price.g.dart @@ -11,11 +11,11 @@ Price _$PriceFromJson(Map json) => Price() ..categoryTag = json['category_tag'] as String? ..labelsTags = (json['labels_tags'] as List?)?.map((e) => e as String).toList() - ..price = json['price'] as num? - ..currency = $enumDecodeNullable(_$CurrencyEnumMap, json['currency']) - ..locationOSMId = json['location_osm_id'] as int? + ..price = json['price'] as num + ..currency = $enumDecode(_$CurrencyEnumMap, json['currency']) + ..locationOSMId = json['location_osm_id'] as int ..locationOSMType = - $enumDecodeNullable(_$LocationOSMTypeEnumMap, json['location_osm_type']) + $enumDecode(_$LocationOSMTypeEnumMap, json['location_osm_type']) ..date = JsonHelper.stringTimestampToDate(json['date']) ..proofId = json['proof_id'] as int? ..productId = json['product_id'] as int? @@ -27,14 +27,14 @@ Map _$PriceToJson(Price instance) => { 'category_tag': instance.categoryTag, 'labels_tags': instance.labelsTags, 'price': instance.price, - 'currency': _$CurrencyEnumMap[instance.currency], + 'currency': _$CurrencyEnumMap[instance.currency]!, 'location_osm_id': instance.locationOSMId, - 'location_osm_type': _$LocationOSMTypeEnumMap[instance.locationOSMType], - 'date': instance.date?.toIso8601String(), + 'location_osm_type': _$LocationOSMTypeEnumMap[instance.locationOSMType]!, + 'date': instance.date.toIso8601String(), 'proof_id': instance.proofId, 'product_id': instance.productId, 'location_id': instance.locationId, - 'created': instance.created?.toIso8601String(), + 'created': instance.created.toIso8601String(), }; const _$CurrencyEnumMap = { diff --git a/lib/src/prices/price_product.dart b/lib/src/prices/price_product.dart new file mode 100644 index 0000000000..1084842433 --- /dev/null +++ b/lib/src/prices/price_product.dart @@ -0,0 +1,44 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../interface/json_object.dart'; +import '../utils/json_helper.dart'; + +part 'price_product.g.dart'; + +/// Product object in the Prices API. +/// +/// cf. `ProductBase` in https://prices.openfoodfacts.net/docs +@JsonSerializable() +class PriceProduct extends JsonObject { + @JsonKey() + late String code; + + @JsonKey(name: 'id') + late int productId; + + @JsonKey() + String? source; + + @JsonKey(name: 'product_name') + String? name; + + @JsonKey(name: 'product_quantity') + int? quantity; + + @JsonKey(name: 'image_url') + String? imageURL; + + @JsonKey(fromJson: JsonHelper.stringTimestampToDate) + late DateTime created; + + @JsonKey(fromJson: JsonHelper.nullableStringTimestampToDate) + DateTime? updated; + + PriceProduct(); + + factory PriceProduct.fromJson(Map json) => + _$PriceProductFromJson(json); + + @override + Map toJson() => _$PriceProductToJson(this); +} diff --git a/lib/src/prices/price_product.g.dart b/lib/src/prices/price_product.g.dart new file mode 100644 index 0000000000..fb68ba5097 --- /dev/null +++ b/lib/src/prices/price_product.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'price_product.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PriceProduct _$PriceProductFromJson(Map json) => PriceProduct() + ..code = json['code'] as String + ..productId = json['id'] as int + ..source = json['source'] as String? + ..name = json['product_name'] as String? + ..quantity = json['product_quantity'] as int? + ..imageURL = json['image_url'] as String? + ..created = JsonHelper.stringTimestampToDate(json['created']) + ..updated = JsonHelper.nullableStringTimestampToDate(json['updated']); + +Map _$PriceProductToJson(PriceProduct instance) => + { + 'code': instance.code, + 'id': instance.productId, + 'source': instance.source, + 'product_name': instance.name, + 'product_quantity': instance.quantity, + 'image_url': instance.imageURL, + 'created': instance.created.toIso8601String(), + 'updated': instance.updated?.toIso8601String(), + }; diff --git a/test/api_prices_test.dart b/test/api_prices_test.dart index 1542221d3e..148dc6a578 100644 --- a/test/api_prices_test.dart +++ b/test/api_prices_test.dart @@ -4,7 +4,6 @@ import 'test_constants.dart'; void main() { OpenFoodAPIConfiguration.userAgent = TestConstants.TEST_USER_AGENT; - const UriProductHelper uriHelper = uriHelperFoodTest; group('$OpenPricesAPIClient get prices', () { test('get first prices', () async { @@ -13,7 +12,6 @@ void main() { final GetPricesResults results = await OpenPricesAPIClient.getPrices( pageSize: pageSize, pageNumber: pageNumber, - uriHelper: uriHelper, ); expect(results.result, isNotNull); final GetPricesResult result = results.result!; @@ -29,16 +27,46 @@ void main() { } else { expect(price.categoryTag, isNull); } - expect(price.price, isNotNull); - expect(price.currency, isNotNull); - expect(price.locationOSMId, isNotNull); - expect(price.locationOSMType, isNotNull); - expect(price.date, isNotNull); - // TODO(monsieurtanuki): isn't productId supposed to be not null? - //expect(price.productId, isNotNull); - expect(price.locationId, isNotNull); - expect(price.created, isNotNull); + expect(price.price, greaterThan(0)); + expect(price.locationOSMId, greaterThan(0)); } }); + + test('get existing location', () async { + const int locationId = 1; + final Location? location = + await OpenPricesAPIClient.getLocation(locationId); + expect(location, isNotNull); + expect(location!.locationId, locationId); + expect(location.osmId, greaterThan(0)); + expect(location.type, isNotNull); + }); + + test('get non-existing location', () async { + final Location? location = await OpenPricesAPIClient.getLocation(-1); + expect(location, isNull); + }); + + test('get existing product', () async { + const int productId = 1; + final PriceProduct? priceProduct = + await OpenPricesAPIClient.getPriceProduct(productId); + expect(priceProduct, isNotNull); + expect(priceProduct!.productId, productId); + expect(priceProduct.code.length, greaterThanOrEqualTo(1)); + expect(priceProduct.created, isNotNull); + }); + + test('get non-existing product', () async { + final PriceProduct? priceProduct = + await OpenPricesAPIClient.getPriceProduct(-1); + expect(priceProduct, isNull); + }); + + test('get status', () async { + final String? status = await OpenPricesAPIClient.getStatus(); + expect(status, isNotNull); + expect(status, 'running'); + }); }); }