Skip to content

Commit

Permalink
feat: 620 - Elastic Search autocomplete for categories (openfoodfacts…
Browse files Browse the repository at this point in the history
…#834)

New files:
* `api_search_test.dart`: Tests around the Elastic Search API.
* `autocomplete_search_result.dart`: Result of an autocomplete search on the Elastic Search API.
* `autocomplete_search_result.g.dart`: generated
* `autocomplete_single_result.dart`: Single item result of an autocomplete search on the Elastic Search API.
* `autocomplete_single_result.g.dart`: generated
* `fuzziness_level.dart`: Fuzziness Level for Elastic Search API.
* `open_food_search_api_client.dart`: Client calls of the Open Food Facts Elastic Search API.
* `taxonomy_name.dart`: Taxonomy Name for Elastic Search API.

Impacted files:
* `openfoodfacts.dart`: exported the new 5 files.
* `uri_helper.dart`: added a `forcedHost` parameter in order to deal with alternative subdomains like `search` for Elastic Search
  • Loading branch information
monsieurtanuki authored Nov 23, 2023
1 parent 8a23aee commit 1672f1a
Show file tree
Hide file tree
Showing 10 changed files with 283 additions and 1 deletion.
5 changes: 5 additions & 0 deletions lib/openfoodfacts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export 'src/model/taxonomy_packaging_shape.dart';
export 'src/model/user.dart';
export 'src/model/user_agent.dart';
export 'src/open_food_api_client.dart';
export 'src/open_food_search_api_client.dart';
export 'src/personalized_search/available_attribute_groups.dart';
export 'src/personalized_search/available_preference_importances.dart';
export 'src/personalized_search/available_product_preferences.dart';
Expand All @@ -86,6 +87,10 @@ export 'src/personalized_search/matched_product_v2.dart';
export 'src/personalized_search/preference_importance.dart';
export 'src/personalized_search/product_preferences_manager.dart';
export 'src/personalized_search/product_preferences_selection.dart';
export 'src/search/autocomplete_search_result.dart';
export 'src/search/autocomplete_single_result.dart';
export 'src/search/fuzziness_level.dart';
export 'src/search/taxonomy_name.dart';
export 'src/utils/abstract_query_configuration.dart';
export 'src/utils/barcodes_validator.dart';
export 'src/utils/country_helper.dart';
Expand Down
66 changes: 66 additions & 0 deletions lib/src/open_food_search_api_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import 'dart:async';
import 'dart:convert';

import 'package:http/http.dart';

import 'model/user.dart';
import 'utils/http_helper.dart';
import 'utils/language_helper.dart';
import 'utils/open_food_api_configuration.dart';
import 'search/autocomplete_search_result.dart';
import 'search/fuzziness_level.dart';
import 'search/taxonomy_name.dart';
import 'utils/uri_helper.dart';

/// Client calls of the Open Food Facts Elastic Search API.
class OpenFoodSearchAPIClient {
OpenFoodSearchAPIClient._();

/// Subdomain of the Elastic Search API.
static const String _subdomain = 'search';

/// Host of the Elastic Search API.
static String _getHost(final UriProductHelper uriHelper) =>
uriHelper.getHost(_subdomain);

static Future<AutocompleteSearchResult> autocomplete({
required String query,
required final List<TaxonomyName> taxonomyNames,
required final OpenFoodFactsLanguage language,
final User? user,
final int size = 10,
final Fuzziness fuzziness = Fuzziness.none,
final UriProductHelper uriHelper = uriHelperFoodProd,
}) async {
query = query.trim();
if (query.isEmpty) {
throw Exception('Query cannot be empty!');
}
final List<String> taxonomyTags = <String>[];
for (final TaxonomyName taxonomyName in taxonomyNames) {
taxonomyTags.add(taxonomyName.offTag);
}
if (taxonomyTags.isEmpty) {
throw Exception('Taxonomies cannot be empty!');
}
final Uri uri = uriHelper.getUri(
path: '/autocomplete',
forcedHost: _getHost(uriHelper),
queryParameters: <String, dynamic>{
'q': query,
'taxonomy_names': taxonomyTags.join(','),
'lang': language.offTag,
'size': size.toString(),
'fuzziness': fuzziness.offTag,
},
);
final Response response = await HttpHelper().doGetRequest(
uri,
user: user,
uriHelper: uriHelper,
);
return AutocompleteSearchResult.fromJson(
HttpHelper().jsonDecode(utf8.decode(response.bodyBytes)),
);
}
}
30 changes: 30 additions & 0 deletions lib/src/search/autocomplete_search_result.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'package:json_annotation/json_annotation.dart';
import 'autocomplete_single_result.dart';
import '../interface/json_object.dart';

part 'autocomplete_search_result.g.dart';

/// Result of an autocomplete search on the Elastic Search API.
@JsonSerializable()
class AutocompleteSearchResult extends JsonObject {
@JsonKey(fromJson: JsonObject.parseInt)
final int? took;

@JsonKey(name: 'timed_out', fromJson: JsonObject.parseBool)
final bool? timedOut;

@JsonKey()
final List<AutocompleteSingleResult>? options;

const AutocompleteSearchResult({
this.took,
this.timedOut,
this.options,
});

factory AutocompleteSearchResult.fromJson(Map<String, dynamic> json) =>
_$AutocompleteSearchResultFromJson(json);

@override
Map<String, dynamic> toJson() => _$AutocompleteSearchResultToJson(this);
}
26 changes: 26 additions & 0 deletions lib/src/search/autocomplete_search_result.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions lib/src/search/autocomplete_single_result.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import 'package:json_annotation/json_annotation.dart';
import 'taxonomy_name.dart';
import '../interface/json_object.dart';

part 'autocomplete_single_result.g.dart';

/// Single item result of an autocomplete search on the Elastic Search API.
@JsonSerializable()
class AutocompleteSingleResult extends JsonObject {
/// Tag, e.g. 'en:margherita-pizza'.
@JsonKey()
final String id;

/// Localized text, e.g. 'Pizza au fromage et aux tomates'.
@JsonKey()
final String text;

/// Taxonomy name off tag, e.g. 'category'.
@JsonKey(name: 'taxonomy_name')
final String taxonomyNameAsString;

/// Taxonomy name, if [taxonomyNameAsString] is valid.
TaxonomyName? get taxonomyName =>
TaxonomyName.fromOffTag(taxonomyNameAsString);

const AutocompleteSingleResult({
required this.id,
required this.text,
required this.taxonomyNameAsString,
});

factory AutocompleteSingleResult.fromJson(Map<String, dynamic> json) =>
_$AutocompleteSingleResultFromJson(json);

@override
Map<String, dynamic> toJson() => _$AutocompleteSingleResultToJson(this);
}
23 changes: 23 additions & 0 deletions lib/src/search/autocomplete_single_result.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions lib/src/search/fuzziness_level.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import 'package:openfoodfacts/src/model/off_tagged.dart';

/// Fuzziness Level for Elastic Search API.
enum Fuzziness implements OffTagged {
// TODO(monsieurtanuki): introduce other values when available, like 1 and 2.
none(offTag: '0');

const Fuzziness({
required this.offTag,
});

@override
final String offTag;
}
18 changes: 18 additions & 0 deletions lib/src/search/taxonomy_name.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import '../model/off_tagged.dart';

/// Taxonomy Name for Elastic Search API.
enum TaxonomyName implements OffTagged {
// TODO(monsieurtanuki): add other values when available.
category(offTag: 'category');

const TaxonomyName({
required this.offTag,
});

@override
final String offTag;

/// Returns the [TaxonomyName] that matches the [offTag].
static TaxonomyName? fromOffTag(String? offTag) =>
OffTagged.fromOffTag(offTag, TaxonomyName.values) as TaxonomyName?;
}
5 changes: 4 additions & 1 deletion lib/src/utils/uri_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ class UriHelper {
final Map<String, dynamic>? queryParameters,
final bool? addUserAgentParameters,
final String? userInfo,
final String? forcedHost,
}) =>
Uri(
scheme: scheme,
host: host,
host: forcedHost ?? host,
path: path,
queryParameters: addUserAgentParameters ?? defaultAddUserAgentParameters
? HttpHelper.addUserAgentParameters(queryParameters)
Expand Down Expand Up @@ -114,6 +115,8 @@ class UriProductHelper extends UriHelper {
/// Returns the product images folder (without trailing '/').
String getImageUrlBase() => '$scheme://images.$domain/images/products';

String getHost(final String subdomain) => '$subdomain.$domain';

Uri getPatchUri({
required final String path,
}) =>
Expand Down
60 changes: 60 additions & 0 deletions test/api_search_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import 'package:openfoodfacts/openfoodfacts.dart';
import 'package:test/test.dart';

import 'test_constants.dart';

/// Tests around the Elastic Search API.
void main() {
OpenFoodAPIConfiguration.userAgent = TestConstants.TEST_USER_AGENT;
// TODO(monsieurtanuki): test in PROD when available.
const UriProductHelper uriHelper = uriHelperFoodTest;

group(
'$OpenFoodSearchAPIClient autocomplete',
() {
const int size = 5;
const TaxonomyName taxonomyName = TaxonomyName.category;
const OpenFoodFactsLanguage language = OpenFoodFactsLanguage.FRENCH;

test(
'category with existing products',
() async {
final AutocompleteSearchResult result =
await OpenFoodSearchAPIClient.autocomplete(
query: 'pizza',
taxonomyNames: <TaxonomyName>[taxonomyName],
language: language,
uriHelper: uriHelper,
size: size,
);
expect(result.took, greaterThanOrEqualTo(0));
expect(result.timedOut, false);
expect(result.options, isNotNull);
expect(result.options!.length, size);
for (final AutocompleteSingleResult item in result.options!) {
expect(item.id, contains(':'));
expect(item.taxonomyName, taxonomyName);
}
},
);

test(
'category with non existing products',
() async {
final AutocompleteSearchResult result =
await OpenFoodSearchAPIClient.autocomplete(
query: 'pifsehjfsjkvnskjvbehjszza',
taxonomyNames: <TaxonomyName>[taxonomyName],
language: language,
uriHelper: uriHelper,
size: size,
);
expect(result.took, greaterThanOrEqualTo(0));
expect(result.timedOut, false);
expect(result.options, isNotNull);
expect(result.options, isEmpty);
},
);
},
);
}

0 comments on commit 1672f1a

Please sign in to comment.