Compare commits
12 Commits
feat/sdk-r
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
871cc4c6e5 | ||
|
|
b419e8c620 | ||
|
|
dd191b585d | ||
|
|
44420002c7 | ||
|
|
bdc4bb37f5 | ||
|
|
62ad13cb8c | ||
|
|
15fbc7b218 | ||
|
|
fd493ce546 | ||
|
|
bc1abfdebc | ||
|
|
a4880b9fe1 | ||
|
|
c94088c1f0 | ||
|
|
401d268aa4 |
@@ -8,4 +8,25 @@ extension KeyedDecodingContainer {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func decodeIntOrString(forKey key: Key) throws -> Int? {
|
||||
if let intVal = try? decode(Int.self, forKey: key) {
|
||||
return intVal
|
||||
}
|
||||
if let stringVal = try? decode(String.self, forKey: key) {
|
||||
return Int(stringVal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func decodeStringOrArray(forKey key: Key) throws -> String? {
|
||||
if let arrayVal = try? decode([String].self, forKey: key) {
|
||||
return arrayVal.joined(separator: ",")
|
||||
}
|
||||
if let stringVal = try? decode(String.self, forKey: key) {
|
||||
return stringVal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import FoundationEssentials
|
||||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
@@ -20,7 +20,7 @@ public actor OpenFoodFactsClient {
|
||||
/// - 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?
|
||||
async throws -> ProductResponseEnvelope
|
||||
{
|
||||
var url = config.apiURL.appendingPathComponent("/product/\(barcode)")
|
||||
|
||||
@@ -48,16 +48,16 @@ public actor OpenFoodFactsClient {
|
||||
|
||||
let envelope = try JSONDecoder().decode(
|
||||
ProductResponseEnvelope.self, from: data)
|
||||
return envelope.product
|
||||
return envelope
|
||||
}
|
||||
|
||||
/// Search for products using V2 API.
|
||||
public func search(
|
||||
_ parameters: SearchParameter..., fields: [ProductField]? = nil
|
||||
) async throws -> [Product] {
|
||||
) async throws -> SearchResponseEnvelope {
|
||||
guard
|
||||
var components = URLComponents(
|
||||
url: config.apiURL.appendingPathComponent("/search"),
|
||||
url: config.searchURL,
|
||||
resolvingAgainstBaseURL: true)
|
||||
else {
|
||||
throw OFFError.invalidURL
|
||||
@@ -76,19 +76,19 @@ public actor OpenFoodFactsClient {
|
||||
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))
|
||||
queryItems.append(URLQueryItem(name: "q", 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))
|
||||
// case .sort(let s):
|
||||
// queryItems.append(
|
||||
// URLQueryItem(name: "sort_by", value: s.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ public actor OpenFoodFactsClient {
|
||||
|
||||
let envelope = try JSONDecoder().decode(
|
||||
SearchResponseEnvelope.self, from: data)
|
||||
return envelope.products ?? []
|
||||
return envelope
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
@@ -124,14 +124,28 @@ public enum OFFError: Error {
|
||||
case invalidURL, invalidResponse
|
||||
}
|
||||
|
||||
// Internal Envelopes for Decoding
|
||||
struct ProductResponseEnvelope: Decodable {
|
||||
let code: String?
|
||||
let product: Product?
|
||||
let status: Int?
|
||||
public struct ProductResponseEnvelope: Sendable, Decodable {
|
||||
public let code: String?
|
||||
public let product: Product?
|
||||
public let status: Int?
|
||||
public let statusVerbose: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case code, product, status
|
||||
case statusVerbose = "status_verbose"
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchResponseEnvelope: Decodable {
|
||||
let count: Int?
|
||||
let products: [Product]?
|
||||
public struct SearchResponseEnvelope: Sendable, Decodable {
|
||||
public let count: Int
|
||||
public let page: Int
|
||||
public let pageSize: Int
|
||||
public let hits: [Product]
|
||||
public let pageCount: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case count, page, hits
|
||||
case pageSize = "page_size"
|
||||
case pageCount = "page_count"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import FoundationEssentials
|
||||
import Foundation
|
||||
|
||||
public struct OpenFoodFactsConfig: Sendable {
|
||||
public let baseURL: URL
|
||||
public let userAgent: UserAgent
|
||||
public let apiURL: URL
|
||||
public let searchURL: URL
|
||||
|
||||
public struct UserAgent: CustomStringConvertible, Sendable {
|
||||
public let appName: String
|
||||
@@ -62,5 +63,6 @@ public struct OpenFoodFactsConfig: Sendable {
|
||||
self.baseURL = environment.url
|
||||
self.userAgent = userAgent
|
||||
self.apiURL = self.baseURL.appendingPathComponent("/api/v2")
|
||||
self.searchURL = URL(string: "https://search.openfoodfacts.org/search")!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,324 @@
|
||||
import FoundationEssentials
|
||||
import Foundation
|
||||
import Units
|
||||
|
||||
@dynamicMemberLookup
|
||||
public struct Nutriments: Codable, Sendable {
|
||||
private var storage: [String: Double]
|
||||
public struct Nutriments: Codable, Sendable, Sequence {
|
||||
// We separate values (Doubles) and units (Strings) for efficient, thread-safe storage
|
||||
private let values: [String: Double]
|
||||
private let units: [String: String]
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||
var dict = [String: Double]()
|
||||
var v = [String: Double]()
|
||||
var u = [String: String]()
|
||||
|
||||
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)
|
||||
let keyStr = key.stringValue
|
||||
|
||||
// Try decoding as Double first (most common for _100g, _serving)
|
||||
if let doubleVal = try? container.decode(Double.self, forKey: key) {
|
||||
v[keyStr] = doubleVal
|
||||
}
|
||||
// Try decoding as String (could be a unit OR a number in string format)
|
||||
else if let stringVal = try? container.decode(
|
||||
String.self, forKey: key)
|
||||
{
|
||||
dict[key.stringValue] = val
|
||||
if let doubleFromStr = Double(stringVal) {
|
||||
v[keyStr] = doubleFromStr
|
||||
} else {
|
||||
u[keyStr] = stringVal // It's likely a unit like "kcal", "g", "kj"
|
||||
}
|
||||
}
|
||||
self.storage = dict
|
||||
}
|
||||
self.values = v
|
||||
self.units = u
|
||||
}
|
||||
|
||||
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))
|
||||
for (key, val) in values {
|
||||
try container.encode(val, forKey: AnyCodingKey(stringValue: key))
|
||||
}
|
||||
for (key, unit) in units {
|
||||
try container.encode(unit, 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"]
|
||||
/// Dynamic lookup: converts `nutriments.energyKcal` -> `Nutrient(name: "energy-kcal")`
|
||||
public subscript(dynamicMember member: String) -> Nutrient {
|
||||
let kebabName = member.camelCaseToKebabCase()
|
||||
return Nutrient(name: kebabName, values: values, units: units)
|
||||
}
|
||||
|
||||
// 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"] }
|
||||
// MARK: - Sequence Conformance
|
||||
// Allows: nutriments.forEach { nutrient in ... }
|
||||
public func makeIterator() -> AnyIterator<Nutrient> {
|
||||
// 1. Collect all keys from values and units
|
||||
let allKeys = Set(values.keys).union(units.keys)
|
||||
|
||||
// 2. Extract unique base names (e.g., "energy-kcal_100g" -> "energy-kcal")
|
||||
let baseNames = allKeys.compactMap { key -> String? in
|
||||
// Filter out keys that are not nutrient properties
|
||||
guard !key.isEmpty else { return nil }
|
||||
|
||||
// Remove common suffixes to find the "root" nutrient name
|
||||
let suffixes = [
|
||||
"_100g", "_serving", "_unit", "_value", "_prepared", "_label",
|
||||
]
|
||||
var name = key
|
||||
|
||||
// Sort suffixes by length (descending) to avoid partial matches
|
||||
for suffix in suffixes {
|
||||
if let range = name.range(of: suffix) {
|
||||
name.removeSubrange(range)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// 3. Create a unique sorted list
|
||||
let uniqueNames = Set(baseNames).sorted()
|
||||
var iterator = uniqueNames.makeIterator()
|
||||
|
||||
// 4. Return an iterator that produces `Nutrient` views
|
||||
return AnyIterator {
|
||||
guard let name = iterator.next() else { return nil }
|
||||
return Nutrient(name: name, values: self.values, units: self.units)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for String extension used above
|
||||
struct AnyCodingKey: CodingKey {
|
||||
// MARK: - Nutrient View
|
||||
public struct Nutrient: Sendable {
|
||||
public let name: String
|
||||
private let values: [String: Double]
|
||||
private let units: [String: String]
|
||||
|
||||
internal init(
|
||||
name: String, values: [String: Double], units: [String: String]
|
||||
) {
|
||||
self.name = name
|
||||
self.values = values
|
||||
self.units = units
|
||||
}
|
||||
|
||||
// MARK: - Raw Properties (Mapped to V2 JSON keys)
|
||||
public var per100g: Double? { values["\(name)_100g"] }
|
||||
public var perServing: Double? { values["\(name)_serving"] }
|
||||
public var value: Double? { values["\(name)_value"] ?? values[name] }
|
||||
public var unit: String? { units["\(name)_unit"] ?? _unit?.rawValue }
|
||||
|
||||
// Computed / Legacy
|
||||
public var valueComputed: Double? { values["\(name)_value"] }
|
||||
|
||||
// Prepared
|
||||
public var preparedPer100g: Double? { values["\(name)_prepared_100g"] }
|
||||
public var preparedPerServing: Double? {
|
||||
values["\(name)_prepared_serving"]
|
||||
}
|
||||
public var preparedValue: Double? {
|
||||
values["\(name)_prepared_value"] ?? values["\(name)_prepared"]
|
||||
}
|
||||
public var preparedUnit: String? { units["\(name)_prepared_unit"] }
|
||||
public var preparedValueComputed: Double? {
|
||||
values["\(name)_prepared_value"]
|
||||
}
|
||||
|
||||
// MARK: - UnitValue Helpers (Restored)
|
||||
|
||||
public var per100gUnitValue: UnitValue<Double>? {
|
||||
guard let rawValue = per100g ?? value ?? valueComputed,
|
||||
let unitString = unit,
|
||||
let unitEnum = Unit(rawValue: unitString)
|
||||
else { return nil }
|
||||
return UnitValue(value: rawValue, unit: unitEnum)
|
||||
}
|
||||
|
||||
public var perServingUnitValue: UnitValue<Double>? {
|
||||
guard let rawValue = perServing,
|
||||
let unitString = unit,
|
||||
let unitEnum = Unit(rawValue: unitString)
|
||||
else { return nil }
|
||||
return UnitValue(value: rawValue, unit: unitEnum)
|
||||
}
|
||||
|
||||
public var preparedPer100gUnitValue: UnitValue<Double>? {
|
||||
guard
|
||||
let rawValue = preparedPer100g ?? preparedValue
|
||||
?? preparedValueComputed,
|
||||
let unitString = preparedUnit ?? unit, // Fallback to main unit
|
||||
let unitEnum = Unit(rawValue: unitString)
|
||||
else { return nil }
|
||||
return UnitValue(value: rawValue, unit: unitEnum)
|
||||
}
|
||||
|
||||
public var preparedPerServingUnitValue: UnitValue<Double>? {
|
||||
guard let rawValue = preparedPerServing,
|
||||
let unitString = preparedUnit ?? unit,
|
||||
let unitEnum = Unit(rawValue: unitString)
|
||||
else { return nil }
|
||||
return UnitValue(value: rawValue, unit: unitEnum)
|
||||
}
|
||||
|
||||
private var _unit: Units.Unit? {
|
||||
switch name {
|
||||
case "energy-kj": .init(rawValue: "kJ")
|
||||
case "energy-kcal": .init(rawValue: "kcal")
|
||||
case "energy": .init(rawValue: "kj")
|
||||
case "energy-from-fat": .init(rawValue: "kJ")
|
||||
case "fat": .init(rawValue: "g")
|
||||
case "saturated-fat": .init(rawValue: "g")
|
||||
case "butyric-acid": .init(rawValue: "g")
|
||||
case "caproic-acid": .init(rawValue: "g")
|
||||
case "caprylic-acid": .init(rawValue: "g")
|
||||
case "capric-acid": .init(rawValue: "g")
|
||||
case "lauric-acid": .init(rawValue: "g")
|
||||
case "myristic-acid": .init(rawValue: "g")
|
||||
case "palmitic-acid": .init(rawValue: "g")
|
||||
case "Psicose": .init(rawValue: "g")
|
||||
case "stearic-acid": .init(rawValue: "g")
|
||||
case "arachidic-acid": .init(rawValue: "g")
|
||||
case "behenic-acid": .init(rawValue: "g")
|
||||
case "lignoceric-acid": .init(rawValue: "g")
|
||||
case "cerotic-acid": .init(rawValue: "g")
|
||||
case "montanic-acid": .init(rawValue: "g")
|
||||
case "melissic-acid": .init(rawValue: "g")
|
||||
case "unsaturated-fat": .init(rawValue: "g")
|
||||
case "monounsaturated-fat": .init(rawValue: "g")
|
||||
case "polyunsaturated-fat": .init(rawValue: "g")
|
||||
case "omega-3-fat": .init(rawValue: "mg")
|
||||
case "alpha-linolenic-acid": .init(rawValue: "g")
|
||||
case "eicosapentaenoic-acid": .init(rawValue: "g")
|
||||
case "docosahexaenoic-acid": .init(rawValue: "g")
|
||||
case "omega-6-fat": .init(rawValue: "mg")
|
||||
case "linoleic-acid": .init(rawValue: "g")
|
||||
case "arachidonic-acid": .init(rawValue: "g")
|
||||
case "gamma-linolenic-acid": .init(rawValue: "g")
|
||||
case "dihomo-gamma-linolenic-acid": .init(rawValue: "g")
|
||||
case "omega-9-fat": .init(rawValue: "mg")
|
||||
case "oleic-acid": .init(rawValue: "g")
|
||||
case "elaidic-acid": .init(rawValue: "g")
|
||||
case "gondoic-acid": .init(rawValue: "g")
|
||||
case "mead-acid": .init(rawValue: "g")
|
||||
case "erucic-acid": .init(rawValue: "g")
|
||||
case "nervonic-acid": .init(rawValue: "g")
|
||||
case "trans-fat": .init(rawValue: "g")
|
||||
case "cholesterol": .init(rawValue: "mg")
|
||||
case "gamma-oryzanol": .init(rawValue: "mg")
|
||||
case "carbohydrates-total": .init(rawValue: "g")
|
||||
case "carbohydrates": .init(rawValue: "g")
|
||||
case "sugars": .init(rawValue: "g")
|
||||
case "added-sugars": .init(rawValue: "g")
|
||||
case "sucrose": .init(rawValue: "g")
|
||||
case "glucose": .init(rawValue: "g")
|
||||
case "fructose": .init(rawValue: "g")
|
||||
case "oligosaccharide": .init(rawValue: "g")
|
||||
case "lactose": .init(rawValue: "g")
|
||||
case "galactose": .init(rawValue: "g")
|
||||
case "maltose": .init(rawValue: "g")
|
||||
case "maltodextrins": .init(rawValue: "g")
|
||||
case "starch": .init(rawValue: "g")
|
||||
case "polyols": .init(rawValue: "g")
|
||||
case "Erythritol": .init(rawValue: "g")
|
||||
case "Isomalt": .init(rawValue: "g")
|
||||
case "Maltitol": .init(rawValue: "g")
|
||||
case "Sorbitol": .init(rawValue: "g")
|
||||
case "fiber": .init(rawValue: "g")
|
||||
case "soluble-fiber": .init(rawValue: "g")
|
||||
case "insoluble-fiber": .init(rawValue: "g")
|
||||
case "proteins": .init(rawValue: "g")
|
||||
case "casein": .init(rawValue: "g")
|
||||
case "serum-proteins": .init(rawValue: "g")
|
||||
case "nucleotides": .init(rawValue: "g")
|
||||
case "salt": .init(rawValue: "g")
|
||||
case "added-salt": .init(rawValue: "g")
|
||||
case "sodium": .init(rawValue: "g")
|
||||
case "alcohol": .init(rawValue: "% vol")
|
||||
case "vitamin-a": .init(rawValue: "µg")
|
||||
case "beta-carotene": .init(rawValue: "g")
|
||||
case "vitamin-d": .init(rawValue: "µg")
|
||||
case "vitamin-e": .init(rawValue: "mg")
|
||||
case "vitamin-k": .init(rawValue: "µg")
|
||||
case "vitamin-c": .init(rawValue: "mg")
|
||||
case "vitamin-b1": .init(rawValue: "mg")
|
||||
case "vitamin-b2": .init(rawValue: "mg")
|
||||
case "vitamin-pp": .init(rawValue: "mg")
|
||||
case "vitamin-b6": .init(rawValue: "mg")
|
||||
case "vitamin-b9": .init(rawValue: "µg")
|
||||
case "folates": .init(rawValue: "µg")
|
||||
case "vitamin-b12": .init(rawValue: "µg")
|
||||
case "biotin": .init(rawValue: "µg")
|
||||
case "pantothenic-acid": .init(rawValue: "mg")
|
||||
case "silica": .init(rawValue: "mg")
|
||||
case "bicarbonate": .init(rawValue: "mg")
|
||||
case "Sulphate": .init(rawValue: "mg")
|
||||
case "Nitrate": .init(rawValue: "mg")
|
||||
case "potassium": .init(rawValue: "mg")
|
||||
case "chloride": .init(rawValue: "mg")
|
||||
case "calcium": .init(rawValue: "mg")
|
||||
case "phosphorus": .init(rawValue: "mg")
|
||||
case "iron": .init(rawValue: "mg")
|
||||
case "magnesium": .init(rawValue: "mg")
|
||||
case "zinc": .init(rawValue: "mg")
|
||||
case "copper": .init(rawValue: "mg")
|
||||
case "manganese": .init(rawValue: "mg")
|
||||
case "fluoride": .init(rawValue: "mg")
|
||||
case "selenium": .init(rawValue: "µg")
|
||||
case "chromium": .init(rawValue: "µg")
|
||||
case "molybdenum": .init(rawValue: "µg")
|
||||
case "iodine": .init(rawValue: "µg")
|
||||
case "caffeine": .init(rawValue: "mg")
|
||||
case "taurine": .init(rawValue: "g")
|
||||
case "chlorophyl": .init(rawValue: "g")
|
||||
case "choline": .init(rawValue: "g")
|
||||
case "phylloquinone": .init(rawValue: "g")
|
||||
case "beta-glucan": .init(rawValue: "g")
|
||||
case "inositol": .init(rawValue: "g")
|
||||
case "carnitine": .init(rawValue: "g")
|
||||
case "melatonin": .init(rawValue: "µg")
|
||||
case "methylsulfonylmethane": .init(rawValue: "mg")
|
||||
case "creatine": .init(rawValue: "g")
|
||||
case "l-citrulline": .init(rawValue: "mg")
|
||||
case "l-glutamine": .init(rawValue: "mg")
|
||||
case "bcaa": .init(rawValue: "g")
|
||||
case "l-valine": .init(rawValue: "mg")
|
||||
case "l-leucine": .init(rawValue: "mg")
|
||||
case "l-isoleucine": .init(rawValue: "mg")
|
||||
case "l-arginine": .init(rawValue: "mg")
|
||||
case "l-cysteine": .init(rawValue: "mg")
|
||||
case "l-Glutathione": .init(rawValue: "mg")
|
||||
case "iron II sulphate monohydrate": .init(rawValue: "mg")
|
||||
case "potassium iodide": .init(rawValue: "mg")
|
||||
case "copper II sulphate pentahydrate": .init(rawValue: "mg")
|
||||
case "manganous sulphate monohydrate": .init(rawValue: "mg")
|
||||
case "zinc sulphate monohydrate": .init(rawValue: "mg")
|
||||
case "sodium selenite": .init(rawValue: "mg")
|
||||
case "calcium iodate anhydrous": .init(rawValue: "mg")
|
||||
case "cassia gum": .init(rawValue: "mg")
|
||||
case "ammonium chloride": .init(rawValue: "mg")
|
||||
case "choline chloride": .init(rawValue: "mg")
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private struct AnyCodingKey: CodingKey {
|
||||
var stringValue: String
|
||||
var intValue: Int?
|
||||
init(stringValue: String) { self.stringValue = stringValue }
|
||||
init?(intValue: Int) { return nil }
|
||||
}
|
||||
|
||||
extension String {
|
||||
// Converts "energyKcal" -> "energy-kcal"
|
||||
fileprivate func camelCaseToKebabCase() -> 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()
|
||||
}
|
||||
}
|
||||
|
||||
45
Sources/OpenFoodFactsSDK/Schemas/NutriscoreData.swift
Normal file
45
Sources/OpenFoodFactsSDK/Schemas/NutriscoreData.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
public struct NutriscoreData: Sendable, Codable {
|
||||
|
||||
public let saturatedFatRatio: Float?
|
||||
public let saturatedFatRatioPoints: Int?
|
||||
public let saturatedFatRatioValue: Float?
|
||||
|
||||
public let isBeverage: Int?
|
||||
public let isCheese: Int?
|
||||
public let isWater: Int?
|
||||
public let isFat: Int?
|
||||
public let energy: Int?
|
||||
|
||||
// ...
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case saturatedFatRatio = "saturated_fat_ratio"
|
||||
case saturatedFatRatioPoints = "saturated_fat_ratio_points"
|
||||
case saturatedFatRatioValue = "saturated_fat_ratio_value"
|
||||
case isBeverage = "is_beverage"
|
||||
case isCheese = "is_cheese"
|
||||
case isWater = "is_water"
|
||||
case isFat = "is_fat"
|
||||
case energy
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
saturatedFatRatio = try container.decodeIfPresent(
|
||||
Float.self, forKey: .saturatedFatRatio)
|
||||
saturatedFatRatioPoints = try container.decodeIfPresent(
|
||||
Int.self, forKey: .saturatedFatRatioPoints)
|
||||
saturatedFatRatioValue = try container.decodeIfPresent(
|
||||
Float.self, forKey: .saturatedFatRatioValue)
|
||||
|
||||
isBeverage = try container.decodeIfPresent(
|
||||
Int.self, forKey: .isBeverage)
|
||||
isCheese = try container.decodeIntOrString(forKey: .isCheese)
|
||||
isWater = try container.decodeIntOrString(forKey: .isWater)
|
||||
isFat = try container.decodeIntOrString(forKey: .isFat)
|
||||
energy = try container.decodeIntOrString(forKey: .energy)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,98 +1,246 @@
|
||||
import FoundationEssentials
|
||||
import Foundation
|
||||
import Units
|
||||
|
||||
public struct Product: Codable, Sendable, Identifiable {
|
||||
public let code: String?
|
||||
public let productName: String?
|
||||
public let genericName: String?
|
||||
public let brands: String?
|
||||
|
||||
public var _brands: [String]? {
|
||||
guard let brands = brands else {
|
||||
return nil
|
||||
}
|
||||
return brands.split(separator: ",").map {
|
||||
$0.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
public let brandsTags: [String]?
|
||||
public let quantity: String?
|
||||
|
||||
// MARK: - Images
|
||||
public let imageFrontUrl: String?
|
||||
public let imageSmallUrl: String?
|
||||
public let ingredientsText: String?
|
||||
public let imageUrl: String?
|
||||
public let imageNutritionSmallUrl: String?
|
||||
public let imageNutritionThumbUrl: String?
|
||||
public let imageNutritionUrl: String?
|
||||
public let imageIngredientsSmallUrl: String?
|
||||
public let imageIngredientsThumbUrl: String?
|
||||
public let imageIngredientsUrl: String?
|
||||
|
||||
// Grades
|
||||
// MARK: - Quantities
|
||||
public let productQuantity: Float?
|
||||
public let productQuantityUnit: String?
|
||||
public let servingQuantity: String?
|
||||
public let servingQuantityUnit: String?
|
||||
public let servingSize: String?
|
||||
|
||||
// MARK: - Grades & Scores
|
||||
public let nutriscoreGrade: String?
|
||||
public let nutriscoreData: NutriscoreData?
|
||||
public let novaGroup: Int?
|
||||
public let ecoscoreGrade: String?
|
||||
|
||||
// Nutriments
|
||||
public let nutritionDataPer: String?
|
||||
public let nutriments: Nutriments?
|
||||
public let ingredients: [Ingredient]?
|
||||
public let nutrientLevels: NutrientLevels?
|
||||
|
||||
// MARK: - Ingredients & Allergens
|
||||
public let ingredientsText: String?
|
||||
public let ingredients: [Ingredient]?
|
||||
public let ingredientsHierarchy: [String]?
|
||||
public let allergens: String?
|
||||
public let allergensHierarchy: [String]?
|
||||
|
||||
// MARK: - Product-Misc
|
||||
public let additivesN: Int?
|
||||
|
||||
// MARK: - Metadata
|
||||
public let checked: String?
|
||||
public let complete: Int?
|
||||
public let completeness: Float?
|
||||
public let ecoscoreGrade: String?
|
||||
// ...
|
||||
public let nutrientLevels: NutrientLevels?
|
||||
public var categories: String?
|
||||
public var categoriesHierarchy: [String]?
|
||||
|
||||
public var id: String {
|
||||
guard let code = code else {
|
||||
return UUID().uuidString
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties (Units)
|
||||
public var waterQuantity: Units.UnitValue<Double>? {
|
||||
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
|
||||
}
|
||||
|
||||
// MARK: - CodingKeys
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case code
|
||||
case productName = "product_name"
|
||||
case genericName = "generic_name"
|
||||
case brands
|
||||
case brandsTags = "brands_tags"
|
||||
case quantity
|
||||
|
||||
// Images
|
||||
case imageFrontUrl = "image_front_url"
|
||||
case imageSmallUrl = "image_small_url"
|
||||
case imageUrl = "image_url"
|
||||
case imageNutritionSmallUrl = "image_nutrition_small_url"
|
||||
case imageNutritionThumbUrl = "image_nutrition_thumb_url"
|
||||
case imageNutritionUrl = "image_nutrition_url"
|
||||
case imageIngredientsSmallUrl = "image_ingredients_small_url"
|
||||
case imageIngredientsThumbUrl = "image_ingredients_thumb_url"
|
||||
case imageIngredientsUrl = "image_ingredients_url"
|
||||
|
||||
// Quantities
|
||||
case productQuantity = "product_quantity"
|
||||
case productQuantityUnit = "product_quantity_unit"
|
||||
case servingQuantity = "serving_quantity"
|
||||
case servingQuantityUnit = "serving_quantity_unit"
|
||||
case servingSize = "serving_size"
|
||||
|
||||
case ingredientsText = "ingredients_text"
|
||||
case ingredients
|
||||
case ingredientsHierarchy = "ingredients_hierarchy"
|
||||
|
||||
// Grades & Data
|
||||
case nutriscoreGrade = "nutriscore_grade"
|
||||
case nutriscoreData = "nutriscore_data"
|
||||
case ecoscoreGrade = "ecoscore_grade"
|
||||
case novaGroup = "nova_group"
|
||||
case nutriments
|
||||
case ingredients
|
||||
case nutrientLevels = "nutrient_levels"
|
||||
case nutritionDataPer = "nutrition_data_per"
|
||||
|
||||
case complete
|
||||
case completeness
|
||||
case additivesN = "additives_n"
|
||||
case checked
|
||||
case nutrientLevels = "nutrient_levels"
|
||||
|
||||
case allergens
|
||||
case allergensHierarchy = "allergens_hierarchy"
|
||||
case categories
|
||||
case categoriesHierarchy = "categories_hierarchy"
|
||||
}
|
||||
|
||||
// MARK: - Initializer
|
||||
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)
|
||||
genericName = try container.decodeIfPresent(
|
||||
String.self, forKey: .genericName)
|
||||
brands = try container.decodeStringOrArray(forKey: .brands)
|
||||
brandsTags = try container.decodeIfPresent(
|
||||
[String].self, forKey: .brandsTags)
|
||||
quantity = try container.decodeIfPresent(String.self, forKey: .quantity)
|
||||
|
||||
// Images
|
||||
imageFrontUrl = try container.decodeIfPresent(
|
||||
String.self, forKey: .imageFrontUrl)
|
||||
imageSmallUrl = try container.decodeIfPresent(
|
||||
String.self, forKey: .imageSmallUrl)
|
||||
imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl)
|
||||
imageNutritionSmallUrl = try container.decodeIfPresent(
|
||||
String.self, forKey: .imageNutritionSmallUrl)
|
||||
imageNutritionThumbUrl = try container.decodeIfPresent(
|
||||
String.self, forKey: .imageNutritionThumbUrl)
|
||||
imageNutritionUrl = try container.decodeIfPresent(
|
||||
String.self, forKey: .imageNutritionUrl)
|
||||
imageIngredientsSmallUrl = try container.decodeIfPresent(
|
||||
String.self, forKey: .imageIngredientsSmallUrl)
|
||||
imageIngredientsThumbUrl = try container.decodeIfPresent(
|
||||
String.self, forKey: .imageIngredientsThumbUrl)
|
||||
imageIngredientsUrl = try container.decodeIfPresent(
|
||||
String.self, forKey: .imageIngredientsUrl)
|
||||
|
||||
// Quantities (Robust decoding)
|
||||
productQuantity = try container.decodeFloatOrString(
|
||||
forKey: .productQuantity)
|
||||
productQuantityUnit = try container.decodeIfPresent(
|
||||
String.self, forKey: .productQuantityUnit)
|
||||
|
||||
// servingQuantity is String?, but API might return a number
|
||||
if let floatVal = try? container.decode(
|
||||
Float.self, forKey: .servingQuantity)
|
||||
{
|
||||
servingQuantity = String(floatVal)
|
||||
} else {
|
||||
servingQuantity = try container.decodeIfPresent(
|
||||
String.self, forKey: .servingQuantity)
|
||||
}
|
||||
|
||||
servingQuantityUnit = try container.decodeIfPresent(
|
||||
String.self, forKey: .servingQuantityUnit)
|
||||
servingSize = try container.decodeIfPresent(
|
||||
String.self, forKey: .servingSize)
|
||||
|
||||
// Ingredients & Categories
|
||||
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)
|
||||
ingredientsHierarchy = try container.decodeIfPresent(
|
||||
[String].self, forKey: .ingredientsHierarchy)
|
||||
|
||||
// MARK: - Product-Misc
|
||||
categories = try container.decodeIfPresent(
|
||||
String.self, forKey: .categories)
|
||||
categoriesHierarchy = try container.decodeIfPresent(
|
||||
[String].self, forKey: .categoriesHierarchy)
|
||||
|
||||
// Grades & Nutriments
|
||||
nutriscoreGrade = try container.decodeIfPresent(
|
||||
String.self, forKey: .nutriscoreGrade)
|
||||
nutriscoreData = try container.decodeIfPresent(
|
||||
NutriscoreData.self, forKey: .nutriscoreData)
|
||||
novaGroup = try container.decodeIfPresent(Int.self, forKey: .novaGroup)
|
||||
ecoscoreGrade = try container.decodeIfPresent(
|
||||
String.self, forKey: .ecoscoreGrade)
|
||||
|
||||
nutriments = try container.decodeIfPresent(
|
||||
Nutriments.self, forKey: .nutriments)
|
||||
nutrientLevels = try container.decodeIfPresent(
|
||||
NutrientLevels.self, forKey: .nutrientLevels)
|
||||
nutritionDataPer = try container.decodeIfPresent(
|
||||
String.self, forKey: .nutritionDataPer)
|
||||
|
||||
// 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)
|
||||
|
||||
allergens = try container.decodeIfPresent(
|
||||
String.self, forKey: .allergens)
|
||||
allergensHierarchy = try container.decodeIfPresent(
|
||||
[String].self, forKey: .allergensHierarchy)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import FoundationEssentials
|
||||
import Foundation
|
||||
|
||||
public enum ProductField: String, Sendable, Codable {
|
||||
case code
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import FoundationEssentials
|
||||
import Foundation
|
||||
|
||||
public enum SearchParameter: Sendable, Hashable {
|
||||
case query(String)
|
||||
case tag(tag: SearchTagType, value: String)
|
||||
// case tag(tag: SearchTagType, value: String)
|
||||
case page(Int)
|
||||
case pageSize(Int)
|
||||
case sort(SearchSort)
|
||||
// case sort(SearchSort)
|
||||
}
|
||||
|
||||
public enum SearchTagType: String, Sendable {
|
||||
|
||||
7239
Sources/OpenFoodFactsSDK/Schemas/nutrients.txt
Normal file
7239
Sources/OpenFoodFactsSDK/Schemas/nutrients.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,11 +18,13 @@ final class OpenFoodFactsTests: XCTestCase {
|
||||
|
||||
func testProductFetch() async throws {
|
||||
// Fetch specific fields only
|
||||
let product = try await client.product(
|
||||
let response = try await client.product(
|
||||
barcode: "3017620422003", // Nutella
|
||||
fields: [.code, .productName, .nutriscoreGrade, .nutriments]
|
||||
)
|
||||
|
||||
let product = response.product
|
||||
|
||||
XCTAssertEqual(product?.code, "3017620422003")
|
||||
XCTAssertNotNil(product?.productName)
|
||||
XCTAssertNotNil(product?.nutriscoreGrade)
|
||||
@@ -31,14 +33,20 @@ final class OpenFoodFactsTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSearch() async throws {
|
||||
let results = try await client.search(
|
||||
.query("chocolate"),
|
||||
.tag(tag: .brands, value: "milka"),
|
||||
let response = try await client.search(
|
||||
.query("Peanut butter"),
|
||||
// .tag(tag: .brands, value: "milka"),
|
||||
.pageSize(5),
|
||||
.sort(.popularity),
|
||||
fields: [.nutrientLevels]
|
||||
// .sort(.popularity),
|
||||
)
|
||||
|
||||
let results = response.hits
|
||||
let a = results.compactMap { $0.nutriments }
|
||||
print(
|
||||
a.compactMap { b in
|
||||
b.fat.per100gUnitValue
|
||||
})
|
||||
|
||||
let jsonResults = try JSONEncoder().encode(results)
|
||||
try jsonResults.write(to: .init(filePath: "./jsonResults.json"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user