Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/openfoodfacts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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';
Expand Down
11 changes: 11 additions & 0 deletions lib/src/model/flexible/flexible_map.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'package:meta/meta.dart';

@experimental
typedef JsonMap = Map<String, dynamic>;

@experimental
abstract class FlexibleMap {
const FlexibleMap(this.json);

final JsonMap json;
}
190 changes: 190 additions & 0 deletions lib/src/model/flexible/flexible_product.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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<AttributeGroup>? get attributeGroups =>
(json['attribute_groups'] as List<dynamic>?)
?.map(AttributeGroup.fromJson);

String? get brands => json['brands'] as String?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following on your example with brands.
Since FlexibleProduct is supposed to be used with both the OxF API and Searchalicious, wouldn't this throw if json['brands'] is a List instead of String?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@PrimaelQuemerais That's correct. But it's not relevant for the moment to implement anything specific to Searchalicious.
That first PR - that already started more than 3 months ago - is only about creating a FlexibleProduct that works like a Product.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But if it doesn't implement this kind of logic I don't understand where this model stands between Product and storing the product in a Map. Do you already know how this would be handled in FlexibleProduct and why it couldn't be implemented in Product directly?

JSONSerializable still allows us to implement multiple parsing logics as long as the returning type remains the same, and wouldn't you face the same issue with FlexibleProduct?

Let me know if there's something I'm not getting here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as long as the returning type remains the same

That's the problem for "brands".

No format problem will be faced with FlexibleProduct, assuming it's correct, JSON-wise.
What we cannot do today with Product is accepting a priori data that don't match exactly requirements decided months ago.
Again, currently when we download products, we first check if they match the expected Product structure. If they don't, we ignore them by throwing an exception.
With FlexibleProduct, we won't have this problem because as long as it's JSON, we accept the data. We'll still have to decide later how to retrieve this or that field - e.g. for brands - but the data won't be rejected a priori. And extending FlexibleProduct e.g. in Smoothie will make it possible to be flexible about the data.

Basically that would be something like

@override
String? get brands {
  dynamic result = json['brands'];
  if (result == null) {
    return null;
  }
  try {
    return result as String;
  } catch(e) {
    return (result as List<String>).join(',');
  }
}

Copy link
Member

@PrimaelQuemerais PrimaelQuemerais Jul 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can implement the same try catch in the Product deserialization logic which I think is what we should do.

This FlexibleProduct approach seems to introduce a lot of runtime errors as no-one expects a getter to throw.
If we want to manipulate products from the server without flexibly I think it is wiser to keep it as a Map until we are sure of the structure, at which point it should be deserialization into an object, but then I don't see what prevents us from implementing everything into Product directly.

That's my take but it would be nice to get more opinions @g123k @teolemon

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay let's reset and let me try to understand you better.

Cool

Is the goal of FlexibleProduct to provide developers a way to get products from the API regardless of the structure of what the API sends back?

Correct. In order to provide flexibility to API structure changes.

And it is better than a function returning a Map<String, dynamic> because it offers default getters?

Correct. So that developers don't have to reinvent the wheel for the 99% for fields that don't change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a bit more time to ponder over this but here's a few thoughts :

One improvement I would make then is to allow developers to pass their own type to getFlexibleProductResult() like this : static Future<FlexibleProductResult> getFlexibleProductResult<T extends FlexibleProduct>(...). This way FlexibleProductResult can contain a list of their own type. And same for searchFlexibleProducts().

One concern is if I extend FlexibleProduct to let's say fix the issue with brands being Strings or Lists, I won't be able to use the brand attribute as dart doesn't allow me to both override and change the type at the same time. How to we expect developers to work around this?

I still don't really like having getters which can throw, but that's a design choice and no solution will be perfect for this problem anyway, but maybe we can find a way to let developers know that these getters can be unsafe? I can take some time to think about it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One improvement I would make then is to allow developers to pass their own type to getFlexibleProductResult() like this : static Future<FlexibleProductResult> getFlexibleProductResult<T extends FlexibleProduct>(...). This way FlexibleProductResult can contain a list of their own type. And same for searchFlexibleProducts().

I think that would be over-engineering the code:

  • we already have access to json, so we can build whatever class on top of that json if needed
  • in dart we can extend FlexibleProduct whenever we want, including in Smoothie, so I can't think of anything we couldn't fix with an extension

One concern is if I extend FlexibleProduct to let's say fix the issue with brands being Strings or Lists, I won't be able to use the brand attribute as dart doesn't allow me to both override and change the type at the same time. How to we expect developers to work around this?

I still don't really like having getters which can throw, but that's a design choice and no solution will be perfect for this problem anyway, but maybe we can find a way to let developers know that these getters can be unsafe? I can take some time to think about it.

