150 lines
4.1 KiB
Swift
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"
|
|
}
|
|
}
|