From 5e74a55181b7fc7ea6caec0e80edbbdf39b365b2 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Mon, 25 Oct 2021 09:57:40 -0700 Subject: [PATCH] Add a taxonomy API for labels (#277) This adds an API for fetching the labels taxonomy from the server using the taxonomy server API, with tests. --- lib/model/TaxonomyLabel.dart | 318 +++++++++++++++++++++++++++ lib/model/TaxonomyLabel.g.dart | 81 +++++++ lib/openfoodfacts.dart | 10 + test/api_getTaxonomyLabels_test.dart | 150 +++++++++++++ 4 files changed, 559 insertions(+) create mode 100644 lib/model/TaxonomyLabel.dart create mode 100644 lib/model/TaxonomyLabel.g.dart create mode 100644 test/api_getTaxonomyLabels_test.dart diff --git a/lib/model/TaxonomyLabel.dart b/lib/model/TaxonomyLabel.dart new file mode 100644 index 0000000000..353c3487d4 --- /dev/null +++ b/lib/model/TaxonomyLabel.dart @@ -0,0 +1,318 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:openfoodfacts/interface/JsonObject.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:openfoodfacts/utils/TaxonomyQueryConfiguration.dart'; +import 'package:openfoodfacts/utils/TagType.dart'; + +part 'TaxonomyLabel.g.dart'; + +/// Fields of an [TaxonomyLabel] +enum TaxonomyLabelField { + ALL, + AUTH_ADDRESS, + AUTH_NAME, + AUTH_URL, + CATEGORIES, + CHILDREN, + COUNTRIES_WHERE_SOLD, + COUNTRY, + DESCRIPTION, + EU_GROUPS, + EXCEPTIONS, + IMAGE, + IMAGES, + INGREDIENTS, + LABEL_CATEGORIES, + MANUFACTURING_PLACES, + NAME, + OPPOSITE, + ORIGINS, + PACKAGING, + PACKAGING_PLACES, + PARENTS, + PROTECTED_NAME_TYPE, + STORES, + WIKIDATA, +} + +extension TaxonomyLabelFieldExtension on TaxonomyLabelField { + static const Map _KEYS = + { + TaxonomyLabelField.ALL: 'all', + TaxonomyLabelField.AUTH_ADDRESS: 'auth_address', + TaxonomyLabelField.AUTH_NAME: 'auth_name', + TaxonomyLabelField.AUTH_URL: 'auth_url', + TaxonomyLabelField.CATEGORIES: 'categories', + TaxonomyLabelField.CHILDREN: 'children', + TaxonomyLabelField.COUNTRIES_WHERE_SOLD: 'countries_where_sold', + TaxonomyLabelField.COUNTRY: 'country', + TaxonomyLabelField.DESCRIPTION: 'description', + TaxonomyLabelField.EU_GROUPS: 'eu_groups', + TaxonomyLabelField.EXCEPTIONS: 'exceptions', + TaxonomyLabelField.IMAGE: 'image', + TaxonomyLabelField.IMAGES: 'images', + TaxonomyLabelField.INGREDIENTS: 'ingredients', + TaxonomyLabelField.LABEL_CATEGORIES: 'label_categories', + TaxonomyLabelField.MANUFACTURING_PLACES: 'manufacturing_places', + TaxonomyLabelField.NAME: 'name', + TaxonomyLabelField.OPPOSITE: 'opposite', + TaxonomyLabelField.ORIGINS: 'origins', + TaxonomyLabelField.PACKAGING: 'packaging', + TaxonomyLabelField.PACKAGING_PLACES: 'packaging_places', + TaxonomyLabelField.PARENTS: 'parents', + TaxonomyLabelField.PROTECTED_NAME_TYPE: 'protected_name_type', + TaxonomyLabelField.STORES: 'stores', + TaxonomyLabelField.WIKIDATA: 'wikidata', + }; + + /// Returns the key of the Label field + String get key => _KEYS[this] ?? ''; +} + +/// A JSON-serializable version of a Label taxonomy result. +/// +/// See [OpenFoodAPIClient.getTaxonomy] for more details on how to retrieve one +/// of these. +@JsonSerializable() +class TaxonomyLabel extends JsonObject { + TaxonomyLabel( + this.authAddress, + this.authName, + this.authUrl, + this.categories, + this.children, + this.countriesWhereSold, + this.country, + this.description, + this.euGroups, + this.exceptions, + this.image, + this.images, + this.ingredients, + this.labelCategories, + this.manufacturingPlaces, + this.name, + this.opposite, + this.origins, + this.packaging, + this.packagingPlaces, + this.parents, + this.protectedNameType, + this.stores, + this.wikidata, + ); + + factory TaxonomyLabel.fromJson(Map json) { + return _$TaxonomyLabelFromJson(json); + } + + @override + Map toJson() { + return _$TaxonomyLabelToJson(this); + } + + @JsonKey( + name: 'auth_address', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? authAddress; + @JsonKey( + name: 'auth_name', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? authName; + @JsonKey( + name: 'auth_url', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? authUrl; + @JsonKey( + name: 'categories', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? categories; + @JsonKey( + name: 'countries_where_sold', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? countriesWhereSold; + @JsonKey(name: 'children', includeIfNull: false) + List? children; + @JsonKey( + name: 'country', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? country; + @JsonKey( + name: 'description', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? description; + @JsonKey( + name: 'eu_groups', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? euGroups; + @JsonKey( + name: 'exceptions', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? exceptions; + @JsonKey( + name: 'image', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? image; + @JsonKey( + name: 'images', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? images; + @JsonKey( + name: 'ingredients', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? ingredients; + @JsonKey( + name: 'label_categories', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? labelCategories; + @JsonKey( + name: 'manufacturing_places', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? manufacturingPlaces; + @JsonKey( + name: 'name', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? name; + @JsonKey( + name: 'opposite', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? opposite; + @JsonKey( + name: 'origins', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? origins; + @JsonKey( + name: 'packaging', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? packaging; + @JsonKey( + name: 'packaging_places', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? packagingPlaces; + @JsonKey(name: 'parents', includeIfNull: false) + List? parents; + @JsonKey( + name: 'protected_name_type', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? protectedNameType; + @JsonKey( + name: 'stores', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? stores; + @JsonKey( + name: 'wikidata', + fromJson: LanguageHelper.fromJsonStringMap, + toJson: LanguageHelper.toJsonStringMap, + includeIfNull: false, + ) + Map? wikidata; + + @override + String toString() => toJson().toString(); +} + +class TaxonomyLabelQueryConfiguration + extends TaxonomyQueryConfiguration { + TaxonomyLabelQueryConfiguration({ + required List tags, + List? languages = const [], + String? cc, + List fields = const [], + List additionalParameters = const [], + }) : super( + TagType.LABELS, + tags, + languages: languages, + cc: cc, + includeChildren: false, + fields: fields, + additionalParameters: additionalParameters, + ); + + @override + Map convertResults(dynamic jsonData) { + if (jsonData is! Map) { + return const {}; + } + return jsonData.map((String key, dynamic taxonomy) { + if (taxonomy is! Map) { + assert(false, 'Received JSON Label is not a Map'); + return MapEntry(key, TaxonomyLabel.fromJson({})); + } + return MapEntry(key, TaxonomyLabel.fromJson(taxonomy)); + }); + } + + @override + Set get ignoredFields => const {TaxonomyLabelField.ALL}; + + @override + Iterable convertFieldsToStrings(Iterable fields) { + return fields + .where((TaxonomyLabelField field) => !ignoredFields.contains(field)) + .map((TaxonomyLabelField field) => field.key); + } +} diff --git a/lib/model/TaxonomyLabel.g.dart b/lib/model/TaxonomyLabel.g.dart new file mode 100644 index 0000000000..dfa439d06b --- /dev/null +++ b/lib/model/TaxonomyLabel.g.dart @@ -0,0 +1,81 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'TaxonomyLabel.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TaxonomyLabel _$TaxonomyLabelFromJson(Map json) => + TaxonomyLabel( + LanguageHelper.fromJsonStringMap(json['auth_address']), + LanguageHelper.fromJsonStringMap(json['auth_name']), + LanguageHelper.fromJsonStringMap(json['auth_url']), + LanguageHelper.fromJsonStringMap(json['categories']), + (json['children'] as List?)?.map((e) => e as String).toList(), + LanguageHelper.fromJsonStringMap(json['countries_where_sold']), + LanguageHelper.fromJsonStringMap(json['country']), + LanguageHelper.fromJsonStringMap(json['description']), + LanguageHelper.fromJsonStringMap(json['eu_groups']), + LanguageHelper.fromJsonStringMap(json['exceptions']), + LanguageHelper.fromJsonStringMap(json['image']), + LanguageHelper.fromJsonStringMap(json['images']), + LanguageHelper.fromJsonStringMap(json['ingredients']), + LanguageHelper.fromJsonStringMap(json['label_categories']), + LanguageHelper.fromJsonStringMap(json['manufacturing_places']), + LanguageHelper.fromJsonStringMap(json['name']), + LanguageHelper.fromJsonStringMap(json['opposite']), + LanguageHelper.fromJsonStringMap(json['origins']), + LanguageHelper.fromJsonStringMap(json['packaging']), + LanguageHelper.fromJsonStringMap(json['packaging_places']), + (json['parents'] as List?)?.map((e) => e as String).toList(), + LanguageHelper.fromJsonStringMap(json['protected_name_type']), + LanguageHelper.fromJsonStringMap(json['stores']), + LanguageHelper.fromJsonStringMap(json['wikidata']), + ); + +Map _$TaxonomyLabelToJson(TaxonomyLabel instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull( + 'auth_address', LanguageHelper.toJsonStringMap(instance.authAddress)); + writeNotNull('auth_name', LanguageHelper.toJsonStringMap(instance.authName)); + writeNotNull('auth_url', LanguageHelper.toJsonStringMap(instance.authUrl)); + writeNotNull( + 'categories', LanguageHelper.toJsonStringMap(instance.categories)); + writeNotNull('countries_where_sold', + LanguageHelper.toJsonStringMap(instance.countriesWhereSold)); + writeNotNull('children', instance.children); + writeNotNull('country', LanguageHelper.toJsonStringMap(instance.country)); + writeNotNull( + 'description', LanguageHelper.toJsonStringMap(instance.description)); + writeNotNull('eu_groups', LanguageHelper.toJsonStringMap(instance.euGroups)); + writeNotNull( + 'exceptions', LanguageHelper.toJsonStringMap(instance.exceptions)); + writeNotNull('image', LanguageHelper.toJsonStringMap(instance.image)); + writeNotNull('images', LanguageHelper.toJsonStringMap(instance.images)); + writeNotNull( + 'ingredients', LanguageHelper.toJsonStringMap(instance.ingredients)); + writeNotNull('label_categories', + LanguageHelper.toJsonStringMap(instance.labelCategories)); + writeNotNull('manufacturing_places', + LanguageHelper.toJsonStringMap(instance.manufacturingPlaces)); + writeNotNull('name', LanguageHelper.toJsonStringMap(instance.name)); + writeNotNull('opposite', LanguageHelper.toJsonStringMap(instance.opposite)); + writeNotNull('origins', LanguageHelper.toJsonStringMap(instance.origins)); + writeNotNull('packaging', LanguageHelper.toJsonStringMap(instance.packaging)); + writeNotNull('packaging_places', + LanguageHelper.toJsonStringMap(instance.packagingPlaces)); + writeNotNull('parents', instance.parents); + writeNotNull('protected_name_type', + LanguageHelper.toJsonStringMap(instance.protectedNameType)); + writeNotNull('stores', LanguageHelper.toJsonStringMap(instance.stores)); + writeNotNull('wikidata', LanguageHelper.toJsonStringMap(instance.wikidata)); + return val; +} diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index 2d2aff279a..ca917ef514 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -8,6 +8,7 @@ import 'package:http/http.dart'; import 'package:openfoodfacts/interface/JsonObject.dart'; import 'package:openfoodfacts/model/TaxonomyAllergen.dart'; import 'package:openfoodfacts/model/TaxonomyCategory.dart'; +import 'package:openfoodfacts/model/TaxonomyLabel.dart'; import 'package:openfoodfacts/model/TaxonomyIngredient.dart'; import 'package:openfoodfacts/model/KnowledgePanels.dart'; import 'package:openfoodfacts/model/OcrIngredientsResult.dart'; @@ -360,6 +361,15 @@ class OpenFoodAPIClient { queryType: queryType); } + static Future?> getTaxonomyLabels( + TaxonomyLabelQueryConfiguration configuration, { + User? user, + QueryType? queryType, + }) { + return getTaxonomy(configuration, + user: user, queryType: queryType); + } + static void _removeImages( final SearchResult searchResult, final AbstractQueryConfiguration configuration, diff --git a/test/api_getTaxonomyLabels_test.dart b/test/api_getTaxonomyLabels_test.dart new file mode 100644 index 0000000000..6b21923178 --- /dev/null +++ b/test/api_getTaxonomyLabels_test.dart @@ -0,0 +1,150 @@ +import 'package:openfoodfacts/model/TaxonomyLabel.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:openfoodfacts/utils/OpenFoodAPIConfiguration.dart'; +import 'package:openfoodfacts/utils/QueryType.dart'; +import 'package:test/test.dart'; + +import 'fake_http_helper.dart'; +import 'test_constants.dart'; + +void main() { + OpenFoodAPIConfiguration.globalQueryType = QueryType.TEST; + OpenFoodAPIConfiguration.globalCC = 'fr'; + late FakeHttpHelper httpHelper; + final Map expectedResponse = { + 'en:vegetarian': { + 'description': { + 'fr': + 'Le v\u00e9g\u00e9tarisme est une pratique alimentaire qui exclut la consommation de chair animale.', + 'en': + 'Vegetarianism is the practice of abstaining from the consumption of meat, and may also include abstention from by-products of animal slaughter.', + }, + 'wikidata': {'en': 'Q638022'}, + 'children': [ + 'da:dansk-vegetarisk-forening', + 'en:european-vegetarian-union', + 'en:green-dot-india', + 'en:vegan', + 'en:vege-project', + 'en:vegetarian-society', + 'it:icea-bio-vegetariano', + 'it:icea-vegetariano', + ], + 'name': { + 'en': 'Vegetarian', + 'fr': 'V\u00e9g\u00e9tarien', + } + } + }; + + setUp(() { + httpHelper = FakeHttpHelper(); + HttpHelper.instance = httpHelper; + }); + + group('OpenFoodAPIClient getTaxonomyLabels', () { + test('get a label', () async { + final String tag = 'en:vegetarian'; + TaxonomyLabelQueryConfiguration configuration = + TaxonomyLabelQueryConfiguration( + fields: [ + TaxonomyLabelField.NAME, + TaxonomyLabelField.WIKIDATA, + ], + languages: [ + OpenFoodFactsLanguage.ENGLISH, + OpenFoodFactsLanguage.FRENCH, + ], + tags: [tag], + ); + httpHelper.setResponse(configuration.getUri(), + response: expectedResponse); + + Map? labels = + await OpenFoodAPIClient.getTaxonomyLabels( + configuration, + user: TestConstants.TEST_USER, + ); + expect(labels, isNotNull); + expect(labels!.length, equals(1)); + TaxonomyLabel label = labels[tag]!; + expect( + label.name![OpenFoodFactsLanguage.ENGLISH]!, + equals(expectedResponse[tag][TaxonomyLabelField.NAME.key] + [OpenFoodFactsLanguage.ENGLISH.code])); + expect( + label.name![OpenFoodFactsLanguage.FRENCH]!, + equals(expectedResponse[tag][TaxonomyLabelField.NAME.key] + [OpenFoodFactsLanguage.FRENCH.code])); + expect( + label.wikidata![OpenFoodFactsLanguage.ENGLISH]!, + equals(expectedResponse[tag][TaxonomyLabelField.WIKIDATA.key] + [OpenFoodFactsLanguage.ENGLISH.code])); + }); + test("get an label that doesn't exist", () async { + final String tag = 'en:some_nonexistent_label'; + Map expectedResponse = { + tag: {}, + }; + TaxonomyLabelQueryConfiguration configuration = + TaxonomyLabelQueryConfiguration( + fields: [ + TaxonomyLabelField.NAME, + ], + languages: [ + OpenFoodFactsLanguage.ENGLISH, + OpenFoodFactsLanguage.FRENCH, + ], + tags: [tag], + ); + httpHelper.setResponse(configuration.getUri(), + response: expectedResponse); + + Map? categories = + await OpenFoodAPIClient.getTaxonomyLabels( + configuration, + user: TestConstants.TEST_USER, + ); + expect(categories, isNull); + }); + test("get an label that doesn't exist with one that does", () async { + final String tag = 'en:vegetarian'; + TaxonomyLabelQueryConfiguration configuration = + TaxonomyLabelQueryConfiguration( + fields: [ + TaxonomyLabelField.NAME, + TaxonomyLabelField.WIKIDATA, + ], + languages: [ + OpenFoodFactsLanguage.ENGLISH, + OpenFoodFactsLanguage.FRENCH, + ], + tags: ['en:some_nonexistent_label', tag], + ); + httpHelper.setResponse(configuration.getUri(), + response: expectedResponse); + + Map? labels = + await OpenFoodAPIClient.getTaxonomyLabels( + configuration, + user: TestConstants.TEST_USER, + ); + expect(labels, isNotNull); + + expect(labels!.length, equals(1)); + TaxonomyLabel label = labels[tag]!; + expect( + label.name![OpenFoodFactsLanguage.ENGLISH]!, + equals(expectedResponse[tag][TaxonomyLabelField.NAME.key] + [OpenFoodFactsLanguage.ENGLISH.code])); + expect( + label.name![OpenFoodFactsLanguage.FRENCH]!, + equals(expectedResponse[tag][TaxonomyLabelField.NAME.key] + [OpenFoodFactsLanguage.FRENCH.code])); + expect( + label.wikidata![OpenFoodFactsLanguage.ENGLISH]!, + equals(expectedResponse[tag][TaxonomyLabelField.WIKIDATA.key] + [OpenFoodFactsLanguage.ENGLISH.code])); + }); + }); +}