import Foundation #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]? }