Files
swift-openfoodfacts-sdk/Sources/OpenFoodFactsSDK/OpenFoodFactsClient.swift
2025-12-06 17:53:51 +01:00

150 lines
4.1 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 -> ProductResponseEnvelope
{
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
}
/// Search for products using V2 API.
public func search(
_ parameters: SearchParameter..., fields: [ProductField]? = nil
) async throws -> SearchResponseEnvelope {
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
}
// 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
}
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"
}
}
public struct SearchResponseEnvelope: Sendable, Decodable {
public let count: Int?
public let page: Int?
public let pageSize: Int?
public let products: [Product]?
private enum CodingKeys: String, CodingKey {
case count, page, products
case pageSize = "page_size"
}
}