Files
swift-openfoodfacts-sdk/Sources/OpenFoodFactsSDK/OpenFoodFactsClient.swift
2025-12-06 16:48:22 +01:00

138 lines
3.7 KiB
Swift

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