This commit is contained in:
cdricms
2025-12-06 16:33:17 +01:00
parent 7be99711e1
commit 043de16a16
31 changed files with 594 additions and 1674 deletions

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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]?
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,5 @@
public struct LanguagesCodes: Sendable, Codable {
public var en: Float? = nil
public var fr: Float? = nil
public var pl: Float? = nil
}

View File

@@ -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
}
}
}

View File

@@ -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 }
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,98 @@
public enum SearchNutriment: String, Codable {
case energy // Energy
case energyFromFat = "energy-from-fat" // Energy from fat
case fat // Fat
case saturatedFat = "saturated-fat" // Saturated fat
case butyricAcid = "butyric-acid" // Butyric acid (4:0)
case caproicAcid = "caproic-acid" // Caproic acid (6:0)
case caprylicAcid = "caprylic-acid" // Caprylic acid (8:0)
case capricAcid = "capric-acid" // Capric acid (10:0)
case lauricAcid = "lauric-acid" // Lauric acid (12:0)
case myristicAcid = "myristic-acid" // Myristic acid (14:0)
case palmiticAcid = "palmitic-acid" // Palmitic acid (16:0)
case stearicAcid = "stearic-acid" // Stearic acid (18:0)
case arachidicAcid = "arachidic-acid" // Arachidic acid (20:0)
case behenicAcid = "behenic-acid" // Behenic acid (22:0)
case lignocericAcid = "lignoceric-acid" // Lignoceric acid (24:0)
case ceroticAcid = "cerotic-acid" // Cerotic acid (26:0)
case montanicAcid = "montanic-acid" // Montanic acid (28:0)
case melissicAcid = "melissic-acid" // Melissic acid (30:0)
case monounsaturatedFat = "monounsaturated-fat" // Monounsaturated fat
case polyunsaturatedFat = "polyunsaturated-fat" // Polyunsaturated fat
case omega3Fat = "omega-3-fat" // Omega 3 fatty acids
case alphaLinolenicAcid = "alpha-linolenic-acid" // Alpha-linolenic acid / ALA (18:3 n-3)
case eicosapentaenoicAcid = "eicosapentaenoic-acid" // Eicosapentaenoic acid / EPA (20:5 n-3)
case docosahexaenoicAcid = "docosahexaenoic-acid" // Docosahexaenoic acid / DHA (22:6 n-3)
case omega6Fat = "omega-6-fat" // Omega 6 fatty acids
case linoleicAcid = "linoleic-acid" // Linoleic acid / LA (18:2 n-6)
case arachidonicAcid = "arachidonic-acid" // Arachidonic acid / AA / ARA (20:4 n-6)
case gammaLinolenicAcid = "gamma-linolenic-acid" // Gamma-linolenic acid / GLA (18:3 n-6)
case dihomoGammaLinolenicAcid = "dihomo-gamma-linolenic-acid" // Dihomo-gamma-linolenic acid / DGLA (20:3 n-6)
case omega9Fat = "omega-9-fat" // Omega 9 fatty acids
case oleicAcid = "oleic-acid" // Oleic acid (18:1 n-9)
case elaidicAcid = "elaidic-acid" // Elaidic acid (18:1 n-9)
case gondoicAcid = "gondoic-acid" // Gondoic acid (20:1 n-9)
case meadAcid = "mead-acid" // Mead acid (20:3 n-9)
case erucicAcid = "erucic-acid" // Erucic acid (22:1 n-9)
case nervonicAcid = "nervonic-acid" // Nervonic acid (24:1 n-9)
case transFat = "trans-fat" // Trans fat
case cholesterol // Cholesterol
case carbohydrates // Carbohydrate
case sugars // Sugars
case sucrose // Sucrose
case glucose // Glucose
case fructose // Fructose
case lactose // Lactose
case maltose // Maltose
case maltodextrins // Maltodextrins
case starch // Starch
case polyols // Sugar alcohols (Polyols)
case fiber // Dietary fiber
case proteins // Proteins
case casein // Casein
case serumProteins = "serum-proteins" // Serum proteins
case nucleotides // Nucleotides
case salt // Salt
case sodium // Sodium
case alcohol // Alcohol
case vitaminA = "vitamin-a" // Vitamin A
case betaCarotene = "beta-carotene" // Beta carotene
case vitaminD = "vitamin-d" // Vitamin D
case vitaminE = "vitamin-e" // Vitamin E
case vitaminK = "vitamin-k" // Vitamin K
case vitaminC = "vitamin-c" // Vitamin C (ascorbic acid)
case vitaminB1 = "vitamin-b1" // Vitamin B1 (Thiamin)
case vitaminB2 = "vitamin-b2" // Vitamin B2 (Riboflavin)
case vitaminPP = "vitamin-pp" // Vitamin B3 / Vitamin PP (Niacin)
case vitaminB6 = "vitamin-b6" // Vitamin B6 (Pyridoxin)
case vitaminB9 = "vitamin-b9" // Vitamin B9 (Folic acid / Folates)
case vitaminB12 = "vitamin-b12" // Vitamin B12 (Cobalamin)
case biotin // Biotin
case pantothenicAcid = "pantothenic-acid" // Pantothenic acid / Pantothenate (Vitamin B5)
case silica // Silica
case bicarbonate // Bicarbonate
case potassium // Potassium
case chloride // Chloride
case calcium // Calcium
case phosphorus // Phosphorus
case iron // Iron
case magnesium // Magnesium
case zinc // Zinc
case copper // Copper
case manganese // Manganese
case fluoride // Fluoride
case selenium // Selenium
case chromium // Chromium
case molybdenum // Molybdenum
case iodine // Iodine
case caffeine // Caffeine
case taurine // Taurine
case pH // pH
case fruitsVegetablesNuts = "fruits-vegetables-nuts" // Fruits, vegetables, and nuts (minimum)
case collagenMeatProteinRatio = "collagen-meat-protein-ratio" // Collagen/Meat protein ratio (maximum)
case cocoa // Cocoa (minimum)
case chlorophyll = "chlorophyll" // Chlorophyll
case carbonFootprint = "carbon-footprint" // Carbon footprint / CO2 emissions
case nutritionScoreFR = "nutrition-score-fr" // Experimental nutrition score
case nutritionScoreUK = "nutrition-score-uk" // Nutrition score - UK
}