In order to solve this issue forever, the idea would be to deprecate brands and to create 2 new getters, approximately coded like that:

  String? get brandsAsString {
    final result = json['brands'];
    if (result == null) {
      return null;
    }
    if (result is String) {
      return result;
    }
    return (result as List<String>).join(',');
  }

  List<String>? get brandsAsList {
    final result = json['brands'];
    if (result == null) {
      return null;
    }
    if (result is List<String>) {
      return result;
    }
    return (result as String).split(',');
  }

What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct me if I'm wrong but in order to deprecate brands, one would need to create a new class extending from FlexibleProduct. Thus an extension wouldn't be enough?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@PrimaelQuemerais Indeed, we don't need brands after all. With brandsAsString and brandsAsList we're good.


String? get categories => json['categories'] as String?;

Iterable<String>? get categoriesTags =>
(json['categories_tags'] as List<dynamic>?)?.map(
(e) => e as String,
);

Map<OpenFoodFactsLanguage, List<String>>? 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<OpenFoodFactsLanguage, List<String>>? 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<ProductImage>? get images =>
JsonHelper.allImagesFromJson(json['images'] as Map?);

Iterable<Ingredient>? get ingredients =>
(json['ingredients'] as List<dynamic>?)
?.map((e) => Ingredient.fromJson(e as Map<String, dynamic>));

String? get ingredientsText => json['ingredients_text'] as String?;

Map<OpenFoodFactsLanguage, String>? 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<OpenFoodFactsLanguage, List<String>>? 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<String>? get lastImageDates =>
(json['last_image_dates_tags'] as List<dynamic>?)
?.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<String, dynamic>);

bool? get obsolete => JsonHelper.checkboxFromJSON(json['obsolete']);

String? get origins => json['origins'] as String?;

Map<String, int>? get ownerFields =>
(json['owner_fields'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
);

Iterable<ProductPackaging>? get packagings =>
(json['packagings'] as List<dynamic>?)?.map(ProductPackaging.fromJson);

bool? get packagingsComplete =>
JsonHelper.boolFromJSON(json['packagings_complete']);

Map<OpenFoodFactsLanguage, String>? 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<OpenFoodFactsLanguage, String>? get productNameInLanguages =>
LanguageHelper.fromJsonStringMapWithPrefix(
json,
inProductField: ProductField.NAME_IN_LANGUAGES,
allProductField: ProductField.NAME_ALL_LANGUAGES,
);

String? get quantity => json['quantity'] as String?;

List<ProductImage>? get selectedImages =>
JsonHelper.selectedImagesFromJson(json['selected_images'] as Map?);

String? get servingSize => json['serving_size'] as String?;

Iterable<String>? get statesTags =>
(json['states_tags'] as List<dynamic>?)?.map((e) => e as String);

String? get stores => json['stores'] as String?;

String? get website => json['link'] as String?;
}
40 changes: 40 additions & 0 deletions lib/src/model/flexible/flexible_product_result.dart
Original file line number Diff line number Diff line change
@@ -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, dynamic>);

String? get status => json['status'] as String?;

List<ProductResultFieldAnswer>? get errors =>
ProductResultV3.fromJsonListAnswerForField(json['errors']);

List<ProductResultFieldAnswer>? get warnings =>
ProductResultV3.fromJsonListAnswerForField(json['warnings']);

FlexibleProduct? get product => json['product'] == null
? null
: FlexibleProduct.fromServer(
json['product'] as JsonMap,
uriHelper: uriHelper,
);
}
35 changes: 35 additions & 0 deletions lib/src/model/flexible/flexible_search_result.dart
Original file line number Diff line number Diff line change
@@ -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<FlexibleProduct>? get products =>
(json['products'] as List<dynamic>?)?.map(
(toElement) => FlexibleProduct.fromServer(
toElement,
uriHelper: uriHelper,
),
);
}
13 changes: 13 additions & 0 deletions lib/src/model/product_image.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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].
Expand Down
6 changes: 3 additions & 3 deletions lib/src/model/product_result_v3.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProductResultFieldAnswer>? errors;

/// Warnings.
///
/// Typically populated if [status] is [statusWarning].
@JsonKey(includeIfNull: false, fromJson: _fromJsonListAnswerForField)
@JsonKey(includeIfNull: false, fromJson: fromJsonListAnswerForField)
List<ProductResultFieldAnswer>? warnings;

@JsonKey(includeIfNull: false)
Expand Down Expand Up @@ -70,7 +70,7 @@ class ProductResultV3 extends JsonObject {
String toString() => toJson().toString();

/// From a `List<AnswerForField>` in `dynamic`'s clothing (JsonKey)
static List<ProductResultFieldAnswer>? _fromJsonListAnswerForField(
static List<ProductResultFieldAnswer>? fromJsonListAnswerForField(
dynamic list) {
if (list == null) {
return null;
Expand Down
4 changes: 2 additions & 2 deletions lib/src/model/product_result_v3.g.dart

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

Loading
Loading