Compare commits

...

8 Commits

Author SHA1 Message Date
cdricms
871cc4c6e5 Brands properly formatted 2025-12-31 16:57:50 +01:00
cdricms
b419e8c620 Should have unit even if not given by OFF 2025-12-06 19:31:18 +01:00
cdricms
dd191b585d 😑 using search-a-licious API now... 2025-12-06 18:43:14 +01:00
cdricms
44420002c7 😑 2025-12-06 18:15:17 +01:00
cdricms
bdc4bb37f5 Ahhh, OpenFoodFacts and their types... 2025-12-06 18:13:36 +01:00
cdricms
62ad13cb8c Should work 2025-12-06 17:56:19 +01:00
cdricms
15fbc7b218 That mf everytime 2025-12-06 17:54:19 +01:00
cdricms
fd493ce546 Return response instead of data 2025-12-06 17:53:51 +01:00
9 changed files with 7485 additions and 30 deletions

View File

@@ -8,4 +8,25 @@ extension KeyedDecodingContainer {
} }
return nil 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
}
} }

View File

@@ -20,7 +20,7 @@ public actor OpenFoodFactsClient {
/// - barcode: The product barcode. /// - barcode: The product barcode.
/// - fields: Optional list of fields to fetch (optimizes network usage). /// - fields: Optional list of fields to fetch (optimizes network usage).
public func product(barcode: String, fields: [ProductField]? = nil) public func product(barcode: String, fields: [ProductField]? = nil)
async throws -> Product? async throws -> ProductResponseEnvelope
{ {
var url = config.apiURL.appendingPathComponent("/product/\(barcode)") var url = config.apiURL.appendingPathComponent("/product/\(barcode)")
@@ -48,16 +48,16 @@ public actor OpenFoodFactsClient {
let envelope = try JSONDecoder().decode( let envelope = try JSONDecoder().decode(
ProductResponseEnvelope.self, from: data) ProductResponseEnvelope.self, from: data)
return envelope.product return envelope
} }
/// Search for products using V2 API. /// Search for products using V2 API.
public func search( public func search(
_ parameters: SearchParameter..., fields: [ProductField]? = nil _ parameters: SearchParameter..., fields: [ProductField]? = nil
) async throws -> [Product] { ) async throws -> SearchResponseEnvelope {
guard guard
var components = URLComponents( var components = URLComponents(
url: config.apiURL.appendingPathComponent("/search"), url: config.searchURL,
resolvingAgainstBaseURL: true) resolvingAgainstBaseURL: true)
else { else {
throw OFFError.invalidURL throw OFFError.invalidURL
@@ -76,19 +76,19 @@ public actor OpenFoodFactsClient {
for param in parameters { for param in parameters {
switch param { switch param {
case .query(let q): case .query(let q):
queryItems.append(URLQueryItem(name: "search_terms", value: q)) queryItems.append(URLQueryItem(name: "q", value: q))
case .tag(let tag, let value): // case .tag(let tag, let value):
// V2 allows dynamic tags like `brands_tags=coca` // // V2 allows dynamic tags like `brands_tags=coca`
queryItems.append( // queryItems.append(
URLQueryItem(name: "\(tag.rawValue)_tags", value: value)) // URLQueryItem(name: "\(tag.rawValue)_tags", value: value))
case .page(let p): case .page(let p):
queryItems.append(URLQueryItem(name: "page", value: String(p))) queryItems.append(URLQueryItem(name: "page", value: String(p)))
case .pageSize(let s): case .pageSize(let s):
queryItems.append( queryItems.append(
URLQueryItem(name: "page_size", value: String(s))) URLQueryItem(name: "page_size", value: String(s)))
case .sort(let s): // case .sort(let s):
queryItems.append( // queryItems.append(
URLQueryItem(name: "sort_by", value: s.rawValue)) // URLQueryItem(name: "sort_by", value: s.rawValue))
} }
} }
@@ -106,7 +106,7 @@ public actor OpenFoodFactsClient {
let envelope = try JSONDecoder().decode( let envelope = try JSONDecoder().decode(
SearchResponseEnvelope.self, from: data) SearchResponseEnvelope.self, from: data)
return envelope.products ?? [] return envelope
} }
// MARK: - Private Helpers // MARK: - Private Helpers
@@ -124,14 +124,28 @@ public enum OFFError: Error {
case invalidURL, invalidResponse case invalidURL, invalidResponse
} }
// Internal Envelopes for Decoding public struct ProductResponseEnvelope: Sendable, Decodable {
struct ProductResponseEnvelope: Decodable { public let code: String?
let code: String? public let product: Product?
let product: Product? public let status: Int?
let status: Int? public let statusVerbose: String?
private enum CodingKeys: String, CodingKey {
case code, product, status
case statusVerbose = "status_verbose"
}
} }
struct SearchResponseEnvelope: Decodable { public struct SearchResponseEnvelope: Sendable, Decodable {
let count: Int? public let count: Int
let products: [Product]? 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"
}
} }

View File

@@ -4,6 +4,7 @@ public struct OpenFoodFactsConfig: Sendable {
public let baseURL: URL public let baseURL: URL
public let userAgent: UserAgent public let userAgent: UserAgent
public let apiURL: URL public let apiURL: URL
public let searchURL: URL
public struct UserAgent: CustomStringConvertible, Sendable { public struct UserAgent: CustomStringConvertible, Sendable {
public let appName: String public let appName: String
@@ -62,5 +63,6 @@ public struct OpenFoodFactsConfig: Sendable {
self.baseURL = environment.url self.baseURL = environment.url
self.userAgent = userAgent self.userAgent = userAgent
self.apiURL = self.baseURL.appendingPathComponent("/api/v2") self.apiURL = self.baseURL.appendingPathComponent("/api/v2")
self.searchURL = URL(string: "https://search.openfoodfacts.org/search")!
} }
} }

View File

@@ -106,7 +106,7 @@ public struct Nutrient: Sendable {
public var per100g: Double? { values["\(name)_100g"] } public var per100g: Double? { values["\(name)_100g"] }
public var perServing: Double? { values["\(name)_serving"] } public var perServing: Double? { values["\(name)_serving"] }
public var value: Double? { values["\(name)_value"] ?? values[name] } public var value: Double? { values["\(name)_value"] ?? values[name] }
public var unit: String? { units["\(name)_unit"] } public var unit: String? { units["\(name)_unit"] ?? _unit?.rawValue }
// Computed / Legacy // Computed / Legacy
public var valueComputed: Double? { values["\(name)_value"] } public var valueComputed: Double? { values["\(name)_value"] }
@@ -159,6 +159,147 @@ public struct Nutrient: Sendable {
else { return nil } else { return nil }
return UnitValue(value: rawValue, unit: unitEnum) 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 // MARK: - Helpers

View File

@@ -23,4 +23,23 @@ public struct NutriscoreData: Sendable, Codable {
case energy 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)
}
} }

View File

@@ -6,6 +6,16 @@ public struct Product: Codable, Sendable, Identifiable {
public let productName: String? public let productName: String?
public let genericName: String? public let genericName: String?
public let brands: 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 brandsTags: [String]?
public let quantity: String? public let quantity: String?
@@ -147,7 +157,7 @@ public struct Product: Codable, Sendable, Identifiable {
String.self, forKey: .productName) String.self, forKey: .productName)
genericName = try container.decodeIfPresent( genericName = try container.decodeIfPresent(
String.self, forKey: .genericName) String.self, forKey: .genericName)
brands = try container.decodeIfPresent(String.self, forKey: .brands) brands = try container.decodeStringOrArray(forKey: .brands)
brandsTags = try container.decodeIfPresent( brandsTags = try container.decodeIfPresent(
[String].self, forKey: .brandsTags) [String].self, forKey: .brandsTags)
quantity = try container.decodeIfPresent(String.self, forKey: .quantity) quantity = try container.decodeIfPresent(String.self, forKey: .quantity)

View File

@@ -2,10 +2,10 @@ import Foundation
public enum SearchParameter: Sendable, Hashable { public enum SearchParameter: Sendable, Hashable {
case query(String) case query(String)
case tag(tag: SearchTagType, value: String) // case tag(tag: SearchTagType, value: String)
case page(Int) case page(Int)
case pageSize(Int) case pageSize(Int)
case sort(SearchSort) // case sort(SearchSort)
} }
public enum SearchTagType: String, Sendable { public enum SearchTagType: String, Sendable {

File diff suppressed because it is too large Load Diff

View File

@@ -18,11 +18,13 @@ final class OpenFoodFactsTests: XCTestCase {
func testProductFetch() async throws { func testProductFetch() async throws {
// Fetch specific fields only // Fetch specific fields only
let product = try await client.product( let response = try await client.product(
barcode: "3017620422003", // Nutella barcode: "3017620422003", // Nutella
fields: [.code, .productName, .nutriscoreGrade, .nutriments] fields: [.code, .productName, .nutriscoreGrade, .nutriments]
) )
let product = response.product
XCTAssertEqual(product?.code, "3017620422003") XCTAssertEqual(product?.code, "3017620422003")
XCTAssertNotNil(product?.productName) XCTAssertNotNil(product?.productName)
XCTAssertNotNil(product?.nutriscoreGrade) XCTAssertNotNil(product?.nutriscoreGrade)
@@ -31,13 +33,20 @@ final class OpenFoodFactsTests: XCTestCase {
} }
func testSearch() async throws { func testSearch() async throws {
let results = try await client.search( let response = try await client.search(
.query("chocolate"), .query("Peanut butter"),
.tag(tag: .brands, value: "milka"), // .tag(tag: .brands, value: "milka"),
.pageSize(5), .pageSize(5),
.sort(.popularity), // .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) let jsonResults = try JSONEncoder().encode(results)
try jsonResults.write(to: .init(filePath: "./jsonResults.json")) try jsonResults.write(to: .init(filePath: "./jsonResults.json"))