From 043de16a1636628e2951cdb9768c0616b61d8ac1 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sat, 6 Dec 2025 16:33:17 +0100 Subject: [PATCH] Api v2 --- Package.resolved | 4 +- Package.swift | 63 +- Sources/OpenFoodFacts/Extension+String.swift | 21 - Sources/OpenFoodFacts/OpenFoodFacts.swift | 100 -- Sources/OpenFoodFacts/Schemas/Images.swift | 15 - .../OpenFoodFacts/Schemas/Ingredient.swift | 31 - .../Schemas/NutrientLevels.swift | 23 - .../OpenFoodFacts/Schemas/Nutriments.swift | 299 ------ .../Schemas/ObjectDebugger.swift | 58 -- .../Schemas/PerlSearchQuery.swift | 63 -- Sources/OpenFoodFacts/Schemas/Product.swift | 973 ------------------ .../Schemas/ProductResponse.swift | 13 - .../Schemas/SearchResponse.swift | 14 - .../KeyDecodingContainer+Helpers.swift | 11 + .../Extensions/String+Cases.swift | 12 + .../OpenFoodFactsClient.swift | 137 +++ .../OpenFoodFactsConfig.swift | 66 ++ .../OpenFoodFactsSDK/Schemas/Ingredient.swift | 56 + .../Schemas/LanguagesCodes.swift | 2 +- .../Schemas/NutrientLevels.swift | 28 + .../OpenFoodFactsSDK/Schemas/Nutriments.swift | 52 + .../OpenFoodFactsSDK/Schemas/Product.swift | 98 ++ .../Schemas/ProductField.swift | 20 + .../Schemas/SearchNutriment.swift | 0 .../Schemas/SearchParameter.swift | 30 + .../Schemas/SearchTag.swift | 0 .../Schemas/SelectedImage.swift | 2 +- .../Schemas/SelectedImageItem.swift | 2 +- .../Schemas/SelectedImages.swift | 2 +- .../Schemas/Source.swift | 2 +- .../OpenFoodFactsTests.swift | 71 +- 31 files changed, 594 insertions(+), 1674 deletions(-) delete mode 100644 Sources/OpenFoodFacts/Extension+String.swift delete mode 100644 Sources/OpenFoodFacts/OpenFoodFacts.swift delete mode 100644 Sources/OpenFoodFacts/Schemas/Images.swift delete mode 100644 Sources/OpenFoodFacts/Schemas/Ingredient.swift delete mode 100644 Sources/OpenFoodFacts/Schemas/NutrientLevels.swift delete mode 100644 Sources/OpenFoodFacts/Schemas/Nutriments.swift delete mode 100644 Sources/OpenFoodFacts/Schemas/ObjectDebugger.swift delete mode 100644 Sources/OpenFoodFacts/Schemas/PerlSearchQuery.swift delete mode 100644 Sources/OpenFoodFacts/Schemas/Product.swift delete mode 100644 Sources/OpenFoodFacts/Schemas/ProductResponse.swift delete mode 100644 Sources/OpenFoodFacts/Schemas/SearchResponse.swift create mode 100644 Sources/OpenFoodFactsSDK/Extensions/KeyDecodingContainer+Helpers.swift create mode 100644 Sources/OpenFoodFactsSDK/Extensions/String+Cases.swift create mode 100644 Sources/OpenFoodFactsSDK/OpenFoodFactsClient.swift create mode 100644 Sources/OpenFoodFactsSDK/OpenFoodFactsConfig.swift create mode 100644 Sources/OpenFoodFactsSDK/Schemas/Ingredient.swift rename Sources/{OpenFoodFacts => OpenFoodFactsSDK}/Schemas/LanguagesCodes.swift (61%) create mode 100644 Sources/OpenFoodFactsSDK/Schemas/NutrientLevels.swift create mode 100644 Sources/OpenFoodFactsSDK/Schemas/Nutriments.swift create mode 100644 Sources/OpenFoodFactsSDK/Schemas/Product.swift create mode 100644 Sources/OpenFoodFactsSDK/Schemas/ProductField.swift rename Sources/{OpenFoodFacts => OpenFoodFactsSDK}/Schemas/SearchNutriment.swift (100%) create mode 100644 Sources/OpenFoodFactsSDK/Schemas/SearchParameter.swift rename Sources/{OpenFoodFacts => OpenFoodFactsSDK}/Schemas/SearchTag.swift (100%) rename Sources/{OpenFoodFacts => OpenFoodFactsSDK}/Schemas/SelectedImage.swift (84%) rename Sources/{OpenFoodFacts => OpenFoodFactsSDK}/Schemas/SelectedImageItem.swift (81%) rename Sources/{OpenFoodFacts => OpenFoodFactsSDK}/Schemas/SelectedImages.swift (84%) rename Sources/{OpenFoodFacts => OpenFoodFactsSDK}/Schemas/Source.swift (89%) diff --git a/Package.resolved b/Package.resolved index 97ea9d0..b3eb0b2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "45ae84ae5c5095f4d85c870c36fff7e4577f267c4a02fa550762071d2f9eca53", + "originHash" : "a15a32e3876322ac74f426a1b87f358f2c06dfd7393379edff4f4fb556b95a3b", "pins" : [ { "identity" : "swift-units", @@ -7,7 +7,7 @@ "location" : "git@git.cems.dev:cdricms/swift-units.git", "state" : { "branch" : "master", - "revision" : "0962ae290044d24722610234cb215b748722f196" + "revision" : "edb2bdd02240855675e9e2d3d7f2c51c2aeac2ff" } } ], diff --git a/Package.swift b/Package.swift index f0010db..41511c1 100644 --- a/Package.swift +++ b/Package.swift @@ -4,35 +4,36 @@ import PackageDescription let package = Package( - name: "swift-openfoodfacts-sdk", - platforms: [ - .macOS(.v14), - .iOS(.v15), - ], - products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "OpenFoodFacts", - targets: ["OpenFoodFacts"] - ) - ], - dependencies: [ - .package( - url: "git@git.cems.dev:cdricms/swift-units.git", branch: "master") - ], - targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. - .target( - name: "OpenFoodFacts", - dependencies: [ - .product(name: "Units", package: "swift-units") - ], - path: "Sources/OpenFoodFacts", - ), - .testTarget( - name: "OpenFoodFactsTests", - dependencies: ["OpenFoodFacts"] - ), - ] + name: "swift-openfoodfacts-sdk", + platforms: [ + .macOS(.v12), .iOS(.v15), .tvOS(.v15), .watchOS(.v8), + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "OpenFoodFactsSDK", + targets: ["OpenFoodFactsSDK"] + ) + ], + dependencies: [ + .package( + url: "git@git.cems.dev:cdricms/swift-units.git", branch: "master") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "OpenFoodFactsSDK", + dependencies: [ + .product(name: "Units", package: "swift-units") + ], + swiftSettings: [ + .enableExperimentalFeature("SwiftConcurrency") + ] + ), + .testTarget( + name: "OpenFoodFactsTests", + dependencies: ["OpenFoodFactsSDK"] + ), + ] ) diff --git a/Sources/OpenFoodFacts/Extension+String.swift b/Sources/OpenFoodFacts/Extension+String.swift deleted file mode 100644 index b5de910..0000000 --- a/Sources/OpenFoodFacts/Extension+String.swift +++ /dev/null @@ -1,21 +0,0 @@ -extension String { - func camelCaseToSnakeCase() -> String { - var result = "" - var lastCharacterWasUppercase = false - - for character in self { - if character.isUppercase { - if !result.isEmpty && !lastCharacterWasUppercase { - result.append("_") - } - result.append(character.lowercased()) - lastCharacterWasUppercase = true - } else { - result.append(character) - lastCharacterWasUppercase = false - } - } - - return result - } -} diff --git a/Sources/OpenFoodFacts/OpenFoodFacts.swift b/Sources/OpenFoodFacts/OpenFoodFacts.swift deleted file mode 100644 index e6922a0..0000000 --- a/Sources/OpenFoodFacts/OpenFoodFacts.swift +++ /dev/null @@ -1,100 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book - -import Foundation - -public class OpenFoodFactsClient { - let version: Int = 0 - public var prod: Bool = false - var baseURL: URL? { - if prod { - return URL( - string: "https://world.openfoodfacts.org/api/v\(version)") - } else { - return URL( - string: "https://world.openfoodfacts.net/api/v\(version)") - } - } - - public init() {} - - public func getProductByBarcode(_ barcode: String) async throws - -> ProductResponse - { - guard - let endpoint = baseURL?.appendingPathComponent("product/\(barcode)") - else { throw OFFError.invalidURL } - var request = URLRequest(url: endpoint) - request.setValue("application/json", forHTTPHeaderField: "accept") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let response = response as? HTTPURLResponse, - response.statusCode == 200 - else { - throw OFFError.invalidResponse - } - do { - return try JSONDecoder().decode(ProductResponse.self, from: data) - } - } - - public struct SearchQuery { - public let additivesTags: String? - public let allergensTags: String? - public let brandsTags: String? - public let categoriesTags: String? - public let countriesTagsEn: String? - public let embCodesTags: String? - public let labelsTags: String? - public let manufacturingPlacesTags: String? - public let nutritionGradesTags: String? - public let originsTags: String? - public let packagingTagsDe: String? - public let purchasePlacesTags: String? - } - - // public func search(_ productName: String, queryParams: SearchQuery? = nil) async throws -> SearchResponse { - // let qp = Mirror(reflecting: queryParams ?? {}) - // var s: String = "?product_name=\(productName)&" - // for case let (label?, value) in qp.children { - // s += label.camelCaseToSnakeCase() + "=" + (value as! String) + "&" - // } - // guard let endpoint = baseURL?.appendingPathComponent("search\(s)") else { throw OFFError.invalidURL } - // var request = URLRequest(url: endpoint) - // request.setValue("application/json", forHTTPHeaderField: "accept") - - // let (data, response) = try await URLSession.shared.data(for: request) - // guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { - // throw OFFError.invalidResponse - // } - // do { - // return try JSONDecoder().decode(SearchResponse.self, from: data) - // } - // } - - //https://wiki.openfoodfacts.org/API/Read/Search#Parameters - public func search(query: PerlSearchQuery) async throws -> SearchResponse { - let endpoint = URL( - string: - "https://world.openfoodfacts.org/cgi/search.pl?\(query.makeToRequest())" - )! - print(endpoint) - let request = URLRequest(url: endpoint) - // request.setValue("application/json", forHTTPHeaderField: "accept") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let response = response as? HTTPURLResponse, - response.statusCode == 200 - else { - throw OFFError.invalidResponse - } - do { - return try JSONDecoder().decode(SearchResponse.self, from: data) - } - - } -} - -enum OFFError: Error { - case invalidURL, invalidResponse -} diff --git a/Sources/OpenFoodFacts/Schemas/Images.swift b/Sources/OpenFoodFacts/Schemas/Images.swift deleted file mode 100644 index d0285b0..0000000 --- a/Sources/OpenFoodFacts/Schemas/Images.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -public struct Images: Codable { - public var otherData: [String: Data] = [:] - - mutating func setDetail(key: String, value: T) throws { - let encodedValue = try JSONEncoder().encode(value) - otherData[key] = encodedValue - } - - func getDetail(key: String, type: T.Type) throws -> T? { - guard let data = otherData[key] else { return nil } - return try JSONDecoder().decode(type, from: data) - } -} diff --git a/Sources/OpenFoodFacts/Schemas/Ingredient.swift b/Sources/OpenFoodFacts/Schemas/Ingredient.swift deleted file mode 100644 index 4043dcf..0000000 --- a/Sources/OpenFoodFacts/Schemas/Ingredient.swift +++ /dev/null @@ -1,31 +0,0 @@ -public struct Ingredient: Codable, ObjectDebugger { - public var fromPalmOil: String? = nil - public var id: String? = nil - public var origin: String? = nil - public var percent: Float? = nil - public var rank: Float? = 0 - public var text: String? = nil - public var vegan: String? = nil - public var vegetarian: String? = nil - public var ciqualFoodCode: String? = nil - public var ecobalyseCode: String? = nil - public var percentEstimate: Float? = nil - - private enum CodingKeys: String, CodingKey { - case fromPalmOil = "from_palm_oil" - case id - case origin - case percent - case rank - case text - case vegan - case vegetarian - case ciqualFoodCode = "ciqual_food_code" - case ecobalyseCode = "ecobalyse_code" - case percentEstimate = "percent_estimate" - } - - public var isWater: Bool { - ciqualFoodCode == "18066" - } -} diff --git a/Sources/OpenFoodFacts/Schemas/NutrientLevels.swift b/Sources/OpenFoodFacts/Schemas/NutrientLevels.swift deleted file mode 100644 index 0ac3fbc..0000000 --- a/Sources/OpenFoodFacts/Schemas/NutrientLevels.swift +++ /dev/null @@ -1,23 +0,0 @@ -public struct NutrientLevels: Codable, ObjectDebugger { - public var fat: String? = nil - public var salt: String? = nil - public var saturatedFat: String? = nil - public var sugars: String? = nil - - public enum CodingKeys: String, CodingKey { - case fat - case salt - case saturatedFat = "saturated-fat" - case sugars - } - - public subscript(_ key: CodingKeys) -> String? { - switch key { - case .fat: fat - case .salt: salt - case .saturatedFat: saturatedFat - case .sugars: sugars - } - } - -} diff --git a/Sources/OpenFoodFacts/Schemas/Nutriments.swift b/Sources/OpenFoodFacts/Schemas/Nutriments.swift deleted file mode 100644 index db0b77e..0000000 --- a/Sources/OpenFoodFacts/Schemas/Nutriments.swift +++ /dev/null @@ -1,299 +0,0 @@ -import Foundation -import Units - -@dynamicMemberLookup -public struct Nutriments: Codable, ObjectDebugger { - private var nutrients: [String: Nutrient] = [:] - - private var iterator: Dictionary.Iterator? = nil - private var keys: [String] = [] - - public init() {} - - public subscript(dynamicMember member: String) -> Nutrient? { - nutrients[member] - } - - // MARK: - Decoding - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: AnyCodingKey.self) - for key in container.allKeys { - let keyStr = key.stringValue - let parts = keyStr.components(separatedBy: "_") - - var nutrientName = "" // Original with - - var field = "" - var isPrepared = false - - if let preparedIdx = parts.firstIndex(of: "prepared") { - isPrepared = true - nutrientName = parts[0..? { - guard let rawValue = per100g ?? value ?? valueComputed, - let unitString = unit, - let unitEnum = Unit(rawValue: unitString) - else { - return nil - } - return UnitValue(value: rawValue, unit: unitEnum) - } - - // Computed UnitValue for perServing - public var perServingUnitValue: UnitValue? { - guard let rawValue = perServing, - let unitString = unit, - let unitEnum = Unit(rawValue: unitString) - else { - return nil - } - return UnitValue(value: rawValue, unit: unitEnum) - } - - // Computed UnitValue for preparedPer100g (fallback similar) - public var preparedPer100gUnitValue: UnitValue? { - guard - let rawValue = preparedPer100g ?? preparedValue - ?? preparedValueComputed, - let unitString = preparedUnit ?? unit, // Fallback to main unit if preparedUnit nil - let unitEnum = Unit(rawValue: unitString) - else { - return nil - } - return UnitValue(value: rawValue, unit: unitEnum) - } - - // Computed UnitValue for preparedPerServing - public var preparedPerServingUnitValue: UnitValue? { - guard let rawValue = preparedPerServing, - let unitString = preparedUnit ?? unit, - let unitEnum = Unit(rawValue: unitString) - else { - return nil - } - return UnitValue(value: rawValue, unit: unitEnum) - } -} - -// MARK: - Helper for Dynamic Coding Keys -struct AnyCodingKey: CodingKey, Hashable { - var stringValue: String - var intValue: Int? - - init(stringValue: String) { - self.stringValue = stringValue - self.intValue = nil - } - - init(intValue: Int) { - self.intValue = intValue - self.stringValue = String(intValue) - } -} - -extension Nutriments { - public var alcohol: Nutrient? { nutrients["alcohol"] } - public var carbohydrates: Nutrient? { nutrients["carbohydrates"] } - public var energy: Nutrient? { nutrients["energy"] } - public var energyKj: Nutrient? { nutrients["energy_kj"] } - public var energyKcal: Nutrient? { nutrients["energy_kcal"] } - public var fat: Nutrient? { nutrients["fat"] } - public var fruitsVegetablesNuts: Nutrient? { - nutrients["fruits_vegetables_nuts_estimate_from_ingredients"] - } - public var novaGroup: Nutrient? { nutrients["nova_group"] } - public var proteins: Nutrient? { nutrients["proteins"] } - public var salt: Nutrient? { nutrients["salt"] } - public var saturatedFat: Nutrient? { nutrients["saturated_fat"] } - public var sodium: Nutrient? { nutrients["sodium"] } - public var sugars: Nutrient? { nutrients["sugars"] } - public var calcium: Nutrient? { nutrients["calcium"] } - - public var otherNutrients: [String: Nutrient] { - var others = nutrients - others.removeValue(forKey: "alcohol") - others.removeValue(forKey: "carbohydrates") - others.removeValue(forKey: "energy") - others.removeValue(forKey: "energy_kj") - others.removeValue(forKey: "energy_kcal") - others.removeValue(forKey: "fat") - others.removeValue( - forKey: "fruits_vegetables_nuts_estimate_from_ingredients") - others.removeValue(forKey: "nova_group") - others.removeValue(forKey: "proteins") - others.removeValue(forKey: "salt") - others.removeValue(forKey: "saturated_fat") - others.removeValue(forKey: "sodium") - others.removeValue(forKey: "sugars") - others.removeValue(forKey: "calcium") - - return others - } -} - -extension Nutriments: Sequence, IteratorProtocol { - public mutating func next() -> Element? { - if iterator == nil { - iterator = nutrients.makeIterator() - keys = Array(nutrients.keys).sorted() - } - guard let nextElement = iterator?.next() else { - iterator = nil - return nil - } - return (item: nextElement.key, nutrient: nextElement.value) - } - - public typealias Element = (item: String, nutrient: Nutrient) - -} diff --git a/Sources/OpenFoodFacts/Schemas/ObjectDebugger.swift b/Sources/OpenFoodFacts/Schemas/ObjectDebugger.swift deleted file mode 100644 index 6bc9e75..0000000 --- a/Sources/OpenFoodFacts/Schemas/ObjectDebugger.swift +++ /dev/null @@ -1,58 +0,0 @@ -public protocol ObjectDebugger: CustomStringConvertible { - var description: String { get } -} - -extension ObjectDebugger { - public var description: String { - var description = "\(type(of: self))(" - let mirror = Mirror(reflecting: self) - - for (label, value) in mirror.children { - if let label = label { - description += "\(label): \(value), " - } - } - - // Remove the trailing comma and space - description = String(description.dropLast(2)) - description += ")" - - return description - } - - private func prettyPrint(object: Any, indentation: String = "") -> String { - var description = "\(type(of: object)) {" - - let mirror = Mirror(reflecting: object) - - for (label, value) in mirror.children { - if let label = label { - let childDescription: String - switch value { - case let nestedObject as CustomStringConvertible: - childDescription = prettyPrint( - object: nestedObject, indentation: "\(indentation) ") - case let stringValue as String: - childDescription = "\"\(stringValue)\"" - case let floatValue as Float: - childDescription = "\(floatValue)" - case let intValue as Int: - childDescription = "\(intValue)" - case let optionalValue as CustomStringConvertible?: - if let unwrapped = optionalValue { - childDescription = "\(unwrapped)" - } else { - childDescription = "nil" - } - default: - childDescription = "\(value)" - } - - description += "\n\(indentation) \(label): \(childDescription)" - } - } - - description += "\n\(indentation)}" - return description - } -} diff --git a/Sources/OpenFoodFacts/Schemas/PerlSearchQuery.swift b/Sources/OpenFoodFacts/Schemas/PerlSearchQuery.swift deleted file mode 100644 index e6464cb..0000000 --- a/Sources/OpenFoodFacts/Schemas/PerlSearchQuery.swift +++ /dev/null @@ -1,63 +0,0 @@ -public enum PerlOperator: String { - case lt, lte, gt, gte, eq -} - -public enum PerlFormat: String { - case json, xml, jqm -} - -public struct SearchNutrimentEntry { - public let nutriment: SearchNutriment - public let op: PerlOperator - public let value: Int -} - -public struct SearchTagsEntry { - public let tag: SearchTag - public let value: String - public let contains: Bool = true -} - -//https://wiki.openfoodfacts.org/API/Read/Search#Parameters -public struct PerlSearchQuery { - public var searchTerms: String - public var searchTags: [SearchTagsEntry]? // tagtype_i=SearchTag — i as #element (starting from 0); (tag_contains_i|tag_does_not_contain_i)=String - public var searchNutriment: [SearchNutrimentEntry]? // nutriment_i=SearchNutriment — i as #element (starting from 0); nutriment_compare_i=PerlOperator; nutriment_value_i=String - public var page: Int = 1 // Pagination - public var format: PerlFormat // json=1 | xml=1 | jqm=1 - - public init( - searchTerms: String, searchTags: [SearchTagsEntry]? = nil, - searchNutriment: [SearchNutrimentEntry]? = nil, page: Int = 1, - format: PerlFormat = .json - ) { - self.searchTerms = searchTerms - self.searchTags = searchTags - self.searchNutriment = searchNutriment - self.page = page - self.format = format - } - - public func makeToRequest() -> String { - var _searchTags: String? - var _searchNutriment: String? - let _format: String = "\(format.rawValue)=1" - let _page: String = "page=\(page)" - if let tags = searchTags { - _searchTags = tags.enumerated().map { i, v in - return - "tagtype_\(i)=\(v.tag.rawValue)&tag_contains_\(i)=\(v.contains ? "contains" : "does_not_contain")&tag_\(i)=\(v.value)" - }.joined(separator: "&") - } - if let nutriments = searchNutriment { - _searchNutriment = nutriments.enumerated().map { i, v in - return - "nutriment_\(i)=\(v.nutriment.rawValue)&nutriment_compare_\(i)=\(v.op.rawValue)&nutriment_value_\(i)=\(v.nutriment.rawValue)" - }.joined(separator: "&") - } - - return - "search_terms=\(searchTerms)\(_searchTags != nil ? "&\(_searchTags!)" : "")\(_searchNutriment != nil ? "&\(_searchNutriment!)" : "")&\(_page)&\(_format)" - - } -} diff --git a/Sources/OpenFoodFacts/Schemas/Product.swift b/Sources/OpenFoodFacts/Schemas/Product.swift deleted file mode 100644 index df4381f..0000000 --- a/Sources/OpenFoodFacts/Schemas/Product.swift +++ /dev/null @@ -1,973 +0,0 @@ -import Units - -public class Product: Codable, ObjectDebugger { - // public var images: Images? = Images() - public var ingredients: [Ingredient]? = [] - public var languagesCodes: LanguagesCodes? - public var nutrientLevels: NutrientLevels? - public var nutriments: Nutriments? = Nutriments() - public var selectedImages: SelectedImages? - public var sources: [Source]? = [] - public var additivesN: Float? - public var additivesOldN: Float? - public var additivesOriginalTags: [String]? - public var additivesOldTags: [String]? - public var additivesPrevOriginalTags: [String]? - public var additivesDebugTags: [String]? - public var additivesTags: [String]? - public var allergens: String? - public var allergensFromIngredients: String? - public var allergensFromUser: String? - public var allergensHierarchy: [String]? - public var allergensLc: String? - public var allergensTags: [String]? - public var aminoAcidsPrevTags: [String]? - public var aminoAcidsTags: [String]? - public var brands: String? - public var brandsDebugTags: [String]? - public var brandsTags: [String]? - public var carbonFootprintPercentOfKnownIngredients: Float? - public var carbonFootprintFromKnownIngredientsDebug: String? - public var categories: String? - public var categoriesHierarchy: [String]? - public var categoriesLc: String? - public var categoriesPropertiesTags: [String]? - public var categoriesTags: [String]? - public var checkersTags: [String]? - public var citiesTags: [String]? - public var code: String? - public var codesTags: [String]? - public var comparedToCategory: String? - public var complete: Float? - public var completedT: Float? - public var completeness: Double? - public var conservationConditions: String? - public var countries: String? - public var countriesHierarchy: [String]? - public var countriesLc: String? - public var countriesDebugTags: [String]? - public var countriesTags: [String]? - public var correctorsTags: [String]? - public var createdT: Float? - public var creator: String? - public var dataQualityBugsTags: [String]? - public var dataQualityErrorsTags: [String]? - public var dataQualityInfoTags: [String]? - public var dataQualityTags: [String]? - public var dataQualityWarningsTags: [String]? - public var dataSources: String? - public var dataSourcesTags: [String]? - public var debugParamSortedLangs: [String]? - public var editorsTags: [String]? - public var embCodes: String? - public var embCodesDebugTags: [String]? - public var embCodesOrig: String? - public var embCodesTags: [String]? - public var entryDatesTags: [String]? - public var expirationDate: String? - public var expirationDateDebugTags: [String]? - public var fruitsVegetablesNuts100GEstimate: Float? - public var genericName: String? - public var id: String? - public var imageFrontSmallUrl: String? - public var imageFrontThumbUrl: String? - public var imageFrontUrl: String? - public var imageIngredientsUrl: String? - public var imageIngredientsSmallUrl: String? - public var imageIngredientsThumbUrl: String? - public var imageNutritionSmallUrl: String? - public var imageNutritionThumbUrl: String? - public var imageNutritionUrl: String? - public var imageSmallUrl: String? - public var imageThumbUrl: String? - public var imageUrl: String? - public var informersTags: [String]? - public var ingredientsAnalysisTags: [String]? - public var ingredientsDebug: [String?]? - public var ingredientsFromOrThatMayBeFromPalmOilN: Float? - public var ingredientsFromPalmOilTags: [String]? - public var ingredientsFromPalmOilN: Float? - public var ingredientsHierarchy: [String]? - public var ingredientsIdsDebug: [String]? - public var ingredientsN: Float? - public var ingredientsNTags: [String]? - public var ingredientsOriginalTags: [String]? - public var ingredientsTags: [String]? - public var ingredientsText: String? - public var ingredientsTextDebug: String? - public var ingredientsTextWithAllergens: String? - public var ingredientsThatMayBeFromPalmOilN: Float? - public var ingredientsThatMayBeFromPalmOilTags: [String]? - public var interfaceVersionCreated: String? - public var interfaceVersionModified: String? - public var keywords: [String]? - public var knownIngredientsN: Float? - public var labels: String? - public var labelsHierarchy: [String]? - public var labelsLc: String? - public var labelsPrevHierarchy: [String]? - public var labelsPrevTags: [String]? - public var labelsTags: [String]? - public var labelsDebugTags: [String]? - public var lang: String? - public var langDebugTags: [String]? - public var languagesHierarchy: [String]? - public var languagesTags: [String]? - public var lastEditDatesTags: [String]? - public var lastEditor: String? - public var lastImageDatesTags: [String]? - public var lastImageT: Float? - public var lastModifiedBy: String? - public var lastModifiedT: Float? - public var lc: String? - public var link: String? - public var linkDebugTags: [String]? - public var manufacturingPlaces: String? - public var manufacturingPlacesDebugTags: [String]? - public var manufacturingPlacesTags: [String]? - public var maxImgid: String? - public var mineralsPrevTags: [String]? - public var mineralsTags: [String]? - public var miscTags: [String]? - public var netWeightUnit: String? - public var netWeightValue: String? - public var nutritionDataPer: String? - public var nutritionScoreWarningNoFruitsVegetablesNuts: Float? - public var nutriscoreGrade: String? - public var noNutritionData: String? - public var novaGroup: Float? - public var novaGroups: String? - public var novaGroupDebug: String? - public var novaGroupTags: [String]? - public var novaGroupsTags: [String]? - public var nucleotidesPrevTags: [String]? - public var nucleotidesTags: [String]? - public var nutrientLevelsTags: [String]? - public var nutritionData: String? - public var nutritionDataPerDebugTags: [String]? - public var nutritionDataPrepared: String? - public var nutritionDataPreparedPer: String? - public var nutritionGrades: String? - public var nutritionScoreBeverage: Float? - public var nutritionScoreDebug: String? - public var nutritionScoreWarningNoFiber: Float? - public var nutritionGradesTags: [String]? - public var origins: String? - public var originsDebugTags: [String]? - public var originsTags: [String]? - public var otherInformation: String? - public var otherNutritionalSubstancesTags: [String]? - public var packaging: String? - public var packagingDebugTags: [String]? - public var packagingTags: [String]? - public var photographersTags: [String]? - public var pnnsGroups1: String? - public var pnnsGroups2: String? - public var pnnsGroups1Tags: [String]? - public var pnnsGroups2Tags: [String]? - public var popularityKey: Float? - public var producerVersionId: String? - public var productName: String? - public var productQuantity: Float? - public var productQuantityUnit: String? - public var purchasePlaces: String? - public var purchasePlacesDebugTags: [String]? - public var purchasePlacesTags: [String]? - public var qualityTags: [String]? - public var quantity: String? - public var quantityDebugTags: [String]? - public var recyclingInstructionsToDiscard: String? - public var rev: Float? - public var servingQuantity: String? - public var servingQuantityUnit: String? - public var servingSize: String? - public var servingSizeDebugTags: [String]? - public var sortkey: Float? - public var states: String? - public var statesHierarchy: [String]? - public var statesTags: [String]? - public var stores: String? - public var storesDebugTags: [String]? - public var storesTags: [String]? - public var traces: String? - public var tracesFromIngredients: String? - public var tracesHierarchy: [String]? - public var tracesDebugTags: [String]? - public var tracesFromUser: String? - public var tracesLc: String? - public var tracesTags: [String]? - public var unknownIngredientsN: Float? - public var unknownNutrientsTags: [String]? - public var updateKey: String? - public var vitaminsPrevTags: [String]? - public var vitaminsTags: [String]? - - public var waterQuantity: Units.UnitValue? { - guard let ingredient = ingredients?.first(where: { $0.isWater }) - else { - return nil - } - - guard let quantity = productQuantity, - let estimate = ingredient.percentEstimate, - let rawUnit = productQuantityUnit, - let unit = Units.Unit(rawValue: rawUnit) - else { - return nil - } - - return Double(quantity * (estimate / 100))[unit] - } - - public var isLiquid: Bool { - guard let rawUnit = productQuantityUnit, - let unit = Units.Unit(rawValue: rawUnit) - else { - return false - } - - return unit.category == .volume - } - - private enum CodingKeys: String, CodingKey { - // case images - case ingredients - case languagesCodes = "languages_codes" - case nutrientLevels = "nutrient_levels" - case nutriments - case selectedImages = "selected_images" - case sources - case additivesN = "additives_n" - case additivesOldN = "additives_old_n" - case additivesOriginalTags = "additives_original_tags" - case additivesOldTags = "additives_old_tags" - case additivesPrevOriginalTags = "additives_prev_original_tags" - case additivesDebugTags = "additives_debug_tags" - case additivesTags = "additives_tags" - case allergens - case allergensFromIngredients = "allergens_from_ingredients" - case allergensFromUser = "allergens_from_user" - case allergensHierarchy = "allergens_hierarchy" - case allergensLc = "allergens_lc" - case allergensTags = "allergens_tags" - case aminoAcidsPrevTags = "amino_acids_prev_tags" - case aminoAcidsTags = "amino_acids_tags" - case brands - case brandsDebugTags = "brands_debug_tags" - case brandsTags = "brands_tags" - case carbonFootprintPercentOfKnownIngredients = - "carbon_footprint_percent_of_known_ingredients" - case carbonFootprintFromKnownIngredientsDebug = - "carbon_footprint_from_known_ingredients_debug" - case categories - case categoriesHierarchy = "categories_hierarchy" - case categoriesLc = "categories_lc" - case categoriesPropertiesTags = "categories_properties_tags" - case categoriesTags = "categories_tags" - case checkersTags = "checkers_tags" - case citiesTags = "cities_tags" - case code - case codesTags = "codes_tags" - case comparedToCategory = "compared_to_category" - case complete - case completedT = "completed_t" - case completeness - case conservationConditions = "conservation_conditions" - case countries - case countriesHierarchy = "countries_hierarchy" - case countriesLc = "countries_lc" - case countriesDebugTags = "countries_debug_tags" - case countriesTags = "countries_tags" - case correctorsTags = "correctors_tags" - case createdT = "created_t" - case creator - case dataQualityBugsTags = "data_quality_bugs_tags" - case dataQualityErrorsTags = "data_quality_errors_tags" - case dataQualityInfoTags = "data_quality_info_tags" - case dataQualityTags = "data_quality_tags" - case dataQualityWarningsTags = "data_quality_warnings_tags" - case dataSources = "data_sources" - case dataSourcesTags = "data_sources_tags" - case debugParamSortedLangs = "debug_param_sorted_langs" - case editorsTags = "editors_tags" - case embCodes = "emb_codes" - case embCodesDebugTags = "emb_codes_debug_tags" - case embCodesOrig = "emb_codes_orig" - case embCodesTags = "emb_codes_tags" - case entryDatesTags = "entry_dates_tags" - case expirationDate = "expiration_date" - case expirationDateDebugTags = "expiration_date_debug_tags" - case fruitsVegetablesNuts100GEstimate = - "fruits-vegetables-nuts_100g_estimate" - case genericName - case id - case imageFrontSmallUrl = "image_front_small_url" - case imageFrontThumbUrl = "image_front_thumb_url" - case imageFrontUrl = "image_front_url" - case imageIngredientsUrl = "image_ingredients_url" - case imageIngredientsSmallUrl = "image_ingredients_small_url" - case imageIngredientsThumbUrl = "image_ingredients_thumb_url" - case imageNutritionSmallUrl = "image_nutrition_small_url" - case imageNutritionThumbUrl = "image_nutrition_thumb_url" - case imageNutritionUrl = "image_nutrition_url" - case imageSmallUrl = "image_small_url" - case imageThumbUrl = "image_thumb_url" - case imageUrl = "image_url" - case informersTags = "informers_tags" - case ingredientsAnalysisTags = "ingredients_analysis_tags" - case ingredientsDebug = "ingredients_debug" - case ingredientsFromOrThatMayBeFromPalmOilN = - "ingredients_from_or_that_may_be_from_palm_oil_n" - case ingredientsFromPalmOilTags = "ingredients_from_palm_oil_tags" - case ingredientsFromPalmOilN = "ingredients_from_palm_oil_n" - case ingredientsHierarchy = "ingredients_hierarchy" - case ingredientsIdsDebug = "ingredients_ids_debug" - case ingredientsN = "ingredients_n" - case ingredientsNTags = "ingredients_n_tags" - case ingredientsOriginalTags = "ingredients_original_tags" - case ingredientsTags = "ingredients_tags" - case ingredientsText = "ingredients_text" - case ingredientsTextDebug = "ingredients_text_debug" - case ingredientsTextWithAllergens = "ingredients_text_with_allergens" - case ingredientsThatMayBeFromPalmOilN = - "ingredients_that_may_be_from_palm_oil_n" - case ingredientsThatMayBeFromPalmOilTags = - "ingredients_that_may_be_from_palm_oil_tags" - case interfaceVersionCreated = "interface_version_created" - case interfaceVersionModified = "interface_version_modified" - case keywords - case knownIngredientsN = "known_ingredients_n" - case labels - case labelsHierarchy = "labels_hierarchy" - case labelsLc = "labels_lc" - case labelsPrevHierarchy = "labels_prev_hierarchy" - case labelsPrevTags = "labels_prev_tags" - case labelsTags = "labels_tags" - case labelsDebugTags = "labels_debug_tags" - case lang - case langDebugTags = "lang_debug_tags" - case languagesHierarchy = "languages_hierarchy" - case languagesTags = "languages_tags" - case lastEditDatesTags = "last_edit_dates_tags" - case lastEditor = "last_editor" - case lastImageDatesTags = "last_image_dates_tags" - case lastImageT = "last_image_t" - case lastModifiedBy = "last_modified_by" - case lastModifiedT = "last_modified_t" - case lc - case link - case linkDebugTags = "link_debug_tags" - case manufacturingPlaces = "manufacturing_places" - case manufacturingPlacesDebugTags = "manufacturing_places_debug_tags" - case manufacturingPlacesTags = "manufacturing_places_tags" - case maxImgid = "max_imgid" - case mineralsPrevTags = "minerals_prev_tags" - case mineralsTags = "minerals_tags" - case miscTags = "misc_tags" - case netWeightUnit = "net_weight_unit" - case netWeightValue = "net_weight_value" - case nutritionDataPer = "nutrition_data_per" - case nutritionScoreWarningNoFruitsVegetablesNuts = - "nutrition_score_warning_no_fruits_vegetables_nuts" - case nutriscoreGrade = "nutriscore_grade" - case noNutritionData = "no_nutrition_data" - case novaGroup = "nova_group" - case novaGroups = "nova_groups" - case novaGroupDebug = "nova_group_debug" - case novaGroupTags = "nova_group_tags" - case novaGroupsTags = "nova_groups_tags" - case nucleotidesPrevTags = "nucleotides_prev_tags" - case nucleotidesTags = "nucleotides_tags" - case nutrientLevelsTags = "nutrient_levels_tags" - case nutritionData = "nutrition_data" - case nutritionDataPerDebugTags = "nutrition_data_per_debug_tags" - case nutritionDataPrepared = "nutrition_data_prepared" - case nutritionDataPreparedPer = "nutrition_data_prepared_per" - case nutritionGrades = "nutrition_grades" - case nutritionScoreBeverage = "nutrition_score_beverage" - case nutritionScoreDebug = "nutrition_score_debug" - case nutritionScoreWarningNoFiber = "nutrition_score_warning_no_fiber" - case nutritionGradesTags = "nutrition_grades_tags" - case origins - case originsDebugTags = "origins_debug_tags" - case originsTags = "origins_tags" - case otherInformation = "other_information" - case otherNutritionalSubstancesTags = - "other_nutritional_substances_tags" - case packaging - case packagingDebugTags = "packaging_debug_tags" - case packagingTags = "packaging_tags" - case photographersTags = "photographers_tags" - case pnnsGroups1 = "pnns_groups_1" - case pnnsGroups2 = "pnns_groups_2" - case pnnsGroups1Tags = "pnns_groups_1_tags" - case pnnsGroups2Tags = "pnns_groups_2_tags" - case popularityKey = "popularity_key" - case producerVersionId = "producer_version_id" - case productName = "product_name" - case productQuantity = "product_quantity" - case productQuantityUnit = "product_quantity_unit" - case purchasePlaces = "purchase_places" - case purchasePlacesDebugTags = "purchase_places_debug_tags" - case purchasePlacesTags = "purchase_places_tags" - case qualityTags = "quality_tags" - case quantity - case quantityDebugTags = "quantity_debug_tags" - case recyclingInstructionsToDiscard = - "recycling_instructions_to_discard" - case rev - case servingQuantity = "serving_quantity" - case servingQuantityUnit = "serving_quantity_unit" - case servingSize = "serving_size" - case servingSizeDebugTags = "serving_size_debug_tags" - case sortkey - case states - case statesHierarchy = "states_hierarchy" - case statesTags = "states_tags" - case stores - case storesDebugTags = "stores_debug_tags" - case storesTags = "stores_tags" - case traces - case tracesFromIngredients = "traces_from_ingredients" - case tracesHierarchy = "traces_hierarchy" - case tracesDebugTags = "traces_debug_tags" - case tracesFromUser = "traces_from_user" - case tracesLc = "traces_lc" - case tracesTags = "traces_tags" - case unknownIngredientsN = "unknown_ingredients_n" - case unknownNutrientsTags = "unknown_nutrients_tags" - case updateKey = "update_key" - case vitaminsPrevTags = "vitamins_prev_tags" - case vitaminsTags = "vitamins_tags" - } - - public required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - ingredients = try container.decodeIfPresent( - [Ingredient].self, forKey: .ingredients) - languagesCodes = try container.decodeIfPresent( - LanguagesCodes.self, forKey: .languagesCodes) - nutrientLevels = try container.decodeIfPresent( - NutrientLevels.self, forKey: .nutrientLevels) - nutriments = - try container.decodeIfPresent(Nutriments.self, forKey: .nutriments) - ?? Nutriments() - selectedImages = try container.decodeIfPresent( - SelectedImages.self, forKey: .selectedImages) - sources = try container.decodeIfPresent([Source].self, forKey: .sources) - additivesN = try container.decodeIfPresent( - Float.self, forKey: .additivesN) - additivesOldN = try container.decodeIfPresent( - Float.self, forKey: .additivesOldN) - additivesOriginalTags = try container.decodeIfPresent( - [String].self, forKey: .additivesOriginalTags) - additivesOldTags = try container.decodeIfPresent( - [String].self, forKey: .additivesOldTags) - additivesPrevOriginalTags = try container.decodeIfPresent( - [String].self, forKey: .additivesPrevOriginalTags) - additivesDebugTags = try container.decodeIfPresent( - [String].self, forKey: .additivesDebugTags) - additivesTags = try container.decodeIfPresent( - [String].self, forKey: .additivesTags) - allergens = try container.decodeIfPresent( - String.self, forKey: .allergens) - allergensFromIngredients = try container.decodeIfPresent( - String.self, forKey: .allergensFromIngredients) - allergensFromUser = try container.decodeIfPresent( - String.self, forKey: .allergensFromUser) - allergensHierarchy = try container.decodeIfPresent( - [String].self, forKey: .allergensHierarchy) - allergensLc = try container.decodeIfPresent( - String.self, forKey: .allergensLc) - allergensTags = try container.decodeIfPresent( - [String].self, forKey: .allergensTags) - aminoAcidsPrevTags = try container.decodeIfPresent( - [String].self, forKey: .aminoAcidsPrevTags) - aminoAcidsTags = try container.decodeIfPresent( - [String].self, forKey: .aminoAcidsTags) - brands = try container.decodeIfPresent(String.self, forKey: .brands) - brandsDebugTags = try container.decodeIfPresent( - [String].self, forKey: .brandsDebugTags) - brandsTags = try container.decodeIfPresent( - [String].self, forKey: .brandsTags) - carbonFootprintPercentOfKnownIngredients = - try container.decodeIfPresent( - Float.self, forKey: .carbonFootprintPercentOfKnownIngredients) - carbonFootprintFromKnownIngredientsDebug = - try container.decodeIfPresent( - String.self, forKey: .carbonFootprintFromKnownIngredientsDebug) - categories = try container.decodeIfPresent( - String.self, forKey: .categories) - categoriesHierarchy = try container.decodeIfPresent( - [String].self, forKey: .categoriesHierarchy) - categoriesLc = try container.decodeIfPresent( - String.self, forKey: .categoriesLc) - categoriesPropertiesTags = try container.decodeIfPresent( - [String].self, forKey: .categoriesPropertiesTags) - categoriesTags = try container.decodeIfPresent( - [String].self, forKey: .categoriesTags) - checkersTags = try container.decodeIfPresent( - [String].self, forKey: .checkersTags) - citiesTags = try container.decodeIfPresent( - [String].self, forKey: .citiesTags) - code = try container.decodeIfPresent(String.self, forKey: .code) - codesTags = try container.decodeIfPresent( - [String].self, forKey: .codesTags) - comparedToCategory = try container.decodeIfPresent( - String.self, forKey: .comparedToCategory) - complete = try container.decodeIfPresent(Float.self, forKey: .complete) - completedT = try container.decodeIfPresent( - Float.self, forKey: .completedT) - completeness = try container.decodeIfPresent( - Double.self, forKey: .completeness) - conservationConditions = try container.decodeIfPresent( - String.self, forKey: .conservationConditions) - countries = try container.decodeIfPresent( - String.self, forKey: .countries) - countriesHierarchy = try container.decodeIfPresent( - [String].self, forKey: .countriesHierarchy) - countriesLc = try container.decodeIfPresent( - String.self, forKey: .countriesLc) - countriesDebugTags = try container.decodeIfPresent( - [String].self, forKey: .countriesDebugTags) - countriesTags = try container.decodeIfPresent( - [String].self, forKey: .countriesTags) - correctorsTags = try container.decodeIfPresent( - [String].self, forKey: .correctorsTags) - createdT = try container.decodeIfPresent(Float.self, forKey: .createdT) - creator = try container.decodeIfPresent(String.self, forKey: .creator) - dataQualityBugsTags = try container.decodeIfPresent( - [String].self, forKey: .dataQualityBugsTags) - dataQualityErrorsTags = try container.decodeIfPresent( - [String].self, forKey: .dataQualityErrorsTags) - dataQualityInfoTags = try container.decodeIfPresent( - [String].self, forKey: .dataQualityInfoTags) - dataQualityTags = try container.decodeIfPresent( - [String].self, forKey: .dataQualityTags) - dataQualityWarningsTags = try container.decodeIfPresent( - [String].self, forKey: .dataQualityWarningsTags) - dataSources = try container.decodeIfPresent( - String.self, forKey: .dataSources) - dataSourcesTags = try container.decodeIfPresent( - [String].self, forKey: .dataSourcesTags) - debugParamSortedLangs = try container.decodeIfPresent( - [String].self, forKey: .debugParamSortedLangs) - editorsTags = try container.decodeIfPresent( - [String].self, forKey: .editorsTags) - embCodes = try container.decodeIfPresent(String.self, forKey: .embCodes) - embCodesDebugTags = try container.decodeIfPresent( - [String].self, forKey: .embCodesDebugTags) - embCodesOrig = try container.decodeIfPresent( - String.self, forKey: .embCodesOrig) - embCodesTags = try container.decodeIfPresent( - [String].self, forKey: .embCodesTags) - entryDatesTags = try container.decodeIfPresent( - [String].self, forKey: .entryDatesTags) - expirationDate = try container.decodeIfPresent( - String.self, forKey: .expirationDate) - expirationDateDebugTags = try container.decodeIfPresent( - [String].self, forKey: .expirationDateDebugTags) - fruitsVegetablesNuts100GEstimate = try container.decodeIfPresent( - Float.self, forKey: .fruitsVegetablesNuts100GEstimate) - genericName = try container.decodeIfPresent( - String.self, forKey: .genericName) - id = try container.decodeIfPresent(String.self, forKey: .id) - imageFrontSmallUrl = try container.decodeIfPresent( - String.self, forKey: .imageFrontSmallUrl) - imageFrontThumbUrl = try container.decodeIfPresent( - String.self, forKey: .imageFrontThumbUrl) - imageFrontUrl = try container.decodeIfPresent( - String.self, forKey: .imageFrontUrl) - imageIngredientsUrl = try container.decodeIfPresent( - String.self, forKey: .imageIngredientsUrl) - imageIngredientsSmallUrl = try container.decodeIfPresent( - String.self, forKey: .imageIngredientsSmallUrl) - imageIngredientsThumbUrl = try container.decodeIfPresent( - String.self, forKey: .imageIngredientsThumbUrl) - imageNutritionSmallUrl = try container.decodeIfPresent( - String.self, forKey: .imageNutritionSmallUrl) - imageNutritionThumbUrl = try container.decodeIfPresent( - String.self, forKey: .imageNutritionThumbUrl) - imageNutritionUrl = try container.decodeIfPresent( - String.self, forKey: .imageNutritionUrl) - imageSmallUrl = try container.decodeIfPresent( - String.self, forKey: .imageSmallUrl) - imageThumbUrl = try container.decodeIfPresent( - String.self, forKey: .imageThumbUrl) - imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl) - informersTags = try container.decodeIfPresent( - [String].self, forKey: .informersTags) - ingredientsAnalysisTags = try container.decodeIfPresent( - [String].self, forKey: .ingredientsAnalysisTags) - ingredientsDebug = try container.decodeIfPresent( - [String?].self, forKey: .ingredientsDebug) - ingredientsFromOrThatMayBeFromPalmOilN = try container.decodeIfPresent( - Float.self, forKey: .ingredientsFromOrThatMayBeFromPalmOilN) - ingredientsFromPalmOilTags = try container.decodeIfPresent( - [String].self, forKey: .ingredientsFromPalmOilTags) - ingredientsFromPalmOilN = try container.decodeIfPresent( - Float.self, forKey: .ingredientsFromPalmOilN) - ingredientsHierarchy = try container.decodeIfPresent( - [String].self, forKey: .ingredientsHierarchy) - ingredientsIdsDebug = try container.decodeIfPresent( - [String].self, forKey: .ingredientsIdsDebug) - if container.contains(.ingredientsN) { - if try container.decodeNil(forKey: .ingredientsN) { - ingredientsN = nil - } else { - if let intValue = try? container.decode( - Float.self, forKey: .ingredientsN) - { - ingredientsN = intValue - } else if let stringValue = try? container.decode( - String.self, forKey: .ingredientsN) - { - ingredientsN = Float(stringValue) - } else { - // If decoding as both Float and String fails, handle the error accordingly - throw DecodingError.dataCorruptedError( - forKey: .ingredientsN, - in: container, - debugDescription: "Unable to decode ingredientsN" - ) - } - - } - } else { - ingredientsN = nil - } - ingredientsNTags = try container.decodeIfPresent( - [String].self, forKey: .ingredientsNTags) - ingredientsOriginalTags = try container.decodeIfPresent( - [String].self, forKey: .ingredientsOriginalTags) - ingredientsTags = try container.decodeIfPresent( - [String].self, forKey: .ingredientsTags) - ingredientsText = try container.decodeIfPresent( - String.self, forKey: .ingredientsText) - ingredientsTextDebug = try container.decodeIfPresent( - String.self, forKey: .ingredientsTextDebug) - ingredientsTextWithAllergens = try container.decodeIfPresent( - String.self, forKey: .ingredientsTextWithAllergens) - ingredientsThatMayBeFromPalmOilN = try container.decodeIfPresent( - Float.self, forKey: .ingredientsThatMayBeFromPalmOilN) - ingredientsThatMayBeFromPalmOilTags = try container.decodeIfPresent( - [String].self, forKey: .ingredientsThatMayBeFromPalmOilTags) - interfaceVersionCreated = try container.decodeIfPresent( - String.self, forKey: .interfaceVersionCreated) - interfaceVersionModified = try container.decodeIfPresent( - String.self, forKey: .interfaceVersionModified) - keywords = try container.decodeIfPresent( - [String].self, forKey: .keywords) - knownIngredientsN = try container.decodeIfPresent( - Float.self, forKey: .knownIngredientsN) - labels = try container.decodeIfPresent(String.self, forKey: .labels) - labelsHierarchy = try container.decodeIfPresent( - [String].self, forKey: .labelsHierarchy) - labelsLc = try container.decodeIfPresent(String.self, forKey: .labelsLc) - labelsPrevHierarchy = try container.decodeIfPresent( - [String].self, forKey: .labelsPrevHierarchy) - labelsPrevTags = try container.decodeIfPresent( - [String].self, forKey: .labelsPrevTags) - labelsTags = try container.decodeIfPresent( - [String].self, forKey: .labelsTags) - labelsDebugTags = try container.decodeIfPresent( - [String].self, forKey: .labelsDebugTags) - lang = try container.decodeIfPresent(String.self, forKey: .lang) - langDebugTags = try container.decodeIfPresent( - [String].self, forKey: .langDebugTags) - languagesHierarchy = try container.decodeIfPresent( - [String].self, forKey: .languagesHierarchy) - languagesTags = try container.decodeIfPresent( - [String].self, forKey: .languagesTags) - lastEditDatesTags = try container.decodeIfPresent( - [String].self, forKey: .lastEditDatesTags) - lastEditor = try container.decodeIfPresent( - String.self, forKey: .lastEditor) - lastImageDatesTags = try container.decodeIfPresent( - [String].self, forKey: .lastImageDatesTags) - lastImageT = try container.decodeIfPresent( - Float.self, forKey: .lastImageT) - lastModifiedBy = try container.decodeIfPresent( - String.self, forKey: .lastModifiedBy) - if container.contains(.lastModifiedT) { - if try container.decodeNil(forKey: .maxImgid) { - lastModifiedT = nil - } else { - if let floatValue = try? container.decode( - Float.self, forKey: .lastModifiedT) - { - lastModifiedT = floatValue - } else if let stringValue = try? container.decode( - String.self, forKey: .lastModifiedT) - { - lastModifiedT = Float(stringValue) - } else { - // If decoding as both Float and String fails, handle the error accordingly - throw DecodingError.dataCorruptedError( - forKey: .lastModifiedT, - in: container, - debugDescription: "Unable to decode lastModifiedT" - ) - } - - } - } else { - lastModifiedT = nil - } - lc = try container.decodeIfPresent(String.self, forKey: .lc) - link = try container.decodeIfPresent(String.self, forKey: .link) - linkDebugTags = try container.decodeIfPresent( - [String].self, forKey: .linkDebugTags) - manufacturingPlaces = try container.decodeIfPresent( - String.self, forKey: .manufacturingPlaces) - manufacturingPlacesDebugTags = try container.decodeIfPresent( - [String].self, forKey: .manufacturingPlacesDebugTags) - manufacturingPlacesTags = try container.decodeIfPresent( - [String].self, forKey: .manufacturingPlacesTags) - if container.contains(.maxImgid) { - if try container.decodeNil(forKey: .maxImgid) { - maxImgid = nil - } else { - if let floatValue = try? container.decode( - Float.self, forKey: .maxImgid) - { - maxImgid = "\(floatValue)" - } else if let stringValue = try? container.decode( - String.self, forKey: .maxImgid) - { - maxImgid = stringValue - } else { - // If decoding as both Float and String fails, handle the error accordingly - throw DecodingError.dataCorruptedError( - forKey: .maxImgid, - in: container, - debugDescription: "Unable to decode maxImgid" - ) - } - - } - } else { - maxImgid = nil - } - mineralsPrevTags = try container.decodeIfPresent( - [String].self, forKey: .mineralsPrevTags) - mineralsTags = try container.decodeIfPresent( - [String].self, forKey: .mineralsTags) - miscTags = try container.decodeIfPresent( - [String].self, forKey: .miscTags) - netWeightUnit = try container.decodeIfPresent( - String.self, forKey: .netWeightUnit) - netWeightValue = try container.decodeIfPresent( - String.self, forKey: .netWeightValue) - nutritionDataPer = try container.decodeIfPresent( - String.self, forKey: .nutritionDataPer) - nutritionScoreWarningNoFruitsVegetablesNuts = - try container.decodeIfPresent( - Float.self, forKey: .nutritionScoreWarningNoFruitsVegetablesNuts - ) - noNutritionData = try container.decodeIfPresent( - String.self, forKey: .noNutritionData) - novaGroup = try container.decodeIfPresent( - Float.self, forKey: .novaGroup) - novaGroups = try container.decodeIfPresent( - String.self, forKey: .novaGroups) - novaGroupDebug = try container.decodeIfPresent( - String.self, forKey: .novaGroupDebug) - novaGroupTags = try container.decodeIfPresent( - [String].self, forKey: .novaGroupTags) - novaGroupsTags = try container.decodeIfPresent( - [String].self, forKey: .novaGroupsTags) - nucleotidesTags = try container.decodeIfPresent( - [String].self, forKey: .nucleotidesTags) - nutrientLevelsTags = try container.decodeIfPresent( - [String].self, forKey: .nutrientLevelsTags) - nutritionData = try container.decodeIfPresent( - String.self, forKey: .nutritionData) - nutriscoreGrade = try container.decodeIfPresent( - String.self, forKey: .nutriscoreGrade) - nutritionDataPerDebugTags = try container.decodeIfPresent( - [String].self, forKey: .nutritionDataPerDebugTags) - nutritionDataPrepared = try container.decodeIfPresent( - String.self, forKey: .nutritionDataPrepared) - nutritionDataPreparedPer = try container.decodeIfPresent( - String.self, forKey: .nutritionDataPreparedPer) - nutritionGrades = try container.decodeIfPresent( - String.self, forKey: .nutritionGrades) - nutritionScoreBeverage = try container.decodeIfPresent( - Float.self, forKey: .nutritionScoreBeverage) - nutritionScoreDebug = try container.decodeIfPresent( - String.self, forKey: .nutritionScoreDebug) - nutritionScoreWarningNoFiber = try container.decodeIfPresent( - Float.self, forKey: .nutritionScoreWarningNoFiber) - nutritionGradesTags = try container.decodeIfPresent( - [String].self, forKey: .nutritionGradesTags) - origins = try container.decodeIfPresent(String.self, forKey: .origins) - originsDebugTags = try container.decodeIfPresent( - [String].self, forKey: .originsDebugTags) - originsTags = try container.decodeIfPresent( - [String].self, forKey: .originsTags) - otherInformation = try container.decodeIfPresent( - String.self, forKey: .otherInformation) - otherNutritionalSubstancesTags = try container.decodeIfPresent( - [String].self, forKey: .otherNutritionalSubstancesTags) - packaging = try container.decodeIfPresent( - String.self, forKey: .packaging) - packagingDebugTags = try container.decodeIfPresent( - [String].self, forKey: .packagingDebugTags) - packagingTags = try container.decodeIfPresent( - [String].self, forKey: .packagingTags) - photographersTags = try container.decodeIfPresent( - [String].self, forKey: .photographersTags) - pnnsGroups1 = try container.decodeIfPresent( - String.self, forKey: .pnnsGroups1) - pnnsGroups2 = try container.decodeIfPresent( - String.self, forKey: .pnnsGroups2) - pnnsGroups1Tags = try container.decodeIfPresent( - [String].self, forKey: .pnnsGroups1Tags) - pnnsGroups2Tags = try container.decodeIfPresent( - [String].self, forKey: .pnnsGroups2Tags) - popularityKey = try container.decodeIfPresent( - Float.self, forKey: .popularityKey) - producerVersionId = try container.decodeIfPresent( - String.self, forKey: .producerVersionId) - productName = try container.decodeIfPresent( - String.self, forKey: .productName) - purchasePlaces = try container.decodeIfPresent( - String.self, forKey: .purchasePlaces) - purchasePlacesDebugTags = try container.decodeIfPresent( - [String].self, forKey: .purchasePlacesDebugTags) - purchasePlacesTags = try container.decodeIfPresent( - [String].self, forKey: .purchasePlacesTags) - qualityTags = try container.decodeIfPresent( - [String].self, forKey: .qualityTags) - quantity = try container.decodeIfPresent(String.self, forKey: .quantity) - quantityDebugTags = try container.decodeIfPresent( - [String].self, forKey: .quantityDebugTags) - recyclingInstructionsToDiscard = try container.decodeIfPresent( - String.self, forKey: .recyclingInstructionsToDiscard) - rev = try container.decodeIfPresent(Float.self, forKey: .rev) - if container.contains(.servingQuantity) { - if try container.decodeNil(forKey: .servingQuantity) { - servingQuantity = nil - } else { - if let floatValue = try? container.decode( - Float.self, forKey: .servingQuantity) - { - servingQuantity = "\(floatValue)" - } else if let stringValue = try? container.decode( - String.self, forKey: .servingQuantity) - { - servingQuantity = stringValue - } else { - // If decoding as both Float and String fails, handle the error accordingly - throw DecodingError.dataCorruptedError( - forKey: .servingQuantity, - in: container, - debugDescription: "Unable to decode servingQuantity" - ) - } - - } - } else { - servingQuantity = nil - } - servingQuantityUnit = try container.decodeIfPresent( - String.self, - forKey: - .servingQuantityUnit) - servingSize = try container.decodeIfPresent( - String.self, forKey: .servingSize) - servingSizeDebugTags = try container.decodeIfPresent( - [String].self, forKey: .servingSizeDebugTags) - sortkey = try container.decodeIfPresent(Float.self, forKey: .sortkey) - states = try container.decodeIfPresent(String.self, forKey: .states) - statesHierarchy = try container.decodeIfPresent( - [String].self, forKey: .statesHierarchy) - statesTags = try container.decodeIfPresent( - [String].self, forKey: .statesTags) - stores = try container.decodeIfPresent(String.self, forKey: .stores) - storesDebugTags = try container.decodeIfPresent( - [String].self, forKey: .storesDebugTags) - storesTags = try container.decodeIfPresent( - [String].self, forKey: .storesTags) - traces = try container.decodeIfPresent(String.self, forKey: .traces) - tracesFromIngredients = try container.decodeIfPresent( - String.self, forKey: .tracesFromIngredients) - tracesHierarchy = try container.decodeIfPresent( - [String].self, forKey: .tracesHierarchy) - tracesDebugTags = try container.decodeIfPresent( - [String].self, forKey: .tracesDebugTags) - tracesFromUser = try container.decodeIfPresent( - String.self, forKey: .tracesFromUser) - tracesLc = try container.decodeIfPresent(String.self, forKey: .tracesLc) - tracesTags = try container.decodeIfPresent( - [String].self, forKey: .tracesTags) - if container.contains(.unknownIngredientsN) { - if try container.decodeNil(forKey: .unknownIngredientsN) { - unknownIngredientsN = nil - } else { - if let intValue = try? container.decode( - Float.self, forKey: .unknownIngredientsN) - { - unknownIngredientsN = intValue - } else if let stringValue = try? container.decode( - String.self, forKey: .unknownIngredientsN) - { - unknownIngredientsN = Float(stringValue) - } else { - // If decoding as both Float and String fails, handle the error accordingly - throw DecodingError.dataCorruptedError( - forKey: .unknownIngredientsN, - in: container, - debugDescription: "Unable to decode unknownIngredientsN" - ) - } - - } - } else { - unknownIngredientsN = nil - } - unknownNutrientsTags = try container.decodeIfPresent( - [String].self, forKey: .unknownNutrientsTags) - updateKey = try container.decodeIfPresent( - String.self, forKey: .updateKey) - vitaminsPrevTags = try container.decodeIfPresent( - [String].self, forKey: .vitaminsPrevTags) - vitaminsTags = try container.decodeIfPresent( - [String].self, forKey: .vitaminsTags) - - // Check for null value - if container.contains(.productQuantity) { - if try container.decodeNil(forKey: .productQuantity) { - productQuantity = nil - } else { - // Try to decode as Float - if let floatValue = try? container.decode( - Float.self, forKey: .productQuantity) - { - productQuantity = floatValue - } else if let stringValue = try? container.decode( - String.self, forKey: .productQuantity) - { - // If decoding as Float fails, try to decode as String - productQuantity = Float(stringValue) - } else { - // If decoding as both Float and String fails, handle the error accordingly - throw DecodingError.dataCorruptedError( - forKey: .productQuantity, - in: container, - debugDescription: "Unable to decode productQuantity" - ) - } - } - } else { - productQuantity = nil - } - - productQuantityUnit = try container.decodeIfPresent( - String.self, - forKey: - .productQuantityUnit) - // ... (initialize other properties) - } -} diff --git a/Sources/OpenFoodFacts/Schemas/ProductResponse.swift b/Sources/OpenFoodFacts/Schemas/ProductResponse.swift deleted file mode 100644 index 5c97bd0..0000000 --- a/Sources/OpenFoodFacts/Schemas/ProductResponse.swift +++ /dev/null @@ -1,13 +0,0 @@ -public struct ProductResponse: Codable, ObjectDebugger { - public var product: Product? - public var code: String? - public var status: Int? // or Bool, depending on your needs - public var statusVerbose: String? - - private enum CodingKeys: String, CodingKey { - case product - case code - case status - case statusVerbose = "status_verbose" - } -} diff --git a/Sources/OpenFoodFacts/Schemas/SearchResponse.swift b/Sources/OpenFoodFacts/Schemas/SearchResponse.swift deleted file mode 100644 index 0933fc2..0000000 --- a/Sources/OpenFoodFacts/Schemas/SearchResponse.swift +++ /dev/null @@ -1,14 +0,0 @@ -public struct SearchResponse: Codable, ObjectDebugger { - public var count: Int - public var page: Int - public var pageCount: Int - public var pageSize: Int - public var products: [Product]? - public var skip: Int - - private enum CodingKeys: String, CodingKey { - case count, page, products, skip - case pageCount = "page_count" - case pageSize = "page_size" - } -} diff --git a/Sources/OpenFoodFactsSDK/Extensions/KeyDecodingContainer+Helpers.swift b/Sources/OpenFoodFactsSDK/Extensions/KeyDecodingContainer+Helpers.swift new file mode 100644 index 0000000..0c83c9d --- /dev/null +++ b/Sources/OpenFoodFactsSDK/Extensions/KeyDecodingContainer+Helpers.swift @@ -0,0 +1,11 @@ +extension KeyedDecodingContainer { + public func decodeFloatOrString(forKey key: Key) throws -> Float? { + if let floatVal = try? decode(Float.self, forKey: key) { + return floatVal + } + if let stringVal = try? decode(String.self, forKey: key) { + return Float(stringVal) + } + return nil + } +} diff --git a/Sources/OpenFoodFactsSDK/Extensions/String+Cases.swift b/Sources/OpenFoodFactsSDK/Extensions/String+Cases.swift new file mode 100644 index 0000000..84870e0 --- /dev/null +++ b/Sources/OpenFoodFactsSDK/Extensions/String+Cases.swift @@ -0,0 +1,12 @@ +import Foundation + +extension String { + public func camelCaseToSnakeCase() -> String { + let pattern = "([a-z0-9])([A-Z])" + let regex = try! NSRegularExpression(pattern: pattern, options: []) + let range = NSRange(location: 0, length: self.count) + return regex.stringByReplacingMatches( + in: self, options: [], range: range, withTemplate: "$1_$2" + ).lowercased() + } +} diff --git a/Sources/OpenFoodFactsSDK/OpenFoodFactsClient.swift b/Sources/OpenFoodFactsSDK/OpenFoodFactsClient.swift new file mode 100644 index 0000000..e6e6d27 --- /dev/null +++ b/Sources/OpenFoodFactsSDK/OpenFoodFactsClient.swift @@ -0,0 +1,137 @@ +import FoundationEssentials + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +public actor OpenFoodFactsClient { + private let config: OpenFoodFactsConfig + private let session: URLSession + + public init(config: OpenFoodFactsConfig, session: URLSession = .shared) { + self.config = config + self.session = session + } + + // MARK: - API V2 Implementation + + /// Fetch a product by barcode. + /// - Parameters: + /// - barcode: The product barcode. + /// - fields: Optional list of fields to fetch (optimizes network usage). + public func product(barcode: String, fields: [ProductField]? = nil) + async throws -> Product? + { + var url = config.apiURL.appendingPathComponent("/product/\(barcode)") + + // Append fields parameter if present + if let fields = fields, !fields.isEmpty { + let fieldString = fields.map { $0.rawValue }.joined(separator: ",") + if var components = URLComponents( + url: url, resolvingAgainstBaseURL: true) + { + components.queryItems = [ + URLQueryItem(name: "fields", value: fieldString) + ] + if let newUrl = components.url { url = newUrl } + } + } + + let request = buildRequest(url: url) + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) + else { + throw OFFError.invalidResponse + } + + let envelope = try JSONDecoder().decode( + ProductResponseEnvelope.self, from: data) + return envelope.product + } + + /// Search for products using V2 API. + public func search( + _ parameters: SearchParameter..., fields: [ProductField]? = nil + ) async throws -> [Product] { + guard + var components = URLComponents( + url: config.apiURL.appendingPathComponent("/search"), + resolvingAgainstBaseURL: true) + else { + throw OFFError.invalidURL + } + + var queryItems: [URLQueryItem] = [] + + // Add requested fields + if let fields = fields, !fields.isEmpty { + let fieldVal = fields.map { $0.rawValue }.joined(separator: ",") + queryItems.append(URLQueryItem(name: "fields", value: fieldVal)) + } + + let parameters = Set(parameters) + // Add search parameters + for param in parameters { + switch param { + case .query(let q): + queryItems.append(URLQueryItem(name: "search_terms", value: q)) + case .tag(let tag, let value): + // V2 allows dynamic tags like `brands_tags=coca` + queryItems.append( + URLQueryItem(name: "\(tag.rawValue)_tags", value: value)) + case .page(let p): + queryItems.append(URLQueryItem(name: "page", value: String(p))) + case .pageSize(let s): + queryItems.append( + URLQueryItem(name: "page_size", value: String(s))) + case .sort(let s): + queryItems.append( + URLQueryItem(name: "sort_by", value: s.rawValue)) + } + } + + components.queryItems = queryItems + guard let url = components.url else { throw OFFError.invalidURL } + + let request = buildRequest(url: url) + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) + else { + throw OFFError.invalidResponse + } + + let envelope = try JSONDecoder().decode( + SearchResponseEnvelope.self, from: data) + return envelope.products ?? [] + } + + // MARK: - Private Helpers + + private func buildRequest(url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.setValue( + config.userAgent.description, forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + return request + } +} + +public enum OFFError: Error { + case invalidURL, invalidResponse +} + +// Internal Envelopes for Decoding +struct ProductResponseEnvelope: Decodable { + let code: String? + let product: Product? + let status: Int? +} + +struct SearchResponseEnvelope: Decodable { + let count: Int? + let products: [Product]? +} diff --git a/Sources/OpenFoodFactsSDK/OpenFoodFactsConfig.swift b/Sources/OpenFoodFactsSDK/OpenFoodFactsConfig.swift new file mode 100644 index 0000000..ba8d2ca --- /dev/null +++ b/Sources/OpenFoodFactsSDK/OpenFoodFactsConfig.swift @@ -0,0 +1,66 @@ +import FoundationEssentials + +public struct OpenFoodFactsConfig: Sendable { + public let baseURL: URL + public let userAgent: UserAgent + public let apiURL: URL + + public struct UserAgent: CustomStringConvertible, Sendable { + public let appName: String + public let version: String + public let contactInfo: String + + /// AppName/Version (Contact info) + public var description: String { + "\(appName)/\(version) (\(contactInfo))" + } + + public init(appName: String, version: String, contactInfo: String) { + self.appName = appName + self.version = version + self.contactInfo = contactInfo + } + + public init?(from userAgent: String) { + guard userAgent.count > 0 else { + return nil + } + let splitted = userAgent.split(separator: " ") + guard splitted.count == 2 else { + return nil + } + + let appNameVersion = splitted[0].split(separator: "/") + guard appNameVersion.count == 2 else { + return nil + } + + self.appName = String(appNameVersion[0]) + self.version = String(appNameVersion[1]) + self.contactInfo = String(splitted[1]) + } + + } + + public enum Environment: Sendable { + case production + case staging + + var url: URL { + switch self { + case .production: + return URL(string: "https://world.openfoodfacts.org")! + case .staging: + return URL(string: "https://world.openfoodfacts.net")! + } + } + } + + /// - Parameters: + /// - userAgent: Crucial for OFF. Format: "AppName/Version (Contact info)" + public init(environment: Environment = .production, userAgent: UserAgent) { + self.baseURL = environment.url + self.userAgent = userAgent + self.apiURL = self.baseURL.appendingPathComponent("/api/v2") + } +} diff --git a/Sources/OpenFoodFactsSDK/Schemas/Ingredient.swift b/Sources/OpenFoodFactsSDK/Schemas/Ingredient.swift new file mode 100644 index 0000000..5304873 --- /dev/null +++ b/Sources/OpenFoodFactsSDK/Schemas/Ingredient.swift @@ -0,0 +1,56 @@ +public struct Ingredient: Sendable, Codable { + public var fromPalmOil: String? = nil + public var id: String? = nil + public var origin: String? = nil + public var percent: Float? = nil + public var rank: Float? = 0 + public var text: String? = nil + public var vegan: String? = nil + public var vegetarian: String? = nil + public var ciqualFoodCode: String? = nil + public var ecobalyseCode: String? = nil + public var percentEstimate: Float? = nil // String or number + + private enum CodingKeys: String, CodingKey { + case fromPalmOil = "from_palm_oil" + case id + case origin + case percent + case rank + case text + case vegan + case vegetarian + case ciqualFoodCode = "ciqual_food_code" + case ecobalyseCode = "ecobalyse_code" + case percentEstimate = "percent_estimate" + } + + public var isWater: Bool { + ciqualFoodCode == "18066" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Standard String fields + id = try container.decodeIfPresent(String.self, forKey: .id) + text = try container.decodeIfPresent(String.self, forKey: .text) + vegan = try container.decodeIfPresent(String.self, forKey: .vegan) + vegetarian = try container.decodeIfPresent( + String.self, forKey: .vegetarian) + fromPalmOil = try container.decodeIfPresent( + String.self, forKey: .fromPalmOil) + origin = try container.decodeIfPresent(String.self, forKey: .origin) + ciqualFoodCode = try container.decodeIfPresent( + String.self, forKey: .ciqualFoodCode) + ecobalyseCode = try container.decodeIfPresent( + String.self, forKey: .ecobalyseCode) + + // Polymorphic Fields (String or Number) + percentEstimate = try container.decodeFloatOrString( + forKey: .percentEstimate) + percent = try container.decodeFloatOrString(forKey: .percent) + rank = try container.decodeFloatOrString(forKey: .rank) + } + +} diff --git a/Sources/OpenFoodFacts/Schemas/LanguagesCodes.swift b/Sources/OpenFoodFactsSDK/Schemas/LanguagesCodes.swift similarity index 61% rename from Sources/OpenFoodFacts/Schemas/LanguagesCodes.swift rename to Sources/OpenFoodFactsSDK/Schemas/LanguagesCodes.swift index 5f155a2..929376b 100644 --- a/Sources/OpenFoodFacts/Schemas/LanguagesCodes.swift +++ b/Sources/OpenFoodFactsSDK/Schemas/LanguagesCodes.swift @@ -1,4 +1,4 @@ -public struct LanguagesCodes: Codable, ObjectDebugger { +public struct LanguagesCodes: Sendable, Codable { public var en: Float? = nil public var fr: Float? = nil public var pl: Float? = nil diff --git a/Sources/OpenFoodFactsSDK/Schemas/NutrientLevels.swift b/Sources/OpenFoodFactsSDK/Schemas/NutrientLevels.swift new file mode 100644 index 0000000..29371c5 --- /dev/null +++ b/Sources/OpenFoodFactsSDK/Schemas/NutrientLevels.swift @@ -0,0 +1,28 @@ +public struct NutrientLevels: Sendable, Codable { + + public enum Level: String, Sendable, Codable { + case high, moderate, low + } + + public var fat: Level? = nil + public var salt: Level? = nil + public var saturatedFat: Level? = nil + public var sugars: Level? = nil + + public enum CodingKeys: String, CodingKey { + case fat + case salt + case saturatedFat = "saturated-fat" + case sugars + } + + public subscript(_ key: CodingKeys) -> Level? { + switch key { + case .fat: fat + case .salt: salt + case .saturatedFat: saturatedFat + case .sugars: sugars + } + } + +} diff --git a/Sources/OpenFoodFactsSDK/Schemas/Nutriments.swift b/Sources/OpenFoodFactsSDK/Schemas/Nutriments.swift new file mode 100644 index 0000000..91b6798 --- /dev/null +++ b/Sources/OpenFoodFactsSDK/Schemas/Nutriments.swift @@ -0,0 +1,52 @@ +import FoundationEssentials + +@dynamicMemberLookup +public struct Nutriments: Codable, Sendable { + private var storage: [String: Double] + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AnyCodingKey.self) + var dict = [String: Double]() + for key in container.allKeys { + if let val = try? container.decode(Double.self, forKey: key) { + dict[key.stringValue] = val + } else if let valStr = try? container.decode( + String.self, forKey: key), let val = Double(valStr) + { + dict[key.stringValue] = val + } + } + self.storage = dict + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: AnyCodingKey.self) + for (key, value) in storage { + try container.encode(value, forKey: AnyCodingKey(stringValue: key)) + } + } + + /// Access any nutrient dynamically (e.g. `nutriments.energyKcal`) + public subscript(dynamicMember member: String) -> Double? { + // Convert camelCase "energyKcal" to snake_case "energy-kcal" or "energy_kcal" logic if needed + // For V2, OFF often returns "energy-kcal_100g" + let snake = member.camelCaseToSnakeCase() + return storage[snake] ?? storage["\(snake)_100g"] + ?? storage["\(snake)_value"] + } + + // Specific standard getters + public var energyKcal: Double? { self.storage["energy-kcal_100g"] } + public var carbohydrates: Double? { self.storage["carbohydrates_100g"] } + public var fat: Double? { self.storage["fat_100g"] } + public var proteins: Double? { self.storage["proteins_100g"] } + public var salt: Double? { self.storage["salt_100g"] } +} + +// Helper for String extension used above +struct AnyCodingKey: CodingKey { + var stringValue: String + var intValue: Int? + init(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } +} diff --git a/Sources/OpenFoodFactsSDK/Schemas/Product.swift b/Sources/OpenFoodFactsSDK/Schemas/Product.swift new file mode 100644 index 0000000..12a15ba --- /dev/null +++ b/Sources/OpenFoodFactsSDK/Schemas/Product.swift @@ -0,0 +1,98 @@ +import FoundationEssentials + +public struct Product: Codable, Sendable, Identifiable { + public let code: String? + public let productName: String? + public let brands: String? + public let quantity: String? + public let imageFrontUrl: String? + public let imageSmallUrl: String? + public let ingredientsText: String? + + // Grades + public let nutriscoreGrade: String? + public let novaGroup: Int? + + // Nutriments + public let nutriments: Nutriments? + public let ingredients: [Ingredient]? + + // MARK: - Product-Misc + public let additivesN: Int? + public let checked: String? + public let complete: Int? + public let completeness: Float? + public let ecoscoreGrade: String? + // ... + public let nutrientLevels: NutrientLevels? + + public var id: String { + guard let code = code else { + return UUID().uuidString + } + + return code + } + + private enum CodingKeys: String, CodingKey { + case code + case productName = "product_name" + case brands + case quantity + case imageFrontUrl = "image_front_url" + case imageSmallUrl = "image_small_url" + case ingredientsText = "ingredients_text" + case nutriscoreGrade = "nutriscore_grade" + case ecoscoreGrade = "ecoscore_grade" + case novaGroup = "nova_group" + case nutriments + case ingredients + case complete + case completeness + case additivesN = "additives_n" + case checked + case nutrientLevels = "nutrient_levels" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + code = try container.decodeIfPresent(String.self, forKey: .code) + productName = try container.decodeIfPresent( + String.self, forKey: .productName) + brands = try container.decodeIfPresent(String.self, forKey: .brands) + quantity = try container.decodeIfPresent(String.self, forKey: .quantity) + imageFrontUrl = try container.decodeIfPresent( + String.self, forKey: .imageFrontUrl) + imageSmallUrl = try container.decodeIfPresent( + String.self, forKey: .imageSmallUrl) + ingredientsText = try container.decodeIfPresent( + String.self, forKey: .ingredientsText) + + // Grades + nutriscoreGrade = try container.decodeIfPresent( + String.self, forKey: .nutriscoreGrade) + novaGroup = try container.decodeIfPresent( + Int.self, forKey: .novaGroup) + + // Nutriments + nutriments = try container.decodeIfPresent( + Nutriments.self, forKey: .nutriments) + ingredients = try container.decodeIfPresent( + [Ingredient].self, forKey: .ingredients) + + // MARK: - Product-Misc + additivesN = try container.decodeIfPresent( + Int.self, forKey: .additivesN) + checked = try container.decodeIfPresent(String.self, forKey: .checked) + complete = try container.decodeIfPresent(Int.self, forKey: .complete) + completeness = try container.decodeFloatOrString(forKey: .completeness) + ecoscoreGrade = try container.decodeIfPresent( + String.self, forKey: .ecoscoreGrade) + // ... + nutrientLevels = try container.decodeIfPresent( + NutrientLevels.self, forKey: .nutrientLevels) + + } + +} diff --git a/Sources/OpenFoodFactsSDK/Schemas/ProductField.swift b/Sources/OpenFoodFactsSDK/Schemas/ProductField.swift new file mode 100644 index 0000000..d024255 --- /dev/null +++ b/Sources/OpenFoodFactsSDK/Schemas/ProductField.swift @@ -0,0 +1,20 @@ +import FoundationEssentials + +public enum ProductField: String, Sendable, Codable { + case code + case productName = "product_name" + case brands + case quantity + case nutriments + case ingredients + case nutrientLevels = "nutrient_levels" + case ingredientsText = "ingredients_text" + case imageFrontUrl = "image_front_url" + case imageSmallUrl = "image_small_url" + case categories + case ecoscoreGrade = "ecoscore_grade" + case nutriscoreGrade = "nutriscore_grade" + case novaGroup = "nova_group" + case stores + // Add other specific fields as needed +} diff --git a/Sources/OpenFoodFacts/Schemas/SearchNutriment.swift b/Sources/OpenFoodFactsSDK/Schemas/SearchNutriment.swift similarity index 100% rename from Sources/OpenFoodFacts/Schemas/SearchNutriment.swift rename to Sources/OpenFoodFactsSDK/Schemas/SearchNutriment.swift diff --git a/Sources/OpenFoodFactsSDK/Schemas/SearchParameter.swift b/Sources/OpenFoodFactsSDK/Schemas/SearchParameter.swift new file mode 100644 index 0000000..9bce093 --- /dev/null +++ b/Sources/OpenFoodFactsSDK/Schemas/SearchParameter.swift @@ -0,0 +1,30 @@ +import FoundationEssentials + +public enum SearchParameter: Sendable, Hashable { + case query(String) + case tag(tag: SearchTagType, value: String) + case page(Int) + case pageSize(Int) + case sort(SearchSort) +} + +public enum SearchTagType: String, Sendable { + case brands + case categories + case packaging + case labels + case origins + case manufacturingPlaces = "manufacturing_places" + case countries + case additives + case allergens + case traces + case states +} + +public enum SearchSort: String, Sendable { + case popularity + case productName = "product_name" + case created + case edited +} diff --git a/Sources/OpenFoodFacts/Schemas/SearchTag.swift b/Sources/OpenFoodFactsSDK/Schemas/SearchTag.swift similarity index 100% rename from Sources/OpenFoodFacts/Schemas/SearchTag.swift rename to Sources/OpenFoodFactsSDK/Schemas/SearchTag.swift diff --git a/Sources/OpenFoodFacts/Schemas/SelectedImage.swift b/Sources/OpenFoodFactsSDK/Schemas/SelectedImage.swift similarity index 84% rename from Sources/OpenFoodFacts/Schemas/SelectedImage.swift rename to Sources/OpenFoodFactsSDK/Schemas/SelectedImage.swift index d2f11e4..be1ba1b 100644 --- a/Sources/OpenFoodFacts/Schemas/SelectedImage.swift +++ b/Sources/OpenFoodFactsSDK/Schemas/SelectedImage.swift @@ -1,4 +1,4 @@ -public class SelectedImage: Codable, ObjectDebugger { +public class SelectedImage: Codable { public var display: SelectedImageItem? public var small: SelectedImageItem? public var thumb: SelectedImageItem? diff --git a/Sources/OpenFoodFacts/Schemas/SelectedImageItem.swift b/Sources/OpenFoodFactsSDK/Schemas/SelectedImageItem.swift similarity index 81% rename from Sources/OpenFoodFacts/Schemas/SelectedImageItem.swift rename to Sources/OpenFoodFactsSDK/Schemas/SelectedImageItem.swift index a6b5d72..3c284c4 100644 --- a/Sources/OpenFoodFacts/Schemas/SelectedImageItem.swift +++ b/Sources/OpenFoodFactsSDK/Schemas/SelectedImageItem.swift @@ -1,4 +1,4 @@ -public struct SelectedImageItem: Codable, ObjectDebugger { +public struct SelectedImageItem: Codable { public var en: String? public var fr: String? public var pl: String? diff --git a/Sources/OpenFoodFacts/Schemas/SelectedImages.swift b/Sources/OpenFoodFactsSDK/Schemas/SelectedImages.swift similarity index 84% rename from Sources/OpenFoodFacts/Schemas/SelectedImages.swift rename to Sources/OpenFoodFactsSDK/Schemas/SelectedImages.swift index f93f5e7..97d6d54 100644 --- a/Sources/OpenFoodFacts/Schemas/SelectedImages.swift +++ b/Sources/OpenFoodFactsSDK/Schemas/SelectedImages.swift @@ -1,4 +1,4 @@ -public struct SelectedImages: Codable, ObjectDebugger { +public struct SelectedImages: Codable { public var front: SelectedImage? public var ingredients: SelectedImage? public var nutrition: SelectedImage? diff --git a/Sources/OpenFoodFacts/Schemas/Source.swift b/Sources/OpenFoodFactsSDK/Schemas/Source.swift similarity index 89% rename from Sources/OpenFoodFacts/Schemas/Source.swift rename to Sources/OpenFoodFactsSDK/Schemas/Source.swift index a605989..972e0e4 100644 --- a/Sources/OpenFoodFacts/Schemas/Source.swift +++ b/Sources/OpenFoodFactsSDK/Schemas/Source.swift @@ -1,4 +1,4 @@ -public struct Source: Codable, ObjectDebugger { +public struct Source: Codable { public let fields: [String] = [] public let id: String? = nil public let images: [String] = [] diff --git a/Tests/OpenFoodFactsTests/OpenFoodFactsTests.swift b/Tests/OpenFoodFactsTests/OpenFoodFactsTests.swift index bbb9d87..28376a6 100644 --- a/Tests/OpenFoodFactsTests/OpenFoodFactsTests.swift +++ b/Tests/OpenFoodFactsTests/OpenFoodFactsTests.swift @@ -1,31 +1,50 @@ -@testable import OpenFoodFacts import XCTest -final class swift_openfoodfacts_sdkTests: XCTestCase { - func testBarcodeProcessing() async throws { - let off = OpenFoodFactsClient() - off.prod = true +@testable import OpenFoodFactsSDK - for barcode in ["0737628064502", "0812133010036", "0849092103196", "22007377", "3033610048398", "3222473161867", "3242272260059", "3245412470929", "3502110000880", "3551100749018", "3560070805259", "3560070976867", "3596710352418", "3800020430781", "4000539770708", "4388858946739", "5010251168577", "5015821151720", "5050854517631", "5054070608074", "5201051001076", "5410228196693", "5449000179661", "5601077161035", "6194002510064", "7311041026670", "7640101710236", "8424259826051", "8585002476821", "8712000031312", "8992696419766", "9300601768226", "9300650658615", "9310155100335"] { - do { - let _ = try await off.getProductByBarcode(barcode) - } catch { - XCTFail("[BARCODE: \(barcode)] \(error)") - } - } - } +final class OpenFoodFactsTests: XCTestCase { - func testPerlSearch() async throws { - let off = OpenFoodFactsClient() - do { - // try await off.search(query: .init(searchTerms: "prince", format: .json)) - let _ = try await off.search(query: .init( - searchTerms: "", - searchTags: [.init(tag: .brands, value: "mondelez"), .init(tag: .countries, value: "france")], - format: .json - )) - } catch { - XCTFail("\(error)") - } - } + var client: OpenFoodFactsClient! + + override func setUp() { + let config = OpenFoodFactsConfig( + environment: .staging, + userAgent: .init( + appName: "SwiftOpenFoodFactsTests", version: "1.0", + contactInfo: "(test@test.com)") + ) + client = OpenFoodFactsClient(config: config) + } + + func testProductFetch() async throws { + // Fetch specific fields only + let product = try await client.product( + barcode: "3017620422003", // Nutella + fields: [.code, .productName, .nutriscoreGrade, .nutriments] + ) + + XCTAssertEqual(product?.code, "3017620422003") + XCTAssertNotNil(product?.productName) + XCTAssertNotNil(product?.nutriscoreGrade) + // This field was NOT requested, so it should be nil (if decoding handles strict optionals) + // or the struct just holds nil. + } + + func testSearch() async throws { + let results = try await client.search( + .query("chocolate"), + .tag(tag: .brands, value: "milka"), + .pageSize(5), + .sort(.popularity), + fields: [.nutrientLevels] + ) + + let jsonResults = try JSONEncoder().encode(results) + try jsonResults.write(to: .init(filePath: "./jsonResults.json")) + + XCTAssertFalse(results.isEmpty) + XCTAssertEqual(results.count, 5) + // XCTAssertTrue( + // results.first?.brands?.lowercased().contains("milka") ?? false) + } }