Api v2
This commit is contained in:
137
Sources/OpenFoodFactsSDK/OpenFoodFactsClient.swift
Normal file
137
Sources/OpenFoodFactsSDK/OpenFoodFactsClient.swift
Normal file
@@ -0,0 +1,137 @@
|
||||
import FoundationEssentials
|
||||
|
||||
#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]?
|
||||
}
|
||||
Reference in New Issue
Block a user