diff --git a/.gitignore b/.gitignore index 0023a53..ec833f3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +.env diff --git a/Package.swift b/Package.swift index 8e7b70b..4c14ff5 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,9 @@ import PackageDescription let package = Package( name: "USDA_FDC", + platforms: [ + .macOS(.v14), + ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( diff --git a/Sources/USDA_FDC/USDA_FDC.swift b/Sources/USDA_FDC/USDA_FDC.swift index ff50eff..146e13b 100644 --- a/Sources/USDA_FDC/USDA_FDC.swift +++ b/Sources/USDA_FDC/USDA_FDC.swift @@ -1,6 +1,129 @@ // The Swift Programming Language // https://docs.swift.org/swift-book -final class USDA_FDC_Client { - +import Foundation + +public final class USDA_FDC_Client { + var version = 1 + var baseURL: URL { + URL(string: "https://api.nal.usda.gov/fdc/v\(version)")! + } + + public init() {} + + public var apiKey: String = "" + + public func getFood(_ foodCriteria: FoodCriteria) async throws -> AbridgedFoodItem { + var endpoint = baseURL.appendingPathComponent("/food/\(foodCriteria.fdcId)") + endpoint.append(queryItems: [.init(name: "api_key", value: apiKey)]) + // if criteria != nil { + // for (key, value) in Mirror(reflecting: criteria!).children { + // if value as Any? != nil { + // if let l = value as? [String] { + // endpoint.append(queryItems: [.init(name: key!, value: String(describing: l.joined(separator: ",")))]) + // } + // endpoint.append(queryItems: [.init(name: key!, value: String(describing: value))]) + // } + // } + // } + print(endpoint) + var request = URLRequest(url: endpoint) + request.setValue("application/json", forHTTPHeaderField: "accept") + let (data, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { + throw FDCError.invalidResponse + } + do { + return try JSONDecoder().decode(AbridgedFoodItem.self, from: data) + } catch { + throw error + } + } + + // TODO: + public func getFoods(_ foodsCriteria: FoodsCriteria) async throws -> [AbridgedFoodItem] { + var endpoint = baseURL.appendingPathComponent("/food") + endpoint.append(queryItems: [.init(name: "api_key", value: apiKey)]) + // if criteria != nil { + // for (key, value) in Mirror(reflecting: criteria!).children { + // if value as Any? != nil { + // if let l = value as? [String] { + // endpoint.append(queryItems: [.init(name: key!, value: String(describing: l.joined(separator: ",")))]) + // } + // endpoint.append(queryItems: [.init(name: key!, value: String(describing: value))]) + // } + // } + // } + print(endpoint) + var request = URLRequest(url: endpoint) + request.setValue("application/json", forHTTPHeaderField: "accept") + let (data, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { + throw FDCError.invalidResponse + } + do { + return try JSONDecoder().decode([AbridgedFoodItem].self, from: data) + } catch { + throw error + } + } + + public func getFoodsSearch(_ search: FoodSearchCriteria) async throws -> SearchResult { + var endpoint = baseURL.appendingPathComponent("/foods/search") + endpoint.append(queryItems: [.init(name: "api_key", value: apiKey)]) + endpoint.append(queryItems: [.init(name: "query", value: search.query)]) + // if criteria != nil { + // for (key, value) in Mirror(reflecting: criteria!).children { + // if value as Any? != nil { + // if let l = value as? [String] { + // endpoint.append(queryItems: [.init(name: key!, value: String(describing: l.joined(separator: ",")))]) + // } + // endpoint.append(queryItems: [.init(name: key!, value: String(describing: value))]) + // } + // } + // } + print(endpoint) + var request = URLRequest(url: endpoint) + request.setValue("application/json", forHTTPHeaderField: "accept") + let (data, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { + throw FDCError.invalidResponse + } + do { + return try JSONDecoder().decode(SearchResult.self, from: data) + } catch { + throw error + } + } + + public func getFoodList(_: FoodListCriteria? = nil) async throws -> [AbridgedFoodItem] { + var endpoint = baseURL.appendingPathComponent("/foods/list") + endpoint.append(queryItems: [.init(name: "api_key", value: apiKey)]) + // if criteria != nil { + // for (key, value) in Mirror(reflecting: criteria!).children { + // if value as Any? != nil { + // if let l = value as? [String] { + // endpoint.append(queryItems: [.init(name: key!, value: String(describing: l.joined(separator: ",")))]) + // } + // endpoint.append(queryItems: [.init(name: key!, value: String(describing: value))]) + // } + // } + // } + print(endpoint) + var request = URLRequest(url: endpoint) + request.setValue("application/json", forHTTPHeaderField: "accept") + let (data, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { + throw FDCError.invalidResponse + } + do { + return try JSONDecoder().decode([AbridgedFoodItem].self, from: data) + } catch { + throw error + } + } +} + +public enum FDCError: Error { + case invalidResponse } diff --git a/Sources/USDA_FDC/types/AbridgedFoodItem.swift b/Sources/USDA_FDC/types/AbridgedFoodItem.swift index f9e5fde..9cf81b8 100644 --- a/Sources/USDA_FDC/types/AbridgedFoodItem.swift +++ b/Sources/USDA_FDC/types/AbridgedFoodItem.swift @@ -1,12 +1,40 @@ -public struct AbridgedFoodItem: Codable { - public let dataType: String - public let description: String - public let fdcId: Int - public let foodNutrients: [AbridgedFoodNutrient] - public let publicationDate: String? - public let brandOwner: String? - public let gtinUpc: String? - public let ndbNumber: Int? - public let foodCode: String? -} +import Foundation +public class AbridgedFoodItem: Codable { + public var dataType: String = "" + public var description: String = "" + public var fdcId: Int = 0 + public var foodNutrients: [AbridgedFoodNutrient] = [] + public var publicationDate: String? + public var brandOwner: String? + public var gtinUpc: String? + public var ndbNumber: Int? + public var foodCode: String? + + private enum CodingKeys: String, CodingKey { + case dataType, description, fdcId, foodNutrients, publicationDate, brandOwner, gtinUpc, ndbNumber, foodCode + } + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + dataType = try container.decode(String.self, forKey: .dataType) + description = try container.decode(String.self, forKey: .description) + fdcId = try container.decode(Int.self, forKey: .fdcId) + foodNutrients = try container.decode([AbridgedFoodNutrient].self, forKey: .foodNutrients) + publicationDate = try container.decodeIfPresent(String.self, forKey: .publicationDate) + brandOwner = try container.decodeIfPresent(String.self, forKey: .brandOwner) + gtinUpc = try container.decodeIfPresent(String.self, forKey: .gtinUpc) + foodCode = try container.decodeIfPresent(String.self, forKey: .foodCode) + // Only manually handle the special case for ndbNumber + if let stringValue = try? container.decode(String.self, forKey: .ndbNumber), + let intValue = Int(stringValue) { + // If the value is a string, attempt to convert it to Int + ndbNumber = intValue + } else { + // Let other properties initialize themselves + ndbNumber = nil // Or any other default value or leave it as nil + } + } + +} diff --git a/Sources/USDA_FDC/types/AbridgedFoodNutrient.swift b/Sources/USDA_FDC/types/AbridgedFoodNutrient.swift index 8418954..db2e873 100644 --- a/Sources/USDA_FDC/types/AbridgedFoodNutrient.swift +++ b/Sources/USDA_FDC/types/AbridgedFoodNutrient.swift @@ -1,9 +1,9 @@ public struct AbridgedFoodNutrient: Codable { - public let number: Int - public let name: String - public let amount: Float - public let unitName: String - public let derivationCode: String - public let derivationDescription: String + public let number: String? + public let name: String? + public let amount: Float? + public let unitName: String? + public let derivationCode: String? + public let derivationDescription: String? } diff --git a/Sources/USDA_FDC/types/FoodCriteria.swift b/Sources/USDA_FDC/types/FoodCriteria.swift new file mode 100644 index 0000000..ad260bc --- /dev/null +++ b/Sources/USDA_FDC/types/FoodCriteria.swift @@ -0,0 +1,9 @@ +public struct FoodCriteria: Codable { + public var fdcId: Int // Max : 20 items + public var format: [Format]? + public var nutrients: [Int]? // Max : 25 items + + public enum Format: String, Codable { + case abridged, full + } +} diff --git a/Sources/USDA_FDC/types/FoodListCriteria.swift b/Sources/USDA_FDC/types/FoodListCriteria.swift index 4fdae64..63b5c47 100644 --- a/Sources/USDA_FDC/types/FoodListCriteria.swift +++ b/Sources/USDA_FDC/types/FoodListCriteria.swift @@ -1,7 +1,7 @@ public struct FoodListCriteria: Codable { - public let dataType: [DataType]? - public let pageSize: Int? // 1..=200, default 50 - public let pageNumber: Int? - public let sortBy: SortBy? - public let sortOrder: SortOrder? + public var dataType: [DataType]? + public var pageSize: Int? // 1..=200, default 50 + public var pageNumber: Int? + public var sortBy: SortBy? + public var sortOrder: SortOrder? } diff --git a/Sources/USDA_FDC/types/FoodSearchCriteria.swift b/Sources/USDA_FDC/types/FoodSearchCriteria.swift index 14190db..35dfa89 100644 --- a/Sources/USDA_FDC/types/FoodSearchCriteria.swift +++ b/Sources/USDA_FDC/types/FoodSearchCriteria.swift @@ -1,12 +1,12 @@ public struct FoodSearchCriteria: Codable { - public let query: String - public let dataType: DataType? - public let pageSize: Int? - public let pageNumber: Int? - public let sortBy: SortBy? - public let sortOrder: SortOrder? - public let brandOwner: String? - public let tradeChannel: [TradeChannel]? // Max items: 3 - public let startDate: String? // Format: YYYY-MM-DD - public let endDate: String? // Format: YYYY-MM-DD + public var query: String + public var dataType: DataType? + public var pageSize: Int? + public var pageNumber: Int? + public var sortBy: SortBy? + public var sortOrder: SortOrder? + public var brandOwner: String? + public var tradeChannel: [TradeChannel]? // Max items: 3 + public var startDate: String? // Format: YYYY-MM-DD + public var endDate: String? // Format: YYYY-MM-DD } diff --git a/Sources/USDA_FDC/types/SearchResultFood.swift b/Sources/USDA_FDC/types/SearchResultFood.swift index 4775913..7b9ac7d 100644 --- a/Sources/USDA_FDC/types/SearchResultFood.swift +++ b/Sources/USDA_FDC/types/SearchResultFood.swift @@ -1,17 +1,70 @@ public struct SearchResultFood: Codable { - public let fdcId: Int - public let dataType: String? - public let description: String - public let foodCode: String? - public let foodNutrients: [AbridgedFoodNutrient] - public let publicationDate: String? - public let scientificName: String? - public let brandOwner: String? - public let gtinUpc: String? - public let ingredients: String? - public let ndbNumber: Int? - public let additionalDescriptions: String? - public let allHighlightFields: String? - public let score: Float? + public var fdcId: Int = 0 + public var dataType: String? + public var description: String = "" + public var foodCode: String? + public var foodNutrients: [AbridgedFoodNutrient] = [] + public var publicationDate: String? + public var scientificName: String? + public var brandOwner: String? + public var gtinUpc: String? + public var ingredients: String? + public var ndbNumber: Int? + public var additionalDescriptions: String? + public var allHighlightFields: String? + public var score: Float? + + private enum CodingKeys: String, CodingKey { + case fdcId + case dataType + case description + case foodCode + case foodNutrients + case publicationDate + case scientificName + case brandOwner + case gtinUpc + case ingredients + case ndbNumber + case additionalDescriptions + case allHighlightFields + case score + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + fdcId = try container.decode(Int.self, forKey: .fdcId) + dataType = try container.decodeIfPresent(String.self, forKey: .dataType) + description = try container.decode(String.self, forKey: .description) + foodNutrients = try container.decodeIfPresent([AbridgedFoodNutrient].self, forKey: .foodNutrients) ?? [] + publicationDate = try container.decodeIfPresent(String.self, forKey: .publicationDate) + scientificName = try container.decodeIfPresent(String.self, forKey: .scientificName) + brandOwner = try container.decodeIfPresent(String.self, forKey: .brandOwner) + gtinUpc = try container.decodeIfPresent(String.self, forKey: .gtinUpc) + ingredients = try container.decodeIfPresent(String.self, forKey: .ingredients) + additionalDescriptions = try container.decodeIfPresent(String.self, forKey: .additionalDescriptions) + allHighlightFields = try container.decodeIfPresent(String.self, forKey: .allHighlightFields) + score = try container.decodeIfPresent(Float.self, forKey: .score) + if let intValue = try? container.decode(Int.self, forKey: .ndbNumber) { + // If the value can be directly decoded as Int, use it + ndbNumber = intValue + } else if let stringValue = try? container.decode(String.self, forKey: .ndbNumber), + let intValue = Int(stringValue) { + // If the value is a string, attempt to convert it to Int + ndbNumber = intValue + } else { + // Handle other cases or throw an error if needed + ndbNumber = nil + } + + if let stringValue = try? container.decode(String.self, forKey: .foodCode) { + foodCode = stringValue + } else if let intValue = try? container.decode(Int.self, forKey: .foodCode), + let stringValue = Optional(String(intValue)) { + foodCode = stringValue + } else { + foodCode = nil + } + } } diff --git a/Tests/USDATests/USDATests.swift b/Tests/USDATests/USDATests.swift index 715b114..003cb1e 100644 --- a/Tests/USDATests/USDATests.swift +++ b/Tests/USDATests/USDATests.swift @@ -1,12 +1,62 @@ import XCTest -@testable import USDA +@testable import USDA_FDC + +func loadEnvironment() -> [String: String] { + var dic: [String: String] = [:] + if FileManager.default.fileExists(atPath: ".env") { + do { + let contents = try String(contentsOfFile: ".env", encoding: .utf8) + let lines = contents.components(separatedBy: .newlines) + + for line in lines { + let components = line.components(separatedBy: "=") + if components.count == 2 { + let key = components[0].trimmingCharacters(in: .whitespacesAndNewlines) + let value = components[1].trimmingCharacters(in: .whitespacesAndNewlines) + dic[key] = value + } + } + } catch { + print("Error reading .env file: \(error)") + } + } + return dic +} final class USDATests: XCTestCase { - func testExample() throws { - // XCTest Documentation - // https://developer.apple.com/documentation/xctest - // Defining Test Cases and Test Methods - // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + var env: [String: String] { + loadEnvironment() + } + + func testFoodList() async throws { + let fdc = USDA_FDC_Client() + fdc.apiKey = env["API_KEY"] ?? "" + do { + let _ = try await fdc.getFoodList() + } catch { + throw error + } + } + + func testFoodItem() async throws { + let fdc = USDA_FDC_Client() + fdc.apiKey = env["API_KEY"] ?? "" + do { + let _ = try await fdc.getFood(.init(fdcId: 167782)) + } catch { + throw error + } + } + + func testFoodSearch() async throws { + let fdc = USDA_FDC_Client() + fdc.apiKey = env["API_KEY"] ?? "" + do { + let _ = try await fdc.getFoodsSearch(.init(query: "cheddar cheese")) + } catch { + throw error + } + } }