View File

@@ -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
}

View File

@@ -0,0 +1,17 @@
public enum SearchTag: String, Codable {
case brands // Brands
case categories // Categories
case packaging // Packaging
case labels // Labels
case origins // Origins of ingredients
case manufacturingPlaces = "manufacturing_places" // Manufacturing or processing places
case embCodes = "emb_codes" // Packager codes
case purchasePlaces = "purchase_places" // Purchase places
case stores // Stores
case countries // Countries
case additives // Additives
case allergens // Allergens
case traces // Traces
case nutritionGrades = "nutrition_grades" // Nutrition grades
case states // States
}

View File

@@ -0,0 +1,14 @@
public class SelectedImage: Codable {
public var display: SelectedImageItem?
public var small: SelectedImageItem?
public var thumb: SelectedImageItem?
public init(
display: SelectedImageItem?, small: SelectedImageItem?,
thumb: SelectedImageItem?
) {
self.display = display
self.small = small
self.thumb = thumb
}
}

View File

@@ -0,0 +1,15 @@
public struct SelectedImageItem: Codable {
public var en: String?
public var fr: String?
public var pl: String?
public var url: String {
[en, fr, pl].compactMap { $0 }.first ?? ""
}
public init(en: String?, fr: String?, pl: String?) {
self.en = en
self.fr = fr
self.pl = pl
}
}

View File

@@ -0,0 +1,14 @@
public struct SelectedImages: Codable {
public var front: SelectedImage?
public var ingredients: SelectedImage?
public var nutrition: SelectedImage?
public init(
front: SelectedImage?, ingredients: SelectedImage?,
nutrition: SelectedImage?
) {
self.front = front
self.ingredients = ingredients
self.nutrition = nutrition
}
}

View File

@@ -0,0 +1,19 @@
public struct Source: Codable {
public let fields: [String] = []
public let id: String? = nil
public let images: [String] = []
public let importT: Int = 0
public let manufacturer: String? = nil
public let name: String? = nil
public let url: String? = nil
private enum CodingKeys: String, CodingKey {
case fields
case id
case images
case importT = "import_t"
case manufacturer
case name
case url
}
}